From afdb674068d37775bbf63aab15b6ff5943701e82 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Singh Date: Mon, 2 Feb 2026 23:37:14 +0530 Subject: [PATCH] test(integration): added fixture for inserting alert data (#10101) * chore: fixture for notification channel * chore: return notification channel info in Create notification channel API * fix: change scope of create channel fixture to function level * test: added fixture for creating alert rule * chore: added debug message on assertion failure * refactor: improve error handling in webhook notification channel deletion * fix: enhance error handling in alert rule creation and deletion * chore: ran py linter and fmt * chore: ran py linter and fmt * fix: add timeout to alert rule creation and deletion requests * fix: silenced pylint on too broad exception * fix: suppress pylint warnings for broad exception handling in alert rule deletion * test: added fixture for inserting alert data * refactor: added fixture for getting test data file path * feat: add alerts to integration CI workflow * chore: linter fixes * chore: changed scope for get_testdata_file_path * chore: py-formatter * chore: py-formatter * chore: updated get_testdata_file_path fixture to a util function * chore: removed wrong ref --------- Co-authored-by: Srikanth Chekuri --- tests/integration/fixtures/alerts.py | 58 +++++++++++++++++- tests/integration/fixtures/logs.py | 54 +++++++++++++++++ tests/integration/fixtures/metrics.py | 16 ++--- tests/integration/fixtures/traces.py | 86 +++++++++++++++++++++++++++ tests/integration/fixtures/types.py | 13 +++- tests/integration/fixtures/utils.py | 33 ++++++++++ tests/integration/pyproject.toml | 1 + tests/integration/uv.lock | 11 ++++ 8 files changed, 258 insertions(+), 14 deletions(-) create mode 100644 tests/integration/fixtures/utils.py diff --git a/tests/integration/fixtures/alerts.py b/tests/integration/fixtures/alerts.py index 680955b4a6..98424fe90f 100644 --- a/tests/integration/fixtures/alerts.py +++ b/tests/integration/fixtures/alerts.py @@ -1,5 +1,6 @@ +from datetime import datetime, timezone from http import HTTPStatus -from typing import Callable +from typing import Callable, List import pytest import requests @@ -7,6 +8,10 @@ import requests from fixtures import types from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD from fixtures.logger import setup_logger +from fixtures.logs import Logs +from fixtures.metrics import Metrics +from fixtures.traces import Traces +from fixtures.utils import get_testdata_file_path logger = setup_logger(__name__) @@ -52,3 +57,54 @@ def create_alert_rule( _delete_alert_rule(rule_id) except Exception as e: # pylint: disable=broad-exception-caught logger.error("Error deleting rule: %s", {"rule_id": rule_id, "error": e}) + + +@pytest.fixture(name="insert_alert_data", scope="function") +def insert_alert_data( + insert_metrics: Callable[[List[Metrics]], None], + insert_traces: Callable[[List[Traces]], None], + insert_logs: Callable[[List[Logs]], None], +) -> Callable[[List[types.AlertData]], None]: + + def _insert_alert_data( + alert_data_items: List[types.AlertData], + base_time: datetime = None, + ) -> None: + + metrics: List[Metrics] = [] + traces: List[Traces] = [] + logs: List[Logs] = [] + + now = base_time or datetime.now(tz=timezone.utc).replace( + second=0, microsecond=0 + ) + + for data_item in alert_data_items: + if data_item.type == "metrics": + _metrics = Metrics.load_from_file( + get_testdata_file_path(data_item.data_path), + base_time=now, + ) + metrics.extend(_metrics) + elif data_item.type == "traces": + _traces = Traces.load_from_file( + get_testdata_file_path(data_item.data_path), + base_time=now, + ) + traces.extend(_traces) + elif data_item.type == "logs": + _logs = Logs.load_from_file( + get_testdata_file_path(data_item.data_path), + base_time=now, + ) + logs.extend(_logs) + + # Add data to ClickHouse if any data is present + if len(metrics) > 0: + insert_metrics(metrics) + if len(traces) > 0: + insert_traces(traces) + if len(logs) > 0: + insert_logs(logs) + + yield _insert_alert_data diff --git a/tests/integration/fixtures/logs.py b/tests/integration/fixtures/logs.py index 44f5e56418..916ea50de7 100644 --- a/tests/integration/fixtures/logs.py +++ b/tests/integration/fixtures/logs.py @@ -9,6 +9,7 @@ from ksuid import KsuidMs from fixtures import types from fixtures.fingerprint import LogsOrTracesFingerprint +from fixtures.utils import parse_timestamp class LogsResource(ABC): @@ -329,6 +330,59 @@ class Logs(ABC): ] ) + @classmethod + def from_dict( + cls, + data: dict, + ) -> "Logs": + """Create a Logs instance from a dict.""" + # parse timestamp from iso format + timestamp = parse_timestamp(data["timestamp"]) + return cls( + timestamp=timestamp, + resources=data.get("resources", {}), + attributes=data.get("attributes", {}), + body=data["body"], + severity_text=data.get("severity_text", "INFO"), + ) + + @classmethod + def load_from_file( + cls, + file_path: str, + base_time: Optional[datetime.datetime] = None, + ) -> List["Logs"]: + """Load logs from a JSONL file.""" + + data_list = [] + with open(file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + data_list.append(json.loads(line)) + + # If base_time provided, calculate time offset + time_offset = datetime.timedelta(0) + if base_time is not None: + # Find earliest timestamp + earliest = None + for data in data_list: + ts = parse_timestamp(data["timestamp"]) + if earliest is None or ts < earliest: + earliest = ts + if earliest is not None: + time_offset = base_time - earliest + + logs = [] + for data in data_list: + original_ts = parse_timestamp(data["timestamp"]) + adjusted_ts = original_ts + time_offset + data["timestamp"] = adjusted_ts.isoformat() + logs.append(cls.from_dict(data)) + + return logs + @pytest.fixture(name="insert_logs", scope="function") def insert_logs( diff --git a/tests/integration/fixtures/metrics.py b/tests/integration/fixtures/metrics.py index ca32cb4f28..7973dabe75 100644 --- a/tests/integration/fixtures/metrics.py +++ b/tests/integration/fixtures/metrics.py @@ -8,6 +8,7 @@ import numpy as np import pytest from fixtures import types +from fixtures.utils import parse_timestamp class MetricsTimeSeries(ABC): @@ -341,10 +342,7 @@ class Metrics(ABC): metric_name_override: If provided, overrides the metric_name from data """ # parse timestamp from iso format - ts_str = data["timestamp"] - if ts_str.endswith("Z"): - ts_str = ts_str[:-1] + "+00:00" - timestamp = datetime.datetime.fromisoformat(ts_str) + timestamp = parse_timestamp(data["timestamp"]) return cls( metric_name=metric_name_override or data["metric_name"], @@ -397,10 +395,7 @@ class Metrics(ABC): # Find earliest timestamp earliest = None for data in data_list: - ts_str = data["timestamp"] - if ts_str.endswith("Z"): - ts_str = ts_str[:-1] + "+00:00" - ts = datetime.datetime.fromisoformat(ts_str) + ts = parse_timestamp(data["timestamp"]) if earliest is None or ts < earliest: earliest = ts if earliest is not None: @@ -408,10 +403,7 @@ class Metrics(ABC): metrics = [] for data in data_list: - ts_str = data["timestamp"] - if ts_str.endswith("Z"): - ts_str = ts_str[:-1] + "+00:00" - original_ts = datetime.datetime.fromisoformat(ts_str) + original_ts = parse_timestamp(data["timestamp"]) adjusted_ts = original_ts + time_offset data["timestamp"] = adjusted_ts.isoformat() metrics.append( diff --git a/tests/integration/fixtures/traces.py b/tests/integration/fixtures/traces.py index fa3da198ee..15ed8b6ca1 100644 --- a/tests/integration/fixtures/traces.py +++ b/tests/integration/fixtures/traces.py @@ -13,6 +13,7 @@ import pytest from fixtures import types from fixtures.fingerprint import LogsOrTracesFingerprint +from fixtures.utils import parse_duration, parse_timestamp class TracesKind(Enum): @@ -23,12 +24,20 @@ class TracesKind(Enum): SPAN_KIND_PRODUCER = 4 SPAN_KIND_CONSUMER = 5 + @classmethod + def from_value(cls, value: int) -> "TracesKind": + return cls(value) + class TracesStatusCode(Enum): STATUS_CODE_UNSET = 0 STATUS_CODE_OK = 1 STATUS_CODE_ERROR = 2 + @classmethod + def from_value(cls, value: int) -> "TracesStatusCode": + return cls(value) + class TracesRefType(Enum): REF_TYPE_CHILD_OF = "CHILD_OF" @@ -602,6 +611,83 @@ class Traces(ABC): dtype=object, ) + @classmethod + def from_dict( + cls, + data: dict, + ) -> "Traces": + """Create a Traces instance from a dict.""" + # parse timestamp from iso format + timestamp = parse_timestamp(data["timestamp"]) + duration = parse_duration(data.get("duration", "PT1S")) + + kind = TracesKind.from_value( + data.get("kind", TracesKind.SPAN_KIND_INTERNAL.value) + ) + status_code = TracesStatusCode.from_value( + data.get("status_code", TracesStatusCode.STATUS_CODE_UNSET.value) + ) + + return cls( + timestamp=timestamp, + duration=duration, + trace_id=data["trace_id"], + span_id=data["span_id"], + parent_span_id=data.get("parent_span_id", ""), + name=data.get("name", "default span"), + kind=kind, + status_code=status_code, + status_message=data.get("status_message", ""), + resources=data.get("resources", {}), + attributes=data.get("attributes", {}), + trace_state=data.get("trace_state", ""), + flags=data.get("flags", 0), + ) + + @classmethod + def load_from_file( + cls, + file_path: str, + base_time: Optional[datetime.datetime] = None, + ) -> List["Traces"]: + """Load traces from a JSONL file.""" + + data_list = [] + with open(file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + data_list.append(json.loads(line)) + + if not data_list: + return [] + + # If base_time provided, calculate time offset + time_offset = datetime.timedelta(0) + if base_time is not None: + # Find earliest timestamp + earliest = None + for data in data_list: + ts = parse_timestamp(data["timestamp"]) + if earliest is None or ts < earliest: + earliest = ts + if earliest is not None: + time_offset = base_time - earliest + + traces = [] + for data in data_list: + # add time offset to timestamp + original_ts = parse_timestamp(data["timestamp"]) + duration = parse_duration(data.get("duration", "PT1S")) + adjusted_ts = original_ts + time_offset + data["timestamp"] = adjusted_ts.isoformat() + # parse duration of the span + data["duration"] = duration + traces.append(cls.from_dict(data)) + + return traces + @pytest.fixture(name="insert_traces", scope="function") def insert_traces( diff --git a/tests/integration/fixtures/types.py b/tests/integration/fixtures/types.py index 1258ecbc9d..6ea711146a 100644 --- a/tests/integration/fixtures/types.py +++ b/tests/integration/fixtures/types.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict +from typing import Dict, Literal from urllib.parse import urljoin import clickhouse_connect @@ -162,3 +162,14 @@ class Network: def __log__(self) -> str: return f"Network(id={self.id}, name={self.name})" + + +# Alerts related types + + +@dataclass(frozen=True) +class AlertData: + # type of the alert data, one of 'metrics', 'logs', 'traces' + type: Literal["metrics", "logs", "traces"] + # path to the data file in testdata directory + data_path: str diff --git a/tests/integration/fixtures/utils.py b/tests/integration/fixtures/utils.py new file mode 100644 index 0000000000..678c18018c --- /dev/null +++ b/tests/integration/fixtures/utils.py @@ -0,0 +1,33 @@ +import datetime +import os +from typing import Any + +import isodate + + +# parses the given timestamp string from ISO format to datetime.datetime +def parse_timestamp(ts_str: str) -> datetime.datetime: + """ + Parse a timestamp string from ISO format. + """ + if ts_str.endswith("Z"): + ts_str = ts_str[:-1] + "+00:00" + return datetime.datetime.fromisoformat(ts_str) + + +# parses the given duration to datetime.timedelta +def parse_duration(duration: Any) -> datetime.timedelta: + """ + Parse a duration string from ISO format. + """ + # if it's string then parse it as iso format + if isinstance(duration, str): + return isodate.parse_duration(duration) + if isinstance(duration, datetime.timedelta): + return duration + return datetime.timedelta(seconds=duration) + + +def get_testdata_file_path(file: str) -> str: + testdata_dir = os.path.join(os.path.dirname(__file__), "..", "testdata") + return os.path.join(testdata_dir, file) diff --git a/tests/integration/pyproject.toml b/tests/integration/pyproject.toml index 8dea318d47..b00a7e781a 100644 --- a/tests/integration/pyproject.toml +++ b/tests/integration/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "requests>=2.32.4", "sqlalchemy>=2.0.43", "selenium>=4.40.0", + "isodate>=0.7.2", ] [dependency-groups] diff --git a/tests/integration/uv.lock b/tests/integration/uv.lock index ba7eb1b299..c67f302ee8 100644 --- a/tests/integration/uv.lock +++ b/tests/integration/uv.lock @@ -450,6 +450,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "clickhouse-connect" }, + { name = "isodate" }, { name = "numpy" }, { name = "psycopg2" }, { name = "pytest" }, @@ -472,6 +473,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "clickhouse-connect", specifier = ">=0.8.18" }, + { name = "isodate", specifier = ">=0.7.2" }, { name = "numpy", specifier = ">=2.3.2" }, { name = "psycopg2", specifier = ">=2.9.10" }, { name = "pytest", specifier = ">=8.3.5" }, @@ -491,6 +493,15 @@ dev = [ { name = "pylint", specifier = ">=3.3.6" }, ] +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + [[package]] name = "isort" version = "7.0.0"