@obsunified/collector 2.0.1

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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/dist/auth.d.ts +2 -0
  4. package/dist/auth.js +9 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/chunk-UGWDOGGH.js +319 -0
  7. package/dist/chunk-UGWDOGGH.js.map +1 -0
  8. package/dist/chunk-XKQTVIRE.js +671 -0
  9. package/dist/chunk-XKQTVIRE.js.map +1 -0
  10. package/dist/index.d.ts +1197 -0
  11. package/dist/index.js +17707 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/ingest-auth-HFVO1zIp.d.ts +165 -0
  14. package/dist/openai-FJYOQ7WO.js +384 -0
  15. package/dist/openai-FJYOQ7WO.js.map +1 -0
  16. package/package.json +73 -0
  17. package/src/ai-parity/ai-parity.acceptance.test.ts +449 -0
  18. package/src/analyses/derive.ts +642 -0
  19. package/src/analyses/index.ts +50 -0
  20. package/src/analyses/investigations.ts +277 -0
  21. package/src/analyses/tier0.ts +568 -0
  22. package/src/auth/dashboard-auth.ts +269 -0
  23. package/src/auth/ingest-auth.ts +147 -0
  24. package/src/durable-objects/tail-hub.test.ts +32 -0
  25. package/src/durable-objects/tail-hub.ts +158 -0
  26. package/src/framework/collector.ts +467 -0
  27. package/src/framework/env.ts +90 -0
  28. package/src/framework/logger.ts +81 -0
  29. package/src/index.ts +211 -0
  30. package/src/lib/action-aggregates.ts +1031 -0
  31. package/src/lib/action-identity.ts +106 -0
  32. package/src/lib/actions-store.ts +397 -0
  33. package/src/lib/ai-pricing.ts +80 -0
  34. package/src/lib/ai-store/calls.ts +148 -0
  35. package/src/lib/ai-store/evaluations.test.ts +47 -0
  36. package/src/lib/ai-store/evaluations.ts +125 -0
  37. package/src/lib/ai-store/helpers.ts +37 -0
  38. package/src/lib/ai-store/retention.ts +17 -0
  39. package/src/lib/ai-store/sessions.ts +277 -0
  40. package/src/lib/ai-store/spans.ts +109 -0
  41. package/src/lib/ai-store/types.ts +112 -0
  42. package/src/lib/ai-store.ts +76 -0
  43. package/src/lib/alerts-store.ts +458 -0
  44. package/src/lib/analyses-runner.ts +439 -0
  45. package/src/lib/analyses-store.test.ts +243 -0
  46. package/src/lib/analyses-store.ts +454 -0
  47. package/src/lib/ask.test.ts +302 -0
  48. package/src/lib/ask.ts +390 -0
  49. package/src/lib/blob-store-s3.ts +213 -0
  50. package/src/lib/blob-store.ts +366 -0
  51. package/src/lib/eval-cases-store.test.ts +171 -0
  52. package/src/lib/eval-cases-store.ts +1033 -0
  53. package/src/lib/evidence-references.test.ts +10 -0
  54. package/src/lib/evidence-references.ts +684 -0
  55. package/src/lib/hash.ts +26 -0
  56. package/src/lib/identity-index/action-lookups.ts +805 -0
  57. package/src/lib/identity-index/constants.ts +1 -0
  58. package/src/lib/identity-index/key-lookups.ts +199 -0
  59. package/src/lib/identity-index/mappers.ts +349 -0
  60. package/src/lib/identity-index/types.ts +196 -0
  61. package/src/lib/identity-index/user-lookup.ts +137 -0
  62. package/src/lib/identity-index.test.ts +624 -0
  63. package/src/lib/identity-index.ts +85 -0
  64. package/src/lib/json.ts +39 -0
  65. package/src/lib/llm.ts +263 -0
  66. package/src/lib/logs-store.ts +188 -0
  67. package/src/lib/metrics-store.test.ts +189 -0
  68. package/src/lib/metrics-store.ts +312 -0
  69. package/src/lib/narrate-gate.test.ts +260 -0
  70. package/src/lib/narrate-gate.ts +253 -0
  71. package/src/lib/openai.test.ts +300 -0
  72. package/src/lib/openai.ts +512 -0
  73. package/src/lib/otlp.ts +212 -0
  74. package/src/lib/parse-pprof.test.ts +64 -0
  75. package/src/lib/parse-pprof.ts +184 -0
  76. package/src/lib/payload-capture.ts +22 -0
  77. package/src/lib/projects-store.ts +207 -0
  78. package/src/lib/propagation-metric.test.ts +196 -0
  79. package/src/lib/propagation-metric.ts +175 -0
  80. package/src/lib/sql-db-dialect.test.ts +22 -0
  81. package/src/lib/sql-db-postgres.ts +229 -0
  82. package/src/lib/sql-db.ts +166 -0
  83. package/src/lib/stage6.test.ts +216 -0
  84. package/src/lib/store/export.ts +65 -0
  85. package/src/lib/store/helpers.ts +367 -0
  86. package/src/lib/store/ingest.ts +68 -0
  87. package/src/lib/store/issues.ts +158 -0
  88. package/src/lib/store/overview.ts +127 -0
  89. package/src/lib/store/retention.ts +10 -0
  90. package/src/lib/store/service-map.ts +384 -0
  91. package/src/lib/store/trace-detail.bench.ts +76 -0
  92. package/src/lib/store/trace-detail.test.ts +410 -0
  93. package/src/lib/store/trace-detail.ts +427 -0
  94. package/src/lib/store/trace-queries.test.ts +29 -0
  95. package/src/lib/store/trace-queries.ts +89 -0
  96. package/src/lib/store-overview.test.ts +77 -0
  97. package/src/lib/store.ts +96 -0
  98. package/src/lib/tail-publisher.ts +64 -0
  99. package/src/lib/test-utils/mem-sql-db.test.ts +93 -0
  100. package/src/lib/test-utils/mem-sql-db.ts +86 -0
  101. package/src/lib/ua-parser.ts +61 -0
  102. package/src/lib/usage-store.ts +567 -0
  103. package/src/migrations/001_telemetry_spans.sql +28 -0
  104. package/src/migrations/002_usage_events.sql +24 -0
  105. package/src/migrations/003_usage_analytics_columns.sql +13 -0
  106. package/src/migrations/004_logs.sql +19 -0
  107. package/src/migrations/005_ai_calls.sql +25 -0
  108. package/src/migrations/006_user_profiles.sql +11 -0
  109. package/src/migrations/007_session_replay_metadata.sql +10 -0
  110. package/src/migrations/008_session_replay_storage_bytes.sql +1 -0
  111. package/src/migrations/009_projects_and_keys.sql +22 -0
  112. package/src/migrations/010_signal_project_id.sql +15 -0
  113. package/src/migrations/011_default_project_seed.sql +3 -0
  114. package/src/migrations/012_alert_rules.sql +17 -0
  115. package/src/migrations/013_alert_evaluations.sql +13 -0
  116. package/src/migrations/014_alert_state.sql +9 -0
  117. package/src/migrations/015_metrics.sql +58 -0
  118. package/src/migrations/016_metrics_exp_histogram_summary.sql +5 -0
  119. package/src/migrations/017_span_dropped_counts.sql +11 -0
  120. package/src/migrations/018_log_flags.sql +8 -0
  121. package/src/migrations/019_ai_span_payloads.sql +30 -0
  122. package/src/migrations/020_ai_span_evaluations.sql +33 -0
  123. package/src/migrations/021_ai_span_session.sql +13 -0
  124. package/src/migrations/022_span_log_session_id.sql +13 -0
  125. package/src/migrations/023_service_map_indexes.sql +32 -0
  126. package/src/migrations/024_analysis_results.sql +49 -0
  127. package/src/migrations/025_stage6_autopinning_alerts.sql +38 -0
  128. package/src/migrations/026_obs_dashboard_project_seed.sql +18 -0
  129. package/src/migrations/027_identity_propagation.sql +50 -0
  130. package/src/migrations/028_profile_blobs.sql +57 -0
  131. package/src/migrations/029_telemetry_sdk_name.sql +20 -0
  132. package/src/migrations/030_analysis_claim.sql +18 -0
  133. package/src/migrations/031_agent_action_graph.sql +154 -0
  134. package/src/migrations/032_payload_capture_flags.sql +8 -0
  135. package/src/migrations/033_eval_cases.sql +52 -0
  136. package/src/migrations/034_eval_case_results.sql +23 -0
  137. package/src/migrations/035_metric_exemplars.sql +33 -0
  138. package/src/migrations/036_trace_instrumentation_gaps.sql +19 -0
  139. package/src/migrations/037_eval_runs.sql +37 -0
  140. package/src/migrations/038_tool_call_evidence.sql +11 -0
  141. package/src/migrations/039_drop_trace_instrumentation_gaps.sql +11 -0
  142. package/src/migrations/040_evidence_retrieval_refs.sql +50 -0
  143. package/src/migrations-postgres/001_telemetry_spans.sql +33 -0
  144. package/src/migrations-postgres/002_usage_events.sql +24 -0
  145. package/src/migrations-postgres/003_usage_analytics_columns.sql +13 -0
  146. package/src/migrations-postgres/004_logs.sql +19 -0
  147. package/src/migrations-postgres/005_ai_calls.sql +25 -0
  148. package/src/migrations-postgres/006_user_profiles.sql +11 -0
  149. package/src/migrations-postgres/007_session_replay_metadata.sql +10 -0
  150. package/src/migrations-postgres/008_session_replay_storage_bytes.sql +2 -0
  151. package/src/migrations-postgres/009_projects_and_keys.sql +25 -0
  152. package/src/migrations-postgres/010_signal_project_id.sql +18 -0
  153. package/src/migrations-postgres/011_default_project_seed.sql +5 -0
  154. package/src/migrations-postgres/012_alert_rules.sql +17 -0
  155. package/src/migrations-postgres/013_alert_evaluations.sql +13 -0
  156. package/src/migrations-postgres/014_alert_state.sql +9 -0
  157. package/src/migrations-postgres/015_metrics.sql +47 -0
  158. package/src/migrations-postgres/016_metrics_exp_histogram_summary.sql +1 -0
  159. package/src/migrations-postgres/017_span_dropped_counts.sql +4 -0
  160. package/src/migrations-postgres/018_log_flags.sql +2 -0
  161. package/src/migrations-postgres/019_ai_span_payloads.sql +18 -0
  162. package/src/migrations-postgres/020_ai_span_evaluations.sql +21 -0
  163. package/src/migrations-postgres/021_ai_span_session.sql +7 -0
  164. package/src/migrations-postgres/022_span_log_session_id.sql +7 -0
  165. package/src/migrations-postgres/023_service_map_indexes.sql +9 -0
  166. package/src/migrations-postgres/024_analysis_results.sql +41 -0
  167. package/src/migrations-postgres/025_stage6_autopinning_alerts.sql +17 -0
  168. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +14 -0
  169. package/src/migrations-postgres/027_identity_propagation.sql +27 -0
  170. package/src/migrations-postgres/028_profile_blobs.sql +48 -0
  171. package/src/migrations-postgres/029_telemetry_sdk_name.sql +5 -0
  172. package/src/migrations-postgres/030_analysis_claim.sql +5 -0
  173. package/src/migrations-postgres/031_agent_action_graph.sql +190 -0
  174. package/src/migrations-postgres/032_payload_capture_flags.sql +8 -0
  175. package/src/migrations-postgres/033_eval_cases.sql +55 -0
  176. package/src/migrations-postgres/034_eval_case_results.sql +26 -0
  177. package/src/migrations-postgres/035_metric_exemplars.sql +31 -0
  178. package/src/migrations-postgres/036_trace_instrumentation_gaps.sql +19 -0
  179. package/src/migrations-postgres/037_eval_runs.sql +40 -0
  180. package/src/migrations-postgres/038_tool_call_evidence.sql +11 -0
  181. package/src/migrations-postgres/039_drop_trace_instrumentation_gaps.sql +11 -0
  182. package/src/migrations-postgres/040_evidence_retrieval_refs.sql +47 -0
  183. package/src/migrations-postgres/README.md +40 -0
  184. package/src/otlp/acceptance.test.ts +373 -0
  185. package/src/otlp/decode/body.ts +127 -0
  186. package/src/otlp/decode/logs.ts +96 -0
  187. package/src/otlp/decode/metrics.ts +402 -0
  188. package/src/otlp/decode/traces.ts +92 -0
  189. package/src/otlp/decode/values.ts +150 -0
  190. package/src/otlp/decode.test.ts +800 -0
  191. package/src/otlp/decode.ts +11 -0
  192. package/src/otlp/gen/opentelemetry/proto/collector/logs/v1/logs_service_pb.ts +132 -0
  193. package/src/otlp/gen/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.ts +158 -0
  194. package/src/otlp/gen/opentelemetry/proto/collector/trace/v1/trace_service_pb.ts +158 -0
  195. package/src/otlp/gen/opentelemetry/proto/common/v1/common_pb.ts +289 -0
  196. package/src/otlp/gen/opentelemetry/proto/logs/v1/logs_pb.ts +397 -0
  197. package/src/otlp/gen/opentelemetry/proto/metrics/v1/metrics_pb.ts +1329 -0
  198. package/src/otlp/gen/opentelemetry/proto/resource/v1/resource_pb.ts +86 -0
  199. package/src/otlp/gen/opentelemetry/proto/trace/v1/trace_pb.ts +710 -0
  200. package/src/otlp/response.test.ts +158 -0
  201. package/src/otlp/response.ts +129 -0
  202. package/src/plugins/_context.ts +15 -0
  203. package/src/plugins/action-graph-processor/enrichers.ts +45 -0
  204. package/src/plugins/action-graph-processor/redaction.ts +84 -0
  205. package/src/plugins/action-graph-processor.test.ts +1023 -0
  206. package/src/plugins/action-graph-processor.ts +1172 -0
  207. package/src/plugins/action-routes.test.ts +716 -0
  208. package/src/plugins/action-routes.ts +343 -0
  209. package/src/plugins/ai-receiver.ts +254 -0
  210. package/src/plugins/ai-span-payloads-processor.test.ts +90 -0
  211. package/src/plugins/ai-span-payloads-processor.ts +180 -0
  212. package/src/plugins/alerts-evaluator.test.ts +91 -0
  213. package/src/plugins/alerts-evaluator.ts +328 -0
  214. package/src/plugins/alerts-routes.ts +101 -0
  215. package/src/plugins/analyses-routes.ts +138 -0
  216. package/src/plugins/ask-routes.ts +182 -0
  217. package/src/plugins/bot-filter.ts +19 -0
  218. package/src/plugins/connected-routes/manifest.ts +531 -0
  219. package/src/plugins/connected-routes.test.ts +1791 -0
  220. package/src/plugins/connected-routes.ts +1103 -0
  221. package/src/plugins/dashboard-routes.ts +41 -0
  222. package/src/plugins/default-span-enrichment.ts +92 -0
  223. package/src/plugins/eval-cases-routes.test.ts +391 -0
  224. package/src/plugins/eval-cases-routes.ts +529 -0
  225. package/src/plugins/evidence-retrieval-routes.benchmark.test.ts +245 -0
  226. package/src/plugins/evidence-retrieval-routes.test.ts +932 -0
  227. package/src/plugins/evidence-retrieval-routes.ts +2179 -0
  228. package/src/plugins/gen-ai-normalizer.test.ts +680 -0
  229. package/src/plugins/gen-ai-normalizer.ts +302 -0
  230. package/src/plugins/identity-receiver.ts +96 -0
  231. package/src/plugins/issue-enrichment.ts +132 -0
  232. package/src/plugins/issue-insights.ts +81 -0
  233. package/src/plugins/logs-receiver.ts +160 -0
  234. package/src/plugins/metrics-receiver.ts +91 -0
  235. package/src/plugins/onboarding-routes.ts +54 -0
  236. package/src/plugins/otlp-receiver.ts +124 -0
  237. package/src/plugins/platform-routes.ts +165 -0
  238. package/src/plugins/profile-routes.ts +468 -0
  239. package/src/plugins/projects-routes.ts +92 -0
  240. package/src/plugins/query-routes.ts +220 -0
  241. package/src/plugins/redaction-processor.ts +132 -0
  242. package/src/plugins/replay-query-routes.test.ts +38 -0
  243. package/src/plugins/replay-query-routes.ts +199 -0
  244. package/src/plugins/replay-receiver.ts +81 -0
  245. package/src/plugins/tail-routes.test.ts +47 -0
  246. package/src/plugins/tail-routes.ts +42 -0
  247. package/src/plugins/timeline-routes.ts +244 -0
  248. package/src/plugins/ua-enrichment.ts +27 -0
  249. package/src/plugins/usage-normalization.ts +46 -0
  250. package/src/plugins/usage-privacy.ts +55 -0
  251. package/src/plugins/usage-query-routes.ts +102 -0
  252. package/src/plugins/usage-receiver.ts +129 -0
  253. package/src/plugins/users-query-routes.ts +78 -0
  254. package/src/plugins/utm-enrichment.ts +36 -0
@@ -0,0 +1,127 @@
1
+ import type {
2
+ TelemetryOverviewOptions,
3
+ TelemetryOverviewResponse,
4
+ } from "@obsunified/types";
5
+ import type { SqlDb } from "../sql-db";
6
+ import {
7
+ average,
8
+ cutoffIso,
9
+ normalizeService,
10
+ type ParsedSpan,
11
+ percentile,
12
+ } from "./helpers";
13
+ import { fetchSpansForTraceIds, selectTraceCandidates } from "./trace-queries";
14
+
15
+ export async function getTelemetryOverview(
16
+ db: SqlDb,
17
+ options: TelemetryOverviewOptions,
18
+ ): Promise<TelemetryOverviewResponse> {
19
+ if (!options.projectId)
20
+ throw new Error("TelemetryStore.getOverview: projectId is required");
21
+ const cutoff = cutoffIso(options.hours);
22
+ const traceLimit = options.limit ?? 30;
23
+ const candidates = await selectTraceCandidates(db, {
24
+ projectId: options.projectId,
25
+ cutoff,
26
+ service: options.service,
27
+ search: options.search,
28
+ status: options.status,
29
+ limit: traceLimit,
30
+ });
31
+ const candidateOrder = new Map(
32
+ candidates.map((candidate, index) => [candidate.trace_id, index]),
33
+ );
34
+ const grouped = new Map<string, ParsedSpan[]>();
35
+ for (const parsed of await fetchSpansForTraceIds(
36
+ db,
37
+ options.projectId,
38
+ candidates.map((candidate) => candidate.trace_id),
39
+ )) {
40
+ const traceSpans = grouped.get(parsed.traceId) ?? [];
41
+ traceSpans.push(parsed);
42
+ grouped.set(parsed.traceId, traceSpans);
43
+ }
44
+
45
+ const traces = Array.from(grouped.values())
46
+ .map((traceSpans) => {
47
+ const sorted = traceSpans.sort((left, right) =>
48
+ left.startTime.localeCompare(right.startTime),
49
+ );
50
+ const root = sorted.find((span) => !span.parentSpanId) ?? sorted[0];
51
+ return {
52
+ traceId: root.traceId,
53
+ serviceName: normalizeService(root.serviceName),
54
+ spanName: root.spanName,
55
+ statusCode: traceSpans.some((span) => span.statusCode === 2)
56
+ ? 2
57
+ : root.statusCode,
58
+ statusMessage:
59
+ traceSpans.find((span) => span.statusCode === 2)?.statusMessage ??
60
+ root.statusMessage,
61
+ startTime: root.startTime,
62
+ endTime: root.endTime,
63
+ durationMs: root.durationMs,
64
+ receivedAt: root.receivedAt,
65
+ spanCount: traceSpans.length,
66
+ errorSpanCount: traceSpans.filter((span) => span.statusCode === 2)
67
+ .length,
68
+ };
69
+ })
70
+ .sort((left, right) => right.receivedAt.localeCompare(left.receivedAt));
71
+ traces.sort(
72
+ (left, right) =>
73
+ (candidateOrder.get(left.traceId) ?? Number.MAX_SAFE_INTEGER) -
74
+ (candidateOrder.get(right.traceId) ?? Number.MAX_SAFE_INTEGER),
75
+ );
76
+
77
+ const durations = traces.map((trace) => trace.durationMs);
78
+ const errorTraces = traces.filter((trace) => trace.errorSpanCount > 0);
79
+ const serviceMap = new Map<
80
+ string,
81
+ { traceCount: number; errorTraceCount: number; durations: number[] }
82
+ >();
83
+ for (const trace of traces) {
84
+ const entry = serviceMap.get(trace.serviceName) ?? {
85
+ traceCount: 0,
86
+ errorTraceCount: 0,
87
+ durations: [],
88
+ };
89
+ entry.traceCount += 1;
90
+ entry.errorTraceCount += trace.errorSpanCount > 0 ? 1 : 0;
91
+ entry.durations.push(trace.durationMs);
92
+ serviceMap.set(trace.serviceName, entry);
93
+ }
94
+
95
+ return {
96
+ summary: {
97
+ totalTraces: traces.length,
98
+ errorTraces: errorTraces.length,
99
+ successTraces: traces.length - errorTraces.length,
100
+ errorRate: traces.length > 0 ? errorTraces.length / traces.length : 0,
101
+ averageDurationMs: Math.round(average(durations)),
102
+ p95DurationMs: Math.round(percentile(durations, 0.95)),
103
+ },
104
+ services: Array.from(serviceMap.entries())
105
+ .map(([serviceName, entry]) => ({
106
+ serviceName,
107
+ traceCount: entry.traceCount,
108
+ errorTraceCount: entry.errorTraceCount,
109
+ errorRate:
110
+ entry.traceCount > 0 ? entry.errorTraceCount / entry.traceCount : 0,
111
+ averageDurationMs: Math.round(average(entry.durations)),
112
+ maxDurationMs: Math.max(...entry.durations, 0),
113
+ }))
114
+ .sort(
115
+ (left, right) =>
116
+ right.traceCount - left.traceCount ||
117
+ left.serviceName.localeCompare(right.serviceName),
118
+ ),
119
+ traces: traces.slice(0, options.limit ?? 30),
120
+ windowHours: options.hours,
121
+ filters: {
122
+ service: options.service ?? "all",
123
+ status: options.status ?? "all",
124
+ },
125
+ timestamp: new Date().toISOString(),
126
+ };
127
+ }
@@ -0,0 +1,10 @@
1
+ import type { SqlDb } from "../sql-db";
2
+
3
+ export async function purgeExpiredTelemetry(db: SqlDb): Promise<number> {
4
+ const now = new Date().toISOString();
5
+ const result = await db
6
+ .prepare("DELETE FROM telemetry_spans WHERE expires_at <= ?")
7
+ .bind(now)
8
+ .run();
9
+ return result.meta?.changes ?? 0;
10
+ }
@@ -0,0 +1,384 @@
1
+ import { dialectFor, type SqlDb } from "../sql-db";
2
+ import { cutoffIso, percentile } from "./helpers";
3
+
4
+ export interface ServiceMapOptions {
5
+ projectId: string;
6
+ hours: number;
7
+ source?: "all" | "sdk" | "ebpf";
8
+ }
9
+
10
+ export interface ServiceOperationsOptions {
11
+ projectId: string;
12
+ service: string;
13
+ hours: number;
14
+ }
15
+
16
+ export async function getTelemetryServiceMap(
17
+ db: SqlDb,
18
+ options: ServiceMapOptions,
19
+ ) {
20
+ if (!options.projectId)
21
+ throw new Error("TelemetryStore.getServiceMap: projectId is required");
22
+ const cutoff = cutoffIso(options.hours);
23
+ const dialect = dialectFor(db);
24
+ const source = options.source ?? "all";
25
+
26
+ // RFC 0009 — eBPF-derived agents identify themselves via
27
+ // resource_attribute `telemetry.sdk.name`. The set is small and
28
+ // stable enough to inline; expand as new agents land.
29
+ const EBPF_SDK_NAMES = new Set(["beyla", "otel-ebpf-profiler"]);
30
+ const sourceClause =
31
+ source === "ebpf"
32
+ ? ` AND telemetry_sdk_name IN (${Array.from(EBPF_SDK_NAMES)
33
+ .map(() => "?")
34
+ .join(",")})`
35
+ : source === "sdk"
36
+ ? ` AND (telemetry_sdk_name IS NULL OR telemetry_sdk_name NOT IN (${Array.from(
37
+ EBPF_SDK_NAMES,
38
+ )
39
+ .map(() => "?")
40
+ .join(",")}))`
41
+ : "";
42
+ const sourceBinds: unknown[] =
43
+ source === "all" ? [] : Array.from(EBPF_SDK_NAMES);
44
+
45
+ const nodesResult = await db
46
+ .prepare(
47
+ `SELECT
48
+ service_name,
49
+ COUNT(*) AS span_count,
50
+ SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_count,
51
+ COUNT(DISTINCT trace_id) AS trace_count
52
+ FROM telemetry_spans
53
+ WHERE project_id = ? AND received_at >= ? AND service_name IS NOT NULL${sourceClause}
54
+ GROUP BY service_name`,
55
+ )
56
+ .bind(options.projectId, cutoff, ...sourceBinds)
57
+ .all<{
58
+ service_name: string;
59
+ span_count: number;
60
+ error_count: number;
61
+ trace_count: number;
62
+ }>();
63
+
64
+ // Edges come from two relationships:
65
+ // 1. Synchronous: child span's parent_span_id points at the parent's
66
+ // span_id within the same trace, and they're in different services.
67
+ // 2. Asynchronous: child span carries a `link` (in links_json) back
68
+ // to a producer span in another service — this is how OpenTelemetry
69
+ // represents Kafka / RabbitMQ / SQS / Pub/Sub dispatch, because
70
+ // consumers can process messages long after producers close their
71
+ // span and a single consume can correspond to many produces.
72
+ // We UNION ALL both — duplicates are accumulated as separate calls,
73
+ // which matches the synchronous semantics (each child = one call).
74
+ // RFC 0009 — applying the source filter to *child* spans for sync
75
+ // edges and to *consumer* spans for async edges. The producer
76
+ // side is left unfiltered (an SDK-instrumented service that
77
+ // publishes to a Kafka topic still belongs in the eBPF view if
78
+ // the consumer is Beyla-instrumented; the edge represents the
79
+ // kernel-observed call, not the producer's instrumentation).
80
+ const childSourceClause =
81
+ source === "ebpf"
82
+ ? ` AND c.telemetry_sdk_name IN (${Array.from(EBPF_SDK_NAMES)
83
+ .map(() => "?")
84
+ .join(",")})`
85
+ : source === "sdk"
86
+ ? ` AND (c.telemetry_sdk_name IS NULL OR c.telemetry_sdk_name NOT IN (${Array.from(
87
+ EBPF_SDK_NAMES,
88
+ )
89
+ .map(() => "?")
90
+ .join(",")}))`
91
+ : "";
92
+ const consumerSourceClause =
93
+ source === "ebpf"
94
+ ? ` AND telemetry_sdk_name IN (${Array.from(EBPF_SDK_NAMES)
95
+ .map(() => "?")
96
+ .join(",")})`
97
+ : source === "sdk"
98
+ ? ` AND (telemetry_sdk_name IS NULL OR telemetry_sdk_name NOT IN (${Array.from(
99
+ EBPF_SDK_NAMES,
100
+ )
101
+ .map(() => "?")
102
+ .join(",")}))`
103
+ : "";
104
+ const linkEdgesSql =
105
+ dialect.name === "postgres"
106
+ ? `link_edges AS (
107
+ SELECT
108
+ producer.service_name AS source,
109
+ consumer.service_name AS target,
110
+ consumer.status_code AS status_code,
111
+ consumer.duration_ms AS duration_ms,
112
+ consumer.received_at AS received_at,
113
+ CASE WHEN consumer.telemetry_sdk_name IN ('beyla', 'otel-ebpf-profiler') THEN 1 ELSE 0 END AS is_ebpf
114
+ FROM (
115
+ SELECT trace_id, span_id, project_id, service_name,
116
+ status_code, duration_ms, received_at, links_json, telemetry_sdk_name
117
+ FROM telemetry_spans
118
+ WHERE project_id = ?
119
+ AND received_at >= ?
120
+ AND links_json IS NOT NULL
121
+ AND links_json != '[]'${consumerSourceClause}
122
+ ) consumer
123
+ CROSS JOIN LATERAL jsonb_array_elements(consumer.links_json::jsonb) link(value)
124
+ JOIN telemetry_spans producer
125
+ ON producer.trace_id = link.value ->> 'traceId'
126
+ AND producer.span_id = link.value ->> 'spanId'
127
+ AND producer.project_id = consumer.project_id
128
+ WHERE producer.received_at >= ?
129
+ AND consumer.service_name IS NOT NULL
130
+ AND producer.service_name IS NOT NULL
131
+ AND producer.service_name != consumer.service_name
132
+ )`
133
+ : `link_edges AS (
134
+ SELECT
135
+ producer.service_name AS source,
136
+ consumer.service_name AS target,
137
+ consumer.status_code AS status_code,
138
+ consumer.duration_ms AS duration_ms,
139
+ consumer.received_at AS received_at,
140
+ CASE WHEN consumer.telemetry_sdk_name IN ('beyla', 'otel-ebpf-profiler') THEN 1 ELSE 0 END AS is_ebpf
141
+ -- Pre-filter consumers in a subquery so json_each is only
142
+ -- invoked on spans that actually carry links. Without this
143
+ -- gate the cross product fans out across every span in the
144
+ -- window and the query times out on tens of thousands of
145
+ -- rows.
146
+ FROM (
147
+ SELECT trace_id, span_id, project_id, service_name,
148
+ status_code, duration_ms, received_at, links_json, telemetry_sdk_name
149
+ FROM telemetry_spans
150
+ WHERE project_id = ?
151
+ AND received_at >= ?
152
+ AND links_json IS NOT NULL
153
+ AND links_json != '[]'${consumerSourceClause}
154
+ ) consumer,
155
+ json_each(consumer.links_json) link
156
+ -- Match on (trace_id, span_id) so the lookup hits the
157
+ -- composite PRIMARY KEY index. span_id alone has no index
158
+ -- (only the composite PK), so a span_id-only join would
159
+ -- full-scan per link.
160
+ JOIN telemetry_spans producer
161
+ ON producer.trace_id = json_extract(link.value, '$.traceId')
162
+ AND producer.span_id = json_extract(link.value, '$.spanId')
163
+ AND producer.project_id = consumer.project_id
164
+ WHERE producer.received_at >= ?
165
+ AND consumer.service_name IS NOT NULL
166
+ AND producer.service_name IS NOT NULL
167
+ AND producer.service_name != consumer.service_name
168
+ )`;
169
+
170
+ const edgeRowsResult = await db
171
+ .prepare(
172
+ `WITH parent_child_edges AS (
173
+ SELECT
174
+ p.service_name AS source,
175
+ c.service_name AS target,
176
+ c.status_code AS status_code,
177
+ c.duration_ms AS duration_ms,
178
+ c.received_at AS received_at,
179
+ CASE WHEN c.telemetry_sdk_name IN ('beyla', 'otel-ebpf-profiler') THEN 1 ELSE 0 END AS is_ebpf
180
+ FROM telemetry_spans p
181
+ JOIN telemetry_spans c
182
+ ON c.parent_span_id = p.span_id
183
+ AND c.trace_id = p.trace_id
184
+ AND c.project_id = p.project_id
185
+ WHERE p.project_id = ?
186
+ AND p.received_at >= ?
187
+ AND c.received_at >= ?
188
+ AND p.service_name IS NOT NULL
189
+ AND c.service_name IS NOT NULL
190
+ AND p.service_name != c.service_name${childSourceClause}
191
+ ),
192
+ ${linkEdgesSql}
193
+ SELECT * FROM parent_child_edges
194
+ UNION ALL
195
+ SELECT * FROM link_edges
196
+ ORDER BY received_at DESC
197
+ LIMIT 50000`,
198
+ )
199
+ .bind(
200
+ options.projectId,
201
+ cutoff,
202
+ cutoff,
203
+ ...sourceBinds,
204
+ options.projectId,
205
+ cutoff,
206
+ ...sourceBinds,
207
+ cutoff,
208
+ )
209
+ .all<{
210
+ source: string;
211
+ target: string;
212
+ status_code: number;
213
+ duration_ms: number;
214
+ is_ebpf: number;
215
+ }>();
216
+
217
+ interface EdgeAcc {
218
+ source: string;
219
+ target: string;
220
+ calls: number;
221
+ errors: number;
222
+ durations: number[];
223
+ isEbpf: boolean;
224
+ }
225
+ const edgeMap = new Map<string, EdgeAcc>();
226
+ for (const row of edgeRowsResult.results ?? []) {
227
+ const key = `${row.source}|${row.target}`;
228
+ let acc = edgeMap.get(key);
229
+ if (!acc) {
230
+ acc = {
231
+ source: row.source,
232
+ target: row.target,
233
+ calls: 0,
234
+ errors: 0,
235
+ durations: [],
236
+ isEbpf: false,
237
+ };
238
+ edgeMap.set(key, acc);
239
+ }
240
+ acc.calls += 1;
241
+ if (row.status_code === 2) acc.errors += 1;
242
+ acc.durations.push(row.duration_ms ?? 0);
243
+ if (row.is_ebpf === 1) acc.isEbpf = true;
244
+ }
245
+
246
+ const windowSeconds = Math.max(1, options.hours * 3600);
247
+ const edges = Array.from(edgeMap.values()).map((e) => ({
248
+ source: e.source,
249
+ target: e.target,
250
+ calls: e.calls,
251
+ errors: e.errors,
252
+ errorRate: e.calls > 0 ? e.errors / e.calls : 0,
253
+ p50DurationMs: percentile(e.durations, 0.5),
254
+ p95DurationMs: percentile(e.durations, 0.95),
255
+ rps: e.calls / windowSeconds,
256
+ isEbpf: e.isEbpf,
257
+ }));
258
+
259
+ const nodes = (nodesResult.results ?? []).map((r) => ({
260
+ service: r.service_name,
261
+ spanCount: r.span_count,
262
+ errorCount: r.error_count,
263
+ traceCount: r.trace_count,
264
+ errorRate: r.span_count > 0 ? r.error_count / r.span_count : 0,
265
+ }));
266
+
267
+ return { nodes, edges };
268
+ }
269
+
270
+ export async function getTelemetryServiceOperations(
271
+ db: SqlDb,
272
+ options: ServiceOperationsOptions,
273
+ ) {
274
+ if (!options.projectId)
275
+ throw new Error(
276
+ "TelemetryStore.getServiceOperations: projectId is required",
277
+ );
278
+ const cutoff = cutoffIso(options.hours);
279
+
280
+ // Top operations — group by span_name, accumulate durations in JS.
281
+ const opsResult = await db
282
+ .prepare(
283
+ // One row per span; calls/errors/percentiles are aggregated per
284
+ // span_name in JS below. Do NOT reintroduce COUNT()/SUM() without
285
+ // a GROUP BY — that collapses the result to a single row in SQLite.
286
+ `SELECT
287
+ span_name,
288
+ status_code,
289
+ duration_ms
290
+ FROM telemetry_spans
291
+ WHERE project_id = ?
292
+ AND service_name = ?
293
+ AND received_at >= ?
294
+ ORDER BY received_at DESC
295
+ LIMIT 20000`,
296
+ )
297
+ .bind(options.projectId, options.service, cutoff)
298
+ .all<{
299
+ span_name: string;
300
+ status_code: number | null;
301
+ duration_ms: number;
302
+ }>();
303
+
304
+ interface OpAcc {
305
+ calls: number;
306
+ errors: number;
307
+ durations: number[];
308
+ }
309
+ const opMap = new Map<string, OpAcc>();
310
+ let totalSpans = 0;
311
+ let totalErrors = 0;
312
+ for (const row of opsResult.results ?? []) {
313
+ const isError = row.status_code === 2;
314
+ totalSpans += 1;
315
+ if (isError) totalErrors += 1;
316
+ let acc = opMap.get(row.span_name);
317
+ if (!acc) {
318
+ acc = { calls: 0, errors: 0, durations: [] };
319
+ opMap.set(row.span_name, acc);
320
+ }
321
+ acc.calls += 1;
322
+ if (isError) acc.errors += 1;
323
+ acc.durations.push(row.duration_ms ?? 0);
324
+ }
325
+ const operations = Array.from(opMap.entries())
326
+ .map(([spanName, acc]) => ({
327
+ spanName,
328
+ calls: acc.calls,
329
+ errors: acc.errors,
330
+ errorRate: acc.calls > 0 ? acc.errors / acc.calls : 0,
331
+ p50DurationMs: percentile(acc.durations, 0.5),
332
+ p95DurationMs: percentile(acc.durations, 0.95),
333
+ }))
334
+ .sort((l, r) => r.calls - l.calls)
335
+ .slice(0, 12);
336
+
337
+ // Recent error spans for triage.
338
+ const errorRows = await db
339
+ .prepare(
340
+ `SELECT trace_id, span_id, span_name, status_message, duration_ms, start_time
341
+ FROM telemetry_spans
342
+ WHERE project_id = ?
343
+ AND service_name = ?
344
+ AND received_at >= ?
345
+ AND status_code = 2
346
+ ORDER BY received_at DESC
347
+ LIMIT 10`,
348
+ )
349
+ .bind(options.projectId, options.service, cutoff)
350
+ .all<{
351
+ trace_id: string;
352
+ span_id: string;
353
+ span_name: string;
354
+ status_message: string | null;
355
+ duration_ms: number;
356
+ start_time: string;
357
+ }>();
358
+
359
+ // Distinct trace count for this service.
360
+ const traceCountRow = await db
361
+ .prepare(
362
+ `SELECT COUNT(DISTINCT trace_id) AS trace_count
363
+ FROM telemetry_spans
364
+ WHERE project_id = ? AND service_name = ? AND received_at >= ?`,
365
+ )
366
+ .bind(options.projectId, options.service, cutoff)
367
+ .first<{ trace_count: number }>();
368
+
369
+ return {
370
+ service: options.service,
371
+ spanCount: totalSpans,
372
+ traceCount: traceCountRow?.trace_count ?? 0,
373
+ errorCount: totalErrors,
374
+ operations,
375
+ recentErrors: (errorRows.results ?? []).map((r) => ({
376
+ traceId: r.trace_id,
377
+ spanId: r.span_id,
378
+ spanName: r.span_name,
379
+ statusMessage: r.status_message,
380
+ durationMs: r.duration_ms,
381
+ startTime: r.start_time,
382
+ })),
383
+ };
384
+ }
@@ -0,0 +1,76 @@
1
+ import type { TelemetrySpanDetail } from "@obsunified/types";
2
+ import { bench, describe } from "vitest";
3
+ import { buildTraceInstrumentationGaps } from "./trace-detail";
4
+
5
+ /**
6
+ * Q1 benchmark — read-time gap computation cost vs trace size.
7
+ *
8
+ * Since PR #21, trace instrumentation gaps are computed lazily at read time
9
+ * from a trace's spans (`buildTraceInstrumentationGaps`) instead of being
10
+ * materialized on ingest. This benchmark measures that pure-compute cost as a
11
+ * function of spans-per-trace, to answer: is read-time computation fast enough
12
+ * to serve on a trace-detail view without a cache, even for very large traces?
13
+ *
14
+ * This measures the trace-detail-page hot path, where spans are already loaded
15
+ * in memory and passed straight into the gap computation. The standalone
16
+ * `/internal/.../gaps` endpoint additionally pays a project-scoped span SELECT
17
+ * + JSON parse; that DB-inclusive end-to-end cost is captured separately by the
18
+ * read-path child-span instrumentation (the other half of "Q1 = Both"), not
19
+ * here.
20
+ *
21
+ * Run: `pnpm --filter @obsunified/collector bench` (excluded from `pnpm test`).
22
+ */
23
+
24
+ const SIZES = [10, 100, 500, 1_000, 5_000, 10_000];
25
+
26
+ // Deterministic synthetic trace generator (no Math.random — reproducible runs).
27
+ // Builds a tree with branching factor 2 and durations that taper with depth so
28
+ // the gap heuristic does real work (self-time/ratio math + a child reduce for
29
+ // every span). Returns fully-formed TelemetrySpanDetail objects so we exercise
30
+ // the exact code path the read handler uses.
31
+ function makeTrace(spanCount: number): {
32
+ spans: TelemetrySpanDetail[];
33
+ rootDurationMs: number;
34
+ } {
35
+ const BRANCH = 2;
36
+ const baseStart = Date.parse("2026-06-01T00:00:00.000Z");
37
+ const spans: TelemetrySpanDetail[] = [];
38
+
39
+ for (let i = 0; i < spanCount; i++) {
40
+ const parentIndex = i === 0 ? null : Math.floor((i - 1) / BRANCH);
41
+ const depth = i === 0 ? 0 : Math.floor(Math.log2(i + 1));
42
+ // Duration tapers with depth; kept > 0 so ratio math runs.
43
+ const durationMs = Math.max(1, 2_000 - depth * 150);
44
+ const startMs = baseStart + i; // monotonic, distinct
45
+ spans.push({
46
+ traceId: "bench-trace",
47
+ spanId: `s${i}`,
48
+ parentSpanId: parentIndex === null ? null : `s${parentIndex}`,
49
+ serviceName: `svc-${i % 8}`,
50
+ scopeName: null,
51
+ scopeVersion: null,
52
+ spanName: `op.${i % 32}`,
53
+ spanKind: 2,
54
+ statusCode: 1,
55
+ statusMessage: null,
56
+ startTime: new Date(startMs).toISOString(),
57
+ endTime: new Date(startMs + durationMs).toISOString(),
58
+ durationMs,
59
+ attributes: { "http.method": "GET", "http.route": `/r/${i % 16}` },
60
+ resourceAttributes: { "service.name": `svc-${i % 8}` },
61
+ events: [],
62
+ links: [],
63
+ });
64
+ }
65
+
66
+ return { spans, rootDurationMs: spans[0]?.durationMs ?? 0 };
67
+ }
68
+
69
+ describe("buildTraceInstrumentationGaps (read-time gap compute)", () => {
70
+ for (const size of SIZES) {
71
+ const { spans, rootDurationMs } = makeTrace(size);
72
+ bench(`${size} spans`, () => {
73
+ buildTraceInstrumentationGaps("bench-trace", spans, rootDurationMs);
74
+ });
75
+ }
76
+ });