@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,212 @@
1
+ /**
2
+ * OTLP wire format → StoredSpan conversion.
3
+ * Union of all repos: A's advanced value conversion, hex validation,
4
+ * service name fallback, safe BigInt handling.
5
+ */
6
+
7
+ import type {
8
+ JsonValue,
9
+ OtlpAnyValue,
10
+ OtlpKeyValue,
11
+ OtlpResourceSpans,
12
+ OtlpScopeSpans,
13
+ OtlpSpan,
14
+ OtlpTraceExportRequest,
15
+ StoredSpan,
16
+ } from "@obsunified/types";
17
+ import {
18
+ DEFAULT_WINDOW_HOURS,
19
+ getConfiguredRetentionHours,
20
+ } from "@obsunified/types/constants";
21
+
22
+ const EMPTY_ARRAY_JSON = "[]";
23
+ const EMPTY_OBJECT_JSON = "{}";
24
+
25
+ /** Recursive OTLP value → JSON (from A: handles arrayValue, kvlistValue) */
26
+ const anyValueToJson = (value?: OtlpAnyValue): JsonValue => {
27
+ if (!value) return null;
28
+ if (typeof value.stringValue === "string") return value.stringValue;
29
+ if (typeof value.boolValue === "boolean") return value.boolValue;
30
+ if (typeof value.doubleValue === "number") return value.doubleValue;
31
+ if (
32
+ typeof value.intValue === "string" ||
33
+ typeof value.intValue === "number"
34
+ ) {
35
+ const parsed = Number(value.intValue);
36
+ return Number.isFinite(parsed) ? parsed : String(value.intValue);
37
+ }
38
+ if (value.arrayValue?.values) {
39
+ return value.arrayValue.values.map((item) => anyValueToJson(item));
40
+ }
41
+ if (value.kvlistValue?.values) {
42
+ return keyValuesToRecord(value.kvlistValue.values);
43
+ }
44
+ return null;
45
+ };
46
+
47
+ const keyValuesToRecord = (
48
+ values?: OtlpKeyValue[],
49
+ ): Record<string, JsonValue> => {
50
+ if (!values?.length) return {};
51
+ return Object.fromEntries(
52
+ values.map((entry) => [entry.key, anyValueToJson(entry.value)]),
53
+ );
54
+ };
55
+
56
+ /** Validates hex string length (from A) */
57
+ const normalizeHex = (
58
+ value: string | undefined,
59
+ length: number,
60
+ ): string | null => {
61
+ if (!value) return null;
62
+ const normalized = value.trim().toLowerCase();
63
+ if (!/^[0-9a-f]+$/i.test(normalized) || normalized.length !== length)
64
+ return null;
65
+ return normalized;
66
+ };
67
+
68
+ /** Safe BigInt nanosecond → millisecond duration (from A) */
69
+ const nanosToMs = (start?: string, end?: string): number => {
70
+ try {
71
+ const startNs = BigInt(start ?? 0);
72
+ const endNs = BigInt(end ?? 0);
73
+ if (endNs < startNs) return 0;
74
+ return Number((endNs - startNs) / 1_000_000n);
75
+ } catch {
76
+ return 0;
77
+ }
78
+ };
79
+
80
+ /** Safe BigInt nanosecond → ISO string (from A) */
81
+ const nanosToIso = (value?: string, fallback?: Date): string => {
82
+ if (!value) return fallback?.toISOString() ?? new Date().toISOString();
83
+ try {
84
+ const ns = BigInt(value);
85
+ if (ns <= 0n) return fallback?.toISOString() ?? new Date().toISOString();
86
+ return new Date(Number(ns / 1_000_000n)).toISOString();
87
+ } catch {
88
+ return fallback?.toISOString() ?? new Date().toISOString();
89
+ }
90
+ };
91
+
92
+ const serializeJson = (value: unknown, fallback: string): string => {
93
+ try {
94
+ return JSON.stringify(value ?? JSON.parse(fallback));
95
+ } catch {
96
+ return fallback;
97
+ }
98
+ };
99
+
100
+ /** Service name with fallback to server.address (from A) */
101
+ const getServiceName = (
102
+ resourceAttributes: Record<string, JsonValue>,
103
+ spanAttributes: Record<string, JsonValue>,
104
+ ): string | null => {
105
+ const resourceName = resourceAttributes["service.name"];
106
+ if (typeof resourceName === "string" && resourceName.trim())
107
+ return resourceName.trim();
108
+ const serverAddress = spanAttributes["server.address"];
109
+ if (typeof serverAddress === "string" && serverAddress.trim())
110
+ return serverAddress.trim();
111
+ return null;
112
+ };
113
+
114
+ const scopeToStoredSpans = (
115
+ resourceSpans: OtlpResourceSpans,
116
+ scopeSpans: OtlpScopeSpans,
117
+ receivedAt: Date,
118
+ retentionHours: number,
119
+ projectId: string,
120
+ ): StoredSpan[] => {
121
+ const resourceAttributes = keyValuesToRecord(
122
+ resourceSpans.resource?.attributes,
123
+ );
124
+ const scopeName = scopeSpans.scope?.name ?? null;
125
+ const scopeVersion = scopeSpans.scope?.version ?? null;
126
+
127
+ return (scopeSpans.spans ?? []).flatMap((span: OtlpSpan): StoredSpan[] => {
128
+ const traceId = normalizeHex(span.traceId, 32);
129
+ const spanId = normalizeHex(span.spanId, 16);
130
+ if (!traceId || !spanId) return [];
131
+
132
+ const attributes = keyValuesToRecord(span.attributes);
133
+ const serviceName = getServiceName(resourceAttributes, attributes);
134
+
135
+ return [
136
+ {
137
+ projectId,
138
+ traceId,
139
+ spanId,
140
+ parentSpanId: normalizeHex(span.parentSpanId, 16),
141
+ traceState: span.traceState ?? null,
142
+ serviceName,
143
+ scopeName,
144
+ scopeVersion,
145
+ spanName: span.name || "unnamed-span",
146
+ spanKind: span.kind ?? 0,
147
+ statusCode: span.status?.code ?? 0,
148
+ statusMessage: span.status?.message ?? null,
149
+ startTime: nanosToIso(span.startTimeUnixNano, receivedAt),
150
+ endTime: nanosToIso(span.endTimeUnixNano, receivedAt),
151
+ durationMs: nanosToMs(span.startTimeUnixNano, span.endTimeUnixNano),
152
+ attributesJson: serializeJson(attributes, EMPTY_OBJECT_JSON),
153
+ droppedAttributesCount: span.droppedAttributesCount ?? 0,
154
+ resourceAttributesJson: serializeJson(
155
+ resourceAttributes,
156
+ EMPTY_OBJECT_JSON,
157
+ ),
158
+ eventsJson: serializeJson(
159
+ (span.events ?? []).map((event) => ({
160
+ name: event.name,
161
+ timeUnixNano: event.timeUnixNano ?? null,
162
+ attributes: keyValuesToRecord(event.attributes),
163
+ droppedAttributesCount: event.droppedAttributesCount ?? 0,
164
+ })),
165
+ EMPTY_ARRAY_JSON,
166
+ ),
167
+ droppedEventsCount: span.droppedEventsCount ?? 0,
168
+ linksJson: serializeJson(
169
+ (span.links ?? []).map((link) => ({
170
+ traceId: normalizeHex(link.traceId, 32),
171
+ spanId: normalizeHex(link.spanId, 16),
172
+ traceState: link.traceState ?? null,
173
+ attributes: keyValuesToRecord(link.attributes),
174
+ droppedAttributesCount: link.droppedAttributesCount ?? 0,
175
+ })),
176
+ EMPTY_ARRAY_JSON,
177
+ ),
178
+ droppedLinksCount: span.droppedLinksCount ?? 0,
179
+ receivedAt: receivedAt.toISOString(),
180
+ expiresAt: retentionExpiry(receivedAt, retentionHours),
181
+ },
182
+ ];
183
+ });
184
+ };
185
+
186
+ export const retentionExpiry = (
187
+ receivedAt: Date,
188
+ retentionHours = DEFAULT_WINDOW_HOURS,
189
+ ): string => {
190
+ const expiry = new Date(
191
+ receivedAt.getTime() + retentionHours * 60 * 60 * 1000,
192
+ );
193
+ return expiry.toISOString();
194
+ };
195
+
196
+ export const toStoredSpans = (
197
+ payload: OtlpTraceExportRequest,
198
+ projectId: string,
199
+ receivedAt = new Date(),
200
+ retentionHours = getConfiguredRetentionHours(undefined),
201
+ ): StoredSpan[] =>
202
+ (payload.resourceSpans ?? []).flatMap((resourceSpans) =>
203
+ (resourceSpans.scopeSpans ?? []).flatMap((scopeSpans) =>
204
+ scopeToStoredSpans(
205
+ resourceSpans,
206
+ scopeSpans,
207
+ receivedAt,
208
+ retentionHours,
209
+ projectId,
210
+ ),
211
+ ),
212
+ );
@@ -0,0 +1,64 @@
1
+ import type { PprofProfile } from "@obsunified/pprof-decoder";
2
+ import { describe, expect, it } from "vitest";
3
+ import { summarizeProfileFrames } from "./parse-pprof";
4
+
5
+ const tinyProfile = (): PprofProfile => {
6
+ const stringTable = [
7
+ "",
8
+ "cpu",
9
+ "nanoseconds",
10
+ "main.handle",
11
+ "src/main.ts",
12
+ "trace_id",
13
+ "abc123def4567890",
14
+ ];
15
+ const functions = new Map();
16
+ functions.set(1, { id: 1, nameIdx: 3, filenameIdx: 4 });
17
+ const locations = new Map();
18
+ locations.set(1, {
19
+ id: 1,
20
+ lines: [{ functionId: 1, line: 42 }],
21
+ functionIds: [1],
22
+ });
23
+ return {
24
+ sampleTypes: [{ typeIdx: 1, unitIdx: 2 }],
25
+ samples: [
26
+ {
27
+ locationIds: [1],
28
+ values: [500],
29
+ labels: [{ keyIdx: 5, strIdx: 6, num: 0 }],
30
+ },
31
+ ],
32
+ locations,
33
+ functions,
34
+ stringTable,
35
+ };
36
+ };
37
+
38
+ describe("summarizeProfileFrames", () => {
39
+ it("returns source-linked frame summaries scoped by trace id", () => {
40
+ const frames = summarizeProfileFrames(tinyProfile(), {
41
+ traceIdFilter: "abc123def4567890",
42
+ });
43
+
44
+ expect(frames).toEqual([
45
+ {
46
+ name: "main.handle",
47
+ value: 500,
48
+ sampleCount: 1,
49
+ codeReference: {
50
+ originalPath: "src/main.ts",
51
+ relativePath: "src/main.ts",
52
+ absolutePath: undefined,
53
+ lineNumber: 42,
54
+ symbol: "main.handle",
55
+ },
56
+ },
57
+ ]);
58
+ expect(
59
+ summarizeProfileFrames(tinyProfile(), {
60
+ traceIdFilter: "ffffffffffffffff",
61
+ }),
62
+ ).toEqual([]);
63
+ });
64
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Collector-side pprof helpers. The wire-format decoder/encoder lives in
3
+ * `@obsunified/pprof-decoder` and is shared with the dashboard's flame graph
4
+ * viewer; this file adds the server-specific concerns:
5
+ *
6
+ * - extractTraceIdsFromProfile — pulls distinct trace_id labels from a
7
+ * parsed profile so `profile-routes.ts` can populate
8
+ * `profile_trace_index` without trusting the `x-obs-trace-ids` header.
9
+ * - filterPprofByTraceId — drops samples that don't carry the requested
10
+ * trace_id label and re-emits a smaller gzipped pprof. Powers the
11
+ * Phase 4.5 server-side pre-filter for the trace waterfall's 🔥 link.
12
+ */
13
+
14
+ import {
15
+ decodePprof,
16
+ decodePprofBlob,
17
+ encodePprof,
18
+ gzipBytes,
19
+ type PprofFunction,
20
+ type PprofLabel,
21
+ type PprofLocation,
22
+ type PprofProfile,
23
+ type PprofSample,
24
+ type PprofValueType,
25
+ } from "@obsunified/pprof-decoder";
26
+
27
+ // Re-exports keep call sites (profile-routes, tests) importing from one
28
+ // place — they don't need to know whether something lives upstream.
29
+ export {
30
+ decodePprof,
31
+ decodePprofBlob,
32
+ encodePprof,
33
+ type PprofFunction,
34
+ type PprofLabel,
35
+ type PprofLocation,
36
+ type PprofProfile,
37
+ type PprofSample,
38
+ type PprofValueType,
39
+ };
40
+
41
+ const TRACE_ID_LABEL_KEYS = ["trace_id", "trace.id"];
42
+ const TRACE_ID_RE = /^[0-9a-f]{16,32}$/i;
43
+
44
+ export interface ProfileFrameSummary {
45
+ name: string;
46
+ value: number;
47
+ sampleCount: number;
48
+ codeReference?: {
49
+ originalPath: string;
50
+ relativePath?: string;
51
+ absolutePath?: string;
52
+ lineNumber?: number;
53
+ symbol?: string;
54
+ };
55
+ }
56
+
57
+ const sampleHasTraceId = (
58
+ profile: PprofProfile,
59
+ sample: PprofSample,
60
+ traceIdFilter: string | null,
61
+ ): boolean => {
62
+ if (!traceIdFilter) return true;
63
+ const want = traceIdFilter.toLowerCase();
64
+ const traceIdKeyIxs = new Set<number>();
65
+ for (let i = 0; i < profile.stringTable.length; i++) {
66
+ if (TRACE_ID_LABEL_KEYS.includes(profile.stringTable[i])) {
67
+ traceIdKeyIxs.add(i);
68
+ }
69
+ }
70
+ return sample.labels.some(
71
+ (label) =>
72
+ traceIdKeyIxs.has(label.keyIdx) &&
73
+ profile.stringTable[label.strIdx]?.toLowerCase() === want,
74
+ );
75
+ };
76
+
77
+ export const summarizeProfileFrames = (
78
+ profile: PprofProfile,
79
+ opts: { limit?: number; traceIdFilter?: string | null } = {},
80
+ ): ProfileFrameSummary[] => {
81
+ const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
82
+ const byFrame = new Map<string, ProfileFrameSummary>();
83
+
84
+ for (const sample of profile.samples) {
85
+ if (!sampleHasTraceId(profile, sample, opts.traceIdFilter ?? null))
86
+ continue;
87
+ const value = sample.values[0] ?? 0;
88
+ for (const locationId of sample.locationIds) {
89
+ const loc = profile.locations.get(locationId);
90
+ if (!loc) continue;
91
+ const lines =
92
+ loc.lines ||
93
+ (loc.functionIds || []).map((functionId) => ({ functionId, line: 0 }));
94
+ for (const line of lines) {
95
+ const fn = profile.functions.get(line.functionId);
96
+ if (!fn) continue;
97
+ const name = profile.stringTable[fn.nameIdx] ?? "?";
98
+ const filename = profile.stringTable[fn.filenameIdx] ?? "";
99
+ const key = `${name}\0${filename}\0${line.line || 0}`;
100
+ const existing = byFrame.get(key) ?? {
101
+ name,
102
+ value: 0,
103
+ sampleCount: 0,
104
+ codeReference: filename
105
+ ? {
106
+ originalPath: filename,
107
+ relativePath: filename.startsWith("/") ? undefined : filename,
108
+ absolutePath: filename.startsWith("/") ? filename : undefined,
109
+ lineNumber: line.line || undefined,
110
+ symbol: name,
111
+ }
112
+ : undefined,
113
+ };
114
+ existing.value += value;
115
+ existing.sampleCount += 1;
116
+ byFrame.set(key, existing);
117
+ }
118
+ }
119
+ }
120
+
121
+ return Array.from(byFrame.values())
122
+ .sort((a, b) => b.value - a.value)
123
+ .slice(0, limit);
124
+ };
125
+
126
+ /**
127
+ * Extract distinct trace_id label values from a parsed pprof. Returns
128
+ * an array of valid 16-32 hex-char trace_ids, deduplicated. Defensive
129
+ * against malformed labels — silently ignores anything that doesn't
130
+ * match the hex pattern (a malformed agent shouldn't pollute the index).
131
+ */
132
+ export const extractTraceIdsFromProfile = (profile: PprofProfile): string[] => {
133
+ const traceIdKeyIxs = new Set<number>();
134
+ for (let i = 0; i < profile.stringTable.length; i++) {
135
+ if (TRACE_ID_LABEL_KEYS.includes(profile.stringTable[i])) {
136
+ traceIdKeyIxs.add(i);
137
+ }
138
+ }
139
+ if (traceIdKeyIxs.size === 0) return [];
140
+
141
+ const traceIds = new Set<string>();
142
+ for (const sample of profile.samples) {
143
+ for (const label of sample.labels) {
144
+ if (!traceIdKeyIxs.has(label.keyIdx)) continue;
145
+ const value = profile.stringTable[label.strIdx];
146
+ if (value && TRACE_ID_RE.test(value)) {
147
+ traceIds.add(value.toLowerCase());
148
+ }
149
+ }
150
+ }
151
+ return [...traceIds];
152
+ };
153
+
154
+ /**
155
+ * Filter a pprof down to samples carrying the requested trace_id label
156
+ * and re-emit a gzipped blob. location/function/string tables are
157
+ * preserved verbatim — unused entries become dead weight in the output
158
+ * but the result is still valid pprof, and the wire savings come almost
159
+ * entirely from the sample list (~80% of a typical blob). Compacting
160
+ * the auxiliary tables is a Phase 4+ optimization.
161
+ */
162
+ export const filterPprofByTraceId = async (
163
+ profile: PprofProfile,
164
+ traceIdFilter: string,
165
+ ): Promise<Uint8Array> => {
166
+ const want = traceIdFilter.toLowerCase();
167
+ const traceIdKeyIxs = new Set<number>();
168
+ for (let i = 0; i < profile.stringTable.length; i++) {
169
+ if (TRACE_ID_LABEL_KEYS.includes(profile.stringTable[i])) {
170
+ traceIdKeyIxs.add(i);
171
+ }
172
+ }
173
+ const filtered: PprofProfile = {
174
+ ...profile,
175
+ samples: profile.samples.filter((s) =>
176
+ s.labels.some(
177
+ (l) =>
178
+ traceIdKeyIxs.has(l.keyIdx) &&
179
+ profile.stringTable[l.strIdx]?.toLowerCase() === want,
180
+ ),
181
+ ),
182
+ };
183
+ return gzipBytes(encodePprof(filtered));
184
+ };
@@ -0,0 +1,22 @@
1
+ import type { CollectorEnv } from "../framework/env";
2
+ import type { SqlDb } from "./sql-db";
3
+
4
+ export const isTruthyFlag = (value: unknown): boolean =>
5
+ value === true || value === 1 || value === "1" || value === "true";
6
+
7
+ export async function isPayloadCaptureEnabled(
8
+ db: SqlDb,
9
+ projectId: string,
10
+ env?: Partial<CollectorEnv>,
11
+ ): Promise<boolean> {
12
+ if (isTruthyFlag(env?.OBS_PAYLOAD_CAPTURE_DEFAULT)) return true;
13
+
14
+ const row = await db
15
+ .prepare(
16
+ `SELECT payload_capture_enabled FROM projects WHERE id = ? LIMIT 1`,
17
+ )
18
+ .bind(projectId)
19
+ .first<{ payload_capture_enabled: boolean | number | string | null }>();
20
+
21
+ return isTruthyFlag(row?.payload_capture_enabled);
22
+ }
@@ -0,0 +1,207 @@
1
+ import type {
2
+ IngestKey,
3
+ IngestKeyRow,
4
+ IngestKeyWithPlaintext,
5
+ Project,
6
+ ProjectRow,
7
+ } from "@obsunified/types";
8
+ import { randomHex, sha256Hex } from "./hash";
9
+ import type { SqlDb } from "./sql-db";
10
+
11
+ const rowToProject = (row: ProjectRow): Project => ({
12
+ id: row.id,
13
+ name: row.name,
14
+ slug: row.slug,
15
+ createdAt: row.created_at,
16
+ payloadCaptureEnabled:
17
+ row.payload_capture_enabled === true ||
18
+ row.payload_capture_enabled === 1 ||
19
+ row.payload_capture_enabled === "1" ||
20
+ row.payload_capture_enabled === "true",
21
+ });
22
+
23
+ const rowToKey = (row: IngestKeyRow): IngestKey => ({
24
+ id: row.id,
25
+ projectId: row.project_id,
26
+ name: row.name,
27
+ keyPrefix: row.key_prefix,
28
+ createdAt: row.created_at,
29
+ revokedAt: row.revoked_at,
30
+ });
31
+
32
+ const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$/;
33
+
34
+ export class ProjectsStore {
35
+ constructor(private readonly db: SqlDb) {}
36
+
37
+ async listProjects(): Promise<Project[]> {
38
+ const rs = await this.db
39
+ .prepare(`SELECT * FROM projects ORDER BY created_at ASC`)
40
+ .all<ProjectRow>();
41
+ return (rs.results ?? []).map(rowToProject);
42
+ }
43
+
44
+ async getProject(id: string): Promise<Project | null> {
45
+ const row = await this.db
46
+ .prepare(`SELECT * FROM projects WHERE id = ?`)
47
+ .bind(id)
48
+ .first<ProjectRow>();
49
+ return row ? rowToProject(row) : null;
50
+ }
51
+
52
+ async createProject(input: { name: string; slug: string }): Promise<Project> {
53
+ const name = input.name.trim();
54
+ const slug = input.slug.trim().toLowerCase();
55
+ if (!name) throw new Error("Project name is required");
56
+ if (!SLUG_PATTERN.test(slug))
57
+ throw new Error(
58
+ "Slug must be lowercase letters, digits, and hyphens (max 50 chars)",
59
+ );
60
+
61
+ const id = randomHex(16);
62
+ const createdAt = new Date().toISOString();
63
+
64
+ await this.db
65
+ .prepare(
66
+ `INSERT INTO projects (id, name, slug, created_at) VALUES (?, ?, ?, ?)`,
67
+ )
68
+ .bind(id, name, slug, createdAt)
69
+ .run();
70
+
71
+ return { id, name, slug, createdAt, payloadCaptureEnabled: false };
72
+ }
73
+
74
+ async updateProject(
75
+ id: string,
76
+ patch: { name?: string; payloadCaptureEnabled?: boolean },
77
+ ): Promise<Project | null> {
78
+ const existing = await this.getProject(id);
79
+ if (!existing) return null;
80
+
81
+ const name = patch.name?.trim();
82
+ let next = existing;
83
+ if (name !== undefined && name !== existing.name) {
84
+ await this.db
85
+ .prepare(`UPDATE projects SET name = ? WHERE id = ?`)
86
+ .bind(name, id)
87
+ .run();
88
+ next = { ...next, name };
89
+ }
90
+ if (
91
+ patch.payloadCaptureEnabled !== undefined &&
92
+ patch.payloadCaptureEnabled !== existing.payloadCaptureEnabled
93
+ ) {
94
+ await this.db
95
+ .prepare(`UPDATE projects SET payload_capture_enabled = ? WHERE id = ?`)
96
+ .bind(patch.payloadCaptureEnabled ? 1 : 0, id)
97
+ .run();
98
+ next = {
99
+ ...next,
100
+ payloadCaptureEnabled: patch.payloadCaptureEnabled,
101
+ };
102
+ }
103
+ return next;
104
+ }
105
+
106
+ async ensureDefaultProject(): Promise<void> {
107
+ await this.db
108
+ .prepare(
109
+ `INSERT OR IGNORE INTO projects (id, name, slug, created_at) VALUES ('default', 'Default', 'default', ?)`,
110
+ )
111
+ .bind(new Date().toISOString())
112
+ .run();
113
+ }
114
+
115
+ // ── Ingest keys ──
116
+
117
+ async listKeys(projectId: string): Promise<IngestKey[]> {
118
+ const rs = await this.db
119
+ .prepare(
120
+ `SELECT * FROM ingest_keys WHERE project_id = ? ORDER BY created_at DESC`,
121
+ )
122
+ .bind(projectId)
123
+ .all<IngestKeyRow>();
124
+ return (rs.results ?? []).map(rowToKey);
125
+ }
126
+
127
+ async createKey(
128
+ projectId: string,
129
+ name: string,
130
+ ): Promise<IngestKeyWithPlaintext> {
131
+ const project = await this.getProject(projectId);
132
+ if (!project) throw new Error("Project not found");
133
+
134
+ const trimmedName = name.trim() || "unnamed";
135
+ const id = randomHex(16);
136
+ const plaintext = `obs_${project.slug}_${randomHex(24)}`;
137
+ const keyHash = await sha256Hex(plaintext);
138
+ // Prefix: show enough to identify but not recover (first 16 chars).
139
+ const keyPrefix = plaintext.slice(0, 16);
140
+ const createdAt = new Date().toISOString();
141
+
142
+ await this.db
143
+ .prepare(
144
+ `INSERT INTO ingest_keys (id, project_id, key_hash, key_prefix, name, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
145
+ )
146
+ .bind(id, projectId, keyHash, keyPrefix, trimmedName, createdAt)
147
+ .run();
148
+
149
+ return {
150
+ id,
151
+ projectId,
152
+ name: trimmedName,
153
+ keyPrefix,
154
+ createdAt,
155
+ revokedAt: null,
156
+ key: plaintext,
157
+ warning: "This key will not be shown again. Store it somewhere safe.",
158
+ };
159
+ }
160
+
161
+ async revokeKey(keyId: string): Promise<boolean> {
162
+ const result = await this.db
163
+ .prepare(
164
+ `UPDATE ingest_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`,
165
+ )
166
+ .bind(new Date().toISOString(), keyId)
167
+ .run();
168
+ return (result.meta?.changes ?? 0) > 0;
169
+ }
170
+
171
+ async findByKeyHash(
172
+ keyHash: string,
173
+ ): Promise<{ projectId: string; keyId: string } | null> {
174
+ const row = await this.db
175
+ .prepare(
176
+ `SELECT id, project_id FROM ingest_keys WHERE key_hash = ? AND revoked_at IS NULL`,
177
+ )
178
+ .bind(keyHash)
179
+ .first<{ id: string; project_id: string }>();
180
+ return row ? { projectId: row.project_id, keyId: row.id } : null;
181
+ }
182
+
183
+ /**
184
+ * Idempotently register the legacy env INGEST_KEY as a bootstrap key on the
185
+ * default project, so existing deployments keep working without manual migration.
186
+ */
187
+ async bootstrapEnvKey(plaintext: string): Promise<void> {
188
+ if (!plaintext) return;
189
+ await this.ensureDefaultProject();
190
+ const keyHash = await sha256Hex(plaintext);
191
+ const existing = await this.db
192
+ .prepare(`SELECT id FROM ingest_keys WHERE key_hash = ?`)
193
+ .bind(keyHash)
194
+ .first<{ id: string }>();
195
+ if (existing) return;
196
+
197
+ const id = randomHex(16);
198
+ const keyPrefix = plaintext.slice(0, Math.min(16, plaintext.length));
199
+ const createdAt = new Date().toISOString();
200
+ await this.db
201
+ .prepare(
202
+ `INSERT INTO ingest_keys (id, project_id, key_hash, key_prefix, name, created_at) VALUES (?, 'default', ?, ?, 'bootstrap', ?)`,
203
+ )
204
+ .bind(id, keyHash, keyPrefix, createdAt)
205
+ .run();
206
+ }
207
+ }