@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,367 @@
1
+ import type {
2
+ JsonValue,
3
+ StoredSpan,
4
+ TelemetryIssueCategory,
5
+ TelemetryIssueOptions,
6
+ TelemetryIssueSeverity,
7
+ TelemetryIssueSpanSummary,
8
+ TelemetryIssueSummary,
9
+ } from "@obsunified/types";
10
+ import { parseJsonArray, parseJsonRecord } from "../json";
11
+
12
+ /** Map D1 snake_case row to camelCase StoredSpan */
13
+ export const rowToSpan = (row: Record<string, unknown>): StoredSpan => ({
14
+ projectId: (row.project_id as string) ?? "default",
15
+ traceId: row.trace_id as string,
16
+ spanId: row.span_id as string,
17
+ parentSpanId: (row.parent_span_id as string) ?? null,
18
+ traceState: (row.trace_state as string) ?? null,
19
+ serviceName: (row.service_name as string) ?? null,
20
+ scopeName: (row.scope_name as string) ?? null,
21
+ scopeVersion: (row.scope_version as string) ?? null,
22
+ spanName: row.span_name as string,
23
+ spanKind: (row.span_kind as number) ?? 0,
24
+ statusCode: (row.status_code as number) ?? 0,
25
+ statusMessage: (row.status_message as string) ?? null,
26
+ startTime: row.start_time as string,
27
+ endTime: row.end_time as string,
28
+ durationMs: (row.duration_ms as number) ?? 0,
29
+ attributesJson: (row.attributes_json as string) ?? "{}",
30
+ droppedAttributesCount: (row.dropped_attributes_count as number) ?? 0,
31
+ resourceAttributesJson: (row.resource_attributes_json as string) ?? "{}",
32
+ eventsJson: (row.events_json as string) ?? "[]",
33
+ droppedEventsCount: (row.dropped_events_count as number) ?? 0,
34
+ linksJson: (row.links_json as string) ?? "[]",
35
+ droppedLinksCount: (row.dropped_links_count as number) ?? 0,
36
+ receivedAt: row.received_at as string,
37
+ expiresAt: row.expires_at as string,
38
+ });
39
+
40
+ export type NormalizedIssue = TelemetryIssueSummary & {
41
+ traces: Array<{
42
+ traceId: string;
43
+ serviceName: string;
44
+ routeLabel: string;
45
+ startTime: string;
46
+ durationMs: number;
47
+ statusCode: number;
48
+ statusMessage: string | null;
49
+ rootSpanName: string;
50
+ culpritSpanName: string;
51
+ dependencyTarget: string | null;
52
+ }>;
53
+ culpritSpans: TelemetryIssueSpanSummary[];
54
+ };
55
+
56
+ export type ParsedSpan = StoredSpan & {
57
+ attributes: Record<string, JsonValue>;
58
+ resourceAttributes: Record<string, JsonValue>;
59
+ events: unknown[];
60
+ links: unknown[];
61
+ };
62
+
63
+ export interface TraceCandidateRow {
64
+ trace_id: string;
65
+ latest_received_at: string;
66
+ error_span_count: number;
67
+ }
68
+
69
+ // ── Helpers ──
70
+
71
+ export const cutoffIso = (hours: number): string =>
72
+ new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
73
+
74
+ export const percentile = (values: number[], fraction: number): number => {
75
+ if (values.length === 0) return 0;
76
+ const sorted = [...values].sort((left, right) => left - right);
77
+ const index = Math.min(
78
+ sorted.length - 1,
79
+ Math.max(0, Math.ceil(sorted.length * fraction) - 1),
80
+ );
81
+ return sorted[index];
82
+ };
83
+
84
+ export const average = (values: number[]): number =>
85
+ values.length > 0
86
+ ? values.reduce((sum, value) => sum + value, 0) / values.length
87
+ : 0;
88
+
89
+ export const normalizeService = (value: string | null | undefined): string =>
90
+ value?.trim() || "unknown";
91
+
92
+ export const toParsedSpan = (row: StoredSpan): ParsedSpan => ({
93
+ ...row,
94
+ attributes: parseJsonRecord(row.attributesJson),
95
+ resourceAttributes: parseJsonRecord(row.resourceAttributesJson),
96
+ events: parseJsonArray(row.eventsJson),
97
+ links: parseJsonArray(row.linksJson),
98
+ });
99
+
100
+ export const spanSelectColumns = `
101
+ project_id, trace_id, span_id, parent_span_id, service_name, scope_name,
102
+ scope_version, span_name, span_kind, status_code, status_message,
103
+ start_time, end_time, duration_ms, attributes_json,
104
+ resource_attributes_json, events_json, links_json,
105
+ received_at, expires_at
106
+ `;
107
+
108
+ export const placeholders = (count: number): string =>
109
+ Array.from({ length: count }, () => "?").join(",");
110
+
111
+ export const getRouteLabel = (span: ParsedSpan): string => {
112
+ const collectorRoute = span.attributes["collector.route_label"];
113
+ if (typeof collectorRoute === "string" && collectorRoute.trim())
114
+ return collectorRoute.trim();
115
+ const method = span.attributes["http.request.method"];
116
+ const path = span.attributes["url.path"];
117
+ if (typeof method === "string" && typeof path === "string")
118
+ return `${method} ${path}`;
119
+ return span.spanName;
120
+ };
121
+
122
+ export const isInternalTrace = (span: ParsedSpan): boolean =>
123
+ span.attributes["collector.is_internal"] === true;
124
+
125
+ export const getDependencyTarget = (span: ParsedSpan): string | null => {
126
+ const collectorTarget = span.attributes["collector.dependency_target"];
127
+ if (typeof collectorTarget === "string" && collectorTarget.trim())
128
+ return collectorTarget.trim();
129
+ return null;
130
+ };
131
+
132
+ export const getSeverity = (
133
+ category: TelemetryIssueCategory,
134
+ count: number,
135
+ durationMs: number,
136
+ statusCode: number,
137
+ ): TelemetryIssueSeverity => {
138
+ if (category === "error" && (count >= 10 || statusCode === 2))
139
+ return "critical";
140
+ if (category === "dependency" && (statusCode === 2 || durationMs >= 2_000))
141
+ return "high";
142
+ if (durationMs >= 3_000 || count >= 5) return "high";
143
+ if (durationMs >= 1_000 || count >= 2) return "medium";
144
+ return "low";
145
+ };
146
+
147
+ export const buildIssueTitle = (
148
+ category: TelemetryIssueCategory,
149
+ routeLabel: string,
150
+ dependencyTarget: string | null,
151
+ ): string => {
152
+ switch (category) {
153
+ case "error":
154
+ return `Errors on ${routeLabel}`;
155
+ case "dependency":
156
+ return dependencyTarget
157
+ ? `Dependency failures talking to ${dependencyTarget}`
158
+ : `Dependency failures on ${routeLabel}`;
159
+ case "latency":
160
+ return `Latency spike on ${routeLabel}`;
161
+ }
162
+ };
163
+
164
+ export const buildIssueId = (
165
+ category: TelemetryIssueCategory,
166
+ serviceName: string,
167
+ routeLabel: string,
168
+ culpritSpanName: string,
169
+ dependencyTarget: string | null,
170
+ ): string =>
171
+ [
172
+ category,
173
+ serviceName,
174
+ routeLabel,
175
+ culpritSpanName,
176
+ dependencyTarget ?? "",
177
+ ].join("|");
178
+
179
+ export const groupIssues = (
180
+ spans: ParsedSpan[],
181
+ options: TelemetryIssueOptions,
182
+ ): NormalizedIssue[] => {
183
+ const traces = new Map<string, ParsedSpan[]>();
184
+ for (const span of spans) {
185
+ const traceSpans = traces.get(span.traceId) ?? [];
186
+ traceSpans.push(span);
187
+ traces.set(span.traceId, traceSpans);
188
+ }
189
+
190
+ const grouped = new Map<
191
+ string,
192
+ {
193
+ category: TelemetryIssueCategory;
194
+ serviceName: string;
195
+ routeLabel: string;
196
+ dependencyTarget: string | null;
197
+ culpritSpanName: string;
198
+ title: string;
199
+ traces: NormalizedIssue["traces"];
200
+ }
201
+ >();
202
+
203
+ for (const traceSpans of traces.values()) {
204
+ const sorted = [...traceSpans].sort((left, right) =>
205
+ left.startTime.localeCompare(right.startTime),
206
+ );
207
+ const root = sorted.find((span) => !span.parentSpanId) ?? sorted[0];
208
+ if (!options.includeInternal && isInternalTrace(root)) continue;
209
+
210
+ const serviceName = normalizeService(root.serviceName);
211
+ if (options.service && options.service !== serviceName) continue;
212
+
213
+ const routeLabel = getRouteLabel(root);
214
+ const errorSpan = sorted.find((span) => span.statusCode === 2);
215
+ const dependencySpan = sorted.find(
216
+ (span) =>
217
+ span.attributes["collector.span_role"] === "dependency" &&
218
+ (span.statusCode === 2 || span.durationMs >= 1_000),
219
+ );
220
+
221
+ const candidates: Array<{
222
+ category: TelemetryIssueCategory;
223
+ culprit: ParsedSpan;
224
+ dependencyTarget: string | null;
225
+ }> = [];
226
+
227
+ if (errorSpan) {
228
+ candidates.push({
229
+ category: "error",
230
+ culprit: errorSpan,
231
+ dependencyTarget: getDependencyTarget(errorSpan),
232
+ });
233
+ }
234
+ if (dependencySpan) {
235
+ candidates.push({
236
+ category: "dependency",
237
+ culprit: dependencySpan,
238
+ dependencyTarget: getDependencyTarget(dependencySpan),
239
+ });
240
+ }
241
+ if (root.durationMs >= 1_000) {
242
+ candidates.push({
243
+ category: "latency",
244
+ culprit: dependencySpan ?? root,
245
+ dependencyTarget: dependencySpan
246
+ ? getDependencyTarget(dependencySpan)
247
+ : null,
248
+ });
249
+ }
250
+
251
+ for (const candidate of candidates) {
252
+ if (
253
+ options.category &&
254
+ options.category !== "all" &&
255
+ candidate.category !== options.category
256
+ )
257
+ continue;
258
+
259
+ const issueId = buildIssueId(
260
+ candidate.category,
261
+ serviceName,
262
+ routeLabel,
263
+ candidate.culprit.spanName,
264
+ candidate.dependencyTarget,
265
+ );
266
+ const entry = grouped.get(issueId) ?? {
267
+ category: candidate.category,
268
+ serviceName,
269
+ routeLabel,
270
+ dependencyTarget: candidate.dependencyTarget,
271
+ culpritSpanName: candidate.culprit.spanName,
272
+ title: buildIssueTitle(
273
+ candidate.category,
274
+ routeLabel,
275
+ candidate.dependencyTarget,
276
+ ),
277
+ traces: [],
278
+ };
279
+
280
+ entry.traces.push({
281
+ traceId: root.traceId,
282
+ serviceName,
283
+ routeLabel,
284
+ startTime: root.startTime,
285
+ durationMs: root.durationMs,
286
+ statusCode: root.statusCode,
287
+ statusMessage: root.statusMessage,
288
+ rootSpanName: root.spanName,
289
+ culpritSpanName: candidate.culprit.spanName,
290
+ dependencyTarget: candidate.dependencyTarget,
291
+ });
292
+ grouped.set(issueId, entry);
293
+ }
294
+ }
295
+
296
+ return Array.from(grouped.entries()).map(
297
+ ([issueId, entry]): NormalizedIssue => {
298
+ const occurrenceCount = entry.traces.length;
299
+ const durations = entry.traces.map((trace) => trace.durationMs);
300
+ const latest = [...entry.traces].sort((left, right) =>
301
+ right.startTime.localeCompare(left.startTime),
302
+ )[0];
303
+ const culpritCounts = new Map<string, TelemetryIssueSpanSummary>();
304
+
305
+ for (const trace of entry.traces) {
306
+ const culpritKey = `${trace.culpritSpanName}|${trace.dependencyTarget ?? ""}|${trace.statusCode}`;
307
+ const existing = culpritCounts.get(culpritKey) ?? {
308
+ spanName: trace.culpritSpanName,
309
+ dependencyTarget: trace.dependencyTarget,
310
+ statusCode: trace.statusCode,
311
+ occurrenceCount: 0,
312
+ averageDurationMs: 0,
313
+ maxDurationMs: 0,
314
+ };
315
+ existing.occurrenceCount += 1;
316
+ existing.averageDurationMs += trace.durationMs;
317
+ existing.maxDurationMs = Math.max(
318
+ existing.maxDurationMs,
319
+ trace.durationMs,
320
+ );
321
+ culpritCounts.set(culpritKey, existing);
322
+ }
323
+
324
+ for (const culprit of culpritCounts.values()) {
325
+ culprit.averageDurationMs = Math.round(
326
+ culprit.averageDurationMs / culprit.occurrenceCount,
327
+ );
328
+ }
329
+
330
+ return {
331
+ issueId,
332
+ category: entry.category,
333
+ severity: getSeverity(
334
+ entry.category,
335
+ occurrenceCount,
336
+ average(durations),
337
+ latest.statusCode,
338
+ ),
339
+ title: entry.title,
340
+ serviceName: entry.serviceName,
341
+ routeLabel: entry.routeLabel,
342
+ occurrenceCount,
343
+ affectedTraceCount: new Set(entry.traces.map((trace) => trace.traceId))
344
+ .size,
345
+ firstSeen:
346
+ [...entry.traces].sort((left, right) =>
347
+ left.startTime.localeCompare(right.startTime),
348
+ )[0]?.startTime ?? latest.startTime,
349
+ lastSeen: latest.startTime,
350
+ latestStatusMessage: latest.statusMessage,
351
+ culpritSpanName: entry.culpritSpanName,
352
+ dependencyTarget: entry.dependencyTarget,
353
+ averageDurationMs: Math.round(average(durations)),
354
+ maxDurationMs: Math.max(...durations, 0),
355
+ sampleTraceId: latest.traceId,
356
+ traces: entry.traces
357
+ .sort((left, right) => right.startTime.localeCompare(left.startTime))
358
+ .slice(0, 20),
359
+ culpritSpans: Array.from(culpritCounts.values()).sort(
360
+ (left, right) =>
361
+ right.occurrenceCount - left.occurrenceCount ||
362
+ left.spanName.localeCompare(right.spanName),
363
+ ),
364
+ };
365
+ },
366
+ );
367
+ };
@@ -0,0 +1,68 @@
1
+ import type { StoredSpan } from "@obsunified/types";
2
+ import type { SqlDb } from "../sql-db";
3
+
4
+ export async function ingestTelemetrySpans(
5
+ db: SqlDb,
6
+ spans: StoredSpan[],
7
+ ): Promise<{ inserted: number; traceCount: number }> {
8
+ if (spans.length === 0) return { inserted: 0, traceCount: 0 };
9
+
10
+ const statements = spans.map((span) => {
11
+ if (!span.projectId)
12
+ throw new Error("TelemetryStore.ingest: span.projectId is required");
13
+ return db
14
+ .prepare(`
15
+ INSERT OR IGNORE INTO telemetry_spans (
16
+ project_id, trace_id, span_id, parent_span_id, trace_state,
17
+ service_name, scope_name, scope_version, span_name, span_kind,
18
+ status_code, status_message, start_time, end_time, duration_ms,
19
+ attributes_json, dropped_attributes_count,
20
+ resource_attributes_json, events_json, dropped_events_count,
21
+ links_json, dropped_links_count, received_at, expires_at,
22
+ session_id, interaction_id, telemetry_sdk_name
23
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
24
+ `)
25
+ .bind(
26
+ span.projectId,
27
+ span.traceId,
28
+ span.spanId,
29
+ span.parentSpanId,
30
+ span.traceState,
31
+ span.serviceName,
32
+ span.scopeName,
33
+ span.scopeVersion,
34
+ span.spanName,
35
+ span.spanKind,
36
+ span.statusCode,
37
+ span.statusMessage,
38
+ span.startTime,
39
+ span.endTime,
40
+ span.durationMs,
41
+ span.attributesJson,
42
+ span.droppedAttributesCount,
43
+ span.resourceAttributesJson,
44
+ span.eventsJson,
45
+ span.droppedEventsCount,
46
+ span.linksJson,
47
+ span.droppedLinksCount,
48
+ span.receivedAt,
49
+ span.expiresAt,
50
+ span.sessionId ?? null,
51
+ span.interactionId ?? null,
52
+ span.telemetrySdkName ?? null,
53
+ );
54
+ });
55
+
56
+ await db.batch(statements);
57
+
58
+ // Trace instrumentation gaps are computed lazily at read time (see
59
+ // `getTelemetryTraceGaps`), not materialized on ingest. This keeps the
60
+ // ingest hot path to a single batched span write and avoids recomputing
61
+ // gaps for the >99% of traces that are never inspected.
62
+ const uniqueTraces = new Set(spans.map((s) => `${s.projectId}:${s.traceId}`));
63
+
64
+ return {
65
+ inserted: spans.length,
66
+ traceCount: uniqueTraces.size,
67
+ };
68
+ }
@@ -0,0 +1,158 @@
1
+ import type {
2
+ TelemetryIssueDetailResponse,
3
+ TelemetryIssueOptions,
4
+ TelemetryIssueOverviewResponse,
5
+ } from "@obsunified/types";
6
+ import type { SqlDb } from "../sql-db";
7
+ import { cutoffIso, groupIssues } from "./helpers";
8
+ import { fetchSpansForTraceIds, selectTraceCandidates } from "./trace-queries";
9
+
10
+ export async function getTelemetryIssueOverview(
11
+ db: SqlDb,
12
+ options: TelemetryIssueOptions,
13
+ ): Promise<TelemetryIssueOverviewResponse> {
14
+ if (!options.projectId)
15
+ throw new Error("TelemetryStore.getIssueOverview: projectId is required");
16
+ const cutoff = cutoffIso(options.hours);
17
+ const issueLimit = options.limit ?? 50;
18
+ const candidateLimit = Math.min(issueLimit * 10, 500);
19
+ const candidates = await selectTraceCandidates(db, {
20
+ projectId: options.projectId,
21
+ cutoff,
22
+ service: options.service,
23
+ limit: candidateLimit,
24
+ });
25
+ const spans = await fetchSpansForTraceIds(
26
+ db,
27
+ options.projectId,
28
+ candidates.map((candidate) => candidate.trace_id),
29
+ );
30
+
31
+ const issues = groupIssues(spans, options)
32
+ .sort(
33
+ (left, right) =>
34
+ right.occurrenceCount - left.occurrenceCount ||
35
+ right.lastSeen.localeCompare(left.lastSeen),
36
+ )
37
+ .slice(0, options.limit ?? 50);
38
+
39
+ const services = new Map<
40
+ string,
41
+ {
42
+ issueCount: number;
43
+ affectedTraces: Set<string>;
44
+ error: number;
45
+ latency: number;
46
+ dependency: number;
47
+ }
48
+ >();
49
+
50
+ for (const issue of issues) {
51
+ const entry = services.get(issue.serviceName) ?? {
52
+ issueCount: 0,
53
+ affectedTraces: new Set<string>(),
54
+ error: 0,
55
+ latency: 0,
56
+ dependency: 0,
57
+ };
58
+ entry.issueCount += 1;
59
+ for (const trace of issue.traces) {
60
+ entry.affectedTraces.add(trace.traceId);
61
+ }
62
+ entry[issue.category] += 1;
63
+ services.set(issue.serviceName, entry);
64
+ }
65
+
66
+ return {
67
+ summary: {
68
+ totalIssues: issues.length,
69
+ criticalIssues: issues.filter((issue) => issue.severity === "critical")
70
+ .length,
71
+ highIssues: issues.filter((issue) => issue.severity === "high").length,
72
+ affectedTraces: new Set(
73
+ issues.flatMap((issue) => issue.traces.map((trace) => trace.traceId)),
74
+ ).size,
75
+ errorIssues: issues.filter((issue) => issue.category === "error").length,
76
+ latencyIssues: issues.filter((issue) => issue.category === "latency")
77
+ .length,
78
+ dependencyIssues: issues.filter(
79
+ (issue) => issue.category === "dependency",
80
+ ).length,
81
+ },
82
+ services: Array.from(services.entries())
83
+ .map(([serviceName, entry]) => ({
84
+ serviceName,
85
+ issueCount: entry.issueCount,
86
+ affectedTraceCount: entry.affectedTraces.size,
87
+ errorIssueCount: entry.error,
88
+ latencyIssueCount: entry.latency,
89
+ dependencyIssueCount: entry.dependency,
90
+ }))
91
+ .sort(
92
+ (left, right) =>
93
+ right.issueCount - left.issueCount ||
94
+ left.serviceName.localeCompare(right.serviceName),
95
+ ),
96
+ issues: issues.map(
97
+ ({ traces: _t, culpritSpans: _c, ...summary }) => summary,
98
+ ),
99
+ windowHours: options.hours,
100
+ filters: {
101
+ service: options.service ?? "all",
102
+ category: options.category ?? "all",
103
+ includeInternal: options.includeInternal ?? false,
104
+ },
105
+ timestamp: new Date().toISOString(),
106
+ };
107
+ }
108
+
109
+ export async function getTelemetryIssueDetail(
110
+ db: SqlDb,
111
+ issueId: string,
112
+ options: TelemetryIssueOptions,
113
+ ): Promise<TelemetryIssueDetailResponse | null> {
114
+ if (!options.projectId)
115
+ throw new Error("TelemetryStore.getIssueDetail: projectId is required");
116
+ const cutoff = cutoffIso(options.hours);
117
+ const candidates = await selectTraceCandidates(db, {
118
+ projectId: options.projectId,
119
+ cutoff,
120
+ service: options.service,
121
+ limit: 500,
122
+ });
123
+ const spans = await fetchSpansForTraceIds(
124
+ db,
125
+ options.projectId,
126
+ candidates.map((candidate) => candidate.trace_id),
127
+ );
128
+
129
+ const issue = groupIssues(spans, options).find(
130
+ (candidate) => candidate.issueId === issueId,
131
+ );
132
+
133
+ if (!issue) return null;
134
+
135
+ return {
136
+ issue: {
137
+ issueId: issue.issueId,
138
+ category: issue.category,
139
+ severity: issue.severity,
140
+ title: issue.title,
141
+ serviceName: issue.serviceName,
142
+ routeLabel: issue.routeLabel,
143
+ occurrenceCount: issue.occurrenceCount,
144
+ affectedTraceCount: issue.affectedTraceCount,
145
+ firstSeen: issue.firstSeen,
146
+ lastSeen: issue.lastSeen,
147
+ latestStatusMessage: issue.latestStatusMessage,
148
+ culpritSpanName: issue.culpritSpanName,
149
+ dependencyTarget: issue.dependencyTarget,
150
+ averageDurationMs: issue.averageDurationMs,
151
+ maxDurationMs: issue.maxDurationMs,
152
+ sampleTraceId: issue.sampleTraceId,
153
+ },
154
+ traces: issue.traces,
155
+ culpritSpans: issue.culpritSpans,
156
+ timestamp: new Date().toISOString(),
157
+ };
158
+ }