From 5f81a17eeb6bc2fa8ea02b69db64d2f980c9be69 Mon Sep 17 00:00:00 2001 From: Trevor Bonas <45324987+trevorbonas@users.noreply.github.com> Date: Thu, 31 Oct 2024 11:31:17 -0700 Subject: [PATCH 1/7] Improve Lambda function sample Changes: - 10 kinds of data generators have been added. - A custom data generator has been added so that users can define their own data generation. - For data generation: - Batch size is configurable. - Timestamp precision is configurable. - Start and end dates are configurable. - Reporting interval is configurable. - Number of entities that report per interval is configurable. - A step has been added that calculates metrics users can copy and paste into the AWS pricing calculator. - README added for the sample application. - `requirements.txt` and `environment.yml` added for fast Conda configuration. --- integrations/lambda/README.md | 113 ++ integrations/lambda/dashboard.json | 136 ++ integrations/lambda/demo.ipynb | 1459 +++++++++++++++++ integrations/lambda/environment.yml | 8 + .../lambda/img/lambda_ingestion_overview.png | Bin 0 -> 107501 bytes integrations/lambda/requirements.txt | 2 + 6 files changed, 1718 insertions(+) create mode 100644 integrations/lambda/README.md create mode 100644 integrations/lambda/dashboard.json create mode 100644 integrations/lambda/demo.ipynb create mode 100644 integrations/lambda/environment.yml create mode 100644 integrations/lambda/img/lambda_ingestion_overview.png create mode 100644 integrations/lambda/requirements.txt diff --git a/integrations/lambda/README.md b/integrations/lambda/README.md new file mode 100644 index 00000000..3675c4bc --- /dev/null +++ b/integrations/lambda/README.md @@ -0,0 +1,113 @@ +# Timestream for LiveAnalytics Lambda Sample Application + +## Overview + +This sample application demonstrates how [time series data](https://docs.aws.amazon.com/timestream/latest/developerguide/concepts.html) can be ingested into [Timestream for LiveAnalytics](https://docs.aws.amazon.com/timestream/latest/developerguide/what-is-timestream.html) using an [AWS Lambda function](https://aws.amazon.com/lambda/) and [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html). + +This sample application is comprised of three files: +- `demo.ipynb`: A [Jupyter notebook](https://jupyter.org/) that: + - Generates simulated time series data from a selection of predefined scenarios or a user-defined scenario. + - Deploys a Lambda function that receives the data and ingests the data into Timestream for LiveAnalytics. + - Sends the generated time series data to the Lambda's URL using SigV4 authentication. +- `dashboard.json`: A Grafana dashboard, configured to view all data ingested into the Timestream for LiveAnalytics database in the last hour. +- `requirements.txt`: A file containing required packages for the Jupyter notebook, for quick environment setup. + +The following diagram depicts the deployed Lambda function receiving generated data and ingesting the data to Timestream for LiveAnalytics that then is queried and displayed in [Amazon Managed Grafana](https://aws.amazon.com/grafana/). + + + +## Prerequisites + +1. [Configure AWS credentials for use with boto3](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html). +2. [Install Conda](https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html). +3. On Linux and macOS, run the following command to enable `conda`, replacing `` with your shell, whether that be `zsh`, `bash`, or `fish`: + ```shell + conda init + ``` +4. On Linux and macOS, restart your shell or `source` your shell configuration file (`.zshrc`, `.bashrc`, etc.). +5. Initialize a Conda environment named `sample_app_env` with the required packages by running: + ```shell + conda env create -f environment.yml + ``` +6. Activate the environment by running: + ```shell + conda activate sample_app_env + ``` +7. Install an application to run the `demo.ipynb` file. We recommend [Visual Studio Code](https://code.visualstudio.com/) with the [Jupyter extension](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter). + +## Using the Jupyter Notebook Locally + +These steps assume you have followed the prerequisites and are using Visual Studio Code with the Jupyter extension. + +To run the notebook locally: + +1. After configuring the Conda environment, open `demo.ipynb` in Visual Studio Code. +2. Click the search bar and from the dropdown menu select **Show and Run Commands**. +3. Search for "Python: Select Interpreter". +4. Select the Python "sample_app_env" environment from the dropdown menu. +5. Scroll through the steps of the Jupyter notebook. Adjust values in the "Define Timestream for LiveAnalytics Settings" and "Generate Data" sections to your desire. Default values have been set for all steps. +6. Once the kernel is running, press the **Run All** button. +7. When all cells in the notebook have finished executing, records will have been ingested to the `sample_app_table` table in the `sample_app_database` database in Timestream for LiveAnalytics. + +## Using the Jupyter Notebook in Amazon SageMaker + +### IAM Configuration + +When deployed in Amazon SageMaker, the instance hosting the Jupyter notebook must use an IAM role with the following permissions, replacing `` with your desired AWS region name and `` with your AWS account ID: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "lambda:GetFunction", + "lambda:GetFunctionUrlConfig", + "lambda:InvokeFunctionUrl", + "lambda:UpdateFunctionUrlConfig", + "lambda:CreateFunction", + "lambda:CreateFunctionUrlConfig", + "lambda:AddPermission" + ], + "Resource": "arn:aws:lambda:::function:TimestreamLambdaFunction" + }, + { + "Effect": "Allow", + "Action": [ + "iam:CreateRole", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:CreatePolicy", + "iam:CreatePolicyVersion", + "iam:UpdateAssumeRolePolicy", + "iam:GetPolicy", + "iam:AttachRolePolicy", + "iam:AttachGroupPolicy", + "iam:PutRolePolicy", + "iam:PutGroupPolicy", + "iam:PassRole" + ], + "Resource": "arn:aws:iam:::role/TimestreamLambdaRole" + } + ] +} +``` + +The Lambda function name `TimestreamLambdaFunction` and the role name `TimestreamLambdaRole` are the default names used in the Jupyter notebook. + +### SageMaker Configuration + +To host the Jupyter notebook in SageMaker and run the notebook: + +1. Go to the Amazon SageMaker console. +2. In the navigation panel, choose **Notebooks**. +3. Choose **Create notebook instance**. +4. For IAM role, select the role created in the above [**IAM Configuration**](#iam-configuration) section. +5. After configuring the rest of the notebook settings to your liking, choose **Create notebook instance**. +6. Once the notebook's status is **InService**, choose the notebook's **Open Jupyter** link. +7. Choose **Upload** and select `demo.ipynb`. +8. Choose the uploaded notebook. +9. In the **Kernel not found** popup window, select `conda_python3` form the dropdown menu and choose **Set Kernel**. +10. Once the kernel has started, choose **Kernel** > **Restart & Run All**. +11. When all cells in the notebook have finished executing, records will have been ingested to the `sample_app_table` table in the `sample_app_database` database in Timestream for LiveAnalytics. \ No newline at end of file diff --git a/integrations/lambda/dashboard.json b/integrations/lambda/dashboard.json new file mode 100644 index 00000000..4524ca7a --- /dev/null +++ b/integrations/lambda/dashboard.json @@ -0,0 +1,136 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 12, + "links": [], + "panels": [ + { + "datasource": { + "default": true, + "type": "grafana-timestream-datasource", + "uid": "timestream_sample_app_database" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 22, + "w": 20, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-timestream-datasource", + "uid": "timestream_sample_app_database" + }, + "format": 1, + "hide": false, + "measure": "", + "rawQuery": "SELECT * FROM \"sample_app_database\".\"sample_app_table\" ORDER BY time ASC", + "refId": "A" + } + ], + "title": "Panel Title", + "type": "timeseries" + } + ], + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-10m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Timestream Sample Dashboard", + "uid": "de0yzhhg7xo8wd", + "version": 2, + "weekStart": "" +} diff --git a/integrations/lambda/demo.ipynb b/integrations/lambda/demo.ipynb new file mode 100644 index 00000000..8e620a82 --- /dev/null +++ b/integrations/lambda/demo.ipynb @@ -0,0 +1,1459 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6efcc07e", + "metadata": {}, + "source": [ + "# Timestream Lambda Function Sample Application" + ] + }, + { + "cell_type": "markdown", + "id": "dfb5f3c3", + "metadata": {}, + "source": [ + "This notebook demonstrates generating data, according to a schema defined by the user; deploying an AWS Lambda function to process it; and visualizing the data using Grafana." + ] + }, + { + "cell_type": "markdown", + "id": "318886b7", + "metadata": {}, + "source": [ + "## Step 1: Generate Data" + ] + }, + { + "cell_type": "markdown", + "id": "26c9edae", + "metadata": {}, + "source": [ + "### Imports" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "59510d93", + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "from datetime import datetime, timedelta, UTC\n", + "import json\n", + "import math" + ] + }, + { + "cell_type": "markdown", + "id": "abd60adf", + "metadata": {}, + "source": [ + "### Data Generator Base Class Definition\n", + "\n", + "Defines the `DataGenerator` class, a base class for generating time series data. Data scenarios are defined by subclasses, in which subclasses set `_measure_templates` and `_dimension_templates` values, which define the possible values and restrictions for record measures and dimensions." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "de3bda06", + "metadata": {}, + "outputs": [], + "source": [ + "class DataGenerator:\n", + " # A list of dicts used to define the format of dimensions.\n", + " #\n", + " # Format:\n", + " # \"name\": str. Required. The name of the dimension.\n", + " # \"value_length\": int. Optional. The length of the dimension value, when it is randomly generated. If neither this nor\n", + " # \"options\" are provided, defaults to 10.\n", + " # \"random_options\": [str]. Optional. An array of strings to pick at random as options for the dimension value.\n", + " # Has precedence over \"value_length\". Values will be reused.\n", + " # \"unique_options\": [str]. Optional. An array of strings to pick serially for the dimension value. Each value in\n", + " # this array will be used once.\n", + " #\n", + " # Example:\n", + " # [\n", + " # {\n", + " # \"name\": \"device_id\",\n", + " # \"value_length\": 9\n", + " # },\n", + " # {\n", + " # \"name\": \"region\",\n", + " # \"random_options\": [\"us-east-1\", \"us-west-2\"]\n", + " # }\n", + " # ]\n", + " _dimension_templates: list\n", + "\n", + " # A list of dicts used to define the format of measures.\n", + " #\n", + " # Format:\n", + " # \"name\": str. Required. The name of the measure.\n", + " # \"type\": str. Optional. The Timestream for LiveAnalytics data type of the measure. Valid options are \"DOUBLE\",\n", + " # \"BIGINT\", \"BOOLEAN\", and \"VARCHAR\". Defaults to \"DOUBLE\".\n", + " # \"max_variation\": Optional. The maximum amount a measure value can changed over time, positively or negatively.\n", + " # For example, with a value of 5.0, measure values will increment by a max of 5.0 and a min of -5.0. Defaults to 1.5.\n", + " # \"max\": Optional. The maximum measure value. Defaults to 100.0.\n", + " # \"min\": Optional. The minimum measure value. Defaults to 0.0.\n", + " # \"random_options\": Optional. A list of values the measure value can have. All elements of the list should be the same data type and\n", + " # match the data type specified by the \"type\" field. This field overrides \"max_variation\", \"max\", and \"min\".\n", + " # Values will be reused, in the same way as the dimension_templates \"unique_options\" field.\n", + " #\n", + " # Example:\n", + " # [\n", + " # {\n", + " # \"name\": \"temperature_celsius\",\n", + " # \"type\": \"DOUBLE\",\n", + " # \"max_variation\": 2.0,\n", + " # \"max\": 40.0,\n", + " # \"min\": 30.0\n", + " # },\n", + " # {\n", + " # \"name\": \"symptoms\",\n", + " # \"type\": \"VARCHAR\",\n", + " # \"random_options\": [\"none\", \"headache\", \"shortness of breath\", \"fatigue\", \"nausea\"]\n", + " # }\n", + " # ]\n", + " _measure_templates: list\n", + "\n", + " def __init__(self):\n", + " # All subclasses need to do is provide values for _measure_templates and dimension_templates\n", + " self._measure_templates = []\n", + " self._dimension_templates = []\n", + "\n", + " def generate(self, start_date: datetime, end_date: datetime, reporting_frequency: timedelta,\n", + " num_entities: int, precision=\"MILLISECONDS\", generate_unique_options_fallback=False) -> list:\n", + " \"\"\"\n", + " Generates time series data.\n", + "\n", + " :param start_date: The start date to use when generating records. This cannot be older in hours\n", + " than the memory retention period in hours value for the table.\n", + " :param end_date: The end date to use when generating records. The maximum end date\n", + " Timestream for LiveAnalytics allows is 15 minutes in the future.\n", + " :param reporting_frequency: The frequency that records are generated by all entities, for example,\n", + " every 2 seconds, every 5 hours, etc.\n", + " :param num_entities: The number of entities that will report for each timestamp, for example,\n", + " the number of servers or number of stocks.\n", + " :param precision: The precision to use for record timestamps. Valid options are \"MILLISECONDS\",\n", + " \"SECONDS\", and \"MICROSECONDS\".\n", + " :param generate_unique_options_fallback: Whether to generate random strings for dimension values\n", + " after all values in a dimension template's \"unique_options\" array have been used.\n", + " \"\"\"\n", + "\n", + " # Construct entities (e.g., servers, weather reporting stations, stocks, etc.)\n", + " entities = []\n", + " for _ in range(num_entities):\n", + " entity = {\"latest_measures\": {}}\n", + " for dimension_template in self._dimension_templates:\n", + " dimension_value_length = 20\n", + "\n", + " if \"value_length\" in dimension_template:\n", + " dimension_value_length = dimension_template[\"value_length\"]\n", + "\n", + " # Unique options that should not be reused. Stock symbols are\n", + " # an example of this.\n", + " if \"unique_options\" in dimension_template:\n", + " if len(dimension_template[\"unique_options\"]) > 0:\n", + " dimension_value = dimension_template[\"unique_options\"][0]\n", + " # Dimensions must be unique. Each time a choice is chosen, remove it from the list.\n", + " dimension_template[\"unique_options\"].pop(0)\n", + " elif generate_unique_options_fallback:\n", + " # Generate a fallback value, since we've run out of options and the user\n", + " # has specified that they want more unique values generated.\n", + " dimension_value = self._generate_random_string(dimension_value_length)\n", + " \n", + " # Options that are reused. Server regions are an example of this.\n", + " elif \"random_options\" in dimension_template:\n", + " dimension_value = random.choice(dimension_template[\"random_options\"])\n", + "\n", + " elif \"unique_options\" not in dimension_template and \"random_options\" not in dimension_template:\n", + " dimension_value = self._generate_random_string(dimension_value_length)\n", + "\n", + " entity[dimension_template[\"name\"]] = dimension_value\n", + " \n", + " entities.append(entity)\n", + "\n", + " records = []\n", + " current_date = start_date\n", + " while current_date <= end_date:\n", + " # Each entity has a record for a single timestamp\n", + " for entity in entities:\n", + " dimensions = []\n", + " for key in entity:\n", + " if key != \"latest_measures\":\n", + " dimension = {\n", + " \"Name\": key,\n", + " \"Value\": entity[key],\n", + " \"DimensionValueType\": \"VARCHAR\" # Not configurable\n", + " }\n", + " dimensions.append(dimension)\n", + "\n", + " measures = []\n", + "\n", + " for measure_template in self._measure_templates:\n", + " if \"name\" not in measure_template:\n", + " raise Exception(f\"Measure template was missing name: {measure_template}\")\n", + " measure_name = measure_template[\"name\"]\n", + "\n", + " # Optional template fields\n", + " measure_value_type = \"DOUBLE\"\n", + " if \"type\" in measure_template:\n", + " measure_value_type = str(measure_template[\"type\"]).strip().upper()\n", + " max_variation = 1.5\n", + " if \"max_variation\" in measure_template:\n", + " max_variation = measure_template[\"max_variation\"]\n", + " max_value = 100.0\n", + " if \"max\" in measure_template:\n", + " max_value = measure_template[\"max\"]\n", + " min_value = 0.0\n", + " if \"min\" in measure_template:\n", + " min_value = measure_template[\"min\"]\n", + " varchar_length = 10\n", + " if \"varchar_length\" in measure_template:\n", + " varchar_length = measure_template[\"varchar_length\"]\n", + "\n", + " measure = {\n", + " \"MeasureName\": measure_name,\n", + " \"MeasureValueType\": measure_value_type\n", + " }\n", + "\n", + " measure_value = None\n", + " if \"random_options\" in measure_template:\n", + " measure_value = random.choice(measure_template[\"random_options\"])\n", + " else:\n", + " if current_date == start_date:\n", + " if measure_value_type == \"DOUBLE\":\n", + " measure_value = random.uniform(min_value, max_value)\n", + " elif measure_value_type == \"VARCHAR\":\n", + " measure_value = self._generate_random_string(varchar_length)\n", + " elif measure_value_type == \"BIGINT\":\n", + " measure_value = int(random.uniform(min_value, max_value))\n", + " elif measure_value_type == \"BOOLEAN\":\n", + " measure_value = random.choice(True, False)\n", + " else:\n", + " raise Exception(\"Measure value type not recognized\")\n", + " else:\n", + " if measure_value_type == \"DOUBLE\":\n", + " measure_value = max(min_value, min(entity[\"latest_measures\"][measure_name] + random.uniform(-max_variation, max_variation), max_value))\n", + " elif measure_value_type == \"VARCHAR\":\n", + " measure_value = self._generate_random_string(varchar_length)\n", + " elif measure_value_type == \"BIGINT\":\n", + " measure_value = int(max(min_value, min(entity[\"latest_measures\"][measure_name] + int(random.uniform(-max_variation, max_variation)), max_value)))\n", + " elif measure_value_type == \"BOOLEAN\":\n", + " measure_value = random.choice(True, False)\n", + " else:\n", + " raise Exception(\"Measure value type not recognized\")\n", + " \n", + " # Store the actual value in the entity for future iteration\n", + " entity[\"latest_measures\"][measure_name] = measure_value\n", + "\n", + " # Timestream requires that all data be inserted as a string\n", + " measure[\"MeasureValue\"] = str(measure_value)\n", + "\n", + " measures.append(measure)\n", + " \n", + " precision = precision.strip().upper()\n", + " if precision == \"SECONDS\":\n", + " timestamp = str(int(current_date.timestamp()))\n", + " if precision == \"MICROSECONDS\":\n", + " timestamp = str(int(current_date.timestamp() * 1_000_000))\n", + " else:\n", + " # Default to millisecond precision\n", + " timestamp = str(int(current_date.timestamp() * 1_000))\n", + "\n", + " record = {\n", + " \"Dimensions\": dimensions,\n", + " \"Time\": timestamp,\n", + " \"Measures\": measures\n", + " }\n", + " records.append(record)\n", + " current_date += reporting_frequency\n", + " return records\n", + " \n", + " def _generate_random_string(self, length: int):\n", + " \"\"\"\n", + " Generates a random alphanumeric string.\n", + "\n", + " :param length: The length of the string to generate.\n", + " \"\"\"\n", + "\n", + " letters = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\"\n", + " return ''.join(random.choice(letters) for _ in range(length))\n", + " " + ] + }, + { + "cell_type": "markdown", + "id": "0241c11e", + "metadata": {}, + "source": [ + "### Data Generator Subclass Definitions\n", + "\n", + "Defines subclasses of DataGenerator that define `_measure_templates` and `_dimension_templates`.\n", + "\n", + "The following subclasses are defined:\n", + "- `DevOpsDataGenerator`: Generates generic DevOps time series data for servers.\n", + "- `IoTDateGenerator`: Generates generic IoT time series data for devices.\n", + "- `StockMarketGenerator`: Generates time series data simulating stock market prices.\n", + "- `WeatherDataGenerator`: Generates time series data simulating weather reporting for different US cities.\n", + "- `GamingDataGenerator`: Generates time series data simulating player activity in a competitive online video game.\n", + "- `AirQualityDataGenerator`: Generates time series data simulating air quality in different cities around the world.\n", + "- `PatientDataGenerator`: Generates time series data simulating the status of healthcare patients.\n", + "- `EnergyDataGenerator`: Generates time series data simulating building energy usage.\n", + "- `FlightDataGenerator`: Generates time series data simulating different airline flights and the status of in-flight planes.\n", + "- `ExchangeRateDataGenerator`: Generates time series data simulating the fluctuating exchange rates of different currency pairs.\n", + "- `CustomDataGenerator`: Allows users to define their own `_measure_templates` and `_dimension_templates` to generate data of their choosing." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "4d3deb01", + "metadata": {}, + "outputs": [], + "source": [ + "class DevOpsDataGenerator(DataGenerator):\n", + " def __init__(self):\n", + " self._measure_templates = [\n", + " {\n", + " \"name\": \"cpu_usage\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 1.2,\n", + " \"max\": 100.0,\n", + " \"min\": 0.0\n", + " },\n", + " {\n", + " \"name\": \"mem_usage\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 2.3,\n", + " \"max\": 100.0,\n", + " \"min\": 0.0\n", + " },\n", + " {\n", + " \"name\": \"disk_usage\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 0.5,\n", + " \"max\": 100.0,\n", + " \"min\": 0.0\n", + " },\n", + " {\n", + " \"name\": \"network_in\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 100,\n", + " \"max\": 5000,\n", + " \"min\": 0\n", + " },\n", + " {\n", + " \"name\": \"network_out\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 20,\n", + " \"max\": 2000,\n", + " \"min\": 0\n", + " }\n", + " ]\n", + " self._dimension_templates = [\n", + " {\n", + " \"name\": \"server_id\",\n", + " \"value_length\": 14\n", + " },\n", + " {\n", + " \"name\": \"region\",\n", + " \"random_options\": [\"ca-central-1\", \"ca-west-1\", \"us-east-1\", \"us-east-2\", \"us-west-1\", \"us-west-2\", \"sa-east-1\", \"eu-central-1\", \"eu-west-1\", \"eu-west-2\", \"eu-south-1\", \"eu-west-3\"]\n", + " }\n", + " ]\n", + "\n", + "class IoTDataGenerator(DataGenerator):\n", + " def __init__(self):\n", + " self._measure_templates = [\n", + " {\n", + " \"name\": \"temperature_celsius\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 0.5,\n", + " \"max\": 60,\n", + " \"min\": -30\n", + " },\n", + " {\n", + " \"name\": \"relative_humidity\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 0.3,\n", + " \"max\": 100.0,\n", + " \"min\": 0.0\n", + " },\n", + " {\n", + " \"name\": \"battery_level\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 1,\n", + " \"max\": 100,\n", + " \"min\": 1 # All devices have enough battery to report\n", + " },\n", + " {\n", + " \"name\": \"velocity\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 4.2,\n", + " \"max\": 100.0,\n", + " \"min\": 0.0\n", + " }\n", + " ]\n", + " self._dimension_templates = [\n", + " {\n", + " \"name\": \"device_id\",\n", + " \"value_length\": 14\n", + " }\n", + " ]\n", + "\n", + "class StockMarketDataGenerator(DataGenerator):\n", + " def __init__(self):\n", + " self._measure_templates = [\n", + " {\n", + " \"name\": \"volume\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 30000,\n", + " \"max\": 100000000,\n", + " \"min\": 1\n", + " },\n", + " {\n", + " \"name\": \"market_cap\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 100,\n", + " \"max\": 100000000000,\n", + " \"min\": 1000000000,\n", + " },\n", + " {\n", + " \"name\": \"price_change\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 0.5,\n", + " \"max\": 1000.0,\n", + " \"min\": 1.0\n", + " },\n", + " {\n", + " \"name\": \"percentage_change\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 10.0,\n", + " \"max\": 100.0,\n", + " \"min\": 0.0\n", + " }\n", + " ]\n", + " self._dimension_templates = [\n", + " {\n", + " \"name\": \"stock_symbol\",\n", + " \"unique_options\": [\"AAPL\", \"TSLA\", \"DJIA\", \"SPOT\", \"NFLX\", \"MSFT\", \"MCD\", \"PG\", \"KO\", \"MMM\", \"IBM\", \"AMZN\", \"VZ\", \"JNJ\", \"WMT\"]\n", + " }\n", + " ]\n", + "\n", + " def generate(self, start_date, end_date, reporting_frequency, num_entities, precision=\"MILLISECONDS\", generate_unique_dimension_fallback=False):\n", + " num_stock_symbols = len(self._dimension_templates[0][\"unique_options\"])\n", + " if num_entities > num_stock_symbols and not generate_unique_dimension_fallback:\n", + " raise Exception(f\"num_entities ({num_entities}) was greater than the number of stock symbols ({num_stock_symbols})\")\n", + " return super().generate(start_date, end_date, reporting_frequency, num_entities, generate_unique_dimension_fallback)\n", + "\n", + "class WeatherDataGenerator(DataGenerator):\n", + " def __init__(self):\n", + " self._measure_templates = [\n", + " {\n", + " \"name\": \"temperature_celsius\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 1.5,\n", + " \"max\": 65.0,\n", + " \"min\": -30\n", + " },\n", + " {\n", + " \"name\": \"relative_humidity\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 0.5,\n", + " \"max\": 100.0,\n", + " \"min\": 0.0\n", + " },\n", + " {\n", + " \"name\": \"wind_speed_kph\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 5.5,\n", + " \"max\": 407.164,\n", + " \"min\": 0.0\n", + " },\n", + " {\n", + " \"name\": \"precipitation_mm\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 1.5,\n", + " \"max\": 60.0,\n", + " \"min\": 0.0\n", + " },\n", + " {\n", + " \"name\": \"cloud_percentage\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 10.0,\n", + " \"max\": 100.0,\n", + " \"min\": 0.0\n", + " },\n", + " {\n", + " \"name\": \"pressure_hpa\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 10,\n", + " \"max\": 1050,\n", + " \"min\": 950\n", + " },\n", + " {\n", + " \"name\": \"visibility_km\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 20,\n", + " \"max\": 200,\n", + " \"min\": 1\n", + " }\n", + " ]\n", + " self._dimension_templates = [\n", + " {\n", + " \"name\": \"location\", \n", + " \"unique_options\": [\"San Francisco, CA\", \"Chicago, IL\", \"New York, NY\", \"Miami, FL\", \"Dallas, TX\", \"Gary, IN\", \"Las Vegas, NV\", \"San Diego, CA\", \"Portland, OR\", \"Seattle, WA\", \"New Orleans, LA\", \"Fargo, ND\", \"Albuquerque, NM\"]\n", + " }\n", + " ]\n", + "\n", + " def generate(self, start_date, end_date, reporting_frequency, num_entities, precision=\"MILLISECONDS\", generate_unique_options_fallback=False):\n", + " num_locations = len(self._dimension_templates[0][\"unique_options\"])\n", + " if num_entities > num_locations and not generate_unique_options_fallback:\n", + " raise Exception(f\"num_entities ({num_entities}) was greater than the number of locations ({num_locations})\")\n", + " return super().generate(start_date, end_date, reporting_frequency, num_entities, generate_unique_options_fallback)\n", + "\n", + "class GamingDataGenerator(DataGenerator):\n", + " def __init__(self):\n", + " self._measure_templates = [\n", + " {\n", + " \"name\": \"X\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 2.1,\n", + " \"max\": 5000.0,\n", + " \"min\": -5000.0\n", + " },\n", + " {\n", + " \"name\": \"Y\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 2.1,\n", + " \"max\": 5000.0,\n", + " \"min\": -5000.0\n", + " },\n", + " {\n", + " \"name\": \"Z\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 2.1,\n", + " \"max\": 5000.0,\n", + " \"min\": -5000.0\n", + " },\n", + " {\n", + " \"name\": \"health\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 60,\n", + " \"max\": 100,\n", + " \"min\": 1\n", + " },\n", + " {\n", + " \"name\": \"ping\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 10,\n", + " \"max\": 250,\n", + " \"min\": 25\n", + " },\n", + " {\n", + " \"name\": \"current_equip_value\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 150,\n", + " \"max\": 1000000,\n", + " \"min\": 10\n", + " },\n", + " {\n", + " \"name\": \"flash_duration\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 0.3,\n", + " \"max\": 10.0,\n", + " \"min\": 0.0\n", + " },\n", + " {\n", + " \"name\": \"pitch\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 20.0,\n", + " \"max\": 90.0,\n", + " \"min\": -90.0\n", + " },\n", + " {\n", + " \"name\": \"yaw\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 0.8,\n", + " \"max\": 360.0,\n", + " \"min\": 0.0\n", + " }\n", + " ]\n", + " self._dimension_templates = [\n", + " {\n", + " \"name\": \"player_id\",\n", + " \"value_length\": 25\n", + " },\n", + " {\n", + " \"name\": \"player_name\",\n", + " \"value_length\": 15\n", + " },\n", + " {\n", + " \"name\": \"clan\",\n", + " \"random_options\": [\"mosdeff\", \"green_berets\", \"golden_ducks\", \"roberts\", \"club_z\"]\n", + " }\n", + " ]\n", + "\n", + "class AirQualityDataGenerator(DataGenerator):\n", + " def __init__(self):\n", + " self._measure_templates = [\n", + " {\n", + " \"name\": \"PM2.5\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 1.0,\n", + " \"max\": 150.0,\n", + " \"min\": 15.0\n", + " },\n", + " {\n", + " \"name\": \"PM10\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 1.0,\n", + " \"max\": 150.0,\n", + " \"min\": 15.0\n", + " },\n", + " {\n", + " \"name\": \"CO_ppm\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 5.0,\n", + " \"max\": 100.0,\n", + " \"min\": 0.1\n", + " },\n", + " {\n", + " \"name\": \"NO2_ppb\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 1.0,\n", + " \"max\": 300.0,\n", + " \"min\": 0.5\n", + " },\n", + " {\n", + " \"name\": \"O2_percentage\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 1.0,\n", + " \"max\": 25.0,\n", + " \"min\": 20.8\n", + " }\n", + " ]\n", + " self._dimension_templates = [\n", + " {\n", + " \"name\": \"city\",\n", + " \"unique_options\": [\"Los Angeles\", \"New York\", \"Vancouver\", \"Sydney\", \"Delhi\", \"Beijing\", \"London\", \"Miami\", \"Toronto\", \"Seattle\", \"Amsterdam\"]\n", + " }\n", + " ]\n", + "\n", + " def generate(self, start_date, end_date, reporting_frequency, num_entities, precision=\"MILLISECONDS\", generate_unique_options_fallback=False):\n", + " num_cities = len(self._dimension_templates[0][\"unique_options\"])\n", + " if num_entities > num_cities and not generate_unique_options_fallback:\n", + " raise Exception(f\"num_entities ({num_entities}) was greater than the number of cities ({num_cities})\")\n", + " return super().generate(start_date, end_date, reporting_frequency, num_entities, generate_unique_options_fallback)\n", + "\n", + "class PatientDataGenerator(DataGenerator):\n", + " def __init__(self):\n", + " self._measure_templates = [\n", + " {\n", + " \"name\": \"heart_rate_bpm\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 5.0,\n", + " \"max\": 100,\n", + " \"min\": 60\n", + " },\n", + " {\n", + " \"name\": \"oxygen_saturation_percentage\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 5.0,\n", + " \"max\": 100.0,\n", + " \"min\": 60.0\n", + " },\n", + " {\n", + " \"name\": \"temperature_celsius\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 2.0,\n", + " \"max\": 40.0,\n", + " \"min\": 30.0\n", + " }\n", + " ]\n", + " self._dimension_templates = [\n", + " {\n", + " \"name\": \"patient_id\",\n", + " \"value_length\": 14\n", + " }\n", + " ]\n", + "\n", + "class EnergyDataGenerator(DataGenerator):\n", + " def __init__(self):\n", + " self._measure_templates = [\n", + " {\n", + " \"name\": \"energy_usage_kWh\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 50,\n", + " \"max\": 300,\n", + " \"min\": 10\n", + " },\n", + " {\n", + " \"name\": \"occupancy\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 20,\n", + " \"max\": 100,\n", + " \"min\": 0\n", + " },\n", + " {\n", + " \"name\": \"temperature_celsius\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 0.5,\n", + " \"max\": 60,\n", + " \"min\": -30\n", + " },\n", + " {\n", + " \"name\": \"relative_humidity\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 0.3,\n", + " \"max\": 100.0,\n", + " \"min\": 0.0\n", + " }\n", + " ]\n", + " self._dimension_templates = [\n", + " {\n", + " \"name\": \"building_id\",\n", + " \"value_length\": 14\n", + " }\n", + " ]\n", + "\n", + "class FlightDataGenerator(DataGenerator):\n", + " def __init__(self):\n", + " self._measure_templates = [\n", + " {\n", + " \"name\": \"fuel_level_gallons\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 40,\n", + " \"max\": 8000,\n", + " \"min\": 1\n", + " },\n", + " {\n", + " \"name\": \"heading\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 10,\n", + " \"max\": 360,\n", + " \"min\": 0\n", + " },\n", + " {\n", + " \"name\": \"air_temperature_celsius\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 2.0,\n", + " \"max\": 40.0,\n", + " \"min\": -90.0\n", + " },\n", + " {\n", + " \"name\": \"lat\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 0.5,\n", + " \"max\": 90.0,\n", + " \"min\": -90.0\n", + " },\n", + " {\n", + " \"name\": \"lon\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 0.5,\n", + " \"max\": 180.0,\n", + " \"min\": -180.0\n", + " },\n", + " {\n", + " \"name\": \"speed_knots\",\n", + " \"type\": \"BIGINT\",\n", + " \"max_variation\": 10,\n", + " \"max\": 250,\n", + " \"min\": 200\n", + " }\n", + " ]\n", + " self._dimension_templates = [\n", + " {\n", + " \"name\": \"flight_id\",\n", + " \"unique_options\": [\"FL123\", \"FL456\", \"FL890\", \"FL333\", \"FL100\", \"FL650\", \"FL256\", \"FL430\", \"FL211\", \"FL874\"]\n", + " },\n", + " {\n", + " \"name\": \"aircraft_type\",\n", + " \"random_options\": [\"Boeing 737\", \"Airbus A320\"]\n", + " }\n", + " ]\n", + "\n", + " def generate(self, start_date, end_date, reporting_frequency, num_entities, generate_unique_options_fallback=False):\n", + " num_flight_ids = len(self._dimension_templates[0][\"unique_options\"])\n", + " if num_entities > num_flight_ids and not generate_unique_options_fallback:\n", + " raise Exception(f\"num_entities ({num_entities}) was greater than the number of flight IDs ({num_flight_ids})\")\n", + " return super().generate(start_date, end_date, reporting_frequency, num_entities, generate_unique_options_fallback)\n", + "\n", + "class ExchangeRateDataGenerator(DataGenerator):\n", + " def __init__(self):\n", + " self._measure_templates = [\n", + " {\n", + " \"name\": \"exchange_rate\",\n", + " \"type\": \"DOUBLE\",\n", + " \"max_variation\": 1.0,\n", + " \"max\": 90.0,\n", + " \"min\": 0.62\n", + " }\n", + " ]\n", + " self._dimension_templates = [\n", + " {\n", + " \"name\": \"currency_pair\",\n", + " \"unique_options\": [\"USD/EUR\", \"USD/CAD\", \"USD/GPP\", \"USD/CNY\" \"GBP/CAD\", \"GBP/JPY\", \"GBP/INR\", \"CHF/INR\", \"XAU/CNY\", \"UYU/CAD\", \"USD/XAU\"]\n", + " }\n", + " ]\n", + "\n", + " def generate(self, start_date, end_date, reporting_frequency, num_entities, precision=\"MILLISECONDS\", generate_unique_options_fallback=False):\n", + " num_currency_pairs = len(self._dimension_templates[0][\"unique_options\"])\n", + " if num_entities > num_currency_pairs and not generate_unique_options_fallback:\n", + " raise Exception(f\"num_entities ({num_entities}) was greater than the number of currency pairs ({num_currency_pairs})\")\n", + " return super().generate(start_date, end_date, reporting_frequency, num_entities, generate_unique_options_fallback)\n", + "\n", + "\n", + "class CustomDataGenerator(DataGenerator):\n", + " def __init__(self, measure_templates: list, dimension_templates: list):\n", + " self._measure_templates = measure_templates\n", + " self._dimension_templates = dimension_templates" + ] + }, + { + "cell_type": "markdown", + "id": "17b8430c", + "metadata": {}, + "source": [ + "### Define Timestream for LiveAnalytics Settings\n", + "\n", + "These variables are used later, by the Lambda function, when creating tables and ingesting records. These variables are defined here as they are used to confirm the desired time range for generated records is acceptable and calculate metrics to be used for cost estimation." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "fc5279cf", + "metadata": {}, + "outputs": [], + "source": [ + "DATABASE_NAME = \"sample_app_database\"\n", + "TABLE_NAME = \"sample_app_table\"\n", + "\n", + "# To be used later, by the Lambda function, to create the Timestream for LiveAnalytics table.\n", + "# If you created your table manually, update with the actual values you configured for your table.\n", + "# Default values when creating a new table in the AWS console.\n", + "MEM_STORE_RETENTION_PERIOD_IN_HOURS = 12\n", + "MAG_STORE_RETENTION_PERIOD_IN_DAYS = 3653 # 10 years\n", + "\n", + "# The number of records to ingest to Timestream for LiveAnalytics at a time.\n", + "# Timestream for LiveAnalytics accepts a maximum of 100 records at a time.\n", + "BATCH_SIZE = 100\n", + "# BATCH_SIZE = 1\n", + "\n", + "# The precision of the timestamp for each generated record. Valid options are \"MILLISECONDS\", \"SECONDS\", and \"MICROSECONDS\".\n", + "# This will also be included as a query parameter in the request sent to the Lambda function.\n", + "PRECISION = \"MICROSECONDS\"" + ] + }, + { + "cell_type": "markdown", + "id": "7932f505", + "metadata": {}, + "source": [ + "### Generate Data\n", + "\n", + "The data generator classes use the `generate` function to generate data. The arguments to `generate` are as follows:\n", + "- `start_date`: The start date to use when generating records. This cannot be older in hours than the memory retention period in hours value for the table.\n", + "- `end_date`: The end date to use when generating records. The maximum end date Timestream for LiveAnalytics allows is 15 minutes in the future.\n", + "- `reporting_frequency`: The frequency that records are generated by all entities, for example, every 2 seconds, every 5 hours, etc.\n", + "- `param num_entities` The number of entities that will report for each timestamp, for example, the number of servers or number of stocks.\n", + "- `param precision`: The precision to use for record timestamps. Valid options are `\"MILLISECONDS\"`, `\"SECONDS\"`, and `\"MICROSECONDS\"`.\n", + "- `generate_unique_options_fallback`: Whether to generate random strings for dimension values after all values in a dimension template's \"unique_options\" array have been used." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ea01eea0", + "metadata": {}, + "outputs": [], + "source": [ + "# All timestamps default to UTC\n", + "end_date = datetime.now(UTC)\n", + "start_date = end_date - timedelta(hours=8)\n", + "reporting_frequency = timedelta(minutes=30)\n", + "num_entities = 10\n", + "\n", + "if end_date > datetime.now(UTC) + timedelta(minutes=15):\n", + " raise Exception(\"The end date for data generation cannot be more than 15 minutes in the future\")\n", + "if start_date < datetime.now(UTC) - timedelta(hours=MEM_STORE_RETENTION_PERIOD_IN_HOURS):\n", + " raise Exception(f\"The start date for data generation cannot be more than {MEM_STORE_RETENTION_PERIOD_IN_HOURS} hours in the past\")\n", + "if start_date >= end_date:\n", + " raise Exception(\"The start date and end date for data generation are the same\")\n", + "if (end_date - start_date) < reporting_frequency:\n", + " raise Exception(\"The reporting frequency is too small for the data generation time range\")\n", + "\n", + "# By default, generate DevOps data, which simulates reporting from servers\n", + "sample_data = DevOpsDataGenerator().generate(start_date, end_date, reporting_frequency, num_entities, precision=PRECISION)\n", + "\n", + "# Custom data\n", + "#measure_templates = [\n", + "# {\n", + "# \"name\": \"exchange_rate\",\n", + "# \"type\": \"DOUBLE\",\n", + "# \"max_variation\": 1.0,\n", + "# \"max\": 90.0,\n", + "# \"min\": 0.62\n", + "# }\n", + "# ]\n", + "#dimension_templates = [\n", + "# {\n", + "# \"name\": \"currency_pair\",\n", + "# \"value_length\": 4,\n", + "# \"unique_options\": [\"USD/EUR\", \"USD/CAD\", \"USD/GPP\", \"USD/CNY\" \"GBP/CAD\", \"GBP/JPY\", \"GBP/INR\", \"CHF/INR\", \"XAU/CNY\", \"UYU/CAD\"]\n", + "# }\n", + "# ]\n", + "\n", + "#sample_data = CustomDataGenerator(measure_templates=measure_templates, dimension_templates=dimension_templates).generate(start_date, end_date, reporting_frequency, num_entities)\n", + "\n", + "# Print generated data\n", + "print(json.dumps(sample_data, indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "af707378", + "metadata": {}, + "source": [ + "## Step 2: Calculate Cost Metrics\n", + "\n", + "The following cell provides metrics that can be input into the [AWS pricing calculator](https://calculator.aws/#/) to give an estimate of costs for ingesting data to Timestream for LiveAnalytics.\n", + "\n", + "The metrics are:\n", + "\n", + "- Memory store writes.\n", + " - This is calculated by determining the number of records that would be ingested within the `MEM_STORE_RETENTION_PERIOD_IN_HOURS` time frame.\n", + "\n", + "Magnetic store writes are not calculated since Timestream for LiveAnalytics does not allow ingesting records with timestamps outside of the `MEM_STORE_RETENTION_PERIOD_IN_HOURS` time frame. In order for records to be stored in magnetic storage, they need to first be stored in memory then be moved to magnetic storage once enough time has passed.\n", + "\n", + "These cost metrics may not be accurate, as there may be a delay between generating the data and ingesting it, causing some amounts of records to be put into magnetic store or rejected due to being too old." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb5d1e1f", + "metadata": {}, + "outputs": [], + "source": [ + "# The AWS pricing calculator only allows per second, per minute, per hour, per day, and per month.\n", + "\n", + "num_records = len(sample_data)\n", + "time_diff = end_date - start_date\n", + "\n", + "if time_diff <= timedelta(seconds=1):\n", + " unit = \"second\"\n", + " scaled_count = num_records\n", + "elif time_diff <= timedelta(minutes=1):\n", + " unit = \"minute\"\n", + " scaled_count = num_records\n", + "elif time_diff <= timedelta(hours=1):\n", + " unit = \"hour\"\n", + " scaled_count = num_records\n", + "elif time_diff <= timedelta(days=1):\n", + " unit = \"day\"\n", + " scaled_count = num_records\n", + "# 30 days in a month is standard for billing\n", + "elif time_diff <= timedelta(days=30):\n", + " unit = \"month\"\n", + " scaled_count = num_records\n", + "else:\n", + " unit = \"month\"\n", + " print(time_diff.days)\n", + " # Round up, since the AWS pricing calculator does not accept decimal numbers\n", + " scaled_count = math.ceil(num_records / (time_diff.days / 30))\n", + "\n", + "print(f\"Total records: {num_records}\")\n", + "print(f\"Memory store writes: {scaled_count} per {unit}\")" + ] + }, + { + "cell_type": "markdown", + "id": "a37a6cd5", + "metadata": {}, + "source": [ + "## Step 3: Deploy AWS Lambda Function" + ] + }, + { + "cell_type": "markdown", + "id": "3368d6f6", + "metadata": {}, + "source": [ + "The following code will construct and deploy a Lambda function that ingests data to Timestream." + ] + }, + { + "cell_type": "markdown", + "id": "86546694", + "metadata": {}, + "source": [ + "### Imports" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "3c08c93e", + "metadata": {}, + "outputs": [], + "source": [ + "import boto3\n", + "import zipfile\n", + "import os\n", + "import json" + ] + }, + { + "cell_type": "markdown", + "id": "92213f98", + "metadata": {}, + "source": [ + "### Generate and Deploy Lambda Function" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f87489d2", + "metadata": {}, + "outputs": [], + "source": [ + "REGION_NAME='us-west-2'\n", + "\n", + "# Initialize clients\n", + "\n", + "iam_client = boto3.client('iam', region_name=REGION_NAME)\n", + "lambda_client = boto3.client('lambda', region_name=REGION_NAME)\n", + "sts_client = boto3.client('sts', region_name=REGION_NAME)\n", + "account_id = sts_client.get_caller_identity()['Account']\n", + "\n", + "lambda_name = \"TimestreamSampleLambda\"\n", + "\n", + "# Create IAM Role for Lambda\n", + "role_name = \"TimestreamLambdaRole\"\n", + "assume_role_policy = {\n", + " \"Version\": \"2012-10-17\",\n", + " \"Statement\": [\n", + " {\n", + " \"Effect\": \"Allow\",\n", + " \"Principal\": {\"Service\": \"lambda.amazonaws.com\"},\n", + " \"Action\": \"sts:AssumeRole\"\n", + " }\n", + " ]\n", + "}\n", + "\n", + "role_arn = \"\"\n", + "\n", + "try:\n", + " create_role_response = iam_client.create_role(\n", + " RoleName=role_name,\n", + " AssumeRolePolicyDocument=json.dumps(assume_role_policy),\n", + " Description=\"Role for Lambda to write to Timestream\"\n", + " )\n", + " print(f\"Created IAM Role: {role_name}\")\n", + " role_arn = create_role_response['Role']['Arn']\n", + "except iam_client.exceptions.EntityAlreadyExistsException:\n", + " print(f\"IAM Role {role_name} already exists\")\n", + " try:\n", + " role_arn = iam_client.get_role(RoleName=role_name)['Role']['Arn']\n", + " except iam_client.exceptions.NoSuchEntityException:\n", + " print(\"IAM Role could not be found\")\n", + " raise\n", + "\n", + "# CloudWatch logs policy to be added to the role\n", + "cloudwatch_logs_policy = {\n", + " \"Version\": \"2012-10-17\",\n", + " \"Statement\": [\n", + " {\n", + " \"Effect\": \"Allow\",\n", + " \"Action\": [\n", + " \"logs:CreateLogGroup\",\n", + " \"logs:CreateLogStream\",\n", + " \"logs:PutLogEvents\"\n", + " ],\n", + " \"Resource\": f\"arn:aws:logs:{REGION_NAME}:{account_id}:log-group:/aws/lambda/{lambda_name}*\"\n", + " }\n", + " ]\n", + "}\n", + "\n", + "# Add the CloudWatch logs policy to the role\n", + "try:\n", + " iam_client.put_role_policy(\n", + " RoleName=role_name,\n", + " PolicyName='CloudWatchLogsPolicy',\n", + " PolicyDocument=json.dumps(cloudwatch_logs_policy)\n", + " )\n", + " print(f\"Attached CloudWatch logs policy to role: {role_name}\")\n", + "except Exception as e:\n", + " print(f\"Error attaching CloudWatch logs policy: {e}\")\n", + "\n", + "# Attach Policy to the IAM Role\n", + "policy_arn = \"arn:aws:iam::aws:policy/AmazonTimestreamFullAccess\"\n", + "iam_client.attach_role_policy(\n", + " RoleName=role_name,\n", + " PolicyArn=policy_arn\n", + ")\n", + "\n", + "print(f\"Attached Timestream write policy to {role_name}\")\n", + "\n", + "# Create Lambda function code\n", + "lambda_function_code = '''\n", + "import json\n", + "import os\n", + "import boto3\n", + "from botocore.exceptions import ClientError\n", + "\n", + "REGION_NAME = os.environ['REGION_NAME']\n", + "DATABASE_NAME = os.environ['DATABASE_NAME']\n", + "TABLE_NAME = os.environ['TABLE_NAME']\n", + "BATCH_SIZE = int(os.environ['BATCH_SIZE'])\n", + "MEM_STORE_RETENTION_PERIOD_IN_HOURS = int(os.environ['MEM_STORE_RETENTION_PERIOD_IN_HOURS'])\n", + "MAG_STORE_RETENTION_PERIOD_IN_DAYS = int(os.environ['MAG_STORE_RETENTION_PERIOD_IN_DAYS'])\n", + "\n", + "# Initialize the Timestream client\n", + "timestream_client = boto3.client('timestream-write', REGION_NAME)\n", + "\n", + "# Define your table retention properties\n", + "RETENTION_PROPERTIES = {\n", + " 'MemoryStoreRetentionPeriodInHours': 24, # Adjust as needed\n", + " 'MagneticStoreRetentionPeriodInDays': 365 # Adjust as needed\n", + "}\n", + "\n", + "def create_timestream_database_and_table():\n", + " \"\"\"\n", + " Create Timestream database and table if they do not exist.\n", + " \"\"\"\n", + " try:\n", + " # Create database if it does not exist\n", + " timestream_client.create_database(DatabaseName=DATABASE_NAME)\n", + " print(f\"Database '{DATABASE_NAME}' created successfully.\")\n", + " except ClientError as e:\n", + " if e.response['Error']['Code'] == 'ConflictException':\n", + " print(f\"Database '{DATABASE_NAME}' already exists.\")\n", + " else:\n", + " raise e # Raise if it's a different error\n", + "\n", + " try:\n", + " # Create table if it does not exist\n", + " timestream_client.create_table(\n", + " DatabaseName=DATABASE_NAME,\n", + " TableName=TABLE_NAME,\n", + " RetentionProperties=RETENTION_PROPERTIES\n", + " )\n", + " print(f\"Table '{TABLE_NAME}' created successfully in database '{DATABASE_NAME}'.\")\n", + " except ClientError as e:\n", + " if e.response['Error']['Code'] == 'ConflictException':\n", + " print(f\"Table '{TABLE_NAME}' already exists in database '{DATABASE_NAME}'.\")\n", + " else:\n", + " raise e # Raise if it's a different error\n", + "\n", + "def lambda_handler(event, context):\n", + " \"\"\"\n", + " Lambda function to process the request and ingest records into Timestream.\n", + " The function accepts a list of records, handles MULTI measure types, \n", + " and sends data in batches to Timestream.\n", + " \"\"\"\n", + "\n", + " print(event)\n", + " query_params = event.get('queryStringParameters', {})\n", + " precision = query_params.get('precision', 'MILLISECONDS')\n", + "\n", + " # Create the database and table if they do not exist\n", + " create_timestream_database_and_table()\n", + "\n", + " try:\n", + " # Extract the records from the event\n", + " body = event.get('body', '{}')\n", + " parsed_body = json.loads(body)\n", + " records = parsed_body.get('records', [])\n", + " if not records:\n", + " return {\n", + " \"statusCode\": 400,\n", + " \"body\": json.dumps(\"No records found in the request.\")\n", + " }\n", + "\n", + " # Process records in batches\n", + " for i in range(0, len(records), BATCH_SIZE):\n", + " records_batch = records[i:i + 100]\n", + " # Prepare the records for Timestream\n", + " prepared_records = []\n", + "\n", + " for record in records_batch:\n", + " dimensions = record.get(\"Dimensions\", [])\n", + " time_value = record.get(\"Time\")\n", + " measures = record.get(\"Measures\", [])\n", + "\n", + " # Check if there are multiple measures, in which case we'll use MULTI\n", + " if len(measures) > 1:\n", + " measure_value_type = 'MULTI'\n", + " multi_value_measure = {\n", + " 'MeasureName': measures[0]['MeasureName'], # Example MeasureName; Timestream expects just one for MULTI\n", + " 'MeasureValues': [\n", + " {\n", + " 'Name': m['MeasureName'],\n", + " 'Value': m['MeasureValue'],\n", + " 'Type': m['MeasureValueType']\n", + " }\n", + " for m in measures\n", + " ]\n", + " }\n", + " prepared_record = {\n", + " 'Dimensions': dimensions,\n", + " 'Time': time_value,\n", + " 'TimeUnit': precision,\n", + " 'MeasureName': multi_value_measure['MeasureName'], # Set MULTI MeasureName\n", + " 'MeasureValueType': measure_value_type,\n", + " 'MeasureValues': multi_value_measure['MeasureValues']\n", + " }\n", + " else:\n", + " # Handle the case where there is only one measure\n", + " measure = measures[0]\n", + " prepared_record = {\n", + " 'Dimensions': dimensions,\n", + " 'Time': time_value,\n", + " 'TimeUnit': precision,\n", + " 'MeasureName': measure['MeasureName'],\n", + " 'MeasureValue': measure['MeasureValue'],\n", + " 'MeasureValueType': measure['MeasureValueType']\n", + " }\n", + "\n", + " prepared_records.append(prepared_record)\n", + "\n", + " # Write to Timestream using the `write_records` API\n", + " response = timestream_client.write_records(\n", + " DatabaseName=DATABASE_NAME,\n", + " TableName=TABLE_NAME,\n", + " Records=prepared_records\n", + " )\n", + " print(f\"Batch write successful for records {i} to {i + len(records_batch) - 1}: {response}\")\n", + "\n", + " return {\n", + " \"statusCode\": 200,\n", + " \"body\": json.dumps(f\"Successfully ingested {len(records)} records into Timestream.\")\n", + " }\n", + " \n", + " except ClientError as e:\n", + " print(f\"Failed to write to Timestream: {e}\")\n", + " return {\n", + " \"statusCode\": 500,\n", + " \"body\": json.dumps(f\"Error writing to Timestream: {str(e)}\")\n", + " }\n", + "'''\n", + "\n", + "# Save the Lambda function code to a file\n", + "lambda_function_file = \"lambda_function.py\"\n", + "with open(lambda_function_file, 'w') as f:\n", + " f.write(lambda_function_code)\n", + "\n", + "# Create a deployment package (zip file)\n", + "lambda_zip = \"lambda_function.zip\"\n", + "with zipfile.ZipFile(lambda_zip, 'w') as zipf:\n", + " zipf.write(lambda_function_file)\n", + "\n", + "try:\n", + " with open(lambda_zip, 'rb') as f:\n", + " lambda_client.create_function(\n", + " FunctionName=lambda_name,\n", + " Runtime='python3.12',\n", + " Role=role_arn,\n", + " Handler='lambda_function.lambda_handler',\n", + " Architectures=['arm64'],\n", + " Code={'ZipFile': f.read()},\n", + " Environment={\n", + " 'Variables': {\n", + " 'REGION_NAME': REGION_NAME,\n", + " 'DATABASE_NAME': DATABASE_NAME,\n", + " 'TABLE_NAME': TABLE_NAME,\n", + " 'MEM_STORE_RETENTION_PERIOD_IN_HOURS': str(MEM_STORE_RETENTION_PERIOD_IN_HOURS),\n", + " 'MAG_STORE_RETENTION_PERIOD_IN_DAYS': str(MAG_STORE_RETENTION_PERIOD_IN_DAYS),\n", + " 'BATCH_SIZE': str(BATCH_SIZE)\n", + " }\n", + " },\n", + " Timeout=30,\n", + " MemorySize=128\n", + " )\n", + " print(f\"Lambda function {lambda_name} created successfully.\")\n", + "except lambda_client.exceptions.ResourceConflictException:\n", + " print(f\"Lambda function {lambda_name} already exists.\")\n", + "\n", + "# Clean up the files\n", + "os.remove(lambda_function_file)\n", + "os.remove(lambda_zip)\n", + "\n", + "# Add a resource policy to allow invocation via the function URL\n", + "\n", + "try:\n", + " lambda_client.add_permission(\n", + " FunctionName=lambda_name,\n", + " StatementId='FunctionURLAllowInvoke',\n", + " Action='lambda:InvokeFunctionUrl',\n", + " Principal=role_arn,\n", + " FunctionUrlAuthType='AWS_IAM'\n", + " )\n", + " print(f\"Added resource policy to allow function URL invocation for {lambda_name}.\")\n", + "except lambda_client.exceptions.ResourceConflictException:\n", + " print(f\"Resource policy for {lambda_name} already exists.\")\n", + "\n", + "# Create or get the Lambda Function URL\n", + "try:\n", + " response = lambda_client.create_function_url_config(\n", + " FunctionName=lambda_name,\n", + " AuthType='AWS_IAM'\n", + " )\n", + " function_url = response['FunctionUrl']\n", + " print(f\"Lambda Function URL: {function_url}\")\n", + "except lambda_client.exceptions.ResourceConflictException:\n", + " # If the URL configuration already exists, retrieve it\n", + " response = lambda_client.get_function_url_config(FunctionName=lambda_name)\n", + " function_url = response['FunctionUrl']\n", + " print(f\"Lambda Function URL (existing): {function_url}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cef5a36c", + "metadata": {}, + "source": [ + "## Step 3: Send Data to the Lambda Function" + ] + }, + { + "cell_type": "markdown", + "id": "1066096a", + "metadata": {}, + "source": [ + "The following code will send the generated sample data to the Lambda function's URL with SigV4 authenticated requests, ensuring requests do not exceed Lambda's limit of 6 MB." + ] + }, + { + "cell_type": "markdown", + "id": "63d4070a", + "metadata": {}, + "source": [ + "### Imports" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "960ad90c", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import requests\n", + "import boto3\n", + "from botocore.auth import SigV4Auth\n", + "from botocore.awsrequest import AWSRequest" + ] + }, + { + "cell_type": "markdown", + "id": "44bb87a9", + "metadata": {}, + "source": [ + "### Send Generated Data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e08f884b", + "metadata": {}, + "outputs": [], + "source": [ + "MAX_REQUEST_SIZE = 6 * 1024 * 1024 # 6 MB in bytes\n", + "\n", + "def send_data_to_lambda(data, precision=\"MILLISECONDS\"):\n", + " \"\"\"Sends generated data to the Lambda function in chunks.\"\"\"\n", + " region = REGION_NAME\n", + " method = \"POST\"\n", + "\n", + " session = boto3.Session(region_name=region)\n", + "\n", + " # Calculate the size of the entire data payload\n", + " data_payload = json.dumps({'records': data})\n", + " total_size = len(data_payload.encode('utf-8'))\n", + "\n", + " # Check if the total size exceeds the maximum request size\n", + " if total_size <= MAX_REQUEST_SIZE:\n", + " send_request(session, method, data_payload, precision)\n", + " else:\n", + " # Chunk the data if it's too large\n", + " chunk_size = MAX_REQUEST_SIZE - len(b'{\"records\":[]}') # Reserve space for the JSON structure\n", + " chunks = [data[i:i + chunk_size] for i in range(0, len(data), chunk_size)]\n", + " for chunk in chunks:\n", + " chunk_payload = json.dumps({'records': chunk})\n", + " send_request(session, method, chunk_payload, precision)\n", + "\n", + "def send_request(session, method, payload, precision=\"MILLISECONDS\"):\n", + " \"\"\"Sends the request to the Lambda function.\"\"\"\n", + " request = AWSRequest(\n", + " method=method,\n", + " url=function_url,\n", + " params={'precision': precision},\n", + " headers={'Content-Type': 'application/json'},\n", + " data=payload\n", + " )\n", + "\n", + " SigV4Auth(session.get_credentials(), 'lambda', REGION_NAME).add_auth(request)\n", + "\n", + " try:\n", + " response = requests.request(method, function_url, params={\"precision\": precision}, headers=dict(request.headers), data=payload, timeout=30)\n", + " response.raise_for_status()\n", + " print(f'Response Status: {response.status_code}')\n", + " print(f'Response Body: {response.content.decode(\"utf-8\")}')\n", + " except Exception as e:\n", + " print(f'Error: {e}')\n", + "\n", + "# Send sample data to the Lambda function\n", + "send_data_to_lambda(sample_data, PRECISION)\n" + ] + }, + { + "cell_type": "markdown", + "id": "dc25f96b", + "metadata": {}, + "source": [ + "## Step 4: Configure Grafana" + ] + }, + { + "cell_type": "markdown", + "id": "112cf6a6", + "metadata": {}, + "source": [ + "### Grafana Configuration Steps\n", + "1. Open Grafana.\n", + "2. Add a new data source with the name \"timestream_sample_app_database\".\n", + "3. Create a new dashboard using the provided `dashboard.json`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "test_env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/integrations/lambda/environment.yml b/integrations/lambda/environment.yml new file mode 100644 index 00000000..662a694d --- /dev/null +++ b/integrations/lambda/environment.yml @@ -0,0 +1,8 @@ +name: sample_app_env +channels: + - conda-forge +dependencies: + - python=3.12 + - pip + - pip: + - -r requirements.txt diff --git a/integrations/lambda/img/lambda_ingestion_overview.png b/integrations/lambda/img/lambda_ingestion_overview.png new file mode 100644 index 0000000000000000000000000000000000000000..96c275708ddf219d51715c17c1ebdb4600c66e78 GIT binary patch literal 107501 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

    >p7YFJzUr`Zn9d* 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 literal 0 HcmV?d00001 diff --git a/integrations/lambda/requirements.txt b/integrations/lambda/requirements.txt new file mode 100644 index 00000000..3e7b571a --- /dev/null +++ b/integrations/lambda/requirements.txt @@ -0,0 +1,2 @@ +boto3==1.35.48 +requests==2.32.3 From 02c6d9874653308fee77675727b269f12af92df7 Mon Sep 17 00:00:00 2001 From: Trevor Bonas <45324987+trevorbonas@users.noreply.github.com> Date: Fri, 1 Nov 2024 12:54:54 -0700 Subject: [PATCH 2/7] Automate Grafana dashboard deployment Changes: - Add cell that creates workspace role. - Add cell that creates workspace. - Add cell to create a token for Grafana management. - Add cells for installing Timestream plugin in workspace and adding Timestream data source. - Add cell for generating and uploading dashboard. - Add markdown cells with instructions for accessing workspace. - README section added for how to view data in Grafana. --- integrations/lambda/README.md | 39 +- integrations/lambda/dashboard.json | 136 ------ integrations/lambda/demo.ipynb | 665 ++++++++++++++++++++++++++- integrations/lambda/requirements.txt | 2 +- 4 files changed, 690 insertions(+), 152 deletions(-) delete mode 100644 integrations/lambda/dashboard.json diff --git a/integrations/lambda/README.md b/integrations/lambda/README.md index 3675c4bc..2cc9757e 100644 --- a/integrations/lambda/README.md +++ b/integrations/lambda/README.md @@ -70,7 +70,7 @@ When deployed in Amazon SageMaker, the instance hosting the Jupyter notebook mus "lambda:CreateFunctionUrlConfig", "lambda:AddPermission" ], - "Resource": "arn:aws:lambda:::function:TimestreamLambdaFunction" + "Resource": "arn:aws:lambda:::function:TimestreamSampleLambda" }, { "Effect": "Allow", @@ -88,7 +88,34 @@ When deployed in Amazon SageMaker, the instance hosting the Jupyter notebook mus "iam:PutGroupPolicy", "iam:PassRole" ], - "Resource": "arn:aws:iam:::role/TimestreamLambdaRole" + "Resource": [ + "arn:aws:iam:::role/TimestreamLambdaRole", + "arn:aws:iam:::role/GrafanaWorkspaceRole" + ] + }, + { + "Effect": "Allow", + "Action": [ + "sso:DescribeRegisteredRegions", + "sso:CreateManagedApplicationInstance" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "grafana:DescribeWorkspace", + "grafana:CreateWorkspace", + "grafana:ListWorkspaces", + "grafana:CreateWorkspaceServiceAccount", + "grafana:CreateWorkspaceServiceAccountToken", + "grafana:DeleteWorkspaceServiceAccountToken", + "grafana:DescribeWorkspaceConfiguration", + "grafana:UpdateWorkspaceConfiguration", + "grafana:ListWorkspaceServiceAccounts", + "grafana:ListWorkspaceServiceAccountTokens" + ], + "Resource": "arn:aws:grafana:>::/workspaces*" } ] } @@ -110,4 +137,10 @@ To host the Jupyter notebook in SageMaker and run the notebook: 8. Choose the uploaded notebook. 9. In the **Kernel not found** popup window, select `conda_python3` form the dropdown menu and choose **Set Kernel**. 10. Once the kernel has started, choose **Kernel** > **Restart & Run All**. -11. When all cells in the notebook have finished executing, records will have been ingested to the `sample_app_table` table in the `sample_app_database` database in Timestream for LiveAnalytics. \ No newline at end of file +11. When all cells in the notebook have finished executing, records will have been ingested to the `sample_app_table` table in the `sample_app_database` database in Timestream for LiveAnalytics. + +## Viewing Data in Amazon Managed Grafana + +The notebook will create an Amazon Managed Grafana workspace and create a dashboard. + +Before accessing the dashboard, an IAM Identity Center user must be created and added to the workspace manually. The last two steps of the notebook provide instructions for how to do this and access the dashboard. The "Generate and Upload Grafana Dashboard" cell will output the login url for the workspace. \ No newline at end of file diff --git a/integrations/lambda/dashboard.json b/integrations/lambda/dashboard.json deleted file mode 100644 index 4524ca7a..00000000 --- a/integrations/lambda/dashboard.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": 12, - "links": [], - "panels": [ - { - "datasource": { - "default": true, - "type": "grafana-timestream-datasource", - "uid": "timestream_sample_app_database" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 22, - "w": 20, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "grafana-timestream-datasource", - "uid": "timestream_sample_app_database" - }, - "format": 1, - "hide": false, - "measure": "", - "rawQuery": "SELECT * FROM \"sample_app_database\".\"sample_app_table\" ORDER BY time ASC", - "refId": "A" - } - ], - "title": "Panel Title", - "type": "timeseries" - } - ], - "schemaVersion": 39, - "tags": [], - "templating": { - "list": [] - }, - "time": { - "from": "now-10m", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Timestream Sample Dashboard", - "uid": "de0yzhhg7xo8wd", - "version": 2, - "weekStart": "" -} diff --git a/integrations/lambda/demo.ipynb b/integrations/lambda/demo.ipynb index 8e620a82..e0f39868 100644 --- a/integrations/lambda/demo.ipynb +++ b/integrations/lambda/demo.ipynb @@ -40,7 +40,7 @@ "outputs": [], "source": [ "import random\n", - "from datetime import datetime, timedelta, UTC\n", + "from datetime import datetime, timedelta, timezone\n", "import json\n", "import math" ] @@ -873,14 +873,14 @@ "outputs": [], "source": [ "# All timestamps default to UTC\n", - "end_date = datetime.now(UTC)\n", - "start_date = end_date - timedelta(hours=8)\n", - "reporting_frequency = timedelta(minutes=30)\n", + "end_date = datetime.now(timezone.utc)\n", + "start_date = end_date - timedelta(hours=2)\n", + "reporting_frequency = timedelta(minutes=1)\n", "num_entities = 10\n", "\n", - "if end_date > datetime.now(UTC) + timedelta(minutes=15):\n", + "if end_date > datetime.now(timezone.utc) + timedelta(minutes=15):\n", " raise Exception(\"The end date for data generation cannot be more than 15 minutes in the future\")\n", - "if start_date < datetime.now(UTC) - timedelta(hours=MEM_STORE_RETENTION_PERIOD_IN_HOURS):\n", + "if start_date < datetime.now(timezone.utc) - timedelta(hours=MEM_STORE_RETENTION_PERIOD_IN_HOURS):\n", " raise Exception(f\"The start date for data generation cannot be more than {MEM_STORE_RETENTION_PERIOD_IN_HOURS} hours in the past\")\n", "if start_date >= end_date:\n", " raise Exception(\"The start date and end date for data generation are the same\")\n", @@ -1423,21 +1423,662 @@ "## Step 4: Configure Grafana" ] }, + { + "cell_type": "markdown", + "id": "8f9d9ce0", + "metadata": {}, + "source": [ + "### Imports" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "a279643c", + "metadata": {}, + "outputs": [], + "source": [ + "import boto3\n", + "from botocore.exceptions import ClientError\n", + "import json\n", + "import time\n", + "import requests" + ] + }, + { + "cell_type": "markdown", + "id": "f10bfbc6", + "metadata": {}, + "source": [ + "### Create Role for Workspace" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5fcf4ef", + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize IAM and Managed Grafana clients\n", + "iam_client = boto3.client('iam', region_name=REGION_NAME)\n", + "grafana_client = boto3.client('grafana', region_name=REGION_NAME)\n", + "\n", + "workspace_role_arn = \"\"\n", + "\n", + "# Define the trust policy for Amazon Managed Grafana\n", + "trust_policy = {\n", + " \"Version\": \"2012-10-17\",\n", + " \"Statement\": [\n", + " {\n", + " \"Effect\": \"Allow\",\n", + " \"Principal\": {\n", + " \"Service\": \"grafana.amazonaws.com\"\n", + " },\n", + " \"Action\": \"sts:AssumeRole\"\n", + " }\n", + " ]\n", + "}\n", + "\n", + "workspace_role_name = 'GrafanaWorkspaceRole'\n", + "try:\n", + " # Create the IAM role\n", + " create_role_response = iam_client.create_role(\n", + " RoleName=workspace_role_name,\n", + " AssumeRolePolicyDocument=json.dumps(trust_policy),\n", + " Description=\"Role for Amazon Managed Grafana to access AWS resources\"\n", + " )\n", + " workspace_role_arn = create_role_response['Role']['Arn']\n", + "except iam_client.exceptions.EntityAlreadyExistsException:\n", + " print(f\"Workspace IAM role {role_name} already exists\")\n", + " try:\n", + " workspace_role_arn = iam_client.get_role(RoleName=workspace_role_name)['Role']['Arn']\n", + " except iam_client.exceptions.NoSuchEntityException:\n", + " print(\"Workspace IAM role could not be found\")\n", + " raise\n", + " \n", + "print(f\"Created workspace role with ARN: {workspace_role_arn}\")\n", + "\n", + "# Define an inline policy for Timestream and CloudWatch read access\n", + "inline_policy = {\n", + " \"Version\": \"2012-10-17\",\n", + " \"Statement\": [\n", + " {\n", + " \"Effect\": \"Allow\",\n", + " \"Action\": [\n", + " \"timestream:DescribeEndpoints\",\n", + " \"timestream:ListDatabases\"\n", + " ],\n", + " \"Resource\": \"*\"\n", + " },\n", + " {\n", + " \"Effect\": \"Allow\",\n", + " \"Action\": [\n", + " \"timestream:Select\"\n", + " ],\n", + " \"Resource\": f\"arn:aws:timestream:{REGION_NAME}:{account_id}:database/{DATABASE_NAME}/table/{TABLE_NAME}\"\n", + " },\n", + " {\n", + " \"Effect\": \"Allow\",\n", + " \"Action\": [\n", + " \"timestream:ListTables\"\n", + " ],\n", + " \"Resource\": f\"arn:aws:timestream:{REGION_NAME}:{account_id}:database/{DATABASE_NAME}\"\n", + " }\n", + " ]\n", + "}\n", + "\n", + "try:\n", + " # Attach the inline policy\n", + " iam_client.put_role_policy(\n", + " RoleName=workspace_role_name,\n", + " PolicyName='GrafanaWorkspaceAccessPolicy',\n", + " PolicyDocument=json.dumps(inline_policy)\n", + " )\n", + " print(f\"Attached inline policy to role {workspace_role_name}\")\n", + "except Exception as err:\n", + " print(\"Failed to attach policy to workspace role\")\n", + " raise" + ] + }, { "cell_type": "markdown", "id": "112cf6a6", "metadata": {}, "source": [ - "### Grafana Configuration Steps\n", - "1. Open Grafana.\n", - "2. Add a new data source with the name \"timestream_sample_app_database\".\n", - "3. Create a new dashboard using the provided `dashboard.json`." + "### Create Workspace or Use Existing Workspace" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3b1d7609", + "metadata": {}, + "outputs": [], + "source": [ + "WORKSPACE_NAME = \"sample_app_workspace\"\n", + "MAX_WAIT_SECONDS = 900 # 15 minutes\n", + "WAIT_PERIOD_SECONDS = 15\n", + "\n", + "workspace_id = \"\"\n", + "grafana_endpoint_url = \"\"\n", + "try:\n", + " list_workspaces_response = grafana_client.list_workspaces()\n", + " for workspace in list_workspaces_response['workspaces']:\n", + " if workspace['name'] == WORKSPACE_NAME:\n", + " print(f\"Workspace '{WORKSPACE_NAME}' already exists with ID: {workspace['id']}\")\n", + " workspace_id = workspace['id']\n", + " current_wait_seconds = 0\n", + " if workspace['status'] != 'ACTIVE':\n", + " status = \"\"\n", + " while current_wait_seconds < MAX_WAIT_SECONDS:\n", + " status = grafana_client.describe_workspace(workspaceId=workspace_id)['workspace']['status']\n", + " print(f\"Workspace status: {status}\")\n", + " if status == 'ACTIVE':\n", + " break\n", + " time.sleep(WAIT_PERIOD_SECONDS)\n", + " current_wait_seconds += WAIT_PERIOD_SECONDS\n", + " if current_wait_seconds >= MAX_WAIT_SECONDS and status != 'ACTIVE':\n", + " raise Exception(\"Timed out while waiting for workspace to become active\")\n", + "\n", + "except ClientError as e:\n", + " raise Exception(f\"Error checking for workspace: {e}\")\n", + "\n", + "if not workspace_id:\n", + " try:\n", + " configuration = {\n", + " \"plugins\": {\n", + " \"pluginAdminEnabled\": True,\n", + " }\n", + " }\n", + " create_workspace_response = grafana_client.create_workspace(\n", + " accountAccessType='CURRENT_ACCOUNT',\n", + " authenticationProviders=['AWS_SSO'],\n", + " permissionType='CUSTOMER_MANAGED',\n", + " workspaceName=WORKSPACE_NAME,\n", + " workspaceRoleArn=workspace_role_arn,\n", + " configuration=json.dumps(configuration)\n", + " )\n", + " except Exception as err:\n", + " print(f\"Failed to create workspace: {err}\")\n", + " workspace_id = create_workspace_response['workspace']['id']\n", + " print(f\"Workspace '{WORKSPACE_NAME}' created with ID: {workspace_id}\")\n", + "\n", + " # Wait until the workspace is active\n", + " current_wait_seconds = 0\n", + " while current_wait_seconds < MAX_WAIT_SECONDS:\n", + " status = grafana_client.describe_workspace(workspaceId=workspace_id)['workspace']['status']\n", + " print(f\"Workspace status: {status}\")\n", + " if status == 'ACTIVE':\n", + " break\n", + " time.sleep(WAIT_PERIOD_SECONDS)\n", + " current_wait_seconds += WAIT_PERIOD_SECONDS\n", + "\n", + " if current_wait_seconds >= MAX_WAIT_SECONDS and status != 'ACTIVE':\n", + " raise Exception(\"Timed out while waiting for workspace to become active\")\n", + "\n", + "grafana_workspace = grafana_client.describe_workspace(workspaceId=workspace_id)\n", + "grafana_endpoint_url = grafana_workspace['workspace']['endpoint']" + ] + }, + { + "cell_type": "markdown", + "id": "314e1a4e", + "metadata": {}, + "source": [ + "### Create Grafana API Key\n", + "\n", + "A Grafana API key is required in order to make requests to Grafana to add the Timestream plugin, create the Timestream data source, and upload the dashboard." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "2b1d0960", + "metadata": {}, + "outputs": [], + "source": [ + "workspace_service_account_name = 'admin'\n", + "workspace_service_account_id = ''\n", + "try:\n", + " create_service_account_response = grafana_client.create_workspace_service_account(grafanaRole='ADMIN', name='admin', workspaceId=workspace_id)\n", + " workspace_service_account_id = create_service_account_response['id']\n", + "except grafana_client.exceptions.ConflictException:\n", + " print(\"Using existing workspace service account\")\n", + " list_workspace_services_accounts_response = grafana_client.list_workspace_service_accounts(\n", + " maxResults=200,\n", + " workspaceId=workspace_id\n", + " )\n", + " next_token = list_workspace_services_accounts_response.get('nextToken', '')\n", + " for service_account in list_workspace_services_accounts_response['serviceAccounts']:\n", + " if service_account['name'] == workspace_service_account_name:\n", + " workspace_service_account_id = service_account['id']\n", + " if not workspace_service_account_id:\n", + " while next_token:\n", + " list_workspace_services_accounts_response = grafana_client.list_workspace_service_accounts(\n", + " maxResults=200,\n", + " workspaceId=workspace_id,\n", + " nextToken=next_token\n", + " )\n", + " for service_account in list_workspace_services_accounts_response['serviceAccounts']:\n", + " if service_account['name'] == workspace_service_account_name:\n", + " workspace_service_account_id = service_account['id']\n", + " if workspace_service_account_id:\n", + " break\n", + " next_token = list_workspace_services_accounts_response.get('nextToken', '')\n", + " if not workspace_service_account_id:\n", + " raise Exception(f\"Existing workspace service account with name {workspace_service_account_name} could not be found\")\n", + "except Exception as err:\n", + " print(f\"An unexpected exception occurred when creating workspace service account: {err}\")\n", + " raise\n", + "\n", + "service_account_token_name = 'admin_token'\n", + "# If the token already exists, it must be deleted and recreated. list_workspace_service_account_tokens\n", + "# will not return its key.\n", + "try:\n", + " service_account_token_id = ''\n", + " list_service_account_tokens_response = grafana_client.list_workspace_service_account_tokens(\n", + " maxResults=200,\n", + " workspaceId=workspace_id,\n", + " serviceAccountId=workspace_service_account_id\n", + " )\n", + " next_token = list_service_account_tokens_response.get(\"nextToken\", '')\n", + " for service_account_token in list_service_account_tokens_response['serviceAccountTokens']:\n", + " if service_account_token['name'] == service_account_token_name:\n", + " service_account_token_id = service_account_token['id']\n", + " if not workspace_service_account_id:\n", + " while next_token:\n", + " list_service_account_tokens_response = grafana_client.list_workspace_service_account_tokens(\n", + " maxResults=200,\n", + " workspaceId=workspace_id,\n", + " nextToken=next_token,\n", + " serviceAccountId=workspace_service_account_id\n", + " )\n", + " for service_account_token in list_service_account_tokens_response['serviceAccountTokens']:\n", + " if service_account_token['name'] == service_account_token_name:\n", + " service_account_token_id = service_account_token['id']\n", + " if service_account_token_id:\n", + " break\n", + " next_token = list_service_account_tokens_response.get('nextToken', '')\n", + " if service_account_token_id:\n", + " grafana_client.delete_workspace_service_account_token(\n", + " serviceAccountId=workspace_service_account_id,\n", + " tokenId=service_account_token_id,\n", + " workspaceId=workspace_id\n", + " )\n", + "except Exception as err:\n", + " print(f\"An unexpected exception occurred when checking for existing service tokens: {e}\")\n", + " raise\n", + "\n", + "try:\n", + " create_token_response = grafana_client.create_workspace_service_account_token(\n", + " name=service_account_token_name,\n", + " secondsToLive=86400, # 1 day\n", + " serviceAccountId=workspace_service_account_id,\n", + " workspaceId=workspace_id\n", + " )\n", + " service_account_token = create_token_response['serviceAccountToken']['key']\n", + "except Exception as err:\n", + " print(f\"An exception occurred when trying to create a new service token: {err}\")\n", + " raise" + ] + }, + { + "cell_type": "markdown", + "id": "6a59e1de", + "metadata": {}, + "source": [ + "### Add Timestream Plugin to Workspace" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "339901c7", + "metadata": {}, + "outputs": [], + "source": [ + "headers = {\n", + " \"Authorization\": f\"Bearer {service_account_token}\",\n", + " \"Accept\": \"application/json\",\n", + " \"Content-Type\": \"application/json\"\n", + "}\n", + "\n", + "install_timestream_plugin_response = requests.post(\n", + " f\"https://{grafana_endpoint_url}/api/plugins/grafana-timestream-datasource/install\",\n", + " headers=headers,\n", + ")\n", + "\n", + "if install_timestream_plugin_response.status_code == 409:\n", + " print(\"Amazon Timestream plugin already installed\")\n", + "elif not install_timestream_plugin_response.ok:\n", + " raise Exception(f\"Failed to install Amazon Timestream plugin for workspace: {install_timestream_plugin_response.content}\")\n", + "\n", + "current_wait_seconds = 0\n", + "while current_wait_seconds < MAX_WAIT_SECONDS:\n", + " installed_plugins_response = requests.get(\n", + " f\"https://{grafana_endpoint_url}/api/plugins\",\n", + " headers=headers\n", + " )\n", + " if installed_plugins_response.ok:\n", + " installed_plugins_response_json = installed_plugins_response.json()\n", + " if any(installed_plugin.get('id') == 'grafana-timestream-datasource' for installed_plugin in installed_plugins_response_json):\n", + " # Grafana will report the plugin as installed but needs more time\n", + " # for the installation to truly finish\n", + " time.sleep(WAIT_PERIOD_SECONDS)\n", + " print(\"Amazon Timestream plugin installed\")\n", + " break\n", + " else:\n", + " raise Exception(\"Failed to check currently installed plugins\")\n", + " print(\"Waiting for the Amazon Timestream plugin to finish installing . . .\")\n", + " time.sleep(WAIT_PERIOD_SECONDS)\n", + " current_wait_seconds += WAIT_PERIOD_SECONDS\n", + "\n", + "enable_plugin_payload = {\n", + " \"enabled\": True,\n", + " \"pinned\": True,\n", + " \"json\": None\n", + "}\n", + "\n", + "# Post request to add the data source\n", + "add_timestream_plugin_response = requests.post(\n", + " f\"https://{grafana_endpoint_url}/api/plugins/grafana-timestream-datasource/settings\",\n", + " headers=headers,\n", + " data=json.dumps(enable_plugin_payload)\n", + ")\n", + "\n", + "if add_timestream_plugin_response.ok:\n", + " print(\"Amazon Timestream plugin enabled\")\n", + "elif add_timestream_plugin_response.status_code == 409:\n", + " print(\"Amazon Timestream plugin already enabled\")\n", + "else:\n", + " raise Exception(f\"Failed to enable Amazon Timestream plugin for workspace: {add_timestream_plugin_response.content}\")" + ] + }, + { + "cell_type": "markdown", + "id": "06f5d5e3", + "metadata": {}, + "source": [ + "### Add Timestream Grafana Data Source" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "362e8e86", + "metadata": {}, + "outputs": [], + "source": [ + "headers = {\n", + " \"Authorization\": f\"Bearer {service_account_token}\",\n", + " \"Accept\": \"application/json\",\n", + " \"Content-Type\": \"application/json\"\n", + "}\n", + "\n", + "grafana_data_source_name = \"Amazon Timestream for LiveAnalytics Sample Data Source\"\n", + "\n", + "# Timestream data source payload\n", + "data_source_payload = {\n", + " \"name\": grafana_data_source_name,\n", + " \"type\": \"grafana-timestream-datasource\",\n", + " \"access\": \"proxy\",\n", + " \"jsonData\": {\n", + " \"defaultRegion\": REGION_NAME,\n", + " \"database\": DATABASE_NAME,\n", + " \"table\": TABLE_NAME,\n", + " \"authenticationType\": \"AWS_IAM\"\n", + " }\n", + "}\n", + "\n", + "# Post request to add the data source\n", + "create_data_source_response = requests.post(\n", + " f\"https://{grafana_endpoint_url}/api/datasources\",\n", + " headers=headers,\n", + " data=json.dumps(data_source_payload)\n", + ")\n", + "\n", + "data_source_id = \"\"\n", + "if create_data_source_response.ok:\n", + " print(\"Amazon Timestream for LiveAnalytics data source added successfully.\")\n", + " data_source_id = create_data_source_response.json()['id']\n", + "elif create_data_source_response.status_code == 409: # Conflict - Data source already exists\n", + " print(\"Amazon Timestream for LiveAnalytics data source already exists.\")\n", + " data_source_id_response = requests.get(f\"https://{grafana_endpoint_url}/api/datasources/name/{grafana_data_source_name}\", headers=headers)\n", + " if data_source_id_response.ok:\n", + " data_source_id = data_source_id_response.json().get(\"id\")\n", + " else:\n", + " raise Exception(f\"Failed to get ID of existing {grafana_data_source_name} data source\")\n", + "else:\n", + " raise Exception(f\"Failed to add Timestream data source: {create_data_source_response.content}\")" + ] + }, + { + "cell_type": "markdown", + "id": "2d3293d5", + "metadata": {}, + "source": [ + "### Generate and Upload Grafana Dashboard" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "603364f0", + "metadata": {}, + "outputs": [], + "source": [ + "headers = {\n", + " \"Authorization\": f\"Bearer {service_account_token}\",\n", + " \"Accept\": \"application/json\",\n", + " \"Content-Type\": \"application/json\"\n", + "}\n", + "\n", + "dashboard = {\n", + " \"annotations\": {\n", + " \"list\": [\n", + " {\n", + " \"builtIn\": 1,\n", + " \"datasource\": {\n", + " \"type\": \"grafana\",\n", + " \"uid\": \"-- Grafana --\"\n", + " },\n", + " \"enable\": True,\n", + " \"hide\": True,\n", + " \"iconColor\": \"rgba(0, 211, 255, 1)\",\n", + " \"name\": \"Annotations & Alerts\",\n", + " \"type\": \"dashboard\"\n", + " }\n", + " ]\n", + " },\n", + " \"editable\": True,\n", + " \"fiscalYearStartMonth\": 0,\n", + " \"graphTooltip\": 0,\n", + " \"links\": [],\n", + " \"panels\": [\n", + " {\n", + " \"datasource\": grafana_data_source_name,\n", + " \"fieldConfig\": {\n", + " \"defaults\": {\n", + " \"color\": {\n", + " \"mode\": \"palette-classic\"\n", + " },\n", + " \"custom\": {\n", + " \"axisBorderShow\": False,\n", + " \"axisCenteredZero\": False,\n", + " \"axisColorMode\": \"text\",\n", + " \"axisLabel\": \"\",\n", + " \"axisPlacement\": \"auto\",\n", + " \"barAlignment\": 0,\n", + " \"barWidthFactor\": 0.6,\n", + " \"drawStyle\": \"line\",\n", + " \"fillOpacity\": 0,\n", + " \"gradientMode\": \"none\",\n", + " \"hideFrom\": {\n", + " \"legend\": False,\n", + " \"tooltip\": False,\n", + " \"viz\": False\n", + " },\n", + " \"insertNulls\": False,\n", + " \"lineInterpolation\": \"linear\",\n", + " \"lineWidth\": 1,\n", + " \"pointSize\": 5,\n", + " \"scaleDistribution\": {\n", + " \"type\": \"linear\"\n", + " },\n", + " \"showPoints\": \"auto\",\n", + " \"spanNulls\": False,\n", + " \"stacking\": {\n", + " \"group\": \"A\",\n", + " \"mode\": \"none\"\n", + " },\n", + " \"thresholdsStyle\": {\n", + " \"mode\": \"off\"\n", + " }\n", + " },\n", + " \"mappings\": [],\n", + " \"thresholds\": {\n", + " \"mode\": \"absolute\",\n", + " \"steps\": [\n", + " {\n", + " \"color\": \"green\",\n", + " \"value\": None\n", + " },\n", + " {\n", + " \"color\": \"red\",\n", + " \"value\": 80\n", + " }\n", + " ]\n", + " }\n", + " },\n", + " \"overrides\": []\n", + " },\n", + " \"gridPos\": {\n", + " \"h\": 22,\n", + " \"w\": 20,\n", + " \"x\": 0,\n", + " \"y\": 0\n", + " },\n", + " \"id\": 1,\n", + " \"options\": {\n", + " \"legend\": {\n", + " \"calcs\": [],\n", + " \"displayMode\": \"list\",\n", + " \"placement\": \"bottom\",\n", + " \"showLegend\": True\n", + " },\n", + " \"tooltip\": {\n", + " \"mode\": \"single\",\n", + " \"sort\": \"none\"\n", + " }\n", + " },\n", + " \"targets\": [\n", + " {\n", + " \"datasource\": grafana_data_source_name,\n", + " \"format\": 1,\n", + " \"hide\": False,\n", + " \"measure\": \"\",\n", + " \"rawQuery\": f\"SELECT * FROM \\\"{DATABASE_NAME}\\\".\\\"{TABLE_NAME}\\\" ORDER BY time ASC\",\n", + " \"refId\": \"A\"\n", + " }\n", + " ],\n", + " \"title\": f\"{DATABASE_NAME}.{TABLE_NAME} Data\",\n", + " \"type\": \"timeseries\"\n", + " }\n", + " ],\n", + " \"schemaVersion\": 39,\n", + " \"tags\": [],\n", + " \"templating\": {\n", + " \"list\": []\n", + " },\n", + " \"time\": {\n", + " \"from\": \"now-15m\",\n", + " \"to\": \"now\"\n", + " },\n", + " \"timepicker\": {},\n", + " \"timezone\": \"\",\n", + " \"title\": \"Amazon Timestream for LiveAnalytics Sample Dashboard\",\n", + " \"uid\": \"de0yzhhg7xo8wd\",\n", + " \"version\": 2,\n", + " \"weekStart\": \"\"\n", + "}\n", + "\n", + "# Write the dashboard JSON to a local file in order to\n", + "# upload manually\n", + "#with open('sample_app_dashboard.json', 'w') as f:\n", + "# json.dump(dashboard, f)\n", + "\n", + "dashboard_payload = {\n", + " \"dashboard\": dashboard,\n", + " \"overwrite\": True, # Ensures replacement if it exists\n", + " \"id\": None,\n", + " \"uid\": None\n", + "}\n", + "\n", + "create_dashboard_response = requests.post(\n", + " f\"https://{grafana_endpoint_url}/api/dashboards/db\",\n", + " headers=headers,\n", + " data=json.dumps(dashboard_payload)\n", + ")\n", + "\n", + "if create_dashboard_response.ok:\n", + " print(f\"Dashboard deployed successfully\")\n", + " print(f\"Workspace login url: https://{grafana_endpoint_url}/login\")\n", + "else:\n", + " print(f\"Failed to deploy dashboard: {create_dashboard_response.content}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "dc2024be", + "metadata": {}, + "source": [ + "### Add IAM Identity User to Grafana Workspace\n", + "\n", + "This step must be done using the AWS management console. An IAM identity user must be added to the Grafana workspace. Only users added to the workspace will be able to log in to the workspace.\n", + "\n", + "#### Create IAM Identity User\n", + "\n", + "If you already have an IAM identity user you want to use to login to the workspace, skip to the next section.\n", + "\n", + "1. [Go to the IAM Identity Center console](https://console.aws.amazon.com/singlesignon/home).\n", + "2. In the navigation pane, choose **Users**.\n", + "3. Choose **Add user**.\n", + "4. Input user details.\n", + "5. Choose **Next**.\n", + "6. Add the user to a group if you wish.\n", + "7. Choose **Next**.\n", + "8. Choose **Add user**.\n", + "\n", + "#### Adding IAM Identity User to Workspace\n", + "\n", + "1. [Go to the Amazon Managed Grafana console]().\n", + "2. In the navigation pane, choose **All workspaces**.\n", + "3. From the list of workspaces, choose the created workspace. By default, it is named `sample_app_workspace`.\n", + "4. in the **Authentication** tab, under **AWS IAM Identity Center (successor to AWS SSO)** choose **Assign new user or group**.\n", + "5. From the list of users, choose the user(s) you want to allow to login to the workspace and then choose **Assign users and groups**.\n", + "6. By default, users are added as a Viewer. If you want to allow your user to manage data sources in Grafana, select your user, then, in the **Action** dropdown menu, select **Make admin**.\n", + "7. Go to the login page as output by the previous cell and input your user's username and password to sign into the workspace." + ] + }, + { + "cell_type": "markdown", + "id": "93cd1479", + "metadata": {}, + "source": [ + "### Viewing the Dashboard\n", + "\n", + "1. Log in to the Amazon Managed Grafana workspace.\n", + "2. In the navigation pane, select **Dashboards**.\n", + "3. From the list of dashboards, select the deployed dashboard. By default, it is named `Amazon Timestream for LiveAnalytics Sample Dashboard`.\n", + "4. Adjust the time range as needed. By default, all data for the last 15 minutes is displayed. Timestamps are in UTC.\n", + "5. Measures are listed below the graph, select measures to display them." ] } ], "metadata": { "kernelspec": { - "display_name": "test_env", + "display_name": "sample_app_env", "language": "python", "name": "python3" }, @@ -1451,7 +2092,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.4" + "version": "3.12.7" } }, "nbformat": 4, diff --git a/integrations/lambda/requirements.txt b/integrations/lambda/requirements.txt index 3e7b571a..b1968c03 100644 --- a/integrations/lambda/requirements.txt +++ b/integrations/lambda/requirements.txt @@ -1,2 +1,2 @@ -boto3==1.35.48 +boto3==1.35.51 requests==2.32.3 From 3ca328313510b8a22bb9e7aa34404d26e8d02fb2 Mon Sep 17 00:00:00 2001 From: Trevor Bonas Date: Fri, 1 Nov 2024 12:59:07 -0700 Subject: [PATCH 3/7] Fix incorrect Lambda function name in README --- integrations/lambda/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integrations/lambda/README.md b/integrations/lambda/README.md index 2cc9757e..5f593672 100644 --- a/integrations/lambda/README.md +++ b/integrations/lambda/README.md @@ -121,7 +121,7 @@ When deployed in Amazon SageMaker, the instance hosting the Jupyter notebook mus } ``` -The Lambda function name `TimestreamLambdaFunction` and the role name `TimestreamLambdaRole` are the default names used in the Jupyter notebook. +The Lambda function name `TimestreamSampleLambda` and the role name `TimestreamLambdaRole` are the default names used in the Jupyter notebook. ### SageMaker Configuration @@ -143,4 +143,4 @@ To host the Jupyter notebook in SageMaker and run the notebook: The notebook will create an Amazon Managed Grafana workspace and create a dashboard. -Before accessing the dashboard, an IAM Identity Center user must be created and added to the workspace manually. The last two steps of the notebook provide instructions for how to do this and access the dashboard. The "Generate and Upload Grafana Dashboard" cell will output the login url for the workspace. \ No newline at end of file +Before accessing the dashboard, an IAM Identity Center user must be created and added to the workspace manually. The last two steps of the notebook provide instructions for how to do this and access the dashboard. The "Generate and Upload Grafana Dashboard" cell will output the login url for the workspace. From 4342d3fdb7413b07c24a4a29bb4ad90c23cd3fca Mon Sep 17 00:00:00 2001 From: Trevor Bonas Date: Tue, 5 Nov 2024 12:37:59 -0800 Subject: [PATCH 4/7] Remove reference to dashboard.json in README --- integrations/lambda/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/lambda/README.md b/integrations/lambda/README.md index 5f593672..d9bc8f4f 100644 --- a/integrations/lambda/README.md +++ b/integrations/lambda/README.md @@ -9,8 +9,8 @@ This sample application is comprised of three files: - Generates simulated time series data from a selection of predefined scenarios or a user-defined scenario. - Deploys a Lambda function that receives the data and ingests the data into Timestream for LiveAnalytics. - Sends the generated time series data to the Lambda's URL using SigV4 authentication. -- `dashboard.json`: A Grafana dashboard, configured to view all data ingested into the Timestream for LiveAnalytics database in the last hour. - `requirements.txt`: A file containing required packages for the Jupyter notebook, for quick environment setup. +- `environment.yml`: A Conda environment file that specifies the environment name, channel, and dependencies. The following diagram depicts the deployed Lambda function receiving generated data and ingesting the data to Timestream for LiveAnalytics that then is queried and displayed in [Amazon Managed Grafana](https://aws.amazon.com/grafana/). From 02c68f93234d9ebd1bf6a13011414568c10a0683 Mon Sep 17 00:00:00 2001 From: Trevor Bonas Date: Tue, 5 Nov 2024 12:40:22 -0800 Subject: [PATCH 5/7] Update steps for Jupyter notebook in README --- integrations/lambda/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integrations/lambda/README.md b/integrations/lambda/README.md index d9bc8f4f..d3df857e 100644 --- a/integrations/lambda/README.md +++ b/integrations/lambda/README.md @@ -9,6 +9,8 @@ This sample application is comprised of three files: - Generates simulated time series data from a selection of predefined scenarios or a user-defined scenario. - Deploys a Lambda function that receives the data and ingests the data into Timestream for LiveAnalytics. - Sends the generated time series data to the Lambda's URL using SigV4 authentication. + - Creates an Amazon Managed Grafana workspace. + - Generates and uploads a dashboard to the Amazon Managed Grafana workspace. - `requirements.txt`: A file containing required packages for the Jupyter notebook, for quick environment setup. - `environment.yml`: A Conda environment file that specifies the environment name, channel, and dependencies. From d1a3c2f35773c57c44f9638985385de67a9e1185 Mon Sep 17 00:00:00 2001 From: Trevor Bonas Date: Wed, 6 Nov 2024 15:06:12 -0800 Subject: [PATCH 6/7] Fix numbering in notebook --- integrations/lambda/demo.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integrations/lambda/demo.ipynb b/integrations/lambda/demo.ipynb index e0f39868..e9aa2a10 100644 --- a/integrations/lambda/demo.ipynb +++ b/integrations/lambda/demo.ipynb @@ -1319,7 +1319,7 @@ "id": "cef5a36c", "metadata": {}, "source": [ - "## Step 3: Send Data to the Lambda Function" + "## Step 4: Send Data to the Lambda Function" ] }, { @@ -1420,7 +1420,7 @@ "id": "dc25f96b", "metadata": {}, "source": [ - "## Step 4: Configure Grafana" + "## Step 5: Configure Grafana" ] }, { From 55733f30759f674c56ec1d9bea264611ffb8113e Mon Sep 17 00:00:00 2001 From: Trevor Bonas Date: Thu, 7 Nov 2024 14:38:07 -0800 Subject: [PATCH 7/7] Generate panels for each measure --- integrations/lambda/demo.ipynb | 354 +++++++++++++++++---------------- 1 file changed, 185 insertions(+), 169 deletions(-) diff --git a/integrations/lambda/demo.ipynb b/integrations/lambda/demo.ipynb index e9aa2a10..29545f07 100644 --- a/integrations/lambda/demo.ipynb +++ b/integrations/lambda/demo.ipynb @@ -34,7 +34,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "59510d93", "metadata": {}, "outputs": [], @@ -52,12 +52,12 @@ "source": [ "### Data Generator Base Class Definition\n", "\n", - "Defines the `DataGenerator` class, a base class for generating time series data. Data scenarios are defined by subclasses, in which subclasses set `_measure_templates` and `_dimension_templates` values, which define the possible values and restrictions for record measures and dimensions." + "Defines the `DataGenerator` class, a base class for generating time series data. Data scenarios are defined by subclasses, in which subclasses set `measure_templates` and `dimension_templates` values, which define the possible values and restrictions for record measures and dimensions." ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "de3bda06", "metadata": {}, "outputs": [], @@ -85,7 +85,7 @@ " # \"random_options\": [\"us-east-1\", \"us-west-2\"]\n", " # }\n", " # ]\n", - " _dimension_templates: list\n", + " dimension_templates: list\n", "\n", " # A list of dicts used to define the format of measures.\n", " #\n", @@ -116,12 +116,12 @@ " # \"random_options\": [\"none\", \"headache\", \"shortness of breath\", \"fatigue\", \"nausea\"]\n", " # }\n", " # ]\n", - " _measure_templates: list\n", + " measure_templates: list\n", "\n", " def __init__(self):\n", - " # All subclasses need to do is provide values for _measure_templates and dimension_templates\n", - " self._measure_templates = []\n", - " self._dimension_templates = []\n", + " # All subclasses need to do is provide values for measure_templates and dimension_templates\n", + " self.measure_templates = []\n", + " self.dimension_templates = []\n", "\n", " def generate(self, start_date: datetime, end_date: datetime, reporting_frequency: timedelta,\n", " num_entities: int, precision=\"MILLISECONDS\", generate_unique_options_fallback=False) -> list:\n", @@ -146,7 +146,7 @@ " entities = []\n", " for _ in range(num_entities):\n", " entity = {\"latest_measures\": {}}\n", - " for dimension_template in self._dimension_templates:\n", + " for dimension_template in self.dimension_templates:\n", " dimension_value_length = 20\n", "\n", " if \"value_length\" in dimension_template:\n", @@ -192,7 +192,7 @@ "\n", " measures = []\n", "\n", - " for measure_template in self._measure_templates:\n", + " for measure_template in self.measure_templates:\n", " if \"name\" not in measure_template:\n", " raise Exception(f\"Measure template was missing name: {measure_template}\")\n", " measure_name = measure_template[\"name\"]\n", @@ -291,7 +291,7 @@ "source": [ "### Data Generator Subclass Definitions\n", "\n", - "Defines subclasses of DataGenerator that define `_measure_templates` and `_dimension_templates`.\n", + "Defines subclasses of DataGenerator that define `measure_templates` and `dimension_templates`.\n", "\n", "The following subclasses are defined:\n", "- `DevOpsDataGenerator`: Generates generic DevOps time series data for servers.\n", @@ -304,19 +304,19 @@ "- `EnergyDataGenerator`: Generates time series data simulating building energy usage.\n", "- `FlightDataGenerator`: Generates time series data simulating different airline flights and the status of in-flight planes.\n", "- `ExchangeRateDataGenerator`: Generates time series data simulating the fluctuating exchange rates of different currency pairs.\n", - "- `CustomDataGenerator`: Allows users to define their own `_measure_templates` and `_dimension_templates` to generate data of their choosing." + "- `CustomDataGenerator`: Allows users to define their own `measure_templates` and `dimension_templates` to generate data of their choosing." ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "4d3deb01", "metadata": {}, "outputs": [], "source": [ "class DevOpsDataGenerator(DataGenerator):\n", " def __init__(self):\n", - " self._measure_templates = [\n", + " self.measure_templates = [\n", " {\n", " \"name\": \"cpu_usage\",\n", " \"type\": \"DOUBLE\",\n", @@ -353,7 +353,7 @@ " \"min\": 0\n", " }\n", " ]\n", - " self._dimension_templates = [\n", + " self.dimension_templates = [\n", " {\n", " \"name\": \"server_id\",\n", " \"value_length\": 14\n", @@ -366,7 +366,7 @@ "\n", "class IoTDataGenerator(DataGenerator):\n", " def __init__(self):\n", - " self._measure_templates = [\n", + " self.measure_templates = [\n", " {\n", " \"name\": \"temperature_celsius\",\n", " \"type\": \"DOUBLE\",\n", @@ -396,7 +396,7 @@ " \"min\": 0.0\n", " }\n", " ]\n", - " self._dimension_templates = [\n", + " self.dimension_templates = [\n", " {\n", " \"name\": \"device_id\",\n", " \"value_length\": 14\n", @@ -405,7 +405,7 @@ "\n", "class StockMarketDataGenerator(DataGenerator):\n", " def __init__(self):\n", - " self._measure_templates = [\n", + " self.measure_templates = [\n", " {\n", " \"name\": \"volume\",\n", " \"type\": \"BIGINT\",\n", @@ -435,7 +435,7 @@ " \"min\": 0.0\n", " }\n", " ]\n", - " self._dimension_templates = [\n", + " self.dimension_templates = [\n", " {\n", " \"name\": \"stock_symbol\",\n", " \"unique_options\": [\"AAPL\", \"TSLA\", \"DJIA\", \"SPOT\", \"NFLX\", \"MSFT\", \"MCD\", \"PG\", \"KO\", \"MMM\", \"IBM\", \"AMZN\", \"VZ\", \"JNJ\", \"WMT\"]\n", @@ -443,14 +443,14 @@ " ]\n", "\n", " def generate(self, start_date, end_date, reporting_frequency, num_entities, precision=\"MILLISECONDS\", generate_unique_dimension_fallback=False):\n", - " num_stock_symbols = len(self._dimension_templates[0][\"unique_options\"])\n", + " num_stock_symbols = len(self.dimension_templates[0][\"unique_options\"])\n", " if num_entities > num_stock_symbols and not generate_unique_dimension_fallback:\n", " raise Exception(f\"num_entities ({num_entities}) was greater than the number of stock symbols ({num_stock_symbols})\")\n", " return super().generate(start_date, end_date, reporting_frequency, num_entities, generate_unique_dimension_fallback)\n", "\n", "class WeatherDataGenerator(DataGenerator):\n", " def __init__(self):\n", - " self._measure_templates = [\n", + " self.measure_templates = [\n", " {\n", " \"name\": \"temperature_celsius\",\n", " \"type\": \"DOUBLE\",\n", @@ -501,7 +501,7 @@ " \"min\": 1\n", " }\n", " ]\n", - " self._dimension_templates = [\n", + " self.dimension_templates = [\n", " {\n", " \"name\": \"location\", \n", " \"unique_options\": [\"San Francisco, CA\", \"Chicago, IL\", \"New York, NY\", \"Miami, FL\", \"Dallas, TX\", \"Gary, IN\", \"Las Vegas, NV\", \"San Diego, CA\", \"Portland, OR\", \"Seattle, WA\", \"New Orleans, LA\", \"Fargo, ND\", \"Albuquerque, NM\"]\n", @@ -509,14 +509,14 @@ " ]\n", "\n", " def generate(self, start_date, end_date, reporting_frequency, num_entities, precision=\"MILLISECONDS\", generate_unique_options_fallback=False):\n", - " num_locations = len(self._dimension_templates[0][\"unique_options\"])\n", + " num_locations = len(self.dimension_templates[0][\"unique_options\"])\n", " if num_entities > num_locations and not generate_unique_options_fallback:\n", " raise Exception(f\"num_entities ({num_entities}) was greater than the number of locations ({num_locations})\")\n", " return super().generate(start_date, end_date, reporting_frequency, num_entities, generate_unique_options_fallback)\n", "\n", "class GamingDataGenerator(DataGenerator):\n", " def __init__(self):\n", - " self._measure_templates = [\n", + " self.measure_templates = [\n", " {\n", " \"name\": \"X\",\n", " \"type\": \"DOUBLE\",\n", @@ -581,7 +581,7 @@ " \"min\": 0.0\n", " }\n", " ]\n", - " self._dimension_templates = [\n", + " self.dimension_templates = [\n", " {\n", " \"name\": \"player_id\",\n", " \"value_length\": 25\n", @@ -598,7 +598,7 @@ "\n", "class AirQualityDataGenerator(DataGenerator):\n", " def __init__(self):\n", - " self._measure_templates = [\n", + " self.measure_templates = [\n", " {\n", " \"name\": \"PM2.5\",\n", " \"type\": \"DOUBLE\",\n", @@ -635,7 +635,7 @@ " \"min\": 20.8\n", " }\n", " ]\n", - " self._dimension_templates = [\n", + " self.dimension_templates = [\n", " {\n", " \"name\": \"city\",\n", " \"unique_options\": [\"Los Angeles\", \"New York\", \"Vancouver\", \"Sydney\", \"Delhi\", \"Beijing\", \"London\", \"Miami\", \"Toronto\", \"Seattle\", \"Amsterdam\"]\n", @@ -643,14 +643,14 @@ " ]\n", "\n", " def generate(self, start_date, end_date, reporting_frequency, num_entities, precision=\"MILLISECONDS\", generate_unique_options_fallback=False):\n", - " num_cities = len(self._dimension_templates[0][\"unique_options\"])\n", + " num_cities = len(self.dimension_templates[0][\"unique_options\"])\n", " if num_entities > num_cities and not generate_unique_options_fallback:\n", " raise Exception(f\"num_entities ({num_entities}) was greater than the number of cities ({num_cities})\")\n", " return super().generate(start_date, end_date, reporting_frequency, num_entities, generate_unique_options_fallback)\n", "\n", "class PatientDataGenerator(DataGenerator):\n", " def __init__(self):\n", - " self._measure_templates = [\n", + " self.measure_templates = [\n", " {\n", " \"name\": \"heart_rate_bpm\",\n", " \"type\": \"BIGINT\",\n", @@ -673,7 +673,7 @@ " \"min\": 30.0\n", " }\n", " ]\n", - " self._dimension_templates = [\n", + " self.dimension_templates = [\n", " {\n", " \"name\": \"patient_id\",\n", " \"value_length\": 14\n", @@ -682,7 +682,7 @@ "\n", "class EnergyDataGenerator(DataGenerator):\n", " def __init__(self):\n", - " self._measure_templates = [\n", + " self.measure_templates = [\n", " {\n", " \"name\": \"energy_usage_kWh\",\n", " \"type\": \"BIGINT\",\n", @@ -712,7 +712,7 @@ " \"min\": 0.0\n", " }\n", " ]\n", - " self._dimension_templates = [\n", + " self.dimension_templates = [\n", " {\n", " \"name\": \"building_id\",\n", " \"value_length\": 14\n", @@ -721,7 +721,7 @@ "\n", "class FlightDataGenerator(DataGenerator):\n", " def __init__(self):\n", - " self._measure_templates = [\n", + " self.measure_templates = [\n", " {\n", " \"name\": \"fuel_level_gallons\",\n", " \"type\": \"BIGINT\",\n", @@ -765,7 +765,7 @@ " \"min\": 200\n", " }\n", " ]\n", - " self._dimension_templates = [\n", + " self.dimension_templates = [\n", " {\n", " \"name\": \"flight_id\",\n", " \"unique_options\": [\"FL123\", \"FL456\", \"FL890\", \"FL333\", \"FL100\", \"FL650\", \"FL256\", \"FL430\", \"FL211\", \"FL874\"]\n", @@ -777,14 +777,14 @@ " ]\n", "\n", " def generate(self, start_date, end_date, reporting_frequency, num_entities, generate_unique_options_fallback=False):\n", - " num_flight_ids = len(self._dimension_templates[0][\"unique_options\"])\n", + " num_flight_ids = len(self.dimension_templates[0][\"unique_options\"])\n", " if num_entities > num_flight_ids and not generate_unique_options_fallback:\n", " raise Exception(f\"num_entities ({num_entities}) was greater than the number of flight IDs ({num_flight_ids})\")\n", " return super().generate(start_date, end_date, reporting_frequency, num_entities, generate_unique_options_fallback)\n", "\n", "class ExchangeRateDataGenerator(DataGenerator):\n", " def __init__(self):\n", - " self._measure_templates = [\n", + " self.measure_templates = [\n", " {\n", " \"name\": \"exchange_rate\",\n", " \"type\": \"DOUBLE\",\n", @@ -793,15 +793,15 @@ " \"min\": 0.62\n", " }\n", " ]\n", - " self._dimension_templates = [\n", + " self.dimension_templates = [\n", " {\n", " \"name\": \"currency_pair\",\n", - " \"unique_options\": [\"USD/EUR\", \"USD/CAD\", \"USD/GPP\", \"USD/CNY\" \"GBP/CAD\", \"GBP/JPY\", \"GBP/INR\", \"CHF/INR\", \"XAU/CNY\", \"UYU/CAD\", \"USD/XAU\"]\n", + " \"unique_options\": [\"USD/EUR\", \"USD/CAD\", \"USD/GPP\", \"USD/CNY\", \"GBP/CAD\", \"GBP/JPY\", \"GBP/INR\", \"CHF/INR\", \"XAU/CNY\", \"UYU/CAD\", \"USD/XAU\"]\n", " }\n", " ]\n", "\n", " def generate(self, start_date, end_date, reporting_frequency, num_entities, precision=\"MILLISECONDS\", generate_unique_options_fallback=False):\n", - " num_currency_pairs = len(self._dimension_templates[0][\"unique_options\"])\n", + " num_currency_pairs = len(self.dimension_templates[0][\"unique_options\"])\n", " if num_entities > num_currency_pairs and not generate_unique_options_fallback:\n", " raise Exception(f\"num_entities ({num_entities}) was greater than the number of currency pairs ({num_currency_pairs})\")\n", " return super().generate(start_date, end_date, reporting_frequency, num_entities, generate_unique_options_fallback)\n", @@ -809,8 +809,8 @@ "\n", "class CustomDataGenerator(DataGenerator):\n", " def __init__(self, measure_templates: list, dimension_templates: list):\n", - " self._measure_templates = measure_templates\n", - " self._dimension_templates = dimension_templates" + " self.measure_templates = measure_templates\n", + " self.dimension_templates = dimension_templates" ] }, { @@ -825,7 +825,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "fc5279cf", "metadata": {}, "outputs": [], @@ -860,8 +860,8 @@ "- `start_date`: The start date to use when generating records. This cannot be older in hours than the memory retention period in hours value for the table.\n", "- `end_date`: The end date to use when generating records. The maximum end date Timestream for LiveAnalytics allows is 15 minutes in the future.\n", "- `reporting_frequency`: The frequency that records are generated by all entities, for example, every 2 seconds, every 5 hours, etc.\n", - "- `param num_entities` The number of entities that will report for each timestamp, for example, the number of servers or number of stocks.\n", - "- `param precision`: The precision to use for record timestamps. Valid options are `\"MILLISECONDS\"`, `\"SECONDS\"`, and `\"MICROSECONDS\"`.\n", + "- `num_entities` The number of entities that will report for each timestamp, for example, the number of servers or number of stocks.\n", + "- `precision`: The precision to use for record timestamps. Valid options are `\"MILLISECONDS\"`, `\"SECONDS\"`, and `\"MICROSECONDS\"`.\n", "- `generate_unique_options_fallback`: Whether to generate random strings for dimension values after all values in a dimension template's \"unique_options\" array have been used." ] }, @@ -888,27 +888,31 @@ " raise Exception(\"The reporting frequency is too small for the data generation time range\")\n", "\n", "# By default, generate DevOps data, which simulates reporting from servers\n", - "sample_data = DevOpsDataGenerator().generate(start_date, end_date, reporting_frequency, num_entities, precision=PRECISION)\n", + "# Define data_generator to help generate Grafana dashboard later\n", + "data_generator = DevOpsDataGenerator()\n", + "sample_data = data_generator.generate(start_date, end_date, reporting_frequency, num_entities, precision=PRECISION)\n", "\n", "# Custom data\n", "#measure_templates = [\n", - "# {\n", - "# \"name\": \"exchange_rate\",\n", - "# \"type\": \"DOUBLE\",\n", - "# \"max_variation\": 1.0,\n", - "# \"max\": 90.0,\n", - "# \"min\": 0.62\n", - "# }\n", - "# ]\n", + "# {\n", + "# \"name\": \"exchange_rate\",\n", + "# \"type\": \"DOUBLE\",\n", + "# \"max_variation\": 1.0,\n", + "# \"max\": 90.0,\n", + "# \"min\": 0.62\n", + "# }\n", + "#]\n", + "\n", "#dimension_templates = [\n", - "# {\n", - "# \"name\": \"currency_pair\",\n", - "# \"value_length\": 4,\n", - "# \"unique_options\": [\"USD/EUR\", \"USD/CAD\", \"USD/GPP\", \"USD/CNY\" \"GBP/CAD\", \"GBP/JPY\", \"GBP/INR\", \"CHF/INR\", \"XAU/CNY\", \"UYU/CAD\"]\n", - "# }\n", - "# ]\n", + "# {\n", + "# \"name\": \"currency_pair\",\n", + "# \"value_length\": 4,\n", + "# \"unique_options\": [\"USD/EUR\", \"USD/CAD\", \"USD/GPP\", \"USD/CNY\", \"GBP/CAD\", \"GBP/JPY\", \"GBP/INR\", \"CHF/INR\", \"XAU/CNY\", \"UYU/CAD\"]\n", + "# }\n", + "#]\n", "\n", - "#sample_data = CustomDataGenerator(measure_templates=measure_templates, dimension_templates=dimension_templates).generate(start_date, end_date, reporting_frequency, num_entities)\n", + "#data_generator = CustomDataGenerator(measure_templates=measure_templates, dimension_templates=dimension_templates)\n", + "#sample_data = data_generator.generate(start_date, end_date, reporting_frequency, num_entities, precision=PRECISION)\n", "\n", "# Print generated data\n", "print(json.dumps(sample_data, indent=2))" @@ -997,7 +1001,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "3c08c93e", "metadata": {}, "outputs": [], @@ -1192,7 +1196,7 @@ " if len(measures) > 1:\n", " measure_value_type = 'MULTI'\n", " multi_value_measure = {\n", - " 'MeasureName': measures[0]['MeasureName'], # Example MeasureName; Timestream expects just one for MULTI\n", + " 'MeasureName': 'metrics', # General measure name, used for any multi-measure dataset\n", " 'MeasureValues': [\n", " {\n", " 'Name': m['MeasureName'],\n", @@ -1206,7 +1210,7 @@ " 'Dimensions': dimensions,\n", " 'Time': time_value,\n", " 'TimeUnit': precision,\n", - " 'MeasureName': multi_value_measure['MeasureName'], # Set MULTI MeasureName\n", + " 'MeasureName': multi_value_measure['MeasureName'],\n", " 'MeasureValueType': measure_value_type,\n", " 'MeasureValues': multi_value_measure['MeasureValues']\n", " }\n", @@ -1340,7 +1344,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "960ad90c", "metadata": {}, "outputs": [], @@ -1433,7 +1437,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "a279643c", "metadata": {}, "outputs": [], @@ -1633,7 +1637,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "2b1d0960", "metadata": {}, "outputs": [], @@ -1874,126 +1878,138 @@ " \"Content-Type\": \"application/json\"\n", "}\n", "\n", - "dashboard = {\n", - " \"annotations\": {\n", - " \"list\": [\n", - " {\n", - " \"builtIn\": 1,\n", - " \"datasource\": {\n", - " \"type\": \"grafana\",\n", - " \"uid\": \"-- Grafana --\"\n", - " },\n", - " \"enable\": True,\n", - " \"hide\": True,\n", - " \"iconColor\": \"rgba(0, 211, 255, 1)\",\n", - " \"name\": \"Annotations & Alerts\",\n", - " \"type\": \"dashboard\"\n", - " }\n", - " ]\n", - " },\n", - " \"editable\": True,\n", - " \"fiscalYearStartMonth\": 0,\n", - " \"graphTooltip\": 0,\n", - " \"links\": [],\n", - " \"panels\": [\n", - " {\n", + "# Each measure will have a panel\n", + "panels = []\n", + "for i, measure_template in enumerate(data_generator.measure_templates):\n", + " measure_name = measure_template['name']\n", + " query = \"\"\n", + " if len(data_generator.measure_templates) > 1:\n", + " query = f\"SELECT time, {measure_name}, {', '.join(dimension_template['name'] for dimension_template in data_generator.dimension_templates)} FROM \\\"{DATABASE_NAME}\\\".\\\"{TABLE_NAME}\\\" ORDER BY time ASC\"\n", + " elif len(data_generator.measure_templates) == 1:\n", + " query = f\"SELECT * FROM \\\"{DATABASE_NAME}\\\".\\\"{TABLE_NAME}\\\" ORDER BY time ASC\"\n", + " \n", + " panel = {\n", " \"datasource\": grafana_data_source_name,\n", " \"fieldConfig\": {\n", - " \"defaults\": {\n", - " \"color\": {\n", - " \"mode\": \"palette-classic\"\n", - " },\n", - " \"custom\": {\n", - " \"axisBorderShow\": False,\n", - " \"axisCenteredZero\": False,\n", - " \"axisColorMode\": \"text\",\n", - " \"axisLabel\": \"\",\n", - " \"axisPlacement\": \"auto\",\n", - " \"barAlignment\": 0,\n", - " \"barWidthFactor\": 0.6,\n", - " \"drawStyle\": \"line\",\n", - " \"fillOpacity\": 0,\n", - " \"gradientMode\": \"none\",\n", - " \"hideFrom\": {\n", - " \"legend\": False,\n", - " \"tooltip\": False,\n", - " \"viz\": False\n", - " },\n", - " \"insertNulls\": False,\n", - " \"lineInterpolation\": \"linear\",\n", - " \"lineWidth\": 1,\n", - " \"pointSize\": 5,\n", - " \"scaleDistribution\": {\n", - " \"type\": \"linear\"\n", - " },\n", - " \"showPoints\": \"auto\",\n", - " \"spanNulls\": False,\n", - " \"stacking\": {\n", - " \"group\": \"A\",\n", - " \"mode\": \"none\"\n", - " },\n", - " \"thresholdsStyle\": {\n", - " \"mode\": \"off\"\n", - " }\n", - " },\n", - " \"mappings\": [],\n", - " \"thresholds\": {\n", - " \"mode\": \"absolute\",\n", - " \"steps\": [\n", - " {\n", - " \"color\": \"green\",\n", - " \"value\": None\n", + " \"defaults\": {\n", + " \"color\": {\n", + " \"mode\": \"palette-classic\"\n", + " },\n", + " \"custom\": {\n", + " \"axisBorderShow\": False,\n", + " \"axisCenteredZero\": False,\n", + " \"axisColorMode\": \"text\",\n", + " \"axisLabel\": \"\",\n", + " \"axisPlacement\": \"auto\",\n", + " \"barAlignment\": 0,\n", + " \"barWidthFactor\": 0.6,\n", + " \"drawStyle\": \"line\",\n", + " \"fillOpacity\": 0,\n", + " \"gradientMode\": \"none\",\n", + " \"hideFrom\": {\n", + " \"legend\": False,\n", + " \"tooltip\": False,\n", + " \"viz\": False\n", + " },\n", + " \"insertNulls\": False,\n", + " \"lineInterpolation\": \"linear\",\n", + " \"lineWidth\": 1,\n", + " \"pointSize\": 5,\n", + " \"scaleDistribution\": {\n", + " \"type\": \"linear\"\n", + " },\n", + " \"showPoints\": \"auto\",\n", + " \"spanNulls\": False,\n", + " \"stacking\": {\n", + " \"group\": \"A\",\n", + " \"mode\": \"none\"\n", + " },\n", + " \"thresholdsStyle\": {\n", + " \"mode\": \"off\"\n", + " }\n", " },\n", - " {\n", - " \"color\": \"red\",\n", - " \"value\": 80\n", + " \"mappings\": [],\n", + " \"thresholds\": {\n", + " \"mode\": \"absolute\",\n", + " \"steps\": [\n", + " {\n", + " \"color\": \"green\",\n", + " \"value\": None\n", + " },\n", + " {\n", + " \"color\": \"red\",\n", + " \"value\": 80\n", + " }\n", + " ]\n", " }\n", - " ]\n", - " }\n", - " },\n", - " \"overrides\": []\n", + " },\n", + " \"overrides\": []\n", " },\n", " \"gridPos\": {\n", - " \"h\": 22,\n", - " \"w\": 20,\n", - " \"x\": 0,\n", - " \"y\": 0\n", + " \"h\": 22,\n", + " \"w\": 20,\n", + " \"x\": 0,\n", + " \"y\": 0\n", " },\n", - " \"id\": 1,\n", + " \"id\": i + 1,\n", " \"options\": {\n", - " \"legend\": {\n", - " \"calcs\": [],\n", - " \"displayMode\": \"list\",\n", - " \"placement\": \"bottom\",\n", - " \"showLegend\": True\n", - " },\n", - " \"tooltip\": {\n", - " \"mode\": \"single\",\n", - " \"sort\": \"none\"\n", - " }\n", + " \"legend\": {\n", + " \"calcs\": [],\n", + " \"displayMode\": \"list\",\n", + " \"placement\": \"bottom\",\n", + " \"showLegend\": True\n", + " },\n", + " \"tooltip\": {\n", + " \"mode\": \"single\",\n", + " \"sort\": \"none\"\n", + " }\n", " },\n", " \"targets\": [\n", - " {\n", - " \"datasource\": grafana_data_source_name,\n", - " \"format\": 1,\n", - " \"hide\": False,\n", - " \"measure\": \"\",\n", - " \"rawQuery\": f\"SELECT * FROM \\\"{DATABASE_NAME}\\\".\\\"{TABLE_NAME}\\\" ORDER BY time ASC\",\n", - " \"refId\": \"A\"\n", - " }\n", + " {\n", + " \"datasource\": grafana_data_source_name,\n", + " \"format\": 1,\n", + " \"hide\": False,\n", + " \"measure\": \"\",\n", + " \"rawQuery\": query,\n", + " \"refId\": \"A\"\n", + " }\n", " ],\n", - " \"title\": f\"{DATABASE_NAME}.{TABLE_NAME} Data\",\n", + " \"title\": f\"{measure_name}\",\n", " \"type\": \"timeseries\"\n", - " }\n", - " ],\n", + " }\n", + " panels.append(panel)\n", + "\n", + "\n", + "dashboard = {\n", + " \"annotations\": {\n", + " \"list\": [\n", + " {\n", + " \"builtIn\": 1,\n", + " \"datasource\": {\n", + " \"type\": \"grafana\",\n", + " \"uid\": \"-- Grafana --\"\n", + " },\n", + " \"enable\": True,\n", + " \"hide\": True,\n", + " \"iconColor\": \"rgba(0, 211, 255, 1)\",\n", + " \"name\": \"Annotations & Alerts\",\n", + " \"type\": \"dashboard\"\n", + " }\n", + " ]\n", + " },\n", + " \"editable\": True,\n", + " \"fiscalYearStartMonth\": 0,\n", + " \"graphTooltip\": 0,\n", + " \"links\": [],\n", + " \"panels\": panels,\n", " \"schemaVersion\": 39,\n", " \"tags\": [],\n", " \"templating\": {\n", - " \"list\": []\n", + " \"list\": []\n", " },\n", " \"time\": {\n", - " \"from\": \"now-15m\",\n", - " \"to\": \"now\"\n", + " \"from\": \"now-15m\",\n", + " \"to\": \"now\"\n", " },\n", " \"timepicker\": {},\n", " \"timezone\": \"\",\n",