diff --git a/CHANGELOG.md b/CHANGELOG.md index e048e1d..207d90c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. +## [v0.0.5] - 2026-04-21 + +### Changed + +- Deferred resolver route router construction until the module-level `router` attribute is accessed, reducing import-time dependency coupling. +- Added resolver mutation-testing configuration for `mutmut`, including resolver API routes, services, store modules, and selective test invocation. +- Lazily load baseline and granger engine classes via module `__getattr__` hooks to avoid import-time engine dependencies in store modules. +- Hardened shared Redis patching in resolver tests by importing store modules dynamically and only patching available attributes. +- Improved exception wrapper tests to preserve original HTTP exception identity and validate sync/async handler shape preservation. +- Tightened resolver pylint design thresholds and refactored analyzer/connector/rca interfaces to keep strict linting without compatibility regressions. +- Added structured RCA signal-input wiring and expanded edge-case tests so resolver `mypy`, `pylint`, and `pytest` all pass cleanly at 100% coverage. +- Applied cleanup-only compatibility wording updates in resolver config/comments and datasource fallback test literals with no behavior changes. +- Added a temporary SQLite test bootstrap for resolver tests, cleaned up resolver request/analyzer docstrings, and excluded generated mutation fixtures from resolver mypy. + ## [v0.0.4] - 2026-04-14 ### Changed diff --git a/api/__init__.py b/api/__init__.py index 7329c26..33dac04 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,4 +1,8 @@ -# package marker for api submodules """ -Package exports for the `api` module namespace. +Api module for the resolver package. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ diff --git a/api/requests/__init__.py b/api/requests/__init__.py index d97bc32..18f58c0 100644 --- a/api/requests/__init__.py +++ b/api/requests/__init__.py @@ -1,11 +1,10 @@ """ Requests and data models for API endpoints. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from .analyze import AnalyzeJobCreateRequest, AnalyzeRequest diff --git a/api/requests/_time_range.py b/api/requests/_time_range.py index 82ba0cb..0294f14 100644 --- a/api/requests/_time_range.py +++ b/api/requests/_time_range.py @@ -1,5 +1,10 @@ """ -Shared time-range request models. +Time range request model for log and metric queries. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/requests/analyze.py b/api/requests/analyze.py index 068c920..16da036 100644 --- a/api/requests/analyze.py +++ b/api/requests/analyze.py @@ -1,11 +1,10 @@ """ Analyze request models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/requests/correlation.py b/api/requests/correlation.py index e2874e5..000a82c 100644 --- a/api/requests/correlation.py +++ b/api/requests/correlation.py @@ -1,11 +1,10 @@ """ Correlation request models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/requests/events.py b/api/requests/events.py index a82db19..e389c6a 100644 --- a/api/requests/events.py +++ b/api/requests/events.py @@ -1,11 +1,10 @@ """ Deployment event request models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/requests/logs.py b/api/requests/logs.py index bd851c9..7b75346 100644 --- a/api/requests/logs.py +++ b/api/requests/logs.py @@ -1,11 +1,10 @@ """ Log request models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/requests/metrics.py b/api/requests/metrics.py index 1ab7714..a9fbd37 100644 --- a/api/requests/metrics.py +++ b/api/requests/metrics.py @@ -1,11 +1,10 @@ """ Metrics request models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/requests/slo.py b/api/requests/slo.py index df3acad..e78afe3 100644 --- a/api/requests/slo.py +++ b/api/requests/slo.py @@ -1,11 +1,10 @@ """ SLO request models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/requests/topology.py b/api/requests/topology.py index 44a3a6d..c1798f3 100644 --- a/api/requests/topology.py +++ b/api/requests/topology.py @@ -1,11 +1,10 @@ """ Topology request models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/requests/traces.py b/api/requests/traces.py index 506fb59..cda2737 100644 --- a/api/requests/traces.py +++ b/api/requests/traces.py @@ -1,11 +1,10 @@ """ Trace request models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/responses/__init__.py b/api/responses/__init__.py index 4dfcb2c..6d4586d 100644 --- a/api/responses/__init__.py +++ b/api/responses/__init__.py @@ -1,11 +1,10 @@ """ Response models for API endpoints and internal data structures. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/responses/analysis.py b/api/responses/analysis.py index 4488ec3..979588c 100644 --- a/api/responses/analysis.py +++ b/api/responses/analysis.py @@ -1,11 +1,10 @@ """ Analysis report response models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/responses/anomalies.py b/api/responses/anomalies.py index 23d0445..753e981 100644 --- a/api/responses/anomalies.py +++ b/api/responses/anomalies.py @@ -1,11 +1,10 @@ """ Anomaly response models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/responses/base.py b/api/responses/base.py index 36c6889..ccc6d35 100644 --- a/api/responses/base.py +++ b/api/responses/base.py @@ -1,11 +1,10 @@ """ Base response models and serialization helpers. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -19,17 +18,18 @@ def _coerce(obj: SerializableValue) -> SerializableValue: + result: SerializableValue = obj if isinstance(obj, dict): - return {k: _coerce(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_coerce(v) for v in obj] - if isinstance(obj, np.integer): - return int(obj) - if isinstance(obj, np.floating): - return float(obj) - if isinstance(obj, np.ndarray): - return obj.tolist() - return obj + result = {k: _coerce(v) for k, v in obj.items()} + elif isinstance(obj, list): + result = [_coerce(v) for v in obj] + elif isinstance(obj, np.integer): + result = int(obj) + elif isinstance(obj, np.floating): + result = float(obj) + elif isinstance(obj, np.ndarray): + result = obj.tolist() + return result class NpModel(BaseModel): diff --git a/api/responses/jobs.py b/api/responses/jobs.py index a99b9f6..9e365b3 100644 --- a/api/responses/jobs.py +++ b/api/responses/jobs.py @@ -1,11 +1,10 @@ """ Asynchronous analysis job response models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/responses/logs.py b/api/responses/logs.py index a935b9b..02933a6 100644 --- a/api/responses/logs.py +++ b/api/responses/logs.py @@ -1,11 +1,10 @@ """ Log response models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/responses/rca.py b/api/responses/rca.py index eddbf9b..0a9e520 100644 --- a/api/responses/rca.py +++ b/api/responses/rca.py @@ -1,11 +1,10 @@ """ RCA and causal response models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/responses/slo.py b/api/responses/slo.py index 3f532a1..8f5ae96 100644 --- a/api/responses/slo.py +++ b/api/responses/slo.py @@ -1,11 +1,10 @@ """ SLO response models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/responses/traces.py b/api/responses/traces.py index 12e7049..9b34a33 100644 --- a/api/responses/traces.py +++ b/api/responses/traces.py @@ -1,11 +1,10 @@ """ Trace response models. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/routes/__init__.py b/api/routes/__init__.py index f57cdaf..c9d5b57 100644 --- a/api/routes/__init__.py +++ b/api/routes/__init__.py @@ -1,45 +1,45 @@ """ Routes initialization and shared data models for API endpoints. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations +from importlib import import_module + from fastapi import APIRouter -from api.routes.analyze import router as analyze_router -from api.routes.causal import router as causal_router -from api.routes.correlation import router as correlation_router -from api.routes.events import router as events_router -from api.routes.forecast import router as forecast_router -from api.routes.health import router as health_router -from api.routes.jobs import router as jobs_router -from api.routes.logs import router as logs_router -from api.routes.metrics import router as metrics_router -from api.routes.ml import router as ml_router -from api.routes.slo import router as slo_router -from api.routes.topology import router as topology_router -from api.routes.traces import router as traces_router - -router = APIRouter() - -router.include_router(health_router) -router.include_router(analyze_router) -router.include_router(metrics_router) -router.include_router(logs_router) -router.include_router(traces_router) -router.include_router(correlation_router) -router.include_router(slo_router) -router.include_router(topology_router) -router.include_router(events_router) -router.include_router(forecast_router) -router.include_router(causal_router) -router.include_router(ml_router) -router.include_router(jobs_router) +ROUTE_MODULES = [ + "health", + "analyze", + "metrics", + "logs", + "traces", + "correlation", + "slo", + "topology", + "events", + "forecast", + "causal", + "ml", + "jobs", +] + + +def _build_router() -> APIRouter: + router_instance = APIRouter() + + for module_name in ROUTE_MODULES: + module = import_module(f"api.routes.{module_name}") + router_instance.include_router(module.router) + + return router_instance + + +router = _build_router() __all__ = ["router"] diff --git a/api/routes/analyze.py b/api/routes/analyze.py index 038ac73..1539404 100644 --- a/api/routes/analyze.py +++ b/api/routes/analyze.py @@ -3,11 +3,10 @@ including metric anomalies, log bursts, service latency issues, error propagation, and more. You may filter or specify time ranges and other parameters in the AnalyzeRequest to focus the analysis. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/routes/causal.py b/api/routes/causal.py index f23fa7d..2ad79bf 100644 --- a/api/routes/causal.py +++ b/api/routes/causal.py @@ -1,9 +1,10 @@ """ Causal inference routes for root cause analysis. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -55,7 +56,7 @@ def _common_causes_for_roots(causal_graph: CausalGraph, roots: list[str]) -> dic async def _fetch_requested_metrics(provider: DataSourceProvider, req: CorrelateRequest) -> list[tuple[str, JSONDict]]: queries = list(dict.fromkeys((getattr(req, "metric_queries", None) or []) + DEFAULT_METRIC_QUERIES)) - return await safe_call(fetch_metrics(provider, queries, req.start, req.end, req.step)) + return await safe_call(fetch_metrics(provider, queries, req.start, req.end, step=req.step)) @router.post( @@ -66,6 +67,7 @@ async def _fetch_requested_metrics(provider: DataSourceProvider, req: CorrelateR @handle_exceptions async def granger_causality( req: CorrelateRequest, + *, limit: int = Query(default=100, ge=1, le=2000), min_strength: float = Query(default=0.05, ge=0.0, le=1.0), max_series: int = Query(default=25, ge=2, le=200), diff --git a/api/routes/common.py b/api/routes/common.py index e2963fc..d3229e2 100644 --- a/api/routes/common.py +++ b/api/routes/common.py @@ -5,11 +5,10 @@ and other helpers used across multiple routers. This keeps individual route files thin and avoids repeating boilerplate logic. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -84,4 +83,4 @@ async def fetch_requested_metrics( req: _MetricRequestLike, ) -> list[tuple[str, JSONDict]]: queries = list(dict.fromkeys((getattr(req, "metric_queries", None) or []) + DEFAULT_METRIC_QUERIES)) - return await safe_call(fetch_metrics(provider, queries, req.start, req.end, req.step)) + return await safe_call(fetch_metrics(provider, queries, req.start, req.end, step=req.step)) diff --git a/api/routes/correlation.py b/api/routes/correlation.py index 53da7ba..19d5e34 100644 --- a/api/routes/correlation.py +++ b/api/routes/correlation.py @@ -1,9 +1,10 @@ """ Correlation routes for quick cross-signal temporal correlation without full RCA. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -45,7 +46,7 @@ async def correlate_signals(req: CorrelateRequest) -> JSONDict: start=req.start * 1_000_000_000, end=req.end * 1_000_000_000, ), - fetch_metrics(provider, all_queries, req.start, req.end, req.step), + fetch_metrics(provider, all_queries, req.start, req.end, step=req.step), return_exceptions=True, ) diff --git a/api/routes/events.py b/api/routes/events.py index b0e188d..448ae1b 100644 --- a/api/routes/events.py +++ b/api/routes/events.py @@ -2,9 +2,10 @@ Event registration routes for recording deployment events and other relevant occurrences that can be used for RCA correlation. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/routes/exception.py b/api/routes/exception.py index af29d86..6e7284c 100644 --- a/api/routes/exception.py +++ b/api/routes/exception.py @@ -9,11 +9,11 @@ response detail. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/routes/forecast.py b/api/routes/forecast.py index c44513f..06a62eb 100644 --- a/api/routes/forecast.py +++ b/api/routes/forecast.py @@ -1,9 +1,10 @@ """ Forecast routes for quick cross-signal temporal correlation without full RCA. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/routes/health.py b/api/routes/health.py index 5852d31..02ed489 100644 --- a/api/routes/health.py +++ b/api/routes/health.py @@ -1,9 +1,10 @@ """ Health check route to verify service and store connectivity. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/routes/logs.py b/api/routes/logs.py index 6f572d5..48dd426 100644 --- a/api/routes/logs.py +++ b/api/routes/logs.py @@ -1,9 +1,10 @@ """ Log analysis routes for detecting anomalous patterns and bursts in log data. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/routes/metrics.py b/api/routes/metrics.py index ac57341..3ebf807 100644 --- a/api/routes/metrics.py +++ b/api/routes/metrics.py @@ -1,9 +1,10 @@ """ Metric analysis routes for detecting anomalies and changepoints in time series data. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -55,15 +56,12 @@ async def metric_changepoints(req: ChangepointRequest) -> list[ChangePoint]: results: list[ChangePoint] = [] for metric_name, ts, vals in anomaly.iter_series(raw, query_hint=req.query): threshold_sigma = float(req.threshold_sigma) - try: - results.extend( - changepoint_detect( - ts, - vals, - threshold_sigma=threshold_sigma, - metric_name=metric_name, - ) + results.extend( + changepoint_detect( + ts, + vals, + threshold_sigma=threshold_sigma, + metric_name=metric_name, ) - except TypeError: - results.extend(changepoint_detect(ts, vals, threshold_sigma)) + ) return sorted(results, key=lambda c: c.timestamp) diff --git a/api/routes/ml.py b/api/routes/ml.py index 213d716..6f197dc 100644 --- a/api/routes/ml.py +++ b/api/routes/ml.py @@ -1,9 +1,10 @@ """ ML routes for adaptive signal weighting based on user feedback. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/routes/slo.py b/api/routes/slo.py index f3d657e..91b7119 100644 --- a/api/routes/slo.py +++ b/api/routes/slo.py @@ -1,9 +1,10 @@ """ SLO routes for detecting metric anomalies and changepoints based on user-defined sensitivity and thresholds. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -72,7 +73,15 @@ async def slo_burn(req: SloRequest) -> JSONDict: err_vals = err_vals[:n] tot_vals = tot_vals[:n] err_ts = err_ts[:n] - alerts.extend(slo_evaluate(req.service, err_vals, tot_vals, err_ts, req.target_availability)) + alerts.extend( + slo_evaluate( + req.service, + err_vals, + tot_vals, + err_ts, + target_availability=req.target_availability, + ) + ) budget = remaining_minutes(req.service, err_vals, tot_vals, req.target_availability) return { diff --git a/api/routes/topology.py b/api/routes/topology.py index cbeb1d2..3f49443 100644 --- a/api/routes/topology.py +++ b/api/routes/topology.py @@ -2,9 +2,10 @@ Topology analysis routes for computing service dependency blast radius and upstream/downstream relationships from trace data. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/api/routes/traces.py b/api/routes/traces.py index ed52897..b4994d9 100644 --- a/api/routes/traces.py +++ b/api/routes/traces.py @@ -1,9 +1,10 @@ """ Trace analysis routes for detecting anomalous latency patterns and service degradations in distributed systems. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/config.py b/config.py index 1d4bc12..e019fcc 100644 --- a/config.py +++ b/config.py @@ -1,11 +1,10 @@ """ Constants and configuration for Resolver. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import os @@ -248,7 +247,7 @@ class Settings(BaseSettings): # changepoint detection cusum_window: int = 10 cusum_relative_cutoff: float = 0.6 - # we keep legacy name for backwards compatibility but allow override + cusum_threshold_sigma: float = cusum_threshold # correlation/temporal scoring diff --git a/connectors/__init__.py b/connectors/__init__.py index 7cec23d..93e392c 100644 --- a/connectors/__init__.py +++ b/connectors/__init__.py @@ -1,9 +1,8 @@ """ Connectors for Resolver to interact with data sources like Mimir, Loki, and Tempo. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ diff --git a/connectors/common.py b/connectors/common.py index f1d3925..458a44c 100644 --- a/connectors/common.py +++ b/connectors/common.py @@ -1,14 +1,20 @@ """ Shared request helpers for datasource connectors. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations +from dataclasses import dataclass from typing import Protocol import httpx -from datasources.helpers import fetch_json +from datasources.helpers import FetchErrorMessages, FetchRequestOptions, fetch_json from datasources.types import JSONDict, QueryParams @@ -20,29 +26,43 @@ class _BackendConnector(Protocol): def request_headers(self) -> dict[str, str]: ... +@dataclass(frozen=True) +class BackendErrorMessages: + invalid: str + timeout: str + unavailable: str + + async def query_backend_json( connector: _BackendConnector, - *, path: str, params: QueryParams, - invalid_msg: str, - timeout_msg: str, - unavailable_msg: str, + messages: BackendErrorMessages | None = None, ) -> JSONDict: + resolved_messages = messages or BackendErrorMessages(invalid="", timeout="", unavailable="") + headers_getter = getattr(connector, "request_headers", None) if callable(headers_getter): headers = headers_getter() else: - legacy_headers_getter = getattr(connector, "_headers", None) - headers = legacy_headers_getter() if callable(legacy_headers_getter) else {} + fallback_headers_getter = getattr(connector, "_headers", None) + headers = fallback_headers_getter() if callable(fallback_headers_getter) else {} return await fetch_json( f"{connector.base_url}{path}", - params=params, - headers=headers, - timeout=connector.timeout, - client=connector.client, - invalid_msg=invalid_msg, - timeout_msg=timeout_msg, - unavailable_msg=unavailable_msg, + options=FetchRequestOptions( + params=params, + headers=headers, + timeout=connector.timeout, + client=connector.client, + ), + messages=_to_fetch_messages(resolved_messages), + ) + + +def _to_fetch_messages(messages: BackendErrorMessages) -> FetchErrorMessages: + return FetchErrorMessages( + invalid_msg=messages.invalid, + timeout_msg=messages.timeout, + unavailable_msg=messages.unavailable, ) diff --git a/connectors/loki.py b/connectors/loki.py index 042f9eb..25192c3 100644 --- a/connectors/loki.py +++ b/connectors/loki.py @@ -1,9 +1,23 @@ +""" +Loki connector for Log and LogQL queries. This module defines a LokiConnector +class that provides methods to query logs and log labels from a Loki instance. +It uses httpx for asynchronous HTTP requests to the Loki API and includes error +handling for invalid queries, timeouts, and service unavailability. The connector +is designed to fetch log data for analysis and correlation with other telemetry +data. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. +""" + import re import httpx from config import DATASOURCE_TIMEOUT, HEALTH_PATH -from connectors.common import query_backend_json +from connectors.common import BackendErrorMessages, query_backend_json from datasources.base import LogsConnector from datasources.retry import retry from datasources.types import JSONDict @@ -16,10 +30,11 @@ def __init__( self, base_url: str, tenant_id: str, + *, timeout: int = DATASOURCE_TIMEOUT, headers: dict[str, str] | None = None, ) -> None: - super().__init__(tenant_id, base_url, timeout, headers) + super().__init__(tenant_id, base_url, timeout=timeout, headers=headers) @staticmethod def _normalize_query(query: str) -> str: @@ -36,6 +51,7 @@ async def query_range( query: str, start: int, end: int, + *, limit: int | None = None, ) -> JSONDict: params: dict[str, str | int | float | bool] = { @@ -45,11 +61,14 @@ async def query_range( } if limit is not None: params["limit"] = limit + messages = BackendErrorMessages( + invalid="Loki query failed", + timeout="Loki query timed out", + unavailable="Cannot reach Loki at", + ) return await query_backend_json( self, - path="/loki/api/v1/query_range", - params=params, - invalid_msg="Loki query failed", - timeout_msg="Loki query timed out", - unavailable_msg="Cannot reach Loki at", + "/loki/api/v1/query_range", + params, + messages=messages, ) diff --git a/connectors/mimir.py b/connectors/mimir.py index 4ec80c3..c78bd8b 100644 --- a/connectors/mimir.py +++ b/connectors/mimir.py @@ -1,9 +1,22 @@ +""" +Mimir connector for Metrics queries. This module defines a MimirConnector class that provides methods to scrape metrics +and query time series data from a Mimir instance. It uses httpx for making asynchronous HTTP requests to the Mimir API +and includes error handling for common issues such as invalid queries, timeouts, and service unavailability. +The connector is designed to be used within the resolver service to fetch metric data for analysis and correlation +with other telemetry data. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. +""" + import httpx from config import DATASOURCE_TIMEOUT, HEALTH_PATH -from connectors.common import query_backend_json +from connectors.common import BackendErrorMessages, query_backend_json from datasources.base import MetricsConnector -from datasources.helpers import fetch_text +from datasources.helpers import FetchErrorMessages, FetchRequestOptions, fetch_text from datasources.retry import retry from datasources.types import JSONDict, QueryParams @@ -15,22 +28,23 @@ def __init__( self, base_url: str, tenant_id: str, + *, timeout: int = DATASOURCE_TIMEOUT, headers: dict[str, str] | None = None, ) -> None: - super().__init__(tenant_id, base_url, timeout, headers) + super().__init__(tenant_id, base_url, timeout=timeout, headers=headers) @retry(attempts=3, delay=0.5, backoff=2.0, exceptions=(httpx.RequestError, httpx.TimeoutException)) async def scrape(self) -> str: url = f"{self.base_url}/metrics" return await fetch_text( url, - headers=self._headers(), - timeout=self.timeout, - client=self.client, - invalid_msg="Mimir scrape failed", - timeout_msg="Mimir scrape timed out", - unavailable_msg="Cannot reach Mimir at", + options=FetchRequestOptions(headers=self._headers(), timeout=self.timeout, client=self.client), + messages=FetchErrorMessages( + invalid_msg="Mimir scrape failed", + timeout_msg="Mimir scrape timed out", + unavailable_msg="Cannot reach Mimir at", + ), ) @retry(attempts=3, delay=0.5, backoff=2.0, exceptions=(httpx.RequestError, httpx.TimeoutException)) @@ -39,14 +53,21 @@ async def query_range( query: str, start: int, end: int, - step: str, + *, + step: str | None = None, ) -> JSONDict: - params: QueryParams = {"query": query, "start": start, "end": end, "step": step} + resolved_step = None if step is None else str(step) + if not resolved_step: + raise TypeError("step is required") + params: QueryParams = {"query": query, "start": start, "end": end, "step": resolved_step} + messages = BackendErrorMessages( + invalid="Mimir query failed", + timeout="Mimir query timed out", + unavailable="Cannot reach Mimir at", + ) return await query_backend_json( self, - path="/prometheus/api/v1/query_range", - params=params, - invalid_msg="Mimir query failed", - timeout_msg="Mimir query timed out", - unavailable_msg="Cannot reach Mimir at", + "/prometheus/api/v1/query_range", + params, + messages=messages, ) diff --git a/connectors/tempo.py b/connectors/tempo.py index 8236e50..493c5fa 100644 --- a/connectors/tempo.py +++ b/connectors/tempo.py @@ -1,7 +1,21 @@ +""" +Tempo connector for topology and trace queries. This module defines a +TempoConnector class that provides methods to query traces from a Tempo +instance. It uses httpx for asynchronous HTTP requests to the Tempo API and +includes error handling for invalid queries, timeouts, and service +unavailability. The connector is designed to fetch trace data for analysis and +correlation with other telemetry data. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. +""" + import httpx from config import DATASOURCE_TIMEOUT, HEALTH_PATH -from connectors.common import query_backend_json +from connectors.common import BackendErrorMessages, query_backend_json from datasources.base import TracesConnector from datasources.retry import retry from datasources.types import JSONDict, TraceFilters @@ -14,10 +28,11 @@ def __init__( self, base_url: str, tenant_id: str, + *, timeout: int = DATASOURCE_TIMEOUT, headers: dict[str, str] | None = None, ) -> None: - super().__init__(tenant_id, base_url, timeout, headers) + super().__init__(tenant_id, base_url, timeout=timeout, headers=headers) @retry(attempts=3, delay=0.5, backoff=2.0, exceptions=(httpx.RequestError, httpx.TimeoutException)) async def query_range( @@ -25,16 +40,20 @@ async def query_range( filters: TraceFilters, start: int, end: int, + *, limit: int | None = None, ) -> JSONDict: params: dict[str, str | int | float | bool] = {"start": start, "end": end, **filters} if limit is not None: params["limit"] = limit + messages = BackendErrorMessages( + invalid="Tempo query failed", + timeout="Tempo query timed out", + unavailable="Cannot reach Tempo at", + ) return await query_backend_json( self, - path="/api/search", - params=params, - invalid_msg="Tempo query failed", - timeout_msg="Tempo query timed out", - unavailable_msg="Cannot reach Tempo at", + "/api/search", + params, + messages=messages, ) diff --git a/custom_types/__init__.py b/custom_types/__init__.py index d5adf54..1f53eaf 100644 --- a/custom_types/__init__.py +++ b/custom_types/__init__.py @@ -1,11 +1,10 @@ """ Custom types used across the Resolver codebase. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/custom_types/json.py b/custom_types/json.py index a39156a..a73c7c2 100644 --- a/custom_types/json.py +++ b/custom_types/json.py @@ -1,11 +1,10 @@ """ JSON-related custom types and helper functions. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/database.py b/database.py index 58bc4a5..d081923 100644 --- a/database.py +++ b/database.py @@ -1,11 +1,10 @@ """ Database initialization and session management for Resolver. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/datasources/__init__.py b/datasources/__init__.py index 4045ef0..36faeb9 100644 --- a/datasources/__init__.py +++ b/datasources/__init__.py @@ -1,9 +1,8 @@ """ Datasources for Resolver to interact with various data sources like Mimir, Tempo, and VictoriaMetrics. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ diff --git a/datasources/base.py b/datasources/base.py index e34f450..cc2a15c 100644 --- a/datasources/base.py +++ b/datasources/base.py @@ -1,11 +1,10 @@ """ Base connectors and shared utilities for data sources. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from abc import ABC, abstractmethod @@ -18,7 +17,14 @@ class BaseConnector(ABC): health_path: str = "" - def __init__(self, tenant_id: str, base_url: str, timeout: int = 30, headers: dict[str, str] | None = None): + def __init__( + self, + tenant_id: str, + base_url: str, + *, + timeout: int = 30, + headers: dict[str, str] | None = None, + ): self.tenant_id = tenant_id self.base_url = str(base_url).rstrip("/") self.timeout = timeout @@ -48,6 +54,7 @@ async def query_range( query: str, start: int, end: int, + *, limit: int | None = None, ) -> JSONDict: ... @@ -59,6 +66,7 @@ async def query_range( query: str, start: int, end: int, + *, step: str, ) -> JSONDict: ... @@ -70,5 +78,6 @@ async def query_range( filters: TraceFilters, start: int, end: int, + *, limit: int | None = None, ) -> JSONDict: ... diff --git a/datasources/data_config.py b/datasources/data_config.py index a9ce739..345456a 100644 --- a/datasources/data_config.py +++ b/datasources/data_config.py @@ -1,11 +1,10 @@ """ Data source connectors for querying traces, metrics, and logs from various backends. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from pydantic import field_validator diff --git a/datasources/exceptions.py b/datasources/exceptions.py index e7dbd06..1aed347 100644 --- a/datasources/exceptions.py +++ b/datasources/exceptions.py @@ -1,11 +1,10 @@ """ Custom exceptions for data source connectors and queries. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ diff --git a/datasources/factory.py b/datasources/factory.py index 3c5fdeb..2660c7f 100644 --- a/datasources/factory.py +++ b/datasources/factory.py @@ -1,11 +1,10 @@ """ Factory for creating data source connectors based on configuration. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/datasources/helpers.py b/datasources/helpers.py index 0c6468e..82ca869 100644 --- a/datasources/helpers.py +++ b/datasources/helpers.py @@ -1,17 +1,16 @@ """ Shared helper functions for data source connectors. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Protocol, cast +from typing import Any, cast import httpx @@ -19,16 +18,12 @@ from datasources.types import JSONDict, QueryParams -class AsyncGetClient(Protocol): - async def get(self, url: str, **kwargs: object) -> Any: ... - - @dataclass(frozen=True) class FetchRequestOptions: params: QueryParams | None = None headers: dict[str, str] | None = None timeout: int = 30 - client: AsyncGetClient | None = None + client: Any | None = None @dataclass(frozen=True) @@ -51,24 +46,15 @@ class FetchErrorMessages: ) -def _coerce_fetch_options( - options: FetchRequestOptions | None, - legacy_kwargs: dict[str, object], -) -> FetchRequestOptions: +def _coerce_fetch_options(options: FetchRequestOptions | None) -> FetchRequestOptions: base = options or FetchRequestOptions() - - params = legacy_kwargs.pop("params", base.params) - headers = legacy_kwargs.pop("headers", base.headers) - timeout_raw = legacy_kwargs.pop("timeout", base.timeout) - client_raw = legacy_kwargs.pop("client", base.client) - - timeout = int(cast(int | str | bytes | bytearray, timeout_raw)) - - client = client_raw if hasattr(client_raw, "get") else base.client + timeout = int(cast(int | str | bytes | bytearray, base.timeout)) + client_raw = base.client + client = client_raw if callable(getattr(client_raw, "get", None)) else None return FetchRequestOptions( - params=cast(QueryParams | None, params), - headers=cast(dict[str, str] | None, headers), + params=base.params, + headers=base.headers, timeout=timeout, client=client, ) @@ -76,17 +62,13 @@ def _coerce_fetch_options( def _coerce_error_messages( messages: FetchErrorMessages | None, - legacy_kwargs: dict[str, object], defaults: FetchErrorMessages, ) -> FetchErrorMessages: base = messages or defaults - invalid_raw = legacy_kwargs.pop("invalid_msg", base.invalid_msg) - timeout_raw = legacy_kwargs.pop("timeout_msg", base.timeout_msg) - unavailable_raw = legacy_kwargs.pop("unavailable_msg", base.unavailable_msg) return FetchErrorMessages( - invalid_msg=str(invalid_raw), - timeout_msg=str(timeout_raw), - unavailable_msg=str(unavailable_raw), + invalid_msg=str(base.invalid_msg), + timeout_msg=str(base.timeout_msg), + unavailable_msg=str(base.unavailable_msg), ) @@ -94,10 +76,9 @@ async def fetch_json( url: str, options: FetchRequestOptions | None = None, messages: FetchErrorMessages | None = None, - **legacy_kwargs: object, ) -> JSONDict: - parsed_options = _coerce_fetch_options(options, dict(legacy_kwargs)) - parsed_messages = _coerce_error_messages(messages, dict(legacy_kwargs), _DEFAULT_JSON_MESSAGES) + parsed_options = _coerce_fetch_options(options) + parsed_messages = _coerce_error_messages(messages, _DEFAULT_JSON_MESSAGES) try: if parsed_options.client is None: @@ -120,10 +101,9 @@ async def fetch_text( url: str, options: FetchRequestOptions | None = None, messages: FetchErrorMessages | None = None, - **legacy_kwargs: object, ) -> str: - parsed_options = _coerce_fetch_options(options, dict(legacy_kwargs)) - parsed_messages = _coerce_error_messages(messages, dict(legacy_kwargs), _DEFAULT_TEXT_MESSAGES) + parsed_options = _coerce_fetch_options(options) + parsed_messages = _coerce_error_messages(messages, _DEFAULT_TEXT_MESSAGES) try: if parsed_options.client is None: diff --git a/datasources/provider.py b/datasources/provider.py index d8cf4f8..48e97e6 100644 --- a/datasources/provider.py +++ b/datasources/provider.py @@ -1,11 +1,10 @@ """ Provider for data source connectors to query logs, metrics, and traces based on tenant configuration. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from .base import LogsConnector, MetricsConnector, TracesConnector @@ -25,16 +24,51 @@ def __init__(self, tenant_id: str, settings: DataSourceSettings) -> None: self.metrics = DataSourceFactory.create_metrics(settings, tenant_id) self.traces = DataSourceFactory.create_traces(settings, tenant_id) - async def query_logs(self, query: str, start: int, end: int, limit: int | None = None) -> JSONDict: - return await self.logs.query_range(query=query, start=start, end=end, limit=limit) + async def query_logs( + self, + query: str, + start: int, + end: int, + *, + limit: int | None = None, + ) -> JSONDict: + resolved_limit = _coerce_optional_int(limit) + return await self.logs.query_range(query=query, start=start, end=end, limit=resolved_limit) - async def query_metrics(self, query: str, start: int, end: int, step: str) -> JSONDict: - return await self.metrics.query_range(query=query, start=start, end=end, step=step) + async def query_metrics( + self, + query: str, + start: int, + end: int, + *, + step: str | None = None, + ) -> JSONDict: + resolved_step = None if step is None else str(step) + if not resolved_step: + raise TypeError("step is required") + return await self.metrics.query_range(query=query, start=start, end=end, step=resolved_step) - async def query_traces(self, filters: TraceFilters, start: int, end: int, limit: int | None = None) -> JSONDict: - return await self.traces.query_range(filters=filters, start=start, end=end, limit=limit) + async def query_traces( + self, + filters: TraceFilters, + start: int, + end: int, + *, + limit: int | None = None, + ) -> JSONDict: + resolved_limit = _coerce_optional_int(limit) + return await self.traces.query_range(filters=filters, start=start, end=end, limit=resolved_limit) async def aclose(self) -> None: await self.logs.aclose() await self.metrics.aclose() await self.traces.aclose() + + +def _coerce_optional_int(value: object | None) -> int | None: + if value is None: + return None + try: + return int(str(value)) + except (TypeError, ValueError): + return None diff --git a/datasources/retry.py b/datasources/retry.py index c4ce4bb..8d6f5ac 100644 --- a/datasources/retry.py +++ b/datasources/retry.py @@ -1,11 +1,10 @@ """ Retry decorator for connector methods. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/datasources/types.py b/datasources/types.py index 540db1f..36438c4 100644 --- a/datasources/types.py +++ b/datasources/types.py @@ -1,11 +1,10 @@ """ Types related to data sources and queries. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/db_models.py b/db_models.py index b924896..9bbbca2 100644 --- a/db_models.py +++ b/db_models.py @@ -3,11 +3,10 @@ represent the RCA jobs and their corresponding reports stored in the database. The models are defined using SQLAlchemy's DeclarativeBase, allowing for easy interaction with the database using Python objects. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/__init__.py b/engine/__init__.py index 5123946..d5624bc 100644 --- a/engine/__init__.py +++ b/engine/__init__.py @@ -1,11 +1,10 @@ """ Engine Packages for Resolver Analysis Engine. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.enums import ChangeType, RcaCategory, Severity, Signal diff --git a/engine/analyze/__init__.py b/engine/analyze/__init__.py index 0e581a0..82fa114 100644 --- a/engine/analyze/__init__.py +++ b/engine/analyze/__init__.py @@ -1,3 +1,8 @@ """ Analyzer support package for helper and filtering utilities. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ diff --git a/engine/analyze/filters.py b/engine/analyze/filters.py index 16134d9..e2a8b25 100644 --- a/engine/analyze/filters.py +++ b/engine/analyze/filters.py @@ -1,11 +1,10 @@ """ Analyzer Filter Helpers. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -33,21 +32,17 @@ def result_matches_services(result: object, services: set[str]) -> bool: def filter_metric_response_by_services(response: object, services: set[str]) -> object: - if not services: - return response - if not isinstance(response, dict): - return response - data = response.get("data") - if not isinstance(data, dict): - return response - results = data.get("result") - if not isinstance(results, list): - return response - filtered = [item for item in results if result_matches_services(item, services)] - if len(filtered) == len(results): - return response - response_copy = dict(response) - data_copy = dict(data) - data_copy["result"] = filtered - response_copy["data"] = data_copy - return response_copy + filtered_response = response + if services and isinstance(response, dict): + data = response.get("data") + if isinstance(data, dict): + results = data.get("result") + if isinstance(results, list): + filtered = [item for item in results if result_matches_services(item, services)] + if len(filtered) != len(results): + response_copy = dict(response) + data_copy = dict(data) + data_copy["result"] = filtered + response_copy["data"] = data_copy + filtered_response = response_copy + return filtered_response diff --git a/engine/analyze/helpers.py b/engine/analyze/helpers.py index c7cef56..f8f0514 100644 --- a/engine/analyze/helpers.py +++ b/engine/analyze/helpers.py @@ -1,9 +1,10 @@ """ -Analyzer Helpers. -Copyright (c) 2026 Stefan Kumarasinghe +Analyzer Helpers for validating and normalizing incoming requests to the resolver engine's analyze endpoint. + +Copyright (c) 2026 Stefan Kumarasinghe. + Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -13,12 +14,10 @@ import logging import math from collections import defaultdict -from collections.abc import Callable, Sequence -from typing import Any, TypeAlias, TypeVar +from collections.abc import Callable +from typing import TypeAlias, TypeVar import httpx -import numpy as np - from api.requests import AnalyzeRequest from api.responses import ( AnalysisQuality, @@ -42,6 +41,18 @@ ) from engine.analyze.series import select_granger_series as _select_granger_series_impl from engine.analyze.series import slo_series_pairs as _slo_series_pairs_impl +from engine.analyze.quality_support import ( + apply_root_cause_quality_gates as _apply_root_cause_quality_gates, + build_selection_score_components as _build_selection_score_components_impl, + compute_anomaly_density as _compute_anomaly_density, + filter_log_bursts_for_precision_rca as _filter_log_bursts_for_precision_rca_impl, + is_precision_profile as _is_precision_profile_impl, + is_strongly_periodic_log_bursts as _is_strongly_periodic_log_bursts_impl, + root_cause_corroboration_summary as _root_cause_corroboration_summary_impl, + root_cause_signal_count as _root_cause_signal_count_impl, + safe_float as _safe_float_impl, + signal_key as _signal_key_impl, +) from engine.anomaly.stats import compute_series_distribution_stats from engine.baseline import compute as baseline_compute from engine.causal.granger import GrangerResult @@ -254,8 +265,7 @@ def _cap_list( def _limit_analyzer_output( - inputs: AnalyzerOutputInputs | None = None, - **legacy_kwargs: Any, + inputs: AnalyzerOutputInputs, ) -> tuple[ list[MetricAnomaly], list[ChangePoint], @@ -264,16 +274,6 @@ def _limit_analyzer_output( list[AnomalyCluster], list[GrangerResult], ]: - if inputs is None: - inputs = AnalyzerOutputInputs( - metric_anomalies=legacy_kwargs.get("metric_anomalies", []), - change_points=legacy_kwargs.get("change_points", []), - root_causes=legacy_kwargs.get("root_causes", []), - ranked_causes=legacy_kwargs.get("ranked_causes", []), - anomaly_clusters=legacy_kwargs.get("anomaly_clusters", []), - granger_results=legacy_kwargs.get("granger_results", []), - warnings=legacy_kwargs.get("warnings", []), - ) metric_anomalies_limited = _cap_list( inputs.metric_anomalies, settings.analyzer_max_metric_anomalies, @@ -333,121 +333,32 @@ def _limit_analyzer_output( return ma, cp, rc, ranked_limited, clusters_limited, granger_limited -def _signal_key(value: object) -> str: - if isinstance(value, Signal): - return value.value - text = str(value or "").strip().lower() - if text.startswith("metric"): - return Signal.METRICS.value - if text.startswith("log"): - return Signal.LOGS.value - if text.startswith("trace"): - return Signal.TRACES.value - if text.startswith("event") or text.startswith("deploy"): - return Signal.EVENTS.value - return text - +def _build_selection_score_components(ranked_item: object, root_cause: RootCauseModel) -> dict[str, float]: + return _build_selection_score_components_impl(ranked_item, root_cause) -def _root_cause_signal_count(root_cause: RootCauseModel) -> int: - signals = getattr(root_cause, "contributing_signals", []) or [] - keys = {_signal_key(signal) for signal in signals if _signal_key(signal)} - keys.discard("") - return len(keys) +def _signal_key(value: object) -> str: + return _signal_key_impl(value) -def _root_cause_corroboration_summary(root_cause: RootCauseModel) -> str: - count = _root_cause_signal_count(root_cause) - signals = sorted( - { - _signal_key(signal) - for signal in (getattr(root_cause, "contributing_signals", []) or []) - if _signal_key(signal) - } - ) - if not signals: - return "single-signal evidence" - return f"{count} corroborating signal(s): {', '.join(signals)}" +def _is_strongly_periodic_log_bursts(log_bursts: list[LogBurst]) -> bool: + return _is_strongly_periodic_log_bursts_impl(log_bursts) -def _build_selection_score_components(ranked_item: object, root_cause: RootCauseModel) -> dict[str, float]: - components: dict[str, float] = {} - for key, value in ( - ("rule_confidence", getattr(root_cause, "confidence", None)), - ("ml_score", getattr(ranked_item, "ml_score", None)), - ("final_score", getattr(ranked_item, "final_score", None)), - ): - if value is None: - continue - try: - number = float(value) - if math.isfinite(number): - components[key] = round(number, 6) - except (TypeError, ValueError): - continue - importances = getattr(ranked_item, "feature_importance", None) - if isinstance(importances, dict): - for name, value in importances.items(): - if not isinstance(value, (str, int, float)): - continue - try: - number = float(value) - except (TypeError, ValueError): - continue - if math.isfinite(number): - components[f"feature_importance:{name}"] = round(number, 6) - return components +def _root_cause_signal_count(root_cause: RootCauseModel) -> int: + return _root_cause_signal_count_impl(root_cause) -def _compute_anomaly_density(metric_anomalies: Sequence[MetricAnomaly], duration_seconds: float) -> dict[str, float]: - if not metric_anomalies: - return {} - hours = max(float(duration_seconds) / 3600.0, 1.0 / 60.0) - counts: dict[str, int] = defaultdict(int) - for anomaly_item in metric_anomalies: - metric_name = str(getattr(anomaly_item, "metric_name", "metric")).strip() or "metric" - counts[metric_name] += 1 - return {name: round(count / hours, 4) for name, count in counts.items()} +def _root_cause_corroboration_summary(root_cause: RootCauseModel) -> str: + return _root_cause_corroboration_summary_impl(root_cause) def _is_precision_profile() -> bool: - profile = str(getattr(settings, "quality_gating_profile", "precision_strict_v1")).strip() - return profile.lower().startswith("precision") + return _is_precision_profile_impl() def _safe_float(value: object) -> float | None: - if not isinstance(value, (str, int, float)): - return None - try: - parsed = float(value) - except (TypeError, ValueError): - return None - if not math.isfinite(parsed): - return None - return parsed - - -def _is_strongly_periodic_log_bursts(log_bursts: list[LogBurst]) -> bool: - if len(log_bursts) < 4: - return False - raw_starts = [_safe_float(getattr(burst, "window_start", getattr(burst, "start", None))) for burst in log_bursts] - starts: list[float] = sorted([value for value in raw_starts if value is not None]) - if len(starts) < 4: - return False - deltas = [starts[idx] - starts[idx - 1] for idx in range(1, len(starts))] - deltas = [delta for delta in deltas if delta > 0] - if len(deltas) < 3: - return False - median = float(np.median(deltas)) - if median < 20.0 or median > 180.0: - return False - std = float(np.std(deltas)) - cv = std / median if median > 0 else float("inf") - if cv > 0.25: - return False - band = median * 0.2 - in_band = sum(1 for delta in deltas if abs(delta - median) <= band) - return (in_band / len(deltas)) >= 0.75 + return _safe_float_impl(value) def _filter_log_bursts_for_precision_rca( @@ -457,42 +368,110 @@ def _filter_log_bursts_for_precision_rca( suppression_counts: dict[str, int], warnings: list[str], ) -> list[LogBurst]: - if not log_bursts: - return log_bursts - if not _is_precision_profile(): - return log_bursts - if not log_patterns: - return log_bursts - highest_pattern_severity = max( - (getattr(pattern, "severity", Severity.LOW).weight() for pattern in log_patterns), - default=Severity.LOW.weight(), + return _filter_log_bursts_for_precision_rca_impl( + log_bursts=log_bursts, + log_patterns=log_patterns, + suppression_counts=suppression_counts, + warnings=warnings, ) - if highest_pattern_severity > Severity.LOW.weight(): - return log_bursts - if not _is_strongly_periodic_log_bursts(log_bursts): - return log_bursts - suppressed = len(log_bursts) - suppression_counts["low_signal_periodic_log_bursts"] = ( - suppression_counts.get("low_signal_periodic_log_bursts", 0) + suppressed - ) - warnings.append(f"Quality gate suppressed {suppressed} periodic low-severity log burst(s) from RCA corroboration.") - return [] + + +def _apply_metric_anomaly_density_gate( + metric_anomalies: list[MetricAnomaly], + *, + hours: float, + suppression_counts: dict[str, int], + warnings: list[str], +) -> list[MetricAnomaly]: + if not (_is_precision_profile() and metric_anomalies): + return metric_anomalies + max_density = max(0.0, float(getattr(settings, "quality_max_anomaly_density_per_metric_per_hour", 0.0))) + if max_density <= 0: + return metric_anomalies + + keep_per_metric = max(1, int(math.ceil(max_density * hours))) + by_metric: dict[str, list[MetricAnomaly]] = defaultdict(list) + for item in metric_anomalies: + metric_name = str(getattr(item, "metric_name", "metric")).strip() or "metric" + by_metric[metric_name].append(item) + + filtered: list[MetricAnomaly] = [] + suppressed = 0 + for items in by_metric.values(): + if len(items) <= keep_per_metric: + filtered.extend(items) + continue + ranked = sorted( + items, + key=lambda a: ( + getattr(getattr(a, "severity", Severity.LOW), "weight", lambda: 0)(), + abs(float(getattr(a, "z_score", 0.0))), + abs(float(getattr(a, "mad_score", 0.0))), + float(getattr(a, "timestamp", 0.0)), + ), + reverse=True, + ) + filtered.extend(ranked[:keep_per_metric]) + suppressed += len(items) - keep_per_metric + metric_anomalies = sorted(filtered, key=lambda a: (a.timestamp, a.metric_name)) + if suppressed > 0: + suppression_counts["density_suppressed_metric_anomalies"] = ( + suppression_counts.get("density_suppressed_metric_anomalies", 0) + suppressed + ) + warnings.append( + f"Quality gate suppressed {suppressed} metric anomaly(ies) above density cap {max_density}/metric/hour." + ) + return metric_anomalies + + +def _apply_change_point_density_gate( + change_points: list[ChangePoint], + *, + hours: float, + suppression_counts: dict[str, int], + warnings: list[str], +) -> list[ChangePoint]: + if not (_is_precision_profile() and change_points): + return change_points + max_density_cp = max(0.0, float(getattr(settings, "quality_max_change_point_density_per_metric_per_hour", 0.0))) + if max_density_cp <= 0: + return change_points + + keep_per_metric_cp = max(1, int(math.ceil(max_density_cp * hours))) + by_metric_cp: dict[str, list[ChangePoint]] = defaultdict(list) + for change_point in change_points: + metric_name = str(getattr(change_point, "metric_name", "metric")).strip() or "metric" + by_metric_cp[metric_name].append(change_point) + filtered_cp: list[ChangePoint] = [] + suppressed_cp = 0 + for change_point_items in by_metric_cp.values(): + if len(change_point_items) <= keep_per_metric_cp: + filtered_cp.extend(change_point_items) + continue + ranked_cp = sorted( + change_point_items, + key=lambda c: ( + float(getattr(c, "magnitude", 0.0)), + float(getattr(c, "timestamp", 0.0)), + ), + reverse=True, + ) + filtered_cp.extend(ranked_cp[:keep_per_metric_cp]) + suppressed_cp += len(change_point_items) - keep_per_metric_cp + change_points = sorted(filtered_cp, key=lambda c: (c.timestamp, c.metric_name)) + if suppressed_cp > 0: + suppression_counts["density_suppressed_change_points"] = ( + suppression_counts.get("density_suppressed_change_points", 0) + suppressed_cp + ) + warnings.append( + f"Quality gate suppressed {suppressed_cp} change point(s) above density cap {max_density_cp}/metric/hour." + ) + return change_points def _apply_precision_quality_gates( - inputs: PrecisionQualityGateInputs | None = None, - **legacy_kwargs: Any, + inputs: PrecisionQualityGateInputs, ) -> tuple[list[MetricAnomaly], list[ChangePoint], list[RootCauseModel], list[RankedCause], AnalysisQuality]: - kw = legacy_kwargs - inputs = inputs or PrecisionQualityGateInputs( - metric_anomalies=kw.get("metric_anomalies", []), - change_points=kw.get("change_points", []), - root_causes=kw.get("root_causes", []), - ranked_causes=kw.get("ranked_causes", []), - duration_seconds=float(kw.get("duration_seconds", 0.0)), - suppression_counts=kw.get("suppression_counts", {}), - warnings=kw.get("warnings", []), - ) metric_anomalies, change_points, root_causes, ranked_causes, duration_seconds, suppression_counts, warnings = ( inputs.metric_anomalies, inputs.change_points, @@ -502,143 +481,25 @@ def _apply_precision_quality_gates( inputs.suppression_counts, inputs.warnings, ) - hours = max(float(duration_seconds) / 3600.0, 1.0 / 60.0) - - if _is_precision_profile() and metric_anomalies: - max_density = max(0.0, float(getattr(settings, "quality_max_anomaly_density_per_metric_per_hour", 0.0))) - if max_density > 0: - keep_per_metric = max(1, int(math.ceil(max_density * hours))) - by_metric: dict[str, list[MetricAnomaly]] = defaultdict(list) - for item in metric_anomalies: - metric_name = str(getattr(item, "metric_name", "metric")).strip() or "metric" - by_metric[metric_name].append(item) - filtered: list[MetricAnomaly] = [] - suppressed = 0 - for items in by_metric.values(): - if len(items) <= keep_per_metric: - filtered.extend(items) - continue - ranked = sorted( - items, - key=lambda a: ( - getattr(getattr(a, "severity", Severity.LOW), "weight", lambda: 0)(), - abs(float(getattr(a, "z_score", 0.0))), - abs(float(getattr(a, "mad_score", 0.0))), - float(getattr(a, "timestamp", 0.0)), - ), - reverse=True, - ) - filtered.extend(ranked[:keep_per_metric]) - suppressed += len(items) - keep_per_metric - metric_anomalies = sorted(filtered, key=lambda a: (a.timestamp, a.metric_name)) - if suppressed > 0: - suppression_counts["density_suppressed_metric_anomalies"] = ( - suppression_counts.get("density_suppressed_metric_anomalies", 0) + suppressed - ) - warnings.append( - f"Quality gate suppressed {suppressed} metric anomaly(ies) above density cap " - f"{max_density}/metric/hour." - ) - if _is_precision_profile() and change_points: - max_density_cp = max( - 0.0, - float(getattr(settings, "quality_max_change_point_density_per_metric_per_hour", 0.0)), - ) - if max_density_cp > 0: - keep_per_metric_cp = max(1, int(math.ceil(max_density_cp * hours))) - by_metric_cp: dict[str, list[ChangePoint]] = defaultdict(list) - for change_point in change_points: - metric_name = str(getattr(change_point, "metric_name", "metric")).strip() or "metric" - by_metric_cp[metric_name].append(change_point) - filtered_cp: list[ChangePoint] = [] - suppressed_cp = 0 - for change_point_items in by_metric_cp.values(): - if len(change_point_items) <= keep_per_metric_cp: - filtered_cp.extend(change_point_items) - continue - ranked_cp = sorted( - change_point_items, - key=lambda c: ( - float(getattr(c, "magnitude", 0.0)), - float(getattr(c, "timestamp", 0.0)), - ), - reverse=True, - ) - filtered_cp.extend(ranked_cp[:keep_per_metric_cp]) - suppressed_cp += len(change_point_items) - keep_per_metric_cp - change_points = sorted(filtered_cp, key=lambda c: (c.timestamp, c.metric_name)) - if suppressed_cp > 0: - suppression_counts["density_suppressed_change_points"] = ( - suppression_counts.get("density_suppressed_change_points", 0) + suppressed_cp - ) - warnings.append( - f"Quality gate suppressed {suppressed_cp} change point(s) above density cap " - f"{max_density_cp}/metric/hour." - ) - - if root_causes: - min_corr = max(1, int(getattr(settings, "quality_min_corroboration_signals", 2))) - max_without = max(1, int(getattr(settings, "quality_max_root_causes_without_multisignal", 1))) - low_conf_cutoff = max(float(getattr(settings, "rca_min_confidence_display", 0.05)), 0.10) - - if _is_precision_profile(): - filtered_root_causes: list[RootCauseModel] = [] - suppressed_low_conf = 0 - for cause in root_causes: - if float(getattr(cause, "confidence", 0.0)) < low_conf_cutoff and len(root_causes) > 1: - suppressed_low_conf += 1 - continue - filtered_root_causes.append(cause) - root_causes = filtered_root_causes or root_causes - if suppressed_low_conf > 0: - suppression_counts["low_confidence_root_causes"] = ( - suppression_counts.get("low_confidence_root_causes", 0) + suppressed_low_conf - ) - warnings.append( - "Quality gate suppressed " - f"{suppressed_low_conf} low-confidence root cause(s) below {low_conf_cutoff:.2f}." - ) - - multi_signal = [cause for cause in root_causes if _root_cause_signal_count(cause) >= min_corr] - if not multi_signal and len(root_causes) > max_without: - suppressed_without_multi = len(root_causes) - max_without - root_causes = root_causes[:max_without] - suppression_counts["root_causes_without_multisignal"] = ( - suppression_counts.get("root_causes_without_multisignal", 0) + suppressed_without_multi - ) - warnings.append( - "Quality gate suppressed " - f"{suppressed_without_multi} root cause(s) without multi-signal corroboration." - ) - - allowed_hypotheses = {str(cause.hypothesis) for cause in root_causes} - ranked_before = len(ranked_causes) - ranked_causes = [ - item - for item in ranked_causes - if str(getattr(getattr(item, "root_cause", None), "hypothesis", "")) in allowed_hypotheses - ] - dropped_ranked = ranked_before - len(ranked_causes) - if dropped_ranked > 0: - suppression_counts["suppressed_ranked_causes"] = ( - suppression_counts.get("suppressed_ranked_causes", 0) + dropped_ranked - ) - - for cause in root_causes: - if not getattr(cause, "corroboration_summary", None): - cause.corroboration_summary = _root_cause_corroboration_summary(cause) - diagnostics = dict(getattr(cause, "suppression_diagnostics", {}) or {}) - diagnostics.setdefault( - "gating_profile", - str(getattr(settings, "quality_gating_profile", "precision_strict_v1")).strip() - or "precision_strict_v1", - ) - signal_count = _root_cause_signal_count(cause) - diagnostics.setdefault("signal_count", signal_count) - diagnostics["min_corroboration_signals"] = min_corr - diagnostics["meets_min_corroboration_signals"] = signal_count >= min_corr - cause.suppression_diagnostics = diagnostics + metric_anomalies = _apply_metric_anomaly_density_gate( + metric_anomalies, + hours=hours, + suppression_counts=suppression_counts, + warnings=warnings, + ) + change_points = _apply_change_point_density_gate( + change_points, + hours=hours, + suppression_counts=suppression_counts, + warnings=warnings, + ) + root_causes, ranked_causes = _apply_root_cause_quality_gates( + root_causes, + ranked_causes, + suppression_counts=suppression_counts, + warnings=warnings, + ) quality = AnalysisQuality( anomaly_density=_compute_anomaly_density(metric_anomalies, duration_seconds), @@ -653,21 +514,12 @@ def _apply_precision_quality_gates( async def _process_one_metric_series( - **legacy_kwargs: Any, + job: MetricSeriesJob, ) -> tuple[list[MetricAnomaly], list[ChangePoint], TrajectoryForecast | None, DegradationSignal | None]: - job = MetricSeriesJob( - req=legacy_kwargs["req"], - query_string=legacy_kwargs["query_string"], - metric_name=legacy_kwargs["metric_name"], - ts=legacy_kwargs["ts"], - vals=legacy_kwargs["vals"], - z_threshold=legacy_kwargs["z_threshold"], - analysis_window_seconds=legacy_kwargs["analysis_window_seconds"], - ) req, metric_name, ts, vals, z_threshold = job.req, job.metric_name, job.ts, job.vals, job.z_threshold try: # result is persisted by store; value not used later - _ = await baseline_store.compute_and_persist(req.tenant_id, metric_name, ts, vals, z_threshold) + _ = await baseline_store.compute_and_persist(req.tenant_id, metric_name, ts, vals, z_threshold=z_threshold) except _RECOVERABLE_ANALYSIS_ERRORS: # fallback compute also only triggers side‑effects _ = baseline_compute(ts, vals, z_threshold=z_threshold) @@ -679,22 +531,18 @@ async def _process_one_metric_series( else float(settings.cusum_threshold_sigma) ) sigma_multiplier = max(1.0, sigma_multiplier) - try: - change_points = changepoint_detect( - job.ts, - job.vals, - threshold_sigma=sigma_multiplier, - metric_name=metric_name, - ) - except TypeError: - # Backward-compatible path for monkeypatched/legacy detector signatures. - change_points = changepoint_detect(job.ts, job.vals, sigma_multiplier) + change_points = changepoint_detect( + job.ts, + job.vals, + threshold_sigma=sigma_multiplier, + metric_name=metric_name, + ) threshold = next((v for k, v in FORECAST_THRESHOLDS.items() if k in job.query_string), None) if threshold and job.analysis_window_seconds >= float( getattr(settings, "analyzer_forecast_min_window_seconds", 0.0) ): - fc = forecast(metric_name, ts, vals, threshold, req.forecast_horizon_seconds) + fc = forecast(metric_name, ts, vals, threshold, horizon_seconds=req.forecast_horizon_seconds) else: fc = None @@ -711,6 +559,7 @@ async def _process_metrics( req: AnalyzeRequest, all_metric_queries: list[str], z_threshold: float, + *, analysis_window_seconds: float, ) -> tuple[ list[MetricAnomaly], @@ -720,7 +569,7 @@ async def _process_metrics( dict[str, list[float]], list[MetricSeriesDistributionStats], ]: - metrics_raw = await fetch_metrics(provider, all_metric_queries, req.start, req.end, req.step) + metrics_raw = await fetch_metrics(provider, all_metric_queries, req.start, req.end, step=req.step) requested_services = _normalize_services(req.services) if requested_services: filtered_metrics_raw: list[tuple[str, JSONDict]] = [] @@ -743,11 +592,19 @@ async def _process_metrics( distribution_by_key[sk] = row distribution_stats = list(distribution_by_key.values()) - shared_kwargs = {"req": req, "z_threshold": z_threshold, "analysis_window_seconds": analysis_window_seconds} - tasks = [ - _process_one_metric_series(**shared_kwargs, query_string=q, metric_name=m, ts=t, vals=v) - for q, m, t, v in series_list + series_jobs = [ + MetricSeriesJob( + req=req, + query_string=query_string, + metric_name=metric_name, + ts=ts, + vals=vals, + z_threshold=z_threshold, + analysis_window_seconds=analysis_window_seconds, + ) + for query_string, metric_name, ts, vals in series_list ] + tasks = [_process_one_metric_series(job) for job in series_jobs] processed = await asyncio.gather(*tasks, return_exceptions=True) metric_anomalies: list[MetricAnomaly] = [] diff --git a/engine/analyze/quality_support.py b/engine/analyze/quality_support.py new file mode 100644 index 0000000..dd2a1c0 --- /dev/null +++ b/engine/analyze/quality_support.py @@ -0,0 +1,242 @@ +""" +Shared quality-gate and scoring helpers for analyzer output. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. +""" + +from __future__ import annotations + +import math +from collections import defaultdict +from collections.abc import Sequence + +import numpy as np + +from api.responses import LogBurst, LogPattern, MetricAnomaly, RootCause as RootCauseModel +from config import settings +from engine.enums import Severity, Signal +from engine.ml import RankedCause + + +def signal_key(value: object) -> str: + if isinstance(value, Signal): + return value.value + text = str(value or "").strip().lower() + prefixes = ( + ("metric", Signal.METRICS.value), + ("log", Signal.LOGS.value), + ("trace", Signal.TRACES.value), + ("event", Signal.EVENTS.value), + ("deploy", Signal.EVENTS.value), + ) + for prefix, mapped in prefixes: + if text.startswith(prefix): + return mapped + return text + + +def root_cause_signal_count(root_cause: RootCauseModel) -> int: + signals = getattr(root_cause, "contributing_signals", []) or [] + keys = {signal_key(signal) for signal in signals if signal_key(signal)} + keys.discard("") + return len(keys) + + +def root_cause_corroboration_summary(root_cause: RootCauseModel) -> str: + count = root_cause_signal_count(root_cause) + signals = sorted( + { + signal_key(signal) + for signal in (getattr(root_cause, "contributing_signals", []) or []) + if signal_key(signal) + } + ) + if not signals: + return "single-signal evidence" + return f"{count} corroborating signal(s): {', '.join(signals)}" + + +def build_selection_score_components(ranked_item: object, root_cause: RootCauseModel) -> dict[str, float]: + components: dict[str, float] = {} + for key, value in ( + ("rule_confidence", getattr(root_cause, "confidence", None)), + ("ml_score", getattr(ranked_item, "ml_score", None)), + ("final_score", getattr(ranked_item, "final_score", None)), + ): + if value is None: + continue + try: + number = float(value) + if math.isfinite(number): + components[key] = round(number, 6) + except (TypeError, ValueError): + continue + + importances = getattr(ranked_item, "feature_importance", None) + if isinstance(importances, dict): + for name, value in importances.items(): + if not isinstance(value, (str, int, float)): + continue + try: + number = float(value) + except (TypeError, ValueError): + continue + if math.isfinite(number): + components[f"feature_importance:{name}"] = round(number, 6) + return components + + +def compute_anomaly_density(metric_anomalies: Sequence[MetricAnomaly], duration_seconds: float) -> dict[str, float]: + if not metric_anomalies: + return {} + hours = max(float(duration_seconds) / 3600.0, 1.0 / 60.0) + counts: dict[str, int] = defaultdict(int) + for anomaly_item in metric_anomalies: + metric_name = str(getattr(anomaly_item, "metric_name", "metric")).strip() or "metric" + counts[metric_name] += 1 + return {name: round(count / hours, 4) for name, count in counts.items()} + + +def is_precision_profile() -> bool: + profile = str(getattr(settings, "quality_gating_profile", "precision_strict_v1")).strip() + return profile.lower().startswith("precision") + + +def safe_float(value: object) -> float | None: + if not isinstance(value, (str, int, float)): + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(parsed): + return None + return parsed + + +def is_strongly_periodic_log_bursts(log_bursts: list[LogBurst]) -> bool: + is_periodic = False + if len(log_bursts) >= 4: + raw_starts = [safe_float(getattr(burst, "window_start", getattr(burst, "start", None))) for burst in log_bursts] + starts: list[float] = sorted([value for value in raw_starts if value is not None]) + deltas = [starts[idx] - starts[idx - 1] for idx in range(1, len(starts))] + deltas = [delta for delta in deltas if delta > 0] + if len(starts) >= 4 and len(deltas) >= 3: + median = float(np.median(deltas)) + if 20.0 <= median <= 180.0: + std = float(np.std(deltas)) + cv = std / median if median > 0 else float("inf") + if cv <= 0.25: + band = median * 0.2 + in_band = sum(1 for delta in deltas if abs(delta - median) <= band) + is_periodic = (in_band / len(deltas)) >= 0.75 + return is_periodic + + +def filter_log_bursts_for_precision_rca( + *, + log_bursts: list[LogBurst], + log_patterns: list[LogPattern], + suppression_counts: dict[str, int], + warnings: list[str], +) -> list[LogBurst]: + highest_pattern_severity = max( + (getattr(pattern, "severity", Severity.LOW).weight() for pattern in log_patterns), + default=Severity.LOW.weight(), + ) + should_suppress = ( + bool(log_bursts) + and is_precision_profile() + and bool(log_patterns) + and highest_pattern_severity <= Severity.LOW.weight() + and is_strongly_periodic_log_bursts(log_bursts) + ) + if not should_suppress: + return log_bursts + suppressed = len(log_bursts) + suppression_counts["low_signal_periodic_log_bursts"] = ( + suppression_counts.get("low_signal_periodic_log_bursts", 0) + suppressed + ) + warnings.append(f"Quality gate suppressed {suppressed} periodic low-severity log burst(s) from RCA corroboration.") + return [] + + +def finalize_root_cause_metadata( + root_causes: list[RootCauseModel], + *, + min_corr: int, +) -> None: + for cause in root_causes: + if not getattr(cause, "corroboration_summary", None): + cause.corroboration_summary = root_cause_corroboration_summary(cause) + diagnostics = dict(getattr(cause, "suppression_diagnostics", {}) or {}) + diagnostics.setdefault( + "gating_profile", + str(getattr(settings, "quality_gating_profile", "precision_strict_v1")).strip() or "precision_strict_v1", + ) + signal_count = root_cause_signal_count(cause) + diagnostics.setdefault("signal_count", signal_count) + diagnostics["min_corroboration_signals"] = min_corr + diagnostics["meets_min_corroboration_signals"] = signal_count >= min_corr + cause.suppression_diagnostics = diagnostics + + +def apply_root_cause_quality_gates( + root_causes: list[RootCauseModel], + ranked_causes: list[RankedCause], + *, + suppression_counts: dict[str, int], + warnings: list[str], +) -> tuple[list[RootCauseModel], list[RankedCause]]: + if not root_causes: + return root_causes, ranked_causes + min_corr = max(1, int(getattr(settings, "quality_min_corroboration_signals", 2))) + max_without = max(1, int(getattr(settings, "quality_max_root_causes_without_multisignal", 1))) + low_conf_cutoff = max(float(getattr(settings, "rca_min_confidence_display", 0.05)), 0.10) + + if is_precision_profile(): + filtered_root_causes: list[RootCauseModel] = [] + suppressed_low_conf = 0 + for cause in root_causes: + if float(getattr(cause, "confidence", 0.0)) < low_conf_cutoff and len(root_causes) > 1: + suppressed_low_conf += 1 + continue + filtered_root_causes.append(cause) + root_causes = filtered_root_causes or root_causes + if suppressed_low_conf > 0: + suppression_counts["low_confidence_root_causes"] = ( + suppression_counts.get("low_confidence_root_causes", 0) + suppressed_low_conf + ) + warnings.append( + "Quality gate suppressed " + f"{suppressed_low_conf} low-confidence root cause(s) below {low_conf_cutoff:.2f}." + ) + + multi_signal = [cause for cause in root_causes if root_cause_signal_count(cause) >= min_corr] + if not multi_signal and len(root_causes) > max_without: + suppressed_without_multi = len(root_causes) - max_without + root_causes = root_causes[:max_without] + suppression_counts["root_causes_without_multisignal"] = ( + suppression_counts.get("root_causes_without_multisignal", 0) + suppressed_without_multi + ) + warnings.append( + f"Quality gate suppressed {suppressed_without_multi} root cause(s) without multi-signal corroboration." + ) + + allowed_hypotheses = {str(cause.hypothesis) for cause in root_causes} + ranked_before = len(ranked_causes) + ranked_causes = [ + item + for item in ranked_causes + if str(getattr(getattr(item, "root_cause", None), "hypothesis", "")) in allowed_hypotheses + ] + dropped_ranked = ranked_before - len(ranked_causes) + if dropped_ranked > 0: + suppression_counts["suppressed_ranked_causes"] = ( + suppression_counts.get("suppressed_ranked_causes", 0) + dropped_ranked + ) + finalize_root_cause_metadata(root_causes, min_corr=min_corr) + return root_causes, ranked_causes diff --git a/engine/analyze/series.py b/engine/analyze/series.py index ea6a290..5e81423 100644 --- a/engine/analyze/series.py +++ b/engine/analyze/series.py @@ -1,3 +1,12 @@ +""" +Shared series processing utilities for analyzers. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. +""" + from __future__ import annotations import numpy as np diff --git a/engine/analyze/stage_context.py b/engine/analyze/stage_context.py new file mode 100644 index 0000000..ea106d1 --- /dev/null +++ b/engine/analyze/stage_context.py @@ -0,0 +1,65 @@ +""" +Dataclasses for analyzer stage inputs. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. +""" + +from __future__ import annotations + +import dataclasses + +from api.responses import ErrorPropagation, LogBurst, LogPattern, MetricAnomaly, ServiceLatency +from engine.changepoint import ChangePoint +from engine.correlation import CorrelatedEvent +from engine.forecast.degradation import DegradationSignal +from engine.forecast.trajectory import TrajectoryForecast +from engine.ml import AnomalyCluster +from engine.topology import DependencyGraph + + +@dataclasses.dataclass(frozen=True) +class CorrelateStageInputs: + metric_anomalies: list[MetricAnomaly] + log_bursts: list[LogBurst] + rca_log_bursts: list[LogBurst] + service_latency: list[ServiceLatency] + + +@dataclasses.dataclass(frozen=True) +class CausalStageInputs: + series_map: dict[str, list[float]] + metric_anomalies: list[MetricAnomaly] + rca_log_bursts: list[LogBurst] + log_patterns: list[LogPattern] + service_latency: list[ServiceLatency] + error_propagation: list[ErrorPropagation] + correlated_events: list[CorrelatedEvent] + graph: DependencyGraph + change_points: list[ChangePoint] + forecasts: list[TrajectoryForecast] + degradation_signals: list[DegradationSignal] + anomaly_clusters: list[AnomalyCluster] + + +@dataclasses.dataclass(frozen=True) +class MetricsStageInputs: + all_metric_queries: list[str] + z_threshold: float + analysis_window_seconds: float + + +@dataclasses.dataclass(frozen=True) +class TracesStageInputs: + primary_service: str | None + trace_filters: dict[str, str | int | float | bool] + traces_raw: object + warnings: list[str] + + +@dataclasses.dataclass(frozen=True) +class AnalysisScope: + tenant_id: str + primary_service: str | None diff --git a/engine/analyzer.py b/engine/analyzer.py index 76627e8..5b53529 100644 --- a/engine/analyzer.py +++ b/engine/analyzer.py @@ -1,11 +1,10 @@ """ -Analyzer Module for Root Cause Analysis and Correlation of Anomalies. +Analyzer pipeline orchestration -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -57,6 +56,13 @@ _slo_series_pairs, _to_root_cause_model, ) +from engine.analyze.stage_context import ( + AnalysisScope, + CausalStageInputs, + CorrelateStageInputs, + MetricsStageInputs, + TracesStageInputs, +) from engine.anomaly.series import WrappedMimirResponse from engine.causal import BayesianScore, CausalGraph, GrangerResult, bayesian_score, test_all_pairs from engine.changepoint import ChangePoint @@ -101,31 +107,7 @@ class AnalyzerRuntimeState: suppression_counts: dict[str, int] -@dataclasses.dataclass(frozen=True) -class CorrelateStageInputs: - metric_anomalies: list[MetricAnomaly] - log_bursts: list[LogBurst] - rca_log_bursts: list[LogBurst] - service_latency: list[ServiceLatency] - - -@dataclasses.dataclass(frozen=True) -class CausalStageInputs: - series_map: dict[str, list[float]] - metric_anomalies: list[MetricAnomaly] - rca_log_bursts: list[LogBurst] - log_patterns: list[LogPattern] - service_latency: list[ServiceLatency] - error_propagation: list[ErrorPropagation] - correlated_events: list[CorrelatedEvent] - graph: DependencyGraph - change_points: list[ChangePoint] - forecasts: list[TrajectoryForecast] - degradation_signals: list[DegradationSignal] - anomaly_clusters: list[AnomalyCluster] - - -def _overall_severity(*groups: Sequence[object]) -> Severity: +def _overall_severity(groups: Sequence[Sequence[object]]) -> Severity: best = Severity.LOW for group in groups: for item in group: @@ -212,9 +194,7 @@ async def _fetch_parallel_observations( async def _run_metrics_stage( provider: DataSourceProvider, req: AnalyzeRequest, - all_metric_queries: list[str], - z_threshold: float, - analysis_window_seconds: float, + inputs: MetricsStageInputs, state: AnalyzerRuntimeState, ) -> tuple[ list[MetricAnomaly], @@ -234,7 +214,13 @@ async def _run_metrics_stage( series_map, metric_series_statistics, ) = await asyncio.wait_for( - _process_metrics(provider, req, all_metric_queries, z_threshold, analysis_window_seconds), + _process_metrics( + provider, + req, + inputs.all_metric_queries, + inputs.z_threshold, + analysis_window_seconds=inputs.analysis_window_seconds, + ), timeout=float(settings.analyzer_metrics_timeout_seconds), ) except TimeoutError: @@ -364,15 +350,15 @@ async def _run_logs_stage( async def _run_traces_stage( provider: DataSourceProvider, req: AnalyzeRequest, - *, - primary_service: str | None, - trace_filters: dict[str, str | int | float | bool], - traces_raw: object, - warnings: list[str], + inputs: TracesStageInputs, ) -> tuple[list[ServiceLatency], list[ErrorPropagation], DependencyGraph]: traces_started = time.perf_counter() service_latency, error_propagation = [], [] graph = DependencyGraph() + primary_service = inputs.primary_service + trace_filters = inputs.trace_filters + traces_raw = inputs.traces_raw + warnings = inputs.warnings if isinstance(traces_raw, dict): service_latency = traces.analyze(traces_raw, req.apdex_threshold_ms) error_propagation = traces.detect_propagation(traces_raw) @@ -439,7 +425,13 @@ def _run_slo_stage( filtered_slo_total_raw = filtered_totals for err_ts, err_vals, tot_vals in _slo_series_pairs(filtered_slo_errors_raw, filtered_slo_total_raw, warnings): slo_alerts_raw.extend( - slo_evaluate(primary_service or "global", err_vals, tot_vals, err_ts, req.slo_target or 0.999) + slo_evaluate( + primary_service or "global", + err_vals, + tot_vals, + err_ts, + target_availability=req.slo_target or 0.999, + ) ) else: warnings.append("SLO metrics unavailable for one or both queries.") @@ -500,8 +492,7 @@ async def _run_correlate_cluster_stage( async def _run_causal_rank_and_quality( - tenant_id: str, - primary_service: str | None, + scope: AnalysisScope, req: AnalyzeRequest, *, registry: TenantRegistry, @@ -540,7 +531,7 @@ async def _run_causal_rank_and_quality( try: await asyncio.wait_for( - granger_store.save_and_merge(tenant_id, primary_service or "global", fresh_granger), + granger_store.save_and_merge(scope.tenant_id, scope.primary_service or "global", fresh_granger), timeout=1.0, ) except _RECOVERABLE_ANALYSIS_ERRORS as exc: @@ -557,7 +548,7 @@ async def _run_causal_rank_and_quality( if common_cause_hints: log.debug("analyzer causal common_cause_hints=%s", common_cause_hints) - raw_deployment_events = await registry.events_in_window(tenant_id, req.start, req.end) + raw_deployment_events = await registry.events_in_window(scope.tenant_id, req.start, req.end) deployment_events = list(raw_deployment_events) if isinstance(raw_deployment_events, list) else [] bayesian_scores = bayesian_score( has_deployment_event=bool(deployment_events), @@ -567,12 +558,15 @@ async def _run_causal_rank_and_quality( has_error_propagation=bool(inputs.error_propagation), ) + rca_signal_inputs = rca.RcaSignalInputs( + metric_anomalies=metric_anomalies, + log_bursts=inputs.rca_log_bursts, + log_patterns=inputs.log_patterns, + service_latency=inputs.service_latency, + error_propagation=inputs.error_propagation, + ) root_causes = rca.generate( - metric_anomalies, - inputs.rca_log_bursts, - inputs.log_patterns, - inputs.service_latency, - inputs.error_propagation, + rca_signal_inputs, correlated_events=inputs.correlated_events, graph=inputs.graph, event_registry=_build_compat_registry(deployment_events), @@ -662,9 +656,11 @@ async def run(provider: DataSourceProvider, req: AnalyzeRequest) -> AnalysisRepo ) = await _run_metrics_stage( provider, req, - all_metric_queries, - z_threshold, - analysis_window_seconds, + MetricsStageInputs( + all_metric_queries=all_metric_queries, + z_threshold=z_threshold, + analysis_window_seconds=analysis_window_seconds, + ), state, ) @@ -675,10 +671,12 @@ async def run(provider: DataSourceProvider, req: AnalyzeRequest) -> AnalysisRepo service_latency, error_propagation, graph = await _run_traces_stage( provider, req, - primary_service=primary_service, - trace_filters=trace_filters, - traces_raw=traces_raw, - warnings=warnings, + TracesStageInputs( + primary_service=primary_service, + trace_filters=trace_filters, + traces_raw=traces_raw, + warnings=warnings, + ), ) slo_alerts = _run_slo_stage( @@ -719,8 +717,7 @@ async def run(provider: DataSourceProvider, req: AnalyzeRequest) -> AnalysisRepo quality, bayesian_scores, ) = await _run_causal_rank_and_quality( - tenant_id, - primary_service, + AnalysisScope(tenant_id=tenant_id, primary_service=primary_service), req, registry=registry, inputs=CausalStageInputs( @@ -741,12 +738,14 @@ async def run(provider: DataSourceProvider, req: AnalyzeRequest) -> AnalysisRepo ) severity = _overall_severity( - metric_anomalies, - log_bursts, - log_patterns, - service_latency, - slo_alerts, - forecasts, + [ + metric_anomalies, + log_bursts, + log_patterns, + service_latency, + slo_alerts, + forecasts, + ] ) has_actionable_now = bool( metric_anomalies or log_bursts or log_patterns or service_latency or error_propagation or slo_alerts diff --git a/engine/anomaly/__init__.py b/engine/anomaly/__init__.py index a7b4850..ad092d0 100644 --- a/engine/anomaly/__init__.py +++ b/engine/anomaly/__init__.py @@ -3,11 +3,10 @@ machine learning (Isolation Forest), along with heuristics for classifying the type and severity of detected anomalies, to provide actionable insights into potential issues in monitored systems. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.anomaly.detection import detect diff --git a/engine/anomaly/detection.py b/engine/anomaly/detection.py index 596f584..4fcab7a 100644 --- a/engine/anomaly/detection.py +++ b/engine/anomaly/detection.py @@ -3,11 +3,10 @@ (z-score, MAD) and machine learning (Isolation Forest), along with heuristics for classifying the type and severity of detected anomalies, to provide actionable insights into potential issues in monitored systems. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -74,19 +73,19 @@ def _tukey_outlier_class( q3: float, iqr: float, ) -> str: - if iqr <= 0: - return "none" - mild_k = float(getattr(settings, "tukey_mild_k", 1.5)) - extreme_k = float(getattr(settings, "tukey_extreme_k", 3.0)) - if value > q3 + extreme_k * iqr: - return "extreme_high" - if value > q3 + mild_k * iqr: - return "mild_high" - if value < q1 - extreme_k * iqr: - return "extreme_low" - if value < q1 - mild_k * iqr: - return "mild_low" - return "none" + category = "none" + if iqr > 0: + mild_k = float(getattr(settings, "tukey_mild_k", 1.5)) + extreme_k = float(getattr(settings, "tukey_extreme_k", 3.0)) + if value > q3 + extreme_k * iqr: + category = "extreme_high" + elif value > q3 + mild_k * iqr: + category = "mild_high" + elif value < q1 - extreme_k * iqr: + category = "extreme_low" + elif value < q1 - mild_k * iqr: + category = "mild_low" + return category def _cusum_changepoints(arr: np.ndarray, threshold: float | None = None) -> np.ndarray: diff --git a/engine/anomaly/series.py b/engine/anomaly/series.py index 6875843..0bf11d1 100644 --- a/engine/anomaly/series.py +++ b/engine/anomaly/series.py @@ -2,11 +2,10 @@ Series iteration logic for processing Mimir query responses, extracting metric labels and corresponding timestamp- value pairs, to facilitate downstream analysis and anomaly detection on time series data. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/anomaly/stats.py b/engine/anomaly/stats.py index d072c3d..8e4d351 100644 --- a/engine/anomaly/stats.py +++ b/engine/anomaly/stats.py @@ -1,11 +1,10 @@ """ Descriptive statistics for metric series (IQR, MAD, skewness, kurtosis) used in RCA reports. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/baseline/__init__.py b/engine/baseline/__init__.py index f49fa70..3097c9d 100644 --- a/engine/baseline/__init__.py +++ b/engine/baseline/__init__.py @@ -3,11 +3,10 @@ time series data points, with optional seasonal adjustment based on hourly patterns, to assist in anomaly detection by providing a reference point for identifying significant deviations in metric values. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.baseline.compute import Baseline, compute, score diff --git a/engine/baseline/compute.py b/engine/baseline/compute.py index 4678e07..5a352e6 100644 --- a/engine/baseline/compute.py +++ b/engine/baseline/compute.py @@ -3,11 +3,10 @@ time series data points, with optional seasonal adjustment based on hourly patterns, to assist in anomaly detection by providing a reference point for identifying significant deviations in metric values. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/causal/__init__.py b/engine/causal/__init__.py index 382d6b5..ad24046 100644 --- a/engine/causal/__init__.py +++ b/engine/causal/__init__.py @@ -3,11 +3,10 @@ causal graph construction and intervention simulation, to assist in understanding relationships between metrics and identifying potential causes of anomalies. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.causal.bayesian import BayesianScore diff --git a/engine/causal/bayesian.py b/engine/causal/bayesian.py index ad17eb6..bf7acdd 100644 --- a/engine/causal/bayesian.py +++ b/engine/causal/bayesian.py @@ -4,11 +4,10 @@ latency spikes, and error propagation) using configurable priors and likelihoods, to assist in prioritizing potential causes during incident investigation. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -40,6 +39,7 @@ def score( has_metric_spike: bool, has_log_burst: bool, has_latency_spike: bool, + *, has_error_propagation: bool, ) -> list[BayesianScore]: evidence: dict[str, bool] = { diff --git a/engine/causal/granger.py b/engine/causal/granger.py index 2b3688d..7d32847 100644 --- a/engine/causal/granger.py +++ b/engine/causal/granger.py @@ -3,11 +3,10 @@ the predictability of the effect series using past values of the cause series, to assist in root cause analysis and understanding of relationships between metrics. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -33,6 +32,12 @@ class GrangerResult: strength: float +@dataclass(frozen=True) +class GrangerAnalysisOptions: + max_lag: int | None = None + p_threshold: float | None = None + + def _ols(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, float]: coeffs, _, _, _ = np.linalg.lstsq(x, y, rcond=None) predicted = x @ coeffs @@ -53,31 +58,32 @@ def granger_pair_analysis( cause_vals: list[float], effect_name: str, effect_vals: list[float], - max_lag: int | None = None, - p_threshold: float | None = None, + *, + options: GrangerAnalysisOptions | None = None, ) -> GrangerResult | None: - if max_lag is None: - max_lag = settings.granger_max_lag - if p_threshold is None: - p_threshold = settings.granger_p_threshold - if len(cause_vals) != len(effect_vals) or len(cause_vals) < max_lag + 10: + cfg = options or GrangerAnalysisOptions() + resolved_max_lag = cfg.max_lag if cfg.max_lag is not None else settings.granger_max_lag + resolved_p_threshold = cfg.p_threshold if cfg.p_threshold is not None else settings.granger_p_threshold + if len(cause_vals) != len(effect_vals) or len(cause_vals) < resolved_max_lag + 10: return None cause = np.array(cause_vals, dtype=float) effect = np.array(effect_vals, dtype=float) - n = len(effect) - max_lag - y = effect[max_lag:] + n = len(effect) - resolved_max_lag + y = effect[resolved_max_lag:] - x_restricted = _lag_matrix(effect, max_lag) + x_restricted = _lag_matrix(effect, resolved_max_lag) _, ss_restricted = _ols(x_restricted, y) - cause_lags = np.column_stack([cause[max_lag - lag : max_lag - lag + n] for lag in range(1, max_lag + 1)]) + cause_lags = np.column_stack( + [cause[resolved_max_lag - lag : resolved_max_lag - lag + n] for lag in range(1, resolved_max_lag + 1)] + ) x_unrestricted = np.hstack([x_restricted, cause_lags]) _, ss_unrestricted = _ols(x_unrestricted, y) - k = max_lag - denom_df = n - 2 * max_lag - 1 + k = resolved_max_lag + denom_df = n - 2 * resolved_max_lag - 1 if denom_df <= 0 or ss_unrestricted == 0: return None @@ -86,7 +92,7 @@ def granger_pair_analysis( p_value = float(1.0 - _scipy_stats.f.cdf(f_stat, k, denom_df)) - is_causal = p_value < p_threshold and f_stat > 1.0 + is_causal = p_value < resolved_p_threshold and f_stat > 1.0 strength = round( max(0.0, 1.0 - p_value) * min(1.0, f_stat / settings.granger_strength_scale), 3, @@ -95,7 +101,7 @@ def granger_pair_analysis( return GrangerResult( cause_metric=cause_name, effect_metric=effect_name, - max_lag=max_lag, + max_lag=resolved_max_lag, f_statistic=round(f_stat, 4), p_value=round(p_value, 6), is_causal=is_causal, @@ -112,6 +118,7 @@ def granger_multiple_pairs( max_lag = settings.granger_max_lag if p_threshold is None: p_threshold = settings.granger_p_threshold + options = GrangerAnalysisOptions(max_lag=max_lag, p_threshold=p_threshold) names = list(series_map.keys()) results: list[GrangerResult] = [] @@ -124,8 +131,7 @@ def granger_multiple_pairs( series_map[cause], effect, series_map[effect], - max_lag=max_lag, - p_threshold=p_threshold, + options=options, ) if result and result.is_causal: results.append(result) diff --git a/engine/causal/graph.py b/engine/causal/graph.py index e8a18d0..99d689b 100644 --- a/engine/causal/graph.py +++ b/engine/causal/graph.py @@ -2,11 +2,10 @@ Graph structure and logic for representing causal relationships between metrics, allowing for simulation of interventions and identification of root causes based on a directed acyclic graph (DAG) representation of causality. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -40,7 +39,7 @@ def __init__(self) -> None: self._forward: dict[str, list[CausalEdge]] = defaultdict(list) self._reverse: dict[str, set[str]] = defaultdict(set) - def add_edge(self, cause: str, effect: str, strength: float, lag_seconds: float = 0.0) -> None: + def add_edge(self, cause: str, effect: str, strength: float, *, lag_seconds: float = 0.0) -> None: edge = CausalEdge(cause=cause, effect=effect, strength=strength, lag_seconds=lag_seconds) self._edges.append(edge) self._forward[cause].append(edge) diff --git a/engine/changepoint/__init__.py b/engine/changepoint/__init__.py index 85becc4..24afc01 100644 --- a/engine/changepoint/__init__.py +++ b/engine/changepoint/__init__.py @@ -6,11 +6,11 @@ a clean import path of ``engine.changepoint`` for change point analysis utilities. The implementation itself lives in ``cusum.py``. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.changepoint.cusum import ChangePoint, detect diff --git a/engine/changepoint/cusum.py b/engine/changepoint/cusum.py index a830b63..cfac4c2 100644 --- a/engine/changepoint/cusum.py +++ b/engine/changepoint/cusum.py @@ -2,11 +2,10 @@ Cusum (Cumulative Sum) change point detection logic for identifying significant shifts in metric behavior, to assist in early detection of anomalies and support root cause analysis. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/correlation/__init__.py b/engine/correlation/__init__.py index b2cdd37..dded6c3 100644 --- a/engine/correlation/__init__.py +++ b/engine/correlation/__init__.py @@ -2,11 +2,10 @@ Correlation logic for identifying related anomalies across different signals (metrics, logs, traces) based on temporal proximity and other heuristics, to assist in root cause analysis and incident investigation. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.correlation.signals import LogMetricLink, link_logs_to_metrics diff --git a/engine/correlation/signals.py b/engine/correlation/signals.py index 5c26201..8ed6776 100644 --- a/engine/correlation/signals.py +++ b/engine/correlation/signals.py @@ -3,11 +3,10 @@ metric anomalies based on temporal proximity and strength of correlation, to support root cause analysis and incident investigation. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/correlation/temporal.py b/engine/correlation/temporal.py index 179c1c1..0b4fd2c 100644 --- a/engine/correlation/temporal.py +++ b/engine/correlation/temporal.py @@ -3,11 +3,10 @@ occurrence within a configurable time window, and to compute a confidence score for the correlation based on the number and types of signals involved, to assist in root cause analysis and incident investigation. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -94,6 +93,63 @@ def _safe_float(value: object) -> float | None: return None +def _windowed_log_bursts(log_bursts: list[LogBurst], window_start: float, window_end: float) -> list[LogBurst]: + selected: list[LogBurst] = [] + for burst in log_bursts: + burst_start = _safe_float(getattr(burst, "start", getattr(burst, "window_start", None))) + burst_end = _safe_float(getattr(burst, "end", getattr(burst, "window_end", None))) + if burst_start is None or burst_end is None: + continue + if _overlap(window_start, window_end, burst_start, burst_end): + selected.append(burst) + return selected + + +def _correlated_service_tokens(ma: list[MetricAnomaly], lb: list[LogBurst]) -> set[str]: + metric_services: set[str] = set() + for anomaly in ma: + metric_services.update(_service_tokens_from_metric_name(getattr(anomaly, "metric_name", ""))) + log_services: set[str] = set() + for burst in lb: + log_services.update(_service_tokens_from_log_burst(burst)) + return metric_services | log_services + + +def _windowed_latency( + service_latency: list[ServiceLatency], + correlated_services: set[str], + window_start: float, + window_end: float, +) -> list[ServiceLatency]: + selected: list[ServiceLatency] = [] + for latency in service_latency: + service_name = _normalize_service(getattr(latency, "service", "")) + if not service_name or not correlated_services or service_name not in correlated_services: + continue + latency_start, latency_end = _latency_window(latency) + if latency_start is None or latency_end is None: + continue + if _overlap(window_start, window_end, latency_start, latency_end): + selected.append(latency) + return selected + + +def _confidence_for_signals( + metric_count: int, + log_count: int, + trace_count: int, + weight_fn: Callable[[float, float, float], float] | None, +) -> float: + metric_score = min(settings.correlation_score_max, metric_count * settings.correlation_weight_time) + log_score = min(settings.correlation_score_max, log_count * settings.correlation_weight_latency) + trace_score = min(settings.correlation_errors_cap, trace_count * settings.correlation_weight_errors) + if weight_fn is not None: + raw_conf = weight_fn(metric_score, log_score, trace_score) + else: + raw_conf = metric_score + log_score + trace_score + return round(min(settings.correlation_score_max, raw_conf), 3) + + def correlate( metric_anomalies: list[MetricAnomaly], log_bursts: list[LogBurst], @@ -128,51 +184,15 @@ def correlate( w_end = anchor + window_seconds ma = [a for a in metric_anomalies if w_start <= a.timestamp <= w_end] - lb = [] - for burst in log_bursts: - burst_start = getattr(burst, "start", getattr(burst, "window_start", None)) - burst_end = getattr(burst, "end", getattr(burst, "window_end", None)) - burst_start = _safe_float(burst_start) - burst_end = _safe_float(burst_end) - if burst_start is None or burst_end is None: - continue - if _overlap(w_start, w_end, burst_start, burst_end): - lb.append(burst) - metric_services: set[str] = set() - for anomaly in ma: - metric_services.update(_service_tokens_from_metric_name(getattr(anomaly, "metric_name", ""))) - log_services: set[str] = set() - for burst in lb: - log_services.update(_service_tokens_from_log_burst(burst)) - correlated_services = metric_services | log_services - - sl = [] - for latency in service_latency: - service_name = _normalize_service(getattr(latency, "service", "")) - if not service_name: - continue - if not correlated_services: - continue - if service_name not in correlated_services: - continue - latency_start, latency_end = _latency_window(latency) - if latency_start is None or latency_end is None: - continue - if _overlap(w_start, w_end, latency_start, latency_end): - sl.append(latency) + lb = _windowed_log_bursts(log_bursts, w_start, w_end) + correlated_services = _correlated_service_tokens(ma, lb) + sl = _windowed_latency(service_latency, correlated_services, w_start, w_end) sig = len(ma) + len(lb) + len(sl) if sig < 2: continue - metric_score = min(settings.correlation_score_max, len(ma) * settings.correlation_weight_time) - log_score = min(settings.correlation_score_max, len(lb) * settings.correlation_weight_latency) - trace_score = min(settings.correlation_errors_cap, len(sl) * settings.correlation_weight_errors) - if weight_fn is not None: - raw_conf = weight_fn(metric_score, log_score, trace_score) - else: - raw_conf = metric_score + log_score + trace_score - confidence = round(min(settings.correlation_score_max, raw_conf), 3) + confidence = _confidence_for_signals(len(ma), len(lb), len(sl), weight_fn) events.append( CorrelatedEvent( diff --git a/engine/dedup/__init__.py b/engine/dedup/__init__.py index 3ee2450..711f28e 100644 --- a/engine/dedup/__init__.py +++ b/engine/dedup/__init__.py @@ -2,11 +2,10 @@ Deduplication logic for grouping related anomalies together based on similarity of their characteristics, to reduce noise and improve the signal-to-noise ratio for root cause analysis and alerting. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.dedup.grouping import AnomalyGroup, group_metric_anomalies diff --git a/engine/dedup/grouping.py b/engine/dedup/grouping.py index a124571..aa98f7d 100644 --- a/engine/dedup/grouping.py +++ b/engine/dedup/grouping.py @@ -2,11 +2,10 @@ Grouping logic for deduplication of anomalies, providing functionality to cluster similar anomalies based on temporal proximity and optionally by metric name, to reduce noise and improve signal quality for downstream analysis. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/enums.py b/engine/enums.py index 18d687c..e586451 100644 --- a/engine/enums.py +++ b/engine/enums.py @@ -1,11 +1,10 @@ """ Enumerations for Severity, Signal Types, Change Types, and RCA Categories. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/events/__init__.py b/engine/events/__init__.py index 18be869..44687f0 100644 --- a/engine/events/__init__.py +++ b/engine/events/__init__.py @@ -3,11 +3,10 @@ information such as service name, timestamp, version, author, environment, source, and additional metadata, to facilitate correlation with observed anomalies and support root cause analysis. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.events.registry import DeploymentEvent, EventRegistry diff --git a/engine/events/models.py b/engine/events/models.py index eb9d879..4e335ff 100644 --- a/engine/events/models.py +++ b/engine/events/models.py @@ -1,5 +1,10 @@ """ Shared deployment event model. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/events/registry.py b/engine/events/registry.py index 694670f..9626840 100644 --- a/engine/events/registry.py +++ b/engine/events/registry.py @@ -3,11 +3,10 @@ name, timestamp, version, author, environment, source, and additional metadata, to facilitate correlation with observed anomalies and support root cause analysis. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/fetcher.py b/engine/fetcher.py index 2a360a9..e0c634a 100644 --- a/engine/fetcher.py +++ b/engine/fetcher.py @@ -1,11 +1,10 @@ """ Fetcher Module for Metrics Retrieval and Scrape Fallback. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -95,14 +94,19 @@ async def fetch_metrics( queries: list[str], start: int, end: int, - step: str, + *, + step: str | None = None, ) -> list[tuple[str, JSONDict]]: + resolved_step = None if step is None else str(step) + if not resolved_step: + raise TypeError("step is required") + max_parallel = max(1, int(settings.analyzer_max_parallel_metric_queries)) sem = asyncio.Semaphore(max_parallel) async def _query(q: str) -> JSONDict: async with sem: - return await provider.query_metrics(query=q, start=start, end=end, step=step) + return await provider.query_metrics(query=q, start=start, end=end, step=resolved_step) raw = await asyncio.gather(*[_query(q) for q in queries], return_exceptions=True) diff --git a/engine/forecast/__init__.py b/engine/forecast/__init__.py index 34bff02..95a807d 100644 --- a/engine/forecast/__init__.py +++ b/engine/forecast/__init__.py @@ -3,11 +3,10 @@ and degradation signal analysis based on rate and acceleration of change, to predict future behavior of metrics and identify potential issues. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.forecast.degradation import DegradationSignal diff --git a/engine/forecast/degradation.py b/engine/forecast/degradation.py index f634e9b..f85ab38 100644 --- a/engine/forecast/degradation.py +++ b/engine/forecast/degradation.py @@ -2,11 +2,10 @@ Degradation analysis logic for time series metrics, including trend detection, volatility measurement, and severity classification based on configured thresholds, to identify potential performance degradations in monitored systems. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/forecast/trajectory.py b/engine/forecast/trajectory.py index 9a3087e..b754f49 100644 --- a/engine/forecast/trajectory.py +++ b/engine/forecast/trajectory.py @@ -2,11 +2,10 @@ Trajectory forecasting logic for metrics, using linear regression to predict future values based on recent trends, and estimating time to breach thresholds with confidence scoring and severity classification. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -54,6 +53,7 @@ def forecast( ts: Sequence[float], vals: Sequence[float], threshold: float, + *, horizon_seconds: float | None = None, ) -> TrajectoryForecast | None: if horizon_seconds is None: diff --git a/engine/log_query.py b/engine/log_query.py index 0f1866d..81e3c6f 100644 --- a/engine/log_query.py +++ b/engine/log_query.py @@ -1,5 +1,10 @@ """ Shared helpers for building log queries. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/logs/__init__.py b/engine/logs/__init__.py index 74bcca1..3993995 100644 --- a/engine/logs/__init__.py +++ b/engine/logs/__init__.py @@ -2,11 +2,10 @@ Logs analysis logic for burst detection and pattern recognition, including frequency-based burst detection and normalized log pattern extraction with severity categorization. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.logs.frequency import detect_bursts diff --git a/engine/logs/frequency.py b/engine/logs/frequency.py index c213532..9b8b99a 100644 --- a/engine/logs/frequency.py +++ b/engine/logs/frequency.py @@ -2,11 +2,10 @@ Frequency-based burst detection logic for logs, analyzing log entry timestamps to identify periods of unusually high log activity, with severity categorization based on configured thresholds. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ # pylint: disable=duplicate-code diff --git a/engine/logs/patterns.py b/engine/logs/patterns.py index 6aeab3c..20d5d72 100644 --- a/engine/logs/patterns.py +++ b/engine/logs/patterns.py @@ -2,11 +2,10 @@ Pattern recognition logic for logs, including normalization, severity classification, and entropy-based uniqueness scoring to identify common log patterns and their characteristics. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/ml/__init__.py b/engine/ml/__init__.py index 409a42d..1615d62 100644 --- a/engine/ml/__init__.py +++ b/engine/ml/__init__.py @@ -2,11 +2,10 @@ ML packages for clustering related anomalies and ranking potential root causes based on multi-signal correlation patterns, with configurable signal weights for different data sources. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.ml.clustering import AnomalyCluster, cluster diff --git a/engine/ml/clustering.py b/engine/ml/clustering.py index 3b9df24..dcb07fb 100644 --- a/engine/ml/clustering.py +++ b/engine/ml/clustering.py @@ -2,11 +2,10 @@ Clustering logic for grouping related anomalies based on temporal proximity and value similarity, using DBSCAN or a simple fallback method when scikit-learn is unavailable. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/ml/ranking.py b/engine/ml/ranking.py index a200501..e6c03e1 100644 --- a/engine/ml/ranking.py +++ b/engine/ml/ranking.py @@ -3,11 +3,10 @@ features extracted from root cause hypotheses and correlated events, to produce a final ranked list of potential causes for observed anomalies. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -114,14 +113,12 @@ def rank( events_map[a.metric_name] = ev feature_matrix = [] - event_refs: list[CorrelatedEvent | None] = [] for cause in causes: ref_metric = next( (s.split(":")[1] for s in cause.contributing_signals if s.startswith("metric:")), None, ) event_ref: CorrelatedEvent | None = events_map.get(ref_metric) if ref_metric else None - event_refs.append(event_ref) feature_matrix.append(_extract_features(cause, event_ref)) x = np.array(feature_matrix, dtype=float) diff --git a/engine/ml/weights.py b/engine/ml/weights.py index f6ca50f..90729a9 100644 --- a/engine/ml/weights.py +++ b/engine/ml/weights.py @@ -2,11 +2,10 @@ Weights and thresholds for machine learning models and heuristics used in anomaly detection, root cause analysis, and severity classification, defined in a centralized module for easy configuration and tuning. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/rca/__init__.py b/engine/rca/__init__.py index 9765907..00cff0a 100644 --- a/engine/rca/__init__.py +++ b/engine/rca/__init__.py @@ -1,7 +1,12 @@ """ Engine module for rca -> init . + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ -from engine.rca.hypothesis import RootCause, generate +from engine.rca.hypothesis import RcaSignalInputs, RootCause, generate -__all__ = ["RootCause", "generate"] +__all__ = ["RootCause", "RcaSignalInputs", "generate"] diff --git a/engine/rca/hypothesis.py b/engine/rca/hypothesis.py index 991f422..345a282 100644 --- a/engine/rca/hypothesis.py +++ b/engine/rca/hypothesis.py @@ -2,11 +2,10 @@ RCA hypothesis generation based on correlated events, error propagation analysis, and multi-signal correlation patterns, with confidence scoring and severity categorization. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -80,36 +79,9 @@ class RcaSignalInputs: def _coerce_signal_inputs( - signal_inputs: RcaSignalInputs | list[MetricAnomaly] | None, - legacy_signal_groups: tuple[object, ...], + signal_inputs: RcaSignalInputs | None, ) -> RcaSignalInputs: - if isinstance(signal_inputs, RcaSignalInputs): - return signal_inputs - - groups: list[object] = [] - if signal_inputs is not None: - groups.append(signal_inputs) - groups.extend(legacy_signal_groups) - - def _as_list(value: object) -> list[object]: - return value if isinstance(value, list) else [] - - if not groups: - return RcaSignalInputs() - - metric_anomalies = _as_list(groups[0]) - log_bursts = _as_list(groups[1]) if len(groups) > 1 else [] - log_patterns = _as_list(groups[2]) if len(groups) > 2 else [] - service_latency = _as_list(groups[3]) if len(groups) > 3 else [] - error_propagation = _as_list(groups[4]) if len(groups) > 4 else [] - - return RcaSignalInputs( - metric_anomalies=[item for item in metric_anomalies if isinstance(item, MetricAnomaly)], - log_bursts=[item for item in log_bursts if isinstance(item, LogBurst)], - log_patterns=[item for item in log_patterns if isinstance(item, LogPattern)], - service_latency=[item for item in service_latency if isinstance(item, ServiceLatency)], - error_propagation=[item for item in error_propagation if isinstance(item, ErrorPropagation)], - ) + return signal_inputs or RcaSignalInputs() def _anomaly_impact_rank(anomaly: MetricAnomaly) -> tuple[float, float, float]: @@ -304,81 +276,82 @@ def _action_for_category(category: RcaCategory | None, service: str = "") -> str return actions.get(category, "Investigate correlated signals.") -def generate( - signal_inputs: RcaSignalInputs | list[MetricAnomaly] | None, - *legacy_signal_groups: object, - correlated_events: list[CorrelatedEvent] | None = None, - graph: DependencyGraph | None = None, - event_registry: EventRegistry | None = None, +def _nearest_deployment( + event: CorrelatedEvent, + deployments: list[DeploymentEvent], + event_registry: EventRegistry | None, + service: str | None = None, +) -> DeploymentEvent | None: + window_seconds = float(settings.rca_deploy_window_seconds) + window_start = float(event.window_start) - window_seconds + window_end = float(event.window_start) + window_seconds + + def _deployment_distance( + deployment: DeploymentEvent, + reference_time: float = event.window_start, + ) -> float: + return abs(deployment.timestamp - reference_time) + + if service and event_registry: + candidates = [d for d in event_registry.for_service(service) if window_start <= d.timestamp <= window_end] + if candidates: + return min(candidates, key=_deployment_distance) + + nearby_deploys = event_registry.in_window(window_start, window_end) if event_registry else [ + d for d in deployments if window_start <= d.timestamp <= window_end + ] + return min(nearby_deploys, key=_deployment_distance) if nearby_deploys else None + + +def _build_event_hypothesis_parts( + event: CorrelatedEvent, + deploy_event: DeploymentEvent | None, +) -> tuple[list[str], list[str], list[str]]: + metric_names = _metric_names_for_hypothesis(event.metric_anomalies, limit=2) + service_names = sorted({service.service for service in event.service_latency})[:2] + process_entities = _process_entities_for_hypothesis(event.metric_anomalies, limit=2) + parts: list[str] = [] + if deploy_event: + parts.append(f"deployment of {deploy_event.service} v{deploy_event.version}") + if metric_names: + parts.append(f"metric anomaly in {', '.join(metric_names)}") + if process_entities: + parts.append(f"process hotspot in {', '.join(process_entities)}") + if service_names: + parts.append(f"latency spike in {', '.join(service_names)}") + if event.log_bursts: + parts.append(f"{len(event.log_bursts)} log burst(s)") + return parts, process_entities, service_names + + +def _correlated_event_causes( + correlated_events: list[CorrelatedEvent], + *, + deployments: list[DeploymentEvent], + graph: DependencyGraph | None, + event_registry: EventRegistry | None, ) -> list[RootCause]: - inputs = _coerce_signal_inputs(signal_inputs, legacy_signal_groups) - _ = (inputs.metric_anomalies, inputs.log_bursts, inputs.service_latency) causes: list[RootCause] = [] - deployments = event_registry.list_all() if event_registry else [] - - for event in correlated_events or []: + for event in correlated_events: if event.confidence < settings.rca_event_confidence_threshold: continue - event_window_start = event.window_start - category = categorize(event, deployments) base_score = score_correlated_event(event) deploy_score = score_deployment_correlation(event.window_start, deployments) confidence = round(min(settings.rca_score_cap, base_score + deploy_score * 0.2), 3) - deploy_event: DeploymentEvent | None = None - window_seconds = float(settings.rca_deploy_window_seconds) - window_start = float(event.window_start) - window_seconds - window_end = float(event.window_start) + window_seconds - - def _deployment_distance( - deployment: DeploymentEvent, - reference_time: float = event_window_start, - ) -> float: - return abs(deployment.timestamp - reference_time) - - if event_registry: - nearby_deploys = event_registry.in_window(window_start, window_end) - else: - nearby_deploys = [d for d in deployments if window_start <= d.timestamp <= window_end] - if nearby_deploys: - deploy_event = min(nearby_deploys, key=_deployment_distance) - - affected: list[str] = [] - root_svc = "" - if event.service_latency and graph: - root_svc = event.service_latency[0].service - blast = graph.blast_radius(root_svc) - affected = blast.affected_downstream - if event_registry: - service_deploys = [ - d for d in event_registry.for_service(root_svc) if window_start <= d.timestamp <= window_end - ] - if service_deploys: - deploy_event = min(service_deploys, key=_deployment_distance) - - metric_names = _metric_names_for_hypothesis(event.metric_anomalies, limit=2) - svc_names = sorted({s.service for s in event.service_latency})[:2] - process_entities = _process_entities_for_hypothesis(event.metric_anomalies, limit=2) - - parts = [] - if deploy_event: - parts.append(f"deployment of {deploy_event.service} v{deploy_event.version}") - if metric_names: - parts.append(f"metric anomaly in {', '.join(metric_names)}") - if process_entities: - parts.append(f"process hotspot in {', '.join(process_entities)}") - if svc_names: - parts.append(f"latency spike in {', '.join(svc_names)}") - if event.log_bursts: - parts.append(f"{len(event.log_bursts)} log burst(s)") - - hypothesis = f"[{category.value}] Correlated incident: {' + '.join(parts) or 'multi-signal event'}" + root_service = event.service_latency[0].service if event.service_latency else "" + deploy_event = _nearest_deployment(event, deployments, event_registry) + affected_services: list[str] = [] + if root_service and graph: + affected_services = graph.blast_radius(root_service).affected_downstream + deploy_event = _nearest_deployment(event, deployments, event_registry, service=root_service) or deploy_event + parts, process_entities, _ = _build_event_hypothesis_parts(event, deploy_event) event_signals = _signals_from_event(event) causes.append( RootCause( - hypothesis=hypothesis, + hypothesis=f"[{category.value}] Correlated incident: {' + '.join(parts) or 'multi-signal event'}", confidence=confidence, severity=Severity.from_score(confidence), category=category, @@ -389,31 +362,63 @@ def _deployment_distance( f"latency_services={len(event.service_latency)}", ], contributing_signals=event_signals, - affected_services=affected, - recommended_action=_action_for_category(category, root_svc), + affected_services=affected_services, + recommended_action=_action_for_category(category, root_service), deployment=deploy_event, corroboration_summary=_corroboration_summary(event_signals), ) ) + return causes - for prop in inputs.error_propagation: - svc = prop.source_service - affected = getattr(prop, "affected_services", []) - conf = score_error_propagation([prop]) - upstream = graph.find_upstream_roots(svc) if graph else [] + +def _error_propagation_causes( + error_propagation: list[ErrorPropagation], + *, + graph: DependencyGraph | None, +) -> list[RootCause]: + causes: list[RootCause] = [] + for propagation in error_propagation: + source_service = propagation.source_service + affected = getattr(propagation, "affected_services", []) + confidence = score_error_propagation([propagation]) + upstream = graph.find_upstream_roots(source_service) if graph else [] all_affected = list(dict.fromkeys(upstream + affected)) + signal = f"trace:propagation:{source_service}" causes.append( RootCause( - hypothesis=f"[error_propagation] Errors originating from {svc}, cascading to {', '.join(affected[:3])}", - confidence=conf, + hypothesis=( + f"[error_propagation] Errors originating from {source_service}, " + f"cascading to {', '.join(affected[:3])}" + ), + confidence=confidence, severity=Severity.HIGH, category=RcaCategory.ERROR_PROPAGATION, - contributing_signals=[f"trace:propagation:{svc}"], + contributing_signals=[signal], affected_services=all_affected, - recommended_action=_action_for_category(RcaCategory.ERROR_PROPAGATION, svc), - corroboration_summary=_corroboration_summary([f"trace:propagation:{svc}"]), + recommended_action=_action_for_category(RcaCategory.ERROR_PROPAGATION, source_service), + corroboration_summary=_corroboration_summary([signal]), ) ) + return causes + + +def generate( + signal_inputs: RcaSignalInputs | None, + *, + correlated_events: list[CorrelatedEvent] | None = None, + graph: DependencyGraph | None = None, + event_registry: EventRegistry | None = None, +) -> list[RootCause]: + inputs = _coerce_signal_inputs(signal_inputs) + _ = (inputs.metric_anomalies, inputs.log_bursts, inputs.service_latency) + deployments = event_registry.list_all() if event_registry else [] + causes = _correlated_event_causes( + correlated_events or [], + deployments=deployments, + graph=graph, + event_registry=event_registry, + ) + causes.extend(_error_propagation_causes(inputs.error_propagation, graph=graph)) critical_patterns = [ p for p in inputs.log_patterns if p.severity.weight() >= settings.rca_severity_weight_threshold diff --git a/engine/rca/scoring.py b/engine/rca/scoring.py index 4a4be56..eaf9291 100644 --- a/engine/rca/scoring.py +++ b/engine/rca/scoring.py @@ -2,11 +2,10 @@ Scoring and categorization logic for RCA hypotheses based on deployment correlation, error propagation, and multi- signal correlation patterns. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/registry.py b/engine/registry.py index 05a0667..121f438 100644 --- a/engine/registry.py +++ b/engine/registry.py @@ -1,11 +1,10 @@ """ Registry for Tenant-Specific Weights and Events. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -74,18 +73,19 @@ def _serialize_weights(weights: dict[Signal, float]) -> dict[str, float]: def _coerce_update_count(value: object) -> int: + parsed = 0 if isinstance(value, bool): - return int(value) - if isinstance(value, int): - return max(0, value) - if isinstance(value, float): - return max(0, int(value)) if math.isfinite(value) else 0 - if isinstance(value, str): + parsed = int(value) + elif isinstance(value, int): + parsed = value + elif isinstance(value, float) and math.isfinite(value): + parsed = int(value) + elif isinstance(value, str): try: - return max(0, int(value)) + parsed = int(value) except ValueError: - return 0 - return 0 + parsed = 0 + return max(0, parsed) class TenantState: diff --git a/engine/slo/__init__.py b/engine/slo/__init__.py index 0d55fea..23fc30c 100644 --- a/engine/slo/__init__.py +++ b/engine/slo/__init__.py @@ -2,11 +2,10 @@ SLO packages for evaluating error budget burn rates and remaining budget based on user-defined thresholds and sensitivity. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.slo.budget import BudgetStatus, remaining_minutes diff --git a/engine/slo/budget.py b/engine/slo/budget.py index b4de08a..893a733 100644 --- a/engine/slo/budget.py +++ b/engine/slo/budget.py @@ -2,11 +2,10 @@ Budget analysis for SLOs, calculating remaining error budget and time based on current error rates and target availability. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/slo/burn.py b/engine/slo/burn.py index 179ebca..35d5995 100644 --- a/engine/slo/burn.py +++ b/engine/slo/burn.py @@ -1,11 +1,10 @@ """ Analyzer Module for Root Cause Analysis and Correlation of Anomalies. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -41,8 +40,18 @@ def evaluate( error_counts: Sequence[float], total_counts: Sequence[float], ts: Sequence[float], - target_availability: float = settings.slo_default_target_availability, + *, + target_availability: float | None = None, ) -> list[SloBurnAlert]: + resolved_target = target_availability + if resolved_target is not None: + try: + resolved_target = float(str(resolved_target)) + except (TypeError, ValueError): + resolved_target = settings.slo_default_target_availability + if resolved_target is None: + resolved_target = settings.slo_default_target_availability + if not error_counts or not total_counts or len(ts) < 2: return [] @@ -59,7 +68,7 @@ def evaluate( return [] error_rate = errors / total - allowed_error_rate = 1.0 - target_availability + allowed_error_rate = 1.0 - resolved_target if allowed_error_rate <= 0: return [] diff --git a/engine/slo/models.py b/engine/slo/models.py index dcd634e..f3517d7 100644 --- a/engine/slo/models.py +++ b/engine/slo/models.py @@ -1,5 +1,10 @@ """ Shared SLO result models. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/topology/__init__.py b/engine/topology/__init__.py index 0ab055c..7c7521c 100644 --- a/engine/topology/__init__.py +++ b/engine/topology/__init__.py @@ -3,6 +3,11 @@ This package provides dependency-graph primitives and blast-radius helpers used by the RCA pipeline to reason about service impact propagation. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.topology.graph import BlastRadius, DependencyGraph diff --git a/engine/topology/graph.py b/engine/topology/graph.py index 89544b8..e5fda0e 100644 --- a/engine/topology/graph.py +++ b/engine/topology/graph.py @@ -2,11 +2,10 @@ Graph representation of service dependencies, with methods for blast radius analysis, upstream root finding, and critical path detection. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/traces/__init__.py b/engine/traces/__init__.py index eb40d4d..5995178 100644 --- a/engine/traces/__init__.py +++ b/engine/traces/__init__.py @@ -1,11 +1,10 @@ """ Package for trace analysis, including latency analysis and error propagation detection. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.traces.errors import detect_propagation diff --git a/engine/traces/common.py b/engine/traces/common.py index ceb9324..bfd3f5d 100644 --- a/engine/traces/common.py +++ b/engine/traces/common.py @@ -1,5 +1,10 @@ """ Shared helpers for iterating Tempo trace spans. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/traces/errors.py b/engine/traces/errors.py index 438b36b..d2bd912 100644 --- a/engine/traces/errors.py +++ b/engine/traces/errors.py @@ -1,11 +1,10 @@ """ Error propagation detection for traces, identifying source services and affected downstream services. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/engine/traces/latency.py b/engine/traces/latency.py index d9d43bc..a1794f4 100644 --- a/engine/traces/latency.py +++ b/engine/traces/latency.py @@ -1,11 +1,10 @@ """ Latency analysis for traces, including Apdex scoring and severity classification. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/main.py b/main.py index 3371970..b810872 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,17 @@ """ Entry point for the Resolver Analysis Engine API server. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations import asyncio import contextlib +import os import logging import sys import time @@ -19,18 +19,30 @@ from contextlib import asynccontextmanager import httpx -import uvicorn from fastapi import FastAPI from fastapi.responses import JSONResponse from fastapi.routing import APIRoute from pydantic import BaseModel, Field -from api.routes import router +from api.routes.analyze import router as analyze_router +from api.routes.causal import router as causal_router +from api.routes.correlation import router as correlation_router +from api.routes.events import router as events_router +from api.routes.forecast import router as forecast_router +from api.routes.health import router as health_router +from api.routes.jobs import router as jobs_router +from api.routes.logs import router as logs_router +from api.routes.metrics import router as metrics_router +from api.routes.ml import router as ml_router +from api.routes.slo import router as slo_router +from api.routes.topology import router as topology_router +from api.routes.traces import router as traces_router from api.routes.common import close_providers from config import LOGS_BACKEND_LOKI, METRICS_BACKEND_MIMIR, TRACES_BACKEND_TEMPO, Settings, settings from database import dispose_database, init_database, init_db from datasources.exceptions import BackendStartupTimeout from middleware.openapi import install_custom_openapi +from middleware.runtime_ssl import RuntimeSSLOptions, run_uvicorn from services.rca_job_service import rca_job_service from services.security_service import InternalAuthMiddleware @@ -73,6 +85,32 @@ def _generate_operation_id(route: APIRoute) -> str: return route.name +async def _bootstrap_database() -> None: + timeout_seconds = float(os.getenv("DATABASE_STARTUP_TIMEOUT", "180")) + retry_delay_seconds = float(os.getenv("DATABASE_STARTUP_RETRY_DELAY", "2")) + deadline = time.monotonic() + timeout_seconds + attempt = 0 + + while True: + attempt += 1 + try: + if settings.database_url: + init_database(settings.database_url) + init_db() + log.info("Resolver database initialization completed") + return + except Exception as exc: # pylint: disable=broad-exception-caught + if time.monotonic() >= deadline: + raise RuntimeError("Resolver database did not become ready before startup timeout") from exc + log.warning( + "Resolver database not ready (attempt %d, retrying in %.1fs): %s", + attempt, + retry_delay_seconds, + exc, + ) + await asyncio.sleep(retry_delay_seconds) + + class ResolverReadyResponse(BaseModel): ready: bool = Field(description="Whether resolver dependencies are currently ready.") backends: dict[str, str] = Field( @@ -85,6 +123,7 @@ async def wait_for( name: str, url: str, timeout: float, + *, headers: dict[str, str] | None = None, accept_status: tuple[int, ...] = (200, 204, 404), ) -> None: @@ -175,8 +214,7 @@ async def _wait_for_all_bg(data_settings: Settings, tenant_id: str) -> None: @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncIterator[None]: if settings.database_url: - init_database(settings.database_url) - init_db() + await _bootstrap_database() await rca_job_service.startup_recovery() tenant_id = settings.default_tenant_id @@ -221,7 +259,22 @@ async def _cleanup_loop() -> None: ) app.add_middleware(InternalAuthMiddleware) -app.include_router(router, prefix="/api/v1") +for api_router in ( + health_router, + analyze_router, + metrics_router, + logs_router, + traces_router, + correlation_router, + slo_router, + topology_router, + events_router, + forecast_router, + causal_router, + ml_router, + jobs_router, +): + app.include_router(api_router, prefix="/api/v1") install_custom_openapi(app) @@ -245,21 +298,11 @@ async def ready() -> JSONResponse: if __name__ == "__main__": - if settings.ssl_enabled: - uvicorn.run( - "main:app", - host=settings.host, - port=settings.port, - log_level="info", - access_log=True, - ssl_certfile=settings.ssl_certfile, - ssl_keyfile=settings.ssl_keyfile, - ) - else: - uvicorn.run( - "main:app", - host=settings.host, - port=settings.port, - log_level="info", - access_log=True, - ) + run_uvicorn( + app, + host=settings.host, + port=settings.port, + log_level="info", + access_log=True, + ssl_options=RuntimeSSLOptions.from_settings(settings), + ) diff --git a/middleware/__init__.py b/middleware/__init__.py index 3e3c07e..a95f9fa 100644 --- a/middleware/__init__.py +++ b/middleware/__init__.py @@ -1,9 +1,8 @@ """ Middleware utilities for Resolver API. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ diff --git a/middleware/openapi.py b/middleware/openapi.py index 42ed5ef..38d3368 100644 --- a/middleware/openapi.py +++ b/middleware/openapi.py @@ -1,11 +1,10 @@ """ OpenAPI customization wiring for the Resolver FastAPI app. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/middleware/runtime_ssl.py b/middleware/runtime_ssl.py new file mode 100644 index 0000000..58d3b40 --- /dev/null +++ b/middleware/runtime_ssl.py @@ -0,0 +1,49 @@ +""" +Runtime SSL helpers for the Resolver service. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed 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 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class RuntimeSSLOptions: + ssl_certfile: str + ssl_keyfile: str + + @classmethod + def from_settings(cls, settings: object) -> RuntimeSSLOptions | None: + if not getattr(settings, "ssl_enabled", False): + return None + + certfile = str(getattr(settings, "ssl_certfile", "")).strip() + keyfile = str(getattr(settings, "ssl_keyfile", "")).strip() + if not certfile or not keyfile: + raise ValueError( + "RESOLVER_SSL_ENABLED=true requires RESOLVER_SSL_CERTFILE and RESOLVER_SSL_KEYFILE to be set" + ) + + return cls(ssl_certfile=certfile, ssl_keyfile=keyfile) + + def to_uvicorn_kwargs(self) -> dict[str, str]: + return { + "ssl_certfile": self.ssl_certfile, + "ssl_keyfile": self.ssl_keyfile, + } + + +def run_uvicorn(app: Any, *, ssl_options: RuntimeSSLOptions | None = None, **kwargs: Any) -> None: + if ssl_options is not None: + kwargs.update(ssl_options.to_uvicorn_kwargs()) + + import uvicorn # pylint: disable=import-outside-toplevel + + uvicorn.run(app=app, **kwargs) diff --git a/openapi.json b/openapi.json index 6bbdb23..33e014f 100644 --- a/openapi.json +++ b/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Resolver Analysis Engine","description":"AI-powered root cause analysis and anomaly detection over logs, metrics, and traces.","version":"0.0.4"},"paths":{"/api/v1/health":{"get":{"tags":["Health"],"summary":"Service health probe","description":"Checks resolver health and reports the active store backend.","operationId":"health","responses":{"200":{"description":"Current resolver health state and store backend in use.","content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Response Health"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/analyze":{"post":{"tags":["RCA"],"summary":"Full cross-signal RCA","operationId":"analyze","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalysisReport"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/analyze/config-template":{"get":{"tags":["RCA"],"summary":"Default RCA YAML config template","operationId":"analyze_config_template","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeConfigTemplateResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/anomalies/metrics":{"post":{"tags":["Metrics"],"summary":"Metric Anomalies","operationId":"metric_anomalies","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MetricRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/MetricAnomaly"},"type":"array","title":"Response Metric Anomalies"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/changepoints":{"post":{"tags":["Metrics"],"summary":"Metric Changepoints","operationId":"metric_changepoints","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangepointRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ChangePoint"},"type":"array","title":"Response Metric Changepoints"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/anomalies/logs/patterns":{"post":{"tags":["Logs"],"summary":"Log Patterns","operationId":"log_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/LogPattern"},"type":"array","title":"Response Log Patterns"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/anomalies/logs/bursts":{"post":{"tags":["Logs"],"summary":"Log Bursts","operationId":"log_bursts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/LogBurst"},"type":"array","title":"Response Log Bursts"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/anomalies/traces":{"post":{"tags":["Traces"],"summary":"Trace Anomalies","operationId":"trace_anomalies","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ServiceLatency"},"type":"array","title":"Response Trace Anomalies"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/correlate":{"post":{"tags":["Correlation"],"summary":"Cross-signal temporal correlation without full RCA","operationId":"correlate_signals","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CorrelateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Response Correlate Signals"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/slo/burn":{"post":{"tags":["SLO"],"summary":"SLO error budget burn rate","operationId":"slo_burn","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SloRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Response Slo Burn"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/topology/blast-radius":{"post":{"tags":["Topology"],"summary":"Service dependency blast radius from traces","operationId":"blast_radius","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TopologyRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Response Blast Radius"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/events/deployment":{"post":{"tags":["Events"],"summary":"Register a deployment event for RCA correlation","operationId":"register_deployment","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeploymentEventRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Register Deployment"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/events/deployments":{"get":{"tags":["Events"],"summary":"List registered deployment events for a tenant","operationId":"list_deployments","parameters":[{"name":"tenant_id","in":"query","required":true,"schema":{"type":"string","title":"Tenant Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"}},"title":"Response List Deployments"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]},"delete":{"tags":["Events"],"summary":"Clear all deployment events for a tenant","operationId":"clear_deployments","parameters":[{"name":"tenant_id","in":"query","required":true,"schema":{"type":"string","title":"Tenant Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Clear Deployments"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/forecast/trajectory":{"post":{"tags":["Forecast"],"summary":"Time-to-failure and degradation trajectory per metric","operationId":"metric_trajectory","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":2000,"minimum":1,"default":100,"title":"Limit"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CorrelateRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"title":"Response Metric Trajectory"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/causal/granger":{"post":{"tags":["Causal"],"summary":"Granger causality between metrics (bounded by default)","operationId":"granger_causality","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":2000,"minimum":1,"default":100,"title":"Limit"}},{"name":"min_strength","in":"query","required":false,"schema":{"type":"number","maximum":1.0,"minimum":0.0,"default":0.05,"title":"Min Strength"}},{"name":"max_series","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":2,"default":25,"title":"Max Series"}},{"name":"include_raw","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Raw"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CorrelateRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"title":"Response Granger Causality"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/causal/bayesian":{"post":{"tags":["Causal"],"summary":"Bayesian posterior over RCA categories given observed signals","operationId":"bayesian_rca","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Response Bayesian Rca"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/ml/weights/feedback":{"post":{"tags":["ML"],"summary":"Submit signal correctness feedback","operationId":"signal_feedback","parameters":[{"name":"tenant_id","in":"query","required":true,"schema":{"type":"string","title":"Tenant Id"}},{"name":"signal","in":"query","required":true,"schema":{"type":"string","title":"Signal"}},{"name":"was_correct","in":"query","required":true,"schema":{"type":"boolean","title":"Was Correct"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"title":"Response Signal Feedback"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/ml/weights":{"get":{"tags":["ML"],"summary":"Current adaptive signal weights for a tenant","operationId":"get_signal_weights","parameters":[{"name":"tenant_id","in":"query","required":true,"schema":{"type":"string","title":"Tenant Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"title":"Response Get Signal Weights"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/ml/weights/reset":{"post":{"tags":["ML"],"summary":"Reset adaptive weights to defaults for a tenant","operationId":"reset_signal_weights","parameters":[{"name":"tenant_id","in":"query","required":true,"schema":{"type":"string","title":"Tenant Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"title":"Response Reset Signal Weights"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/jobs/analyze":{"post":{"tags":["RCA Jobs"],"summary":"Create Job","operationId":"create_job","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeJobCreateRequest"}}},"required":true},"responses":{"202":{"description":"Accepted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeJobCreateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/jobs":{"get":{"tags":["RCA Jobs"],"summary":"List Jobs","operationId":"list_jobs","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/JobStatus"},{"type":"null"}],"title":"Status"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":20,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeJobListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/jobs/{job_id}":{"get":{"tags":["RCA Jobs"],"summary":"Get Job","operationId":"get_job","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeJobSummary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/jobs/{job_id}/result":{"get":{"tags":["RCA Jobs"],"summary":"Get Job Result","operationId":"get_job_result","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeJobResultResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/reports/{report_id}":{"get":{"tags":["RCA Jobs"],"summary":"Get Report","operationId":"get_report","parameters":[{"name":"report_id","in":"path","required":true,"schema":{"type":"string","title":"Report Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeReportResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]},"delete":{"tags":["RCA Jobs"],"summary":"Delete Report","operationId":"delete_report","parameters":[{"name":"report_id","in":"path","required":true,"schema":{"type":"string","title":"Report Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeReportDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/ready":{"get":{"tags":["Health"],"summary":"Backend readiness probe","description":"Returns readiness state for configured backend dependencies.","operationId":"ready","responses":{"200":{"description":"Resolver readiness state and per-backend status details.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolverReadyResponse"}}}},"503":{"description":"Service Unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolverReadyResponse"}}}}}}}},"components":{"schemas":{"AnalysisQuality":{"properties":{"anomaly_density":{"additionalProperties":{"type":"number"},"type":"object","title":"Anomaly Density"},"suppression_counts":{"additionalProperties":{"type":"integer"},"type":"object","title":"Suppression Counts"},"gating_profile":{"type":"string","title":"Gating Profile"},"confidence_calibration_version":{"type":"string","title":"Confidence Calibration Version"}},"type":"object","required":["gating_profile","confidence_calibration_version"],"title":"AnalysisQuality"},"AnalysisReport":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"duration_seconds":{"type":"integer","title":"Duration Seconds"},"metric_anomalies":{"items":{"$ref":"#/components/schemas/MetricAnomaly"},"type":"array","title":"Metric Anomalies"},"log_bursts":{"items":{"$ref":"#/components/schemas/LogBurst"},"type":"array","title":"Log Bursts"},"log_patterns":{"items":{"$ref":"#/components/schemas/LogPattern"},"type":"array","title":"Log Patterns"},"service_latency":{"items":{"$ref":"#/components/schemas/ServiceLatency"},"type":"array","title":"Service Latency"},"error_propagation":{"items":{"$ref":"#/components/schemas/ErrorPropagation"},"type":"array","title":"Error Propagation"},"slo_alerts":{"items":{"$ref":"#/components/schemas/SloBurnAlert"},"type":"array","title":"Slo Alerts","default":[]},"root_causes":{"items":{"$ref":"#/components/schemas/ApiRootCause"},"type":"array","title":"Root Causes"},"ranked_causes":{"items":{"$ref":"#/components/schemas/RankedCause"},"type":"array","title":"Ranked Causes","default":[]},"change_points":{"items":{"$ref":"#/components/schemas/ChangePoint"},"type":"array","title":"Change Points","default":[]},"log_metric_links":{"items":{"$ref":"#/components/schemas/LogMetricLink"},"type":"array","title":"Log Metric Links","default":[]},"forecasts":{"items":{"$ref":"#/components/schemas/TrajectoryForecast"},"type":"array","title":"Forecasts","default":[]},"degradation_signals":{"items":{"$ref":"#/components/schemas/DegradationSignal"},"type":"array","title":"Degradation Signals","default":[]},"anomaly_clusters":{"items":{"$ref":"#/components/schemas/AnomalyCluster"},"type":"array","title":"Anomaly Clusters","default":[]},"granger_results":{"items":{"$ref":"#/components/schemas/GrangerResult"},"type":"array","title":"Granger Results","default":[]},"bayesian_scores":{"items":{"$ref":"#/components/schemas/BayesianScore"},"type":"array","title":"Bayesian Scores","default":[]},"analysis_warnings":{"items":{"type":"string"},"type":"array","title":"Analysis Warnings","default":[]},"overall_severity":{"$ref":"#/components/schemas/Severity"},"summary":{"type":"string","title":"Summary"},"quality":{"anyOf":[{"$ref":"#/components/schemas/AnalysisQuality"},{"type":"null"}]},"metric_series_statistics":{"items":{"$ref":"#/components/schemas/MetricSeriesDistributionStats"},"type":"array","title":"Metric Series Statistics"}},"type":"object","required":["tenant_id","start","end","duration_seconds","metric_anomalies","log_bursts","log_patterns","service_latency","error_propagation","root_causes","overall_severity","summary"],"title":"AnalysisReport"},"AnalyzeConfigTemplateResponse":{"properties":{"version":{"type":"integer","title":"Version"},"defaults":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Defaults"},"template_yaml":{"type":"string","title":"Template Yaml"},"file_name":{"type":"string","title":"File Name"}},"type":"object","required":["version","defaults","template_yaml","file_name"],"title":"AnalyzeConfigTemplateResponse"},"AnalyzeJobCreateRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"services":{"items":{"type":"string"},"type":"array","title":"Services"},"log_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Log Query"},"metric_queries":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Metric Queries"},"config_yaml":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Config Yaml"},"sensitivity":{"anyOf":[{"type":"number","maximum":6.0,"minimum":1.0},{"type":"null"}],"title":"Sensitivity","default":3.0},"apdex_threshold_ms":{"type":"number","title":"Apdex Threshold Ms","default":500.0},"slo_target":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Slo Target","default":0.999},"correlation_window_seconds":{"type":"number","maximum":600.0,"minimum":10.0,"title":"Correlation Window Seconds","default":60.0},"forecast_horizon_seconds":{"type":"number","maximum":86400.0,"minimum":60.0,"title":"Forecast Horizon Seconds","default":1800.0}},"type":"object","required":["tenant_id","start","end"],"title":"AnalyzeJobCreateRequest"},"AnalyzeJobCreateResponse":{"properties":{"job_id":{"type":"string","title":"Job Id"},"report_id":{"type":"string","title":"Report Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"tenant_id":{"type":"string","title":"Tenant Id"},"requested_by":{"type":"string","title":"Requested By"}},"type":"object","required":["job_id","report_id","status","created_at","tenant_id","requested_by"],"title":"AnalyzeJobCreateResponse"},"AnalyzeJobListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/AnalyzeJobSummary"},"type":"array","title":"Items"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"type":"object","required":["items"],"title":"AnalyzeJobListResponse"},"AnalyzeJobResultResponse":{"properties":{"job_id":{"type":"string","title":"Job Id"},"report_id":{"type":"string","title":"Report Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"tenant_id":{"type":"string","title":"Tenant Id"},"requested_by":{"type":"string","title":"Requested By"},"result":{"anyOf":[{"$ref":"#/components/schemas/AnalysisReport"},{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object"},{"type":"null"}],"title":"Result"}},"type":"object","required":["job_id","report_id","status","tenant_id","requested_by"],"title":"AnalyzeJobResultResponse"},"AnalyzeJobSummary":{"properties":{"job_id":{"type":"string","title":"Job Id"},"report_id":{"type":"string","title":"Report Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"finished_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Finished At"},"duration_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Duration Ms"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"summary_preview":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary Preview"},"tenant_id":{"type":"string","title":"Tenant Id"},"requested_by":{"type":"string","title":"Requested By"}},"type":"object","required":["job_id","report_id","status","created_at","tenant_id","requested_by"],"title":"AnalyzeJobSummary"},"AnalyzeReportDeleteResponse":{"properties":{"report_id":{"type":"string","title":"Report Id"},"status":{"$ref":"#/components/schemas/JobStatus","default":"deleted"},"deleted":{"type":"boolean","title":"Deleted","default":true}},"type":"object","required":["report_id"],"title":"AnalyzeReportDeleteResponse"},"AnalyzeReportResponse":{"properties":{"job_id":{"type":"string","title":"Job Id"},"report_id":{"type":"string","title":"Report Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"tenant_id":{"type":"string","title":"Tenant Id"},"requested_by":{"type":"string","title":"Requested By"},"result":{"anyOf":[{"$ref":"#/components/schemas/AnalysisReport"},{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object"},{"type":"null"}],"title":"Result"}},"type":"object","required":["job_id","report_id","status","tenant_id","requested_by"],"title":"AnalyzeReportResponse"},"AnalyzeRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"services":{"items":{"type":"string"},"type":"array","title":"Services"},"log_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Log Query"},"metric_queries":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Metric Queries"},"config_yaml":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Config Yaml"},"sensitivity":{"anyOf":[{"type":"number","maximum":6.0,"minimum":1.0},{"type":"null"}],"title":"Sensitivity","default":3.0},"apdex_threshold_ms":{"type":"number","title":"Apdex Threshold Ms","default":500.0},"slo_target":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Slo Target","default":0.999},"correlation_window_seconds":{"type":"number","maximum":600.0,"minimum":10.0,"title":"Correlation Window Seconds","default":60.0},"forecast_horizon_seconds":{"type":"number","maximum":86400.0,"minimum":60.0,"title":"Forecast Horizon Seconds","default":1800.0}},"type":"object","required":["tenant_id","start","end"],"title":"AnalyzeRequest"},"AnomalyCluster":{"properties":{"cluster_id":{"type":"integer","title":"Cluster Id"},"members":{"items":{"$ref":"#/components/schemas/MetricAnomaly"},"type":"array","title":"Members"},"centroid_timestamp":{"type":"number","title":"Centroid Timestamp"},"centroid_value":{"type":"number","title":"Centroid Value"},"metric_names":{"items":{"type":"string"},"type":"array","title":"Metric Names"},"size":{"type":"integer","title":"Size"},"is_noise":{"type":"boolean","title":"Is Noise","default":false}},"type":"object","required":["cluster_id","members","centroid_timestamp","centroid_value","metric_names","size"],"title":"AnomalyCluster"},"ApiRootCause":{"properties":{"hypothesis":{"type":"string","title":"Hypothesis"},"confidence":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Confidence"},"evidence":{"items":{"type":"string"},"type":"array","title":"Evidence"},"contributing_signals":{"items":{"$ref":"#/components/schemas/Signal"},"type":"array","title":"Contributing Signals"},"recommended_action":{"type":"string","title":"Recommended Action"},"severity":{"$ref":"#/components/schemas/Severity"},"corroboration_summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Corroboration Summary"},"suppression_diagnostics":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Suppression Diagnostics"},"selection_score_components":{"additionalProperties":{"type":"number"},"type":"object","title":"Selection Score Components"}},"type":"object","required":["hypothesis","confidence","evidence","contributing_signals","recommended_action","severity"],"title":"ApiRootCause"},"BayesianScore":{"properties":{"category":{"$ref":"#/components/schemas/RcaCategory"},"posterior":{"type":"number","title":"Posterior"},"prior":{"type":"number","title":"Prior"},"likelihood":{"type":"number","title":"Likelihood"}},"type":"object","required":["category","posterior","prior","likelihood"],"title":"BayesianScore"},"ChangePoint":{"properties":{"index":{"type":"integer","title":"Index"},"timestamp":{"type":"number","title":"Timestamp"},"value_before":{"type":"number","title":"Value Before"},"value_after":{"type":"number","title":"Value After"},"magnitude":{"type":"number","title":"Magnitude"},"change_type":{"$ref":"#/components/schemas/ChangeType"},"metric_name":{"type":"string","title":"Metric Name","default":"metric"}},"type":"object","required":["index","timestamp","value_before","value_after","magnitude","change_type"],"title":"ChangePoint"},"ChangeType":{"type":"string","enum":["spike","drop","drift","shift","oscillation"],"title":"ChangeType"},"ChangepointRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"query":{"type":"string","title":"Query"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"threshold_sigma":{"type":"number","maximum":10.0,"minimum":1.0,"title":"Threshold Sigma","default":4.0}},"type":"object","required":["tenant_id","query","start","end"],"title":"ChangepointRequest"},"CorrelateRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"services":{"items":{"type":"string"},"type":"array","title":"Services"},"log_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Log Query"},"metric_queries":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Metric Queries"},"window_seconds":{"type":"number","maximum":600.0,"minimum":10.0,"title":"Window Seconds","default":60.0}},"type":"object","required":["tenant_id","start","end"],"title":"CorrelateRequest"},"DegradationSignal":{"properties":{"metric_name":{"type":"string","title":"Metric Name"},"degradation_rate":{"type":"number","title":"Degradation Rate"},"volatility":{"type":"number","title":"Volatility"},"trend":{"type":"string","title":"Trend"},"window_seconds":{"type":"number","title":"Window Seconds"},"severity":{"$ref":"#/components/schemas/Severity"},"is_accelerating":{"type":"boolean","title":"Is Accelerating"}},"type":"object","required":["metric_name","degradation_rate","volatility","trend","window_seconds","severity","is_accelerating"],"title":"DegradationSignal"},"DeploymentEvent":{"properties":{"service":{"type":"string","title":"Service"},"timestamp":{"type":"number","title":"Timestamp"},"version":{"type":"string","title":"Version"},"author":{"type":"string","title":"Author","default":""},"environment":{"type":"string","title":"Environment","default":"production"},"source":{"type":"string","title":"Source","default":"unknown"},"metadata":{"additionalProperties":{"type":"string"},"type":"object","title":"Metadata"}},"type":"object","required":["service","timestamp","version"],"title":"DeploymentEvent"},"DeploymentEventRequest":{"properties":{"service":{"type":"string","title":"Service"},"timestamp":{"type":"number","title":"Timestamp"},"version":{"type":"string","title":"Version"},"author":{"type":"string","title":"Author","default":""},"environment":{"type":"string","title":"Environment","default":"production"},"source":{"type":"string","title":"Source","default":"unknown"},"metadata":{"additionalProperties":{"type":"string"},"type":"object","title":"Metadata"},"tenant_id":{"type":"string","title":"Tenant Id"}},"type":"object","required":["service","timestamp","version","tenant_id"],"title":"DeploymentEventRequest"},"ErrorPropagation":{"properties":{"source_service":{"type":"string","title":"Source Service"},"affected_services":{"items":{"type":"string"},"type":"array","title":"Affected Services"},"error_rate":{"type":"number","title":"Error Rate"},"severity":{"$ref":"#/components/schemas/Severity"}},"type":"object","required":["source_service","affected_services","error_rate","severity"],"title":"ErrorPropagation"},"GrangerResult":{"properties":{"cause_metric":{"type":"string","title":"Cause Metric"},"effect_metric":{"type":"string","title":"Effect Metric"},"max_lag":{"type":"integer","title":"Max Lag"},"f_statistic":{"type":"number","title":"F Statistic"},"p_value":{"type":"number","title":"P Value"},"is_causal":{"type":"boolean","title":"Is Causal"},"strength":{"type":"number","title":"Strength"}},"type":"object","required":["cause_metric","effect_metric","max_lag","f_statistic","p_value","is_causal","strength"],"title":"GrangerResult"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HypothesisRootCause":{"properties":{"hypothesis":{"type":"string","title":"Hypothesis"},"confidence":{"type":"number","title":"Confidence"},"severity":{"$ref":"#/components/schemas/Severity"},"category":{"$ref":"#/components/schemas/RcaCategory"},"evidence":{"items":{"type":"string"},"type":"array","title":"Evidence"},"contributing_signals":{"items":{"type":"string"},"type":"array","title":"Contributing Signals"},"affected_services":{"items":{"type":"string"},"type":"array","title":"Affected Services"},"recommended_action":{"type":"string","title":"Recommended Action","default":""},"deployment":{"anyOf":[{"$ref":"#/components/schemas/DeploymentEvent"},{"type":"null"}]},"corroboration_summary":{"type":"string","title":"Corroboration Summary","default":""}},"type":"object","required":["hypothesis","confidence","severity","category"],"title":"HypothesisRootCause"},"JSONValue":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object"},{"items":{"$ref":"#/components/schemas/JSONValue"},"type":"array"},{"type":"null"}]},"JobStatus":{"type":"string","enum":["queued","running","completed","failed","cancelled","deleted"],"title":"JobStatus"},"LogBurst":{"properties":{"window_start":{"type":"number","title":"Window Start"},"window_end":{"type":"number","title":"Window End"},"rate_per_second":{"type":"number","title":"Rate Per Second"},"baseline_rate":{"type":"number","title":"Baseline Rate"},"ratio":{"type":"number","title":"Ratio"},"severity":{"$ref":"#/components/schemas/Severity"}},"type":"object","required":["window_start","window_end","rate_per_second","baseline_rate","ratio","severity"],"title":"LogBurst"},"LogMetricLink":{"properties":{"metric_name":{"type":"string","title":"Metric Name"},"metric_timestamp":{"type":"number","title":"Metric Timestamp"},"log_stream":{"type":"string","title":"Log Stream"},"log_burst_start":{"type":"number","title":"Log Burst Start"},"lag_seconds":{"type":"number","title":"Lag Seconds"},"strength":{"type":"number","title":"Strength"}},"type":"object","required":["metric_name","metric_timestamp","log_stream","log_burst_start","lag_seconds","strength"],"title":"LogMetricLink"},"LogPattern":{"properties":{"pattern":{"type":"string","title":"Pattern"},"count":{"type":"integer","title":"Count"},"first_seen":{"type":"number","title":"First Seen"},"last_seen":{"type":"number","title":"Last Seen"},"rate_per_minute":{"type":"number","title":"Rate Per Minute"},"entropy":{"type":"number","title":"Entropy"},"severity":{"$ref":"#/components/schemas/Severity"},"sample":{"type":"string","title":"Sample"}},"type":"object","required":["pattern","count","first_seen","last_seen","rate_per_minute","entropy","severity","sample"],"title":"LogPattern"},"LogRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"query":{"type":"string","title":"Query"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"}},"type":"object","required":["tenant_id","query","start","end"],"title":"LogRequest"},"MetricAnomaly":{"properties":{"metric_name":{"type":"string","title":"Metric Name"},"timestamp":{"type":"number","title":"Timestamp"},"value":{"type":"number","title":"Value"},"change_type":{"$ref":"#/components/schemas/ChangeType"},"z_score":{"type":"number","title":"Z Score"},"mad_score":{"type":"number","title":"Mad Score"},"isolation_score":{"type":"number","title":"Isolation Score"},"expected_range":{"prefixItems":[{"type":"number"},{"type":"number"}],"type":"array","maxItems":2,"minItems":2,"title":"Expected Range"},"severity":{"$ref":"#/components/schemas/Severity"},"description":{"type":"string","title":"Description"},"iqr_score":{"type":"number","title":"Iqr Score","default":0.0},"tukey_outlier_class":{"type":"string","title":"Tukey Outlier Class","default":"none"}},"type":"object","required":["metric_name","timestamp","value","change_type","z_score","mad_score","isolation_score","expected_range","severity","description"],"title":"MetricAnomaly"},"MetricRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"query":{"type":"string","title":"Query"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"sensitivity":{"anyOf":[{"type":"number","maximum":6.0,"minimum":1.0},{"type":"null"}],"title":"Sensitivity","default":3.0}},"type":"object","required":["tenant_id","query","start","end"],"title":"MetricRequest"},"MetricSeriesDistributionStats":{"properties":{"series_key":{"type":"string","title":"Series Key","default":""},"metric_name":{"type":"string","title":"Metric Name"},"sample_count":{"type":"integer","title":"Sample Count"},"mean":{"type":"number","title":"Mean"},"std":{"type":"number","title":"Std"},"min":{"type":"number","title":"Min"},"max":{"type":"number","title":"Max"},"median":{"type":"number","title":"Median"},"q1":{"type":"number","title":"Q1"},"q3":{"type":"number","title":"Q3"},"iqr":{"type":"number","title":"Iqr"},"mad":{"type":"number","title":"Mad"},"skewness":{"type":"number","title":"Skewness"},"kurtosis":{"type":"number","title":"Kurtosis"},"coefficient_of_variation":{"type":"number","title":"Coefficient Of Variation"}},"type":"object","required":["metric_name","sample_count","mean","std","min","max","median","q1","q3","iqr","mad","skewness","kurtosis","coefficient_of_variation"],"title":"MetricSeriesDistributionStats"},"RankedCause":{"properties":{"root_cause":{"$ref":"#/components/schemas/HypothesisRootCause"},"ml_score":{"type":"number","title":"Ml Score"},"final_score":{"type":"number","title":"Final Score"},"feature_importance":{"additionalProperties":{"type":"number"},"type":"object","title":"Feature Importance"}},"type":"object","required":["root_cause","ml_score","final_score","feature_importance"],"title":"RankedCause"},"RcaCategory":{"type":"string","enum":["deployment","resource_exhaustion","dependency_failure","traffic_surge","error_propagation","slo_burn","unknown"],"title":"RcaCategory"},"ResolverReadyResponse":{"properties":{"ready":{"type":"boolean","title":"Ready","description":"Whether resolver dependencies are currently ready."},"backends":{"additionalProperties":{"type":"string"},"type":"object","title":"Backends","description":"Per-backend readiness details keyed by backend name."}},"type":"object","required":["ready"],"title":"ResolverReadyResponse"},"ServiceLatency":{"properties":{"service":{"type":"string","title":"Service"},"operation":{"type":"string","title":"Operation"},"p50_ms":{"type":"number","title":"P50 Ms"},"p95_ms":{"type":"number","title":"P95 Ms"},"p99_ms":{"type":"number","title":"P99 Ms"},"apdex":{"type":"number","title":"Apdex"},"error_rate":{"type":"number","title":"Error Rate"},"sample_count":{"type":"integer","title":"Sample Count"},"severity":{"$ref":"#/components/schemas/Severity"},"window_start":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Window Start"},"window_end":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Window End"}},"type":"object","required":["service","operation","p50_ms","p95_ms","p99_ms","apdex","error_rate","sample_count","severity"],"title":"ServiceLatency"},"Severity":{"type":"string","enum":["low","medium","high","critical"],"title":"Severity"},"Signal":{"type":"string","enum":["metrics","logs","traces","events"],"title":"Signal"},"SloBurnAlert":{"properties":{"service":{"type":"string","title":"Service"},"window_label":{"type":"string","title":"Window Label"},"error_rate":{"type":"number","title":"Error Rate"},"burn_rate":{"type":"number","title":"Burn Rate"},"budget_consumed_pct":{"type":"number","title":"Budget Consumed Pct"},"severity":{"$ref":"#/components/schemas/Severity"}},"type":"object","required":["service","window_label","error_rate","burn_rate","budget_consumed_pct","severity"],"title":"SloBurnAlert"},"SloRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"service":{"type":"string","title":"Service"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"target_availability":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Target Availability","default":0.999},"error_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Query"},"total_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Total Query"}},"type":"object","required":["tenant_id","service","start","end"],"title":"SloRequest"},"TopologyRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"root_service":{"type":"string","title":"Root Service"},"max_depth":{"type":"integer","maximum":10.0,"minimum":1.0,"title":"Max Depth","default":6}},"type":"object","required":["tenant_id","start","end","root_service"],"title":"TopologyRequest"},"TraceRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"service":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Service"},"apdex_threshold_ms":{"type":"number","title":"Apdex Threshold Ms","default":500.0}},"type":"object","required":["tenant_id","start","end"],"title":"TraceRequest"},"TrajectoryForecast":{"properties":{"metric_name":{"type":"string","title":"Metric Name"},"current_value":{"type":"number","title":"Current Value"},"slope_per_second":{"type":"number","title":"Slope Per Second"},"predicted_value_at_horizon":{"type":"number","title":"Predicted Value At Horizon"},"time_to_threshold_seconds":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Time To Threshold Seconds"},"breach_threshold":{"type":"number","title":"Breach Threshold"},"confidence":{"type":"number","title":"Confidence"},"severity":{"$ref":"#/components/schemas/Severity"}},"type":"object","required":["metric_name","current_value","slope_per_second","predicted_value_at_horizon","time_to_threshold_seconds","breach_threshold","confidence","severity"],"title":"TrajectoryForecast"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"ErrorResponse":{"type":"object","properties":{"detail":{"type":"string","title":"Detail"}},"required":["detail"],"title":"ErrorResponse"}},"securitySchemes":{"ServiceToken":{"type":"apiKey","in":"header","name":"x-service-token","description":"Internal service token required for resolver API access."},"ContextBearer":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Context JWT carrying tenant and user scope for internal calls."}}},"jsonSchemaDialect":"https://spec.openapis.org/oas/3.1/dialect/base"} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Resolver Analysis Engine","description":"AI-powered root cause analysis and anomaly detection over logs, metrics, and traces.","version":"0.0.5"},"paths":{"/api/v1/health":{"get":{"tags":["Health"],"summary":"Service health probe","description":"Checks resolver health and reports the active store backend.","operationId":"health","responses":{"200":{"description":"Current resolver health state and store backend in use.","content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Response Health"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/analyze":{"post":{"tags":["RCA"],"summary":"Full cross-signal RCA","operationId":"analyze","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalysisReport"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/analyze/config-template":{"get":{"tags":["RCA"],"summary":"Default RCA YAML config template","operationId":"analyze_config_template","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeConfigTemplateResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/anomalies/metrics":{"post":{"tags":["Metrics"],"summary":"Metric Anomalies","operationId":"metric_anomalies","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MetricRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/MetricAnomaly"},"type":"array","title":"Response Metric Anomalies"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/changepoints":{"post":{"tags":["Metrics"],"summary":"Metric Changepoints","operationId":"metric_changepoints","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangepointRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ChangePoint"},"type":"array","title":"Response Metric Changepoints"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/anomalies/logs/patterns":{"post":{"tags":["Logs"],"summary":"Log Patterns","operationId":"log_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/LogPattern"},"type":"array","title":"Response Log Patterns"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/anomalies/logs/bursts":{"post":{"tags":["Logs"],"summary":"Log Bursts","operationId":"log_bursts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/LogBurst"},"type":"array","title":"Response Log Bursts"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/anomalies/traces":{"post":{"tags":["Traces"],"summary":"Trace Anomalies","operationId":"trace_anomalies","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ServiceLatency"},"type":"array","title":"Response Trace Anomalies"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/correlate":{"post":{"tags":["Correlation"],"summary":"Cross-signal temporal correlation without full RCA","operationId":"correlate_signals","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CorrelateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Response Correlate Signals"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/slo/burn":{"post":{"tags":["SLO"],"summary":"SLO error budget burn rate","operationId":"slo_burn","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SloRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Response Slo Burn"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/topology/blast-radius":{"post":{"tags":["Topology"],"summary":"Service dependency blast radius from traces","operationId":"blast_radius","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TopologyRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Response Blast Radius"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/events/deployment":{"post":{"tags":["Events"],"summary":"Register a deployment event for RCA correlation","operationId":"register_deployment","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeploymentEventRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Register Deployment"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/events/deployments":{"get":{"tags":["Events"],"summary":"List registered deployment events for a tenant","operationId":"list_deployments","parameters":[{"name":"tenant_id","in":"query","required":true,"schema":{"type":"string","title":"Tenant Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"}},"title":"Response List Deployments"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]},"delete":{"tags":["Events"],"summary":"Clear all deployment events for a tenant","operationId":"clear_deployments","parameters":[{"name":"tenant_id","in":"query","required":true,"schema":{"type":"string","title":"Tenant Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Clear Deployments"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/forecast/trajectory":{"post":{"tags":["Forecast"],"summary":"Time-to-failure and degradation trajectory per metric","operationId":"metric_trajectory","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":2000,"minimum":1,"default":100,"title":"Limit"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CorrelateRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"title":"Response Metric Trajectory"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/causal/granger":{"post":{"tags":["Causal"],"summary":"Granger causality between metrics (bounded by default)","operationId":"granger_causality","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":2000,"minimum":1,"default":100,"title":"Limit"}},{"name":"min_strength","in":"query","required":false,"schema":{"type":"number","maximum":1.0,"minimum":0.0,"default":0.05,"title":"Min Strength"}},{"name":"max_series","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":2,"default":25,"title":"Max Series"}},{"name":"include_raw","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Raw"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CorrelateRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"title":"Response Granger Causality"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/causal/bayesian":{"post":{"tags":["Causal"],"summary":"Bayesian posterior over RCA categories given observed signals","operationId":"bayesian_rca","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Response Bayesian Rca"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/ml/weights/feedback":{"post":{"tags":["ML"],"summary":"Submit signal correctness feedback","operationId":"signal_feedback","parameters":[{"name":"tenant_id","in":"query","required":true,"schema":{"type":"string","title":"Tenant Id"}},{"name":"signal","in":"query","required":true,"schema":{"type":"string","title":"Signal"}},{"name":"was_correct","in":"query","required":true,"schema":{"type":"boolean","title":"Was Correct"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"title":"Response Signal Feedback"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/ml/weights":{"get":{"tags":["ML"],"summary":"Current adaptive signal weights for a tenant","operationId":"get_signal_weights","parameters":[{"name":"tenant_id","in":"query","required":true,"schema":{"type":"string","title":"Tenant Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"title":"Response Get Signal Weights"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/ml/weights/reset":{"post":{"tags":["ML"],"summary":"Reset adaptive weights to defaults for a tenant","operationId":"reset_signal_weights","parameters":[{"name":"tenant_id","in":"query","required":true,"schema":{"type":"string","title":"Tenant Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"title":"Response Reset Signal Weights"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/jobs/analyze":{"post":{"tags":["RCA Jobs"],"summary":"Create Job","operationId":"create_job","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeJobCreateRequest"}}},"required":true},"responses":{"202":{"description":"Accepted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeJobCreateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/jobs":{"get":{"tags":["RCA Jobs"],"summary":"List Jobs","operationId":"list_jobs","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/JobStatus"},{"type":"null"}],"title":"Status"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":20,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeJobListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/jobs/{job_id}":{"get":{"tags":["RCA Jobs"],"summary":"Get Job","operationId":"get_job","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeJobSummary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/jobs/{job_id}/result":{"get":{"tags":["RCA Jobs"],"summary":"Get Job Result","operationId":"get_job_result","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeJobResultResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/reports/{report_id}":{"get":{"tags":["RCA Jobs"],"summary":"Get Report","operationId":"get_report","parameters":[{"name":"report_id","in":"path","required":true,"schema":{"type":"string","title":"Report Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeReportResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]},"delete":{"tags":["RCA Jobs"],"summary":"Delete Report","operationId":"delete_report","parameters":[{"name":"report_id","in":"path","required":true,"schema":{"type":"string","title":"Report Id"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeReportDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"security":[{"ServiceToken":[],"ContextBearer":[]}]}},"/api/v1/ready":{"get":{"tags":["Health"],"summary":"Backend readiness probe","description":"Returns readiness state for configured backend dependencies.","operationId":"ready","responses":{"200":{"description":"Resolver readiness state and per-backend status details.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolverReadyResponse"}}}},"503":{"description":"Service Unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolverReadyResponse"}}}}}}}},"components":{"schemas":{"AnalysisQuality":{"properties":{"anomaly_density":{"additionalProperties":{"type":"number"},"type":"object","title":"Anomaly Density"},"suppression_counts":{"additionalProperties":{"type":"integer"},"type":"object","title":"Suppression Counts"},"gating_profile":{"type":"string","title":"Gating Profile"},"confidence_calibration_version":{"type":"string","title":"Confidence Calibration Version"}},"type":"object","required":["gating_profile","confidence_calibration_version"],"title":"AnalysisQuality"},"AnalysisReport":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"duration_seconds":{"type":"integer","title":"Duration Seconds"},"metric_anomalies":{"items":{"$ref":"#/components/schemas/MetricAnomaly"},"type":"array","title":"Metric Anomalies"},"log_bursts":{"items":{"$ref":"#/components/schemas/LogBurst"},"type":"array","title":"Log Bursts"},"log_patterns":{"items":{"$ref":"#/components/schemas/LogPattern"},"type":"array","title":"Log Patterns"},"service_latency":{"items":{"$ref":"#/components/schemas/ServiceLatency"},"type":"array","title":"Service Latency"},"error_propagation":{"items":{"$ref":"#/components/schemas/ErrorPropagation"},"type":"array","title":"Error Propagation"},"slo_alerts":{"items":{"$ref":"#/components/schemas/SloBurnAlert"},"type":"array","title":"Slo Alerts","default":[]},"root_causes":{"items":{"$ref":"#/components/schemas/ApiRootCause"},"type":"array","title":"Root Causes"},"ranked_causes":{"items":{"$ref":"#/components/schemas/RankedCause"},"type":"array","title":"Ranked Causes","default":[]},"change_points":{"items":{"$ref":"#/components/schemas/ChangePoint"},"type":"array","title":"Change Points","default":[]},"log_metric_links":{"items":{"$ref":"#/components/schemas/LogMetricLink"},"type":"array","title":"Log Metric Links","default":[]},"forecasts":{"items":{"$ref":"#/components/schemas/TrajectoryForecast"},"type":"array","title":"Forecasts","default":[]},"degradation_signals":{"items":{"$ref":"#/components/schemas/DegradationSignal"},"type":"array","title":"Degradation Signals","default":[]},"anomaly_clusters":{"items":{"$ref":"#/components/schemas/AnomalyCluster"},"type":"array","title":"Anomaly Clusters","default":[]},"granger_results":{"items":{"$ref":"#/components/schemas/GrangerResult"},"type":"array","title":"Granger Results","default":[]},"bayesian_scores":{"items":{"$ref":"#/components/schemas/BayesianScore"},"type":"array","title":"Bayesian Scores","default":[]},"analysis_warnings":{"items":{"type":"string"},"type":"array","title":"Analysis Warnings","default":[]},"overall_severity":{"$ref":"#/components/schemas/Severity"},"summary":{"type":"string","title":"Summary"},"quality":{"anyOf":[{"$ref":"#/components/schemas/AnalysisQuality"},{"type":"null"}]},"metric_series_statistics":{"items":{"$ref":"#/components/schemas/MetricSeriesDistributionStats"},"type":"array","title":"Metric Series Statistics"}},"type":"object","required":["tenant_id","start","end","duration_seconds","metric_anomalies","log_bursts","log_patterns","service_latency","error_propagation","root_causes","overall_severity","summary"],"title":"AnalysisReport"},"AnalyzeConfigTemplateResponse":{"properties":{"version":{"type":"integer","title":"Version"},"defaults":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Defaults"},"template_yaml":{"type":"string","title":"Template Yaml"},"file_name":{"type":"string","title":"File Name"}},"type":"object","required":["version","defaults","template_yaml","file_name"],"title":"AnalyzeConfigTemplateResponse"},"AnalyzeJobCreateRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"services":{"items":{"type":"string"},"type":"array","title":"Services"},"log_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Log Query"},"metric_queries":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Metric Queries"},"config_yaml":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Config Yaml"},"sensitivity":{"anyOf":[{"type":"number","maximum":6.0,"minimum":1.0},{"type":"null"}],"title":"Sensitivity","default":3.0},"apdex_threshold_ms":{"type":"number","title":"Apdex Threshold Ms","default":500.0},"slo_target":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Slo Target","default":0.999},"correlation_window_seconds":{"type":"number","maximum":600.0,"minimum":10.0,"title":"Correlation Window Seconds","default":60.0},"forecast_horizon_seconds":{"type":"number","maximum":86400.0,"minimum":60.0,"title":"Forecast Horizon Seconds","default":1800.0}},"type":"object","required":["tenant_id","start","end"],"title":"AnalyzeJobCreateRequest"},"AnalyzeJobCreateResponse":{"properties":{"job_id":{"type":"string","title":"Job Id"},"report_id":{"type":"string","title":"Report Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"tenant_id":{"type":"string","title":"Tenant Id"},"requested_by":{"type":"string","title":"Requested By"}},"type":"object","required":["job_id","report_id","status","created_at","tenant_id","requested_by"],"title":"AnalyzeJobCreateResponse"},"AnalyzeJobListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/AnalyzeJobSummary"},"type":"array","title":"Items"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"type":"object","required":["items"],"title":"AnalyzeJobListResponse"},"AnalyzeJobResultResponse":{"properties":{"job_id":{"type":"string","title":"Job Id"},"report_id":{"type":"string","title":"Report Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"tenant_id":{"type":"string","title":"Tenant Id"},"requested_by":{"type":"string","title":"Requested By"},"result":{"anyOf":[{"$ref":"#/components/schemas/AnalysisReport"},{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object"},{"type":"null"}],"title":"Result"}},"type":"object","required":["job_id","report_id","status","tenant_id","requested_by"],"title":"AnalyzeJobResultResponse"},"AnalyzeJobSummary":{"properties":{"job_id":{"type":"string","title":"Job Id"},"report_id":{"type":"string","title":"Report Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"finished_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Finished At"},"duration_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Duration Ms"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"summary_preview":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary Preview"},"tenant_id":{"type":"string","title":"Tenant Id"},"requested_by":{"type":"string","title":"Requested By"}},"type":"object","required":["job_id","report_id","status","created_at","tenant_id","requested_by"],"title":"AnalyzeJobSummary"},"AnalyzeReportDeleteResponse":{"properties":{"report_id":{"type":"string","title":"Report Id"},"status":{"$ref":"#/components/schemas/JobStatus","default":"deleted"},"deleted":{"type":"boolean","title":"Deleted","default":true}},"type":"object","required":["report_id"],"title":"AnalyzeReportDeleteResponse"},"AnalyzeReportResponse":{"properties":{"job_id":{"type":"string","title":"Job Id"},"report_id":{"type":"string","title":"Report Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"tenant_id":{"type":"string","title":"Tenant Id"},"requested_by":{"type":"string","title":"Requested By"},"result":{"anyOf":[{"$ref":"#/components/schemas/AnalysisReport"},{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object"},{"type":"null"}],"title":"Result"}},"type":"object","required":["job_id","report_id","status","tenant_id","requested_by"],"title":"AnalyzeReportResponse"},"AnalyzeRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"services":{"items":{"type":"string"},"type":"array","title":"Services"},"log_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Log Query"},"metric_queries":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Metric Queries"},"config_yaml":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Config Yaml"},"sensitivity":{"anyOf":[{"type":"number","maximum":6.0,"minimum":1.0},{"type":"null"}],"title":"Sensitivity","default":3.0},"apdex_threshold_ms":{"type":"number","title":"Apdex Threshold Ms","default":500.0},"slo_target":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Slo Target","default":0.999},"correlation_window_seconds":{"type":"number","maximum":600.0,"minimum":10.0,"title":"Correlation Window Seconds","default":60.0},"forecast_horizon_seconds":{"type":"number","maximum":86400.0,"minimum":60.0,"title":"Forecast Horizon Seconds","default":1800.0}},"type":"object","required":["tenant_id","start","end"],"title":"AnalyzeRequest"},"AnomalyCluster":{"properties":{"cluster_id":{"type":"integer","title":"Cluster Id"},"members":{"items":{"$ref":"#/components/schemas/MetricAnomaly"},"type":"array","title":"Members"},"centroid_timestamp":{"type":"number","title":"Centroid Timestamp"},"centroid_value":{"type":"number","title":"Centroid Value"},"metric_names":{"items":{"type":"string"},"type":"array","title":"Metric Names"},"size":{"type":"integer","title":"Size"},"is_noise":{"type":"boolean","title":"Is Noise","default":false}},"type":"object","required":["cluster_id","members","centroid_timestamp","centroid_value","metric_names","size"],"title":"AnomalyCluster"},"ApiRootCause":{"properties":{"hypothesis":{"type":"string","title":"Hypothesis"},"confidence":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Confidence"},"evidence":{"items":{"type":"string"},"type":"array","title":"Evidence"},"contributing_signals":{"items":{"$ref":"#/components/schemas/Signal"},"type":"array","title":"Contributing Signals"},"recommended_action":{"type":"string","title":"Recommended Action"},"severity":{"$ref":"#/components/schemas/Severity"},"corroboration_summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Corroboration Summary"},"suppression_diagnostics":{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object","title":"Suppression Diagnostics"},"selection_score_components":{"additionalProperties":{"type":"number"},"type":"object","title":"Selection Score Components"}},"type":"object","required":["hypothesis","confidence","evidence","contributing_signals","recommended_action","severity"],"title":"ApiRootCause"},"BayesianScore":{"properties":{"category":{"$ref":"#/components/schemas/RcaCategory"},"posterior":{"type":"number","title":"Posterior"},"prior":{"type":"number","title":"Prior"},"likelihood":{"type":"number","title":"Likelihood"}},"type":"object","required":["category","posterior","prior","likelihood"],"title":"BayesianScore"},"ChangePoint":{"properties":{"index":{"type":"integer","title":"Index"},"timestamp":{"type":"number","title":"Timestamp"},"value_before":{"type":"number","title":"Value Before"},"value_after":{"type":"number","title":"Value After"},"magnitude":{"type":"number","title":"Magnitude"},"change_type":{"$ref":"#/components/schemas/ChangeType"},"metric_name":{"type":"string","title":"Metric Name","default":"metric"}},"type":"object","required":["index","timestamp","value_before","value_after","magnitude","change_type"],"title":"ChangePoint"},"ChangeType":{"type":"string","enum":["spike","drop","drift","shift","oscillation"],"title":"ChangeType"},"ChangepointRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"query":{"type":"string","title":"Query"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"threshold_sigma":{"type":"number","maximum":10.0,"minimum":1.0,"title":"Threshold Sigma","default":4.0}},"type":"object","required":["tenant_id","query","start","end"],"title":"ChangepointRequest"},"CorrelateRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"services":{"items":{"type":"string"},"type":"array","title":"Services"},"log_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Log Query"},"metric_queries":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Metric Queries"},"window_seconds":{"type":"number","maximum":600.0,"minimum":10.0,"title":"Window Seconds","default":60.0}},"type":"object","required":["tenant_id","start","end"],"title":"CorrelateRequest"},"DegradationSignal":{"properties":{"metric_name":{"type":"string","title":"Metric Name"},"degradation_rate":{"type":"number","title":"Degradation Rate"},"volatility":{"type":"number","title":"Volatility"},"trend":{"type":"string","title":"Trend"},"window_seconds":{"type":"number","title":"Window Seconds"},"severity":{"$ref":"#/components/schemas/Severity"},"is_accelerating":{"type":"boolean","title":"Is Accelerating"}},"type":"object","required":["metric_name","degradation_rate","volatility","trend","window_seconds","severity","is_accelerating"],"title":"DegradationSignal"},"DeploymentEvent":{"properties":{"service":{"type":"string","title":"Service"},"timestamp":{"type":"number","title":"Timestamp"},"version":{"type":"string","title":"Version"},"author":{"type":"string","title":"Author","default":""},"environment":{"type":"string","title":"Environment","default":"production"},"source":{"type":"string","title":"Source","default":"unknown"},"metadata":{"additionalProperties":{"type":"string"},"type":"object","title":"Metadata"}},"type":"object","required":["service","timestamp","version"],"title":"DeploymentEvent"},"DeploymentEventRequest":{"properties":{"service":{"type":"string","title":"Service"},"timestamp":{"type":"number","title":"Timestamp"},"version":{"type":"string","title":"Version"},"author":{"type":"string","title":"Author","default":""},"environment":{"type":"string","title":"Environment","default":"production"},"source":{"type":"string","title":"Source","default":"unknown"},"metadata":{"additionalProperties":{"type":"string"},"type":"object","title":"Metadata"},"tenant_id":{"type":"string","title":"Tenant Id"}},"type":"object","required":["service","timestamp","version","tenant_id"],"title":"DeploymentEventRequest"},"ErrorPropagation":{"properties":{"source_service":{"type":"string","title":"Source Service"},"affected_services":{"items":{"type":"string"},"type":"array","title":"Affected Services"},"error_rate":{"type":"number","title":"Error Rate"},"severity":{"$ref":"#/components/schemas/Severity"}},"type":"object","required":["source_service","affected_services","error_rate","severity"],"title":"ErrorPropagation"},"GrangerResult":{"properties":{"cause_metric":{"type":"string","title":"Cause Metric"},"effect_metric":{"type":"string","title":"Effect Metric"},"max_lag":{"type":"integer","title":"Max Lag"},"f_statistic":{"type":"number","title":"F Statistic"},"p_value":{"type":"number","title":"P Value"},"is_causal":{"type":"boolean","title":"Is Causal"},"strength":{"type":"number","title":"Strength"}},"type":"object","required":["cause_metric","effect_metric","max_lag","f_statistic","p_value","is_causal","strength"],"title":"GrangerResult"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HypothesisRootCause":{"properties":{"hypothesis":{"type":"string","title":"Hypothesis"},"confidence":{"type":"number","title":"Confidence"},"severity":{"$ref":"#/components/schemas/Severity"},"category":{"$ref":"#/components/schemas/RcaCategory"},"evidence":{"items":{"type":"string"},"type":"array","title":"Evidence"},"contributing_signals":{"items":{"type":"string"},"type":"array","title":"Contributing Signals"},"affected_services":{"items":{"type":"string"},"type":"array","title":"Affected Services"},"recommended_action":{"type":"string","title":"Recommended Action","default":""},"deployment":{"anyOf":[{"$ref":"#/components/schemas/DeploymentEvent"},{"type":"null"}]},"corroboration_summary":{"type":"string","title":"Corroboration Summary","default":""}},"type":"object","required":["hypothesis","confidence","severity","category"],"title":"HypothesisRootCause"},"JSONValue":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":{"$ref":"#/components/schemas/JSONValue"},"type":"object"},{"items":{"$ref":"#/components/schemas/JSONValue"},"type":"array"},{"type":"null"}]},"JobStatus":{"type":"string","enum":["queued","running","completed","failed","cancelled","deleted"],"title":"JobStatus"},"LogBurst":{"properties":{"window_start":{"type":"number","title":"Window Start"},"window_end":{"type":"number","title":"Window End"},"rate_per_second":{"type":"number","title":"Rate Per Second"},"baseline_rate":{"type":"number","title":"Baseline Rate"},"ratio":{"type":"number","title":"Ratio"},"severity":{"$ref":"#/components/schemas/Severity"}},"type":"object","required":["window_start","window_end","rate_per_second","baseline_rate","ratio","severity"],"title":"LogBurst"},"LogMetricLink":{"properties":{"metric_name":{"type":"string","title":"Metric Name"},"metric_timestamp":{"type":"number","title":"Metric Timestamp"},"log_stream":{"type":"string","title":"Log Stream"},"log_burst_start":{"type":"number","title":"Log Burst Start"},"lag_seconds":{"type":"number","title":"Lag Seconds"},"strength":{"type":"number","title":"Strength"}},"type":"object","required":["metric_name","metric_timestamp","log_stream","log_burst_start","lag_seconds","strength"],"title":"LogMetricLink"},"LogPattern":{"properties":{"pattern":{"type":"string","title":"Pattern"},"count":{"type":"integer","title":"Count"},"first_seen":{"type":"number","title":"First Seen"},"last_seen":{"type":"number","title":"Last Seen"},"rate_per_minute":{"type":"number","title":"Rate Per Minute"},"entropy":{"type":"number","title":"Entropy"},"severity":{"$ref":"#/components/schemas/Severity"},"sample":{"type":"string","title":"Sample"}},"type":"object","required":["pattern","count","first_seen","last_seen","rate_per_minute","entropy","severity","sample"],"title":"LogPattern"},"LogRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"query":{"type":"string","title":"Query"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"}},"type":"object","required":["tenant_id","query","start","end"],"title":"LogRequest"},"MetricAnomaly":{"properties":{"metric_name":{"type":"string","title":"Metric Name"},"timestamp":{"type":"number","title":"Timestamp"},"value":{"type":"number","title":"Value"},"change_type":{"$ref":"#/components/schemas/ChangeType"},"z_score":{"type":"number","title":"Z Score"},"mad_score":{"type":"number","title":"Mad Score"},"isolation_score":{"type":"number","title":"Isolation Score"},"expected_range":{"prefixItems":[{"type":"number"},{"type":"number"}],"type":"array","maxItems":2,"minItems":2,"title":"Expected Range"},"severity":{"$ref":"#/components/schemas/Severity"},"description":{"type":"string","title":"Description"},"iqr_score":{"type":"number","title":"Iqr Score","default":0.0},"tukey_outlier_class":{"type":"string","title":"Tukey Outlier Class","default":"none"}},"type":"object","required":["metric_name","timestamp","value","change_type","z_score","mad_score","isolation_score","expected_range","severity","description"],"title":"MetricAnomaly"},"MetricRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"query":{"type":"string","title":"Query"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"sensitivity":{"anyOf":[{"type":"number","maximum":6.0,"minimum":1.0},{"type":"null"}],"title":"Sensitivity","default":3.0}},"type":"object","required":["tenant_id","query","start","end"],"title":"MetricRequest"},"MetricSeriesDistributionStats":{"properties":{"series_key":{"type":"string","title":"Series Key","default":""},"metric_name":{"type":"string","title":"Metric Name"},"sample_count":{"type":"integer","title":"Sample Count"},"mean":{"type":"number","title":"Mean"},"std":{"type":"number","title":"Std"},"min":{"type":"number","title":"Min"},"max":{"type":"number","title":"Max"},"median":{"type":"number","title":"Median"},"q1":{"type":"number","title":"Q1"},"q3":{"type":"number","title":"Q3"},"iqr":{"type":"number","title":"Iqr"},"mad":{"type":"number","title":"Mad"},"skewness":{"type":"number","title":"Skewness"},"kurtosis":{"type":"number","title":"Kurtosis"},"coefficient_of_variation":{"type":"number","title":"Coefficient Of Variation"}},"type":"object","required":["metric_name","sample_count","mean","std","min","max","median","q1","q3","iqr","mad","skewness","kurtosis","coefficient_of_variation"],"title":"MetricSeriesDistributionStats"},"RankedCause":{"properties":{"root_cause":{"$ref":"#/components/schemas/HypothesisRootCause"},"ml_score":{"type":"number","title":"Ml Score"},"final_score":{"type":"number","title":"Final Score"},"feature_importance":{"additionalProperties":{"type":"number"},"type":"object","title":"Feature Importance"}},"type":"object","required":["root_cause","ml_score","final_score","feature_importance"],"title":"RankedCause"},"RcaCategory":{"type":"string","enum":["deployment","resource_exhaustion","dependency_failure","traffic_surge","error_propagation","slo_burn","unknown"],"title":"RcaCategory"},"ResolverReadyResponse":{"properties":{"ready":{"type":"boolean","title":"Ready","description":"Whether resolver dependencies are currently ready."},"backends":{"additionalProperties":{"type":"string"},"type":"object","title":"Backends","description":"Per-backend readiness details keyed by backend name."}},"type":"object","required":["ready"],"title":"ResolverReadyResponse"},"ServiceLatency":{"properties":{"service":{"type":"string","title":"Service"},"operation":{"type":"string","title":"Operation"},"p50_ms":{"type":"number","title":"P50 Ms"},"p95_ms":{"type":"number","title":"P95 Ms"},"p99_ms":{"type":"number","title":"P99 Ms"},"apdex":{"type":"number","title":"Apdex"},"error_rate":{"type":"number","title":"Error Rate"},"sample_count":{"type":"integer","title":"Sample Count"},"severity":{"$ref":"#/components/schemas/Severity"},"window_start":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Window Start"},"window_end":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Window End"}},"type":"object","required":["service","operation","p50_ms","p95_ms","p99_ms","apdex","error_rate","sample_count","severity"],"title":"ServiceLatency"},"Severity":{"type":"string","enum":["low","medium","high","critical"],"title":"Severity"},"Signal":{"type":"string","enum":["metrics","logs","traces","events"],"title":"Signal"},"SloBurnAlert":{"properties":{"service":{"type":"string","title":"Service"},"window_label":{"type":"string","title":"Window Label"},"error_rate":{"type":"number","title":"Error Rate"},"burn_rate":{"type":"number","title":"Burn Rate"},"budget_consumed_pct":{"type":"number","title":"Budget Consumed Pct"},"severity":{"$ref":"#/components/schemas/Severity"}},"type":"object","required":["service","window_label","error_rate","burn_rate","budget_consumed_pct","severity"],"title":"SloBurnAlert"},"SloRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"service":{"type":"string","title":"Service"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"step":{"type":"string","title":"Step","default":"15s","pattern":"^[1-9][0-9]*[smhd]$"},"target_availability":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Target Availability","default":0.999},"error_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Query"},"total_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Total Query"}},"type":"object","required":["tenant_id","service","start","end"],"title":"SloRequest"},"TopologyRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"root_service":{"type":"string","title":"Root Service"},"max_depth":{"type":"integer","maximum":10.0,"minimum":1.0,"title":"Max Depth","default":6}},"type":"object","required":["tenant_id","start","end","root_service"],"title":"TopologyRequest"},"TraceRequest":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"start":{"type":"integer","title":"Start"},"end":{"type":"integer","title":"End"},"service":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Service"},"apdex_threshold_ms":{"type":"number","title":"Apdex Threshold Ms","default":500.0}},"type":"object","required":["tenant_id","start","end"],"title":"TraceRequest"},"TrajectoryForecast":{"properties":{"metric_name":{"type":"string","title":"Metric Name"},"current_value":{"type":"number","title":"Current Value"},"slope_per_second":{"type":"number","title":"Slope Per Second"},"predicted_value_at_horizon":{"type":"number","title":"Predicted Value At Horizon"},"time_to_threshold_seconds":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Time To Threshold Seconds"},"breach_threshold":{"type":"number","title":"Breach Threshold"},"confidence":{"type":"number","title":"Confidence"},"severity":{"$ref":"#/components/schemas/Severity"}},"type":"object","required":["metric_name","current_value","slope_per_second","predicted_value_at_horizon","time_to_threshold_seconds","breach_threshold","confidence","severity"],"title":"TrajectoryForecast"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"ErrorResponse":{"type":"object","properties":{"detail":{"type":"string","title":"Detail"}},"required":["detail"],"title":"ErrorResponse"}},"securitySchemes":{"ServiceToken":{"type":"apiKey","in":"header","name":"x-service-token","description":"Internal service token required for resolver API access."},"ContextBearer":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Context JWT carrying tenant and user scope for internal calls."}}},"jsonSchemaDialect":"https://spec.openapis.org/oas/3.1/dialect/base"} \ No newline at end of file diff --git a/openapi.yaml b/openapi.yaml index abae5e3..b715d4f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3,7 +3,7 @@ info: title: Resolver Analysis Engine description: AI-powered root cause analysis and anomaly detection over logs, metrics, and traces. - version: 0.0.4 + version: 0.0.5 paths: /api/v1/health: get: diff --git a/pyproject.toml b/pyproject.toml index c6669a3..fb89644 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "resolver" -version = "0.0.4" +version = "0.0.5" description = "Resolver RCA and analysis service for the Observantio platform." readme = "README.md" requires-python = ">=3.11" @@ -63,6 +63,81 @@ filterwarnings = [ "ignore::DeprecationWarning:starlette\\..*", ] +[tool.mutmut] +paths_to_mutate = [ + "config.py", + "database.py", + "db_models.py", + "custom_types/__init__.py", + "custom_types/json.py", + "api/requests/__init__.py", + "api/requests/_time_range.py", + "api/requests/analyze.py", + "api/requests/correlation.py", + "api/requests/events.py", + "api/requests/logs.py", + "api/requests/metrics.py", + "api/requests/slo.py", + "api/requests/topology.py", + "api/requests/traces.py", + "api/responses/__init__.py", + "api/responses/jobs.py", + "api/routes/analyze.py", + "api/routes/causal.py", + "api/routes/common.py", + "api/routes/correlation.py", + "api/routes/events.py", + "api/routes/exception.py", + "api/routes/forecast.py", + "api/routes/health.py", + "api/routes/jobs.py", + "api/routes/logs.py", + "api/routes/metrics.py", + "api/routes/ml.py", + "api/routes/slo.py", + "api/routes/topology.py", + "api/routes/traces.py", + "services/analyze_service.py", + "services/analysis_config_service.py", + "services/rca_job_service.py", + "services/security_service.py", + "engine/__init__.py", + "engine/events/__init__.py", + "engine/events/models.py", + "engine/events/registry.py", + "engine/enums.py", + "store/client.py", + "store/events.py", + "store/granger.py", + "store/keys.py", + "store/weights.py", +] +also_copy = [ + "api", + "connectors", + "services", + "engine", + "custom_types", + "datasources", + "store", + "config.py", + "database.py", + "db_models.py", +] +tests_dir = ["tests/test_route_exception_wrapper.py"] +do_not_mutate = [ + "*/tests/*", + "*/mutants/*", + "*/openapi.json", + "*/openapi.yaml", + "*/assets/*", + "*/configs/*", + "*/templates/*", + "*/migrations/*", +] +pytest_add_cli_args = ["-q"] +mutate_only_covered_lines = true + [tool.coverage.run] branch = true omit = [ @@ -154,10 +229,11 @@ files = ["."] exclude = [ "^(build|dist|venv|\\.venv|__pycache__)/", "^tests/", + "^mutants/", ] [tool.pylint.main] -jobs = 1 +jobs = 4 extension-pkg-allow-list = ["sqlalchemy"] ignore = [".git", "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".venv", "venv", "build", "dist", "tmp", "vendor", "tests", "mutants"] ignore-paths = ["^tests/", "^mutants/"] @@ -177,13 +253,13 @@ max-line-length = 120 max-module-lines = 800 [tool.pylint.design] -max-args = 6 -max-positional-arguments = 6 -max-attributes = 35 -max-public-methods = 25 -max-returns = 10 +max-args = 5 +max-positional-arguments = 4 +max-attributes = 20 +max-public-methods = 15 +max-returns = 5 max-nested-blocks = 5 -max-statements = 80 +max-statements = 50 min-public-methods = 0 [tool.pylint.basic] diff --git a/services/__init__.py b/services/__init__.py index 16282b1..29f93c6 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -1,7 +1,8 @@ """ Service layer for handling analysis requests, enforcing tenant context, and invoking the core analysis engine. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ diff --git a/services/analysis_config_service.py b/services/analysis_config_service.py index 50cb2c8..536e370 100644 --- a/services/analysis_config_service.py +++ b/services/analysis_config_service.py @@ -3,6 +3,11 @@ This module keeps per-job analysis tuning scoped to the current RCA execution while preserving server defaults when no YAML is supplied. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/services/analyze_service.py b/services/analyze_service.py index f23d612..217a2d2 100644 --- a/services/analyze_service.py +++ b/services/analyze_service.py @@ -1,9 +1,10 @@ """ Analyze service implementation that runs the core analysis engine with tenant-aware data providers. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/services/rca_job_service.py b/services/rca_job_service.py index 10cd2af..3c16d14 100644 --- a/services/rca_job_service.py +++ b/services/rca_job_service.py @@ -1,9 +1,10 @@ """ RCA job management service that handles scheduling, execution, and lifecycle of root cause analysis jobs. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -82,7 +83,7 @@ def _to_view(row: RcaJob) -> JobView: ) -def _encode_cursor(*, created_at: datetime, job_id: str) -> str: +def _encode_cursor(created_at: datetime, job_id: str) -> str: payload = {"created_at": created_at.isoformat(), "job_id": job_id} raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") return base64.urlsafe_b64encode(raw).decode("ascii") @@ -175,7 +176,7 @@ def _cleanup_retention_sync(self) -> None: for job in stale_jobs: db.delete(job) - async def create_job(self, *, payload: AnalyzeRequest, ctx: InternalContext) -> JobView: + async def create_job(self, payload: AnalyzeRequest, ctx: InternalContext) -> JobView: now = _utcnow() tenant_payload = payload.model_copy(update={"tenant_id": ctx.tenant_id}) analysis_config_service.prepare_request( @@ -208,7 +209,7 @@ def _create() -> JobView: self._tasks[job_id] = task return created - async def _run_job(self, *, job_id: str) -> None: + async def _run_job(self, job_id: str) -> None: try: async with self._semaphore: row = await asyncio.to_thread(self._get_job_row, job_id) @@ -351,7 +352,7 @@ def _list() -> tuple[list[JobView], str | None]: return await asyncio.to_thread(_list) - async def get_job(self, *, job_id: str, ctx: InternalContext) -> JobView: + async def get_job(self, job_id: str, ctx: InternalContext) -> JobView: def _get() -> JobView: with get_db_session() as db: row = db.get(RcaJob, job_id) @@ -363,7 +364,7 @@ def _get() -> JobView: return await asyncio.to_thread(_get) - async def get_job_result(self, *, job_id: str, ctx: InternalContext) -> tuple[JobView, JSONDict | None]: + async def get_job_result(self, job_id: str, ctx: InternalContext) -> tuple[JobView, JSONDict | None]: def _get() -> tuple[JobView, JSONDict | None]: with get_db_session() as db: row = db.get(RcaJob, job_id) @@ -380,7 +381,7 @@ def _get() -> tuple[JobView, JSONDict | None]: return await asyncio.to_thread(_get) - async def get_report(self, *, report_id: str, ctx: InternalContext) -> tuple[JobView, JSONDict | None]: + async def get_report(self, report_id: str, ctx: InternalContext) -> tuple[JobView, JSONDict | None]: def _get() -> tuple[JobView, JSONDict | None]: with get_db_session() as db: report = db.get(RcaReport, report_id) @@ -399,7 +400,7 @@ def _get() -> tuple[JobView, JSONDict | None]: return await asyncio.to_thread(_get) - async def delete_report(self, *, report_id: str, ctx: InternalContext) -> None: + async def delete_report(self, report_id: str, ctx: InternalContext) -> None: task_to_cancel: asyncio.Task[None] | None = None def _delete() -> str: diff --git a/services/security_service.py b/services/security_service.py index 20a8bab..e583057 100644 --- a/services/security_service.py +++ b/services/security_service.py @@ -1,9 +1,10 @@ """ Security service for validating internal requests, managing tenant context, and enforcing authentication policies. -Copyright (c) 2026 Stefan Kumarasinghe Licensed 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 +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/store/__init__.py b/store/__init__.py index da913b5..9de980a 100644 --- a/store/__init__.py +++ b/store/__init__.py @@ -1,11 +1,10 @@ """ Initialization of the store package, exposing client functions and submodules. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from store import baseline, events, granger, weights diff --git a/store/baseline.py b/store/baseline.py index a690d92..f7a180c 100644 --- a/store/baseline.py +++ b/store/baseline.py @@ -1,27 +1,42 @@ """ Baseline computation and persistence logic. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations +from importlib import import_module import json import logging from json import JSONDecodeError +from typing import TYPE_CHECKING, Any, cast from config import BASELINE_TTL, BLEND_ALPHA -from engine.baseline.compute import Baseline, compute from store import keys from store.client import redis_get, redis_set +if TYPE_CHECKING: + from engine.baseline.compute import Baseline + log = logging.getLogger(__name__) +def _load_compute_module() -> Any: + return import_module("engine.baseline.compute") + + +def __getattr__(name: str) -> Any: + if name in {"Baseline", "compute"}: + value = getattr(_load_compute_module(), name) + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + def _to_json(b: Baseline) -> str: return json.dumps( { @@ -36,28 +51,36 @@ def _to_json(b: Baseline) -> str: def _from_json(data: str) -> Baseline: + baseline_cls = _baseline_cls() d = json.loads(data) - return Baseline( + return cast( + "Baseline", + baseline_cls( mean=d["mean"], std=d["std"], lower=d["lower"], upper=d["upper"], seasonal_mean=d.get("seasonal_mean"), sample_count=d.get("sample_count", 0), + ), ) def _blend(cached: Baseline, fresh: Baseline) -> Baseline: + baseline_cls = _baseline_cls() a = 1.0 - BLEND_ALPHA blended_mean = a * cached.mean + BLEND_ALPHA * fresh.mean blended_std = a * cached.std + BLEND_ALPHA * fresh.std - return Baseline( + return cast( + "Baseline", + baseline_cls( mean=round(blended_mean, 6), std=round(max(blended_std, 1e-9), 6), lower=round(blended_mean - 3 * blended_std, 6), upper=round(blended_mean + 3 * blended_std, 6), seasonal_mean=fresh.seasonal_mean or cached.seasonal_mean, sample_count=cached.sample_count + fresh.sample_count, + ), ) @@ -83,11 +106,26 @@ async def compute_and_persist( metric_name: str, ts: list[float], vals: list[float], + *, z_threshold: float = 3.0, ) -> Baseline: - fresh = compute(ts, vals, z_threshold=z_threshold) + fresh = _compute(ts, vals, z_threshold=z_threshold) cached = await load(tenant_id, metric_name) result = _blend(cached, fresh) if cached and cached.sample_count >= 20 else fresh await save(tenant_id, metric_name, result) return result + + +def _baseline_cls() -> type[Any]: + baseline_cls = globals().get("Baseline") + if baseline_cls is None: + baseline_cls = __getattr__("Baseline") + return cast(type[Any], baseline_cls) + + +def _compute(ts: list[float], vals: list[float], z_threshold: float) -> Baseline: + compute_fn = globals().get("compute") + if compute_fn is None: + compute_fn = __getattr__("compute") + return cast("Baseline", compute_fn(ts, vals, z_threshold=z_threshold)) diff --git a/store/client.py b/store/client.py index 5368ee9..4e21a44 100644 --- a/store/client.py +++ b/store/client.py @@ -1,11 +1,10 @@ """ Client code for Redis access, with in-memory fallback if Redis is unavailable. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/store/events.py b/store/events.py index 3e65ef2..7f516cd 100644 --- a/store/events.py +++ b/store/events.py @@ -1,11 +1,10 @@ """ Event storage and retrieval logic. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -13,7 +12,7 @@ import json import logging from json import JSONDecodeError -from typing import TypedDict +from typing import TypedDict, cast from config import EVENTS_TTL from engine.events.models import DeploymentEvent @@ -44,36 +43,41 @@ def _coerce_float(value: object) -> float: def _coerce_event(value: object) -> StoredEvent | None: - if not isinstance(value, dict): - return None - service = value.get("service") - version = value.get("version") - author = value.get("author", "") - environment = value.get("environment", "production") - source = value.get("source", "unknown") - metadata = value.get("metadata", {}) - if not isinstance(service, str) or not isinstance(version, str): - return None - if not isinstance(author, str) or not isinstance(environment, str) or not isinstance(source, str): - return None - if not isinstance(metadata, dict) or not all( - isinstance(key, str) and isinstance(item, str) for key, item in metadata.items() - ): - return None - timestamp_raw: object = value.get("timestamp") - try: - timestamp = _coerce_float(timestamp_raw) - except (TypeError, ValueError): - return None - return { - "service": service, - "timestamp": timestamp, - "version": version, - "author": author, - "environment": environment, - "source": source, - "metadata": metadata, - } + coerced: StoredEvent | None = None + if isinstance(value, dict): + service = value.get("service") + version = value.get("version") + author = value.get("author", "") + environment = value.get("environment", "production") + source = value.get("source", "unknown") + metadata = value.get("metadata", {}) + has_required_fields = isinstance(service, str) and isinstance(version, str) + has_valid_meta = ( + isinstance(author, str) + and isinstance(environment, str) + and isinstance(source, str) + and isinstance(metadata, dict) + and all(isinstance(key, str) and isinstance(item, str) for key, item in metadata.items()) + ) + if has_required_fields and has_valid_meta: + try: + timestamp = _coerce_float(value.get("timestamp")) + except (TypeError, ValueError): + timestamp = None + if timestamp is not None: + coerced = cast( + StoredEvent, + { + "service": service, + "timestamp": timestamp, + "version": version, + "author": author, + "environment": environment, + "source": source, + "metadata": metadata, + }, + ) + return coerced def _serialise(event: DeploymentEvent) -> str: diff --git a/store/granger.py b/store/granger.py index 23bf20a..3e2d0b9 100644 --- a/store/granger.py +++ b/store/granger.py @@ -1,11 +1,10 @@ """ Granger causality test result storage and retrieval logic. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -14,13 +13,15 @@ import json import logging from json import JSONDecodeError -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict from config import GRANGER_TTL -from engine.causal.granger import GrangerResult from store import keys from store.client import redis_get, redis_set +if TYPE_CHECKING: + from engine.causal.granger import GrangerResult + log = logging.getLogger(__name__) diff --git a/store/keys.py b/store/keys.py index ab52649..0827562 100644 --- a/store/keys.py +++ b/store/keys.py @@ -1,11 +1,10 @@ """ Key-value store access layer with Redis and in-memory fallback. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/store/weights.py b/store/weights.py index 3d7e356..76dd38b 100644 --- a/store/weights.py +++ b/store/weights.py @@ -1,11 +1,10 @@ """ Weights storage and retrieval logic. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/conftest.py b/tests/conftest.py index 7fbe919..59c3206 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,18 @@ """ Test Suite Conftest. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ +import atexit import os +import shutil import sys +import tempfile +from pathlib import Path import pytest @@ -19,6 +22,36 @@ if ROOT not in sys.path: sys.path.insert(0, ROOT) +_TEST_SQLITE_DIR = Path(tempfile.mkdtemp(prefix="observantio-resolver-tests-")) +_TEST_SQLITE_URL = f"sqlite:///{_TEST_SQLITE_DIR / 'resolver.sqlite3'}" +_TEST_DATABASE_INITIALIZED = False + +atexit.register(shutil.rmtree, _TEST_SQLITE_DIR, ignore_errors=True) + + +def _bootstrap_sqlite_database() -> None: + global _TEST_DATABASE_INITIALIZED + + use_temp_sqlite = os.getenv("USE_TEMP_SQLITE_TEST_DB", "").strip().lower() in {"1", "true", "yes", "on"} + database_url = os.getenv("RESOLVER_DATABASE_URL", "").strip() + if use_temp_sqlite: + database_url = _TEST_SQLITE_URL + os.environ["RESOLVER_DATABASE_URL"] = database_url + os.environ["DATABASE_URL"] = database_url + + if _TEST_DATABASE_INITIALIZED or not database_url.startswith("sqlite"): + return + + from database import dispose_database, init_database, init_db + + dispose_database() + init_database(database_url) + init_db() + _TEST_DATABASE_INITIALIZED = True + + +_bootstrap_sqlite_database() + from store.client import _fallback # noqa: E402 @@ -44,12 +77,15 @@ async def fake_delete(key: str): monkeypatch.setattr(client, "redis_set", fake_set) monkeypatch.setattr(client, "redis_delete", fake_delete) - import store.baseline as bstore - import store.events as estore - import store.granger as gstore - import store.weights as wstore + modules = [] + for module_name in ("store.weights", "store.baseline", "store.granger", "store.events"): + try: + mod = __import__(module_name, fromlist=["_placeholder"]) + except ImportError: + continue + modules.append(mod) - for mod in (wstore, bstore, gstore, estore): + for mod in modules: for name in ("redis_get", "redis_set", "redis_delete"): if hasattr(mod, name): monkeypatch.setattr(mod, name, locals()[f"fake_{name.split('_')[1]}"]) diff --git a/tests/test_analysis_config_service.py b/tests/test_analysis_config_service.py index 6eab43d..518b05c 100644 --- a/tests/test_analysis_config_service.py +++ b/tests/test_analysis_config_service.py @@ -1,5 +1,10 @@ """ Tests for per-job RCA YAML configuration overrides. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_analyze_helpers_filters_edges.py b/tests/test_analyze_helpers_filters_edges.py index 46f272c..1313c6b 100644 --- a/tests/test_analyze_helpers_filters_edges.py +++ b/tests/test_analyze_helpers_filters_edges.py @@ -1,11 +1,10 @@ """ Test coverage for helper functions related to filtering and processing edges in the analysis pipeline. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -255,13 +254,15 @@ def test_apply_precision_quality_gates_non_precision_and_empty_filtered(monkeypa suppression_counts: dict[str, int] = {} warnings: list[str] = [] _, _, causes_after, ranked_after, quality = helpers._apply_precision_quality_gates( - metric_anomalies=[_anomaly("m", 1.0, 1.0)], - change_points=[_cp("m", 1.0, 1.0)], - root_causes=causes, - ranked_causes=ranked, - duration_seconds=3600.0, - suppression_counts=suppression_counts, - warnings=warnings, + helpers.PrecisionQualityGateInputs( + metric_anomalies=[_anomaly("m", 1.0, 1.0)], + change_points=[_cp("m", 1.0, 1.0)], + root_causes=causes, + ranked_causes=ranked, + duration_seconds=3600.0, + suppression_counts=suppression_counts, + warnings=warnings, + ) ) assert len(causes_after) == 1 assert ranked_after == [] @@ -269,13 +270,15 @@ def test_apply_precision_quality_gates_non_precision_and_empty_filtered(monkeypa monkeypatch.setattr(helpers.settings, "quality_gating_profile", "recall") _, _, _, _, quality_non_precision = helpers._apply_precision_quality_gates( - metric_anomalies=[_anomaly("m", 1.0, 1.0)], - change_points=[_cp("m", 1.0, 1.0)], - root_causes=causes, - ranked_causes=ranked, - duration_seconds=120.0, - suppression_counts={}, - warnings=[], + helpers.PrecisionQualityGateInputs( + metric_anomalies=[_anomaly("m", 1.0, 1.0)], + change_points=[_cp("m", 1.0, 1.0)], + root_causes=causes, + ranked_causes=ranked, + duration_seconds=120.0, + suppression_counts={}, + warnings=[], + ) ) assert quality_non_precision.gating_profile == "recall" @@ -291,10 +294,11 @@ async def _raise_compute(*_args, **_kwargs): monkeypatch.setattr(helpers, "baseline_compute", lambda *_a, **_k: called.__setitem__("baseline_fallback", 1)) monkeypatch.setattr(helpers.anomaly, "detect", lambda *_a, **_k: [_anomaly("m", 1.0, 1.0)]) - def _legacy_cp(ts, vals, sigma): - return [_cp("m", 1.0, sigma)] + def _cp_detector(ts, vals, threshold_sigma=None, metric_name=None): + assert metric_name == "m" + return [_cp("m", 1.0, float(threshold_sigma or 0.0))] - monkeypatch.setattr(helpers, "changepoint_detect", _legacy_cp) + monkeypatch.setattr(helpers, "changepoint_detect", _cp_detector) monkeypatch.setattr(helpers.settings, "cusum_threshold_sigma", 2.0) monkeypatch.setattr(helpers.settings, "analyzer_forecast_min_window_seconds", 10.0) monkeypatch.setattr(helpers.settings, "analyzer_degradation_min_window_seconds", 10.0) @@ -308,13 +312,15 @@ def _legacy_cp(ts, vals, sigma): req = AnalyzeRequest(tenant_id="t", start=1, end=2, step="15s") key = next(iter(helpers.FORECAST_THRESHOLDS.keys())) anomalies, change_points, fc, deg = await helpers._process_one_metric_series( - req=req, - query_string=key, - metric_name="m", - ts=[1.0, 2.0, 3.0], - vals=[1.0, 2.0, 3.0], - z_threshold=0.0, - analysis_window_seconds=20.0, + helpers.MetricSeriesJob( + req=req, + query_string=key, + metric_name="m", + ts=[1.0, 2.0, 3.0], + vals=[1.0, 2.0, 3.0], + z_threshold=0.0, + analysis_window_seconds=20.0, + ) ) assert anomalies assert change_points @@ -346,10 +352,10 @@ def _iter_series(resp, query_hint=None): lambda sk, mn, vals: None if mn == "m1" else SimpleNamespace(series_key=sk), ) - async def _process_one(req, query_string, metric_name, ts, vals, z_threshold, analysis_window_seconds): - if metric_name == "m1": + async def _process_one(job): + if job.metric_name == "m1": raise RuntimeError("boom") - return ([_anomaly(metric_name, 1.0, 1.0)], [_cp(metric_name, 1.0, 1.0)], None, None) + return ([_anomaly(job.metric_name, 1.0, 1.0)], [_cp(job.metric_name, 1.0, 1.0)], None, None) monkeypatch.setattr(helpers, "_process_one_metric_series", _process_one) @@ -559,13 +565,15 @@ async def _ok_compute(*_args, **_kwargs): req = AnalyzeRequest(tenant_id="t", start=1, end=2, step="15s") _, _, _, deg = await helpers._process_one_metric_series( - req=req, - query_string="no-threshold-key", - metric_name="m", - ts=[1.0, 2.0], - vals=[1.0, 2.0], - z_threshold=1.0, - analysis_window_seconds=10.0, + helpers.MetricSeriesJob( + req=req, + query_string="no-threshold-key", + metric_name="m", + ts=[1.0, 2.0], + vals=[1.0, 2.0], + z_threshold=1.0, + analysis_window_seconds=10.0, + ) ) assert deg is None @@ -580,7 +588,7 @@ def _iter_series(_resp, query_hint=None): monkeypatch.setattr(helpers.anomaly, "iter_series", _iter_series) monkeypatch.setattr(helpers, "compute_series_distribution_stats", lambda *_a, **_k: SimpleNamespace(series_key="s")) - async def _process_one(*_args, **_kwargs): + async def _process_one(_job): return ([], [], SimpleNamespace(name="fc"), None) monkeypatch.setattr(helpers, "_process_one_metric_series", _process_one) diff --git a/tests/test_analyzer_integration.py b/tests/test_analyzer_integration.py index 6cc6fc3..6de5dca 100644 --- a/tests/test_analyzer_integration.py +++ b/tests/test_analyzer_integration.py @@ -1,11 +1,10 @@ """ Test AnalyzerService integration with the core analysis engine and data providers. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import asyncio diff --git a/tests/test_analyzer_quality.py b/tests/test_analyzer_quality.py index c8d7749..7cb0290 100644 --- a/tests/test_analyzer_quality.py +++ b/tests/test_analyzer_quality.py @@ -2,11 +2,10 @@ Test Analyzer Quality and output formatting to ensure results are correctly processed and limited before returning to clients. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -23,6 +22,7 @@ _select_granger_series, _to_root_cause_model, ) +from engine.analyze.helpers import AnalyzerOutputInputs, PrecisionQualityGateInputs from engine.causal.granger import GrangerResult from engine.changepoint import ChangePoint from engine.enums import ChangeType, RcaCategory, Severity, Signal @@ -136,13 +136,15 @@ def test_limit_analyzer_output_caps_noise_lists(): clusters_limited, granger_limited, ) = _limit_analyzer_output( - metric_anomalies=anomalies, - change_points=change_points, - root_causes=root_causes, - ranked_causes=ranked, - anomaly_clusters=clusters, - granger_results=granger, - warnings=warnings, + AnalyzerOutputInputs( + metric_anomalies=anomalies, + change_points=change_points, + root_causes=root_causes, + ranked_causes=ranked, + anomaly_clusters=clusters, + granger_results=granger, + warnings=warnings, + ) ) assert len(anomalies_limited) <= 250 @@ -248,13 +250,15 @@ def test_apply_precision_quality_gates_enforces_density_and_root_cause_filters(m suppression_counts: dict[str, int] = {} anomalies_after, change_points_after, causes_after, ranked_after, quality = _apply_precision_quality_gates( - metric_anomalies=anomalies, - change_points=change_points, - root_causes=causes, - ranked_causes=ranked, - duration_seconds=3600.0, - suppression_counts=suppression_counts, - warnings=warnings, + PrecisionQualityGateInputs( + metric_anomalies=anomalies, + change_points=change_points, + root_causes=causes, + ranked_causes=ranked, + duration_seconds=3600.0, + suppression_counts=suppression_counts, + warnings=warnings, + ) ) assert len(anomalies_after) == 1 diff --git a/tests/test_anomaly_detection.py b/tests/test_anomaly_detection.py index 27b019f..d3dbdc3 100644 --- a/tests/test_anomaly_detection.py +++ b/tests/test_anomaly_detection.py @@ -2,11 +2,10 @@ Test cases for anomaly detection logic in the analysis engine, including output limiting and Granger causality series selection. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import numpy as np diff --git a/tests/test_anomaly_series.py b/tests/test_anomaly_series.py index 00f1749..d332369 100644 --- a/tests/test_anomaly_series.py +++ b/tests/test_anomaly_series.py @@ -2,11 +2,10 @@ Test Anomaly Series Detection and correlation logic in the analysis engine, ensuring correct identification of anomalies and their relationships. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.anomaly.series import MetricRecord, iter_series diff --git a/tests/test_anomaly_stats.py b/tests/test_anomaly_stats.py index bf50d36..a02d7b9 100644 --- a/tests/test_anomaly_stats.py +++ b/tests/test_anomaly_stats.py @@ -1,11 +1,10 @@ """ Tests for per-series distribution statistics used in RCA reports. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_api_models.py b/tests/test_api_models.py index 0c689ec..0cb1f5c 100644 --- a/tests/test_api_models.py +++ b/tests/test_api_models.py @@ -2,11 +2,10 @@ Test API models for request validation and data integrity, ensuring that incoming requests conform to expected schemas and constraints. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_api_route_surface_edges.py b/tests/test_api_route_surface_edges.py index 705d029..b39f5ef 100644 --- a/tests/test_api_route_surface_edges.py +++ b/tests/test_api_route_surface_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -27,6 +26,7 @@ from api.routes import metrics as metrics_route from api.routes import traces as traces_route from custom_types import json as json_types +from middleware.runtime_ssl import RuntimeSSLOptions class DemoModel(NpModel): @@ -249,9 +249,7 @@ async def fake_safe_call(awaitable): captured = [] def fake_changepoint_detect(ts, vals, threshold_sigma=None, metric_name=None): - if metric_name is not None: - raise TypeError("legacy signature") - captured.append((tuple(ts), tuple(vals), threshold_sigma)) + captured.append((tuple(ts), tuple(vals), threshold_sigma, metric_name)) return [types.SimpleNamespace(timestamp=2)] monkeypatch.setattr(metrics_route, "changepoint_detect", fake_changepoint_detect) @@ -260,7 +258,10 @@ def fake_changepoint_detect(ts, vals, threshold_sigma=None, metric_name=None): ) assert [item.timestamp for item in changepoints] == [2, 2] - assert captured == [((1,), (2.0,), 6.0), ((2,), (3.0,), 6.0)] + assert captured == [ + ((1,), (2.0,), 6.0, "metric-b"), + ((2,), (3.0,), 6.0, "metric-a"), + ] @pytest.mark.asyncio @@ -491,8 +492,48 @@ def test_dunder_main_runs_uvicorn_with_ssl(monkeypatch): ) runpy.run_module("main", run_name="__main__") - assert captured["app"] == "main:app" + assert captured["app"].title == "Resolver Analysis Engine" assert captured["host"] == "0.0.0.0" assert captured["port"] == 9443 assert captured["ssl_certfile"] == "/tmp/cert.pem" assert captured["ssl_keyfile"] == "/tmp/key.pem" + + +def test_dunder_main_runs_uvicorn_without_ssl(monkeypatch): + captured = {} + + monkeypatch.delenv("RESOLVER_SSL_ENABLED", raising=False) + monkeypatch.delenv("RESOLVER_SSL_CERTFILE", raising=False) + monkeypatch.delenv("RESOLVER_SSL_KEYFILE", raising=False) + monkeypatch.setenv("RESOLVER_HOST", "127.0.0.1") + monkeypatch.setenv("RESOLVER_PORT", "4322") + import config as config_module + + importlib.reload(config_module) + monkeypatch.setitem( + sys.modules, "uvicorn", types.SimpleNamespace(run=lambda app, **kwargs: captured.update({"app": app, **kwargs})) + ) + runpy.run_module("main", run_name="__main__") + + assert captured["app"].title == "Resolver Analysis Engine" + assert captured["host"] == "127.0.0.1" + assert captured["port"] == 4322 + assert "ssl_certfile" not in captured + assert "ssl_keyfile" not in captured + + +def test_runtime_ssl_options_requires_paths(): + with pytest.raises( + ValueError, + match="RESOLVER_SSL_ENABLED=true requires RESOLVER_SSL_CERTFILE and RESOLVER_SSL_KEYFILE to be set", + ): + RuntimeSSLOptions.from_settings(types.SimpleNamespace(ssl_enabled=True, ssl_certfile="", ssl_keyfile="")) + + +def test_aggregated_router_is_populated_and_ssl_defaults_disabled(): + from api.routes import router as aggregated_router + + route_paths = {route.path for route in aggregated_router.routes} + + assert "/health" in route_paths + assert RuntimeSSLOptions.from_settings(types.SimpleNamespace()) is None diff --git a/tests/test_api_routes_analyze.py b/tests/test_api_routes_analyze.py index 219ddcc..19f403a 100644 --- a/tests/test_api_routes_analyze.py +++ b/tests/test_api_routes_analyze.py @@ -3,11 +3,10 @@ cases where no service filters are provided, ensuring that it does not apply any default service filters and allows the provider to process the request with an empty filter set as intended. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_api_routes_causal.py b/tests/test_api_routes_causal.py index d8becd2..3c04604 100644 --- a/tests/test_api_routes_causal.py +++ b/tests/test_api_routes_causal.py @@ -2,11 +2,10 @@ Test API routes for causal analysis endpoints, validating request handling, response formatting, and integration with the analysis engine. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from types import SimpleNamespace diff --git a/tests/test_api_routes_correlation.py b/tests/test_api_routes_correlation.py index b4bba8a..a5ab28d 100644 --- a/tests/test_api_routes_correlation.py +++ b/tests/test_api_routes_correlation.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_api_routes_events.py b/tests/test_api_routes_events.py index 7efbdab..b0df7b3 100644 --- a/tests/test_api_routes_events.py +++ b/tests/test_api_routes_events.py @@ -2,11 +2,10 @@ Test API routes for deployment events, ensuring correct handling of tenant context, request validation, and integration with the event registry. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_api_routes_metrics.py b/tests/test_api_routes_metrics.py index 07cffb7..7e57d7e 100644 --- a/tests/test_api_routes_metrics.py +++ b/tests/test_api_routes_metrics.py @@ -4,11 +4,10 @@ respected in the anomaly detection logic and that the API route correctly interfaces with the detection implementation to produce expected results based on the provided parameters. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_api_routes_ml.py b/tests/test_api_routes_ml.py index 275c3d0..a5a96b8 100644 --- a/tests/test_api_routes_ml.py +++ b/tests/test_api_routes_ml.py @@ -2,11 +2,10 @@ Test API routes for machine learning analysis endpoints, validating request handling, response formatting, and integration with the analysis engine. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_api_routes_slo.py b/tests/test_api_routes_slo.py index e898ce4..dddc384 100644 --- a/tests/test_api_routes_slo.py +++ b/tests/test_api_routes_slo.py @@ -2,11 +2,10 @@ Test API routes for SLO analysis endpoints, validating request handling, response formatting, and integration with the analysis engine. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from types import SimpleNamespace @@ -27,7 +26,7 @@ async def query_metrics(self, query, start, end, step): return {"data": {"result": [{"metric": {}, "values": [[1, "0"]]}]}} -def dummy_slo_evaluate(service, err_vals, tot_vals, ts, target): +def dummy_slo_evaluate(service, err_vals, tot_vals, ts, target_availability=None): return [] @@ -94,7 +93,7 @@ async def query_metrics(self, query, start, end, step): seen = {"calls": 0} - def tracking_eval(service, err_vals, tot_vals, ts, target): + def tracking_eval(service, err_vals, tot_vals, ts, target_availability=None): seen["calls"] += 1 assert len(err_vals) == len(tot_vals) == len(ts) return [] @@ -122,7 +121,7 @@ async def test_slo_burn_serializes_budget_status(monkeypatch): monkeypatch.setattr( slo_route, "slo_evaluate", - lambda service, err_vals, tot_vals, ts, target: [SimpleNamespace(name="fast-burn")], + lambda service, err_vals, tot_vals, ts, target_availability=None: [SimpleNamespace(name="fast-burn")], ) monkeypatch.setattr( slo_route, diff --git a/tests/test_api_routes_topology.py b/tests/test_api_routes_topology.py index 0360e97..961a0c4 100644 --- a/tests/test_api_routes_topology.py +++ b/tests/test_api_routes_topology.py @@ -2,11 +2,10 @@ Test API routes for topology analysis endpoints, validating request handling, response formatting, and integration with the analysis engine. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_api_routes_traces.py b/tests/test_api_routes_traces.py index ba19a53..f3269b5 100644 --- a/tests/test_api_routes_traces.py +++ b/tests/test_api_routes_traces.py @@ -3,11 +3,10 @@ cases where no service filters are provided, ensuring that it does not apply any default service filters and allows the provider to process the request with an empty filter set as intended. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_changepoint.py b/tests/test_changepoint.py index efa9e08..79a44aa 100644 --- a/tests/test_changepoint.py +++ b/tests/test_changepoint.py @@ -2,11 +2,10 @@ Test cases for changepoint detection logic in the analysis engine, including CUSUM parameter sensitivity and oscillation detection behavior. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import numpy as np diff --git a/tests/test_config_database_and_engine_edges.py b/tests/test_config_database_and_engine_edges.py index 75dd315..4b23202 100644 --- a/tests/test_config_database_and_engine_edges.py +++ b/tests/test_config_database_and_engine_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -193,12 +192,12 @@ async def test_fetcher_scrape_fallback_and_helper_paths(): invalid_metric nope """, ) - results = await fetch_metrics(provider, ["up", "cpu_usage", "bad", "weird"], 10, 20, "15s") + results = await fetch_metrics(provider, ["up", "cpu_usage", "bad", "weird"], 10, 20, step="15s") assert [query for query, _ in results] == ["up", "cpu_usage"] assert results[0][1]["data"]["result"][0]["metric"]["__name__"] == "up" direct = _FetchProvider({"up": {"data": {"result": [{"metric": {}, "values": [[1, 1.0]]}]}}}) - returned = await fetch_metrics(direct, ["up"], 0, 1, "30s") + returned = await fetch_metrics(direct, ["up"], 0, 1, step="30s") assert returned == [("up", {"data": {"result": [{"metric": {}, "values": [[1, 1.0]]}]}})] @@ -271,7 +270,7 @@ def __init__(self, exists): def __enter__(self): return self - def __exit__(self, exc_type, exc, tb): + def __exit__(self, *args): return False def execute(self, stmt, params=None): diff --git a/tests/test_config_security.py b/tests/test_config_security.py index c5cc6ef..d4754bb 100644 --- a/tests/test_config_security.py +++ b/tests/test_config_security.py @@ -1,11 +1,10 @@ """ Configuration security tests for Resolver strict production controls. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_connectors_provider_and_security_edges.py b/tests/test_connectors_provider_and_security_edges.py index 5d6b830..f87a93d 100644 --- a/tests/test_connectors_provider_and_security_edges.py +++ b/tests/test_connectors_provider_and_security_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -22,7 +21,7 @@ from connectors.tempo import TempoConnector from datasources.base import BaseConnector from datasources.exceptions import DataSourceUnavailable, InvalidQuery, QueryTimeout -from datasources.helpers import fetch_json, fetch_text +from datasources.helpers import FetchRequestOptions, fetch_json, fetch_text from datasources.provider import DataSourceProvider @@ -75,28 +74,35 @@ async def test_datasource_helpers_and_provider(monkeypatch): assert connector.client.is_closed is True async_client = _AsyncClient(_Response({"ok": True})) - assert await fetch_json("https://api", client=async_client) == {"ok": True} - assert await fetch_text("https://api", client=_AsyncClient(_Response(text="body"))) == "body" - assert await fetch_json("https://api", client=_AsyncClient(_Response([1, 2, 3]))) == {} + assert await fetch_json("https://api", options=FetchRequestOptions(client=async_client)) == {"ok": True} + assert ( + await fetch_text("https://api", options=FetchRequestOptions(client=_AsyncClient(_Response(text="body")))) + == "body" + ) + assert await fetch_json("https://api", options=FetchRequestOptions(client=_AsyncClient(_Response([1, 2, 3])))) == {} with pytest.raises(InvalidQuery): - await fetch_json("https://api", client=_AsyncClient(_Response(status_code=400, text="bad"))) + await fetch_json("https://api", options=FetchRequestOptions(client=_AsyncClient(_Response(status_code=400, text="bad")))) with pytest.raises(QueryTimeout): - await fetch_json("https://api", client=_AsyncClient(error=httpx.TimeoutException("timeout"))) + await fetch_json("https://api", options=FetchRequestOptions(client=_AsyncClient(error=httpx.TimeoutException("timeout")))) with pytest.raises(DataSourceUnavailable): await fetch_json( "https://api", - client=_AsyncClient(error=httpx.RequestError("down", request=httpx.Request("GET", "https://api"))), + options=FetchRequestOptions( + client=_AsyncClient(error=httpx.RequestError("down", request=httpx.Request("GET", "https://api"))) + ), ) with pytest.raises(InvalidQuery): - await fetch_text("https://api", client=_AsyncClient(_Response(status_code=500, text="bad"))) + await fetch_text("https://api", options=FetchRequestOptions(client=_AsyncClient(_Response(status_code=500, text="bad")))) with pytest.raises(QueryTimeout): - await fetch_text("https://api", client=_AsyncClient(error=httpx.TimeoutException("timeout"))) + await fetch_text("https://api", options=FetchRequestOptions(client=_AsyncClient(error=httpx.TimeoutException("timeout")))) with pytest.raises(DataSourceUnavailable): await fetch_text( "https://api", - client=_AsyncClient(error=httpx.RequestError("down", request=httpx.Request("GET", "https://api"))), + options=FetchRequestOptions( + client=_AsyncClient(error=httpx.RequestError("down", request=httpx.Request("GET", "https://api"))) + ), ) async def _logs_query_range(**kwargs): @@ -115,15 +121,26 @@ async def _traces_query_range(**kwargs): monkeypatch.setattr("datasources.provider.DataSourceFactory.create_metrics", lambda settings, tenant_id: metrics) monkeypatch.setattr("datasources.provider.DataSourceFactory.create_traces", lambda settings, tenant_id: traces) provider = DataSourceProvider("tenant", SimpleNamespace()) - assert await provider.query_logs("{job='x'}", 1, 2, 3) == { + assert await provider.query_logs("{job='x'}", 1, 2, limit=3) == { "logs": {"query": "{job='x'}", "start": 1, "end": 2, "limit": 3} } - assert await provider.query_metrics("up", 1, 2, "60s") == { + assert await provider.query_metrics("up", 1, 2, step="60s") == { "metrics": {"query": "up", "start": 1, "end": 2, "step": "60s"} } - assert await provider.query_traces({"service.name": "api"}, 1, 2, 4) == { + assert await provider.query_traces({"service.name": "api"}, 1, 2, limit=4) == { "traces": {"filters": {"service.name": "api"}, "start": 1, "end": 2, "limit": 4} } + assert await provider.query_logs("{job='x'}", 1, 2, limit="NaN") == { + "logs": {"query": "{job='x'}", "start": 1, "end": 2, "limit": None} + } + assert await provider.query_traces({"service.name": "api"}, 1, 2, limit=object()) == { + "traces": {"filters": {"service.name": "api"}, "start": 1, "end": 2, "limit": None} + } + assert await provider.query_logs("{job='x'}", 1, 2) == { + "logs": {"query": "{job='x'}", "start": 1, "end": 2, "limit": None} + } + with pytest.raises(TypeError, match="step is required"): + await provider.query_metrics("up", 1, 2) await provider.aclose() @@ -135,14 +152,12 @@ async def _awaitable(): async def test_specific_connectors_build_expected_requests(monkeypatch): recorded = [] - async def fake_query_backend_json(connector, path, params, invalid_msg, timeout_msg, unavailable_msg): - recorded.append((connector.__class__.__name__, path, params, invalid_msg, timeout_msg, unavailable_msg)) + async def fake_query_backend_json(connector, path, params, messages=None): + recorded.append((connector.__class__.__name__, path, params, messages)) return {"path": path, "params": params} - async def fake_fetch_text( - url, headers=None, timeout=30, client=None, invalid_msg="", timeout_msg="", unavailable_msg="" - ): - recorded.append(("fetch_text", url, headers, timeout, invalid_msg, timeout_msg, unavailable_msg)) + async def fake_fetch_text(url, options=None, messages=None): + recorded.append(("fetch_text", url, options, messages)) return "metrics" monkeypatch.setattr("connectors.loki.query_backend_json", fake_query_backend_json) @@ -160,7 +175,7 @@ async def fake_fetch_text( "params": {"query": '{app=~".+"}', "start": 1, "end": 2, "limit": 100}, } assert await mimir.scrape() == "metrics" - assert await mimir.query_range("up", 1, 2, "60s") == { + assert await mimir.query_range("up", 1, 2, step="60s") == { "path": "/prometheus/api/v1/query_range", "params": {"query": "up", "start": 1, "end": 2, "step": "60s"}, } @@ -170,6 +185,19 @@ async def fake_fetch_text( } +@pytest.mark.asyncio +async def test_mimir_query_range_compatibility_errors(monkeypatch): + async def _raising_query_backend_json(*args, **kwargs): + raise TypeError("boom") + + monkeypatch.setattr("connectors.mimir.query_backend_json", _raising_query_backend_json) + connector = MimirConnector("https://mimir", "tenant") + with pytest.raises(TypeError, match="boom"): + await connector.query_range("up", 1, 2, step="60s") + with pytest.raises(TypeError, match="step is required"): + await connector.query_range("up", 1, 2) + + def _set_security_defaults() -> None: security_service.settings.expected_service_token = "internal-service-token" security_service.settings.context_verify_key = "very-secret-signing-key-with-32-bytes" diff --git a/tests/test_correlation.py b/tests/test_correlation.py index a25955f..260fa03 100644 --- a/tests/test_correlation.py +++ b/tests/test_correlation.py @@ -2,11 +2,10 @@ Test cases for correlation logic in the analysis engine, validating temporal correlation of anomalies, log bursts, and service latency, as well as edge cases in timestamp handling and relevance filtering. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from api.responses import LogBurst, MetricAnomaly, ServiceLatency diff --git a/tests/test_datasource_and_store_helpers_more.py b/tests/test_datasource_and_store_helpers_more.py index d49abc0..da998f4 100644 --- a/tests/test_datasource_and_store_helpers_more.py +++ b/tests/test_datasource_and_store_helpers_more.py @@ -2,17 +2,17 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations import asyncio +from types import SimpleNamespace import pytest -from connectors.common import query_backend_json +from connectors.common import BackendErrorMessages, query_backend_json from datasources.data_config import DataSourceSettings from datasources.factory import DataSourceFactory from datasources.retry import retry @@ -23,19 +23,19 @@ async def test_query_backend_json_and_store_baseline_helpers(monkeypatch): captured = {} - async def fake_fetch_json( - url, params=None, headers=None, timeout=30, client=None, invalid_msg="", timeout_msg="", unavailable_msg="" - ): + async def fake_fetch_json(url, options=None, messages=None): + options = options or SimpleNamespace(params=None, headers=None, timeout=30, client=None) + messages = messages or SimpleNamespace(invalid_msg="", timeout_msg="", unavailable_msg="") captured.update( { "url": url, - "params": params, - "headers": headers, - "timeout": timeout, - "client": client, - "invalid_msg": invalid_msg, - "timeout_msg": timeout_msg, - "unavailable_msg": unavailable_msg, + "params": options.params, + "headers": options.headers, + "timeout": options.timeout, + "client": options.client, + "invalid_msg": messages.invalid_msg, + "timeout_msg": messages.timeout_msg, + "unavailable_msg": messages.unavailable_msg, } ) return {"ok": True} @@ -52,7 +52,10 @@ async def fake_fetch_json( }, )() assert await query_backend_json( - connector, path="/query", params={"q": "up"}, invalid_msg="bad", timeout_msg="slow", unavailable_msg="down" + connector, + path="/query", + params={"q": "up"}, + messages=BackendErrorMessages(invalid="bad", timeout="slow", unavailable="down"), ) == {"ok": True} assert captured["url"] == "https://backend/query" assert captured["headers"] == {"X-Scope-OrgID": "tenant"} @@ -71,13 +74,31 @@ async def fake_fetch_json( connector_with_request_headers, path="/query2", params={"q": "up"}, - invalid_msg="bad", - timeout_msg="slow", - unavailable_msg="down", + messages=BackendErrorMessages(invalid="bad", timeout="slow", unavailable="down"), ) == {"ok": True} assert captured["url"] == "https://backend/query2" assert captured["headers"] == {"X-From": "request_headers"} + connector_with_non_callable_request_headers = type( + "C3", + (), + { + "base_url": "https://backend", + "timeout": 12, + "client": object(), + "request_headers": {}, + "_headers": lambda self: {"X-Fallback": "compat"}, + }, + )() + assert await query_backend_json( + connector_with_non_callable_request_headers, + "/query3", + {"q": "up"}, + messages=BackendErrorMessages(invalid="i", timeout="t", unavailable="u"), + ) == {"ok": True} + assert captured["url"] == "https://backend/query3" + assert captured["headers"] == {"X-Fallback": "compat"} + baseline = baseline_store.Baseline(mean=1.0, std=2.0, lower=-5.0, upper=7.0, seasonal_mean=3.0, sample_count=4) raw = baseline_store._to_json(baseline) restored = baseline_store._from_json(raw) diff --git a/tests/test_datasource_factory.py b/tests/test_datasource_factory.py index 9a2e65f..b7a96d0 100644 --- a/tests/test_datasource_factory.py +++ b/tests/test_datasource_factory.py @@ -2,11 +2,10 @@ Datasource factory tests focused on validating that connector timeouts are correctly passed through from the factory to all underlying connectors, ensuring consistent timeout behavior across all data source interactions. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_degradation.py b/tests/test_degradation.py index c79e05d..96bc763 100644 --- a/tests/test_degradation.py +++ b/tests/test_degradation.py @@ -2,11 +2,10 @@ Test cases for degradation analysis logic in the analysis engine, including EMA and acceleration calculations, trend classification, and severity assessment. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.enums import Severity diff --git a/tests/test_engine_causal.py b/tests/test_engine_causal.py index 8728edd..77c04cf 100644 --- a/tests/test_engine_causal.py +++ b/tests/test_engine_causal.py @@ -2,22 +2,25 @@ Test engine causal analysis logic, including correlation of anomalies, log bursts, and service latency, as well as edge cases in timestamp handling and relevance filtering. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ +import pytest + from engine.causal.bayesian import score as bayesian_score -from engine.causal.granger import GrangerResult, granger_multiple_pairs, granger_pair_analysis +from engine.causal.granger import GrangerAnalysisOptions, GrangerResult, granger_multiple_pairs, granger_pair_analysis from engine.causal.graph import CausalGraph, InterventionResult def test_bayesian_score_consistency(): - results = bayesian_score(True, False, False, False, False) + results = bayesian_score(True, False, False, False, has_error_propagation=False) assert abs(sum(r.posterior for r in results) - 1.0) < 1e-6 assert results[0].category.value == "deployment" + with pytest.raises(TypeError): + bayesian_score(True, False, False, False) def test_causal_graph_basic(): @@ -42,7 +45,7 @@ def test_granger_pair_and_all(): effect_arr[0] = 0.0 effect = effect_arr.tolist() - res = granger_pair_analysis("c", cause, "e", effect, max_lag=1) + res = granger_pair_analysis("c", cause, "e", effect, options=GrangerAnalysisOptions(max_lag=1)) assert isinstance(res, GrangerResult) assert res.cause_metric == "c" assert res.effect_metric == "e" diff --git a/tests/test_engine_registry.py b/tests/test_engine_registry.py index 604b94c..a974e0f 100644 --- a/tests/test_engine_registry.py +++ b/tests/test_engine_registry.py @@ -2,11 +2,10 @@ Test engine registry logic for managing tenant-specific weights for different signal types, including default handling, updates, resets, and sanitization of corrupt stored data. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_engine_weights.py b/tests/test_engine_weights.py index 68c61b3..bade9b3 100644 --- a/tests/test_engine_weights.py +++ b/tests/test_engine_weights.py @@ -2,11 +2,10 @@ Test Engine Weights logic for managing tenant-specific weights for different signal types, including default handling, updates, resets, and sanitization of corrupt stored data. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_enums.py b/tests/test_enums.py index 0cfe750..94a6a53 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -2,11 +2,10 @@ Test cases for enums used in the analysis engine, including Severity, Signal, ChangeType, and RcaCategory, validating their properties and relationships. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.enums import ChangeType, RcaCategory, Severity, Signal diff --git a/tests/test_events_registry.py b/tests/test_events_registry.py index 1de7061..7a8113a 100644 --- a/tests/test_events_registry.py +++ b/tests/test_events_registry.py @@ -2,11 +2,10 @@ Test cases for the EventRegistry class in the analysis engine, validating registration, retrieval, filtering, and clearing of events, as well as edge cases in timestamp handling. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.events.registry import DeploymentEvent, EventRegistry diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py index 22e4d5e..f536e2c 100644 --- a/tests/test_fetcher.py +++ b/tests/test_fetcher.py @@ -2,11 +2,10 @@ Test cases for Fetcher logic in the analysis engine, including data retrieval, caching behavior, and error handling for different signal types. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest @@ -19,7 +18,7 @@ def __init__(self, results): self._results = results class Metrics: - async def scrape(self_non): + async def scrape(self): return "" self.metrics = Metrics() @@ -34,7 +33,7 @@ async def query_metrics(self, query, start, end, step): async def test_fetch_metrics_filters_exceptions(): provider = DummyProvider(None) queries = ["a", "bad", "c"] - res = await fetch_metrics(provider, queries, 0, 1, "15s") + res = await fetch_metrics(provider, queries, 0, 1, step="15s") assert isinstance(res, list) assert all(isinstance(r, tuple) and isinstance(r[1], dict) for r in res) assert len(res) == 2 diff --git a/tests/test_forecast.py b/tests/test_forecast.py index 91aa0bf..6418a41 100644 --- a/tests/test_forecast.py +++ b/tests/test_forecast.py @@ -2,11 +2,10 @@ Test cases for forecast logic in the analysis engine, including linear fitting, R-squared calculation, and trajectory forecasting with various thresholds and edge cases. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_fuzzy.py b/tests/test_fuzzy.py index 3923580..28ef8cf 100644 --- a/tests/test_fuzzy.py +++ b/tests/test_fuzzy.py @@ -3,11 +3,10 @@ causality, forecasting, degradation analysis, correlation, causal graph logic, and topology graph logic. These tests use randomized inputs to validate that the components can handle a wide range of scenarios without errors. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import random @@ -16,7 +15,7 @@ from api.responses import LogBurst, MetricAnomaly, ServiceLatency from engine.anomaly.detection import detect -from engine.causal.granger import granger_multiple_pairs, granger_pair_analysis +from engine.causal.granger import GrangerAnalysisOptions, granger_multiple_pairs, granger_pair_analysis from engine.causal.graph import CausalGraph from engine.correlation.temporal import correlate from engine.enums import Severity @@ -87,7 +86,7 @@ def test_fuzzy_granger(seed): length = random.randint(15, 60) base = [random.random() for _ in range(length)] other = [b + random.gauss(0, 0.5) for b in base] - res = granger_pair_analysis("a", base, "b", other, max_lag=3) + res = granger_pair_analysis("a", base, "b", other, options=GrangerAnalysisOptions(max_lag=3)) if res: assert res.cause_metric == "a" allr = granger_multiple_pairs({"a": base, "b": other}) diff --git a/tests/test_health_service_and_main_edges.py b/tests/test_health_service_and_main_edges.py index 16a9c30..25ba5d8 100644 --- a/tests/test_health_service_and_main_edges.py +++ b/tests/test_health_service_and_main_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -92,7 +91,7 @@ def __init__(self): async def __aenter__(self): return self - async def __aexit__(self, exc_type, exc, tb): + async def __aexit__(self, *args): return False async def get(self, url, headers=None, timeout=3.0): @@ -116,7 +115,7 @@ class _TimeoutClient: async def __aenter__(self): return self - async def __aexit__(self, exc_type, exc, tb): + async def __aexit__(self, *args): return False async def get(self, url, headers=None, timeout=3.0): diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 64c1c66..c223f1a 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -2,18 +2,17 @@ Test cases for helpers in the analysis engine, including utility functions for time handling, data manipulation, and common calculations used across different components. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import httpx import pytest from datasources.exceptions import InvalidQuery, QueryTimeout -from datasources.helpers import fetch_json, fetch_text +from datasources.helpers import FetchRequestOptions, fetch_json, fetch_text class DummyResponse: @@ -37,7 +36,7 @@ def __init__(self, resp: DummyResponse): async def __aenter__(self): return self - async def __aexit__(self, exc_type, exc, tb): + async def __aexit__(self, *args): return False async def get(self, url, params=None, headers=None): @@ -48,7 +47,7 @@ async def get(self, url, params=None, headers=None): async def test_fetch_json_success(monkeypatch): resp = DummyResponse(status_code=200, json_data={"foo": "bar"}) monkeypatch.setattr(httpx, "AsyncClient", lambda timeout: DummyClient(resp)) - got = await fetch_json("url", params={"a": 1}, headers={}) + got = await fetch_json("url", options=FetchRequestOptions(params={"a": 1}, headers={})) assert got == {"foo": "bar"} @@ -78,3 +77,13 @@ async def test_fetch_text_success(monkeypatch): monkeypatch.setattr(httpx, "AsyncClient", lambda timeout: DummyClient(resp)) got = await fetch_text("url") assert got == "hello" + + +@pytest.mark.asyncio +async def test_fetch_json_falls_back_to_owned_client_when_client_is_invalid(monkeypatch): + resp = DummyResponse(status_code=200, json_data={"ok": True}) + monkeypatch.setattr(httpx, "AsyncClient", lambda timeout: DummyClient(resp)) + + got = await fetch_json("url", options=FetchRequestOptions(client=object())) + + assert got == {"ok": True} diff --git a/tests/test_internal_security.py b/tests/test_internal_security.py index f58cacc..a72489d 100644 --- a/tests/test_internal_security.py +++ b/tests/test_internal_security.py @@ -2,11 +2,10 @@ Test Internal Security logic for the analysis engine, including authentication and authorization of internal service requests, context management, and enforcement of tenant scope based on JWT tokens. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import asyncio diff --git a/tests/test_jobs_routes.py b/tests/test_jobs_routes.py index 8e3e17f..92a9546 100644 --- a/tests/test_jobs_routes.py +++ b/tests/test_jobs_routes.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_logs.py b/tests/test_logs.py index 7329550..770c9c5 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -2,11 +2,10 @@ Test cases for log analysis logic in the analysis engine, including detection of log bursts, correlation with anomalies and latency, and edge cases in timestamp handling and severity assignment. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.enums import Severity diff --git a/tests/test_loki_connector.py b/tests/test_loki_connector.py index 1aa7321..c721740 100644 --- a/tests/test_loki_connector.py +++ b/tests/test_loki_connector.py @@ -2,11 +2,10 @@ Loki connector tests focused on validating that query normalization correctly handles empty and empty-compatible matchers, ensuring that queries are transformed into a format that Loki can process without errors. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from connectors.loki import LokiConnector diff --git a/tests/test_main_ready.py b/tests/test_main_ready.py index 9e65a0c..710397c 100644 --- a/tests/test_main_ready.py +++ b/tests/test_main_ready.py @@ -1,11 +1,10 @@ """ Ready tests for the API service, focused on validating that route permissions are correctly wired and enforced. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_ml_clustering.py b/tests/test_ml_clustering.py index 1f71b31..4664429 100644 --- a/tests/test_ml_clustering.py +++ b/tests/test_ml_clustering.py @@ -1,11 +1,10 @@ """ Tests for metric-aware anomaly clustering. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_ml_ranking.py b/tests/test_ml_ranking.py index 09ab284..9b7e9b5 100644 --- a/tests/test_ml_ranking.py +++ b/tests/test_ml_ranking.py @@ -1,11 +1,10 @@ """ Tests for RCA cause ranking (RandomForest + per-row attribution). -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_mutation_routes.py b/tests/test_mutation_routes.py index 2847704..afb4cc5 100644 --- a/tests/test_mutation_routes.py +++ b/tests/test_mutation_routes.py @@ -1,3 +1,10 @@ +""" +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. +""" + from __future__ import annotations from datetime import UTC, datetime diff --git a/tests/test_openapi_middleware.py b/tests/test_openapi_middleware.py index 714b811..246e358 100644 --- a/tests/test_openapi_middleware.py +++ b/tests/test_openapi_middleware.py @@ -1,11 +1,10 @@ """ OpenAPI middleware tests. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations @@ -48,28 +47,28 @@ def test_project_version_helpers_cover_success_and_fallback(monkeypatch) -> None monkeypatch.setattr( openapi_middleware.Path, "read_text", - lambda self, encoding="utf-8": "[project]\nversion = '9.9.9'\n", + lambda *args, **kwargs: "[project]\nversion = '9.9.9'\n", ) assert openapi_middleware._project_version() == "9.9.9" monkeypatch.setattr( openapi_middleware.Path, "read_text", - lambda self, encoding="utf-8": "[project]\nversion = ''\n", + lambda *args, **kwargs: "[project]\nversion = ''\n", ) assert openapi_middleware._project_version() == openapi_middleware._DEFAULT_APP_VERSION monkeypatch.setattr( openapi_middleware.Path, "read_text", - lambda self, encoding="utf-8": (_ for _ in ()).throw(OSError("boom")), + lambda *args, **kwargs: (_ for _ in ()).throw(OSError("boom")), ) assert openapi_middleware._project_version() == openapi_middleware._DEFAULT_APP_VERSION monkeypatch.setattr( openapi_middleware.Path, "read_text", - lambda self, encoding="utf-8": "bad-toml", + lambda *args, **kwargs: "bad-toml", ) monkeypatch.setattr( openapi_middleware.tomllib, diff --git a/tests/test_rca_hypothesis.py b/tests/test_rca_hypothesis.py index 325acb3..7fb9d7c 100644 --- a/tests/test_rca_hypothesis.py +++ b/tests/test_rca_hypothesis.py @@ -2,17 +2,16 @@ Test RCA hypothesis generation logic in the analysis engine, including creation of hypotheses based on correlated signals, relevance scoring, and edge cases in timestamp handling and signal relationships. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from api.responses import MetricAnomaly, ServiceLatency from engine.correlation.temporal import CorrelatedEvent from engine.enums import ChangeType, RcaCategory, Severity -from engine.rca.hypothesis import RootCause, _action_for_category, _signals_from_event, generate +from engine.rca.hypothesis import RcaSignalInputs, RootCause, _action_for_category, _signals_from_event, generate class DummyEvent: @@ -51,7 +50,12 @@ def test_signals_and_actions(): def test_generate_empty(): - root = generate([], [], [], [], [], correlated_events=[], graph=None, event_registry=None) + root = generate( + RcaSignalInputs(), + correlated_events=[], + graph=None, + event_registry=None, + ) assert root == [] @@ -89,7 +93,12 @@ def test_generate_with_simple_event(): ], confidence=0.5, ) - root = generate([], [], [], [], [], correlated_events=[ev], graph=None, event_registry=None) + root = generate( + RcaSignalInputs(), + correlated_events=[ev], + graph=None, + event_registry=None, + ) assert isinstance(root, list) if root: assert isinstance(root[0], RootCause) @@ -126,7 +135,12 @@ def test_generate_deduplicates_same_hypothesis_events(): service_latency=[], confidence=0.7, ) - causes = generate([], [], [], [], [], correlated_events=[ev1, ev2], graph=None, event_registry=None) + causes = generate( + RcaSignalInputs(), + correlated_events=[ev1, ev2], + graph=None, + event_registry=None, + ) assert len(causes) == 1 assert causes[0].corroboration_summary @@ -169,7 +183,12 @@ def test_hypothesis_prefers_high_impact_metrics_over_alphabetical(): service_latency=[], confidence=0.8, ) - causes = generate([], [], [], [], [], correlated_events=[ev], graph=None, event_registry=None) + causes = generate( + RcaSignalInputs(), + correlated_events=[ev], + graph=None, + event_registry=None, + ) assert causes hyp = causes[0].hypothesis assert "520145" in hyp @@ -200,7 +219,12 @@ def test_generate_includes_process_entity_from_metric_labels(): service_latency=[], confidence=0.7, ) - causes = generate([], [], [], [], [], correlated_events=[ev], graph=None, event_registry=None) + causes = generate( + RcaSignalInputs(), + correlated_events=[ev], + graph=None, + event_registry=None, + ) assert causes assert "process hotspot in redis-server(pid=274)" in causes[0].hypothesis assert any(str(item).startswith("process_entities=") for item in causes[0].evidence) diff --git a/tests/test_rca_scoring.py b/tests/test_rca_scoring.py index 6b25541..1fc81d7 100644 --- a/tests/test_rca_scoring.py +++ b/tests/test_rca_scoring.py @@ -2,11 +2,10 @@ Test cases for scoring logic in the RCA component of the analysis engine, including relevance scoring of root cause candidates based on signal strength, temporal proximity, and category-specific heuristics. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from api.responses import MetricAnomaly, ServiceLatency diff --git a/tests/test_regression_route_gap_workflows.py b/tests/test_regression_route_gap_workflows.py index 62d894d..29f7ef6 100644 --- a/tests/test_regression_route_gap_workflows.py +++ b/tests/test_regression_route_gap_workflows.py @@ -1,5 +1,10 @@ """ Regression workflow tests for route coverage gaps in Resolver. + +Copyright (c) 2026 Stefan Kumarasinghe. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_retry.py b/tests/test_retry.py index 751ca5d..b67c725 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -2,11 +2,10 @@ Test cases for the retry logic in the analysis engine, including handling of transient errors, backoff strategies, and edge cases in retry conditions. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import asyncio diff --git a/tests/test_route_common_helpers.py b/tests/test_route_common_helpers.py index f393a3e..926ec54 100644 --- a/tests/test_route_common_helpers.py +++ b/tests/test_route_common_helpers.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_route_exception_wrapper.py b/tests/test_route_exception_wrapper.py index 9406d3d..76349fa 100644 --- a/tests/test_route_exception_wrapper.py +++ b/tests/test_route_exception_wrapper.py @@ -2,12 +2,13 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations +import inspect + import pytest from fastapi import HTTPException @@ -16,13 +17,15 @@ @pytest.mark.asyncio async def test_handle_exceptions_wraps_async_success_and_errors(): + conflict = HTTPException(status_code=409, detail="conflict") + @handle_exceptions async def ok() -> str: return "ok" @handle_exceptions async def http_fail() -> str: - raise HTTPException(status_code=409, detail="conflict") + raise conflict @handle_exceptions async def fail() -> str: @@ -31,21 +34,25 @@ async def fail() -> str: assert await ok() == "ok" with pytest.raises(HTTPException) as http_exc: await http_fail() + assert http_exc.value is conflict assert http_exc.value.status_code == 409 with pytest.raises(HTTPException) as exc: await fail() assert exc.value.status_code == 500 assert exc.value.detail == "async boom" + assert isinstance(exc.value.__cause__, RuntimeError) def test_handle_exceptions_wraps_sync_success_and_errors(): + missing = HTTPException(status_code=404, detail="missing") + @handle_exceptions def ok() -> str: return "ok" @handle_exceptions def http_fail() -> str: - raise HTTPException(status_code=404, detail="missing") + raise missing @handle_exceptions def fail() -> str: @@ -54,8 +61,23 @@ def fail() -> str: assert ok() == "ok" with pytest.raises(HTTPException) as http_exc: http_fail() + assert http_exc.value is missing assert http_exc.value.status_code == 404 with pytest.raises(HTTPException) as exc: fail() assert exc.value.status_code == 500 assert exc.value.detail == "sync boom" + assert isinstance(exc.value.__cause__, ValueError) + + +def test_handle_exceptions_preserves_sync_async_shapes(): + @handle_exceptions + async def async_handler() -> str: + return "ok" + + @handle_exceptions + def sync_handler() -> str: + return "ok" + + assert inspect.iscoroutinefunction(async_handler) + assert not inspect.iscoroutinefunction(sync_handler) diff --git a/tests/test_route_permissions.py b/tests/test_route_permissions.py index f6d80d1..baf872e 100644 --- a/tests/test_route_permissions.py +++ b/tests/test_route_permissions.py @@ -1,11 +1,10 @@ """ Test suite for validating that route permissions are correctly wired and enforced in the API service. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_slo.py b/tests/test_slo.py index 4c7dd36..1489692 100644 --- a/tests/test_slo.py +++ b/tests/test_slo.py @@ -1,11 +1,10 @@ """ Test Suite for SLO Burn and Budget Calculations. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.slo.budget import BudgetStatus, remaining_minutes @@ -13,7 +12,7 @@ def test_slo_evaluate_empty(): - assert evaluate("svc", [], [], [], 0.99) == [] + assert evaluate("svc", [], [], [], target_availability=0.99) == [] def test_slo_evaluate_burn(): diff --git a/tests/test_store_baseline.py b/tests/test_store_baseline.py index a1d8aa4..3125459 100644 --- a/tests/test_store_baseline.py +++ b/tests/test_store_baseline.py @@ -1,11 +1,10 @@ """ Test Suite for Baseline Storage. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_store_client.py b/tests/test_store_client.py index 5e2a3d5..cd74080 100644 --- a/tests/test_store_client.py +++ b/tests/test_store_client.py @@ -1,11 +1,10 @@ """ Test Suite for Store Client. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_store_client_events_and_weights_edges.py b/tests/test_store_client_events_and_weights_edges.py index ff487e1..365736f 100644 --- a/tests/test_store_client_events_and_weights_edges.py +++ b/tests/test_store_client_events_and_weights_edges.py @@ -2,8 +2,7 @@ Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from __future__ import annotations diff --git a/tests/test_store_granger.py b/tests/test_store_granger.py index c58352e..51e9ca3 100644 --- a/tests/test_store_granger.py +++ b/tests/test_store_granger.py @@ -1,11 +1,10 @@ """ Test Suite for Granger Causality Storage. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_store_keys.py b/tests/test_store_keys.py index 36a7720..629c3a2 100644 --- a/tests/test_store_keys.py +++ b/tests/test_store_keys.py @@ -1,11 +1,10 @@ """ Test Suite for Store Keys. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from store import keys diff --git a/tests/test_store_registry.py b/tests/test_store_registry.py index 89b0341..547cee9 100644 --- a/tests/test_store_registry.py +++ b/tests/test_store_registry.py @@ -1,11 +1,10 @@ """ Test Suite for Store Client. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_store_weights.py b/tests/test_store_weights.py index 553bd05..52a43e4 100644 --- a/tests/test_store_weights.py +++ b/tests/test_store_weights.py @@ -1,11 +1,10 @@ """ Test Suite for Store Weights. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ import pytest diff --git a/tests/test_topology.py b/tests/test_topology.py index 830fb35..790eb49 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -1,11 +1,10 @@ """ Test Suite for Topology Analysis. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.topology.graph import BlastRadius, DependencyGraph diff --git a/tests/test_traces_parsing.py b/tests/test_traces_parsing.py index 732b8af..f14ab93 100644 --- a/tests/test_traces_parsing.py +++ b/tests/test_traces_parsing.py @@ -2,11 +2,10 @@ Test cases for trace analysis logic in the analysis engine, including detection of trace anomalies, correlation with metrics and logs, and edge cases in timestamp handling and service topology. -Copyright (c) 2026 Stefan Kumarasinghe +Copyright (c) 2026 Stefan Kumarasinghe. Licensed 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 +License. See http://www.apache.org/licenses/LICENSE-2.0 for details. """ from engine.traces.errors import detect_propagation