nthlayer-workers 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nthlayer_workers/__init__.py +5 -0
- nthlayer_workers/cli.py +234 -0
- nthlayer_workers/correlate/__init__.py +1 -0
- nthlayer_workers/correlate/cli.py +847 -0
- nthlayer_workers/correlate/config.py +111 -0
- nthlayer_workers/correlate/correlation/__init__.py +1 -0
- nthlayer_workers/correlate/correlation/changes.py +87 -0
- nthlayer_workers/correlate/correlation/dedup.py +62 -0
- nthlayer_workers/correlate/correlation/engine.py +244 -0
- nthlayer_workers/correlate/correlation/temporal.py +79 -0
- nthlayer_workers/correlate/correlation/topology.py +104 -0
- nthlayer_workers/correlate/ingestion/__init__.py +1 -0
- nthlayer_workers/correlate/ingestion/protocol.py +10 -0
- nthlayer_workers/correlate/ingestion/severity.py +18 -0
- nthlayer_workers/correlate/ingestion/webhook.py +197 -0
- nthlayer_workers/correlate/notifications.py +85 -0
- nthlayer_workers/correlate/prometheus.py +234 -0
- nthlayer_workers/correlate/reasoning.py +375 -0
- nthlayer_workers/correlate/session.py +189 -0
- nthlayer_workers/correlate/snapshot/__init__.py +1 -0
- nthlayer_workers/correlate/snapshot/generator.py +170 -0
- nthlayer_workers/correlate/snapshot/model.py +177 -0
- nthlayer_workers/correlate/snapshot/token.py +14 -0
- nthlayer_workers/correlate/state.py +88 -0
- nthlayer_workers/correlate/store/__init__.py +5 -0
- nthlayer_workers/correlate/store/protocol.py +48 -0
- nthlayer_workers/correlate/store/sqlite.py +443 -0
- nthlayer_workers/correlate/summary.py +180 -0
- nthlayer_workers/correlate/traces/__init__.py +1 -0
- nthlayer_workers/correlate/traces/protocol.py +120 -0
- nthlayer_workers/correlate/traces/tempo.py +667 -0
- nthlayer_workers/correlate/traces/topology.py +39 -0
- nthlayer_workers/correlate/types.py +77 -0
- nthlayer_workers/correlate/worker.py +630 -0
- nthlayer_workers/learn/__init__.py +5 -0
- nthlayer_workers/learn/__main__.py +5 -0
- nthlayer_workers/learn/cli.py +164 -0
- nthlayer_workers/learn/retrospective.py +381 -0
- nthlayer_workers/learn/trends.py +102 -0
- nthlayer_workers/learn/worker.py +366 -0
- nthlayer_workers/measure/__init__.py +3 -0
- nthlayer_workers/measure/__main__.py +5 -0
- nthlayer_workers/measure/_parsing.py +15 -0
- nthlayer_workers/measure/adapters/__init__.py +0 -0
- nthlayer_workers/measure/adapters/_util.py +24 -0
- nthlayer_workers/measure/adapters/devin.py +119 -0
- nthlayer_workers/measure/adapters/gastown.py +88 -0
- nthlayer_workers/measure/adapters/prometheus.py +277 -0
- nthlayer_workers/measure/adapters/protocol.py +20 -0
- nthlayer_workers/measure/adapters/webhook.py +161 -0
- nthlayer_workers/measure/api/__init__.py +0 -0
- nthlayer_workers/measure/api/normalise.py +50 -0
- nthlayer_workers/measure/api/queue.py +243 -0
- nthlayer_workers/measure/api/response.py +51 -0
- nthlayer_workers/measure/api/server.py +504 -0
- nthlayer_workers/measure/calibration/__init__.py +0 -0
- nthlayer_workers/measure/calibration/loop.py +62 -0
- nthlayer_workers/measure/calibration/slos.py +212 -0
- nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
- nthlayer_workers/measure/cli.py +753 -0
- nthlayer_workers/measure/config.py +191 -0
- nthlayer_workers/measure/detection/__init__.py +6 -0
- nthlayer_workers/measure/detection/detector.py +82 -0
- nthlayer_workers/measure/detection/protocol.py +29 -0
- nthlayer_workers/measure/governance/__init__.py +0 -0
- nthlayer_workers/measure/governance/engine.py +163 -0
- nthlayer_workers/measure/manifest.py +77 -0
- nthlayer_workers/measure/notifications.py +53 -0
- nthlayer_workers/measure/pipeline/__init__.py +0 -0
- nthlayer_workers/measure/pipeline/evaluator.py +155 -0
- nthlayer_workers/measure/pipeline/router.py +160 -0
- nthlayer_workers/measure/store/__init__.py +0 -0
- nthlayer_workers/measure/store/protocol.py +38 -0
- nthlayer_workers/measure/store/sqlite.py +276 -0
- nthlayer_workers/measure/telemetry.py +116 -0
- nthlayer_workers/measure/tiering/__init__.py +0 -0
- nthlayer_workers/measure/tiering/classifier.py +58 -0
- nthlayer_workers/measure/tiering/promotion.py +118 -0
- nthlayer_workers/measure/trends/__init__.py +0 -0
- nthlayer_workers/measure/trends/tracker.py +72 -0
- nthlayer_workers/measure/types.py +75 -0
- nthlayer_workers/measure/worker.py +439 -0
- nthlayer_workers/observe/__init__.py +25 -0
- nthlayer_workers/observe/__main__.py +5 -0
- nthlayer_workers/observe/api/__init__.py +1 -0
- nthlayer_workers/observe/assessment.py +95 -0
- nthlayer_workers/observe/cli.py +737 -0
- nthlayer_workers/observe/config.py +11 -0
- nthlayer_workers/observe/db/__init__.py +1 -0
- nthlayer_workers/observe/decision_records.py +220 -0
- nthlayer_workers/observe/dependencies/__init__.py +18 -0
- nthlayer_workers/observe/dependencies/discovery.py +294 -0
- nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
- nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
- nthlayer_workers/observe/dependencies/providers/base.py +76 -0
- nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
- nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
- nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
- nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
- nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
- nthlayer_workers/observe/deployments/__init__.py +1 -0
- nthlayer_workers/observe/discovery/__init__.py +14 -0
- nthlayer_workers/observe/discovery/classifier.py +66 -0
- nthlayer_workers/observe/discovery/client.py +189 -0
- nthlayer_workers/observe/discovery/models.py +53 -0
- nthlayer_workers/observe/drift/__init__.py +26 -0
- nthlayer_workers/observe/drift/analyzer.py +383 -0
- nthlayer_workers/observe/drift/models.py +174 -0
- nthlayer_workers/observe/drift/patterns.py +88 -0
- nthlayer_workers/observe/explanation.py +118 -0
- nthlayer_workers/observe/gate/__init__.py +39 -0
- nthlayer_workers/observe/gate/conditions.py +92 -0
- nthlayer_workers/observe/gate/correlator.py +154 -0
- nthlayer_workers/observe/gate/evaluator.py +192 -0
- nthlayer_workers/observe/gate/policies.py +226 -0
- nthlayer_workers/observe/gate_adapter.py +40 -0
- nthlayer_workers/observe/incident.py +36 -0
- nthlayer_workers/observe/portfolio/__init__.py +17 -0
- nthlayer_workers/observe/portfolio/aggregator.py +168 -0
- nthlayer_workers/observe/portfolio/scorer.py +13 -0
- nthlayer_workers/observe/slo/__init__.py +19 -0
- nthlayer_workers/observe/slo/collector.py +235 -0
- nthlayer_workers/observe/slo/spec_loader.py +40 -0
- nthlayer_workers/observe/sqlite_store.py +152 -0
- nthlayer_workers/observe/store.py +92 -0
- nthlayer_workers/observe/verification/__init__.py +22 -0
- nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
- nthlayer_workers/observe/verification/extractor.py +127 -0
- nthlayer_workers/observe/verification/models.py +101 -0
- nthlayer_workers/observe/verification/verifier.py +111 -0
- nthlayer_workers/observe/worker.py +332 -0
- nthlayer_workers/respond/__init__.py +2 -0
- nthlayer_workers/respond/__main__.py +4 -0
- nthlayer_workers/respond/agents/__init__.py +0 -0
- nthlayer_workers/respond/agents/base.py +556 -0
- nthlayer_workers/respond/agents/communication.py +115 -0
- nthlayer_workers/respond/agents/investigation.py +124 -0
- nthlayer_workers/respond/agents/remediation.py +219 -0
- nthlayer_workers/respond/agents/triage.py +132 -0
- nthlayer_workers/respond/cli.py +772 -0
- nthlayer_workers/respond/config.py +135 -0
- nthlayer_workers/respond/context_store.py +256 -0
- nthlayer_workers/respond/coordinator.py +487 -0
- nthlayer_workers/respond/metrics.py +104 -0
- nthlayer_workers/respond/notification_backends/__init__.py +1 -0
- nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
- nthlayer_workers/respond/notification_backends/protocol.py +59 -0
- nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
- nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
- nthlayer_workers/respond/notifications.py +247 -0
- nthlayer_workers/respond/oncall/__init__.py +1 -0
- nthlayer_workers/respond/oncall/escalation.py +103 -0
- nthlayer_workers/respond/oncall/runner.py +193 -0
- nthlayer_workers/respond/oncall/schedule.py +243 -0
- nthlayer_workers/respond/safe_actions/__init__.py +0 -0
- nthlayer_workers/respond/safe_actions/actions.py +139 -0
- nthlayer_workers/respond/safe_actions/registry.py +171 -0
- nthlayer_workers/respond/safe_actions/webhook.py +194 -0
- nthlayer_workers/respond/server.py +357 -0
- nthlayer_workers/respond/sre/__init__.py +1 -0
- nthlayer_workers/respond/sre/brief.py +175 -0
- nthlayer_workers/respond/sre/delegation.py +101 -0
- nthlayer_workers/respond/sre/post_incident.py +146 -0
- nthlayer_workers/respond/sre/shift_report.py +129 -0
- nthlayer_workers/respond/sre/suppression.py +91 -0
- nthlayer_workers/respond/types.py +109 -0
- nthlayer_workers/respond/verdict_submission.py +56 -0
- nthlayer_workers/respond/worker.py +533 -0
- nthlayer_workers/respond/worker_helpers.py +140 -0
- nthlayer_workers/runner.py +198 -0
- nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
- nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
- nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
- nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
- nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""Grafana Tempo trace backend adapter.
|
|
2
|
+
|
|
3
|
+
Uses TraceQL metrics API for server-side aggregation and TraceQL search
|
|
4
|
+
for sample traces. Optionally queries Prometheus for pre-computed service
|
|
5
|
+
graph metrics (metrics-generator).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .protocol import (
|
|
19
|
+
ErrorSummary,
|
|
20
|
+
OperationLatency,
|
|
21
|
+
ServiceCallEdge,
|
|
22
|
+
ServiceTraceProfile,
|
|
23
|
+
TraceEvidence,
|
|
24
|
+
TraceSpanSummary,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TempoTraceBackend:
|
|
29
|
+
"""TraceBackend implementation for Grafana Tempo."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
endpoint: str | None = None,
|
|
34
|
+
org_id: str | None = None,
|
|
35
|
+
timeout: int = 30,
|
|
36
|
+
use_service_graphs: bool = True,
|
|
37
|
+
prometheus_url: str | None = None,
|
|
38
|
+
):
|
|
39
|
+
self.endpoint = (
|
|
40
|
+
endpoint
|
|
41
|
+
or os.environ.get("TEMPO_ENDPOINT", "http://localhost:3200")
|
|
42
|
+
)
|
|
43
|
+
self.org_id = org_id or os.environ.get("TEMPO_ORG_ID", "")
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
self.use_service_graphs = use_service_graphs
|
|
46
|
+
self.prometheus_url = prometheus_url
|
|
47
|
+
|
|
48
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
49
|
+
if self.org_id:
|
|
50
|
+
headers["X-Scope-OrgID"] = self.org_id
|
|
51
|
+
|
|
52
|
+
self._client = httpx.AsyncClient(
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
headers=headers,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# -------------------------------------------------------------------
|
|
58
|
+
# TraceBackend protocol methods
|
|
59
|
+
# -------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
async def get_trace_evidence(
|
|
62
|
+
self,
|
|
63
|
+
services: list[str],
|
|
64
|
+
start: datetime,
|
|
65
|
+
end: datetime,
|
|
66
|
+
baseline_window: timedelta = timedelta(hours=1),
|
|
67
|
+
) -> TraceEvidence:
|
|
68
|
+
query_start = time.monotonic()
|
|
69
|
+
baseline_start = start - baseline_window
|
|
70
|
+
|
|
71
|
+
# Phase 1: Per-service latency + error aggregation
|
|
72
|
+
incident_stats = await self._query_service_stats(services, start, end)
|
|
73
|
+
baseline_stats = await self._query_service_stats(services, baseline_start, start)
|
|
74
|
+
|
|
75
|
+
# Phase 2: Service-to-service call edges
|
|
76
|
+
if self.use_service_graphs and self.prometheus_url:
|
|
77
|
+
edges = await self._query_service_graphs_from_prometheus(services, start, end)
|
|
78
|
+
else:
|
|
79
|
+
edges = await self._query_edges_from_traces(services, start, end)
|
|
80
|
+
|
|
81
|
+
# Phase 3: Per-operation breakdown
|
|
82
|
+
operations = await self._query_operation_breakdown(
|
|
83
|
+
services, start, end, baseline_start,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Phase 4: Top errors
|
|
87
|
+
errors = await self._query_top_errors(services, start, end)
|
|
88
|
+
|
|
89
|
+
# Phase 5: Sample traces
|
|
90
|
+
samples = await self._query_sample_traces(services, start, end)
|
|
91
|
+
|
|
92
|
+
# Assemble per-service profiles
|
|
93
|
+
service_profiles: list[ServiceTraceProfile] = []
|
|
94
|
+
for service in services:
|
|
95
|
+
incident = incident_stats.get(service)
|
|
96
|
+
baseline = baseline_stats.get(service)
|
|
97
|
+
|
|
98
|
+
if not incident:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
latency_change = None
|
|
102
|
+
if baseline and baseline.p50_ms > 0:
|
|
103
|
+
latency_change = (
|
|
104
|
+
(incident.p50_ms - baseline.p50_ms) / baseline.p50_ms * 100
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
profile = ServiceTraceProfile(
|
|
108
|
+
service=service,
|
|
109
|
+
time_window_start=start,
|
|
110
|
+
time_window_end=end,
|
|
111
|
+
callers=[e for e in edges if e.target_service == service],
|
|
112
|
+
callees=[e for e in edges if e.source_service == service],
|
|
113
|
+
p50_latency_ms=incident.p50_ms,
|
|
114
|
+
p95_latency_ms=incident.p95_ms,
|
|
115
|
+
p99_latency_ms=incident.p99_ms,
|
|
116
|
+
baseline_p50_ms=baseline.p50_ms if baseline else None,
|
|
117
|
+
latency_change_pct=latency_change,
|
|
118
|
+
error_rate=incident.error_rate,
|
|
119
|
+
error_count=incident.error_count,
|
|
120
|
+
total_request_count=incident.total_count,
|
|
121
|
+
top_errors=errors.get(service, []),
|
|
122
|
+
slow_operations=operations.get(service, []),
|
|
123
|
+
sample_error_traces=samples.get(service, {}).get("errors", []),
|
|
124
|
+
sample_slow_traces=samples.get(service, {}).get("slow", []),
|
|
125
|
+
)
|
|
126
|
+
service_profiles.append(profile)
|
|
127
|
+
|
|
128
|
+
query_time = (time.monotonic() - query_start) * 1000
|
|
129
|
+
|
|
130
|
+
return TraceEvidence(
|
|
131
|
+
services=service_profiles,
|
|
132
|
+
topology_divergence=None, # computed by caller with specs
|
|
133
|
+
query_time_ms=query_time,
|
|
134
|
+
backend="tempo",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def get_service_dependencies(
|
|
138
|
+
self,
|
|
139
|
+
service: str,
|
|
140
|
+
start: datetime,
|
|
141
|
+
end: datetime,
|
|
142
|
+
) -> list[ServiceCallEdge]:
|
|
143
|
+
if self.use_service_graphs and self.prometheus_url:
|
|
144
|
+
return await self._query_service_graphs_from_prometheus(
|
|
145
|
+
[service], start, end,
|
|
146
|
+
)
|
|
147
|
+
return await self._query_edges_from_traces([service], start, end)
|
|
148
|
+
|
|
149
|
+
async def health_check(self) -> bool:
|
|
150
|
+
try:
|
|
151
|
+
response = await self._client.get(f"{self.endpoint}/ready")
|
|
152
|
+
return response.status_code == 200
|
|
153
|
+
except Exception:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
async def aclose(self) -> None:
|
|
157
|
+
"""Close the underlying httpx client and release connections."""
|
|
158
|
+
await self._client.aclose()
|
|
159
|
+
|
|
160
|
+
# -------------------------------------------------------------------
|
|
161
|
+
# High-level query methods
|
|
162
|
+
# -------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
async def _query_service_stats(
|
|
165
|
+
self, services: list[str], start: datetime, end: datetime,
|
|
166
|
+
) -> dict[str, _ServiceStats]:
|
|
167
|
+
"""Per-service latency percentiles, error count, and request count."""
|
|
168
|
+
|
|
169
|
+
service_filter = self._traceql_service_filter(services)
|
|
170
|
+
duration_secs = max(int((end - start).total_seconds()), 60)
|
|
171
|
+
step = f"{max(duration_secs // 20, 15)}s"
|
|
172
|
+
|
|
173
|
+
p50_query = (
|
|
174
|
+
f'{{ {service_filter} && span.kind = server }} '
|
|
175
|
+
f'| quantile_over_time(duration, .50) by (resource.service.name)'
|
|
176
|
+
)
|
|
177
|
+
p95_query = (
|
|
178
|
+
f'{{ {service_filter} && span.kind = server }} '
|
|
179
|
+
f'| quantile_over_time(duration, .95) by (resource.service.name)'
|
|
180
|
+
)
|
|
181
|
+
p99_query = (
|
|
182
|
+
f'{{ {service_filter} && span.kind = server }} '
|
|
183
|
+
f'| quantile_over_time(duration, .99) by (resource.service.name)'
|
|
184
|
+
)
|
|
185
|
+
count_query = (
|
|
186
|
+
f'{{ {service_filter} && span.kind = server }} '
|
|
187
|
+
f'| count_over_time() by (resource.service.name)'
|
|
188
|
+
)
|
|
189
|
+
error_query = (
|
|
190
|
+
f'{{ {service_filter} && span.kind = server && status = error }} '
|
|
191
|
+
f'| count_over_time() by (resource.service.name)'
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
p50_data, p95_data, p99_data, count_data, error_data = await asyncio.gather(
|
|
195
|
+
self._traceql_metrics_query(p50_query, start, end, step),
|
|
196
|
+
self._traceql_metrics_query(p95_query, start, end, step),
|
|
197
|
+
self._traceql_metrics_query(p99_query, start, end, step),
|
|
198
|
+
self._traceql_metrics_query(count_query, start, end, step),
|
|
199
|
+
self._traceql_metrics_query(error_query, start, end, step),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
p50_by_svc = self._extract_metric_by_service(p50_data)
|
|
203
|
+
p95_by_svc = self._extract_metric_by_service(p95_data)
|
|
204
|
+
p99_by_svc = self._extract_metric_by_service(p99_data)
|
|
205
|
+
count_by_svc = self._extract_metric_by_service(count_data)
|
|
206
|
+
error_by_svc = self._extract_metric_by_service(error_data)
|
|
207
|
+
|
|
208
|
+
results: dict[str, _ServiceStats] = {}
|
|
209
|
+
for service in services:
|
|
210
|
+
total = count_by_svc.get(service, 0)
|
|
211
|
+
errors = error_by_svc.get(service, 0)
|
|
212
|
+
if total == 0:
|
|
213
|
+
continue
|
|
214
|
+
results[service] = _ServiceStats(
|
|
215
|
+
p50_ms=p50_by_svc.get(service, 0) / 1_000_000,
|
|
216
|
+
p95_ms=p95_by_svc.get(service, 0) / 1_000_000,
|
|
217
|
+
p99_ms=p99_by_svc.get(service, 0) / 1_000_000,
|
|
218
|
+
total_count=int(total),
|
|
219
|
+
error_count=int(errors),
|
|
220
|
+
error_rate=errors / total if total > 0 else 0.0,
|
|
221
|
+
)
|
|
222
|
+
return results
|
|
223
|
+
|
|
224
|
+
async def _query_operation_breakdown(
|
|
225
|
+
self,
|
|
226
|
+
services: list[str],
|
|
227
|
+
start: datetime,
|
|
228
|
+
end: datetime,
|
|
229
|
+
baseline_start: datetime,
|
|
230
|
+
) -> dict[str, list[OperationLatency]]:
|
|
231
|
+
"""Per-operation latency breakdown within each service."""
|
|
232
|
+
|
|
233
|
+
service_filter = self._traceql_service_filter(services)
|
|
234
|
+
duration_secs = max(int((end - start).total_seconds()), 60)
|
|
235
|
+
step = f"{max(duration_secs // 20, 15)}s"
|
|
236
|
+
|
|
237
|
+
p50_query = (
|
|
238
|
+
f'{{ {service_filter} && span.kind = server }} '
|
|
239
|
+
f'| quantile_over_time(duration, .50) by (resource.service.name, name)'
|
|
240
|
+
)
|
|
241
|
+
p99_query = (
|
|
242
|
+
f'{{ {service_filter} && span.kind = server }} '
|
|
243
|
+
f'| quantile_over_time(duration, .99) by (resource.service.name, name)'
|
|
244
|
+
)
|
|
245
|
+
count_query = (
|
|
246
|
+
f'{{ {service_filter} && span.kind = server }} '
|
|
247
|
+
f'| count_over_time() by (resource.service.name, name)'
|
|
248
|
+
)
|
|
249
|
+
error_query = (
|
|
250
|
+
f'{{ {service_filter} && span.kind = server && status = error }} '
|
|
251
|
+
f'| count_over_time() by (resource.service.name, name)'
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
(inc_p50, inc_p99, inc_count, inc_error, base_p50) = await asyncio.gather(
|
|
255
|
+
self._traceql_metrics_query(p50_query, start, end, step),
|
|
256
|
+
self._traceql_metrics_query(p99_query, start, end, step),
|
|
257
|
+
self._traceql_metrics_query(count_query, start, end, step),
|
|
258
|
+
self._traceql_metrics_query(error_query, start, end, step),
|
|
259
|
+
self._traceql_metrics_query(p50_query, baseline_start, start, step),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
inc_p50_map = self._extract_metric_by_service_and_op(inc_p50)
|
|
263
|
+
inc_p99_map = self._extract_metric_by_service_and_op(inc_p99)
|
|
264
|
+
inc_count_map = self._extract_metric_by_service_and_op(inc_count)
|
|
265
|
+
inc_error_map = self._extract_metric_by_service_and_op(inc_error)
|
|
266
|
+
base_p50_map = self._extract_metric_by_service_and_op(base_p50)
|
|
267
|
+
|
|
268
|
+
result: dict[str, list[OperationLatency]] = {}
|
|
269
|
+
|
|
270
|
+
for (service, operation), p50_ns in inc_p50_map.items():
|
|
271
|
+
if service not in result:
|
|
272
|
+
result[service] = []
|
|
273
|
+
|
|
274
|
+
total = inc_count_map.get((service, operation), 0)
|
|
275
|
+
errors = inc_error_map.get((service, operation), 0)
|
|
276
|
+
p50_ms = p50_ns / 1_000_000
|
|
277
|
+
p99_ms = inc_p99_map.get((service, operation), 0) / 1_000_000
|
|
278
|
+
baseline_p50_ns = base_p50_map.get((service, operation))
|
|
279
|
+
baseline_p50_ms = baseline_p50_ns / 1_000_000 if baseline_p50_ns else None
|
|
280
|
+
|
|
281
|
+
change_pct = None
|
|
282
|
+
if baseline_p50_ms and baseline_p50_ms > 0:
|
|
283
|
+
change_pct = (p50_ms - baseline_p50_ms) / baseline_p50_ms * 100
|
|
284
|
+
|
|
285
|
+
result[service].append(OperationLatency(
|
|
286
|
+
operation=operation,
|
|
287
|
+
p50_ms=p50_ms,
|
|
288
|
+
p99_ms=p99_ms,
|
|
289
|
+
request_count=int(total),
|
|
290
|
+
error_rate=errors / total if total > 0 else 0.0,
|
|
291
|
+
baseline_p50_ms=baseline_p50_ms,
|
|
292
|
+
change_pct=change_pct,
|
|
293
|
+
))
|
|
294
|
+
|
|
295
|
+
for service in result:
|
|
296
|
+
result[service].sort(key=lambda o: o.p99_ms, reverse=True)
|
|
297
|
+
result[service] = result[service][:10]
|
|
298
|
+
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
async def _query_edges_from_traces(
|
|
302
|
+
self, services: list[str], start: datetime, end: datetime,
|
|
303
|
+
) -> list[ServiceCallEdge]:
|
|
304
|
+
"""Derive service-to-service edges from trace search (slow path).
|
|
305
|
+
|
|
306
|
+
For client spans: resource.service.name is the calling service,
|
|
307
|
+
span.peer.service is the target. Less accurate than service graph
|
|
308
|
+
metrics — use_service_graphs=True is preferred when available.
|
|
309
|
+
"""
|
|
310
|
+
service_filter = self._traceql_service_filter(services)
|
|
311
|
+
search_query = f'{{ {service_filter} && span.kind = client }}'
|
|
312
|
+
|
|
313
|
+
results = await self._traceql_search(search_query, start, end, limit=1000)
|
|
314
|
+
|
|
315
|
+
edge_counts: dict[tuple[str, str], _EdgeAccumulator] = {}
|
|
316
|
+
|
|
317
|
+
for span in results:
|
|
318
|
+
source = span.get("resource.service.name", "")
|
|
319
|
+
target = span.get("span.peer.service", "")
|
|
320
|
+
if not source or not target or source == target:
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
key = (source, target)
|
|
324
|
+
if key not in edge_counts:
|
|
325
|
+
edge_counts[key] = _EdgeAccumulator()
|
|
326
|
+
|
|
327
|
+
acc = edge_counts[key]
|
|
328
|
+
acc.count += 1
|
|
329
|
+
duration_ms = span.get("durationMs", 0)
|
|
330
|
+
acc.durations.append(duration_ms)
|
|
331
|
+
if span.get("status") == "error":
|
|
332
|
+
acc.errors += 1
|
|
333
|
+
|
|
334
|
+
edges = []
|
|
335
|
+
for (source, target), acc in edge_counts.items():
|
|
336
|
+
sorted_d = sorted(acc.durations)
|
|
337
|
+
edges.append(ServiceCallEdge(
|
|
338
|
+
source_service=source,
|
|
339
|
+
target_service=target,
|
|
340
|
+
request_count=acc.count,
|
|
341
|
+
error_count=acc.errors,
|
|
342
|
+
p50_latency_ms=sorted_d[len(sorted_d) // 2] if sorted_d else 0,
|
|
343
|
+
p99_latency_ms=sorted_d[int(len(sorted_d) * 0.99)] if sorted_d else 0,
|
|
344
|
+
))
|
|
345
|
+
return edges
|
|
346
|
+
|
|
347
|
+
async def _query_service_graphs_from_prometheus(
|
|
348
|
+
self, services: list[str], start: datetime, end: datetime,
|
|
349
|
+
) -> list[ServiceCallEdge]:
|
|
350
|
+
"""Fast path: query pre-computed service graph metrics from Prometheus."""
|
|
351
|
+
|
|
352
|
+
if not self.prometheus_url or not services:
|
|
353
|
+
return []
|
|
354
|
+
|
|
355
|
+
service_regex = "|".join(services)
|
|
356
|
+
window = f"{int((end - start).total_seconds())}s"
|
|
357
|
+
|
|
358
|
+
count_query = (
|
|
359
|
+
f'sum by (client, server) ('
|
|
360
|
+
f'increase(traces_service_graph_request_total'
|
|
361
|
+
f'{{client=~"{service_regex}|", server=~"|{service_regex}"}}'
|
|
362
|
+
f'[{window}]))'
|
|
363
|
+
)
|
|
364
|
+
error_query = (
|
|
365
|
+
f'sum by (client, server) ('
|
|
366
|
+
f'increase(traces_service_graph_request_failed_total'
|
|
367
|
+
f'{{client=~"{service_regex}|", server=~"|{service_regex}"}}'
|
|
368
|
+
f'[{window}]))'
|
|
369
|
+
)
|
|
370
|
+
p99_query = (
|
|
371
|
+
f'histogram_quantile(0.99, sum by (client, server, le) ('
|
|
372
|
+
f'increase(traces_service_graph_request_server_seconds_bucket'
|
|
373
|
+
f'{{client=~"{service_regex}|", server=~"|{service_regex}"}}'
|
|
374
|
+
f'[{window}])))'
|
|
375
|
+
)
|
|
376
|
+
p50_query = p99_query.replace("0.99", "0.50")
|
|
377
|
+
|
|
378
|
+
count_data, error_data, p99_data, p50_data = await asyncio.gather(
|
|
379
|
+
self._prometheus_query(count_query, end),
|
|
380
|
+
self._prometheus_query(error_query, end),
|
|
381
|
+
self._prometheus_query(p99_query, end),
|
|
382
|
+
self._prometheus_query(p50_query, end),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return self._parse_service_graph_results(
|
|
386
|
+
count_data, error_data, p99_data, p50_data,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
async def _query_top_errors(
|
|
390
|
+
self, services: list[str], start: datetime, end: datetime,
|
|
391
|
+
) -> dict[str, list[ErrorSummary]]:
|
|
392
|
+
"""Top error messages per service (parallel across services)."""
|
|
393
|
+
|
|
394
|
+
async def _fetch_one(service: str) -> tuple[str, list[ErrorSummary]]:
|
|
395
|
+
search_query = (
|
|
396
|
+
f'{{ resource.service.name = "{service}" '
|
|
397
|
+
f'&& status = error && span.kind = server }}'
|
|
398
|
+
)
|
|
399
|
+
spans = await self._traceql_search(search_query, start, end, limit=100)
|
|
400
|
+
if not spans:
|
|
401
|
+
return service, []
|
|
402
|
+
|
|
403
|
+
error_groups: dict[str, list[dict]] = {}
|
|
404
|
+
for span in spans:
|
|
405
|
+
msg = (
|
|
406
|
+
span.get("span.status_message", "")
|
|
407
|
+
or span.get("status.message", "")
|
|
408
|
+
or "unknown error"
|
|
409
|
+
)
|
|
410
|
+
error_groups.setdefault(msg, []).append(span)
|
|
411
|
+
|
|
412
|
+
sorted_errors = sorted(
|
|
413
|
+
error_groups.items(), key=lambda x: len(x[1]), reverse=True,
|
|
414
|
+
)[:5]
|
|
415
|
+
|
|
416
|
+
summaries = []
|
|
417
|
+
for msg, group_spans in sorted_errors:
|
|
418
|
+
timestamps = [s.get("startTimeUnixNano", 0) for s in group_spans
|
|
419
|
+
if s.get("startTimeUnixNano", 0) > 0]
|
|
420
|
+
first = self._parse_tempo_timestamp(min(timestamps)) if timestamps else None
|
|
421
|
+
last = self._parse_tempo_timestamp(max(timestamps)) if timestamps else None
|
|
422
|
+
summaries.append(ErrorSummary(
|
|
423
|
+
error_message=msg,
|
|
424
|
+
count=len(group_spans),
|
|
425
|
+
first_seen=first,
|
|
426
|
+
last_seen=last,
|
|
427
|
+
sample_trace_id=group_spans[0].get("traceID"),
|
|
428
|
+
))
|
|
429
|
+
return service, summaries
|
|
430
|
+
|
|
431
|
+
pairs = await asyncio.gather(*[_fetch_one(s) for s in services])
|
|
432
|
+
return {svc: errs for svc, errs in pairs if errs}
|
|
433
|
+
|
|
434
|
+
async def _query_sample_traces(
|
|
435
|
+
self, services: list[str], start: datetime, end: datetime,
|
|
436
|
+
) -> dict[str, dict[str, list[TraceSpanSummary]]]:
|
|
437
|
+
"""Sample error and slow traces per service (parallel across services)."""
|
|
438
|
+
|
|
439
|
+
async def _fetch_one(service: str) -> tuple[str, dict[str, list[TraceSpanSummary]]]:
|
|
440
|
+
error_query = (
|
|
441
|
+
f'{{ resource.service.name = "{service}" '
|
|
442
|
+
f'&& status = error && span.kind = server }}'
|
|
443
|
+
)
|
|
444
|
+
error_spans = await self._traceql_search(error_query, start, end, limit=3)
|
|
445
|
+
|
|
446
|
+
slow_query = (
|
|
447
|
+
f'{{ resource.service.name = "{service}" '
|
|
448
|
+
f'&& span.kind = server }}'
|
|
449
|
+
)
|
|
450
|
+
slow_spans_raw = await self._traceql_search(slow_query, start, end, limit=50)
|
|
451
|
+
slow_spans_raw.sort(
|
|
452
|
+
key=lambda s: s.get("durationNanos", 0), reverse=True,
|
|
453
|
+
)
|
|
454
|
+
slow_spans = slow_spans_raw[:3]
|
|
455
|
+
|
|
456
|
+
return service, {
|
|
457
|
+
"errors": [self._span_to_summary(s) for s in error_spans],
|
|
458
|
+
"slow": [self._span_to_summary(s) for s in slow_spans],
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
pairs = await asyncio.gather(*[_fetch_one(s) for s in services])
|
|
462
|
+
return {svc: data for svc, data in pairs}
|
|
463
|
+
|
|
464
|
+
# -------------------------------------------------------------------
|
|
465
|
+
# TraceQL Metrics API
|
|
466
|
+
# -------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
async def _traceql_metrics_query(
|
|
469
|
+
self,
|
|
470
|
+
query: str,
|
|
471
|
+
start: datetime,
|
|
472
|
+
end: datetime,
|
|
473
|
+
step: str,
|
|
474
|
+
) -> dict:
|
|
475
|
+
params = {
|
|
476
|
+
"q": query,
|
|
477
|
+
"start": str(int(start.timestamp())),
|
|
478
|
+
"end": str(int(end.timestamp())),
|
|
479
|
+
"step": step,
|
|
480
|
+
}
|
|
481
|
+
response = await self._client.get(
|
|
482
|
+
f"{self.endpoint}/api/metrics/query_range",
|
|
483
|
+
params=params,
|
|
484
|
+
)
|
|
485
|
+
response.raise_for_status()
|
|
486
|
+
return response.json()
|
|
487
|
+
|
|
488
|
+
async def _traceql_search(
|
|
489
|
+
self,
|
|
490
|
+
query: str,
|
|
491
|
+
start: datetime,
|
|
492
|
+
end: datetime,
|
|
493
|
+
limit: int = 20,
|
|
494
|
+
) -> list[dict]:
|
|
495
|
+
params = {
|
|
496
|
+
"q": query,
|
|
497
|
+
"start": str(int(start.timestamp())),
|
|
498
|
+
"end": str(int(end.timestamp())),
|
|
499
|
+
"limit": str(limit),
|
|
500
|
+
}
|
|
501
|
+
response = await self._client.get(
|
|
502
|
+
f"{self.endpoint}/api/search",
|
|
503
|
+
params=params,
|
|
504
|
+
)
|
|
505
|
+
response.raise_for_status()
|
|
506
|
+
data = response.json()
|
|
507
|
+
return data.get("traces", [])
|
|
508
|
+
|
|
509
|
+
async def _prometheus_query(self, query: str, at: datetime) -> dict:
|
|
510
|
+
if not self.prometheus_url:
|
|
511
|
+
return {}
|
|
512
|
+
params = {
|
|
513
|
+
"query": query,
|
|
514
|
+
"time": str(int(at.timestamp())),
|
|
515
|
+
}
|
|
516
|
+
response = await self._client.get(
|
|
517
|
+
f"{self.prometheus_url}/api/v1/query",
|
|
518
|
+
params=params,
|
|
519
|
+
)
|
|
520
|
+
response.raise_for_status()
|
|
521
|
+
return response.json()
|
|
522
|
+
|
|
523
|
+
# -------------------------------------------------------------------
|
|
524
|
+
# Result parsing helpers (Task 3)
|
|
525
|
+
# -------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
def _extract_metric_by_service(self, response: dict) -> dict[str, float]:
|
|
528
|
+
"""Extract {service_name: average_value} from TraceQL metrics response."""
|
|
529
|
+
result: dict[str, float] = {}
|
|
530
|
+
for series in response.get("series", []):
|
|
531
|
+
service = (
|
|
532
|
+
series.get("labels", {}).get("resource.service.name", "")
|
|
533
|
+
or series.get("promLabels", {}).get("resource_service_name", "")
|
|
534
|
+
)
|
|
535
|
+
if not service:
|
|
536
|
+
continue
|
|
537
|
+
samples = series.get("samples", [])
|
|
538
|
+
if samples:
|
|
539
|
+
values = [s[1] for s in samples if len(s) >= 2 and s[1] is not None]
|
|
540
|
+
result[service] = sum(values) / len(values) if values else 0
|
|
541
|
+
return result
|
|
542
|
+
|
|
543
|
+
def _extract_metric_by_service_and_op(
|
|
544
|
+
self, response: dict,
|
|
545
|
+
) -> dict[tuple[str, str], float]:
|
|
546
|
+
"""Extract {(service, operation): average_value} from faceted response."""
|
|
547
|
+
result: dict[tuple[str, str], float] = {}
|
|
548
|
+
for series in response.get("series", []):
|
|
549
|
+
labels = series.get("labels", {})
|
|
550
|
+
service = labels.get("resource.service.name", "")
|
|
551
|
+
operation = labels.get("name", "")
|
|
552
|
+
if not service or not operation:
|
|
553
|
+
continue
|
|
554
|
+
samples = series.get("samples", [])
|
|
555
|
+
if samples:
|
|
556
|
+
values = [s[1] for s in samples if len(s) >= 2 and s[1] is not None]
|
|
557
|
+
result[(service, operation)] = (
|
|
558
|
+
sum(values) / len(values) if values else 0
|
|
559
|
+
)
|
|
560
|
+
return result
|
|
561
|
+
|
|
562
|
+
def _parse_service_graph_results(
|
|
563
|
+
self,
|
|
564
|
+
count_data: dict,
|
|
565
|
+
error_data: dict,
|
|
566
|
+
p99_data: dict,
|
|
567
|
+
p50_data: dict,
|
|
568
|
+
) -> list[ServiceCallEdge]:
|
|
569
|
+
"""Parse Prometheus service graph metrics into ServiceCallEdge objects."""
|
|
570
|
+
edges: dict[tuple[str, str], dict] = {}
|
|
571
|
+
|
|
572
|
+
for result in count_data.get("data", {}).get("result", []):
|
|
573
|
+
client = result["metric"].get("client", "")
|
|
574
|
+
server = result["metric"].get("server", "")
|
|
575
|
+
if client and server:
|
|
576
|
+
edges[(client, server)] = {
|
|
577
|
+
"count": float(result["value"][1]),
|
|
578
|
+
"errors": 0, "p50": 0, "p99": 0,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
for result in error_data.get("data", {}).get("result", []):
|
|
582
|
+
key = (
|
|
583
|
+
result["metric"].get("client", ""),
|
|
584
|
+
result["metric"].get("server", ""),
|
|
585
|
+
)
|
|
586
|
+
if key in edges:
|
|
587
|
+
edges[key]["errors"] = float(result["value"][1])
|
|
588
|
+
|
|
589
|
+
for result in p99_data.get("data", {}).get("result", []):
|
|
590
|
+
key = (
|
|
591
|
+
result["metric"].get("client", ""),
|
|
592
|
+
result["metric"].get("server", ""),
|
|
593
|
+
)
|
|
594
|
+
if key in edges:
|
|
595
|
+
edges[key]["p99"] = float(result["value"][1]) * 1000
|
|
596
|
+
|
|
597
|
+
for result in p50_data.get("data", {}).get("result", []):
|
|
598
|
+
key = (
|
|
599
|
+
result["metric"].get("client", ""),
|
|
600
|
+
result["metric"].get("server", ""),
|
|
601
|
+
)
|
|
602
|
+
if key in edges:
|
|
603
|
+
edges[key]["p50"] = float(result["value"][1]) * 1000
|
|
604
|
+
|
|
605
|
+
return [
|
|
606
|
+
ServiceCallEdge(
|
|
607
|
+
source_service=client,
|
|
608
|
+
target_service=server,
|
|
609
|
+
request_count=int(vals["count"]),
|
|
610
|
+
error_count=int(vals["errors"]),
|
|
611
|
+
p50_latency_ms=vals["p50"],
|
|
612
|
+
p99_latency_ms=vals["p99"],
|
|
613
|
+
)
|
|
614
|
+
for (client, server), vals in edges.items()
|
|
615
|
+
]
|
|
616
|
+
|
|
617
|
+
def _span_to_summary(self, span: dict) -> TraceSpanSummary:
|
|
618
|
+
"""Convert a Tempo search result span to TraceSpanSummary."""
|
|
619
|
+
return TraceSpanSummary(
|
|
620
|
+
trace_id=span.get("traceID", ""),
|
|
621
|
+
span_id=span.get("spanID", ""),
|
|
622
|
+
service=span.get("rootServiceName", ""),
|
|
623
|
+
operation=span.get("rootTraceName", ""),
|
|
624
|
+
duration_ms=span.get("durationMs", 0),
|
|
625
|
+
status="error" if span.get("status") == "error" else "ok",
|
|
626
|
+
error_message=span.get("span.status_message"),
|
|
627
|
+
parent_service=None,
|
|
628
|
+
attributes={},
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
@staticmethod
|
|
632
|
+
def _parse_tempo_timestamp(nanos: int) -> datetime:
|
|
633
|
+
"""Convert nanosecond unix timestamp to datetime."""
|
|
634
|
+
return datetime.fromtimestamp(nanos / 1_000_000_000, tz=timezone.utc)
|
|
635
|
+
|
|
636
|
+
@staticmethod
|
|
637
|
+
def _traceql_service_filter(services: list[str]) -> str:
|
|
638
|
+
"""Build a TraceQL filter for multiple services."""
|
|
639
|
+
if not services:
|
|
640
|
+
return 'resource.service.name = "__none__"'
|
|
641
|
+
if len(services) == 1:
|
|
642
|
+
return f'resource.service.name = "{services[0]}"'
|
|
643
|
+
conditions = " || ".join(
|
|
644
|
+
f'resource.service.name = "{s}"' for s in services
|
|
645
|
+
)
|
|
646
|
+
return f"({conditions})"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@dataclass
|
|
650
|
+
class _ServiceStats:
|
|
651
|
+
"""Internal: aggregated stats for a single service."""
|
|
652
|
+
|
|
653
|
+
p50_ms: float
|
|
654
|
+
p95_ms: float
|
|
655
|
+
p99_ms: float
|
|
656
|
+
total_count: int
|
|
657
|
+
error_count: int
|
|
658
|
+
error_rate: float
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@dataclass
|
|
662
|
+
class _EdgeAccumulator:
|
|
663
|
+
"""Internal: accumulator for client-side edge aggregation."""
|
|
664
|
+
|
|
665
|
+
count: int = 0
|
|
666
|
+
errors: int = 0
|
|
667
|
+
durations: list[float] = field(default_factory=list)
|