@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,427 @@
1
+ import type {
2
+ JsonValue,
3
+ SpanDetailRow,
4
+ TelemetryCodeReference,
5
+ TelemetryInstrumentationGap,
6
+ TelemetryInstrumentationGapsResponse,
7
+ TelemetrySpanDetail,
8
+ TelemetryTraceDetailResponse,
9
+ } from "@obsunified/types";
10
+ import {
11
+ DEFAULT_INSTRUMENTATION_GAP_THRESHOLDS,
12
+ isInstrumentationGapCandidate,
13
+ } from "@obsunified/types";
14
+ import { parseJsonArray, parseJsonRecord } from "../json";
15
+ import type { SqlDb } from "../sql-db";
16
+ import { normalizeService } from "./helpers";
17
+
18
+ export interface TelemetryInstrumentationGapCalibrationOptions {
19
+ projectId: string;
20
+ hours: number;
21
+ limit?: number;
22
+ }
23
+
24
+ export async function getTelemetryTraceDetail(
25
+ db: SqlDb,
26
+ traceId: string,
27
+ projectId: string,
28
+ ): Promise<TelemetryTraceDetailResponse | null> {
29
+ if (!projectId)
30
+ throw new Error("TelemetryStore.getTraceDetail: projectId is required");
31
+ const result = await db
32
+ .prepare(`
33
+ SELECT project_id, trace_id, span_id, parent_span_id, service_name, scope_name,
34
+ scope_version, span_name, span_kind, status_code, status_message,
35
+ start_time, end_time, duration_ms, attributes_json,
36
+ resource_attributes_json, events_json, links_json,
37
+ received_at, expires_at
38
+ FROM telemetry_spans
39
+ WHERE project_id = ? AND trace_id = ?
40
+ ORDER BY start_time ASC, span_id ASC
41
+ `)
42
+ .bind(projectId, traceId)
43
+ .all<SpanDetailRow>();
44
+
45
+ const rows = result.results ?? [];
46
+ if (rows.length === 0) return null;
47
+
48
+ const spans: TelemetrySpanDetail[] = rows.map((row) => ({
49
+ traceId: row.trace_id,
50
+ spanId: row.span_id,
51
+ parentSpanId: row.parent_span_id,
52
+ serviceName: normalizeService(row.service_name),
53
+ scopeName: row.scope_name,
54
+ scopeVersion: row.scope_version,
55
+ spanName: row.span_name,
56
+ spanKind: row.span_kind,
57
+ statusCode: row.status_code,
58
+ statusMessage: row.status_message,
59
+ startTime: row.start_time,
60
+ endTime: row.end_time,
61
+ durationMs: row.duration_ms,
62
+ attributes: parseJsonRecord(row.attributes_json),
63
+ resourceAttributes: parseJsonRecord(row.resource_attributes_json),
64
+ events: parseJsonArray(row.events_json),
65
+ links: parseJsonArray(row.links_json),
66
+ }));
67
+ for (const span of spans) {
68
+ const codeReference = extractCodeReference(span.attributes);
69
+ if (codeReference) span.codeReference = codeReference;
70
+ }
71
+
72
+ const root = spans.find((span) => !span.parentSpanId) ?? spans[0];
73
+ const timestamp = new Date().toISOString();
74
+ const instrumentationGaps =
75
+ (await getTelemetryTraceGaps(db, traceId, projectId, spans)) ?? undefined;
76
+
77
+ return {
78
+ trace: {
79
+ traceId,
80
+ serviceName: root.serviceName,
81
+ spanName: root.spanName,
82
+ statusCode: spans.some((span) => span.statusCode === 2)
83
+ ? 2
84
+ : root.statusCode,
85
+ statusMessage:
86
+ spans.find((span) => span.statusCode === 2)?.statusMessage ??
87
+ root.statusMessage,
88
+ startTime: root.startTime,
89
+ endTime: root.endTime,
90
+ durationMs: root.durationMs,
91
+ receivedAt: rows[0]?.received_at ?? root.startTime,
92
+ spanCount: spans.length,
93
+ errorSpanCount: spans.filter((span) => span.statusCode === 2).length,
94
+ },
95
+ spans,
96
+ instrumentationGaps,
97
+ timestamp,
98
+ };
99
+ }
100
+
101
+ export async function getTelemetryTraceGaps(
102
+ db: SqlDb,
103
+ traceId: string,
104
+ projectId: string,
105
+ spans?: TelemetrySpanDetail[],
106
+ ): Promise<TelemetryInstrumentationGapsResponse | null> {
107
+ if (!projectId)
108
+ throw new Error("TelemetryStore.getTraceGaps: projectId is required");
109
+
110
+ // Gaps are derived data: compute them lazily from the trace's spans on
111
+ // read, rather than materializing them on the ingest hot path. The
112
+ // `spans` arg is passed by `getTelemetryTraceDetail` (which has already
113
+ // loaded them); the standalone `/gaps` endpoint omits it, so we load
114
+ // them here. The span query is project-scoped, so this avoids the
115
+ // `trace_instrumentation_gaps` table's lack of project scoping entirely.
116
+ const timestamp = new Date().toISOString();
117
+
118
+ let localSpans = spans;
119
+ if (!localSpans) {
120
+ const result = await db
121
+ .prepare(`
122
+ SELECT project_id, trace_id, span_id, parent_span_id, service_name, scope_name,
123
+ scope_version, span_name, span_kind, status_code, status_message,
124
+ start_time, end_time, duration_ms, attributes_json,
125
+ resource_attributes_json, events_json, links_json,
126
+ received_at, expires_at
127
+ FROM telemetry_spans
128
+ WHERE project_id = ? AND trace_id = ?
129
+ ORDER BY start_time ASC, span_id ASC
130
+ `)
131
+ .bind(projectId, traceId)
132
+ .all<SpanDetailRow>();
133
+ const spanRows = result.results ?? [];
134
+ if (spanRows.length === 0) return null;
135
+
136
+ localSpans = spanRows.map((row) => ({
137
+ traceId: row.trace_id,
138
+ spanId: row.span_id,
139
+ parentSpanId: row.parent_span_id,
140
+ serviceName: normalizeService(row.service_name),
141
+ scopeName: row.scope_name,
142
+ scopeVersion: row.scope_version,
143
+ spanName: row.span_name,
144
+ spanKind: row.span_kind,
145
+ statusCode: row.status_code,
146
+ statusMessage: row.status_message,
147
+ startTime: row.start_time,
148
+ endTime: row.end_time,
149
+ durationMs: row.duration_ms,
150
+ attributes: parseJsonRecord(row.attributes_json),
151
+ resourceAttributes: parseJsonRecord(row.resource_attributes_json),
152
+ events: parseJsonArray(row.events_json),
153
+ links: parseJsonArray(row.links_json),
154
+ }));
155
+ }
156
+
157
+ if (localSpans.length === 0) return null;
158
+ const root = localSpans.find((span) => !span.parentSpanId) ?? localSpans[0];
159
+ return buildTraceInstrumentationGaps(
160
+ traceId,
161
+ localSpans,
162
+ root.durationMs,
163
+ timestamp,
164
+ );
165
+ }
166
+
167
+ export function buildTraceInstrumentationGaps(
168
+ traceId: string,
169
+ spans: TelemetrySpanDetail[],
170
+ totalDurationMs: number,
171
+ timestamp = new Date().toISOString(),
172
+ ): TelemetryInstrumentationGapsResponse {
173
+ const children = new Map<string | null, TelemetrySpanDetail[]>();
174
+ for (const span of spans) {
175
+ const parentKey = span.parentSpanId ?? null;
176
+ const bucket = children.get(parentKey) ?? [];
177
+ bucket.push(span);
178
+ children.set(parentKey, bucket);
179
+ }
180
+
181
+ const blindspots: TelemetryInstrumentationGap[] = [];
182
+ for (const span of spans) {
183
+ const childSpans = children.get(span.spanId) ?? [];
184
+ const childDurationMs = childSpans.reduce(
185
+ (acc, child) => acc + child.durationMs,
186
+ 0,
187
+ );
188
+ const rawSelfMs = span.durationMs - childDurationMs;
189
+ const asyncParent = rawSelfMs < 0;
190
+ const selfMs = Math.max(0, rawSelfMs);
191
+ const ratioOfParent =
192
+ span.durationMs > 0 ? Math.min(1, selfMs / span.durationMs) : 0;
193
+ if (
194
+ !isInstrumentationGapCandidate({
195
+ durationMs: span.durationMs,
196
+ selfRatio: ratioOfParent,
197
+ childSpanCount: childSpans.length,
198
+ asyncParent,
199
+ spanKind: span.spanKind,
200
+ })
201
+ ) {
202
+ continue;
203
+ }
204
+ blindspots.push({
205
+ traceId,
206
+ parentSpanId: span.spanId,
207
+ parentServiceName: span.serviceName,
208
+ parentSpanName: span.spanName,
209
+ offsetMs: 0,
210
+ durationMs: Math.round(selfMs),
211
+ ratioOfParent,
212
+ childSpanCount: childSpans.length,
213
+ parentSpanKind: span.spanKind,
214
+ asyncParent,
215
+ thresholdVersion: DEFAULT_INSTRUMENTATION_GAP_THRESHOLDS.version,
216
+ recommendation: `Add tracing inside ${span.spanName} or attach a profile for ${span.serviceName}.`,
217
+ });
218
+ }
219
+
220
+ const uninstrumentedTimeMs = blindspots.reduce(
221
+ (acc, gap) => acc + gap.durationMs,
222
+ 0,
223
+ );
224
+ return {
225
+ traceId,
226
+ totalDurationMs,
227
+ uninstrumentedTimeMs,
228
+ ratio: totalDurationMs > 0 ? uninstrumentedTimeMs / totalDurationMs : 0,
229
+ blindspots,
230
+ thresholds: DEFAULT_INSTRUMENTATION_GAP_THRESHOLDS,
231
+ timestamp,
232
+ };
233
+ }
234
+
235
+ export async function calibrateTelemetryInstrumentationGaps(
236
+ db: SqlDb,
237
+ options: TelemetryInstrumentationGapCalibrationOptions,
238
+ ) {
239
+ if (!options.projectId)
240
+ throw new Error("TelemetryStore.calibrateTraceGaps: projectId is required");
241
+ const hours = Math.max(1, Math.min(options.hours, 24 * 30));
242
+ const limit = Math.max(1, Math.min(options.limit ?? 5000, 20_000));
243
+ const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
244
+ const result = await db
245
+ .prepare(`
246
+ SELECT project_id, trace_id, span_id, parent_span_id, service_name, scope_name,
247
+ scope_version, span_name, span_kind, status_code, status_message,
248
+ start_time, end_time, duration_ms, attributes_json,
249
+ resource_attributes_json, events_json, links_json,
250
+ received_at, expires_at
251
+ FROM telemetry_spans
252
+ WHERE project_id = ? AND received_at >= ?
253
+ ORDER BY received_at DESC, trace_id ASC, start_time ASC, span_id ASC
254
+ LIMIT ?
255
+ `)
256
+ .bind(options.projectId, since, limit)
257
+ .all<SpanDetailRow>();
258
+
259
+ const rows = result.results ?? [];
260
+ const byTrace = new Map<string, TelemetrySpanDetail[]>();
261
+ for (const row of rows) {
262
+ const bucket = byTrace.get(row.trace_id) ?? [];
263
+ bucket.push({
264
+ traceId: row.trace_id,
265
+ spanId: row.span_id,
266
+ parentSpanId: row.parent_span_id,
267
+ serviceName: normalizeService(row.service_name),
268
+ scopeName: row.scope_name,
269
+ scopeVersion: row.scope_version,
270
+ spanName: row.span_name,
271
+ spanKind: row.span_kind,
272
+ statusCode: row.status_code,
273
+ statusMessage: row.status_message,
274
+ startTime: row.start_time,
275
+ endTime: row.end_time,
276
+ durationMs: row.duration_ms,
277
+ attributes: parseJsonRecord(row.attributes_json),
278
+ resourceAttributes: parseJsonRecord(row.resource_attributes_json),
279
+ events: parseJsonArray(row.events_json),
280
+ links: parseJsonArray(row.links_json),
281
+ });
282
+ byTrace.set(row.trace_id, bucket);
283
+ }
284
+
285
+ const traceSummaries: Array<{
286
+ traceId: string;
287
+ rootSpanName: string;
288
+ serviceName: string;
289
+ totalDurationMs: number;
290
+ blindspotCount: number;
291
+ uninstrumentedTimeMs: number;
292
+ ratio: number;
293
+ topBlindspot: TelemetryInstrumentationGap | null;
294
+ }> = [];
295
+ let flaggedTraceCount = 0;
296
+ let blindspotCount = 0;
297
+ let uninstrumentedTimeMs = 0;
298
+
299
+ for (const [traceId, spans] of byTrace) {
300
+ spans.sort((a, b) => a.startTime.localeCompare(b.startTime));
301
+ const root = spans.find((span) => !span.parentSpanId) ?? spans[0];
302
+ if (!root) continue;
303
+ const gaps = buildTraceInstrumentationGaps(traceId, spans, root.durationMs);
304
+ if (gaps.blindspots.length === 0) continue;
305
+ flaggedTraceCount++;
306
+ blindspotCount += gaps.blindspots.length;
307
+ uninstrumentedTimeMs += gaps.uninstrumentedTimeMs;
308
+ traceSummaries.push({
309
+ traceId,
310
+ rootSpanName: root.spanName,
311
+ serviceName: root.serviceName,
312
+ totalDurationMs: root.durationMs,
313
+ blindspotCount: gaps.blindspots.length,
314
+ uninstrumentedTimeMs: gaps.uninstrumentedTimeMs,
315
+ ratio: gaps.ratio,
316
+ topBlindspot: [...gaps.blindspots].sort(
317
+ (a, b) => b.durationMs - a.durationMs,
318
+ )[0],
319
+ });
320
+ }
321
+
322
+ traceSummaries.sort(
323
+ (a, b) => b.uninstrumentedTimeMs - a.uninstrumentedTimeMs,
324
+ );
325
+ const traceCount = byTrace.size;
326
+ return {
327
+ thresholds: DEFAULT_INSTRUMENTATION_GAP_THRESHOLDS,
328
+ windowHours: hours,
329
+ sampledSpanCount: rows.length,
330
+ traceCount,
331
+ flaggedTraceCount,
332
+ blindspotCount,
333
+ uninstrumentedTimeMs,
334
+ flaggedTraceRate: traceCount > 0 ? flaggedTraceCount / traceCount : 0,
335
+ status:
336
+ traceCount === 0
337
+ ? "no_traces"
338
+ : flaggedTraceCount === 0
339
+ ? "quiet"
340
+ : flaggedTraceCount / traceCount > 0.25
341
+ ? "noisy"
342
+ : "calibrated",
343
+ topTraces: traceSummaries.slice(0, 20),
344
+ timestamp: new Date().toISOString(),
345
+ };
346
+ }
347
+
348
+ export function extractCodeReference(
349
+ attributes: Record<string, JsonValue>,
350
+ ): TelemetryCodeReference | undefined {
351
+ const originalPath = firstString(attributes, [
352
+ "code.filepath",
353
+ "code.file.path",
354
+ "code.path",
355
+ "source.file",
356
+ ]);
357
+ const symbolName = firstString(attributes, [
358
+ "code.function",
359
+ "code.function.name",
360
+ "code.namespace",
361
+ ]);
362
+ const lineNumber = firstPositiveInteger(attributes, [
363
+ "code.lineno",
364
+ "code.line_number",
365
+ "code.line",
366
+ ]);
367
+ const columnNumber = firstPositiveInteger(attributes, [
368
+ "code.column",
369
+ "code.column_number",
370
+ ]);
371
+ const repoName = firstString(attributes, ["code.repository", "repo.name"]);
372
+
373
+ if (!originalPath && !symbolName && lineNumber === undefined)
374
+ return undefined;
375
+
376
+ const reference: TelemetryCodeReference = {};
377
+ if (repoName) reference.repoName = repoName;
378
+ if (originalPath) {
379
+ reference.originalPath = originalPath;
380
+ if (isAbsolutePath(originalPath)) {
381
+ reference.absolutePath = originalPath;
382
+ } else {
383
+ reference.relativePath = normalizeRelativePath(originalPath);
384
+ }
385
+ }
386
+ if (symbolName) reference.symbolName = symbolName;
387
+ if (lineNumber !== undefined) reference.lineNumber = lineNumber;
388
+ if (columnNumber !== undefined) reference.columnNumber = columnNumber;
389
+
390
+ return reference;
391
+ }
392
+
393
+ function firstString(
394
+ attributes: Record<string, JsonValue>,
395
+ keys: string[],
396
+ ): string | undefined {
397
+ for (const key of keys) {
398
+ const value = attributes[key];
399
+ if (typeof value === "string" && value.trim()) return value.trim();
400
+ }
401
+ return undefined;
402
+ }
403
+
404
+ function firstPositiveInteger(
405
+ attributes: Record<string, JsonValue>,
406
+ keys: string[],
407
+ ): number | undefined {
408
+ for (const key of keys) {
409
+ const value = attributes[key];
410
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) {
411
+ return value;
412
+ }
413
+ if (typeof value === "string") {
414
+ const parsed = Number.parseInt(value, 10);
415
+ if (Number.isInteger(parsed) && parsed > 0) return parsed;
416
+ }
417
+ }
418
+ return undefined;
419
+ }
420
+
421
+ function isAbsolutePath(value: string): boolean {
422
+ return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
423
+ }
424
+
425
+ function normalizeRelativePath(value: string): string {
426
+ return value.replace(/^\.\/+/, "");
427
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { MemSqlDb } from "../test-utils/mem-sql-db";
3
+ import { fetchSpansForTraceIds } from "./trace-queries";
4
+
5
+ describe("fetchSpansForTraceIds", () => {
6
+ it("does not query when there are no trace ids", async () => {
7
+ const db = new MemSqlDb();
8
+
9
+ const spans = await fetchSpansForTraceIds(db, "default", []);
10
+
11
+ expect(spans).toEqual([]);
12
+ expect(db.calls).toHaveLength(0);
13
+ });
14
+
15
+ it("chunks large trace id lists to stay under D1 SQL variable limits", async () => {
16
+ const db = new MemSqlDb({ all: () => [] });
17
+ const traceIds = Array.from({ length: 1801 }, (_, i) => `trace-${i}`);
18
+
19
+ await fetchSpansForTraceIds(db, "default", traceIds);
20
+
21
+ const calls = db.callsMatching("FROM telemetry_spans");
22
+ expect(calls).toHaveLength(19);
23
+ expect(calls.slice(0, -1).map((call) => call.binds.length)).toEqual(
24
+ Array.from({ length: 18 }, () => 100),
25
+ );
26
+ expect(calls.at(-1)?.binds.length).toBe(20);
27
+ expect(calls.every((call) => call.binds[0] === "default")).toBe(true);
28
+ });
29
+ });
@@ -0,0 +1,89 @@
1
+ import type { SqlDb } from "../sql-db";
2
+ import {
3
+ type ParsedSpan,
4
+ placeholders,
5
+ rowToSpan,
6
+ spanSelectColumns,
7
+ type TraceCandidateRow,
8
+ toParsedSpan,
9
+ } from "./helpers";
10
+
11
+ const MAX_TRACE_ID_BINDINGS = 99;
12
+
13
+ export interface TraceCandidateOptions {
14
+ projectId: string;
15
+ cutoff: string;
16
+ service?: string;
17
+ search?: string;
18
+ status?: "all" | "error" | "ok";
19
+ limit: number;
20
+ }
21
+
22
+ export async function selectTraceCandidates(
23
+ db: SqlDb,
24
+ options: TraceCandidateOptions,
25
+ ): Promise<TraceCandidateRow[]> {
26
+ let whereClause = "WHERE project_id = ? AND received_at >= ?";
27
+ const binds: unknown[] = [options.projectId, options.cutoff];
28
+
29
+ if (options.service) {
30
+ whereClause += " AND service_name = ?";
31
+ binds.push(options.service);
32
+ }
33
+ if (options.search) {
34
+ const term = `%${options.search}%`;
35
+ whereClause +=
36
+ " AND (span_name LIKE ? OR status_message LIKE ? OR attributes_json LIKE ? OR events_json LIKE ?)";
37
+ binds.push(term, term, term, term);
38
+ }
39
+
40
+ const having =
41
+ options.status === "error"
42
+ ? "HAVING SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) > 0"
43
+ : options.status === "ok"
44
+ ? "HAVING SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) = 0"
45
+ : "";
46
+
47
+ const result = await db
48
+ .prepare(`
49
+ SELECT
50
+ trace_id,
51
+ MAX(received_at) AS latest_received_at,
52
+ SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_span_count
53
+ FROM telemetry_spans
54
+ ${whereClause}
55
+ GROUP BY trace_id
56
+ ${having}
57
+ ORDER BY latest_received_at DESC
58
+ LIMIT ?
59
+ `)
60
+ .bind(...binds, options.limit)
61
+ .all<TraceCandidateRow>();
62
+
63
+ return result.results ?? [];
64
+ }
65
+
66
+ export async function fetchSpansForTraceIds(
67
+ db: SqlDb,
68
+ projectId: string,
69
+ traceIds: string[],
70
+ ): Promise<ParsedSpan[]> {
71
+ if (traceIds.length === 0) return [];
72
+
73
+ const rows: Record<string, unknown>[] = [];
74
+ for (let i = 0; i < traceIds.length; i += MAX_TRACE_ID_BINDINGS) {
75
+ const chunk = traceIds.slice(i, i + MAX_TRACE_ID_BINDINGS);
76
+ const result = await db
77
+ .prepare(`
78
+ SELECT ${spanSelectColumns}
79
+ FROM telemetry_spans
80
+ WHERE project_id = ? AND trace_id IN (${placeholders(chunk.length)})
81
+ ORDER BY received_at DESC, start_time ASC, span_id ASC
82
+ `)
83
+ .bind(projectId, ...chunk)
84
+ .all<Record<string, unknown>>();
85
+ rows.push(...(result.results ?? []));
86
+ }
87
+
88
+ return rows.map(rowToSpan).map(toParsedSpan);
89
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { TelemetryStore } from "./store";
3
+ import { MemSqlDb } from "./test-utils/mem-sql-db";
4
+
5
+ const spanRow = (overrides: Record<string, unknown>) => ({
6
+ project_id: "default",
7
+ trace_id: "trace-1",
8
+ span_id: "span-1",
9
+ parent_span_id: null,
10
+ service_name: "api",
11
+ scope_name: null,
12
+ scope_version: null,
13
+ span_name: "GET /checkout",
14
+ span_kind: 2,
15
+ status_code: 1,
16
+ status_message: null,
17
+ start_time: "2026-05-31T00:00:00.000Z",
18
+ end_time: "2026-05-31T00:00:00.100Z",
19
+ duration_ms: 100,
20
+ attributes_json: "{}",
21
+ resource_attributes_json: "{}",
22
+ events_json: "[]",
23
+ links_json: "[]",
24
+ received_at: "2026-05-31T00:00:01.000Z",
25
+ expires_at: "2026-06-01T00:00:00.000Z",
26
+ ...overrides,
27
+ });
28
+
29
+ describe("TelemetryStore overview trace aggregation", () => {
30
+ it("selects trace ids first and fetches complete spans for selected traces", async () => {
31
+ const db = new MemSqlDb({
32
+ all: (sql) => {
33
+ if (sql.includes("GROUP BY trace_id")) {
34
+ return [
35
+ {
36
+ trace_id: "trace-1",
37
+ latest_received_at: "2026-05-31T00:00:01.000Z",
38
+ error_span_count: 1,
39
+ },
40
+ ];
41
+ }
42
+ if (sql.includes("trace_id IN")) {
43
+ return [
44
+ spanRow({ span_id: "root", status_code: 1 }),
45
+ spanRow({
46
+ span_id: "child-error",
47
+ parent_span_id: "root",
48
+ span_name: "db.query",
49
+ status_code: 2,
50
+ status_message: "boom",
51
+ duration_ms: 25,
52
+ }),
53
+ ];
54
+ }
55
+ return [];
56
+ },
57
+ });
58
+ const store = new TelemetryStore(db);
59
+
60
+ const overview = await store.getOverview({
61
+ projectId: "default",
62
+ hours: 1,
63
+ limit: 1,
64
+ });
65
+
66
+ expect(overview.traces).toHaveLength(1);
67
+ expect(overview.traces[0]).toMatchObject({
68
+ traceId: "trace-1",
69
+ spanCount: 2,
70
+ errorSpanCount: 1,
71
+ statusCode: 2,
72
+ statusMessage: "boom",
73
+ });
74
+ expect(db.calls[0].sql).toContain("GROUP BY trace_id");
75
+ expect(db.calls[1].sql).toContain("trace_id IN");
76
+ });
77
+ });