@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,196 @@
1
+ /**
2
+ * RFC 0004 Phase 1.8 — propagation aggregator tests.
3
+ *
4
+ * Exercises the SQL the aggregator issues against a structural fake
5
+ * database. Validates:
6
+ * - the count queries pin to project_id + the hour cutoff
7
+ * - 8 metric points emerge (4 signals × 2 propagated values)
8
+ * - point identities are stable across runs (so the cron upserts series)
9
+ * - zero rows still produce points with value=0 (visible-empty-state)
10
+ */
11
+
12
+ import { describe, expect, it, vi } from "vitest";
13
+ import type { Logger } from "../framework/logger";
14
+ import { aggregatePropagationForProject } from "./propagation-metric";
15
+ import type { SqlDb } from "./sql-db";
16
+
17
+ interface FakeRow {
18
+ propagated: number;
19
+ missing: number;
20
+ }
21
+
22
+ class FakeDb {
23
+ calls: Array<{ sql: string; binds: unknown[] }> = [];
24
+ constructor(
25
+ private readonly counts: Record<string, FakeRow>,
26
+ private readonly seriesExisting = new Map<string, string>(),
27
+ ) {}
28
+
29
+ prepare(sql: string) {
30
+ const calls = this.calls;
31
+ const counts = this.counts;
32
+ const seriesExisting = this.seriesExisting;
33
+ let binds: unknown[] = [];
34
+ const stmt: {
35
+ bind: (...args: unknown[]) => typeof stmt;
36
+ first: <T>() => Promise<T | null>;
37
+ all: <T>() => Promise<{ results: T[] }>;
38
+ run: () => Promise<{ meta: { changes: number } }>;
39
+ } = {
40
+ bind: (...args: unknown[]) => {
41
+ binds = args;
42
+ return stmt;
43
+ },
44
+ first: async <T>() => {
45
+ calls.push({ sql, binds: [...binds] });
46
+ if (sql.includes("FROM telemetry_spans"))
47
+ return counts.telemetry_spans as T;
48
+ if (sql.includes("FROM logs")) return counts.logs as T;
49
+ if (sql.includes("FROM usage_events")) return counts.usage_events as T;
50
+ if (sql.includes("FROM ai_calls")) return counts.ai_calls as T;
51
+ return null;
52
+ },
53
+ all: async <T>() => {
54
+ calls.push({ sql, binds: [...binds] });
55
+ if (sql.includes("FROM metric_series")) {
56
+ // MetricsStore.resolveSeries SELECT — return any pre-seeded
57
+ // series so the aggregator reuses ids on repeat runs.
58
+ const identities = binds.slice(1) as string[];
59
+ const results = identities
60
+ .filter((id) => seriesExisting.has(id))
61
+ .map((id) => ({
62
+ id: seriesExisting.get(id),
63
+ identity: id,
64
+ }));
65
+ return { results: results as unknown as T[] };
66
+ }
67
+ return { results: [] };
68
+ },
69
+ run: async () => {
70
+ calls.push({ sql, binds: [...binds] });
71
+ return { meta: { changes: 1 } };
72
+ },
73
+ };
74
+ return stmt;
75
+ }
76
+
77
+ async batch(stmts: unknown[]) {
78
+ return stmts.map(() => ({ success: true, meta: { changes: 1 } }));
79
+ }
80
+ }
81
+
82
+ describe("aggregatePropagationForProject", () => {
83
+ it("queries 4 signal tables with project_id + hour cutoff", async () => {
84
+ const db = new FakeDb({
85
+ telemetry_spans: { propagated: 12, missing: 3 },
86
+ logs: { propagated: 8, missing: 0 },
87
+ usage_events: { propagated: 5, missing: 7 },
88
+ ai_calls: { propagated: 2, missing: 1 },
89
+ });
90
+ const now = new Date("2026-05-04T12:30:00.000Z");
91
+ await aggregatePropagationForProject(db as unknown as SqlDb, "p1", now);
92
+
93
+ const expectedCutoff = "2026-05-04T11:30:00.000Z";
94
+
95
+ const countCalls = db.calls.filter((c) =>
96
+ c.sql.includes("SUM(CASE WHEN interaction_id"),
97
+ );
98
+ expect(countCalls).toHaveLength(4);
99
+ for (const call of countCalls) {
100
+ expect(call.binds[0]).toBe("p1");
101
+ expect(call.binds[1]).toBe(expectedCutoff);
102
+ }
103
+ });
104
+
105
+ it("writes 8 metric points (4 signals × 2 propagated values)", async () => {
106
+ const db = new FakeDb({
107
+ telemetry_spans: { propagated: 12, missing: 3 },
108
+ logs: { propagated: 8, missing: 0 },
109
+ usage_events: { propagated: 5, missing: 7 },
110
+ ai_calls: { propagated: 2, missing: 1 },
111
+ });
112
+ const result = await aggregatePropagationForProject(
113
+ db as unknown as SqlDb,
114
+ "p1",
115
+ new Date(),
116
+ );
117
+ expect(result.pointsWritten).toBe(8);
118
+ });
119
+
120
+ it("includes zero-count points so the dashboard sees both buckets", async () => {
121
+ const db = new FakeDb({
122
+ telemetry_spans: { propagated: 0, missing: 0 },
123
+ logs: { propagated: 0, missing: 0 },
124
+ usage_events: { propagated: 0, missing: 0 },
125
+ ai_calls: { propagated: 0, missing: 0 },
126
+ });
127
+ const result = await aggregatePropagationForProject(
128
+ db as unknown as SqlDb,
129
+ "p1",
130
+ new Date(),
131
+ );
132
+ expect(result.pointsWritten).toBe(8);
133
+ });
134
+
135
+ it("treats null SUM as 0 (empty signal table edge case)", async () => {
136
+ const db = new FakeDb({
137
+ telemetry_spans: {
138
+ propagated: null,
139
+ missing: null,
140
+ } as unknown as FakeRow,
141
+ logs: { propagated: null, missing: null } as unknown as FakeRow,
142
+ usage_events: { propagated: null, missing: null } as unknown as FakeRow,
143
+ ai_calls: { propagated: null, missing: null } as unknown as FakeRow,
144
+ });
145
+ const result = await aggregatePropagationForProject(
146
+ db as unknown as SqlDb,
147
+ "p1",
148
+ new Date(),
149
+ );
150
+ expect(result.pointsWritten).toBe(8);
151
+ });
152
+
153
+ it("logs and continues when a single signal count fails", async () => {
154
+ // Make ONE signal throw on its count by using a db that throws for a
155
+ // specific table.
156
+ const baseDb = new FakeDb({
157
+ telemetry_spans: { propagated: 1, missing: 0 },
158
+ logs: { propagated: 1, missing: 0 },
159
+ usage_events: { propagated: 1, missing: 0 },
160
+ ai_calls: { propagated: 1, missing: 0 },
161
+ });
162
+ const originalPrepare = baseDb.prepare.bind(baseDb);
163
+ baseDb.prepare = (sql: string) => {
164
+ if (sql.includes("FROM logs")) {
165
+ return {
166
+ bind: () => ({
167
+ first: async () => {
168
+ throw new Error("boom");
169
+ },
170
+ all: async () => ({ results: [] }),
171
+ run: async () => ({ meta: { changes: 0 } }),
172
+ }),
173
+ } as unknown as ReturnType<typeof originalPrepare>;
174
+ }
175
+ return originalPrepare(sql);
176
+ };
177
+ const logger = {
178
+ error: vi.fn(),
179
+ info: vi.fn(),
180
+ warn: vi.fn(),
181
+ debug: vi.fn(),
182
+ };
183
+ const result = await aggregatePropagationForProject(
184
+ baseDb as unknown as SqlDb,
185
+ "p1",
186
+ new Date(),
187
+ logger as Logger,
188
+ );
189
+ // Three signals succeed (×2 points each) — logs throws, so 6 points.
190
+ expect(result.pointsWritten).toBe(6);
191
+ expect(logger.error).toHaveBeenCalledWith(
192
+ "[propagation-metric] count failed",
193
+ expect.objectContaining({ signal: "log", projectId: "p1" }),
194
+ );
195
+ });
196
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * RFC 0004 Phase 1.8 — interaction_id propagation metric.
3
+ *
4
+ * Hourly aggregator: counts how many records in each signal table for
5
+ * each project carry an interaction_id versus null over the last hour.
6
+ * Writes 8 metric points per project per hour to metric_point as the
7
+ * standard OTel counter `obs.interaction.propagation` with attributes
8
+ * `(signal, propagated)`. The Health dashboard's tile (Phase 1.10
9
+ * follow-up or RFC 0006 Connected rail consumer) reads these series and
10
+ * surfaces the ratio per signal.
11
+ *
12
+ * Why aggregate via cron rather than emit per-request:
13
+ * - The signal tables already have the data; a hourly count is two
14
+ * indexed COUNT(*) queries per signal — sub-ms.
15
+ * - Per-request emission adds a ~2x write amplification across every
16
+ * ingest path. The metric is operational, not analytical, and an
17
+ * hourly cadence is enough to surface drift.
18
+ *
19
+ * Series identity is stable so the cron upserts the same series rows
20
+ * each run rather than creating new ones.
21
+ */
22
+
23
+ import type { Logger } from "../framework/logger";
24
+ import type { DecodedMetricPoint } from "../otlp/decode";
25
+ import { MetricsStore } from "./metrics-store";
26
+ import type { SqlDb } from "./sql-db";
27
+
28
+ const PROPAGATION_METRIC_NAME = "obs.interaction.propagation";
29
+
30
+ type Signal = "span" | "log" | "usage" | "ai_call";
31
+
32
+ interface SignalQuery {
33
+ signal: Signal;
34
+ table: string;
35
+ }
36
+
37
+ const SIGNALS: SignalQuery[] = [
38
+ { signal: "span", table: "telemetry_spans" },
39
+ { signal: "log", table: "logs" },
40
+ { signal: "usage", table: "usage_events" },
41
+ { signal: "ai_call", table: "ai_calls" },
42
+ ];
43
+
44
+ /**
45
+ * Stable identity string for the (name, attributes) tuple. Mirrors the
46
+ * format MetricsStore writes for OTLP-ingested points so a query joining
47
+ * on metric_series.identity gets a consistent result regardless of
48
+ * source.
49
+ */
50
+ const propagationIdentity = (signal: Signal, propagated: boolean): string =>
51
+ `${PROPAGATION_METRIC_NAME}|signal=${signal}|propagated=${propagated}`;
52
+
53
+ const buildPoint = (
54
+ signal: Signal,
55
+ propagated: boolean,
56
+ count: number,
57
+ tsNs: string,
58
+ ): DecodedMetricPoint => ({
59
+ name: PROPAGATION_METRIC_NAME,
60
+ description:
61
+ "Count of signal records ingested in the last hour, partitioned by whether they carry interaction_id (RFC 0004).",
62
+ unit: "{records}",
63
+ type: "sum",
64
+ isMonotonic: false, // delta over a one-hour window, not cumulative
65
+ temporality: 1, // DELTA
66
+ scopeName: "obs.collector",
67
+ scopeVersion: "1",
68
+ serviceName: "obs-collector",
69
+ resourceAttrsJson: null,
70
+ attributesJson: JSON.stringify({ signal, propagated }),
71
+ identity: propagationIdentity(signal, propagated),
72
+ tsNs,
73
+ startTsNs: null,
74
+ value: count,
75
+ count: null,
76
+ sum: null,
77
+ min: null,
78
+ max: null,
79
+ boundsJson: null,
80
+ bucketCountsJson: null,
81
+ extraJson: null,
82
+ exemplarsJson: null,
83
+ });
84
+
85
+ const countsForSignal = async (
86
+ db: SqlDb,
87
+ projectId: string,
88
+ table: string,
89
+ hourCutoffIso: string,
90
+ ): Promise<{ propagated: number; missing: number }> => {
91
+ const row = await db
92
+ .prepare(
93
+ `SELECT
94
+ SUM(CASE WHEN interaction_id IS NOT NULL THEN 1 ELSE 0 END) AS propagated,
95
+ SUM(CASE WHEN interaction_id IS NULL THEN 1 ELSE 0 END) AS missing
96
+ FROM ${table}
97
+ WHERE project_id = ? AND received_at >= ?`,
98
+ )
99
+ .bind(projectId, hourCutoffIso)
100
+ .first<{ propagated: number | null; missing: number | null }>();
101
+ return {
102
+ propagated: row?.propagated ?? 0,
103
+ missing: row?.missing ?? 0,
104
+ };
105
+ };
106
+
107
+ /**
108
+ * Aggregate propagation counts for a single project and write them as
109
+ * metric points. Idempotent per hour-bucket (each run produces a new
110
+ * point; series identity stays stable).
111
+ */
112
+ export const aggregatePropagationForProject = async (
113
+ db: SqlDb,
114
+ projectId: string,
115
+ now: Date,
116
+ logger?: Logger,
117
+ ): Promise<{ pointsWritten: number }> => {
118
+ const hourCutoff = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
119
+ const tsNs = (BigInt(now.getTime()) * 1_000_000n).toString();
120
+ const points: DecodedMetricPoint[] = [];
121
+
122
+ for (const { signal, table } of SIGNALS) {
123
+ try {
124
+ const { propagated, missing } = await countsForSignal(
125
+ db,
126
+ projectId,
127
+ table,
128
+ hourCutoff,
129
+ );
130
+ points.push(buildPoint(signal, true, propagated, tsNs));
131
+ points.push(buildPoint(signal, false, missing, tsNs));
132
+ } catch (err) {
133
+ logger?.error("[propagation-metric] count failed", {
134
+ signal,
135
+ table,
136
+ projectId,
137
+ error: err instanceof Error ? err.message : String(err),
138
+ });
139
+ }
140
+ }
141
+
142
+ if (points.length === 0) return { pointsWritten: 0 };
143
+
144
+ const store = new MetricsStore(db);
145
+ await store.ingestBatch({
146
+ projectId,
147
+ points,
148
+ receivedAt: now.toISOString(),
149
+ // Keep propagation metrics on the same retention as everything else.
150
+ expiresAt: new Date(now.getTime() + 72 * 60 * 60 * 1000).toISOString(),
151
+ });
152
+
153
+ return { pointsWritten: points.length };
154
+ };
155
+
156
+ /**
157
+ * Run the aggregation across all known projects. Designed for a once-per-hour
158
+ * cron handler; cheap enough to run alongside retention cleanup.
159
+ */
160
+ export const aggregatePropagation = async (
161
+ db: SqlDb,
162
+ now: Date,
163
+ logger?: Logger,
164
+ ): Promise<{ projects: number; pointsWritten: number }> => {
165
+ const projects = await db
166
+ .prepare("SELECT id FROM projects")
167
+ .all<{ id: string }>();
168
+
169
+ let pointsWritten = 0;
170
+ for (const p of projects.results) {
171
+ const r = await aggregatePropagationForProject(db, p.id, now, logger);
172
+ pointsWritten += r.pointsWritten;
173
+ }
174
+ return { projects: projects.results.length, pointsWritten };
175
+ };
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { postgresDialect, sqliteDialect } from "./sql-db";
3
+
4
+ describe("SqlDialect", () => {
5
+ it("renders native SQLite expressions", () => {
6
+ expect(sqliteDialect.sinceHours("?")).toBe(
7
+ "datetime('now', '-' || ? || ' hours')",
8
+ );
9
+ expect(
10
+ sqliteDialect.jsonText("attributes_json", '$."llm.cost.total_usd"'),
11
+ ).toBe(`json_extract(attributes_json, '$."llm.cost.total_usd"')`);
12
+ });
13
+
14
+ it("renders native Postgres expressions without adapter regex translation", () => {
15
+ expect(postgresDialect.sinceHours("$1")).toBe(
16
+ "(CURRENT_TIMESTAMP - ($1::text || ' hours')::interval)",
17
+ );
18
+ expect(
19
+ postgresDialect.jsonText("attributes_json", '$."llm.cost.total_usd"'),
20
+ ).toBe("(attributes_json::jsonb ->> 'llm.cost.total_usd')");
21
+ });
22
+ });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Postgres adapter for the SqlDb seam (RFC 0008).
3
+ *
4
+ * Wraps `pg` (node-postgres) to expose the same `prepare → bind → first |
5
+ * all | run` chain as `D1Adapter`. Used by the Docker / standalone Node
6
+ * deployment of `apps/collector`; the Workers deployment continues to
7
+ * use `D1Adapter`.
8
+ *
9
+ * Notable differences from D1 handled here:
10
+ * - Placeholders: D1 uses `?`; Postgres uses `$1`, `$2`. We rewrite
11
+ * `?` to `$N` in `prepare`. Statements MUST use `?` for portability.
12
+ * - `INSERT … RETURNING`: D1 doesn't support RETURNING; Postgres does.
13
+ * Code that targets both MUST NOT rely on RETURNING.
14
+ * - `TEXT vs VARCHAR`: D1 is permissive; we use TEXT exclusively in
15
+ * the postgres migrations.
16
+ * - `meta.changes`: D1 reports affected row count via meta.changes;
17
+ * Postgres reports it via `result.rowCount`. We normalize.
18
+ *
19
+ * Connection pooling is the caller's job — pass a `pg.Pool`, not a
20
+ * one-shot Client. Each `prepare` checks out, runs the statement, and
21
+ * releases. Long-lived transactions need a different surface (not yet
22
+ * exposed; collector code today does not require them).
23
+ */
24
+
25
+ import type { Pool, QueryResultRow } from "pg";
26
+ import { postgresDialect, type SqlDb, type SqlStatement } from "./sql-db";
27
+
28
+ export interface PostgresAdapterOptions {
29
+ /**
30
+ * Statement-level timeout in milliseconds. Defaults to 30s. Issued
31
+ * via `SET statement_timeout` at the start of each query (session-level;
32
+ * `SET LOCAL` outside a transaction would be a no-op).
33
+ */
34
+ statementTimeoutMs?: number;
35
+ }
36
+
37
+ export class PostgresAdapter implements SqlDb {
38
+ readonly dialect = postgresDialect;
39
+ private readonly statementTimeoutMs: number;
40
+
41
+ constructor(
42
+ private readonly pool: Pool,
43
+ options: PostgresAdapterOptions = {},
44
+ ) {
45
+ const rawTimeout = options.statementTimeoutMs ?? 30_000;
46
+ this.statementTimeoutMs =
47
+ Number.isFinite(rawTimeout) && rawTimeout >= 0
48
+ ? Math.trunc(rawTimeout)
49
+ : 30_000;
50
+
51
+ // Configure statement timeout once upon connection establishment
52
+ this.pool.on("connect", (client) => {
53
+ client
54
+ .query(`SET statement_timeout = ${this.statementTimeoutMs}`)
55
+ .catch(() => {});
56
+ });
57
+ }
58
+
59
+ prepare(sql: string): SqlStatement {
60
+ return new PostgresStatement(
61
+ this.pool,
62
+ translateD1Sql(rewriteQuestionMarks(sql)),
63
+ );
64
+ }
65
+
66
+ async batch(
67
+ statements: SqlStatement[],
68
+ ): Promise<Array<{ meta: { changes: number } }>> {
69
+ const client = await this.pool.connect();
70
+ try {
71
+ await client.query("BEGIN");
72
+ // SET LOCAL works here because it is inside the transaction.
73
+ await client.query(
74
+ `SET LOCAL statement_timeout = ${this.statementTimeoutMs}`,
75
+ );
76
+ const out: Array<{ meta: { changes: number } }> = [];
77
+ for (const s of statements) {
78
+ if (!(s instanceof PostgresStatement)) {
79
+ throw new Error(
80
+ "PostgresAdapter.batch: statement was not produced by PostgresAdapter",
81
+ );
82
+ }
83
+ const r = await s.runOnClient(client);
84
+ out.push(r);
85
+ }
86
+ await client.query("COMMIT");
87
+ return out;
88
+ } catch (err) {
89
+ await client.query("ROLLBACK").catch(() => {});
90
+ throw err;
91
+ } finally {
92
+ client.release();
93
+ }
94
+ }
95
+ }
96
+
97
+ class PostgresStatement implements SqlStatement {
98
+ private bound: unknown[] = [];
99
+
100
+ constructor(
101
+ private readonly pool: Pool,
102
+ private readonly sql: string,
103
+ ) {}
104
+
105
+ bind(...args: unknown[]): SqlStatement {
106
+ this.bound = args;
107
+ return this;
108
+ }
109
+
110
+ async first<T = Record<string, unknown>>(): Promise<T | null> {
111
+ const result = await this.exec<T & QueryResultRow>();
112
+ return (result.rows[0] as T | undefined) ?? null;
113
+ }
114
+
115
+ async all<T = Record<string, unknown>>(): Promise<{ results: T[] }> {
116
+ const result = await this.exec<T & QueryResultRow>();
117
+ return { results: result.rows as unknown as T[] };
118
+ }
119
+
120
+ async run(): Promise<{ meta: { changes: number } }> {
121
+ const result = await this.exec();
122
+ return { meta: { changes: result.rowCount ?? 0 } };
123
+ }
124
+
125
+ // Internal — used by batch() to run inside an already-checked-out
126
+ // client (so the BEGIN/COMMIT span the whole batch).
127
+ async runOnClient(client: {
128
+ query: (
129
+ text: string,
130
+ values?: unknown[],
131
+ ) => Promise<{ rowCount: number | null }>;
132
+ }): Promise<{ meta: { changes: number } }> {
133
+ const r = await client.query(this.sql, this.bound);
134
+ return { meta: { changes: r.rowCount ?? 0 } };
135
+ }
136
+
137
+ private async exec<T extends QueryResultRow = QueryResultRow>(): Promise<{
138
+ rows: T[];
139
+ rowCount: number | null;
140
+ }> {
141
+ const client = await this.pool.connect();
142
+ try {
143
+ const r = await client.query<T>(this.sql, this.bound);
144
+ return { rows: r.rows, rowCount: r.rowCount };
145
+ } finally {
146
+ client.release();
147
+ }
148
+ }
149
+ }
150
+
151
+ // D1 → Postgres placeholder rewrite. Walks the string once; tracks
152
+ // whether we're inside a single-quoted string literal so that '?' inside
153
+ // strings isn't rewritten. Doesn't handle dollar-quoted strings — none
154
+ // of the collector's SQL uses them.
155
+ const rewriteQuestionMarks = (sql: string): string => {
156
+ let out = "";
157
+ let inString = false;
158
+ let n = 0;
159
+ for (let i = 0; i < sql.length; i++) {
160
+ const ch = sql[i];
161
+ if (ch === "'") {
162
+ inString = !inString;
163
+ out += ch;
164
+ continue;
165
+ }
166
+ if (ch === "?" && !inString) {
167
+ n += 1;
168
+ out += `$${n}`;
169
+ continue;
170
+ }
171
+ out += ch;
172
+ }
173
+ return out;
174
+ };
175
+
176
+ const translateD1Sql = (sql: string): string => {
177
+ let out = sql;
178
+ const hadInsertOrIgnore = /\bINSERT\s+OR\s+IGNORE\s+INTO\b/i.test(out);
179
+
180
+ out = out.replace(/\bINSERT\s+OR\s+IGNORE\s+INTO\b/gi, "INSERT INTO");
181
+
182
+ out = out.replace(
183
+ /datetime\('now',\s*'-'\s*\|\|\s*(\$\d+)\s*\|\|\s*'\s*hours'\)/gi,
184
+ "(CURRENT_TIMESTAMP - ($1::text || ' hours')::interval)",
185
+ );
186
+ out = out.replace(
187
+ /datetime\('now',\s*'-'\s*\|\|\s*(\$\d+)\s*\|\|\s*'\s*minutes'\)/gi,
188
+ "(CURRENT_TIMESTAMP - ($1::text || ' minutes')::interval)",
189
+ );
190
+ out = out.replace(
191
+ /datetime\('now',\s*'-(\d+)\s+(hour|hours|minute|minutes|day|days)'\)/gi,
192
+ "CURRENT_TIMESTAMP - INTERVAL '$1 $2'",
193
+ );
194
+ out = out.replace(/datetime\('now'\)/gi, "CURRENT_TIMESTAMP");
195
+
196
+ out = out.replace(
197
+ /json_extract\(([^,()]+),\s*'\$\.(?:"([^"]+)"|([A-Za-z0-9_\\.\\\\]+))'\)/gi,
198
+ (
199
+ _match,
200
+ expr: string,
201
+ quotedKey: string | undefined,
202
+ bareKey: string | undefined,
203
+ ) => {
204
+ const key = (quotedKey ?? bareKey ?? "").replace(
205
+ /\\\\u002E|\\u002E/g,
206
+ ".",
207
+ );
208
+ return `(${expr.trim()}::jsonb ->> '${key.replace(/'/g, "''")}')`;
209
+ },
210
+ );
211
+
212
+ out = out.replace(
213
+ /strftime\('%Y-%m-%dT%H:%M:00Z',\s*([^)]+)\)/gi,
214
+ "to_char(date_trunc('minute', $1::timestamp), 'YYYY-MM-DD\"T\"HH24:MI:00\"Z\"')",
215
+ );
216
+ out = out.replace(
217
+ /strftime\('%Y-%m-%dT%H:00:00Z',\s*([^)]+)\)/gi,
218
+ "to_char(date_trunc('hour', $1::timestamp), 'YYYY-MM-DD\"T\"HH24:00:00\"Z\"')",
219
+ );
220
+ out = out.replace(
221
+ /strftime\('%s',\s*([^)]+)\)/gi,
222
+ "EXTRACT(EPOCH FROM $1::timestamp)",
223
+ );
224
+ if (hadInsertOrIgnore && /\bON\s+CONFLICT\b/i.test(out) === false) {
225
+ out = out.replace(/(;?\s*)$/, " ON CONFLICT DO NOTHING$1");
226
+ }
227
+
228
+ return out;
229
+ };