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.
Files changed (175) hide show
  1. nthlayer_workers/__init__.py +5 -0
  2. nthlayer_workers/cli.py +234 -0
  3. nthlayer_workers/correlate/__init__.py +1 -0
  4. nthlayer_workers/correlate/cli.py +847 -0
  5. nthlayer_workers/correlate/config.py +111 -0
  6. nthlayer_workers/correlate/correlation/__init__.py +1 -0
  7. nthlayer_workers/correlate/correlation/changes.py +87 -0
  8. nthlayer_workers/correlate/correlation/dedup.py +62 -0
  9. nthlayer_workers/correlate/correlation/engine.py +244 -0
  10. nthlayer_workers/correlate/correlation/temporal.py +79 -0
  11. nthlayer_workers/correlate/correlation/topology.py +104 -0
  12. nthlayer_workers/correlate/ingestion/__init__.py +1 -0
  13. nthlayer_workers/correlate/ingestion/protocol.py +10 -0
  14. nthlayer_workers/correlate/ingestion/severity.py +18 -0
  15. nthlayer_workers/correlate/ingestion/webhook.py +197 -0
  16. nthlayer_workers/correlate/notifications.py +85 -0
  17. nthlayer_workers/correlate/prometheus.py +234 -0
  18. nthlayer_workers/correlate/reasoning.py +375 -0
  19. nthlayer_workers/correlate/session.py +189 -0
  20. nthlayer_workers/correlate/snapshot/__init__.py +1 -0
  21. nthlayer_workers/correlate/snapshot/generator.py +170 -0
  22. nthlayer_workers/correlate/snapshot/model.py +177 -0
  23. nthlayer_workers/correlate/snapshot/token.py +14 -0
  24. nthlayer_workers/correlate/state.py +88 -0
  25. nthlayer_workers/correlate/store/__init__.py +5 -0
  26. nthlayer_workers/correlate/store/protocol.py +48 -0
  27. nthlayer_workers/correlate/store/sqlite.py +443 -0
  28. nthlayer_workers/correlate/summary.py +180 -0
  29. nthlayer_workers/correlate/traces/__init__.py +1 -0
  30. nthlayer_workers/correlate/traces/protocol.py +120 -0
  31. nthlayer_workers/correlate/traces/tempo.py +667 -0
  32. nthlayer_workers/correlate/traces/topology.py +39 -0
  33. nthlayer_workers/correlate/types.py +77 -0
  34. nthlayer_workers/correlate/worker.py +630 -0
  35. nthlayer_workers/learn/__init__.py +5 -0
  36. nthlayer_workers/learn/__main__.py +5 -0
  37. nthlayer_workers/learn/cli.py +164 -0
  38. nthlayer_workers/learn/retrospective.py +381 -0
  39. nthlayer_workers/learn/trends.py +102 -0
  40. nthlayer_workers/learn/worker.py +366 -0
  41. nthlayer_workers/measure/__init__.py +3 -0
  42. nthlayer_workers/measure/__main__.py +5 -0
  43. nthlayer_workers/measure/_parsing.py +15 -0
  44. nthlayer_workers/measure/adapters/__init__.py +0 -0
  45. nthlayer_workers/measure/adapters/_util.py +24 -0
  46. nthlayer_workers/measure/adapters/devin.py +119 -0
  47. nthlayer_workers/measure/adapters/gastown.py +88 -0
  48. nthlayer_workers/measure/adapters/prometheus.py +277 -0
  49. nthlayer_workers/measure/adapters/protocol.py +20 -0
  50. nthlayer_workers/measure/adapters/webhook.py +161 -0
  51. nthlayer_workers/measure/api/__init__.py +0 -0
  52. nthlayer_workers/measure/api/normalise.py +50 -0
  53. nthlayer_workers/measure/api/queue.py +243 -0
  54. nthlayer_workers/measure/api/response.py +51 -0
  55. nthlayer_workers/measure/api/server.py +504 -0
  56. nthlayer_workers/measure/calibration/__init__.py +0 -0
  57. nthlayer_workers/measure/calibration/loop.py +62 -0
  58. nthlayer_workers/measure/calibration/slos.py +212 -0
  59. nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
  60. nthlayer_workers/measure/cli.py +753 -0
  61. nthlayer_workers/measure/config.py +191 -0
  62. nthlayer_workers/measure/detection/__init__.py +6 -0
  63. nthlayer_workers/measure/detection/detector.py +82 -0
  64. nthlayer_workers/measure/detection/protocol.py +29 -0
  65. nthlayer_workers/measure/governance/__init__.py +0 -0
  66. nthlayer_workers/measure/governance/engine.py +163 -0
  67. nthlayer_workers/measure/manifest.py +77 -0
  68. nthlayer_workers/measure/notifications.py +53 -0
  69. nthlayer_workers/measure/pipeline/__init__.py +0 -0
  70. nthlayer_workers/measure/pipeline/evaluator.py +155 -0
  71. nthlayer_workers/measure/pipeline/router.py +160 -0
  72. nthlayer_workers/measure/store/__init__.py +0 -0
  73. nthlayer_workers/measure/store/protocol.py +38 -0
  74. nthlayer_workers/measure/store/sqlite.py +276 -0
  75. nthlayer_workers/measure/telemetry.py +116 -0
  76. nthlayer_workers/measure/tiering/__init__.py +0 -0
  77. nthlayer_workers/measure/tiering/classifier.py +58 -0
  78. nthlayer_workers/measure/tiering/promotion.py +118 -0
  79. nthlayer_workers/measure/trends/__init__.py +0 -0
  80. nthlayer_workers/measure/trends/tracker.py +72 -0
  81. nthlayer_workers/measure/types.py +75 -0
  82. nthlayer_workers/measure/worker.py +439 -0
  83. nthlayer_workers/observe/__init__.py +25 -0
  84. nthlayer_workers/observe/__main__.py +5 -0
  85. nthlayer_workers/observe/api/__init__.py +1 -0
  86. nthlayer_workers/observe/assessment.py +95 -0
  87. nthlayer_workers/observe/cli.py +737 -0
  88. nthlayer_workers/observe/config.py +11 -0
  89. nthlayer_workers/observe/db/__init__.py +1 -0
  90. nthlayer_workers/observe/decision_records.py +220 -0
  91. nthlayer_workers/observe/dependencies/__init__.py +18 -0
  92. nthlayer_workers/observe/dependencies/discovery.py +294 -0
  93. nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
  94. nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
  95. nthlayer_workers/observe/dependencies/providers/base.py +76 -0
  96. nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
  97. nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
  98. nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
  99. nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
  100. nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
  101. nthlayer_workers/observe/deployments/__init__.py +1 -0
  102. nthlayer_workers/observe/discovery/__init__.py +14 -0
  103. nthlayer_workers/observe/discovery/classifier.py +66 -0
  104. nthlayer_workers/observe/discovery/client.py +189 -0
  105. nthlayer_workers/observe/discovery/models.py +53 -0
  106. nthlayer_workers/observe/drift/__init__.py +26 -0
  107. nthlayer_workers/observe/drift/analyzer.py +383 -0
  108. nthlayer_workers/observe/drift/models.py +174 -0
  109. nthlayer_workers/observe/drift/patterns.py +88 -0
  110. nthlayer_workers/observe/explanation.py +118 -0
  111. nthlayer_workers/observe/gate/__init__.py +39 -0
  112. nthlayer_workers/observe/gate/conditions.py +92 -0
  113. nthlayer_workers/observe/gate/correlator.py +154 -0
  114. nthlayer_workers/observe/gate/evaluator.py +192 -0
  115. nthlayer_workers/observe/gate/policies.py +226 -0
  116. nthlayer_workers/observe/gate_adapter.py +40 -0
  117. nthlayer_workers/observe/incident.py +36 -0
  118. nthlayer_workers/observe/portfolio/__init__.py +17 -0
  119. nthlayer_workers/observe/portfolio/aggregator.py +168 -0
  120. nthlayer_workers/observe/portfolio/scorer.py +13 -0
  121. nthlayer_workers/observe/slo/__init__.py +19 -0
  122. nthlayer_workers/observe/slo/collector.py +235 -0
  123. nthlayer_workers/observe/slo/spec_loader.py +40 -0
  124. nthlayer_workers/observe/sqlite_store.py +152 -0
  125. nthlayer_workers/observe/store.py +92 -0
  126. nthlayer_workers/observe/verification/__init__.py +22 -0
  127. nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
  128. nthlayer_workers/observe/verification/extractor.py +127 -0
  129. nthlayer_workers/observe/verification/models.py +101 -0
  130. nthlayer_workers/observe/verification/verifier.py +111 -0
  131. nthlayer_workers/observe/worker.py +332 -0
  132. nthlayer_workers/respond/__init__.py +2 -0
  133. nthlayer_workers/respond/__main__.py +4 -0
  134. nthlayer_workers/respond/agents/__init__.py +0 -0
  135. nthlayer_workers/respond/agents/base.py +556 -0
  136. nthlayer_workers/respond/agents/communication.py +115 -0
  137. nthlayer_workers/respond/agents/investigation.py +124 -0
  138. nthlayer_workers/respond/agents/remediation.py +219 -0
  139. nthlayer_workers/respond/agents/triage.py +132 -0
  140. nthlayer_workers/respond/cli.py +772 -0
  141. nthlayer_workers/respond/config.py +135 -0
  142. nthlayer_workers/respond/context_store.py +256 -0
  143. nthlayer_workers/respond/coordinator.py +487 -0
  144. nthlayer_workers/respond/metrics.py +104 -0
  145. nthlayer_workers/respond/notification_backends/__init__.py +1 -0
  146. nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
  147. nthlayer_workers/respond/notification_backends/protocol.py +59 -0
  148. nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
  149. nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
  150. nthlayer_workers/respond/notifications.py +247 -0
  151. nthlayer_workers/respond/oncall/__init__.py +1 -0
  152. nthlayer_workers/respond/oncall/escalation.py +103 -0
  153. nthlayer_workers/respond/oncall/runner.py +193 -0
  154. nthlayer_workers/respond/oncall/schedule.py +243 -0
  155. nthlayer_workers/respond/safe_actions/__init__.py +0 -0
  156. nthlayer_workers/respond/safe_actions/actions.py +139 -0
  157. nthlayer_workers/respond/safe_actions/registry.py +171 -0
  158. nthlayer_workers/respond/safe_actions/webhook.py +194 -0
  159. nthlayer_workers/respond/server.py +357 -0
  160. nthlayer_workers/respond/sre/__init__.py +1 -0
  161. nthlayer_workers/respond/sre/brief.py +175 -0
  162. nthlayer_workers/respond/sre/delegation.py +101 -0
  163. nthlayer_workers/respond/sre/post_incident.py +146 -0
  164. nthlayer_workers/respond/sre/shift_report.py +129 -0
  165. nthlayer_workers/respond/sre/suppression.py +91 -0
  166. nthlayer_workers/respond/types.py +109 -0
  167. nthlayer_workers/respond/verdict_submission.py +56 -0
  168. nthlayer_workers/respond/worker.py +533 -0
  169. nthlayer_workers/respond/worker_helpers.py +140 -0
  170. nthlayer_workers/runner.py +198 -0
  171. nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
  172. nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
  173. nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
  174. nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
  175. 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)