@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,439 @@
1
+ /**
2
+ * RFC 0002 Stage 1 — runner that executes SQL-only Analyses on the existing
3
+ * scheduled handler.
4
+ *
5
+ * runSqlAnalysis — execute one definition's `sql` against D1 and shape the
6
+ * row(s) into an AnalysisResult.
7
+ * runAllDueAnalyses — orchestrator: refresh the in-process registry into
8
+ * analysis_definitions, find which ones are due, run them,
9
+ * persist the results. Per-analysis errors are isolated
10
+ * via Promise.allSettled so one bad SQL doesn't kill the
11
+ * batch.
12
+ *
13
+ * Result shape convention: an Analysis SQL is expected to return a single
14
+ * row with the columns:
15
+ *
16
+ * status TEXT | NULL -- 'ok' | 'warn' | 'critical' | 'unknown'
17
+ * primary_value REAL | NULL
18
+ * baseline_value REAL | NULL
19
+ * payload TEXT | NULL -- JSON-serialized object, optional
20
+ *
21
+ * If `status` is NULL we derive it from the magnitude of delta_pct
22
+ * (>=25% → critical, >=10% → warn, else ok). delta_pct itself is computed
23
+ * here from primary/baseline so individual SQL queries don't have to.
24
+ */
25
+
26
+ import type {
27
+ AnalysisDefinition,
28
+ AnalysisResult,
29
+ AnalysisStatus,
30
+ } from "@obsunified/types";
31
+ import { getAllAnalysesForProject } from "../analyses/index";
32
+ import type { CollectorEnv } from "../framework/env";
33
+ import {
34
+ type ChildSpanRunner,
35
+ consoleLogger,
36
+ type Logger,
37
+ } from "../framework/logger";
38
+ import { AnalysesStore } from "./analyses-store";
39
+ import { generateNarrative, LlmCallError, type LlmConfig } from "./llm";
40
+ import {
41
+ computeSignature,
42
+ evaluateGate,
43
+ type NarrativeIntent,
44
+ } from "./narrate-gate";
45
+ import { type SqlDb, sqlDbFor } from "./sql-db";
46
+
47
+ export interface AnalysisRunContext {
48
+ db: SqlDb;
49
+ projectId: string;
50
+ retentionHours: number;
51
+ /** Stage 3: narrative LLM config. Undefined = narratives disabled. */
52
+ llm?: LlmConfig;
53
+ /** Stage 3: max narrative-writes per project per hour. Default 50. */
54
+ narrativeBudgetPerHour?: number;
55
+ /** Pluggable logger; defaults to console. */
56
+ logger?: Logger;
57
+ }
58
+
59
+ interface AnalysisSqlRow {
60
+ status?: string | null;
61
+ primary_value?: number | null;
62
+ baseline_value?: number | null;
63
+ payload?: string | null;
64
+ }
65
+
66
+ const VALID_STATUSES: ReadonlySet<AnalysisStatus> = new Set([
67
+ "ok",
68
+ "warn",
69
+ "critical",
70
+ "unknown",
71
+ ]);
72
+
73
+ const deriveStatus = (deltaPct: number | null): AnalysisStatus => {
74
+ if (deltaPct === null || !Number.isFinite(deltaPct)) return "unknown";
75
+ const magnitude = Math.abs(deltaPct);
76
+ if (magnitude >= 25) return "critical";
77
+ if (magnitude >= 10) return "warn";
78
+ return "ok";
79
+ };
80
+
81
+ const computeDeltaPct = (
82
+ primary: number | null,
83
+ baseline: number | null,
84
+ ): number | null => {
85
+ if (
86
+ primary === null ||
87
+ baseline === null ||
88
+ !Number.isFinite(primary) ||
89
+ !Number.isFinite(baseline) ||
90
+ baseline === 0
91
+ ) {
92
+ return null;
93
+ }
94
+ return ((primary - baseline) / baseline) * 100;
95
+ };
96
+
97
+ const parsePayload = (
98
+ payload: string | null | undefined,
99
+ ): Record<string, unknown> => {
100
+ if (!payload) return {};
101
+ try {
102
+ const parsed = JSON.parse(payload) as unknown;
103
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
104
+ ? (parsed as Record<string, unknown>)
105
+ : {};
106
+ } catch {
107
+ return {};
108
+ }
109
+ };
110
+
111
+ export async function runSqlAnalysis(
112
+ def: AnalysisDefinition,
113
+ ctx: AnalysisRunContext,
114
+ ): Promise<AnalysisResult> {
115
+ if (!def.sql)
116
+ throw new Error(
117
+ `Analysis "${def.id}" has no sql; cannot run as SQL analysis`,
118
+ );
119
+
120
+ const startedAt = Date.now();
121
+ // Contract with Agent 2's analysis derivation:
122
+ // - Tier 0 SQL is static and references the literal token {{PROJECT_ID}};
123
+ // the runner string-replaces it before execution.
124
+ // - Tier 1 SQL bakes the project_id in at derivation time (escaped via
125
+ // SQL '' quoting) and contains no token.
126
+ // Either way, queries expect zero ? bindings. project_id is collector-
127
+ // internal (never user-supplied), but we still validate the shape to keep
128
+ // future user-defined SQL safe from token-replacement injection.
129
+ if (!/^[A-Za-z0-9_-]+$/.test(ctx.projectId)) {
130
+ throw new Error(
131
+ `refusing to run analysis: unsafe project_id ${ctx.projectId}`,
132
+ );
133
+ }
134
+ const sqlWithProject = def.sql.replace(/\{\{PROJECT_ID\}\}/g, ctx.projectId);
135
+ const row = await ctx.db.prepare(sqlWithProject).first<AnalysisSqlRow>();
136
+
137
+ const primaryValue =
138
+ row && typeof row.primary_value === "number" ? row.primary_value : null;
139
+ const baselineValue =
140
+ row && typeof row.baseline_value === "number" ? row.baseline_value : null;
141
+ const deltaPct = computeDeltaPct(primaryValue, baselineValue);
142
+
143
+ const rawStatus = row?.status;
144
+ const status: AnalysisStatus =
145
+ rawStatus && VALID_STATUSES.has(rawStatus as AnalysisStatus)
146
+ ? (rawStatus as AnalysisStatus)
147
+ : deriveStatus(deltaPct);
148
+
149
+ const generatedAt = new Date().toISOString();
150
+
151
+ const payload = parsePayload(row?.payload);
152
+ const narrativeSignature = computeSignature({
153
+ status,
154
+ primaryValue,
155
+ baselineValue,
156
+ payload,
157
+ });
158
+
159
+ return {
160
+ analysisId: def.id,
161
+ projectId: ctx.projectId,
162
+ generatedAt,
163
+ paramsHash: null,
164
+ status,
165
+ primaryValue,
166
+ baselineValue,
167
+ deltaPct,
168
+ payload,
169
+ narrative: null,
170
+ narrativeSignature,
171
+ durationMs: Date.now() - startedAt,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * RFC 0002 Stage 3 — narrate pass. Decides whether to call the LLM,
177
+ * reuse a cached narrative, or skip; mutates the result in place.
178
+ *
179
+ * Budget is checked once at call time (not pre-decided per panel) so
180
+ * the runner doesn't waste effort computing gates for analyses that
181
+ * won't get a turn anyway. `[narrate]` log lines are the operator's
182
+ * audit trail for "why didn't this panel narrate?".
183
+ */
184
+ async function narratePass(
185
+ def: AnalysisDefinition,
186
+ current: AnalysisResult,
187
+ previous: AnalysisResult | null,
188
+ ctx: AnalysisRunContext,
189
+ _store: AnalysesStore,
190
+ budgetState: { remaining: number },
191
+ ): Promise<void> {
192
+ if (!def.narrate) return;
193
+ if (!ctx.llm) return;
194
+
195
+ const intent: NarrativeIntent = evaluateGate(def.narrate.only_when, {
196
+ current,
197
+ previous,
198
+ });
199
+
200
+ if (intent === "skip") return;
201
+
202
+ if (intent === "reuse" && previous?.narrative) {
203
+ current.narrative = previous.narrative;
204
+ // Keep the previous signature so subsequent runs continue to compare
205
+ // against the state that produced this narrative.
206
+ current.narrativeSignature = previous.narrativeSignature;
207
+ return;
208
+ }
209
+
210
+ if (intent !== "call") return;
211
+
212
+ if (budgetState.remaining <= 0) {
213
+ (ctx.logger ?? consoleLogger).warn("[narrate] budget exhausted", {
214
+ analysis_id: def.id,
215
+ });
216
+ if (previous?.narrative) {
217
+ current.narrative = previous.narrative;
218
+ current.narrativeSignature = previous.narrativeSignature;
219
+ }
220
+ return;
221
+ }
222
+
223
+ try {
224
+ budgetState.remaining -= 1;
225
+ const controller = new AbortController();
226
+ const timeout = setTimeout(() => controller.abort(), 20_000);
227
+ const text = await generateNarrative(
228
+ { definition: def, current, previous },
229
+ { ...ctx.llm, signal: controller.signal },
230
+ ).finally(() => clearTimeout(timeout));
231
+ current.narrative = text;
232
+ // Keep the just-computed signature so cache compare works next run.
233
+ } catch (error) {
234
+ const msg =
235
+ error instanceof LlmCallError
236
+ ? `${error.message}`
237
+ : error instanceof Error
238
+ ? error.message
239
+ : String(error);
240
+ (ctx.logger ?? consoleLogger).error("[narrate] LLM call failed", {
241
+ analysis_id: def.id,
242
+ error: msg,
243
+ });
244
+ if (previous?.narrative) {
245
+ current.narrative = previous.narrative;
246
+ current.narrativeSignature = previous.narrativeSignature;
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Orchestrator. Refreshes the analysis_definitions registry from the
253
+ * in-process registry (so newly-derived Tier 1 analyses become visible
254
+ * to the dashboard within one tick), then runs whatever's due.
255
+ *
256
+ * Stage 1 has a single project ("default"). When a projects table is
257
+ * available we'll iterate it here.
258
+ */
259
+ export async function runAllDueAnalyses(
260
+ ctx: {
261
+ env: CollectorEnv;
262
+ retentionHours: number;
263
+ logger?: Logger;
264
+ tracer?: ChildSpanRunner;
265
+ },
266
+ _runtime?: unknown,
267
+ ): Promise<{
268
+ ran: number;
269
+ failed: number;
270
+ refreshed: number;
271
+ narrated: number;
272
+ skipped?: number;
273
+ }> {
274
+ const logger = ctx.logger ?? consoleLogger;
275
+ const db = sqlDbFor(ctx.env);
276
+ const store = new AnalysesStore(db);
277
+ const projectId = "default";
278
+ const now = Date.now();
279
+ const expiresAt = now + ctx.retentionHours * 3600 * 1000;
280
+
281
+ // 1. Refresh the registry — Agent 2's getAllAnalysesForProject builds the
282
+ // union of Tier 0 + derived Tier 1 + user-defined analyses.
283
+ let registered: AnalysisDefinition[] = [];
284
+ try {
285
+ registered = await getAllAnalysesForProject(projectId, {
286
+ db,
287
+ });
288
+ } catch (error) {
289
+ logger.error("[analyses] failed to load registered analyses", {
290
+ project_id: projectId,
291
+ error: error instanceof Error ? error.message : String(error),
292
+ });
293
+ }
294
+
295
+ let refreshed = 0;
296
+ for (const def of registered) {
297
+ try {
298
+ await store.upsertDefinition(projectId, def);
299
+ refreshed += 1;
300
+ } catch (error) {
301
+ logger.error("[analyses] upsertDefinition failed", {
302
+ analysis_id: def.id,
303
+ error: error instanceof Error ? error.message : String(error),
304
+ });
305
+ }
306
+ }
307
+
308
+ // 2. Find what's due and run them.
309
+ const due = await store.getDueAnalyses(projectId, now);
310
+ if (due.length === 0) {
311
+ return { ran: 0, failed: 0, refreshed, narrated: 0 };
312
+ }
313
+
314
+ // Narrative LLM config — present only when an API key is set on the
315
+ // worker. We don't fail the run if it's missing; we just skip the
316
+ // narrate pass entirely (every panel still produces numbers).
317
+ //
318
+ // Provider preference: OpenAI wins when both keys are set — configuring
319
+ // both is presumed deliberate. Empty-string env values from .dev.vars
320
+ // are coerced to undefined so they don't accidentally override defaults.
321
+ const openaiBase = ctx.env.OPENAI_BASE_URL?.trim()
322
+ ? ctx.env.OPENAI_BASE_URL
323
+ : undefined;
324
+ const narrativeModel = ctx.env.NARRATIVE_MODEL?.trim()
325
+ ? ctx.env.NARRATIVE_MODEL
326
+ : undefined;
327
+ const llm: LlmConfig | undefined = ctx.env.OPENAI_API_KEY?.trim()
328
+ ? {
329
+ provider: "openai",
330
+ apiKey: ctx.env.OPENAI_API_KEY,
331
+ model: narrativeModel ?? "gpt-4o-mini",
332
+ apiUrl: openaiBase,
333
+ tracer: ctx.tracer,
334
+ }
335
+ : ctx.env.ANTHROPIC_API_KEY?.trim()
336
+ ? {
337
+ provider: "anthropic",
338
+ apiKey: ctx.env.ANTHROPIC_API_KEY,
339
+ model: narrativeModel ?? "claude-3-5-haiku-latest",
340
+ tracer: ctx.tracer,
341
+ }
342
+ : undefined;
343
+ const parsedNarrativeBudget = Number.parseInt(
344
+ ctx.env.NARRATIVE_BUDGET_PER_HOUR ?? "",
345
+ 10,
346
+ );
347
+ const narrativeBudgetPerHour = Number.isFinite(parsedNarrativeBudget)
348
+ ? parsedNarrativeBudget
349
+ : 50;
350
+ const narrativesUsed = llm
351
+ ? await store.countNarrativesInWindow(projectId, 60)
352
+ : 0;
353
+ const budgetState = {
354
+ remaining: Math.max(0, narrativeBudgetPerHour - narrativesUsed),
355
+ };
356
+
357
+ const runCtx: AnalysisRunContext = {
358
+ db,
359
+ projectId,
360
+ retentionHours: ctx.retentionHours,
361
+ llm,
362
+ narrativeBudgetPerHour,
363
+ logger,
364
+ };
365
+
366
+ // Run with bounded concurrency. D1 is happy with a handful of parallel
367
+ // queries; 45 in flight at once causes the Workers scheduled handler
368
+ // to hit its CPU/wall-time cap before any results land. We also log
369
+ // per-analysis so a stuck SQL is identifiable in the log rather than
370
+ // silently swallowed.
371
+ const CONCURRENCY = 4;
372
+ // Lease window for the per-analysis claim. Long enough that a healthy
373
+ // narrative pass (LLM roundtrip + result insert) won't get rerun by the
374
+ // next tick, short enough that a worker crash unblocks the row within
375
+ // two ticks. 90 s sits between the every-minute cron and a typical LLM
376
+ // p95.
377
+ const CLAIM_LEASE_MS = 90_000;
378
+ let ran = 0;
379
+ let failed = 0;
380
+ let skipped = 0;
381
+ let narrated = 0;
382
+ const queue = [...due];
383
+ const workers: Promise<void>[] = [];
384
+ for (let w = 0; w < CONCURRENCY; w += 1) {
385
+ workers.push(
386
+ (async () => {
387
+ while (queue.length > 0) {
388
+ const def = queue.shift();
389
+ if (!def) return;
390
+ const claimed = await store.claimAnalysis(
391
+ projectId,
392
+ def.id,
393
+ Date.now(),
394
+ CLAIM_LEASE_MS,
395
+ );
396
+ if (!claimed) {
397
+ skipped += 1;
398
+ logger.info("[analyses] skipped (lease held by another tick)", {
399
+ analysis_id: def.id,
400
+ });
401
+ continue;
402
+ }
403
+ try {
404
+ const result = await runSqlAnalysis(def, runCtx);
405
+ // Stage 3: narrate pass. Reads previous result from D1 to
406
+ // run gate logic + signature cache; isolated from query
407
+ // failures so a flaky LLM doesn't drop the data row.
408
+ if (def.narrate && runCtx.llm) {
409
+ const previous = await store.getLatestResult(projectId, def.id);
410
+ await narratePass(
411
+ def,
412
+ result,
413
+ previous,
414
+ runCtx,
415
+ store,
416
+ budgetState,
417
+ );
418
+ if (result.narrative) narrated += 1;
419
+ }
420
+ await store.insertResult(result, expiresAt);
421
+ // markRan releases the claim as a side-effect.
422
+ await store.markRan(projectId, def.id, result.generatedAt);
423
+ ran += 1;
424
+ } catch (error) {
425
+ failed += 1;
426
+ await store.releaseClaim(projectId, def.id);
427
+ logger.error("[analyses] analysis failed", {
428
+ analysis_id: def.id,
429
+ error: error instanceof Error ? error.message : String(error),
430
+ });
431
+ }
432
+ }
433
+ })(),
434
+ );
435
+ }
436
+ await Promise.all(workers);
437
+
438
+ return { ran, failed, refreshed, narrated, skipped };
439
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * AnalysesStore tests — focused on the claim semantics added to prevent
3
+ * cron overlap. Pre-existing methods (upsertDefinition, getLatestResult)
4
+ * are exercised end-to-end by the runner's integration tests; this file
5
+ * only locks down the new claim primitive's contract.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { AnalysesStore } from "./analyses-store";
10
+ import { MemSqlDb } from "./test-utils/mem-sql-db";
11
+
12
+ describe("AnalysesStore.claimAnalysis", () => {
13
+ it("returns true when the UPDATE moved a row (claim acquired)", async () => {
14
+ const db = new MemSqlDb({ run: () => ({ changes: 1 }) });
15
+ const store = new AnalysesStore(db);
16
+ const ok = await store.claimAnalysis(
17
+ "proj-1",
18
+ "analysis-1",
19
+ Date.now(),
20
+ 90_000,
21
+ );
22
+ expect(ok).toBe(true);
23
+ });
24
+
25
+ it("returns false when the UPDATE matched zero rows (lease held)", async () => {
26
+ // changes=0 simulates D1 declining the UPDATE because the WHERE
27
+ // clause excluded the row (active lease held by another tick).
28
+ const db = new MemSqlDb({ run: () => ({ changes: 0 }) });
29
+ const store = new AnalysesStore(db);
30
+ const ok = await store.claimAnalysis(
31
+ "proj-1",
32
+ "analysis-1",
33
+ Date.now(),
34
+ 90_000,
35
+ );
36
+ expect(ok).toBe(false);
37
+ });
38
+
39
+ it("binds an ISO timestamp for the lease floor (now - leaseMs)", async () => {
40
+ const db = new MemSqlDb({ run: () => ({ changes: 1 }) });
41
+ const store = new AnalysesStore(db);
42
+ const now = Date.parse("2026-05-01T12:00:00.000Z");
43
+ await store.claimAnalysis("proj-1", "analysis-1", now, 90_000);
44
+
45
+ const claim = db.callsMatching("last_started_at = ?")[0];
46
+ expect(claim).toBeDefined();
47
+ // binds: [nowIso, projectId, analysisId, leaseFloorIso]
48
+ expect(claim.binds[0]).toBe("2026-05-01T12:00:00.000Z");
49
+ expect(claim.binds[1]).toBe("proj-1");
50
+ expect(claim.binds[2]).toBe("analysis-1");
51
+ expect(claim.binds[3]).toBe("2026-05-01T11:58:30.000Z");
52
+ });
53
+ });
54
+
55
+ describe("AnalysesStore.markRan", () => {
56
+ it("clears last_started_at while writing last_run_at (releases the lease)", async () => {
57
+ const db = new MemSqlDb({ run: () => ({ changes: 1 }) });
58
+ const store = new AnalysesStore(db);
59
+ await store.markRan("proj-1", "analysis-1", "2026-05-01T12:00:00Z");
60
+
61
+ const call = db.callsMatching("UPDATE analysis_definitions")[0];
62
+ expect(call).toBeDefined();
63
+ expect(call.sql).toContain("last_started_at = NULL");
64
+ });
65
+ });
66
+
67
+ describe("AnalysesStore.releaseClaim", () => {
68
+ it("nullifies last_started_at without touching last_run_at", async () => {
69
+ const db = new MemSqlDb({ run: () => ({ changes: 1 }) });
70
+ const store = new AnalysesStore(db);
71
+ await store.releaseClaim("proj-1", "analysis-1");
72
+
73
+ const call = db.callsMatching("last_started_at = NULL")[0];
74
+ expect(call).toBeDefined();
75
+ expect(call.sql).not.toContain("last_run_at");
76
+ expect(call.binds).toEqual(["proj-1", "analysis-1"]);
77
+ });
78
+ });
79
+
80
+ describe("AnalysesStore.getLatestResult evidence references", () => {
81
+ it("derives machine-readable pivots from structured evidence tables", async () => {
82
+ const db = new MemSqlDb({
83
+ first: () => ({
84
+ id: 1,
85
+ project_id: "proj-1",
86
+ analysis_id: "investigate.latency",
87
+ generated_at: Date.parse("2026-05-01T12:00:00.000Z"),
88
+ params_hash: null,
89
+ status: "warn",
90
+ primary_value: 1234,
91
+ baseline_value: null,
92
+ delta_pct: null,
93
+ payload_json: JSON.stringify({
94
+ evidence: {
95
+ tail_offenders: {
96
+ title: "Tail offenders",
97
+ headers: ["trace_id", "span_id", "service", "max_ms"],
98
+ rows: [
99
+ ["trace-1", "span-1", "checkout", 1234],
100
+ [null, null, "api", 400],
101
+ ],
102
+ },
103
+ },
104
+ }),
105
+ narrative: "Latency is elevated.",
106
+ narrative_signature: "sig-1",
107
+ duration_ms: 12,
108
+ expires_at: Date.parse("2026-05-02T12:00:00.000Z"),
109
+ }),
110
+ });
111
+ const store = new AnalysesStore(db);
112
+
113
+ const result = await store.getLatestResult("proj-1", "investigate.latency");
114
+
115
+ expect(result?.evidenceReferences).toEqual([
116
+ expect.objectContaining({
117
+ evidenceId:
118
+ "analysis:investigate.latency:tail_offenders:0:span:trace-1:span-1",
119
+ entityKind: "span",
120
+ entityId: "trace-1:span-1",
121
+ route: "#/traces/trace-1#span=span-1",
122
+ source: "analysis.payload.evidence.tail_offenders",
123
+ confidence: 0.95,
124
+ citations: [
125
+ {
126
+ label: "trace trace-1",
127
+ entityKind: "trace",
128
+ entityId: "trace-1",
129
+ route: "#/traces/trace-1",
130
+ },
131
+ ],
132
+ }),
133
+ expect.objectContaining({
134
+ evidenceId: "analysis:investigate.latency:tail_offenders:1:service:api",
135
+ entityKind: "service",
136
+ entityId: "api",
137
+ route: "#/traces?service=api",
138
+ source: "analysis.payload.evidence.tail_offenders",
139
+ confidence: 0.7,
140
+ }),
141
+ ]);
142
+ });
143
+
144
+ it("omits evidenceReferences for legacy payloads without evidence tables", async () => {
145
+ const db = new MemSqlDb({
146
+ first: () => ({
147
+ id: 1,
148
+ project_id: "proj-1",
149
+ analysis_id: "legacy.panel",
150
+ generated_at: Date.parse("2026-05-01T12:00:00.000Z"),
151
+ params_hash: null,
152
+ status: "ok",
153
+ primary_value: null,
154
+ baseline_value: null,
155
+ delta_pct: null,
156
+ payload_json: JSON.stringify({ rows: [{ service: "api" }] }),
157
+ narrative: null,
158
+ narrative_signature: null,
159
+ duration_ms: 4,
160
+ expires_at: Date.parse("2026-05-02T12:00:00.000Z"),
161
+ }),
162
+ });
163
+ const store = new AnalysesStore(db);
164
+
165
+ const result = await store.getLatestResult("proj-1", "legacy.panel");
166
+
167
+ expect(result?.evidenceReferences).toBeUndefined();
168
+ });
169
+
170
+ it("derives machine-readable pivots from instrumentation gap payloads", async () => {
171
+ const db = new MemSqlDb({
172
+ first: () => ({
173
+ id: 1,
174
+ project_id: "proj-1",
175
+ analysis_id: "trace.instrumentation_gaps",
176
+ generated_at: Date.parse("2026-05-01T12:00:00.000Z"),
177
+ params_hash: null,
178
+ status: "warn",
179
+ primary_value: 500,
180
+ baseline_value: null,
181
+ delta_pct: null,
182
+ payload_json: JSON.stringify({
183
+ instrumentationGaps: {
184
+ traceId: "trace-gap",
185
+ totalDurationMs: 1000,
186
+ uninstrumentedTimeMs: 500,
187
+ ratio: 0.5,
188
+ blindspots: [
189
+ {
190
+ traceId: "trace-gap",
191
+ parentSpanId: "span-root",
192
+ parentServiceName: "checkout",
193
+ parentSpanName: "POST /checkout",
194
+ offsetMs: 100,
195
+ durationMs: 500,
196
+ ratioOfParent: 0.5,
197
+ childSpanCount: 1,
198
+ asyncParent: false,
199
+ recommendation: "Instrument child operations.",
200
+ },
201
+ ],
202
+ },
203
+ }),
204
+ narrative: "Checkout has missing instrumentation.",
205
+ narrative_signature: "sig-gap",
206
+ duration_ms: 12,
207
+ expires_at: Date.parse("2026-05-02T12:00:00.000Z"),
208
+ }),
209
+ });
210
+ const store = new AnalysesStore(db);
211
+
212
+ const result = await store.getLatestResult(
213
+ "proj-1",
214
+ "trace.instrumentation_gaps",
215
+ );
216
+
217
+ expect(result?.evidenceReferences?.[0]).toMatchObject({
218
+ evidenceId:
219
+ "analysis:trace.instrumentation_gaps:instrumentation-gap:0:span:trace-gap:span-root",
220
+ entityKind: "span",
221
+ entityId: "trace-gap:span-root",
222
+ route: "#/traces/trace-gap#span=span-root",
223
+ source: "analysis.payload.instrumentationGaps",
224
+ confidence: 0.9,
225
+ citations: [
226
+ {
227
+ label: "trace trace-gap",
228
+ entityKind: "trace",
229
+ entityId: "trace-gap",
230
+ route: "#/traces/trace-gap",
231
+ },
232
+ ],
233
+ suggestedNextPivots: expect.arrayContaining([
234
+ expect.objectContaining({
235
+ label: "Open profiler setup docs",
236
+ entityKind: "docs",
237
+ entityId: "howto/ebpf",
238
+ route: "#/docs/howto/ebpf",
239
+ }),
240
+ ]),
241
+ });
242
+ });
243
+ });