From 9211bd5b5d533b0ef565aa2f8e7218be4049af34 Mon Sep 17 00:00:00 2001 From: Adam Bernier Date: Sun, 16 Aug 2026 09:22:25 -0700 Subject: [PATCH 1/5] Add InfluxDB importer --- README.md | 2 +- docs/INFLUXDB.md | 80 +++++++++++++++++ docs/README.md | 1 + examples/influxdb/otava.yaml | 23 +++++ otava/config.py | 5 ++ otava/importer.py | 96 ++++++++++++++++++++ otava/influxdb.py | 67 ++++++++++++++ otava/test_config.py | 80 +++++++++++++++++ pyproject.toml | 1 + tests/cli_help_test.py | 90 ++++++++++++++++++- tests/influxdb_test.py | 168 +++++++++++++++++++++++++++++++++++ uv.lock | 86 +++++++++++++++++- 12 files changed, 692 insertions(+), 7 deletions(-) create mode 100644 docs/INFLUXDB.md create mode 100644 examples/influxdb/otava.yaml create mode 100644 otava/influxdb.py create mode 100644 tests/influxdb_test.py diff --git a/README.md b/README.md index 7726d139..7f68e94d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Apache Otava – Change Detection for Continuous Performance Engineering Apache Otava (incubating) performs statistical analysis of performance test results stored -in CSV files, PostgreSQL, BigQuery, or Graphite database. It finds change-points and notifies about +in CSV files, PostgreSQL, BigQuery, InfluxDB 3, or Graphite database. It finds change-points and notifies about possible performance regressions. A typical use-case of otava is as follows: diff --git a/docs/INFLUXDB.md b/docs/INFLUXDB.md new file mode 100644 index 00000000..8f24635b --- /dev/null +++ b/docs/INFLUXDB.md @@ -0,0 +1,80 @@ + + +# Importing results from InfluxDB 3 + +Otava imports query results from InfluxDB 3 Core or Enterprise through the +[`influxdb3-python`](https://docs.influxdata.com/influxdb3/core/reference/client-libraries/v3/python/) +client. SQL is the default query language; set `query_language: influxql` for +InfluxQL queries. + +## Connection + +```yaml +influxdb: + host: http://localhost:8181 + database: performance + token: ${INFLUXDB_TOKEN} +``` + +The same settings are available through `INFLUXDB_HOST`, `INFLUXDB_DATABASE`, +and `INFLUXDB_TOKEN`, or the `--influxdb-host`, `--influxdb-database`, and +`--influxdb-token` command-line options. Command-line values take precedence +over environment variables, which take precedence over YAML. + +## Test configuration + +```yaml +tests: + api_latency: + type: influxdb + query_language: sql + query: | + SELECT time, branch, p95_ms, commit + FROM api_latency + WHERE branch = %{BRANCH} + ORDER BY time + time_column: time + attributes: [branch, commit] + metrics: + p95: + column: p95_ms + direction: -1 + scale: 1 + + legacy_api_latency: + type: influxdb + query_language: influxql + query: SELECT time, branch, p95_ms FROM api_latency WHERE branch = %{BRANCH} + attributes: [branch] + metrics: [p95_ms] +``` + +Metric definitions use `column`, `direction`, and `scale` as with the other +SQL-backed importers. `%{BRANCH}` is replaced with an escaped string literal +when `--branch` is supplied. + +Run the analysis with: + +```bash +otava analyze api_latency --branch main --last 100 +``` + +InfluxDB is import-only in this release; Otava does not write change points +back to InfluxDB. diff --git a/docs/README.md b/docs/README.md index 2d8b9700..37da1c99 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,4 +32,5 @@ - [PostgreSQL](POSTGRESQL.md) - [BigQuery](BIG_QUERY.md) - [CSV](CSV.md) +- [InfluxDB](INFLUXDB.md) - [Annotating Change Points in Grafana](GRAFANA.md) diff --git a/examples/influxdb/otava.yaml b/examples/influxdb/otava.yaml new file mode 100644 index 00000000..63ffcbdc --- /dev/null +++ b/examples/influxdb/otava.yaml @@ -0,0 +1,23 @@ +# InfluxDB 3 connection settings can also be supplied with INFLUXDB_HOST, +# INFLUXDB_DATABASE, and INFLUXDB_TOKEN. +influxdb: + host: http://localhost:8181 + database: performance + token: ${INFLUXDB_TOKEN} + +tests: + api_latency: + type: influxdb + query_language: sql + query: | + SELECT time, branch, p95_ms, commit + FROM api_latency + WHERE branch = %{BRANCH} + ORDER BY time + time_column: time + attributes: [branch, commit] + metrics: + p95: + column: p95_ms + direction: -1 + scale: 1 diff --git a/otava/config.py b/otava/config.py index 11e70214..ae48ad42 100644 --- a/otava/config.py +++ b/otava/config.py @@ -25,6 +25,7 @@ from otava.bigquery import BigQueryConfig from otava.grafana import GrafanaConfig from otava.graphite import GraphiteConfig +from otava.influxdb import InfluxDBConfig from otava.postgres import PostgresConfig from otava.slack import SlackConfig from otava.test_config import TestConfig, create_test_config @@ -40,6 +41,7 @@ class Config: slack: SlackConfig postgres: PostgresConfig bigquery: BigQueryConfig + influxdb: InfluxDBConfig @dataclass @@ -115,6 +117,7 @@ def load_config_from_parser_args(args: configargparse.Namespace) -> Config: slack=SlackConfig.from_parser_args(args), postgres=PostgresConfig.from_parser_args(args), bigquery=BigQueryConfig.from_parser_args(args), + influxdb=InfluxDBConfig.from_parser_args(args), tests=tests, test_groups=groups, ) @@ -133,6 +136,7 @@ class NestedYAMLConfigFileParser(configargparse.ConfigFileParser): SlackConfig.NAME, PostgresConfig.NAME, BigQueryConfig.NAME, + InfluxDBConfig.NAME, ] def parse(self, stream): @@ -180,6 +184,7 @@ def add_service_option_groups(parser) -> None: SlackConfig.add_parser_args(parser.add_argument_group('Slack Options', 'Options for Slack configuration')) PostgresConfig.add_parser_args(parser.add_argument_group('PostgreSQL Options', 'Options for PostgreSQL configuration')) BigQueryConfig.add_parser_args(parser.add_argument_group('BigQuery Options', 'Options for BigQuery configuration')) + InfluxDBConfig.add_parser_args(parser.add_argument_group('InfluxDB Options', 'Options for InfluxDB 3 configuration')) def argument_group(parser, title: str): diff --git a/otava/importer.py b/otava/importer.py index c6cea32d..271f3d8a 100644 --- a/otava/importer.py +++ b/otava/importer.py @@ -30,6 +30,7 @@ from otava.config import Config from otava.data_selector import DataSelector from otava.graphite import DataPoint, Graphite, GraphiteError +from otava.influxdb import InfluxDB from otava.postgres import Postgres from otava.series import Metric, Series from otava.test_config import ( @@ -39,6 +40,8 @@ CsvTestConfig, GraphiteTestConfig, HistoStatTestConfig, + InfluxDBMetric, + InfluxDBTestConfig, JsonTestConfig, PostgresMetric, PostgresTestConfig, @@ -827,6 +830,90 @@ def fetch_all_metric_names(self, test_conf: BigQueryTestConfig) -> List[str]: return [m for m in test_conf.metrics.keys()] +class InfluxDBImporter(Importer): + def __init__(self, influxdb: InfluxDB): + self.__influxdb = influxdb + + @staticmethod + def __selected_metrics( + defined_metrics: Dict[str, InfluxDBMetric], selected_metrics: Optional[List[str]] + ) -> Dict[str, InfluxDBMetric]: + if selected_metrics is not None: + return {name: defined_metrics[name] for name in selected_metrics} + return defined_metrics + + def fetch_data(self, test_conf: TestConfig, selector: DataSelector = DataSelector()) -> Series: + if not isinstance(test_conf, InfluxDBTestConfig): + raise ValueError("Expected InfluxDBTestConfig") + + since_time = selector.since_time + until_time = selector.until_time + if since_time.timestamp() > until_time.timestamp(): + raise DataImportError( + f"Invalid time range: [{format_timestamp(int(since_time.timestamp()))}, " + f"{format_timestamp(int(until_time.timestamp()))}]" + ) + + metrics = self.__selected_metrics(test_conf.metrics, selector.metrics) + query = test_conf.query + if "%{BRANCH}" in query: + if not selector.branch: + raise DataImportError( + f"Test {test_conf.name} uses %{{BRANCH}} in query but --branch was not specified" + ) + branch_literal = "'" + selector.branch.replace("'", "''") + "'" + query = query.replace("%{BRANCH}", branch_literal) + + try: + columns, rows = self.__influxdb.fetch_data(query, test_conf.query_language) + except Exception as err: + raise DataImportError(f"Failed to import test {test_conf.name}: {err}") from err + + try: + time_index = columns.index(test_conf.time_column) + attr_indexes = [columns.index(column) for column in test_conf.attributes] + metric_names = [metric.name for metric in metrics.values()] + metric_indexes = [columns.index(metric.column) for metric in metrics.values()] + except ValueError as err: + raise DataImportError(f"Column not found {err.args[0]}") + + time = [] + data = {name: [] for name in metric_names} + attributes = {columns[index]: [] for index in attr_indexes} + for row in rows: + timestamp = row[time_index] + if timestamp < since_time or timestamp >= until_time: + continue + time.append(timestamp.timestamp()) + for name, index in zip(metric_names, metric_indexes): + try: + data[name].append(float(row[index])) + except (TypeError, ValueError) as err: + raise DataImportError( + f"Could not convert value in column {columns[index]}: {err}" + ) + for index in attr_indexes: + attributes[columns[index]].append(row[index]) + + metrics = {metric.name: Metric(metric.direction, metric.scale) for metric in metrics.values()} + time = time[-selector.last_n_points :] + data = {name: values[-selector.last_n_points :] for name, values in data.items()} + attributes = { + name: values[-selector.last_n_points :] for name, values in attributes.items() + } + return Series( + test_conf.name, + branch=selector.branch, + time=time, + metrics=metrics, + data=data, + attributes=attributes, + ) + + def fetch_all_metric_names(self, test_conf: InfluxDBTestConfig) -> List[str]: + return list(test_conf.metrics.keys()) + + class Importers: __config: Config __csv_importer: Optional[CsvImporter] @@ -835,6 +922,7 @@ class Importers: __postgres_importer: Optional[PostgresImporter] __json_importer: Optional[JsonImporter] __bigquery_importer: Optional[BigQueryImporter] + __influxdb_importer: Optional[InfluxDBImporter] def __init__(self, config: Config): self.__config = config @@ -844,6 +932,7 @@ def __init__(self, config: Config): self.__postgres_importer = None self.__json_importer = None self.__bigquery_importer = None + self.__influxdb_importer = None def csv_importer(self) -> CsvImporter: if self.__csv_importer is None: @@ -875,6 +964,11 @@ def bigquery_importer(self) -> BigQueryImporter: self.__bigquery_importer = BigQueryImporter(BigQuery(self.__config.bigquery)) return self.__bigquery_importer + def influxdb_importer(self) -> InfluxDBImporter: + if self.__influxdb_importer is None: + self.__influxdb_importer = InfluxDBImporter(InfluxDB(self.__config.influxdb)) + return self.__influxdb_importer + def get(self, test: TestConfig) -> Importer: if isinstance(test, CsvTestConfig): return self.csv_importer() @@ -888,5 +982,7 @@ def get(self, test: TestConfig) -> Importer: return self.json_importer() elif isinstance(test, BigQueryTestConfig): return self.bigquery_importer() + elif isinstance(test, InfluxDBTestConfig): + return self.influxdb_importer() else: raise ValueError(f"Unsupported test type {type(test)}") diff --git a/otava/influxdb.py b/otava/influxdb.py new file mode 100644 index 00000000..af5a53b8 --- /dev/null +++ b/otava/influxdb.py @@ -0,0 +1,67 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from dataclasses import dataclass + +from influxdb_client_3 import InfluxDBClient3 + + +@dataclass +class InfluxDBConfig: + NAME = "influxdb" + + host: str + database: str + token: str + + @staticmethod + def add_parser_args(arg_group): + arg_group.add_argument("--influxdb-host", help="InfluxDB 3 server URL", env_var="INFLUXDB_HOST") + arg_group.add_argument("--influxdb-database", help="InfluxDB 3 database name", env_var="INFLUXDB_DATABASE") + arg_group.add_argument("--influxdb-token", help="InfluxDB 3 database token", env_var="INFLUXDB_TOKEN") + + @staticmethod + def from_parser_args(args): + return InfluxDBConfig( + host=getattr(args, "influxdb_host", None), + database=getattr(args, "influxdb_database", None), + token=getattr(args, "influxdb_token", None), + ) + + +class InfluxDB: + def __init__(self, config: InfluxDBConfig): + self.config = config + self._client = None + + @property + def client(self) -> InfluxDBClient3: + if self._client is None: + self._client = InfluxDBClient3( + host=self.config.host, + database=self.config.database, + token=self.config.token, + ) + return self._client + + def fetch_data(self, query: str, language: str): + table = self.client.query(query=query, language=language) + columns = table.column_names + # Keep the public result contract consistent with the SQL importers: + # rows are positional tuples in the same order as ``columns``. + rows = [tuple(record[column] for column in columns) for record in table.to_pylist()] + return columns, rows diff --git a/otava/test_config.py b/otava/test_config.py index 5cc57c95..db24d422 100644 --- a/otava/test_config.py +++ b/otava/test_config.py @@ -199,6 +199,42 @@ def fully_qualified_metric_names(self) -> List[str]: return list(self.metrics.keys()) +@dataclass +class InfluxDBMetric: + name: str + direction: int + scale: float + column: str + + +@dataclass +class InfluxDBTestConfig(TestConfig): + query: str + time_column: str + attributes: List[str] + metrics: Dict[str, InfluxDBMetric] + query_language: str + + def __init__( + self, + name: str, + query: str, + time_column: str = "time", + metrics: List[InfluxDBMetric] = None, + attributes: List[str] = None, + query_language: str = "sql", + ): + self.name = name + self.query = query + self.time_column = time_column + self.metrics = {m.name: m for m in metrics} if metrics else {} + self.attributes = attributes if attributes is not None else [] + self.query_language = query_language + + def fully_qualified_metric_names(self) -> List[str]: + return list(self.metrics.keys()) + + def create_test_config(name: str, config: Dict) -> TestConfig: """ Loads properties of a test from a dictionary read from otava's config file @@ -217,6 +253,8 @@ def create_test_config(name: str, config: Dict) -> TestConfig: return create_postgres_test_config(name, config) elif test_type == "bigquery": return create_bigquery_test_config(name, config) + elif test_type == "influxdb": + return create_influxdb_test_config(name, config) elif test_type == "json": return create_json_test_config(name, config) elif test_type is None: @@ -371,6 +409,48 @@ def create_bigquery_test_config(test_name: str, test_info: Dict) -> BigQueryTest raise TestConfigError(f"Configuration key not found in test {test_name}: {e.args[0]}") +def create_influxdb_test_config(test_name: str, test_info: Dict) -> InfluxDBTestConfig: + try: + query = test_info["query"] + metrics_info = test_info["metrics"] + except KeyError as e: + raise TestConfigError(f"Configuration key not found in test {test_name}: {e.args[0]}") + + if not isinstance(metrics_info, (List, Dict)): + raise TestConfigError(f"Metrics of the test {test_name} must be a list or dictionary") + + metrics = [] + if isinstance(metrics_info, List): + metrics = [InfluxDBMetric(metric_name, 1, 1.0, metric_name) for metric_name in metrics_info] + else: + for metric_name, metric_conf in metrics_info.items(): + metrics.append( + InfluxDBMetric( + name=metric_name, + column=metric_conf.get("column", metric_name), + direction=int(metric_conf.get("direction", "1")), + scale=float(metric_conf.get("scale", "1")), + ) + ) + + attributes = test_info.get("attributes", []) + if not isinstance(attributes, List): + raise TestConfigError(f"Attributes of the test {test_name} must be a list") + query_language = test_info.get("query_language", "sql") + if query_language not in ("sql", "influxql"): + raise TestConfigError( + f"Query language of the test {test_name} must be `sql` or `influxql`" + ) + return InfluxDBTestConfig( + test_name, + query=query, + time_column=test_info.get("time_column", "time"), + metrics=metrics, + attributes=attributes, + query_language=query_language, + ) + + @dataclass class JsonTestConfig(TestConfig): name: str diff --git a/pyproject.toml b/pyproject.toml index cf9ad1b6..53e3762f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dependencies = [ "slack-sdk>=3.39.0", "google-cloud-bigquery>=3.38.0", "pg8000>=1.31.5", + "influxdb3-python>=0.20.0", "configargparse>=1.7.1", "pydantic>=2,<3", diff --git a/tests/cli_help_test.py b/tests/cli_help_test.py index 10605781..f2ce443d 100644 --- a/tests/cli_help_test.py +++ b/tests/cli_help_test.py @@ -56,6 +56,8 @@ def test_otava_help_output(): [--postgres-username POSTGRES_USERNAME] [--postgres-password POSTGRES_PASSWORD] [--postgres-database POSTGRES_DATABASE] [--bigquery-project-id BIGQUERY_PROJECT_ID] [--bigquery-dataset BIGQUERY_DATASET] [--bigquery-credentials BIGQUERY_CREDENTIALS] + [--influxdb-host INFLUXDB_HOST] [--influxdb-database INFLUXDB_DATABASE] + [--influxdb-token INFLUXDB_TOKEN] {list-tests,list-metrics,list-groups,analyze,remove-annotations,validate} ... Change Detection for Continuous Performance Engineering @@ -120,6 +122,16 @@ def test_otava_help_output(): --bigquery-credentials BIGQUERY_CREDENTIALS BigQuery credentials file [env var: BIGQUERY_VAULT_SECRET] +InfluxDB Options: + Options for InfluxDB 3 configuration + + --influxdb-host INFLUXDB_HOST + InfluxDB 3 server URL [env var: INFLUXDB_HOST] + --influxdb-database INFLUXDB_DATABASE + InfluxDB 3 database name [env var: INFLUXDB_DATABASE] + --influxdb-token INFLUXDB_TOKEN + InfluxDB 3 database token [env var: INFLUXDB_TOKEN] + Args that start with '--' can also be set in a config file (specified via --config-file). In general, command-line values override environment variables which override config file values which override defaults. @@ -156,8 +168,9 @@ def test_otava_analyze_help_output(): [--postgres-database POSTGRES_DATABASE] [--bigquery-project-id BIGQUERY_PROJECT_ID] [--bigquery-dataset BIGQUERY_DATASET] - [--bigquery-credentials BIGQUERY_CREDENTIALS] [--update-grafana] - [--update-postgres] [--update-bigquery] + [--bigquery-credentials BIGQUERY_CREDENTIALS] [--influxdb-host INFLUXDB_HOST] + [--influxdb-database INFLUXDB_DATABASE] [--influxdb-token INFLUXDB_TOKEN] + [--update-grafana] [--update-postgres] [--update-bigquery] [--notify-slack NOTIFY_SLACK [NOTIFY_SLACK ...]] [--cph-report-since DATE] [--output {{log,json,regressions_only}}] [--branch [STRING]] [--metrics LIST] {usage_filter_lines} @@ -265,6 +278,16 @@ def test_otava_analyze_help_output(): BigQuery credentials file [env var: BIGQUERY_VAULT_SECRET] --update-bigquery Update BigQuery database results with change points +InfluxDB Options: + Options for InfluxDB 3 configuration + + --influxdb-host INFLUXDB_HOST + InfluxDB 3 server URL [env var: INFLUXDB_HOST] + --influxdb-database INFLUXDB_DATABASE + InfluxDB 3 database name [env var: INFLUXDB_DATABASE] + --influxdb-token INFLUXDB_TOKEN + InfluxDB 3 database token [env var: INFLUXDB_TOKEN] + In general, command-line values override environment variables which override defaults. """ ) @@ -288,6 +311,8 @@ def test_otava_list_tests_help_output(): [--bigquery-project-id BIGQUERY_PROJECT_ID] [--bigquery-dataset BIGQUERY_DATASET] [--bigquery-credentials BIGQUERY_CREDENTIALS] + [--influxdb-host INFLUXDB_HOST] [--influxdb-database INFLUXDB_DATABASE] + [--influxdb-token INFLUXDB_TOKEN] [group ...] positional arguments: @@ -345,6 +370,16 @@ def test_otava_list_tests_help_output(): --bigquery-credentials BIGQUERY_CREDENTIALS BigQuery credentials file [env var: BIGQUERY_VAULT_SECRET] +InfluxDB Options: + Options for InfluxDB 3 configuration + + --influxdb-host INFLUXDB_HOST + InfluxDB 3 server URL [env var: INFLUXDB_HOST] + --influxdb-database INFLUXDB_DATABASE + InfluxDB 3 database name [env var: INFLUXDB_DATABASE] + --influxdb-token INFLUXDB_TOKEN + InfluxDB 3 database token [env var: INFLUXDB_TOKEN] + In general, command-line values override environment variables which override defaults. """ ) @@ -368,6 +403,8 @@ def test_otava_list_metrics_help_output(): [--bigquery-project-id BIGQUERY_PROJECT_ID] [--bigquery-dataset BIGQUERY_DATASET] [--bigquery-credentials BIGQUERY_CREDENTIALS] + [--influxdb-host INFLUXDB_HOST] [--influxdb-database INFLUXDB_DATABASE] + [--influxdb-token INFLUXDB_TOKEN] test positional arguments: @@ -425,6 +462,16 @@ def test_otava_list_metrics_help_output(): --bigquery-credentials BIGQUERY_CREDENTIALS BigQuery credentials file [env var: BIGQUERY_VAULT_SECRET] +InfluxDB Options: + Options for InfluxDB 3 configuration + + --influxdb-host INFLUXDB_HOST + InfluxDB 3 server URL [env var: INFLUXDB_HOST] + --influxdb-database INFLUXDB_DATABASE + InfluxDB 3 database name [env var: INFLUXDB_DATABASE] + --influxdb-token INFLUXDB_TOKEN + InfluxDB 3 database token [env var: INFLUXDB_TOKEN] + In general, command-line values override environment variables which override defaults. """ ) @@ -449,6 +496,8 @@ def test_otava_list_groups_help_output(): [--bigquery-project-id BIGQUERY_PROJECT_ID] [--bigquery-dataset BIGQUERY_DATASET] [--bigquery-credentials BIGQUERY_CREDENTIALS] + [--influxdb-host INFLUXDB_HOST] [--influxdb-database INFLUXDB_DATABASE] + [--influxdb-token INFLUXDB_TOKEN] options: -h, --help show this help message and exit @@ -502,6 +551,16 @@ def test_otava_list_groups_help_output(): --bigquery-credentials BIGQUERY_CREDENTIALS BigQuery credentials file [env var: BIGQUERY_VAULT_SECRET] +InfluxDB Options: + Options for InfluxDB 3 configuration + + --influxdb-host INFLUXDB_HOST + InfluxDB 3 server URL [env var: INFLUXDB_HOST] + --influxdb-database INFLUXDB_DATABASE + InfluxDB 3 database name [env var: INFLUXDB_DATABASE] + --influxdb-token INFLUXDB_TOKEN + InfluxDB 3 database token [env var: INFLUXDB_TOKEN] + In general, command-line values override environment variables which override defaults. """ ) @@ -525,7 +584,10 @@ def test_otava_remove_annotations_help_output(): [--postgres-database POSTGRES_DATABASE] [--bigquery-project-id BIGQUERY_PROJECT_ID] [--bigquery-dataset BIGQUERY_DATASET] - [--bigquery-credentials BIGQUERY_CREDENTIALS] [--force] + [--bigquery-credentials BIGQUERY_CREDENTIALS] + [--influxdb-host INFLUXDB_HOST] + [--influxdb-database INFLUXDB_DATABASE] + [--influxdb-token INFLUXDB_TOKEN] [--force] [tests ...] positional arguments: @@ -584,6 +646,16 @@ def test_otava_remove_annotations_help_output(): --bigquery-credentials BIGQUERY_CREDENTIALS BigQuery credentials file [env var: BIGQUERY_VAULT_SECRET] +InfluxDB Options: + Options for InfluxDB 3 configuration + + --influxdb-host INFLUXDB_HOST + InfluxDB 3 server URL [env var: INFLUXDB_HOST] + --influxdb-database INFLUXDB_DATABASE + InfluxDB 3 database name [env var: INFLUXDB_DATABASE] + --influxdb-token INFLUXDB_TOKEN + InfluxDB 3 database token [env var: INFLUXDB_TOKEN] + In general, command-line values override environment variables which override defaults. """ ) @@ -607,6 +679,8 @@ def test_otava_validate_help_output(): [--bigquery-project-id BIGQUERY_PROJECT_ID] [--bigquery-dataset BIGQUERY_DATASET] [--bigquery-credentials BIGQUERY_CREDENTIALS] + [--influxdb-host INFLUXDB_HOST] [--influxdb-database INFLUXDB_DATABASE] + [--influxdb-token INFLUXDB_TOKEN] options: -h, --help show this help message and exit @@ -660,6 +734,16 @@ def test_otava_validate_help_output(): --bigquery-credentials BIGQUERY_CREDENTIALS BigQuery credentials file [env var: BIGQUERY_VAULT_SECRET] +InfluxDB Options: + Options for InfluxDB 3 configuration + + --influxdb-host INFLUXDB_HOST + InfluxDB 3 server URL [env var: INFLUXDB_HOST] + --influxdb-database INFLUXDB_DATABASE + InfluxDB 3 database name [env var: INFLUXDB_DATABASE] + --influxdb-token INFLUXDB_TOKEN + InfluxDB 3 database token [env var: INFLUXDB_TOKEN] + In general, command-line values override environment variables which override defaults. """ ) diff --git a/tests/influxdb_test.py b/tests/influxdb_test.py new file mode 100644 index 00000000..6f5a2b86 --- /dev/null +++ b/tests/influxdb_test.py @@ -0,0 +1,168 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +import os +from datetime import datetime, timezone +from unittest.mock import Mock + +import pyarrow as pa +import pytest + +from otava.config import load_config_from_file +from otava.data_selector import DataSelector +from otava.importer import DataImportError, InfluxDBImporter +from otava.influxdb import InfluxDB, InfluxDBConfig +from otava.main import create_otava_cli_parser +from otava.test_config import ( + InfluxDBMetric, + InfluxDBTestConfig, + TestConfigError, + create_test_config, +) + + +def selector(): + result = DataSelector() + result.since_time = datetime(2024, 1, 1, tzinfo=timezone.utc) + result.until_time = datetime(2024, 1, 5, tzinfo=timezone.utc) + return result + + +def test_influxdb_connection_config_precedence(tmp_path, monkeypatch): + config_file = tmp_path / "otava.yaml" + config_file.write_text( + "influxdb:\n host: yaml-host\n database: yaml-db\n token: yaml-token\n" + ) + monkeypatch.setenv("INFLUXDB_HOST", "env-host") + monkeypatch.setenv("INFLUXDB_DATABASE", "env-db") + monkeypatch.setenv("INFLUXDB_TOKEN", "env-token") + + config = load_config_from_file( + str(config_file), + arg_overrides=["--influxdb-host", "cli-host", "--influxdb-token", "cli-token"], + ) + assert config.influxdb.host == "cli-host" + assert config.influxdb.database == "env-db" + assert config.influxdb.token == "cli-token" + assert os.environ["INFLUXDB_HOST"] == "env-host" + + +def test_cli_help_includes_influxdb_options(): + help_text = create_otava_cli_parser().format_help() + assert "InfluxDB Options:" in help_text + assert "--influxdb-host" in help_text + assert "--influxdb-database" in help_text + assert "--influxdb-token" in help_text + + +def test_influxdb_test_config_defaults_to_sql_and_parses_metrics(): + test = create_test_config( + "latency", + { + "type": "influxdb", + "query": "SELECT * FROM latency", + "attributes": ["branch"], + "metrics": {"p95": {"column": "p95_ms", "direction": -1, "scale": 0.001}}, + }, + ) + assert isinstance(test, InfluxDBTestConfig) + assert test.query_language == "sql" + assert test.metrics["p95"] == InfluxDBMetric("p95", -1, 0.001, "p95_ms") + + +def test_influxdb_test_config_supports_influxql_and_rejects_unknown_language(): + test = create_test_config( + "latency", + {"type": "influxdb", "query": "SELECT * FROM latency", "metrics": ["p95_ms"], "query_language": "influxql"}, + ) + assert test.query_language == "influxql" + with pytest.raises(TestConfigError): + create_test_config( + "latency", + {"type": "influxdb", "query": "SELECT * FROM latency", "metrics": ["p95_ms"], "query_language": "flux"}, + ) + + +def test_influxdb_importer_reads_arrow_table_and_applies_selection(): + client = Mock() + client.query.return_value = pa.table( + { + "time": [ + datetime(2023, 12, 31, tzinfo=timezone.utc), + datetime(2024, 1, 2, tzinfo=timezone.utc), + datetime(2024, 1, 3, tzinfo=timezone.utc), + datetime(2024, 1, 5, tzinfo=timezone.utc), + ], + "branch": ["main", "main", "main", "main"], + "commit": ["before", "b", "c", "after"], + "p95_ms": [10, 20, 30, 40], + } + ) + backend = InfluxDB(InfluxDBConfig("host", "database", "token")) + backend._client = client + test = InfluxDBTestConfig( + "latency", + "SELECT * FROM latency", + metrics=[InfluxDBMetric("p95", -1, 0.001, "p95_ms")], + attributes=["branch", "commit"], + ) + chosen = selector() + chosen.metrics = ["p95"] + chosen.last_n_points = 2 + series = InfluxDBImporter(backend).fetch_data(test, chosen) + + assert series.branch is None + assert series.data == {"p95": [20.0, 30.0]} + assert series.attributes == {"branch": ["main", "main"], "commit": ["b", "c"]} + assert client.query.call_args.kwargs == {"query": "SELECT * FROM latency", "language": "sql"} + + +def test_influxdb_importer_executes_influxql_and_escapes_branch(): + backend = Mock() + backend.fetch_data.return_value = ( + ["time", "p95_ms"], + [(datetime(2024, 1, 2, tzinfo=timezone.utc), 4)], + ) + test = InfluxDBTestConfig( + "latency", + "SELECT * FROM latency WHERE branch = %{BRANCH}", + query_language="influxql", + metrics=[InfluxDBMetric("p95", 1, 1.0, "p95_ms")], + ) + chosen = selector() + chosen.branch = "release'candidate" + series = InfluxDBImporter(backend).fetch_data(test, chosen) + assert series.data["p95"] == [4.0] + assert "branch = 'release''candidate'" in backend.fetch_data.call_args.args[0] + assert backend.fetch_data.call_args.args[1] == "influxql" + + +def test_influxdb_importer_reports_missing_columns_and_client_errors(): + test = InfluxDBTestConfig( + "latency", + "SELECT * FROM latency", + metrics=[InfluxDBMetric("p95", 1, 1.0, "missing")], + ) + backend = Mock() + backend.fetch_data.return_value = (["time"], []) + with pytest.raises(DataImportError) as missing_error: + InfluxDBImporter(backend).fetch_data(test, selector()) + assert missing_error.value.message == "Column not found 'missing' is not in list" + + backend.fetch_data.side_effect = RuntimeError("server unavailable") + with pytest.raises(DataImportError) as client_error: + InfluxDBImporter(backend).fetch_data( + InfluxDBTestConfig( + "latency", "SELECT * FROM latency", metrics=[InfluxDBMetric("p95", 1, 1.0, "p95_ms")] + ), + selector(), + ) + assert "latency" in client_error.value.message + assert "server unavailable" in client_error.value.message diff --git a/uv.lock b/uv.lock index 5802fbbb..9b08a4e2 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,7 @@ dependencies = [ { name = "configargparse" }, { name = "dateparser" }, { name = "google-cloud-bigquery" }, + { name = "influxdb3-python" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "pg8000" }, @@ -67,6 +68,7 @@ requires-dist = [ { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.38.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "influxdb3-python", specifier = ">=0.20.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "numpy", marker = "python_full_version < '3.14'", specifier = "==2.2.*" }, { name = "numpy", marker = "python_full_version >= '3.14'", specifier = ">=2.3.2,<2.4" }, @@ -405,7 +407,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -746,6 +748,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] +[[package]] +name = "influxdb3-python" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "pyarrow" }, + { name = "python-dateutil" }, + { name = "reactivex" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/f0/3fb234e83316e439a91d4d7ee5a2f569b9b81e5a709618c4168dca3a087b/influxdb3_python-0.20.0.tar.gz", hash = "sha256:f1e28c2f4f244d48006beeb82be7aea82ebdd3b3e1807250d124b6f1d53c5943", size = 102084, upload-time = "2026-06-11T06:49:53.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/2f/06961cdb1d38d0cdb3c6876970db227e69824a293896af6d1eb983fa1534/influxdb3_python-0.20.0-py3-none-any.whl", hash = "sha256:0914f05c2ed9b96f2962fb3d410068dda9c411b562dab22fae1d6c6599a37927", size = 86330, upload-time = "2026-06-11T06:49:52.016Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -1025,6 +1043,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, ] +[[package]] +name = "pyarrow" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/3e/5cd70becb51e1d044c54ba5e627424a6e87df5b98008cbd22cc6abd409ca/pyarrow-25.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0b1edbb2f385a6a65e9711b62ba86ac54a7816a3f8d17bb3e8a5929d65fb2485", size = 35954271, upload-time = "2026-08-10T12:36:33.857Z" }, + { url = "https://files.pythonhosted.org/packages/64/be/17599e086df264ea7dc221d1101e3131e181e00da428a2f9bd0358f0d06b/pyarrow-25.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:a4dd8bf99a8fac133efc0ed6a92f5fddbe2adba0d0f6dd720e39ba9855cea85c", size = 37647543, upload-time = "2026-08-10T12:36:39.486Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/e138b451fd3970a6eda4599f68ae3b2b32b661bc958de3239d54a0bf6575/pyarrow-25.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bddd0c4f7630c2a3ddf6347c1bdaa79d97bcf6bd445f9e60c816b7d77c85a5ae", size = 46837120, upload-time = "2026-08-10T12:36:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/57/5c/f8fc0eb2de03464a557d5a4d0c15e972d73362414696618833b771f7eddd/pyarrow-25.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a4d6d5e9a3d1879a97c08ded0c797579b7965eafd0f0c26c30b45ccc06db939b", size = 50066460, upload-time = "2026-08-10T12:36:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d1/0dd64fd06de0333b808a02f60981635f067b71aad3a30698a9a104fae778/pyarrow-25.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:514ddb60285631af068875550c90eddc181db3e8e63a032b1559be189e82f056", size = 49937892, upload-time = "2026-08-10T12:37:00.349Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3c/f89d1bd76d5f3284c2a44d7d7ebbd8204535e5ae2b41f4077069b4ff2ec6/pyarrow-25.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cab40b1edfef0262e0e5251aa2c58d75630f24d06dd7794480243acc001a1d7d", size = 53107240, upload-time = "2026-08-10T12:37:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/b554a8e09f3f3decccf405eb8fbe86696321cbcb5b62d18b4a5057a4c113/pyarrow-25.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:60e89d8f13861a1f7f8d950fa54aebb8023b30734d0ac51ffa80beabe2df4bba", size = 27848683, upload-time = "2026-08-10T12:37:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8b/0d23b47702fcfe8b3618d5292035099675c5a1c48258932350c08020f7b5/pyarrow-25.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:51093dd9e10325fbdb3c10a2ae7c4806e5c822d94e74ae4938b26524a3323fee", size = 35946180, upload-time = "2026-08-10T12:37:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/707d17a5476c55a9541fde0db8213ac30979a792864d72415f176ba50c45/pyarrow-25.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:eb6203482ff3746a5632303a7279ae0b5a304c46985b49ed1378cb350ea6728d", size = 37644787, upload-time = "2026-08-10T12:37:25.795Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/cdc98ecf1a6408280bc3a6a07054cdd99a3f4670acc0545d383ce113e87d/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:880523be3d29efcf83d3998835d206118ccf35e3871dbd2fb60408cf6b007a80", size = 46834633, upload-time = "2026-08-10T12:37:33.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/6e/d3fafc41f378b2c65be43b827798c0fae42049a641c8526633ed3eb573e2/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:25f8720bf6387d5dc2ebd2622112de630760419e4b66134405dd24110d15f37e", size = 50065507, upload-time = "2026-08-10T12:37:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/d5/12/8d0698954b8c3001844a898e0a6900bebe83d7ee40c11195174c5122f324/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4facd65742a024a4a366328a1d2292062d72d6e023c1b7dda8d4c37544933a25", size = 49955690, upload-time = "2026-08-10T12:37:46.644Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/1ecb936ac6409e90a34d58eea1c7cec09a9ae6d2141b9e49ad01a2b1ea47/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0559502e1cd6254d6814614085dd9c5a3dd0419362978a936a3f68a9e5c3df", size = 53128198, upload-time = "2026-08-10T12:37:52.531Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/5236033550633c9b7377b2a53660b2bbb06cb06dc09c4356332d67643ca1/pyarrow-25.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:62cd0d785b8aa6675ee355f9fc02252a340f4441257c42674937826fd7594325", size = 27857263, upload-time = "2026-08-10T12:37:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" }, + { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" }, + { url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" }, + { url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" }, + { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, +] + [[package]] name = "pyasn1" version = "0.6.4" @@ -1356,6 +1424,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "reactivex" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/c3/eeb429d774c135a8bebe2b8ac51f9639fde1953506f062b42b9ba6e44176/reactivex-5.1.0.tar.gz", hash = "sha256:b6b40269ebcbf24c53455c1b6790d682122cc8c01c907b8c8da47e2babb3b77e", size = 137788, upload-time = "2026-07-27T19:07:49.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/37/5b71117e68e5571c8c10942600eb02fb8c454c7347d08f8cddbdcde6ba6e/reactivex-5.1.0-py3-none-any.whl", hash = "sha256:8668c0a3c8ae8694f1180421b367489d35a8affa281a3605014bf54364eed3a0", size = 257317, upload-time = "2026-07-27T19:07:47.631Z" }, +] + [[package]] name = "regex" version = "2025.11.3" @@ -1590,7 +1670,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -1651,7 +1731,7 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.13'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883, upload-time = "2025-10-28T17:38:54.068Z" } From 2ac165c08312c5ea1dd06697b1a18acb35e057ea Mon Sep 17 00:00:00 2001 From: Adam Bernier Date: Sun, 23 Aug 2026 20:57:58 -0700 Subject: [PATCH 2/5] Fix InfluxDB query escaping and timestamps --- docs/INFLUXDB.md | 2 +- examples/influxdb/otava.yaml | 17 +++++++++++ otava/importer.py | 15 ++++++++-- tests/influxdb_test.py | 58 +++++++++++++++++++++++++++++------- 4 files changed, 79 insertions(+), 13 deletions(-) diff --git a/docs/INFLUXDB.md b/docs/INFLUXDB.md index 8f24635b..f7b6debb 100644 --- a/docs/INFLUXDB.md +++ b/docs/INFLUXDB.md @@ -5,7 +5,7 @@ regarding copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance - with the License. You may obtain a copy of the License at + with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/examples/influxdb/otava.yaml b/examples/influxdb/otava.yaml index 63ffcbdc..01b16ddc 100644 --- a/examples/influxdb/otava.yaml +++ b/examples/influxdb/otava.yaml @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + # InfluxDB 3 connection settings can also be supplied with INFLUXDB_HOST, # INFLUXDB_DATABASE, and INFLUXDB_TOKEN. influxdb: diff --git a/otava/importer.py b/otava/importer.py index 271f3d8a..05a65d39 100644 --- a/otava/importer.py +++ b/otava/importer.py @@ -20,7 +20,7 @@ from collections import OrderedDict from contextlib import contextmanager from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Dict, List, Optional, Set @@ -861,7 +861,16 @@ def fetch_data(self, test_conf: TestConfig, selector: DataSelector = DataSelecto raise DataImportError( f"Test {test_conf.name} uses %{{BRANCH}} in query but --branch was not specified" ) - branch_literal = "'" + selector.branch.replace("'", "''") + "'" + if test_conf.query_language == "influxql": + escaped_branch = ( + selector.branch.replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\r", "\\r") + .replace("\n", "\\n") + ) + else: + escaped_branch = selector.branch.replace("'", "''") + branch_literal = f"'{escaped_branch}'" query = query.replace("%{BRANCH}", branch_literal) try: @@ -882,6 +891,8 @@ def fetch_data(self, test_conf: TestConfig, selector: DataSelector = DataSelecto attributes = {columns[index]: [] for index in attr_indexes} for row in rows: timestamp = row[time_index] + if timestamp.tzinfo is None or timestamp.utcoffset() is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) if timestamp < since_time or timestamp >= until_time: continue time.append(timestamp.timestamp()) diff --git a/tests/influxdb_test.py b/tests/influxdb_test.py index 6f5a2b86..aea65c6a 100644 --- a/tests/influxdb_test.py +++ b/tests/influxdb_test.py @@ -7,6 +7,13 @@ # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. import os from datetime import datetime, timezone @@ -94,12 +101,15 @@ def test_influxdb_importer_reads_arrow_table_and_applies_selection(): client = Mock() client.query.return_value = pa.table( { - "time": [ - datetime(2023, 12, 31, tzinfo=timezone.utc), - datetime(2024, 1, 2, tzinfo=timezone.utc), - datetime(2024, 1, 3, tzinfo=timezone.utc), - datetime(2024, 1, 5, tzinfo=timezone.utc), - ], + "time": pa.array( + [ + datetime(2023, 12, 31), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + datetime(2024, 1, 5), + ], + type=pa.timestamp("ns"), + ), "branch": ["main", "main", "main", "main"], "commit": ["before", "b", "c", "after"], "p95_ms": [10, 20, 30, 40], @@ -119,12 +129,13 @@ def test_influxdb_importer_reads_arrow_table_and_applies_selection(): series = InfluxDBImporter(backend).fetch_data(test, chosen) assert series.branch is None + assert series.time == [1704153600.0, 1704240000.0] assert series.data == {"p95": [20.0, 30.0]} assert series.attributes == {"branch": ["main", "main"], "commit": ["b", "c"]} assert client.query.call_args.kwargs == {"query": "SELECT * FROM latency", "language": "sql"} -def test_influxdb_importer_executes_influxql_and_escapes_branch(): +def test_influxdb_importer_escapes_branch_for_sql(): backend = Mock() backend.fetch_data.return_value = ( ["time", "p95_ms"], @@ -133,15 +144,42 @@ def test_influxdb_importer_executes_influxql_and_escapes_branch(): test = InfluxDBTestConfig( "latency", "SELECT * FROM latency WHERE branch = %{BRANCH}", - query_language="influxql", metrics=[InfluxDBMetric("p95", 1, 1.0, "p95_ms")], ) chosen = selector() chosen.branch = "release'candidate" + series = InfluxDBImporter(backend).fetch_data(test, chosen) + assert series.data["p95"] == [4.0] - assert "branch = 'release''candidate'" in backend.fetch_data.call_args.args[0] - assert backend.fetch_data.call_args.args[1] == "influxql" + assert backend.fetch_data.call_args.args == ( + "SELECT * FROM latency WHERE branch = 'release''candidate'", + "sql", + ) + + +def test_influxdb_importer_escapes_branch_for_influxql(): + backend = Mock() + backend.fetch_data.return_value = ( + ["time", "p95_ms"], + [(datetime(2024, 1, 2, tzinfo=timezone.utc), 4)], + ) + test = InfluxDBTestConfig( + "latency", + "SELECT * FROM latency WHERE branch = %{BRANCH}", + query_language="influxql", + metrics=[InfluxDBMetric("p95", 1, 1.0, "p95_ms")], + ) + chosen = selector() + chosen.branch = "release'candidate\\path\r\nnext" + + series = InfluxDBImporter(backend).fetch_data(test, chosen) + + assert series.data["p95"] == [4.0] + assert backend.fetch_data.call_args.args == ( + "SELECT * FROM latency WHERE branch = 'release\\'candidate\\\\path\\r\\nnext'", + "influxql", + ) def test_influxdb_importer_reports_missing_columns_and_client_errors(): From 770c0653bfc02913d4c30cbf41eaf505a21d51dd Mon Sep 17 00:00:00 2001 From: Adam Bernier Date: Sun, 23 Aug 2026 21:27:58 -0700 Subject: [PATCH 3/5] Add reproducible InfluxDB example and E2E test --- docs/INFLUXDB.md | 21 ++++- examples/influxdb/admin-token.json | 5 ++ examples/influxdb/data.lp | 7 ++ examples/influxdb/docker-compose.yaml | 59 ++++++++++++++ examples/influxdb/otava.yaml | 18 ++++- examples/influxdb/seed.sh | 47 +++++++++++ tests/e2e_test_utils.py | 5 ++ tests/influxdb_e2e_test.py | 109 ++++++++++++++++++++++++++ 8 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 examples/influxdb/admin-token.json create mode 100644 examples/influxdb/data.lp create mode 100644 examples/influxdb/docker-compose.yaml create mode 100755 examples/influxdb/seed.sh create mode 100644 tests/influxdb_e2e_test.py diff --git a/docs/INFLUXDB.md b/docs/INFLUXDB.md index f7b6debb..a02dedc4 100644 --- a/docs/INFLUXDB.md +++ b/docs/INFLUXDB.md @@ -38,6 +38,25 @@ and `INFLUXDB_TOKEN`, or the `--influxdb-host`, `--influxdb-database`, and `--influxdb-token` command-line options. Command-line values take precedence over environment variables, which take precedence over YAML. +## Reproducible example + +The bundled example starts InfluxDB 3 Core with authenticated, in-memory +storage, seeds deterministic latency data, and runs Otava against it: + +```bash +docker build -t apache/otava:latest . +docker compose -f examples/influxdb/docker-compose.yaml run --rm otava \ + analyze api_latency_sql --branch main --since 2025-01-01 +docker compose -f examples/influxdb/docker-compose.yaml down +``` + +Run `api_latency_influxql` instead to query the same data with InfluxQL. + +The admin token committed under `examples/influxdb/` is a fixed test +credential, and the server discards its in-memory data when stopped. Both are +for this local demonstration only. Use a securely generated token and durable +object storage for production deployments. + ## Test configuration ```yaml @@ -73,7 +92,7 @@ when `--branch` is supplied. Run the analysis with: ```bash -otava analyze api_latency --branch main --last 100 +otava analyze api_latency_sql --branch main --last 100 ``` InfluxDB is import-only in this release; Otava does not write change points diff --git a/examples/influxdb/admin-token.json b/examples/influxdb/admin-token.json new file mode 100644 index 00000000..a10164c1 --- /dev/null +++ b/examples/influxdb/admin-token.json @@ -0,0 +1,5 @@ +{ + "token": "apiv3_otava_example_admin_token_2026", + "name": "otava-example-admin", + "description": "Test-only admin token for the reproducible Otava example" +} diff --git a/examples/influxdb/data.lp b/examples/influxdb/data.lp new file mode 100644 index 00000000..46205f1b --- /dev/null +++ b/examples/influxdb/data.lp @@ -0,0 +1,7 @@ +api_latency,branch=main,commit=a1b2c3d p95_ms=87.0 1735689600000000000 +api_latency,branch=release,commit=r1e2l3s p95_ms=105.0 1735689600000000000 +api_latency,branch=main,commit=b2c3d4e p95_ms=85.0 1735776000000000000 +api_latency,branch=main,commit=c3d4e5f p95_ms=89.0 1735862400000000000 +api_latency,branch=main,commit=d4e5f6a p95_ms=118.0 1735948800000000000 +api_latency,branch=main,commit=e5f6a7b p95_ms=121.0 1736035200000000000 +api_latency,branch=main,commit=f6a7b8c p95_ms=119.0 1736121600000000000 diff --git a/examples/influxdb/docker-compose.yaml b/examples/influxdb/docker-compose.yaml new file mode 100644 index 00000000..68f560a3 --- /dev/null +++ b/examples/influxdb/docker-compose.yaml @@ -0,0 +1,59 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +services: + influxdb: + image: influxdb:3.11.2-core + command: + - influxdb3 + - serve + - --node-id=otava-example + - --object-store=memory + - --admin-token-file=/run/secrets/admin-token + ports: + - "8181:8181" + secrets: + - admin-token + + seed: + image: influxdb:3.11.2-core + entrypoint: ["/bin/sh", "/example/seed.sh"] + depends_on: + - influxdb + environment: + INFLUXDB3_AUTH_TOKEN: apiv3_otava_example_admin_token_2026 + INFLUXDB3_DATABASE_NAME: performance + INFLUXDB3_HOST_URL: http://influxdb:8181 + volumes: + - .:/example:ro + + otava: + image: apache/otava:latest + depends_on: + seed: + condition: service_completed_successfully + environment: + INFLUXDB_HOST: http://influxdb:8181 + INFLUXDB_DATABASE: performance + INFLUXDB_TOKEN: apiv3_otava_example_admin_token_2026 + OTAVA_CONFIG: /config/otava.yaml + volumes: + - ./otava.yaml:/config/otava.yaml:ro + +secrets: + admin-token: + file: ./admin-token.json diff --git a/examples/influxdb/otava.yaml b/examples/influxdb/otava.yaml index 01b16ddc..9c302239 100644 --- a/examples/influxdb/otava.yaml +++ b/examples/influxdb/otava.yaml @@ -23,7 +23,7 @@ influxdb: token: ${INFLUXDB_TOKEN} tests: - api_latency: + api_latency_sql: type: influxdb query_language: sql query: | @@ -38,3 +38,19 @@ tests: column: p95_ms direction: -1 scale: 1 + + api_latency_influxql: + type: influxdb + query_language: influxql + query: | + SELECT time, branch, commit, p95_ms + FROM api_latency + WHERE branch = %{BRANCH} + ORDER BY time + time_column: time + attributes: [branch, commit] + metrics: + p95: + column: p95_ms + direction: -1 + scale: 1 diff --git a/examples/influxdb/seed.sh b/examples/influxdb/seed.sh new file mode 100755 index 00000000..3499fc72 --- /dev/null +++ b/examples/influxdb/seed.sh @@ -0,0 +1,47 @@ +#!/bin/sh + +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +set -eu + +: "${INFLUXDB3_AUTH_TOKEN:?INFLUXDB3_AUTH_TOKEN must be set}" + +INFLUXDB3_HOST_URL="${INFLUXDB3_HOST_URL:-http://influxdb:8181}" +INFLUXDB3_DATABASE_NAME="${INFLUXDB3_DATABASE_NAME:-performance}" +SEED_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +export INFLUXDB3_HOST_URL INFLUXDB3_DATABASE_NAME + +attempt=0 +until influxdb3 show databases --format csv >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 60 ]; then + echo "InfluxDB did not become ready at $INFLUXDB3_HOST_URL" >&2 + exit 1 + fi + sleep 1 +done + +if ! influxdb3 show databases --format csv | grep -Fqx "$INFLUXDB3_DATABASE_NAME"; then + influxdb3 create database "$INFLUXDB3_DATABASE_NAME" +fi + +influxdb3 write \ + --database "$INFLUXDB3_DATABASE_NAME" \ + --precision ns \ + --file "$SEED_DIR/data.lp" diff --git a/tests/e2e_test_utils.py b/tests/e2e_test_utils.py index 439dcac2..bb99440b 100644 --- a/tests/e2e_test_utils.py +++ b/tests/e2e_test_utils.py @@ -29,6 +29,7 @@ def container( image: str, *, + command: list[str] | None = None, env: dict[str, str] | None = None, ports: list[int] | None = None, volumes: dict[str, str] | None = None, @@ -39,6 +40,7 @@ def container( Args: image: Docker image to run (e.g., "postgres:latest"). + command: Optional command and arguments to run instead of the image default. env: Optional dict of environment variables to set in the container. ports: Optional list of container ports to publish (will be mapped to random host ports). volumes: Optional dict mapping host paths to container paths for volume mounts. @@ -76,6 +78,9 @@ def container( cmd.append(image) + if command: + cmd.extend(command) + # Start the container proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if proc.returncode != 0: diff --git a/tests/influxdb_e2e_test.py b/tests/influxdb_e2e_test.py new file mode 100644 index 00000000..88999e80 --- /dev/null +++ b/tests/influxdb_e2e_test.py @@ -0,0 +1,109 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from e2e_test_utils import container + +from otava.config import load_config_from_file +from otava.data_selector import DataSelector +from otava.importer import InfluxDBImporter +from otava.influxdb import InfluxDB + +INFLUXDB_IMAGE = "influxdb:3.11.2-core" +INFLUXDB_PORT = 8181 +INFLUXDB_TOKEN = "apiv3_otava_example_admin_token_2026" +EXAMPLE_DIR = Path("examples/influxdb").resolve() + + +def test_influxdb_sql_and_influxql_return_identical_seeded_data(): + with container( + INFLUXDB_IMAGE, + command=[ + "influxdb3", + "serve", + "--node-id=otava-e2e", + "--object-store=memory", + "--admin-token-file=/example/admin-token.json", + ], + ports=[INFLUXDB_PORT], + volumes={str(EXAMPLE_DIR): "/example:ro"}, + ) as (container_id, port_map): + seed = subprocess.run( + [ + "docker", + "exec", + "--env", + f"INFLUXDB3_HOST_URL=http://127.0.0.1:{INFLUXDB_PORT}", + "--env", + f"INFLUXDB3_AUTH_TOKEN={INFLUXDB_TOKEN}", + "--env", + "INFLUXDB3_DATABASE_NAME=performance", + container_id, + "/bin/sh", + "/example/seed.sh", + ], + capture_output=True, + text=True, + timeout=120, + ) + if seed.returncode != 0: + pytest.fail( + "InfluxDB seed command returned non-zero exit code.\n\n" + f"Command: {seed.args!r}\n" + f"Exit code: {seed.returncode}\n\n" + f"Stdout:\n{seed.stdout}\n\n" + f"Stderr:\n{seed.stderr}\n" + ) + + host = f"http://localhost:{port_map[INFLUXDB_PORT]}" + config = load_config_from_file( + str(EXAMPLE_DIR / "otava.yaml"), + arg_overrides=[ + "--influxdb-host", + host, + "--influxdb-token", + INFLUXDB_TOKEN, + ], + ) + importer = InfluxDBImporter(InfluxDB(config.influxdb)) + selector = DataSelector() + selector.branch = "main" + selector.since_time = datetime(2025, 1, 1, tzinfo=timezone.utc) + selector.until_time = datetime(2025, 1, 7, tzinfo=timezone.utc) + + sql = importer.fetch_data(config.tests["api_latency_sql"], selector) + influxql = importer.fetch_data(config.tests["api_latency_influxql"], selector) + + expected_times = [ + datetime(2025, 1, day, tzinfo=timezone.utc).timestamp() + for day in range(1, 7) + ] + expected_attributes = { + "branch": ["main"] * 6, + "commit": ["a1b2c3d", "b2c3d4e", "c3d4e5f", "d4e5f6a", "e5f6a7b", "f6a7b8c"], + } + expected_data = {"p95": [87.0, 85.0, 89.0, 118.0, 121.0, 119.0]} + + assert sql.branch == influxql.branch == "main" + assert sql.time == influxql.time == expected_times + assert sql.attributes == influxql.attributes == expected_attributes + assert sql.data == influxql.data == expected_data + assert sql.metrics == influxql.metrics From 611de523d0f9a6660786faf4bdc9f45169bbe57c Mon Sep 17 00:00:00 2001 From: Adam Bernier Date: Sun, 23 Aug 2026 21:31:24 -0700 Subject: [PATCH 4/5] Stabilize InfluxDB missing column errors --- otava/importer.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/otava/importer.py b/otava/importer.py index 05a65d39..05524059 100644 --- a/otava/importer.py +++ b/otava/importer.py @@ -878,13 +878,19 @@ def fetch_data(self, test_conf: TestConfig, selector: DataSelector = DataSelecto except Exception as err: raise DataImportError(f"Failed to import test {test_conf.name}: {err}") from err - try: - time_index = columns.index(test_conf.time_column) - attr_indexes = [columns.index(column) for column in test_conf.attributes] - metric_names = [metric.name for metric in metrics.values()] - metric_indexes = [columns.index(metric.column) for metric in metrics.values()] - except ValueError as err: - raise DataImportError(f"Column not found {err.args[0]}") + required_columns = [ + test_conf.time_column, + *test_conf.attributes, + *(metric.column for metric in metrics.values()), + ] + for column in required_columns: + if column not in columns: + raise DataImportError(f"Column not found {column!r} is not in list") + + time_index = columns.index(test_conf.time_column) + attr_indexes = [columns.index(column) for column in test_conf.attributes] + metric_names = [metric.name for metric in metrics.values()] + metric_indexes = [columns.index(metric.column) for metric in metrics.values()] time = [] data = {name: [] for name in metric_names} From 7c47ab5c6d7066763f2ab95bb0c95afa582f34a0 Mon Sep 17 00:00:00 2001 From: Adam Bernier Date: Mon, 24 Aug 2026 08:27:04 -0700 Subject: [PATCH 5/5] Test InfluxDB analysis through CLI --- docs/INFLUXDB.md | 2 +- tests/influxdb_e2e_test.py | 100 ++++++++++++++++++++++--------------- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/docs/INFLUXDB.md b/docs/INFLUXDB.md index a02dedc4..024131f1 100644 --- a/docs/INFLUXDB.md +++ b/docs/INFLUXDB.md @@ -61,7 +61,7 @@ object storage for production deployments. ```yaml tests: - api_latency: + api_latency_sql: type: influxdb query_language: sql query: | diff --git a/tests/influxdb_e2e_test.py b/tests/influxdb_e2e_test.py index 88999e80..32b1603b 100644 --- a/tests/influxdb_e2e_test.py +++ b/tests/influxdb_e2e_test.py @@ -15,17 +15,13 @@ # specific language governing permissions and limitations # under the License. +import os import subprocess -from datetime import datetime, timezone +import textwrap from pathlib import Path import pytest -from e2e_test_utils import container - -from otava.config import load_config_from_file -from otava.data_selector import DataSelector -from otava.importer import InfluxDBImporter -from otava.influxdb import InfluxDB +from e2e_test_utils import _remove_trailing_whitespaces, container INFLUXDB_IMAGE = "influxdb:3.11.2-core" INFLUXDB_PORT = 8181 @@ -33,6 +29,44 @@ EXAMPLE_DIR = Path("examples/influxdb").resolve() +def _analyze(test_name: str, host: str) -> str: + command = [ + "uv", + "run", + "otava", + "analyze", + test_name, + "--influxdb-host", + host, + "--influxdb-database", + "performance", + "--influxdb-token", + INFLUXDB_TOKEN, + "--branch", + "main", + "--since", + "2025-01-01T00:00:00Z", + "--until", + "2025-01-07T00:00:00Z", + ] + proc = subprocess.run( + command, + capture_output=True, + text=True, + timeout=600, + env=dict(os.environ, OTAVA_CONFIG=str(EXAMPLE_DIR / "otava.yaml")), + ) + if proc.returncode != 0: + pytest.fail( + "InfluxDB analysis command returned non-zero exit code.\n\n" + f"Command: {proc.args!r}\n" + f"Exit code: {proc.returncode}\n\n" + f"Stdout:\n{proc.stdout}\n\n" + f"Stderr:\n{proc.stderr}\n" + ) + return _remove_trailing_whitespaces(proc.stdout) + + def test_influxdb_sql_and_influxql_return_identical_seeded_data(): with container( INFLUXDB_IMAGE, @@ -73,37 +107,25 @@ def test_influxdb_sql_and_influxql_return_identical_seeded_data(): f"Stderr:\n{seed.stderr}\n" ) - host = f"http://localhost:{port_map[INFLUXDB_PORT]}" - config = load_config_from_file( - str(EXAMPLE_DIR / "otava.yaml"), - arg_overrides=[ - "--influxdb-host", - host, - "--influxdb-token", - INFLUXDB_TOKEN, - ], - ) - importer = InfluxDBImporter(InfluxDB(config.influxdb)) - selector = DataSelector() - selector.branch = "main" - selector.since_time = datetime(2025, 1, 1, tzinfo=timezone.utc) - selector.until_time = datetime(2025, 1, 7, tzinfo=timezone.utc) - - sql = importer.fetch_data(config.tests["api_latency_sql"], selector) - influxql = importer.fetch_data(config.tests["api_latency_influxql"], selector) + expected_output = textwrap.dedent( + """\ + time branch commit p95 + ------------------------- -------- -------- ----- + 2025-01-01 00:00:00 +0000 main a1b2c3d 87 + 2025-01-02 00:00:00 +0000 main b2c3d4e 85 + 2025-01-03 00:00:00 +0000 main c3d4e5f 89 + ····· + +37.2% + ····· + 2025-01-04 00:00:00 +0000 main d4e5f6a 118 + 2025-01-05 00:00:00 +0000 main e5f6a7b 121 + 2025-01-06 00:00:00 +0000 main f6a7b8c 119 + """ + ).rstrip("\n") - expected_times = [ - datetime(2025, 1, day, tzinfo=timezone.utc).timestamp() - for day in range(1, 7) + host = f"http://localhost:{port_map[INFLUXDB_PORT]}" + outputs = [ + _analyze(test_name, host) + for test_name in ("api_latency_sql", "api_latency_influxql") ] - expected_attributes = { - "branch": ["main"] * 6, - "commit": ["a1b2c3d", "b2c3d4e", "c3d4e5f", "d4e5f6a", "e5f6a7b", "f6a7b8c"], - } - expected_data = {"p95": [87.0, 85.0, 89.0, 118.0, 121.0, 119.0]} - - assert sql.branch == influxql.branch == "main" - assert sql.time == influxql.time == expected_times - assert sql.attributes == influxql.attributes == expected_attributes - assert sql.data == influxql.data == expected_data - assert sql.metrics == influxql.metrics + assert outputs == [expected_output, expected_output]