@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,312 @@
1
+ /**
2
+ * Metrics storage.
3
+ *
4
+ * Each ingest collapses points to their series identity, upserts the series
5
+ * row, then batch-inserts points. Series upsert is per-unique-identity per
6
+ * request — at small batch sizes this is cheap; a followup can switch to a
7
+ * single SELECT-many + batched INSERT-missing when volume demands it.
8
+ */
9
+
10
+ import type { DecodedMetricPoint } from "../otlp/decode";
11
+ import { dialectFor, type SqlDb, type SqlStatement } from "./sql-db";
12
+
13
+ interface SeriesRow {
14
+ id: string;
15
+ identity: string;
16
+ }
17
+
18
+ interface StoredExemplar {
19
+ value: number;
20
+ traceId: string | null;
21
+ spanId: string | null;
22
+ tsNs: string;
23
+ }
24
+
25
+ export interface MetricExemplarPoint {
26
+ id: string;
27
+ pointId: string;
28
+ seriesId: string;
29
+ metricName: string;
30
+ serviceName: string | null;
31
+ traceId: string | null;
32
+ spanId: string | null;
33
+ tsNs: string;
34
+ value: number;
35
+ receivedAt: string;
36
+ }
37
+
38
+ const parseExemplars = (raw: string | null): StoredExemplar[] => {
39
+ if (!raw) return [];
40
+ try {
41
+ const parsed = JSON.parse(raw);
42
+ if (!Array.isArray(parsed)) return [];
43
+ return parsed.filter(
44
+ (exemplar): exemplar is StoredExemplar =>
45
+ typeof exemplar === "object" &&
46
+ exemplar !== null &&
47
+ typeof exemplar.value === "number" &&
48
+ Number.isFinite(exemplar.value) &&
49
+ typeof exemplar.tsNs === "string" &&
50
+ (exemplar.traceId === null || typeof exemplar.traceId === "string") &&
51
+ (exemplar.spanId === null || typeof exemplar.spanId === "string"),
52
+ );
53
+ } catch {
54
+ return [];
55
+ }
56
+ };
57
+
58
+ export class MetricsStore {
59
+ constructor(private readonly db: SqlDb) {}
60
+
61
+ async purgeExpired(): Promise<number> {
62
+ const dialect = dialectFor(this.db);
63
+ const [exemplarResult, pointResult] = await this.db.batch([
64
+ this.db.prepare(
65
+ `DELETE FROM metric_exemplars WHERE expires_at < ${dialect.now()}`,
66
+ ),
67
+ this.db.prepare(
68
+ `DELETE FROM metric_point WHERE expires_at < ${dialect.now()}`,
69
+ ),
70
+ ]);
71
+ return (
72
+ (exemplarResult?.meta.changes ?? 0) + (pointResult?.meta.changes ?? 0)
73
+ );
74
+ }
75
+
76
+ async ingestBatch(opts: {
77
+ projectId: string;
78
+ points: DecodedMetricPoint[];
79
+ receivedAt: string;
80
+ expiresAt: string;
81
+ }): Promise<void> {
82
+ const { projectId, points, receivedAt, expiresAt } = opts;
83
+ if (points.length === 0) return;
84
+
85
+ const seriesByIdentity = await this.resolveSeries(projectId, points);
86
+
87
+ const pointStmt = this.db.prepare(`
88
+ INSERT INTO metric_point (
89
+ id, series_id, project_id, ts_ns, start_ts_ns,
90
+ value, count, sum, min, max, bounds_json, bucket_counts_json,
91
+ extra_json, exemplars_json, received_at, expires_at
92
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
93
+ `);
94
+ const exemplarStmt = this.db.prepare(`
95
+ INSERT INTO metric_exemplars (
96
+ id, point_id, series_id, project_id, metric_name, service_name,
97
+ trace_id, span_id, ts_ns, value, received_at, expires_at
98
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
99
+ `);
100
+
101
+ const batch: SqlStatement[] = [];
102
+ for (const p of points) {
103
+ const series = seriesByIdentity.get(p.identity);
104
+ if (!series) throw new Error(`series not resolved: ${p.identity}`);
105
+ const pointId = crypto.randomUUID();
106
+ batch.push(
107
+ pointStmt.bind(
108
+ pointId,
109
+ series.id,
110
+ projectId,
111
+ p.tsNs,
112
+ p.startTsNs,
113
+ p.value,
114
+ p.count,
115
+ p.sum,
116
+ p.min,
117
+ p.max,
118
+ p.boundsJson,
119
+ p.bucketCountsJson,
120
+ p.extraJson,
121
+ p.exemplarsJson,
122
+ receivedAt,
123
+ expiresAt,
124
+ ),
125
+ );
126
+ for (const exemplar of parseExemplars(p.exemplarsJson)) {
127
+ if (!exemplar.traceId && !exemplar.spanId) continue;
128
+ batch.push(
129
+ exemplarStmt.bind(
130
+ crypto.randomUUID(),
131
+ pointId,
132
+ series.id,
133
+ projectId,
134
+ p.name,
135
+ p.serviceName,
136
+ exemplar.traceId,
137
+ exemplar.spanId,
138
+ exemplar.tsNs,
139
+ exemplar.value,
140
+ receivedAt,
141
+ expiresAt,
142
+ ),
143
+ );
144
+ }
145
+ }
146
+
147
+ await this.db.batch(batch);
148
+ }
149
+
150
+ async exemplarsForTrace(
151
+ projectId: string,
152
+ traceId: string,
153
+ limit = 50,
154
+ ): Promise<MetricExemplarPoint[]> {
155
+ const rows = await this.db
156
+ .prepare(
157
+ `SELECT id, point_id, series_id, metric_name, service_name,
158
+ trace_id, span_id, ts_ns, value, received_at
159
+ FROM metric_exemplars
160
+ WHERE project_id = ? AND trace_id = ?
161
+ ORDER BY ts_ns DESC LIMIT ?`,
162
+ )
163
+ .bind(projectId, traceId, limit)
164
+ .all<{
165
+ id: string;
166
+ point_id: string;
167
+ series_id: string;
168
+ metric_name: string;
169
+ service_name: string | null;
170
+ trace_id: string | null;
171
+ span_id: string | null;
172
+ ts_ns: string;
173
+ value: number;
174
+ received_at: string;
175
+ }>();
176
+ return rows.results.map((row) => ({
177
+ id: row.id,
178
+ pointId: row.point_id,
179
+ seriesId: row.series_id,
180
+ metricName: row.metric_name,
181
+ serviceName: row.service_name,
182
+ traceId: row.trace_id,
183
+ spanId: row.span_id,
184
+ tsNs: row.ts_ns,
185
+ value: row.value,
186
+ receivedAt: row.received_at,
187
+ }));
188
+ }
189
+
190
+ async recentExemplars(opts: {
191
+ projectId: string;
192
+ serviceName?: string | null;
193
+ metricPrefix?: string | null;
194
+ limit?: number;
195
+ }): Promise<MetricExemplarPoint[]> {
196
+ const limit = Math.max(1, Math.min(opts.limit ?? 20, 100));
197
+ const where = ["project_id = ?"];
198
+ const binds: unknown[] = [opts.projectId];
199
+ if (opts.serviceName) {
200
+ where.push("service_name = ?");
201
+ binds.push(opts.serviceName);
202
+ }
203
+ if (opts.metricPrefix) {
204
+ where.push("metric_name LIKE ?");
205
+ binds.push(`${opts.metricPrefix}%`);
206
+ }
207
+ const rows = await this.db
208
+ .prepare(
209
+ `SELECT id, point_id, series_id, metric_name, service_name,
210
+ trace_id, span_id, ts_ns, value, received_at
211
+ FROM metric_exemplars
212
+ WHERE ${where.join(" AND ")}
213
+ ORDER BY received_at DESC, ts_ns DESC
214
+ LIMIT ?`,
215
+ )
216
+ .bind(...binds, limit)
217
+ .all<{
218
+ id: string;
219
+ point_id: string;
220
+ series_id: string;
221
+ metric_name: string;
222
+ service_name: string | null;
223
+ trace_id: string | null;
224
+ span_id: string | null;
225
+ ts_ns: string;
226
+ value: number;
227
+ received_at: string;
228
+ }>();
229
+ return rows.results.map((row) => ({
230
+ id: row.id,
231
+ pointId: row.point_id,
232
+ seriesId: row.series_id,
233
+ metricName: row.metric_name,
234
+ serviceName: row.service_name,
235
+ traceId: row.trace_id,
236
+ spanId: row.span_id,
237
+ tsNs: row.ts_ns,
238
+ value: row.value,
239
+ receivedAt: row.received_at,
240
+ }));
241
+ }
242
+
243
+ /**
244
+ * Ensures a `metric_series` row exists for every unique identity in the
245
+ * batch. Returns a map of identity → series row for point linking.
246
+ */
247
+ private async resolveSeries(
248
+ projectId: string,
249
+ points: DecodedMetricPoint[],
250
+ ): Promise<Map<string, SeriesRow>> {
251
+ const byIdentity = new Map<string, DecodedMetricPoint>();
252
+ for (const p of points) {
253
+ if (!byIdentity.has(p.identity)) byIdentity.set(p.identity, p);
254
+ }
255
+ const identities = Array.from(byIdentity.keys());
256
+
257
+ // Lookup existing series in one query (chunked to stay under D1's
258
+ // ~100-param limit per statement).
259
+ const existing = new Map<string, SeriesRow>();
260
+ for (let i = 0; i < identities.length; i += 50) {
261
+ const chunk = identities.slice(i, i + 50);
262
+ const placeholders = chunk.map(() => "?").join(",");
263
+ const rows = await this.db
264
+ .prepare(
265
+ `SELECT id, identity FROM metric_series
266
+ WHERE project_id = ? AND identity IN (${placeholders})`,
267
+ )
268
+ .bind(projectId, ...chunk)
269
+ .all<SeriesRow>();
270
+ for (const row of rows.results) {
271
+ existing.set(row.identity, row);
272
+ }
273
+ }
274
+
275
+ // Insert missing series.
276
+ const insertStmt = this.db.prepare(`
277
+ INSERT INTO metric_series (
278
+ id, project_id, name, description, unit, type,
279
+ is_monotonic, temporality, scope_name, scope_version, service_name,
280
+ resource_attrs_json, attributes_json, identity, created_at
281
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
282
+ `);
283
+ const toInsert: SqlStatement[] = [];
284
+ const createdAt = new Date().toISOString();
285
+ for (const [identity, p] of byIdentity.entries()) {
286
+ if (existing.has(identity)) continue;
287
+ const id = crypto.randomUUID();
288
+ existing.set(identity, { id, identity });
289
+ toInsert.push(
290
+ insertStmt.bind(
291
+ id,
292
+ projectId,
293
+ p.name,
294
+ p.description,
295
+ p.unit,
296
+ p.type,
297
+ p.isMonotonic === null ? null : p.isMonotonic ? 1 : 0,
298
+ p.temporality,
299
+ p.scopeName,
300
+ p.scopeVersion,
301
+ p.serviceName,
302
+ p.resourceAttrsJson,
303
+ p.attributesJson,
304
+ identity,
305
+ createdAt,
306
+ ),
307
+ );
308
+ }
309
+ if (toInsert.length) await this.db.batch(toInsert);
310
+ return existing;
311
+ }
312
+ }
@@ -0,0 +1,260 @@
1
+ import type { AnalysisResult } from "@obsunified/types";
2
+ import { describe, expect, it } from "vitest";
3
+ import { computeSignature, evaluateGate, parsePredicate } from "./narrate-gate";
4
+
5
+ const baseResult = (
6
+ overrides: Partial<AnalysisResult> = {},
7
+ ): AnalysisResult => ({
8
+ analysisId: "x",
9
+ projectId: "default",
10
+ generatedAt: "2026-04-27T00:00:00Z",
11
+ paramsHash: null,
12
+ status: "ok",
13
+ primaryValue: 1,
14
+ baselineValue: 1,
15
+ deltaPct: 0,
16
+ payload: {},
17
+ narrative: null,
18
+ narrativeSignature: "ok|1.0|1.0|",
19
+ durationMs: 1,
20
+ ...overrides,
21
+ });
22
+
23
+ describe("narrate-gate parser", () => {
24
+ it("parses simple atoms", () => {
25
+ const cases = ["status_changed", "signature_changed", "always", "never"];
26
+ for (const c of cases) {
27
+ const r = parsePredicate(c);
28
+ expect(r.kind).toBe("ok");
29
+ }
30
+ });
31
+
32
+ it("parses delta_pct comparators with longest-first matching", () => {
33
+ const r = parsePredicate("delta_pct>=10");
34
+ expect(r.kind).toBe("ok");
35
+ if (r.kind === "ok") {
36
+ expect(r.conjunctions[0]?.[0]).toEqual({
37
+ kind: "delta_pct",
38
+ op: ">=",
39
+ value: 10,
40
+ });
41
+ }
42
+ });
43
+
44
+ it("parses && and || with DNF semantics", () => {
45
+ const r = parsePredicate(
46
+ "status_changed && delta_pct>20 || signature_changed",
47
+ );
48
+ expect(r.kind).toBe("ok");
49
+ if (r.kind === "ok") {
50
+ expect(r.conjunctions).toHaveLength(2);
51
+ expect(r.conjunctions[0]).toHaveLength(2);
52
+ expect(r.conjunctions[1]).toHaveLength(1);
53
+ }
54
+ });
55
+
56
+ it("rejects unknown atoms", () => {
57
+ const r = parsePredicate("foo_bar");
58
+ expect(r.kind).toBe("error");
59
+ });
60
+
61
+ it("rejects delta_pct without comparator", () => {
62
+ const r = parsePredicate("delta_pct 10");
63
+ expect(r.kind).toBe("error");
64
+ });
65
+ });
66
+
67
+ describe("evaluateGate intents", () => {
68
+ it("returns 'skip' when spec is undefined", () => {
69
+ // `evaluateGate(undefined, …)` defaults to status_changed; without
70
+ // previous, this always fires. To get skip we pass `never`.
71
+ expect(
72
+ evaluateGate("never", { current: baseResult(), previous: null }),
73
+ ).toBe("skip");
74
+ });
75
+
76
+ it("returns 'call' on first run when spec is present", () => {
77
+ expect(
78
+ evaluateGate("status_changed", {
79
+ current: baseResult({ status: "warn" }),
80
+ previous: null,
81
+ }),
82
+ ).toBe("call");
83
+ });
84
+
85
+ it("returns 'reuse' when signature unchanged", () => {
86
+ const sig = "ok|1.0|1.0|";
87
+ const previous = baseResult({
88
+ narrative: "everything's fine",
89
+ narrativeSignature: sig,
90
+ });
91
+ const current = baseResult({ narrativeSignature: sig });
92
+ expect(evaluateGate("status_changed", { current, previous })).toBe("reuse");
93
+ });
94
+
95
+ it("status_changed fires when status moves", () => {
96
+ const prev = baseResult({
97
+ status: "ok",
98
+ narrative: "old",
99
+ narrativeSignature: "ok|1.0|1.0|",
100
+ });
101
+ const cur = baseResult({
102
+ status: "critical",
103
+ narrativeSignature: "critical|1.0|1.0|",
104
+ });
105
+ expect(
106
+ evaluateGate("status_changed", { current: cur, previous: prev }),
107
+ ).toBe("call");
108
+ });
109
+
110
+ it("delta_pct>N fires when |delta| exceeds threshold", () => {
111
+ const prev = baseResult({
112
+ narrative: "old",
113
+ narrativeSignature: "ok|1.0|1.0|",
114
+ });
115
+ const cur = baseResult({
116
+ deltaPct: 30,
117
+ narrativeSignature: "ok|1.3|1.0|",
118
+ });
119
+ expect(evaluateGate("delta_pct>20", { current: cur, previous: prev })).toBe(
120
+ "call",
121
+ );
122
+ });
123
+
124
+ it("delta_pct>N treats negative deltas as magnitude", () => {
125
+ const prev = baseResult({
126
+ narrative: "old",
127
+ narrativeSignature: "ok|1.0|1.0|",
128
+ });
129
+ const cur = baseResult({
130
+ deltaPct: -45,
131
+ narrativeSignature: "ok|0.55|1.0|",
132
+ });
133
+ expect(evaluateGate("delta_pct>30", { current: cur, previous: prev })).toBe(
134
+ "call",
135
+ );
136
+ });
137
+
138
+ it("OR semantics: either side fires", () => {
139
+ const prev = baseResult({
140
+ narrative: "old",
141
+ narrativeSignature: "ok|1.0|1.0|",
142
+ });
143
+ const cur = baseResult({
144
+ deltaPct: 5, // below threshold
145
+ status: "warn", // status changed
146
+ narrativeSignature: "warn|1.05|1.0|",
147
+ });
148
+ expect(
149
+ evaluateGate("status_changed || delta_pct>20", {
150
+ current: cur,
151
+ previous: prev,
152
+ }),
153
+ ).toBe("call");
154
+ });
155
+
156
+ it("AND semantics: both must hold", () => {
157
+ const prev = baseResult({
158
+ narrative: "old",
159
+ narrativeSignature: "ok|1.0|1.0|",
160
+ });
161
+ const cur = baseResult({
162
+ deltaPct: 5,
163
+ status: "warn",
164
+ narrativeSignature: "warn|1.05|1.0|",
165
+ });
166
+ expect(
167
+ evaluateGate("status_changed && delta_pct>20", {
168
+ current: cur,
169
+ previous: prev,
170
+ }),
171
+ ).toBe("reuse"); // delta below threshold blocks; we have prev narrative
172
+ });
173
+
174
+ it("falls back to reuse when gate doesn't fire and previous narrative exists", () => {
175
+ const prev = baseResult({
176
+ narrative: "checkpoint",
177
+ narrativeSignature: "ok|1.0|1.0|",
178
+ });
179
+ const cur = baseResult({ narrativeSignature: "ok|1.05|1.0|" });
180
+ expect(
181
+ evaluateGate("status_changed", { current: cur, previous: prev }),
182
+ ).toBe("reuse");
183
+ });
184
+
185
+ it("falls back to skip when gate doesn't fire and there's no previous narrative", () => {
186
+ const prev = baseResult({
187
+ narrative: null,
188
+ narrativeSignature: "ok|1.0|1.0|",
189
+ });
190
+ const cur = baseResult({ narrativeSignature: "ok|1.05|1.0|" });
191
+ expect(
192
+ evaluateGate("status_changed", { current: cur, previous: prev }),
193
+ ).toBe("skip");
194
+ });
195
+
196
+ it("malformed predicate falls back to status_changed", () => {
197
+ const prev = baseResult({
198
+ status: "ok",
199
+ narrative: "old",
200
+ narrativeSignature: "ok|1.0|1.0|",
201
+ });
202
+ const cur = baseResult({
203
+ status: "warn",
204
+ narrativeSignature: "warn|1.0|1.0|",
205
+ });
206
+ expect(
207
+ evaluateGate("totally_made_up_atom", { current: cur, previous: prev }),
208
+ ).toBe("call");
209
+ });
210
+ });
211
+
212
+ describe("computeSignature", () => {
213
+ it("rounds primary value coarsely so noise doesn't bust the cache", () => {
214
+ const a = computeSignature({
215
+ status: "ok",
216
+ primaryValue: 1.241,
217
+ baselineValue: 1.0,
218
+ payload: {},
219
+ });
220
+ const b = computeSignature({
221
+ status: "ok",
222
+ primaryValue: 1.243,
223
+ baselineValue: 1.0,
224
+ payload: {},
225
+ });
226
+ expect(a).toBe(b);
227
+ });
228
+
229
+ it("changes when status changes", () => {
230
+ const a = computeSignature({
231
+ status: "ok",
232
+ primaryValue: 1,
233
+ baselineValue: 1,
234
+ payload: {},
235
+ });
236
+ const b = computeSignature({
237
+ status: "warn",
238
+ primaryValue: 1,
239
+ baselineValue: 1,
240
+ payload: {},
241
+ });
242
+ expect(a).not.toBe(b);
243
+ });
244
+
245
+ it("incorporates payload.signatureKey when set", () => {
246
+ const a = computeSignature({
247
+ status: "ok",
248
+ primaryValue: 1,
249
+ baselineValue: 1,
250
+ payload: { signatureKey: "checkout" },
251
+ });
252
+ const b = computeSignature({
253
+ status: "ok",
254
+ primaryValue: 1,
255
+ baselineValue: 1,
256
+ payload: { signatureKey: "payment" },
257
+ });
258
+ expect(a).not.toBe(b);
259
+ });
260
+ });