@obsunified/collector 2.0.1 → 2.1.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 (107) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/index.d.ts +322 -26
  6. package/dist/index.js +1478 -415
  7. package/dist/index.js.map +1 -1
  8. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  9. package/package.json +5 -5
  10. package/src/analyses/derive.ts +26 -25
  11. package/src/analyses/investigations.ts +8 -8
  12. package/src/analyses/tier0.ts +22 -22
  13. package/src/auth/dashboard-auth.test.ts +791 -0
  14. package/src/auth/dashboard-auth.ts +187 -38
  15. package/src/auth/login-rate-limit.test.ts +129 -0
  16. package/src/auth/login-rate-limit.ts +143 -0
  17. package/src/framework/body-limit.test.ts +165 -0
  18. package/src/framework/collector.ts +355 -115
  19. package/src/framework/env.ts +9 -0
  20. package/src/framework/limits.ts +31 -0
  21. package/src/framework/scheduled-handlers.test.ts +342 -0
  22. package/src/index.ts +16 -1
  23. package/src/lib/action-retention.test.ts +472 -0
  24. package/src/lib/action-retention.ts +420 -0
  25. package/src/lib/actions-store.ts +6 -12
  26. package/src/lib/ai-store/calls.ts +2 -2
  27. package/src/lib/ai-store/sessions.ts +3 -3
  28. package/src/lib/ai-store/spans.ts +1 -1
  29. package/src/lib/ai-store/types.ts +2 -1
  30. package/src/lib/alerts-store.test.ts +153 -0
  31. package/src/lib/alerts-store.ts +122 -21
  32. package/src/lib/analyses-runner.test.ts +75 -0
  33. package/src/lib/analyses-runner.ts +60 -3
  34. package/src/lib/analyses-store-latest.test.ts +61 -0
  35. package/src/lib/analyses-store.ts +17 -10
  36. package/src/lib/blob-store.ts +1 -1
  37. package/src/lib/housekeeping.test.ts +73 -0
  38. package/src/lib/housekeeping.ts +93 -0
  39. package/src/lib/identity-index/action-lookups.ts +6 -6
  40. package/src/lib/identity-index/key-lookups.ts +2 -2
  41. package/src/lib/identity-index/user-lookup.ts +8 -5
  42. package/src/lib/identity-index.test.ts +2 -3
  43. package/src/lib/metrics-store-series.test.ts +99 -0
  44. package/src/lib/metrics-store.test.ts +23 -0
  45. package/src/lib/metrics-store.ts +27 -3
  46. package/src/lib/projects-store.test.ts +25 -0
  47. package/src/lib/projects-store.ts +4 -3
  48. package/src/lib/sql-db-dialect.test.ts +12 -3
  49. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  50. package/src/lib/sql-db-postgres.test.ts +314 -0
  51. package/src/lib/sql-db-postgres.ts +392 -54
  52. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  53. package/src/lib/sql-db.ts +40 -6
  54. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  55. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  56. package/src/lib/webhook-url.test.ts +122 -0
  57. package/src/lib/webhook-url.ts +155 -0
  58. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  59. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  60. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  61. package/src/migrations/044_actions_started_at_index.sql +10 -0
  62. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  63. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  64. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  65. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  66. package/src/migrations/049_alert_state_notified.sql +17 -0
  67. package/src/migrations/alert-state-notified.test.ts +111 -0
  68. package/src/migrations/parity.test.ts +64 -0
  69. package/src/migrations/project-scoped-keys.test.ts +432 -0
  70. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  71. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  72. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  73. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  74. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  75. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  76. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  77. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  78. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  79. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  80. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  81. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  82. package/src/migrations-postgres/README.md +58 -5
  83. package/src/otlp/decode/body.test.ts +76 -0
  84. package/src/otlp/decode/body.ts +67 -6
  85. package/src/plugins/_json.ts +21 -0
  86. package/src/plugins/action-graph-processor.ts +6 -12
  87. package/src/plugins/ai-receiver.ts +5 -12
  88. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  89. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  90. package/src/plugins/alerts-evaluator.test.ts +464 -3
  91. package/src/plugins/alerts-evaluator.ts +165 -41
  92. package/src/plugins/alerts-routes.ts +10 -6
  93. package/src/plugins/ask-routes.ts +4 -5
  94. package/src/plugins/connected-routes.test.ts +106 -0
  95. package/src/plugins/connected-routes.ts +14 -5
  96. package/src/plugins/dashboard-routes.test.ts +120 -0
  97. package/src/plugins/dashboard-routes.ts +11 -2
  98. package/src/plugins/eval-cases-routes.ts +8 -17
  99. package/src/plugins/identity-receiver.ts +9 -11
  100. package/src/plugins/json-body.test.ts +91 -0
  101. package/src/plugins/onboarding-routes.test.ts +105 -0
  102. package/src/plugins/onboarding-routes.ts +26 -11
  103. package/src/plugins/projects-routes.ts +2 -1
  104. package/src/plugins/replay-isolation.test.ts +922 -0
  105. package/src/plugins/replay-receiver.ts +8 -8
  106. package/src/plugins/usage-receiver.ts +3 -6
  107. package/dist/chunk-UGWDOGGH.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import {
2
2
  D1Adapter,
3
3
  ProjectsStore,
4
+ SQLITE_ISO_FORMAT,
4
5
  createIngestAuth,
5
6
  dialectFor,
6
7
  postgresDialect,
8
+ postgresIsoText,
7
9
  randomHex,
8
10
  sha256Hex,
9
11
  sqlDbFor
10
- } from "./chunk-UGWDOGGH.js";
12
+ } from "./chunk-KMEZKGSO.js";
11
13
  import {
12
14
  LlmCallError,
13
15
  aiEvaluationEvidenceReferences,
@@ -21,6 +23,224 @@ import {
21
23
  // src/framework/collector.ts
22
24
  import { getConfiguredRetentionHours } from "@obsunified/types/constants";
23
25
  import { Hono } from "hono";
26
+ import { bodyLimit } from "hono/body-limit";
27
+
28
+ // src/lib/action-retention.ts
29
+ var DEFAULT_MAX_ACTIONS_PER_BATCH = 500;
30
+ var DEFAULT_MAX_TREES_PER_BATCH = 25;
31
+ var DEFAULT_MAX_BATCHES = 10;
32
+ var OVERSIZED_REPORT_LIMIT = 20;
33
+ var treeEligibleSql = (project, root, cutoff) => `
34
+ NOT EXISTS (
35
+ SELECT 1 FROM actions t
36
+ WHERE t.project_id = ${project} AND t.root_action_id = ${root}
37
+ AND (t.started_at >= ${cutoff} OR t.ended_at IS NULL)
38
+ )
39
+ AND NOT EXISTS (
40
+ SELECT 1 FROM actions t
41
+ JOIN agent_runs r ON r.project_id = t.project_id AND r.id = t.agent_run_id
42
+ WHERE t.project_id = ${project} AND t.root_action_id = ${root}
43
+ AND r.status = 'running'
44
+ )
45
+ AND NOT EXISTS (
46
+ SELECT 1 FROM actions t
47
+ JOIN eval_cases c
48
+ ON c.project_id = t.project_id
49
+ AND (c.source_action_id = t.id OR c.source_agent_run_id = t.agent_run_id)
50
+ WHERE t.project_id = ${project} AND t.root_action_id = ${root}
51
+ )
52
+ AND NOT EXISTS (
53
+ SELECT 1 FROM actions t
54
+ JOIN tool_calls tc ON tc.project_id = t.project_id AND tc.action_id = t.id
55
+ JOIN eval_cases c ON c.project_id = tc.project_id AND c.source_tool_call_id = tc.id
56
+ WHERE t.project_id = ${project} AND t.root_action_id = ${root}
57
+ )`;
58
+ var TREE_ELIGIBLE_SQL = treeEligibleSql("?", "?", "?");
59
+ var treeEligibleBinds = (projectId, rootActionId, cutoff) => [
60
+ projectId,
61
+ rootActionId,
62
+ cutoff,
63
+ projectId,
64
+ rootActionId,
65
+ projectId,
66
+ rootActionId,
67
+ projectId,
68
+ rootActionId
69
+ ];
70
+ var candidateTreesSql = (sizeOp) => `
71
+ SELECT a.project_id, a.root_action_id, COUNT(*) AS action_count,
72
+ MIN(a.started_at) AS oldest_started_at
73
+ FROM actions a
74
+ WHERE a.started_at < ?
75
+ GROUP BY a.project_id, a.root_action_id
76
+ HAVING COUNT(*) ${sizeOp} ?
77
+ AND ${treeEligibleSql("a.project_id", "a.root_action_id", "?")}
78
+ ORDER BY oldest_started_at ASC
79
+ LIMIT ?
80
+ `;
81
+ var SELECT_DELETABLE_TREES_SQL = candidateTreesSql("<=");
82
+ var SELECT_OVERSIZED_TREES_SQL = candidateTreesSql(">");
83
+ var LEAF_TABLES = [
84
+ "tool_calls",
85
+ "retrieval_events",
86
+ "eval_results",
87
+ "artifacts"
88
+ ];
89
+ var emptySummary = () => ({
90
+ actions: 0,
91
+ agent_runs: 0,
92
+ tool_calls: 0,
93
+ retrieval_events: 0,
94
+ eval_results: 0,
95
+ artifacts: 0,
96
+ trees: 0,
97
+ batches: 0,
98
+ truncated: false,
99
+ oversized_trees: 0,
100
+ oversized: []
101
+ });
102
+ var actionRetentionCutoff = (retentionHours, nowMs = Date.now()) => new Date(nowMs - retentionHours * 60 * 60 * 1e3).toISOString();
103
+ var selectTrees = async (db, sql, cutoff, sizeCap, limit) => {
104
+ const { results } = await db.prepare(sql).bind(cutoff, sizeCap, cutoff, limit).all();
105
+ return results.map((row) => ({
106
+ projectId: row.project_id,
107
+ rootActionId: row.root_action_id,
108
+ actions: Number(row.action_count)
109
+ }));
110
+ };
111
+ var packBatch = (candidates, maxActions) => {
112
+ const batch = [];
113
+ let used = 0;
114
+ for (const tree of candidates) {
115
+ if (batch.length > 0 && used + tree.actions > maxActions) break;
116
+ batch.push(tree);
117
+ used += tree.actions;
118
+ }
119
+ return batch;
120
+ };
121
+ async function purgeExpiredActionGraph(db, options) {
122
+ const maxActions = Math.max(
123
+ 1,
124
+ options.maxActionsPerBatch ?? DEFAULT_MAX_ACTIONS_PER_BATCH
125
+ );
126
+ const maxTrees = Math.max(
127
+ 1,
128
+ options.maxTreesPerBatch ?? DEFAULT_MAX_TREES_PER_BATCH
129
+ );
130
+ const maxBatches = Math.max(1, options.maxBatches ?? DEFAULT_MAX_BATCHES);
131
+ const nowMs = (options.now ?? Date.now)();
132
+ const cutoff = actionRetentionCutoff(options.retentionHours, nowMs);
133
+ const summary = emptySummary();
134
+ let exhausted = false;
135
+ for (let batch = 0; batch < maxBatches && !exhausted; batch += 1) {
136
+ const candidates = await selectTrees(
137
+ db,
138
+ SELECT_DELETABLE_TREES_SQL,
139
+ cutoff,
140
+ maxActions,
141
+ maxTrees
142
+ );
143
+ if (candidates.length === 0) {
144
+ exhausted = true;
145
+ break;
146
+ }
147
+ const trees = packBatch(candidates, maxActions);
148
+ summary.batches += 1;
149
+ const deleted = await deleteTrees(db, trees, cutoff);
150
+ for (const table of LEAF_TABLES) summary[table] += deleted[table];
151
+ summary.agent_runs += deleted.agent_runs;
152
+ summary.actions += deleted.actions;
153
+ summary.trees += deleted.trees;
154
+ exhausted = candidates.length < maxTrees && trees.length === candidates.length;
155
+ }
156
+ if (!exhausted) {
157
+ const remaining = await selectTrees(
158
+ db,
159
+ SELECT_DELETABLE_TREES_SQL,
160
+ cutoff,
161
+ maxActions,
162
+ 1
163
+ );
164
+ summary.truncated = remaining.length > 0;
165
+ }
166
+ const oversized = await selectTrees(
167
+ db,
168
+ SELECT_OVERSIZED_TREES_SQL,
169
+ cutoff,
170
+ maxActions,
171
+ OVERSIZED_REPORT_LIMIT
172
+ );
173
+ summary.oversized_trees = oversized.length;
174
+ summary.oversized = oversized.map((tree) => ({
175
+ project_id: tree.projectId,
176
+ root_action_id: tree.rootActionId,
177
+ actions: tree.actions
178
+ }));
179
+ return summary;
180
+ }
181
+ async function deleteTrees(db, trees, cutoff) {
182
+ const statements = [];
183
+ const owners = [];
184
+ trees.forEach(({ projectId, rootActionId }, treeIndex) => {
185
+ const guard = treeEligibleBinds(projectId, rootActionId, cutoff);
186
+ const push = (table, stmt) => {
187
+ statements.push(stmt);
188
+ owners.push({ table, tree: treeIndex });
189
+ };
190
+ for (const table of LEAF_TABLES) {
191
+ push(
192
+ table,
193
+ db.prepare(
194
+ `DELETE FROM ${table}
195
+ WHERE project_id = ?
196
+ AND action_id IN (
197
+ SELECT id FROM actions WHERE project_id = ? AND root_action_id = ?
198
+ )
199
+ AND ${TREE_ELIGIBLE_SQL}`
200
+ ).bind(projectId, projectId, rootActionId, ...guard)
201
+ );
202
+ }
203
+ push(
204
+ "agent_runs",
205
+ db.prepare(
206
+ `DELETE FROM agent_runs
207
+ WHERE project_id = ?
208
+ AND id IN (
209
+ SELECT id FROM actions WHERE project_id = ? AND root_action_id = ?
210
+ )
211
+ AND ${TREE_ELIGIBLE_SQL}`
212
+ ).bind(projectId, projectId, rootActionId, ...guard)
213
+ );
214
+ push(
215
+ "actions",
216
+ db.prepare(
217
+ `DELETE FROM actions
218
+ WHERE project_id = ? AND root_action_id = ?
219
+ AND ${TREE_ELIGIBLE_SQL}`
220
+ ).bind(projectId, rootActionId, ...guard)
221
+ );
222
+ });
223
+ const results = await db.batch(statements);
224
+ const counts = {
225
+ tool_calls: 0,
226
+ retrieval_events: 0,
227
+ eval_results: 0,
228
+ artifacts: 0,
229
+ agent_runs: 0,
230
+ actions: 0,
231
+ trees: 0
232
+ };
233
+ const spineDeleted = /* @__PURE__ */ new Set();
234
+ results.forEach((result, index) => {
235
+ const owner = owners[index];
236
+ if (!owner) return;
237
+ const changes2 = result?.meta?.changes ?? 0;
238
+ counts[owner.table] += changes2;
239
+ if (owner.table === "actions" && changes2 > 0) spineDeleted.add(owner.tree);
240
+ });
241
+ counts.trees = spineDeleted.size;
242
+ return counts;
243
+ }
24
244
 
25
245
  // src/lib/ai-store/types.ts
26
246
  var clampInt = (value, min, max, fallback) => {
@@ -102,7 +322,7 @@ async function getAICallsOverview(db, options) {
102
322
  SUM(total_cost_usd) as totalCostUsd,
103
323
  SUM(prompt_tokens) as totalPromptTokens,
104
324
  SUM(completion_tokens) as totalCompletionTokens,
105
- SUM(is_error) as errorCalls
325
+ SUM(CASE WHEN is_error THEN 1 ELSE 0 END) as errorCalls
106
326
  FROM ai_calls WHERE project_id = ? AND received_at >= ${dialect.sinceHours("?")}
107
327
  ${options.service ? "AND service_name = ?" : ""}
108
328
  `;
@@ -124,7 +344,7 @@ async function getAICallsOverview(db, options) {
124
344
  completionTokens: row.completion_tokens,
125
345
  totalCostUsd: row.total_cost_usd,
126
346
  latencyMs: row.latency_ms,
127
- isError: row.is_error === 1,
347
+ isError: row.is_error === 1 || row.is_error === true,
128
348
  errorMessage: row.error_message,
129
349
  occurredAt: row.occurred_at,
130
350
  receivedAt: row.received_at,
@@ -371,7 +591,7 @@ async function listAISessions(db, options) {
371
591
  COUNT(DISTINCT s.trace_id) AS trace_count
372
592
  FROM ai_span_payloads p
373
593
  INNER JOIN telemetry_spans s
374
- ON s.trace_id = p.trace_id AND s.span_id = p.span_id
594
+ ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
375
595
  WHERE p.project_id = ?
376
596
  AND p.session_id IS NOT NULL
377
597
  AND p.received_at >= ${dialect.sinceHours("?")}
@@ -434,7 +654,7 @@ async function getAISessionDetail(db, projectId, sessionId) {
434
654
  p.user_id AS user_id
435
655
  FROM ai_span_payloads p
436
656
  INNER JOIN telemetry_spans s
437
- ON s.trace_id = p.trace_id AND s.span_id = p.span_id
657
+ ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
438
658
  WHERE p.project_id = ? AND p.session_id = ?
439
659
  ORDER BY s.start_time ASC
440
660
  LIMIT 1000
@@ -478,7 +698,7 @@ async function loadSessionPreviews(db, dialect, projectId, hours, sessionIds) {
478
698
  SELECT p.session_id, p.input_json, s.start_time
479
699
  FROM ai_span_payloads p
480
700
  INNER JOIN telemetry_spans s
481
- ON s.trace_id = p.trace_id AND s.span_id = p.span_id
701
+ ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
482
702
  WHERE p.project_id = ?
483
703
  AND p.session_id IS NOT NULL
484
704
  AND p.session_id IN (${sessionIds.map(() => "?").join(", ")})
@@ -572,7 +792,7 @@ async function getAISpansOverview(db, options) {
572
792
  p.output_json AS output_json
573
793
  FROM ai_span_payloads p
574
794
  INNER JOIN telemetry_spans s
575
- ON s.trace_id = p.trace_id AND s.span_id = p.span_id
795
+ ON s.project_id = p.project_id AND s.trace_id = p.trace_id AND s.span_id = p.span_id
576
796
  WHERE p.project_id = ?
577
797
  AND p.received_at >= ${dialect.sinceHours("?")}
578
798
  `;
@@ -844,13 +1064,19 @@ var AnalysesStore = class {
844
1064
  if (analysisIds.length === 0) return /* @__PURE__ */ new Map();
845
1065
  const placeholders2 = analysisIds.map(() => "?").join(", ");
846
1066
  const result = await this.db.prepare(
847
- `SELECT id, project_id, analysis_id, generated_at, params_hash,
848
- status, primary_value, baseline_value, delta_pct, payload_json,
849
- narrative, narrative_signature, duration_ms, expires_at
850
- FROM analysis_results
851
- WHERE project_id = ? AND analysis_id IN (${placeholders2})
852
- ORDER BY generated_at DESC`
853
- ).bind(projectId, ...analysisIds).all();
1067
+ `SELECT r.id, r.project_id, r.analysis_id, r.generated_at, r.params_hash,
1068
+ r.status, r.primary_value, r.baseline_value, r.delta_pct, r.payload_json,
1069
+ r.narrative, r.narrative_signature, r.duration_ms, r.expires_at
1070
+ FROM analysis_results r
1071
+ JOIN (
1072
+ SELECT analysis_id, MAX(generated_at) AS latest_at
1073
+ FROM analysis_results
1074
+ WHERE project_id = ? AND analysis_id IN (${placeholders2})
1075
+ GROUP BY analysis_id
1076
+ ) m ON m.analysis_id = r.analysis_id AND m.latest_at = r.generated_at
1077
+ WHERE r.project_id = ?
1078
+ ORDER BY r.generated_at DESC, r.id DESC`
1079
+ ).bind(projectId, ...analysisIds, projectId).all();
854
1080
  const latest2 = /* @__PURE__ */ new Map();
855
1081
  for (const row of result.results ?? []) {
856
1082
  if (!latest2.has(row.analysis_id)) {
@@ -979,7 +1205,7 @@ var fetchShape = async (projectId, db) => {
979
1205
  FROM telemetry_spans
980
1206
  WHERE project_id = ?
981
1207
  AND service_name IS NOT NULL
982
- AND received_at >= datetime('now', '-1 hour')`
1208
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')`
983
1209
  ).bind(projectId).all();
984
1210
  const services = servicesRes.results.map((row) => row.service_name).filter(
985
1211
  (name) => typeof name === "string" && name.length > 0
@@ -993,7 +1219,7 @@ var fetchShape = async (projectId, db) => {
993
1219
  AND parent.trace_id = child.trace_id
994
1220
  AND parent.span_id = child.parent_span_id
995
1221
  WHERE child.project_id = ?
996
- AND child.received_at >= datetime('now', '-1 hour')
1222
+ AND child.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
997
1223
  AND parent.service_name IS NOT NULL
998
1224
  AND child.service_name IS NOT NULL
999
1225
  AND parent.service_name <> child.service_name`
@@ -1003,7 +1229,7 @@ var fetchShape = async (projectId, db) => {
1003
1229
  `SELECT DISTINCT json_extract(attributes_json, '$."messaging.destination"') AS topic
1004
1230
  FROM telemetry_spans
1005
1231
  WHERE project_id = ?
1006
- AND received_at >= datetime('now', '-1 hour')
1232
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1007
1233
  AND json_extract(attributes_json, '$."messaging.destination"') IS NOT NULL`
1008
1234
  ).bind(projectId).all();
1009
1235
  const messagingTopics = topicsRes.results.map((row) => row.topic).filter(
@@ -1013,7 +1239,7 @@ var fetchShape = async (projectId, db) => {
1013
1239
  `SELECT 1
1014
1240
  FROM telemetry_spans
1015
1241
  WHERE project_id = ?
1016
- AND received_at >= datetime('now', '-1 hour')
1242
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1017
1243
  AND (
1018
1244
  json_extract(attributes_json, '$."openinference.span.kind"') = 'LLM'
1019
1245
  OR json_extract(attributes_json, '$."gen_ai.system"') IS NOT NULL
@@ -1071,7 +1297,7 @@ var makeServiceErrorRate = (projectId, service) => ({
1071
1297
  FROM telemetry_spans
1072
1298
  WHERE project_id = '${sqlEscape(projectId)}'
1073
1299
  AND service_name = '${sqlEscape(service)}'
1074
- AND received_at >= datetime('now', '-5 minutes')
1300
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1075
1301
  ),
1076
1302
  base_window AS (
1077
1303
  SELECT
@@ -1080,8 +1306,8 @@ var makeServiceErrorRate = (projectId, service) => ({
1080
1306
  FROM telemetry_spans
1081
1307
  WHERE project_id = '${sqlEscape(projectId)}'
1082
1308
  AND service_name = '${sqlEscape(service)}'
1083
- AND received_at >= datetime('now', '-1 hour')
1084
- AND received_at < datetime('now', '-5 minutes')
1309
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1310
+ AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1085
1311
  ),
1086
1312
  spark AS (
1087
1313
  SELECT
@@ -1091,7 +1317,7 @@ var makeServiceErrorRate = (projectId, service) => ({
1091
1317
  FROM telemetry_spans
1092
1318
  WHERE project_id = '${sqlEscape(projectId)}'
1093
1319
  AND service_name = '${sqlEscape(service)}'
1094
- AND received_at >= datetime('now', '-30 minutes')
1320
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
1095
1321
  GROUP BY minute
1096
1322
  ORDER BY minute ASC
1097
1323
  )
@@ -1153,7 +1379,7 @@ var makeServiceLatencyP95 = (projectId, service) => ({
1153
1379
  FROM telemetry_spans
1154
1380
  WHERE project_id = '${sqlEscape(projectId)}'
1155
1381
  AND service_name = '${sqlEscape(service)}'
1156
- AND received_at >= datetime('now', '-1 hour')
1382
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1157
1383
  ),
1158
1384
  base AS (
1159
1385
  SELECT duration_ms,
@@ -1162,8 +1388,8 @@ var makeServiceLatencyP95 = (projectId, service) => ({
1162
1388
  FROM telemetry_spans
1163
1389
  WHERE project_id = '${sqlEscape(projectId)}'
1164
1390
  AND service_name = '${sqlEscape(service)}'
1165
- AND received_at >= datetime('now', '-25 hours')
1166
- AND received_at < datetime('now', '-24 hours')
1391
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-25 hours')
1392
+ AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-24 hours')
1167
1393
  ),
1168
1394
  cur_p95 AS (
1169
1395
  SELECT duration_ms FROM cur
@@ -1228,7 +1454,7 @@ var makeDependencyHealth = (projectId, source, target) => ({
1228
1454
  WHERE child.project_id = '${sqlEscape(projectId)}'
1229
1455
  AND parent.service_name = '${sqlEscape(source)}'
1230
1456
  AND child.service_name = '${sqlEscape(target)}'
1231
- AND child.received_at >= datetime('now', '-5 minutes')
1457
+ AND child.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1232
1458
  ),
1233
1459
  edge_base AS (
1234
1460
  SELECT child.status_code AS sc
@@ -1240,8 +1466,8 @@ var makeDependencyHealth = (projectId, source, target) => ({
1240
1466
  WHERE child.project_id = '${sqlEscape(projectId)}'
1241
1467
  AND parent.service_name = '${sqlEscape(source)}'
1242
1468
  AND child.service_name = '${sqlEscape(target)}'
1243
- AND child.received_at >= datetime('now', '-1 hour')
1244
- AND child.received_at < datetime('now', '-5 minutes')
1469
+ AND child.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1470
+ AND child.received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1245
1471
  ),
1246
1472
  now_agg AS (
1247
1473
  SELECT COUNT(*) AS total,
@@ -1302,7 +1528,7 @@ var makeMessagingLag = (projectId, topic) => ({
1302
1528
  WHERE project_id = '${sqlEscape(projectId)}'
1303
1529
  AND span_kind = 4
1304
1530
  AND json_extract(attributes_json, '$."messaging.destination"') = '${sqlEscape(topic)}'
1305
- AND received_at >= datetime('now', '-15 minutes')
1531
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-15 minutes')
1306
1532
  ),
1307
1533
  consumers AS (
1308
1534
  SELECT trace_id,
@@ -1312,7 +1538,7 @@ var makeMessagingLag = (projectId, topic) => ({
1312
1538
  WHERE project_id = '${sqlEscape(projectId)}'
1313
1539
  AND span_kind = 5
1314
1540
  AND json_extract(attributes_json, '$."messaging.destination"') = '${sqlEscape(topic)}'
1315
- AND received_at >= datetime('now', '-15 minutes')
1541
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-15 minutes')
1316
1542
  ),
1317
1543
  gaps AS (
1318
1544
  SELECT
@@ -1377,15 +1603,15 @@ var makeAiCostBurn = (projectId) => ({
1377
1603
  COUNT(*) AS n
1378
1604
  FROM ai_calls
1379
1605
  WHERE project_id = '${sqlEscape(projectId)}'
1380
- AND received_at >= datetime('now', '-1 hour')
1606
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1381
1607
  ),
1382
1608
  prev AS (
1383
1609
  SELECT COALESCE(SUM(total_cost_usd), 0) AS cost,
1384
1610
  COUNT(*) AS n
1385
1611
  FROM ai_calls
1386
1612
  WHERE project_id = '${sqlEscape(projectId)}'
1387
- AND received_at >= datetime('now', '-2 hours')
1388
- AND received_at < datetime('now', '-1 hour')
1613
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-2 hours')
1614
+ AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1389
1615
  ),
1390
1616
  delta AS (
1391
1617
  SELECT CASE
@@ -1422,25 +1648,26 @@ var makeAiErrorRate = (projectId) => ({
1422
1648
  // AI error rate is per-call; 5 min cadence balances signal vs cost on
1423
1649
  // installs with hundreds of LLM spans.
1424
1650
  refreshSeconds: 300,
1425
- // is_error = 1 fraction in the last 5 minutes vs trailing 1h baseline.
1651
+ // is_error fraction in the last 5 minutes vs trailing 1h baseline. Tested
1652
+ // as a bare boolean so it works for INTEGER 0/1 (D1) and BOOLEAN (Postgres).
1426
1653
  // Same thresholds as overall_error_rate.
1427
1654
  sql: `
1428
1655
  WITH now_window AS (
1429
1656
  SELECT
1430
1657
  COUNT(*) AS total,
1431
- SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) AS errors
1658
+ SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS errors
1432
1659
  FROM ai_calls
1433
1660
  WHERE project_id = '${sqlEscape(projectId)}'
1434
- AND received_at >= datetime('now', '-5 minutes')
1661
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1435
1662
  ),
1436
1663
  base_window AS (
1437
1664
  SELECT
1438
1665
  COUNT(*) AS total,
1439
- SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) AS errors
1666
+ SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS errors
1440
1667
  FROM ai_calls
1441
1668
  WHERE project_id = '${sqlEscape(projectId)}'
1442
- AND received_at >= datetime('now', '-1 hour')
1443
- AND received_at < datetime('now', '-5 minutes')
1669
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1670
+ AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1444
1671
  )
1445
1672
  SELECT
1446
1673
  CASE
@@ -1505,7 +1732,7 @@ var errorTopOffenders = {
1505
1732
  COUNT(*) AS errors
1506
1733
  FROM telemetry_spans
1507
1734
  WHERE project_id = '{{PROJECT_ID}}'
1508
- AND received_at >= datetime('now', '-5 minutes')
1735
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1509
1736
  AND status_code = 2
1510
1737
  GROUP BY service_name
1511
1738
  ORDER BY errors DESC
@@ -1516,8 +1743,8 @@ var errorTopOffenders = {
1516
1743
  COUNT(*) AS errors
1517
1744
  FROM telemetry_spans
1518
1745
  WHERE project_id = '{{PROJECT_ID}}'
1519
- AND received_at >= datetime('now', '-1 hour')
1520
- AND received_at < datetime('now', '-5 minutes')
1746
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1747
+ AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1521
1748
  AND status_code = 2
1522
1749
  GROUP BY service_name
1523
1750
  ORDER BY errors DESC
@@ -1579,7 +1806,7 @@ var latencyOutlierAttribution = {
1579
1806
  COUNT(*) OVER () AS n
1580
1807
  FROM telemetry_spans
1581
1808
  WHERE project_id = '{{PROJECT_ID}}'
1582
- AND received_at >= datetime('now', '-15 minutes')
1809
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-15 minutes')
1583
1810
  ),
1584
1811
  p95_cut AS (
1585
1812
  -- Conservatively call "tail" the slowest 5% of the window.
@@ -1647,12 +1874,12 @@ var logAnomalySummary = {
1647
1874
  (SELECT l2.message FROM logs l2
1648
1875
  WHERE l2.project_id = '{{PROJECT_ID}}'
1649
1876
  AND COALESCE(l2.logger_name, '<unknown>') = COALESCE(l1.logger_name, '<unknown>')
1650
- AND l2.received_at >= datetime('now', '-5 minutes')
1877
+ AND l2.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1651
1878
  AND l2.severity_number >= 17
1652
1879
  ORDER BY l2.received_at DESC LIMIT 1) AS sample_message
1653
1880
  FROM logs l1
1654
1881
  WHERE l1.project_id = '{{PROJECT_ID}}'
1655
- AND l1.received_at >= datetime('now', '-5 minutes')
1882
+ AND l1.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1656
1883
  AND l1.severity_number >= 17
1657
1884
  GROUP BY l1.logger_name
1658
1885
  ),
@@ -1660,8 +1887,8 @@ var logAnomalySummary = {
1660
1887
  SELECT DISTINCT COALESCE(logger_name, '<unknown>') AS logger
1661
1888
  FROM logs
1662
1889
  WHERE project_id = '{{PROJECT_ID}}'
1663
- AND received_at >= datetime('now', '-1 hour')
1664
- AND received_at < datetime('now', '-5 minutes')
1890
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1891
+ AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1665
1892
  AND severity_number >= 17
1666
1893
  ),
1667
1894
  newcomers AS (
@@ -1738,7 +1965,7 @@ var overallErrorRate = {
1738
1965
  SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
1739
1966
  FROM telemetry_spans
1740
1967
  WHERE project_id = '{{PROJECT_ID}}'
1741
- AND received_at >= datetime('now', '-5 minutes')
1968
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1742
1969
  ),
1743
1970
  base_window AS (
1744
1971
  SELECT
@@ -1746,8 +1973,8 @@ var overallErrorRate = {
1746
1973
  SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
1747
1974
  FROM telemetry_spans
1748
1975
  WHERE project_id = '{{PROJECT_ID}}'
1749
- AND received_at >= datetime('now', '-1 hour')
1750
- AND received_at < datetime('now', '-5 minutes')
1976
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1977
+ AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1751
1978
  ),
1752
1979
  spark AS (
1753
1980
  -- per-minute error rate over the last 30 minutes for sparkline rendering
@@ -1757,7 +1984,7 @@ var overallErrorRate = {
1757
1984
  SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
1758
1985
  FROM telemetry_spans
1759
1986
  WHERE project_id = '{{PROJECT_ID}}'
1760
- AND received_at >= datetime('now', '-30 minutes')
1987
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
1761
1988
  GROUP BY minute
1762
1989
  ORDER BY minute ASC
1763
1990
  )
@@ -1814,7 +2041,7 @@ var topErrorServices = {
1814
2041
  SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS errors
1815
2042
  FROM telemetry_spans
1816
2043
  WHERE project_id = '{{PROJECT_ID}}'
1817
- AND received_at >= datetime('now', '-5 minutes')
2044
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
1818
2045
  GROUP BY service
1819
2046
  ),
1820
2047
  top5 AS (
@@ -1866,7 +2093,7 @@ var latencyP95Overall = {
1866
2093
  COUNT(*) OVER () AS n
1867
2094
  FROM telemetry_spans
1868
2095
  WHERE project_id = '{{PROJECT_ID}}'
1869
- AND received_at >= datetime('now', '-1 hour')
2096
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1870
2097
  ),
1871
2098
  base AS (
1872
2099
  SELECT duration_ms,
@@ -1874,8 +2101,8 @@ var latencyP95Overall = {
1874
2101
  COUNT(*) OVER () AS n
1875
2102
  FROM telemetry_spans
1876
2103
  WHERE project_id = '{{PROJECT_ID}}'
1877
- AND received_at >= datetime('now', '-25 hours')
1878
- AND received_at < datetime('now', '-24 hours')
2104
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-25 hours')
2105
+ AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-24 hours')
1879
2106
  ),
1880
2107
  cur_p95 AS (
1881
2108
  SELECT duration_ms FROM cur
@@ -1897,7 +2124,7 @@ var latencyP95Overall = {
1897
2124
  COUNT(*) AS n
1898
2125
  FROM telemetry_spans
1899
2126
  WHERE project_id = '{{PROJECT_ID}}'
1900
- AND received_at >= datetime('now', '-1 hour')
2127
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
1901
2128
  GROUP BY bucket
1902
2129
  ORDER BY bucket ASC
1903
2130
  ),
@@ -1949,7 +2176,7 @@ var throughputSlope = {
1949
2176
  COUNT(*) OVER () AS n
1950
2177
  FROM telemetry_spans
1951
2178
  WHERE project_id = '{{PROJECT_ID}}'
1952
- AND received_at >= datetime('now', '-30 minutes')
2179
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
1953
2180
  ),
1954
2181
  prev AS (
1955
2182
  SELECT duration_ms,
@@ -1957,8 +2184,8 @@ var throughputSlope = {
1957
2184
  COUNT(*) OVER () AS n
1958
2185
  FROM telemetry_spans
1959
2186
  WHERE project_id = '{{PROJECT_ID}}'
1960
- AND received_at >= datetime('now', '-60 minutes')
1961
- AND received_at < datetime('now', '-30 minutes')
2187
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-60 minutes')
2188
+ AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
1962
2189
  ),
1963
2190
  cur_n AS (SELECT COALESCE((SELECT n FROM cur LIMIT 1), 0) AS n),
1964
2191
  prev_n AS (SELECT COALESCE((SELECT n FROM prev LIMIT 1), 0) AS n),
@@ -2020,7 +2247,7 @@ var activeSessions = {
2020
2247
  SELECT COUNT(DISTINCT session_id) AS n
2021
2248
  FROM usage_events
2022
2249
  WHERE project_id = '{{PROJECT_ID}}'
2023
- AND received_at >= datetime('now', '-15 minutes')
2250
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-15 minutes')
2024
2251
  ),
2025
2252
  hourly AS (
2026
2253
  SELECT
@@ -2028,7 +2255,7 @@ var activeSessions = {
2028
2255
  COUNT(DISTINCT session_id) AS sessions
2029
2256
  FROM usage_events
2030
2257
  WHERE project_id = '{{PROJECT_ID}}'
2031
- AND received_at >= datetime('now', '-24 hours')
2258
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-24 hours')
2032
2259
  GROUP BY hour
2033
2260
  ORDER BY hour ASC
2034
2261
  )
@@ -2066,7 +2293,7 @@ var logErrorRate = {
2066
2293
  SUM(CASE WHEN severity_number >= 17 THEN 1 ELSE 0 END) AS errors
2067
2294
  FROM logs
2068
2295
  WHERE project_id = '{{PROJECT_ID}}'
2069
- AND received_at >= datetime('now', '-5 minutes')
2296
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
2070
2297
  ),
2071
2298
  base_window AS (
2072
2299
  SELECT
@@ -2074,8 +2301,8 @@ var logErrorRate = {
2074
2301
  SUM(CASE WHEN severity_number >= 17 THEN 1 ELSE 0 END) AS errors
2075
2302
  FROM logs
2076
2303
  WHERE project_id = '{{PROJECT_ID}}'
2077
- AND received_at >= datetime('now', '-1 hour')
2078
- AND received_at < datetime('now', '-5 minutes')
2304
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 hour')
2305
+ AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
2079
2306
  ),
2080
2307
  spark AS (
2081
2308
  SELECT
@@ -2084,7 +2311,7 @@ var logErrorRate = {
2084
2311
  SUM(CASE WHEN severity_number >= 17 THEN 1 ELSE 0 END) AS errors
2085
2312
  FROM logs
2086
2313
  WHERE project_id = '{{PROJECT_ID}}'
2087
- AND received_at >= datetime('now', '-30 minutes')
2314
+ AND received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
2088
2315
  GROUP BY minute
2089
2316
  ORDER BY minute ASC
2090
2317
  )
@@ -2144,19 +2371,19 @@ var serviceCpuUtilization = {
2144
2371
  WHERE s.project_id = '{{PROJECT_ID}}'
2145
2372
  AND s.name = 'process.cpu.utilization'
2146
2373
  AND s.service_name IS NOT NULL
2147
- AND p.received_at >= datetime('now', '-30 minutes')
2374
+ AND p.received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
2148
2375
  ),
2149
2376
  now_window AS (
2150
2377
  SELECT service_name, AVG(value) AS avg_util
2151
2378
  FROM cpu_points
2152
- WHERE received_at >= datetime('now', '-5 minutes')
2379
+ WHERE received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
2153
2380
  GROUP BY service_name
2154
2381
  ),
2155
2382
  base_window AS (
2156
2383
  SELECT service_name, AVG(value) AS avg_util
2157
2384
  FROM cpu_points
2158
- WHERE received_at >= datetime('now', '-30 minutes')
2159
- AND received_at < datetime('now', '-5 minutes')
2385
+ WHERE received_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 minutes')
2386
+ AND received_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 minutes')
2160
2387
  GROUP BY service_name
2161
2388
  ),
2162
2389
  top_now AS (
@@ -2497,7 +2724,35 @@ async function runAllDueAnalyses(ctx, _runtime) {
2497
2724
  const logger = ctx.logger ?? consoleLogger;
2498
2725
  const db = sqlDbFor(ctx.env);
2499
2726
  const store = new AnalysesStore(db);
2500
- const projectId = "default";
2727
+ const totals = { ran: 0, failed: 0, refreshed: 0, narrated: 0, skipped: 0 };
2728
+ for (const projectId of await listAnalysisProjects(db, logger)) {
2729
+ const summary = await runDueAnalysesForProject(projectId, {
2730
+ ctx,
2731
+ db,
2732
+ store,
2733
+ logger
2734
+ });
2735
+ totals.ran += summary.ran;
2736
+ totals.failed += summary.failed;
2737
+ totals.refreshed += summary.refreshed;
2738
+ totals.narrated += summary.narrated;
2739
+ totals.skipped += summary.skipped ?? 0;
2740
+ }
2741
+ return totals;
2742
+ }
2743
+ async function listAnalysisProjects(db, logger) {
2744
+ try {
2745
+ const ids = (await new ProjectsStore(db).listProjects()).map((p) => p.id).filter((id) => typeof id === "string" && id.length > 0);
2746
+ if (ids.length > 0) return ids;
2747
+ } catch (error) {
2748
+ logger.error("[analyses] failed to list projects; running default only", {
2749
+ error: error instanceof Error ? error.message : String(error)
2750
+ });
2751
+ }
2752
+ return ["default"];
2753
+ }
2754
+ async function runDueAnalysesForProject(projectId, deps) {
2755
+ const { ctx, db, store, logger } = deps;
2501
2756
  const now = Date.now();
2502
2757
  const expiresAt = now + ctx.retentionHours * 3600 * 1e3;
2503
2758
  let registered = [];
@@ -2618,6 +2873,42 @@ async function runAllDueAnalyses(ctx, _runtime) {
2618
2873
  return { ran, failed, refreshed, narrated, skipped };
2619
2874
  }
2620
2875
 
2876
+ // src/lib/housekeeping.ts
2877
+ var ALERT_EVALUATIONS_MAX_AGE_HOURS = 720;
2878
+ var ASK_EVIDENCE_EVENTS_MAX_AGE_HOURS = 30 * 24;
2879
+ var changes = (r) => r.meta?.changes ?? 0;
2880
+ async function purgeSecondaryRows(db, options) {
2881
+ const nowMs = options.now?.() ?? Date.now();
2882
+ const isoBefore = (hours) => new Date(nowMs - hours * 36e5).toISOString();
2883
+ const alertEvaluations = await db.prepare(`DELETE FROM alert_evaluations WHERE evaluated_at < ?`).bind(isoBefore(ALERT_EVALUATIONS_MAX_AGE_HOURS)).run();
2884
+ const askEvents = await db.prepare(`DELETE FROM ask_evidence_events WHERE asked_at < ?`).bind(nowMs - ASK_EVIDENCE_EVENTS_MAX_AGE_HOURS * 36e5).run();
2885
+ const series = await db.prepare(
2886
+ `DELETE FROM metric_series
2887
+ WHERE created_at < ?
2888
+ AND NOT EXISTS (
2889
+ SELECT 1 FROM metric_point p WHERE p.series_id = metric_series.id
2890
+ )`
2891
+ ).bind(isoBefore(options.retentionHours)).run();
2892
+ const refs = await db.prepare(
2893
+ `DELETE FROM evidence_retrieval_refs
2894
+ WHERE expires_at IS NOT NULL AND expires_at < ?`
2895
+ ).bind(isoBefore(0)).run();
2896
+ const expansions = await db.prepare(
2897
+ `DELETE FROM evidence_ref_expansions
2898
+ WHERE NOT EXISTS (
2899
+ SELECT 1 FROM evidence_retrieval_refs r
2900
+ WHERE r.ref_id = evidence_ref_expansions.ref_id
2901
+ )`
2902
+ ).run();
2903
+ return {
2904
+ alert_evaluations: changes(alertEvaluations),
2905
+ ask_evidence_events: changes(askEvents),
2906
+ metric_series: changes(series),
2907
+ evidence_refs: changes(refs),
2908
+ evidence_ref_expansions: changes(expansions)
2909
+ };
2910
+ }
2911
+
2621
2912
  // src/lib/logs-store.ts
2622
2913
  var LOG_SEVERITIES = /* @__PURE__ */ new Set([
2623
2914
  "DEBUG",
@@ -2913,18 +3204,20 @@ var MetricsStore = class {
2913
3204
  }
2914
3205
  }
2915
3206
  const insertStmt = this.db.prepare(`
2916
- INSERT INTO metric_series (
3207
+ INSERT OR IGNORE INTO metric_series (
2917
3208
  id, project_id, name, description, unit, type,
2918
3209
  is_monotonic, temporality, scope_name, scope_version, service_name,
2919
3210
  resource_attrs_json, attributes_json, identity, created_at
2920
3211
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2921
3212
  `);
2922
3213
  const toInsert = [];
3214
+ const attemptedIds = /* @__PURE__ */ new Set();
2923
3215
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
2924
3216
  for (const [identity, p] of byIdentity.entries()) {
2925
3217
  if (existing.has(identity)) continue;
2926
3218
  const id = crypto.randomUUID();
2927
3219
  existing.set(identity, { id, identity });
3220
+ attemptedIds.add(identity);
2928
3221
  toInsert.push(
2929
3222
  insertStmt.bind(
2930
3223
  id,
@@ -2945,7 +3238,20 @@ var MetricsStore = class {
2945
3238
  )
2946
3239
  );
2947
3240
  }
2948
- if (toInsert.length) await this.db.batch(toInsert);
3241
+ if (toInsert.length === 0) return existing;
3242
+ await this.db.batch(toInsert);
3243
+ const attempted = identities.filter(
3244
+ (identity) => attemptedIds.has(identity)
3245
+ );
3246
+ for (let i = 0; i < attempted.length; i += 50) {
3247
+ const chunk = attempted.slice(i, i + 50);
3248
+ const placeholders2 = chunk.map(() => "?").join(",");
3249
+ const rows = await this.db.prepare(
3250
+ `SELECT id, identity FROM metric_series
3251
+ WHERE project_id = ? AND identity IN (${placeholders2})`
3252
+ ).bind(projectId, ...chunk).all();
3253
+ for (const row of rows.results) existing.set(row.identity, row);
3254
+ }
2949
3255
  return existing;
2950
3256
  }
2951
3257
  };
@@ -4645,6 +4951,15 @@ var UsageStore = class {
4645
4951
  }
4646
4952
  };
4647
4953
 
4954
+ // src/framework/limits.ts
4955
+ var DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
4956
+ var DEFAULT_MAX_AUTH_BODY_BYTES = 16 * 1024;
4957
+ var DEFAULT_MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
4958
+ var parseByteLimit = (raw, fallback) => {
4959
+ const parsed = Number.parseInt(raw ?? "", 10);
4960
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
4961
+ };
4962
+
4648
4963
  // src/framework/collector.ts
4649
4964
  var defaultSqlDbFactory = (env) => new D1Adapter(env.DB);
4650
4965
  var CollectorRuntime = class {
@@ -4734,6 +5049,7 @@ var CollectorRuntime = class {
4734
5049
  return nextEvents;
4735
5050
  }
4736
5051
  };
5052
+ var isDashboardLoginPath = (path) => path === "/dashboard/login" || path.startsWith("/dashboard/login/");
4737
5053
  var createTelemetryCollectorApp = (pluginsOrConfig) => {
4738
5054
  const config = Array.isArray(pluginsOrConfig) ? { plugins: pluginsOrConfig } : pluginsOrConfig;
4739
5055
  const app = new Hono();
@@ -4773,19 +5089,43 @@ var createTelemetryCollectorApp = (pluginsOrConfig) => {
4773
5089
  c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
4774
5090
  }
4775
5091
  });
5092
+ const bodyCap = (c, next) => {
5093
+ const maxSize = config.maxBodyBytes ?? parseByteLimit(c.env?.MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
5094
+ return bodyLimit({
5095
+ maxSize,
5096
+ onError: (ctx) => ctx.json({ error: `Payload too large (max ${maxSize} bytes)` }, 413)
5097
+ })(c, next);
5098
+ };
5099
+ app.use("/v1/*", bodyCap);
5100
+ app.use("/internal/*", bodyCap);
5101
+ const authMaxSize = config.maxAuthBodyBytes ?? DEFAULT_MAX_AUTH_BODY_BYTES;
5102
+ app.use(
5103
+ "/auth/*",
5104
+ bodyLimit({
5105
+ maxSize: authMaxSize,
5106
+ onError: (ctx) => ctx.json(
5107
+ { error: `Payload too large (max ${authMaxSize} bytes)` },
5108
+ 413
5109
+ )
5110
+ })
5111
+ );
4776
5112
  if (config.auth?.middleware) {
4777
5113
  app.use("/v1/*", config.auth.middleware);
4778
5114
  } else if (config.auth?.ingest) {
4779
5115
  app.use("/v1/*", config.auth.ingest);
4780
5116
  }
4781
5117
  if (config.dashboardAuth) {
5118
+ const { middleware } = config.dashboardAuth;
4782
5119
  config.dashboardAuth.registerRoutes(app);
4783
- app.use("/dashboard/*", config.dashboardAuth.middleware);
4784
- app.use("/internal/*", config.dashboardAuth.middleware);
4785
- } else if (config.auth?.middleware) {
4786
- app.use("/internal/*", config.auth.middleware);
4787
- } else if (config.auth?.query) {
4788
- app.use("/internal/*", config.auth.query);
5120
+ const guard = async (c, next) => isDashboardLoginPath(c.req.path) ? next() : middleware(c, next);
5121
+ app.use("/dashboard/*", guard);
5122
+ app.use("/internal/*", middleware);
5123
+ } else {
5124
+ app.use("/internal/*", async (c) => c.json({ error: "Unauthorized" }, 401));
5125
+ app.use(
5126
+ "/dashboard/*",
5127
+ async (c) => c.json({ error: "Unauthorized" }, 401)
5128
+ );
4789
5129
  }
4790
5130
  for (const plugin of config.plugins) {
4791
5131
  runtime.registerPlugin(plugin);
@@ -4799,101 +5139,199 @@ var createTelemetryCollectorApp = (pluginsOrConfig) => {
4799
5139
  });
4800
5140
  return app;
4801
5141
  };
5142
+ var PROFILE_PURGE_PAGE_SIZE = 500;
5143
+ var PROFILE_PURGE_MAX_PAGES = 10;
4802
5144
  var createRetentionCleanupHandler = (options) => {
4803
5145
  const logger = options?.logger ?? consoleLogger;
4804
- return {
4805
- async scheduled(_event, env, _ctx) {
4806
- const db = sqlDbFor(env);
4807
- const telemetryStore = new TelemetryStore(db);
4808
- const usageStore = new UsageStore(db);
4809
- const logsStore = new LogsStore(db);
4810
- const aiStore = new AIStore(db);
4811
- const metricsStore = new MetricsStore(db);
4812
- const analysesStore = new AnalysesStore(db);
4813
- const [
4814
- telemetryPurged,
4815
- usagePurged,
4816
- logsPurged,
4817
- aiPurged,
4818
- metricsPurged,
4819
- analysesPurged
4820
- ] = await Promise.all([
4821
- telemetryStore.purgeExpired(),
4822
- usageStore.purgeExpired(),
4823
- logsStore.purgeExpired(),
4824
- aiStore.purgeExpired(),
4825
- metricsStore.purgeExpired(),
4826
- analysesStore.purgeExpired()
4827
- ]);
4828
- let profilesPurged = 0;
4829
- try {
4830
- const dialect = dialectFor(db);
4831
- const expiredProfiles = await db.prepare(
4832
- `SELECT id, blob_url FROM profile_blobs WHERE expires_at < ${dialect.now()} LIMIT 500`
5146
+ const run = async (env) => {
5147
+ const db = sqlDbFor(env);
5148
+ const telemetryStore = new TelemetryStore(db);
5149
+ const usageStore = new UsageStore(db);
5150
+ const logsStore = new LogsStore(db);
5151
+ const aiStore = new AIStore(db);
5152
+ const metricsStore = new MetricsStore(db);
5153
+ const analysesStore = new AnalysesStore(db);
5154
+ const [
5155
+ telemetryPurged,
5156
+ usagePurged,
5157
+ logsPurged,
5158
+ aiPurged,
5159
+ metricsPurged,
5160
+ analysesPurged
5161
+ ] = await Promise.all([
5162
+ telemetryStore.purgeExpired(),
5163
+ usageStore.purgeExpired(),
5164
+ logsStore.purgeExpired(),
5165
+ aiStore.purgeExpired(),
5166
+ metricsStore.purgeExpired(),
5167
+ analysesStore.purgeExpired()
5168
+ ]);
5169
+ let profilesPurged = 0;
5170
+ try {
5171
+ const dialect = dialectFor(db);
5172
+ if (!env.PROFILES_BUCKET) {
5173
+ logger.warn(
5174
+ "[retention-cleanup] PROFILES_BUCKET is not bound; expired profile rows are removed without deleting their blobs"
5175
+ );
5176
+ }
5177
+ for (let page = 0; page < PROFILE_PURGE_MAX_PAGES; page += 1) {
5178
+ const expired = await db.prepare(
5179
+ `SELECT id, blob_url FROM profile_blobs WHERE expires_at < ${dialect.now()} LIMIT ${PROFILE_PURGE_PAGE_SIZE}`
4833
5180
  ).all();
4834
- if (expiredProfiles.results.length > 0 && env.PROFILES_BUCKET) {
4835
- await Promise.allSettled(
4836
- expiredProfiles.results.map(
4837
- (p) => env.PROFILES_BUCKET?.delete(p.blob_url)
4838
- )
5181
+ if (expired.results.length === 0) break;
5182
+ const removable = [];
5183
+ if (env.PROFILES_BUCKET) {
5184
+ const bucket = env.PROFILES_BUCKET;
5185
+ const settled = await Promise.allSettled(
5186
+ expired.results.map((p) => bucket.delete(p.blob_url))
4839
5187
  );
5188
+ settled.forEach((outcome, i) => {
5189
+ const row = expired.results[i];
5190
+ if (!row) return;
5191
+ if (outcome.status === "fulfilled") removable.push(row.id);
5192
+ else
5193
+ logger.warn(
5194
+ "[retention-cleanup] profile blob delete failed; row kept for retry",
5195
+ {
5196
+ profile_id: row.id,
5197
+ error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)
5198
+ }
5199
+ );
5200
+ });
5201
+ } else {
5202
+ removable.push(...expired.results.map((p) => p.id));
4840
5203
  }
4841
- const deleted = await db.prepare(
4842
- `DELETE FROM profile_blobs WHERE expires_at < ${dialect.now()}`
4843
- ).run();
4844
- profilesPurged = deleted.meta.changes;
4845
- } catch (err) {
4846
- logger.error("[retention-cleanup] profile purge failed", {
4847
- error: err instanceof Error ? err.message : String(err)
4848
- });
5204
+ for (let i = 0; i < removable.length; i += 90) {
5205
+ const chunk = removable.slice(i, i + 90);
5206
+ const deleted = await db.prepare(
5207
+ `DELETE FROM profile_blobs WHERE id IN (${chunk.map(() => "?").join(", ")})`
5208
+ ).bind(...chunk).run();
5209
+ profilesPurged += deleted.meta.changes;
5210
+ }
5211
+ if (expired.results.length < PROFILE_PURGE_PAGE_SIZE || removable.length < expired.results.length)
5212
+ break;
4849
5213
  }
4850
- logger.info("[retention-cleanup] purged expired rows", {
4851
- spans: telemetryPurged,
4852
- usage: usagePurged,
4853
- logs: logsPurged,
4854
- ai_calls: aiPurged,
4855
- metric_points: metricsPurged,
4856
- analysis_results: analysesPurged,
4857
- profile_blobs: profilesPurged
5214
+ } catch (err) {
5215
+ logger.error("[retention-cleanup] profile purge failed", {
5216
+ error: err instanceof Error ? err.message : String(err)
4858
5217
  });
4859
- try {
4860
- const propagation = await aggregatePropagation(db, /* @__PURE__ */ new Date(), logger);
4861
- logger.info("[propagation-metric] hourly aggregation complete", {
4862
- projects: propagation.projects,
4863
- points_written: propagation.pointsWritten
4864
- });
4865
- } catch (err) {
4866
- logger.error("[propagation-metric] aggregation failed", {
4867
- error: err instanceof Error ? err.message : String(err)
4868
- });
5218
+ }
5219
+ let housekeeping = {
5220
+ alert_evaluations: 0,
5221
+ ask_evidence_events: 0,
5222
+ metric_series: 0,
5223
+ evidence_refs: 0,
5224
+ evidence_ref_expansions: 0
5225
+ };
5226
+ try {
5227
+ housekeeping = await purgeSecondaryRows(db, {
5228
+ retentionHours: getConfiguredRetentionHours(env.RETENTION_HOURS)
5229
+ });
5230
+ } catch (err) {
5231
+ logger.error("[retention-cleanup] housekeeping purge failed", {
5232
+ error: err instanceof Error ? err.message : String(err)
5233
+ });
5234
+ }
5235
+ let actionGraph;
5236
+ try {
5237
+ actionGraph = await purgeExpiredActionGraph(db, {
5238
+ retentionHours: getConfiguredRetentionHours(env.RETENTION_HOURS)
5239
+ });
5240
+ if (actionGraph.truncated) {
5241
+ logger.warn(
5242
+ "[retention-cleanup] action graph purge hit its batch cap; remaining expired trees wait for the next run",
5243
+ { batches: actionGraph.batches, trees: actionGraph.trees }
5244
+ );
5245
+ }
5246
+ if (actionGraph.oversized_trees > 0) {
5247
+ logger.warn(
5248
+ "[retention-cleanup] expired action trees retained because they exceed the per-batch action cap",
5249
+ {
5250
+ oversized_trees: actionGraph.oversized_trees,
5251
+ trees: actionGraph.oversized
5252
+ }
5253
+ );
4869
5254
  }
5255
+ } catch (err) {
5256
+ logger.error("[retention-cleanup] action graph purge failed", {
5257
+ error: err instanceof Error ? err.message : String(err)
5258
+ });
5259
+ }
5260
+ const summary = {
5261
+ spans: telemetryPurged,
5262
+ usage: usagePurged,
5263
+ logs: logsPurged,
5264
+ ai_calls: aiPurged,
5265
+ metric_points: metricsPurged,
5266
+ analysis_results: analysesPurged,
5267
+ profile_blobs: profilesPurged,
5268
+ housekeeping,
5269
+ actions: actionGraph?.actions ?? 0,
5270
+ action_leaves: actionGraph ? actionGraph.agent_runs + actionGraph.tool_calls + actionGraph.retrieval_events + actionGraph.eval_results + actionGraph.artifacts : 0
5271
+ };
5272
+ logger.info("[retention-cleanup] purged expired rows", {
5273
+ ...summary,
5274
+ ...actionGraph ? {
5275
+ action_agent_runs: actionGraph.agent_runs,
5276
+ action_tool_calls: actionGraph.tool_calls,
5277
+ action_retrieval_events: actionGraph.retrieval_events,
5278
+ action_eval_results: actionGraph.eval_results,
5279
+ action_artifacts: actionGraph.artifacts,
5280
+ action_trees: actionGraph.trees,
5281
+ action_batches: actionGraph.batches,
5282
+ action_oversized_trees: actionGraph.oversized_trees
5283
+ } : {}
5284
+ });
5285
+ try {
5286
+ const propagation = await aggregatePropagation(db, /* @__PURE__ */ new Date(), logger);
5287
+ logger.info("[propagation-metric] hourly aggregation complete", {
5288
+ projects: propagation.projects,
5289
+ points_written: propagation.pointsWritten
5290
+ });
5291
+ } catch (err) {
5292
+ logger.error("[propagation-metric] aggregation failed", {
5293
+ error: err instanceof Error ? err.message : String(err)
5294
+ });
5295
+ }
5296
+ return summary;
5297
+ };
5298
+ return {
5299
+ run,
5300
+ scheduled: async (_event, env, _ctx) => {
5301
+ await run(env);
4870
5302
  }
4871
5303
  };
4872
5304
  };
4873
5305
  var createAnalysesRunHandler = (options) => {
4874
5306
  const logger = options?.logger ?? consoleLogger;
4875
5307
  const tracer = options?.tracer;
5308
+ const run = async (env) => {
5309
+ try {
5310
+ const retentionHours = getConfiguredRetentionHours(env.RETENTION_HOURS);
5311
+ const summary = await runAllDueAnalyses({
5312
+ env,
5313
+ retentionHours,
5314
+ logger,
5315
+ tracer
5316
+ });
5317
+ logger.info("[analyses] scheduled run summary", {
5318
+ refreshed: summary.refreshed,
5319
+ ran: summary.ran,
5320
+ failed: summary.failed,
5321
+ narrated: summary.narrated
5322
+ });
5323
+ return summary;
5324
+ } catch (error) {
5325
+ logger.error("[analyses] scheduled run failed", {
5326
+ error: error instanceof Error ? error.message : String(error)
5327
+ });
5328
+ return void 0;
5329
+ }
5330
+ };
4876
5331
  return {
4877
- async scheduled(_event, env, _ctx) {
4878
- try {
4879
- const retentionHours = getConfiguredRetentionHours(env.RETENTION_HOURS);
4880
- const summary = await runAllDueAnalyses({
4881
- env,
4882
- retentionHours,
4883
- logger,
4884
- tracer
4885
- });
4886
- logger.info("[analyses] scheduled run summary", {
4887
- refreshed: summary.refreshed,
4888
- ran: summary.ran,
4889
- failed: summary.failed,
4890
- narrated: summary.narrated
4891
- });
4892
- } catch (error) {
4893
- logger.error("[analyses] scheduled run failed", {
4894
- error: error instanceof Error ? error.message : String(error)
4895
- });
4896
- }
5332
+ run,
5333
+ scheduled: async (_event, env, _ctx) => {
5334
+ await run(env);
4897
5335
  }
4898
5336
  };
4899
5337
  };
@@ -5666,8 +6104,7 @@ var actionGraphProcessorPlugin = {
5666
6104
  span_id, session_id, interaction_id, user_id, agent_run_id, step_id,
5667
6105
  tool_call_id, prompt_version, model_name, provider, total_cost_usd, attrs_json
5668
6106
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5669
- ON CONFLICT (id) DO UPDATE SET
5670
- project_id = excluded.project_id,
6107
+ ON CONFLICT (project_id, id) DO UPDATE SET
5671
6108
  root_action_id = excluded.root_action_id,
5672
6109
  caused_by_action_id = excluded.caused_by_action_id,
5673
6110
  actor_type = excluded.actor_type,
@@ -5730,8 +6167,7 @@ var actionGraphProcessorPlugin = {
5730
6167
  id, project_id, agent_id, agent_name, agent_version, goal, outcome,
5731
6168
  autonomy_level, status, error_message, total_cost_usd, total_duration_ms, metadata_json
5732
6169
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5733
- ON CONFLICT (id) DO UPDATE SET
5734
- project_id = excluded.project_id,
6170
+ ON CONFLICT (project_id, id) DO UPDATE SET
5735
6171
  agent_id = excluded.agent_id,
5736
6172
  agent_name = excluded.agent_name,
5737
6173
  agent_version = excluded.agent_version,
@@ -5772,9 +6208,8 @@ var actionGraphProcessorPlugin = {
5772
6208
  mcp_audit_json, mutation_before_json, mutation_after_json,
5773
6209
  mutation_diff_json, mutation_artifact_id
5774
6210
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5775
- ON CONFLICT (id) DO UPDATE SET
6211
+ ON CONFLICT (project_id, id) DO UPDATE SET
5776
6212
  action_id = excluded.action_id,
5777
- project_id = excluded.project_id,
5778
6213
  tool_name = excluded.tool_name,
5779
6214
  args_hash = excluded.args_hash,
5780
6215
  result_hash = excluded.result_hash,
@@ -5818,9 +6253,8 @@ var actionGraphProcessorPlugin = {
5818
6253
  id, action_id, project_id, retriever_name, query_hash, documents_json,
5819
6254
  total_results, max_relevance_score, duration_ms
5820
6255
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
5821
- ON CONFLICT (id) DO UPDATE SET
6256
+ ON CONFLICT (project_id, id) DO UPDATE SET
5822
6257
  action_id = excluded.action_id,
5823
- project_id = excluded.project_id,
5824
6258
  retriever_name = excluded.retriever_name,
5825
6259
  query_hash = excluded.query_hash,
5826
6260
  documents_json = excluded.documents_json,
@@ -5850,9 +6284,8 @@ var actionGraphProcessorPlugin = {
5850
6284
  id, action_id, project_id, evaluator_name, evaluator_version,
5851
6285
  score, passed, reasoning, rubric_json
5852
6286
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
5853
- ON CONFLICT (id) DO UPDATE SET
6287
+ ON CONFLICT (project_id, id) DO UPDATE SET
5854
6288
  action_id = excluded.action_id,
5855
- project_id = excluded.project_id,
5856
6289
  evaluator_name = excluded.evaluator_name,
5857
6290
  evaluator_version = excluded.evaluator_version,
5858
6291
  score = excluded.score,
@@ -5882,9 +6315,8 @@ var actionGraphProcessorPlugin = {
5882
6315
  id, action_id, project_id, artifact_name, artifact_type,
5883
6316
  storage_ref, size_bytes, sha256_hash, content_preview
5884
6317
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
5885
- ON CONFLICT (id) DO UPDATE SET
6318
+ ON CONFLICT (project_id, id) DO UPDATE SET
5886
6319
  action_id = excluded.action_id,
5887
- project_id = excluded.project_id,
5888
6320
  artifact_name = excluded.artifact_name,
5889
6321
  artifact_type = excluded.artifact_type,
5890
6322
  storage_ref = excluded.storage_ref,
@@ -6809,8 +7241,8 @@ async function manifestBySession(db, projectId, sessionId) {
6809
7241
  db.prepare(
6810
7242
  `SELECT first_chunk_at, last_chunk_at, chunk_count, events_count
6811
7243
  FROM session_replay_metadata
6812
- WHERE session_id = ?`
6813
- ).bind(sessionId).first()
7244
+ WHERE project_id = ? AND session_id = ?`
7245
+ ).bind(projectId, sessionId).first()
6814
7246
  ]);
6815
7247
  return {
6816
7248
  spans: spans.results.map(mapSpan),
@@ -7090,9 +7522,9 @@ async function manifestByAction(db, projectId, actionId) {
7090
7522
  replayRes = await db.prepare(
7091
7523
  `SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
7092
7524
  FROM session_replay_metadata
7093
- WHERE session_id IN (${sessionPlaceholders})
7525
+ WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
7094
7526
  ORDER BY last_chunk_at DESC LIMIT 1`
7095
- ).bind(...sessionIds).first();
7527
+ ).bind(projectId, ...sessionIds).first();
7096
7528
  }
7097
7529
  const sumAiCost = aiCallsRes.results.reduce(
7098
7530
  (sum, a) => sum + (a.total_cost_usd ?? 0),
@@ -7231,9 +7663,9 @@ async function manifestByAgentRun(db, projectId, agentRunId) {
7231
7663
  replayRes = await db.prepare(
7232
7664
  `SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
7233
7665
  FROM session_replay_metadata
7234
- WHERE session_id IN (${sessionPlaceholders})
7666
+ WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
7235
7667
  ORDER BY last_chunk_at DESC LIMIT 1`
7236
- ).bind(...sessionIds).first();
7668
+ ).bind(projectId, ...sessionIds).first();
7237
7669
  }
7238
7670
  const sumAiCost = aiCallsRes.results.reduce(
7239
7671
  (sum, a) => sum + (a.total_cost_usd ?? 0),
@@ -7375,9 +7807,9 @@ async function manifestByActor(db, projectId, actorType, actorId) {
7375
7807
  replayRes = await db.prepare(
7376
7808
  `SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
7377
7809
  FROM session_replay_metadata
7378
- WHERE session_id IN (${sessionPlaceholders})
7810
+ WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
7379
7811
  ORDER BY last_chunk_at DESC LIMIT 1`
7380
- ).bind(...sessionIds).first();
7812
+ ).bind(projectId, ...sessionIds).first();
7381
7813
  }
7382
7814
  return {
7383
7815
  spans: spansRes.results.map(mapSpan),
@@ -7405,7 +7837,9 @@ async function manifestByActor(db, projectId, actorType, actorId) {
7405
7837
  async function manifestByUser(db, projectId, userId, opts = {}) {
7406
7838
  const limit = Math.min(opts.limit ?? 100, FETCH_LIMIT);
7407
7839
  const sessionLimit = Math.max(1, Math.min(opts.sessions ?? 5, 20));
7408
- const profile = await db.prepare(`SELECT visitor_id FROM user_profiles WHERE user_id = ? LIMIT 1`).bind(userId).first();
7840
+ const profile = await db.prepare(
7841
+ `SELECT visitor_id FROM user_profiles WHERE project_id = ? AND user_id = ? LIMIT 1`
7842
+ ).bind(projectId, userId).first();
7409
7843
  if (!profile) {
7410
7844
  return {
7411
7845
  spans: [],
@@ -7469,9 +7903,9 @@ async function manifestByUser(db, projectId, userId, opts = {}) {
7469
7903
  db.prepare(
7470
7904
  `SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
7471
7905
  FROM session_replay_metadata
7472
- WHERE session_id IN (${placeholders2})
7906
+ WHERE project_id = ? AND session_id IN (${placeholders2})
7473
7907
  ORDER BY last_chunk_at DESC LIMIT 1`
7474
- ).bind(...sessionIds).first()
7908
+ ).bind(projectId, ...sessionIds).first()
7475
7909
  ]);
7476
7910
  return {
7477
7911
  spans: spans.results.map(mapSpan),
@@ -7779,17 +8213,29 @@ import {
7779
8213
  TOOL_RESPONSE_CONTRACT_SCHEMA_VERSION
7780
8214
  } from "@obsunified/types";
7781
8215
  import { getConfiguredRetentionHours as getConfiguredRetentionHours3 } from "@obsunified/types/constants";
8216
+
8217
+ // src/plugins/_json.ts
8218
+ async function readJsonObject(c) {
8219
+ let parsed;
8220
+ try {
8221
+ parsed = await c.req.json();
8222
+ } catch {
8223
+ return null;
8224
+ }
8225
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8226
+ return null;
8227
+ }
8228
+ return parsed;
8229
+ }
8230
+
8231
+ // src/plugins/ai-receiver.ts
7782
8232
  var aiReceiverPlugin = {
7783
8233
  name: "ai-receiver",
7784
8234
  register(app, _runtime) {
7785
8235
  app.post("/v1/ai", async (c) => {
7786
8236
  const projectId = getProjectId(c);
7787
- let payload;
7788
- try {
7789
- payload = await c.req.json();
7790
- } catch {
7791
- return c.json({ error: "Invalid JSON body" }, 400);
7792
- }
8237
+ const payload = await readJsonObject(c);
8238
+ if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
7793
8239
  if (!payload.calls || !Array.isArray(payload.calls)) {
7794
8240
  return c.json({ error: "Missing calls array" }, 400);
7795
8241
  }
@@ -7866,12 +8312,8 @@ var aiReceiverPlugin = {
7866
8312
  });
7867
8313
  app.post("/v1/ai/evaluations", async (c) => {
7868
8314
  const projectId = getProjectId(c);
7869
- let payload;
7870
- try {
7871
- payload = await c.req.json();
7872
- } catch {
7873
- return c.json({ error: "Invalid JSON body" }, 400);
7874
- }
8315
+ const payload = await readJsonObject(c);
8316
+ if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
7875
8317
  if (!payload.evaluations || !Array.isArray(payload.evaluations)) {
7876
8318
  return c.json({ error: "Missing evaluations array" }, 400);
7877
8319
  }
@@ -8062,8 +8504,7 @@ var aiSpanPayloadsProcessorPlugin = {
8062
8504
  input_json, output_json, input_hash, output_hash, session_id, user_id,
8063
8505
  received_at, expires_at, interaction_id, action_id
8064
8506
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8065
- ON CONFLICT(trace_id, span_id) DO UPDATE SET
8066
- project_id = excluded.project_id,
8507
+ ON CONFLICT(project_id, trace_id, span_id) DO UPDATE SET
8067
8508
  span_kind = excluded.span_kind,
8068
8509
  input_json = excluded.input_json,
8069
8510
  output_json = excluded.output_json,
@@ -8110,6 +8551,100 @@ var aiSpanPayloadsProcessorPlugin = {
8110
8551
 
8111
8552
  // src/lib/alerts-store.ts
8112
8553
  import { EVIDENCE_REFERENCE_CONTRACT as EVIDENCE_REFERENCE_CONTRACT4 } from "@obsunified/types";
8554
+
8555
+ // src/lib/webhook-url.ts
8556
+ var WebhookUrlError = class extends Error {
8557
+ };
8558
+ var parseIpv4 = (host) => {
8559
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
8560
+ if (!m) return null;
8561
+ const octets = m.slice(1).map((o) => Number.parseInt(o, 10));
8562
+ return octets.every((o) => o <= 255) ? octets : null;
8563
+ };
8564
+ var isPrivateIpv4 = (o) => o[0] === 0 || // "this" network / unspecified
8565
+ o[0] === 10 || o[0] === 127 || o[0] === 100 && o[1] >= 64 && o[1] <= 127 || // CGNAT
8566
+ o[0] === 169 && o[1] === 254 || // link-local incl. cloud metadata
8567
+ o[0] === 172 && o[1] >= 16 && o[1] <= 31 || o[0] === 192 && o[1] === 168 || o[0] >= 224;
8568
+ var isPrivateIpv6 = (host) => {
8569
+ const literal = host.replace(/^\[|\]$/g, "").toLowerCase();
8570
+ if (literal === "::" || literal === "::1") return true;
8571
+ const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(literal);
8572
+ if (dotted) {
8573
+ const v4 = parseIpv4(dotted[1]);
8574
+ return v4 ? isPrivateIpv4(v4) : true;
8575
+ }
8576
+ const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(literal);
8577
+ if (hex) {
8578
+ const hi = Number.parseInt(hex[1], 16);
8579
+ const lo = Number.parseInt(hex[2], 16);
8580
+ return isPrivateIpv4([hi >> 8, hi & 255, lo >> 8, lo & 255]);
8581
+ }
8582
+ const first = Number.parseInt(literal.split(":")[0] || "0", 16);
8583
+ if (Number.isNaN(first)) return true;
8584
+ if ((first & 65024) === 64512) return true;
8585
+ if ((first & 65472) === 65152) return true;
8586
+ if ((first & 65280) === 65280) return true;
8587
+ return false;
8588
+ };
8589
+ var isPrivateWebhookHost = (hostname) => {
8590
+ const host = hostname.toLowerCase().replace(/\.$/, "");
8591
+ if (!host) return true;
8592
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
8593
+ if (host.endsWith(".local") || host.endsWith(".internal")) return true;
8594
+ if (host.startsWith("[")) return isPrivateIpv6(host);
8595
+ const v4 = parseIpv4(host);
8596
+ if (v4) return isPrivateIpv4(v4);
8597
+ return !host.includes(".");
8598
+ };
8599
+ var assertSafeWebhookUrl = (raw, options = {}) => {
8600
+ let url;
8601
+ try {
8602
+ url = new URL(raw);
8603
+ } catch {
8604
+ throw new WebhookUrlError("channel.url must be an absolute http(s) URL");
8605
+ }
8606
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
8607
+ throw new WebhookUrlError("channel.url must be http(s)://");
8608
+ }
8609
+ if (url.username || url.password) {
8610
+ throw new WebhookUrlError(
8611
+ "channel.url must not embed credentials; use channel.headers"
8612
+ );
8613
+ }
8614
+ if (!options.allowPrivateNetworks && isPrivateWebhookHost(url.hostname)) {
8615
+ throw new WebhookUrlError(
8616
+ `channel.url host "${url.hostname}" is not a public address (set ALERT_WEBHOOK_ALLOW_PRIVATE=true to allow internal receivers)`
8617
+ );
8618
+ }
8619
+ return url;
8620
+ };
8621
+ var resolveWebhookHost = async (hostname, lookup) => {
8622
+ if (parseIpv4(hostname) || hostname.startsWith("[")) return "public";
8623
+ let addresses;
8624
+ try {
8625
+ addresses = await lookup(hostname);
8626
+ } catch {
8627
+ return "unresolvable";
8628
+ }
8629
+ if (addresses.length === 0) return "unresolvable";
8630
+ const isPrivate = addresses.some((address) => {
8631
+ const v4 = parseIpv4(address);
8632
+ if (v4) return isPrivateIpv4(v4);
8633
+ return isPrivateIpv6(`[${address}]`);
8634
+ });
8635
+ return isPrivate ? "private" : "public";
8636
+ };
8637
+ var redactWebhookUrl = (raw) => {
8638
+ try {
8639
+ const url = new URL(raw);
8640
+ const hasPath = url.pathname !== "/" || url.search !== "";
8641
+ return `${url.protocol}//${url.host}${hasPath ? "/\u2026" : ""}`;
8642
+ } catch {
8643
+ return "<invalid url>";
8644
+ }
8645
+ };
8646
+
8647
+ // src/lib/alerts-store.ts
8113
8648
  var parseJsonField = (value) => {
8114
8649
  if (typeof value === "string") return JSON.parse(value);
8115
8650
  return value;
@@ -8156,8 +8691,9 @@ function compareValue(value, threshold, comparison) {
8156
8691
  }
8157
8692
  }
8158
8693
  var AlertsStore = class {
8159
- constructor(db) {
8694
+ constructor(db, options = {}) {
8160
8695
  this.db = db;
8696
+ this.options = options;
8161
8697
  }
8162
8698
  validateInput(input) {
8163
8699
  if (!input.name?.trim()) throw new Error("name is required");
@@ -8174,8 +8710,16 @@ var AlertsStore = class {
8174
8710
  for (const ch of input.channels) {
8175
8711
  if (ch.type !== "webhook")
8176
8712
  throw new Error(`unknown channel type: ${ch.type}`);
8177
- if (!ch.url || !/^https?:\/\//.test(ch.url))
8713
+ if (!ch.url || typeof ch.url !== "string")
8178
8714
  throw new Error("channel.url must be http(s)://");
8715
+ try {
8716
+ assertSafeWebhookUrl(ch.url, {
8717
+ allowPrivateNetworks: this.options.allowPrivateWebhookTargets
8718
+ });
8719
+ } catch (err) {
8720
+ if (err instanceof WebhookUrlError) throw new Error(err.message);
8721
+ throw err;
8722
+ }
8179
8723
  }
8180
8724
  }
8181
8725
  async listRules(projectId) {
@@ -8260,9 +8804,15 @@ var AlertsStore = class {
8260
8804
  return this.getRule(id, projectId);
8261
8805
  }
8262
8806
  async deleteRule(id, projectId) {
8807
+ const owned = await this.db.prepare(
8808
+ `SELECT 1 AS ok FROM alert_rules WHERE id = ? AND project_id = ?`
8809
+ ).bind(id, projectId).first();
8810
+ if (!owned) return false;
8811
+ await this.db.prepare(`DELETE FROM alert_state WHERE rule_id = ? AND project_id = ?`).bind(id, projectId).run();
8812
+ await this.db.prepare(
8813
+ `DELETE FROM alert_evaluations WHERE rule_id = ? AND project_id = ?`
8814
+ ).bind(id, projectId).run();
8263
8815
  const result = await this.db.prepare(`DELETE FROM alert_rules WHERE id = ? AND project_id = ?`).bind(id, projectId).run();
8264
- await this.db.prepare(`DELETE FROM alert_state WHERE rule_id = ?`).bind(id).run();
8265
- await this.db.prepare(`DELETE FROM alert_evaluations WHERE rule_id = ?`).bind(id).run();
8266
8816
  return (result.meta?.changes ?? 0) > 0;
8267
8817
  }
8268
8818
  async listEnabledRules() {
@@ -8270,16 +8820,58 @@ var AlertsStore = class {
8270
8820
  return (rs.results ?? []).map((r) => rowToRule(r));
8271
8821
  }
8272
8822
  // ── State ──
8273
- async getState(ruleId) {
8274
- const row = await this.db.prepare(`SELECT * FROM alert_state WHERE rule_id = ?`).bind(ruleId).first();
8823
+ async getState(ruleId, projectId) {
8824
+ const row = await this.db.prepare(`SELECT * FROM alert_state WHERE rule_id = ? AND project_id = ?`).bind(ruleId, projectId).first();
8275
8825
  return row ?? null;
8276
8826
  }
8827
+ /**
8828
+ * Record a state transition with its notification still outstanding
8829
+ * (`notified = 0`); `markNotified` flips it once a channel accepts the
8830
+ * alert. On a database that has not applied migration 049 the column is
8831
+ * missing: the transition is still written (legacy statement) so alerting
8832
+ * keeps working through a rolling deploy, and the evaluator treats a row
8833
+ * without the column as delivered.
8834
+ */
8277
8835
  async transitionState(ruleId, projectId, newState, now) {
8278
- await this.db.prepare(
8279
- `INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change)
8836
+ try {
8837
+ await this.db.prepare(
8838
+ `INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change, notified)
8839
+ VALUES (?, ?, ?, ?, 0)
8840
+ ON CONFLICT(rule_id) DO UPDATE SET
8841
+ current_state = excluded.current_state,
8842
+ last_state_change = excluded.last_state_change,
8843
+ notified = 0`
8844
+ ).bind(ruleId, projectId, newState, now).run();
8845
+ } catch (err) {
8846
+ if (!isMissingNotifiedColumn(err)) throw err;
8847
+ await this.db.prepare(
8848
+ `INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change)
8280
8849
  VALUES (?, ?, ?, ?)
8281
- ON CONFLICT(rule_id) DO UPDATE SET current_state = excluded.current_state, last_state_change = excluded.last_state_change`
8282
- ).bind(ruleId, projectId, newState, now).run();
8850
+ ON CONFLICT(rule_id) DO UPDATE SET
8851
+ current_state = excluded.current_state,
8852
+ last_state_change = excluded.last_state_change`
8853
+ ).bind(ruleId, projectId, newState, now).run();
8854
+ }
8855
+ }
8856
+ /**
8857
+ * Mark the transition `(state, lastStateChange)` as delivered. Scoped to
8858
+ * the exact transition the caller notified for: if the rule changed state
8859
+ * again while the webhook was in flight, the newer transition keeps its
8860
+ * own outstanding flag. Returns false when nothing matched (stale or
8861
+ * already marked). On a pre-049 schema there is nothing to mark.
8862
+ */
8863
+ async markNotified(ruleId, projectId, state, lastStateChange) {
8864
+ try {
8865
+ const result = await this.db.prepare(
8866
+ `UPDATE alert_state SET notified = 1
8867
+ WHERE rule_id = ? AND project_id = ? AND current_state = ?
8868
+ AND last_state_change = ? AND notified = 0`
8869
+ ).bind(ruleId, projectId, state, lastStateChange).run();
8870
+ return (result.meta?.changes ?? 0) > 0;
8871
+ } catch (err) {
8872
+ if (isMissingNotifiedColumn(err)) return false;
8873
+ throw err;
8874
+ }
8283
8875
  }
8284
8876
  async recordEvaluation(ruleId, projectId, value, state, notified) {
8285
8877
  await this.db.prepare(
@@ -8413,16 +9005,48 @@ var AlertsStore = class {
8413
9005
  sql += ` AND model_name = ?`;
8414
9006
  binds.push(q.model);
8415
9007
  }
8416
- if (q.isError === true) sql += ` AND is_error = 1`;
9008
+ if (q.isError === true) sql += ` AND is_error`;
8417
9009
  const row = await this.db.prepare(sql).bind(...binds).first();
8418
9010
  return row?.c ?? 0;
8419
9011
  }
8420
9012
  };
9013
+ var isMissingNotifiedColumn = (err) => {
9014
+ const message = err instanceof Error ? err.message : String(err);
9015
+ return /notified/i.test(message) && /no such column|has no column|does not exist/i.test(message);
9016
+ };
8421
9017
 
8422
9018
  // src/plugins/alerts-evaluator.ts
8423
9019
  var DEFAULT_RULE_CONCURRENCY = 5;
8424
9020
  var DEFAULT_RULE_TIMEOUT_MS = 1e4;
8425
- async function fireWebhook(channel, payload, logger, tracer) {
9021
+ async function fireWebhook(channel, payload, logger, tracer, delivery = {}) {
9022
+ const target = redactWebhookUrl(channel.url);
9023
+ try {
9024
+ const url = assertSafeWebhookUrl(channel.url, {
9025
+ allowPrivateNetworks: delivery.allowPrivateWebhookTargets
9026
+ });
9027
+ if (!delivery.allowPrivateWebhookTargets && delivery.hostLookup) {
9028
+ const resolution = await resolveWebhookHost(
9029
+ url.hostname,
9030
+ delivery.hostLookup
9031
+ );
9032
+ if (resolution === "private") {
9033
+ throw new Error(
9034
+ `channel.url host "${url.hostname}" resolves to a private address`
9035
+ );
9036
+ }
9037
+ if (resolution === "unresolvable") {
9038
+ throw new Error(
9039
+ `channel.url host "${url.hostname}" could not be resolved; refusing to deliver blind`
9040
+ );
9041
+ }
9042
+ }
9043
+ } catch (err) {
9044
+ logger.error("[alerts-evaluator] webhook target refused", {
9045
+ url: target,
9046
+ error: err instanceof Error ? err.message : String(err)
9047
+ });
9048
+ return false;
9049
+ }
8426
9050
  const exec = async (span) => {
8427
9051
  try {
8428
9052
  const response = await fetch(channel.url, {
@@ -8432,13 +9056,24 @@ async function fireWebhook(channel, payload, logger, tracer) {
8432
9056
  ...channel.headers ?? {}
8433
9057
  },
8434
9058
  body: JSON.stringify(payload),
9059
+ // Never follow redirects: a 3xx could re-aim the POST (and its
9060
+ // headers) at an arbitrary host. A redirecting hook is a
9061
+ // misconfiguration and counts as a failed delivery.
9062
+ redirect: "manual",
8435
9063
  signal: AbortSignal.timeout(5e3)
8436
9064
  });
8437
9065
  if (span) span.setAttribute("http.response.status_code", response.status);
9066
+ if (response.status >= 300 && response.status < 400) {
9067
+ logger.error("[alerts-evaluator] webhook responded with a redirect", {
9068
+ url: target,
9069
+ status: response.status
9070
+ });
9071
+ return false;
9072
+ }
8438
9073
  return response.ok;
8439
9074
  } catch (err) {
8440
9075
  logger.error("[alerts-evaluator] webhook delivery failed", {
8441
- url: channel.url,
9076
+ url: target,
8442
9077
  error: err instanceof Error ? err.message : String(err)
8443
9078
  });
8444
9079
  return false;
@@ -8447,14 +9082,14 @@ async function fireWebhook(channel, payload, logger, tracer) {
8447
9082
  if (!tracer) return exec(null);
8448
9083
  return tracer("webhook.alert", async (span) => exec(span), {
8449
9084
  "http.method": "POST",
8450
- "http.url": channel.url,
9085
+ "http.url": target,
8451
9086
  "alert.state": payload.state
8452
9087
  });
8453
9088
  }
8454
- async function fireChannels(channels, payload, logger, tracer) {
9089
+ async function fireChannels(channels, payload, logger, tracer, delivery = {}) {
8455
9090
  if (channels.length === 0) return true;
8456
9091
  const results = await Promise.all(
8457
- channels.filter((ch) => ch.type === "webhook").map((ch) => fireWebhook(ch, payload, logger, tracer))
9092
+ channels.filter((ch) => ch.type === "webhook").map((ch) => fireWebhook(ch, payload, logger, tracer, delivery))
8458
9093
  );
8459
9094
  return results.every(Boolean);
8460
9095
  }
@@ -8485,7 +9120,8 @@ async function mapWithConcurrency(items, concurrency, mapper) {
8485
9120
  );
8486
9121
  return results;
8487
9122
  }
8488
- async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs) {
9123
+ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs, delivery) {
9124
+ const none = { fired: 0, resolved: 0, redelivered: 0 };
8489
9125
  try {
8490
9126
  const value = await withTimeout(
8491
9127
  store.evaluateRule(rule),
@@ -8493,7 +9129,7 @@ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs) {
8493
9129
  `rule evaluation timed out after ${ruleTimeoutMs}ms`
8494
9130
  );
8495
9131
  const shouldFire = compareValue(value, rule.threshold, rule.comparison);
8496
- const priorState = await store.getState(rule.id);
9132
+ const priorState = await store.getState(rule.id, rule.projectId);
8497
9133
  const previous = priorState?.current_state ?? "ok";
8498
9134
  const next = shouldFire ? "firing" : "ok";
8499
9135
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -8504,51 +9140,58 @@ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs) {
8504
9140
  status: n.status
8505
9141
  } : void 0
8506
9142
  ).catch(() => void 0) : void 0;
8507
- if (previous === "ok" && next === "firing") {
8508
- const ok = await fireChannels(
8509
- rule.channels,
8510
- {
8511
- rule: { id: rule.id, name: rule.name, signal: rule.signal },
8512
- value,
8513
- threshold: rule.threshold,
8514
- comparison: rule.comparison,
8515
- state: "firing",
8516
- evaluatedAt: now,
8517
- projectId: rule.projectId,
8518
- analysis: analysisAttachment
8519
- },
8520
- logger,
8521
- tracer
8522
- );
8523
- await store.transitionState(rule.id, rule.projectId, "firing", now);
8524
- await store.recordEvaluation(
8525
- rule.id,
8526
- rule.projectId,
9143
+ const deliver = (state) => fireChannels(
9144
+ rule.channels,
9145
+ {
9146
+ rule: { id: rule.id, name: rule.name, signal: rule.signal },
8527
9147
  value,
8528
- "firing",
8529
- ok
8530
- );
8531
- return { fired: 1, resolved: 0 };
9148
+ threshold: rule.threshold,
9149
+ comparison: rule.comparison,
9150
+ state,
9151
+ evaluatedAt: now,
9152
+ projectId: rule.projectId,
9153
+ analysis: analysisAttachment
9154
+ },
9155
+ logger,
9156
+ tracer,
9157
+ delivery
9158
+ );
9159
+ if (previous !== next) {
9160
+ await store.transitionState(rule.id, rule.projectId, next, now);
9161
+ const ok = await deliver(next);
9162
+ if (ok) {
9163
+ await store.markNotified(rule.id, rule.projectId, next, now);
9164
+ }
9165
+ await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
9166
+ if (!ok) {
9167
+ logger.warn(
9168
+ "[alerts-evaluator] state recorded but delivery failed; will retry",
9169
+ { rule_id: rule.id, project_id: rule.projectId, state: next }
9170
+ );
9171
+ }
9172
+ return next === "firing" ? { fired: 1, resolved: 0, redelivered: 0 } : { fired: 0, resolved: 1, redelivered: 0 };
8532
9173
  }
8533
- if (previous === "firing" && next === "ok") {
8534
- const ok = await fireChannels(
8535
- rule.channels,
8536
- {
8537
- rule: { id: rule.id, name: rule.name, signal: rule.signal },
8538
- value,
8539
- threshold: rule.threshold,
8540
- comparison: rule.comparison,
8541
- state: "ok",
8542
- evaluatedAt: now,
8543
- projectId: rule.projectId,
8544
- analysis: analysisAttachment
8545
- },
8546
- logger,
8547
- tracer
8548
- );
8549
- await store.transitionState(rule.id, rule.projectId, "ok", now);
8550
- await store.recordEvaluation(rule.id, rule.projectId, value, "ok", ok);
8551
- return { fired: 0, resolved: 1 };
9174
+ const owed = priorState !== null && priorState.notified === 0;
9175
+ if (owed) {
9176
+ const ok = await deliver(previous);
9177
+ if (ok) {
9178
+ await store.markNotified(
9179
+ rule.id,
9180
+ rule.projectId,
9181
+ previous,
9182
+ priorState.last_state_change
9183
+ );
9184
+ }
9185
+ await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
9186
+ if (!ok) {
9187
+ logger.warn("[alerts-evaluator] delivery retry failed; will retry", {
9188
+ rule_id: rule.id,
9189
+ project_id: rule.projectId,
9190
+ state: previous
9191
+ });
9192
+ return none;
9193
+ }
9194
+ return { fired: 0, resolved: 0, redelivered: 1 };
8552
9195
  }
8553
9196
  await store.recordEvaluation(rule.id, rule.projectId, value, next, false);
8554
9197
  } catch (err) {
@@ -8558,10 +9201,14 @@ async function evaluateOneRule(store, rule, logger, tracer, ruleTimeoutMs) {
8558
9201
  error: err instanceof Error ? err.message : String(err)
8559
9202
  });
8560
9203
  }
8561
- return { fired: 0, resolved: 0 };
9204
+ return none;
8562
9205
  }
8563
9206
  async function evaluateRuleBatch(store, rules, options) {
8564
9207
  const logger = options?.logger ?? consoleLogger;
9208
+ const delivery = {
9209
+ allowPrivateWebhookTargets: options?.allowPrivateWebhookTargets,
9210
+ hostLookup: options?.hostLookup
9211
+ };
8565
9212
  const concurrency = Math.max(
8566
9213
  1,
8567
9214
  Math.floor(options?.concurrency ?? DEFAULT_RULE_CONCURRENCY)
@@ -8573,24 +9220,35 @@ async function evaluateRuleBatch(store, rules, options) {
8573
9220
  const results = await mapWithConcurrency(
8574
9221
  rules,
8575
9222
  concurrency,
8576
- (rule) => evaluateOneRule(store, rule, logger, options?.tracer, ruleTimeoutMs)
9223
+ (rule) => evaluateOneRule(
9224
+ store,
9225
+ rule,
9226
+ logger,
9227
+ options?.tracer,
9228
+ ruleTimeoutMs,
9229
+ delivery
9230
+ )
8577
9231
  );
8578
9232
  return {
8579
9233
  evaluated: rules.length,
8580
9234
  fired: results.reduce((sum, result) => sum + result.fired, 0),
8581
- resolved: results.reduce((sum, result) => sum + result.resolved, 0)
9235
+ resolved: results.reduce((sum, result) => sum + result.resolved, 0),
9236
+ redelivered: results.reduce((sum, result) => sum + result.redelivered, 0)
8582
9237
  };
8583
9238
  }
8584
9239
  async function evaluateAllRules(env, options) {
8585
9240
  const logger = options?.logger ?? consoleLogger;
8586
9241
  const tracer = options?.tracer;
8587
- const store = new AlertsStore(sqlDbFor(env));
9242
+ const allowPrivateWebhookTargets = env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true";
9243
+ const store = new AlertsStore(sqlDbFor(env), { allowPrivateWebhookTargets });
8588
9244
  const rules = await store.listEnabledRules();
8589
9245
  return evaluateRuleBatch(store, rules, {
8590
9246
  logger,
8591
9247
  tracer,
8592
9248
  concurrency: options?.concurrency,
8593
- ruleTimeoutMs: options?.ruleTimeoutMs
9249
+ ruleTimeoutMs: options?.ruleTimeoutMs,
9250
+ allowPrivateWebhookTargets,
9251
+ hostLookup: options?.hostLookup
8594
9252
  });
8595
9253
  }
8596
9254
  function createAlertEvaluatorHandler() {
@@ -8603,12 +9261,15 @@ function createAlertEvaluatorHandler() {
8603
9261
 
8604
9262
  // src/plugins/alerts-routes.ts
8605
9263
  import { EVIDENCE_REFERENCE_CONTRACT as EVIDENCE_REFERENCE_CONTRACT5 } from "@obsunified/types";
9264
+ var storeOptions = (env) => ({
9265
+ allowPrivateWebhookTargets: env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true"
9266
+ });
8606
9267
  var alertsRoutesPlugin = {
8607
9268
  name: "alerts-routes",
8608
9269
  register(app) {
8609
9270
  app.get("/internal/alerts/rules", async (c) => {
8610
9271
  const projectId = getProjectId(c);
8611
- const store = new AlertsStore(sqlDbFor(c.env));
9272
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
8612
9273
  const rules = await store.listRules(projectId);
8613
9274
  return c.json({ rules });
8614
9275
  });
@@ -8620,7 +9281,7 @@ var alertsRoutesPlugin = {
8620
9281
  } catch {
8621
9282
  return c.json({ error: "Invalid JSON body" }, 400);
8622
9283
  }
8623
- const store = new AlertsStore(sqlDbFor(c.env));
9284
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
8624
9285
  try {
8625
9286
  const rule = await store.createRule(projectId, input);
8626
9287
  return c.json({ rule }, 201);
@@ -8638,7 +9299,7 @@ var alertsRoutesPlugin = {
8638
9299
  } catch {
8639
9300
  return c.json({ error: "Invalid JSON body" }, 400);
8640
9301
  }
8641
- const store = new AlertsStore(sqlDbFor(c.env));
9302
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
8642
9303
  try {
8643
9304
  const rule = await store.updateRule(id, projectId, patch);
8644
9305
  if (!rule) return c.json({ error: "Rule not found" }, 404);
@@ -8651,7 +9312,7 @@ var alertsRoutesPlugin = {
8651
9312
  app.delete("/internal/alerts/rules/:id", async (c) => {
8652
9313
  const projectId = getProjectId(c);
8653
9314
  const id = c.req.param("id");
8654
- const store = new AlertsStore(sqlDbFor(c.env));
9315
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
8655
9316
  const deleted = await store.deleteRule(id, projectId);
8656
9317
  if (!deleted) return c.json({ error: "Rule not found" }, 404);
8657
9318
  return c.json({ success: true });
@@ -8664,7 +9325,7 @@ var alertsRoutesPlugin = {
8664
9325
  1,
8665
9326
  Math.min(720, Number.parseInt(c.req.query("hours") || "24", 10) || 24)
8666
9327
  );
8667
- const store = new AlertsStore(sqlDbFor(c.env));
9328
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
8668
9329
  const response = await store.listEvaluations({
8669
9330
  ruleId,
8670
9331
  projectId,
@@ -8675,7 +9336,7 @@ var alertsRoutesPlugin = {
8675
9336
  app.post("/internal/alerts/rules/:id/test", async (c) => {
8676
9337
  const projectId = getProjectId(c);
8677
9338
  const id = c.req.param("id");
8678
- const store = new AlertsStore(sqlDbFor(c.env));
9339
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
8679
9340
  const rule = await store.getRule(id, projectId);
8680
9341
  if (!rule) return c.json({ error: "Rule not found" }, 404);
8681
9342
  const value = await store.evaluateRule(rule);
@@ -9082,12 +9743,10 @@ var askRoutesPlugin = {
9082
9743
  name: "ask-routes",
9083
9744
  register(app, runtime) {
9084
9745
  app.post("/internal/ask", async (c) => {
9085
- let body = {};
9086
- try {
9087
- body = await c.req.json();
9088
- } catch {
9746
+ const body = await readJsonObject(c);
9747
+ if (!body) {
9089
9748
  return c.json(
9090
- { error: "Bad Request", message: "body must be JSON" },
9749
+ { error: "Bad Request", message: "body must be a JSON object" },
9091
9750
  400
9092
9751
  );
9093
9752
  }
@@ -9777,12 +10436,22 @@ var connectedRoutesPlugin = {
9777
10436
  const otherSpans = traceManifest.spans.filter(
9778
10437
  (s) => s.spanId !== span.spanId
9779
10438
  );
10439
+ const interactionManifest = span.interactionId ? await index.byInteraction(projectId, span.interactionId) : null;
10440
+ const parentSessionId = interactionManifest?.usageEvents.find(
10441
+ (e) => Boolean(e.sessionId)
10442
+ )?.sessionId;
9780
10443
  manifest.up = [
9781
10444
  {
9782
10445
  label: "Trace",
9783
10446
  links: [linkToTrace(span.traceId, "Parent trace")]
9784
10447
  }
9785
10448
  ];
10449
+ if (parentSessionId) {
10450
+ manifest.up.push({
10451
+ label: "User session",
10452
+ links: [linkToSession(parentSessionId)]
10453
+ });
10454
+ }
9786
10455
  manifest.across = [
9787
10456
  linksFromSpans(otherSpans, `Other spans in this trace`),
9788
10457
  linksFromLogs(traceManifest.logs, "Logs in this trace"),
@@ -9816,11 +10485,7 @@ var connectedRoutesPlugin = {
9816
10485
  "Metric exemplars in this trace"
9817
10486
  )
9818
10487
  ];
9819
- if (span.interactionId) {
9820
- const interactionManifest = await index.byInteraction(
9821
- projectId,
9822
- span.interactionId
9823
- );
10488
+ if (interactionManifest) {
9824
10489
  manifest.related = [
9825
10490
  linksFromUsage(
9826
10491
  interactionManifest.usageEvents,
@@ -10435,7 +11100,12 @@ var dashboardRoutesPlugin = {
10435
11100
  url.pathname = "/dashboard/";
10436
11101
  return assets.fetch(new Request(url.toString(), c.req.raw));
10437
11102
  }
10438
- return c.redirect("/dashboard/");
11103
+ return c.json(
11104
+ {
11105
+ error: "Dashboard assets are not configured on this collector. Serve @obsunified/dashboard separately or bind [assets] in wrangler.toml."
11106
+ },
11107
+ 404
11108
+ );
10439
11109
  });
10440
11110
  app.get("/", (c) => {
10441
11111
  return c.redirect("/dashboard/");
@@ -11182,12 +11852,8 @@ var evalCasesRoutesPlugin = {
11182
11852
  name: "eval-cases-routes",
11183
11853
  register(app) {
11184
11854
  app.post("/internal/eval-cases", async (c) => {
11185
- let body;
11186
- try {
11187
- body = await c.req.json();
11188
- } catch {
11189
- return c.json(badRequest("body must be JSON"), 400);
11190
- }
11855
+ const body = await readJsonObject(c);
11856
+ if (!body) return c.json(badRequest("body must be a JSON object"), 400);
11191
11857
  const sourceType = body.sourceEntityType ?? body.source?.type;
11192
11858
  if (!isEvalCaseSourceType(sourceType)) {
11193
11859
  return c.json(
@@ -11280,12 +11946,8 @@ var evalCasesRoutesPlugin = {
11280
11946
  return c.json({ evalCase });
11281
11947
  });
11282
11948
  app.post("/internal/eval-runs", async (c) => {
11283
- let body;
11284
- try {
11285
- body = await c.req.json();
11286
- } catch {
11287
- return c.json(badRequest("body must be JSON"), 400);
11288
- }
11949
+ const body = await readJsonObject(c);
11950
+ if (!body) return c.json(badRequest("body must be a JSON object"), 400);
11289
11951
  const id = asOptionalString(body.id ?? body.runId)?.trim();
11290
11952
  if ((body.id ?? body.runId) !== void 0 && !id) {
11291
11953
  return c.json(badRequest("id must be a non-empty string"), 400);
@@ -11399,11 +12061,9 @@ var evalCasesRoutesPlugin = {
11399
12061
  const evalCaseId = c.req.param("id");
11400
12062
  if (!evalCaseId) return c.json(badRequest("id is required"), 400);
11401
12063
  let body;
11402
- try {
11403
- body = await c.req.json();
11404
- } catch {
11405
- return c.json(badRequest("body must be JSON"), 400);
11406
- }
12064
+ const parsed = await readJsonObject(c);
12065
+ if (!parsed) return c.json(badRequest("body must be a JSON object"), 400);
12066
+ body = parsed;
11407
12067
  if (body.passed === void 0 || typeof body.passed !== "boolean") {
11408
12068
  return c.json(badRequest("passed must be a boolean"), 400);
11409
12069
  }
@@ -13664,12 +14324,8 @@ var identityReceiverPlugin = {
13664
14324
  register(app) {
13665
14325
  app.post("/v1/identify", async (c) => {
13666
14326
  const projectId = getProjectId(c);
13667
- let payload;
13668
- try {
13669
- payload = await c.req.json();
13670
- } catch {
13671
- return c.json({ error: "Invalid JSON body" }, 400);
13672
- }
14327
+ const payload = await readJsonObject(c);
14328
+ if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
13673
14329
  if (!payload.userId || typeof payload.userId !== "string") {
13674
14330
  return c.json({ error: "userId is required" }, 400);
13675
14331
  }
@@ -13696,7 +14352,7 @@ var identityReceiverPlugin = {
13696
14352
  await db.prepare(
13697
14353
  `INSERT INTO user_profiles (project_id, user_id, visitor_id, email, name, properties_json, first_seen_at, last_seen_at)
13698
14354
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
13699
- ON CONFLICT(user_id) DO UPDATE SET
14355
+ ON CONFLICT(project_id, user_id) DO UPDATE SET
13700
14356
  visitor_id = excluded.visitor_id,
13701
14357
  email = COALESCE(excluded.email, user_profiles.email),
13702
14358
  name = COALESCE(excluded.name, user_profiles.name),
@@ -13948,13 +14604,22 @@ var OtlpDecodeError = class extends Error {
13948
14604
  this.status = status;
13949
14605
  }
13950
14606
  };
13951
- var readOtlpBody = async (c) => {
14607
+ var readOtlpBody = async (c, limits = {}) => {
13952
14608
  const contentType = (c.req.header("content-type") ?? "").toLowerCase();
13953
14609
  const wireFormat = detectWireFormat(contentType);
14610
+ const env = c.env ?? {};
14611
+ const maxBodyBytes = limits.maxBodyBytes ?? parseByteLimit(env.MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
14612
+ const maxDecompressedBytes = limits.maxDecompressedBytes ?? DEFAULT_MAX_DECOMPRESSED_BYTES;
13954
14613
  let body = await c.req.arrayBuffer();
14614
+ if (body.byteLength > maxBodyBytes) {
14615
+ throw new OtlpDecodeError(
14616
+ `Payload too large (max ${maxBodyBytes} bytes)`,
14617
+ 413
14618
+ );
14619
+ }
13955
14620
  const encoding = (c.req.header("content-encoding") ?? "").toLowerCase();
13956
14621
  if (encoding === "gzip") {
13957
- body = await gunzip(body);
14622
+ body = await gunzip(body, maxDecompressedBytes);
13958
14623
  } else if (encoding && encoding !== "identity") {
13959
14624
  throw new OtlpDecodeError(`Unsupported content-encoding: ${encoding}`, 415);
13960
14625
  }
@@ -13968,12 +14633,40 @@ var detectWireFormat = (contentType) => {
13968
14633
  415
13969
14634
  );
13970
14635
  };
13971
- var gunzip = async (input) => {
14636
+ var gunzip = async (input, maxBytes) => {
13972
14637
  const stream = new Response(input).body?.pipeThrough(
13973
14638
  new DecompressionStream("gzip")
13974
14639
  );
13975
14640
  if (!stream) throw new OtlpDecodeError("Empty gzip body", 400);
13976
- return new Response(stream).arrayBuffer();
14641
+ const reader = stream.getReader();
14642
+ const chunks = [];
14643
+ let total = 0;
14644
+ try {
14645
+ for (; ; ) {
14646
+ const { done, value } = await reader.read();
14647
+ if (done) break;
14648
+ total += value.byteLength;
14649
+ if (total > maxBytes) {
14650
+ await reader.cancel().catch(() => {
14651
+ });
14652
+ throw new OtlpDecodeError(
14653
+ `Decompressed payload too large (max ${maxBytes} bytes)`,
14654
+ 413
14655
+ );
14656
+ }
14657
+ chunks.push(value);
14658
+ }
14659
+ } catch (err) {
14660
+ if (err instanceof OtlpDecodeError) throw err;
14661
+ throw new OtlpDecodeError("Malformed gzip body", 400);
14662
+ }
14663
+ const out = new Uint8Array(total);
14664
+ let offset = 0;
14665
+ for (const chunk of chunks) {
14666
+ out.set(chunk, offset);
14667
+ offset += chunk.byteLength;
14668
+ }
14669
+ return out.buffer;
13977
14670
  };
13978
14671
  var decodeJsonBody = (bytes) => {
13979
14672
  const text = new TextDecoder().decode(bytes);
@@ -14827,17 +15520,20 @@ var onboardingRoutesPlugin = {
14827
15520
  register(app) {
14828
15521
  app.get("/internal/onboarding/counts", async (c) => {
14829
15522
  const db = sqlDbFor(c.env);
15523
+ const projectId = getProjectId(c);
15524
+ const count = (table, extra = "") => safeCount(
15525
+ db,
15526
+ `SELECT COUNT(*) AS n FROM ${table} WHERE project_id = ?${extra}`,
15527
+ projectId
15528
+ );
14830
15529
  const counts = await Promise.all([
14831
- safeCount(db, "SELECT COUNT(*) AS n FROM telemetry_spans"),
14832
- safeCount(db, "SELECT COUNT(*) AS n FROM logs"),
14833
- safeCount(db, "SELECT COUNT(*) AS n FROM usage_events"),
14834
- safeCount(db, "SELECT COUNT(*) AS n FROM user_profiles"),
14835
- safeCount(db, "SELECT COUNT(*) AS n FROM ai_calls"),
14836
- safeCount(db, "SELECT COUNT(*) AS n FROM session_replay_metadata"),
14837
- safeCount(
14838
- db,
14839
- "SELECT COUNT(*) AS n FROM telemetry_spans WHERE interaction_id IS NOT NULL"
14840
- )
15530
+ count("telemetry_spans"),
15531
+ count("logs"),
15532
+ count("usage_events"),
15533
+ count("user_profiles"),
15534
+ count("ai_calls"),
15535
+ count("session_replay_metadata"),
15536
+ count("telemetry_spans", " AND interaction_id IS NOT NULL")
14841
15537
  ]);
14842
15538
  return c.json({
14843
15539
  spans: counts[0],
@@ -14851,9 +15547,9 @@ var onboardingRoutesPlugin = {
14851
15547
  });
14852
15548
  }
14853
15549
  };
14854
- async function safeCount(db, sql) {
15550
+ async function safeCount(db, sql, ...binds) {
14855
15551
  try {
14856
- const row = await db.prepare(sql).first();
15552
+ const row = await db.prepare(sql).bind(...binds).first();
14857
15553
  return Number(row?.n ?? 0);
14858
15554
  } catch {
14859
15555
  return 0;
@@ -15616,9 +16312,10 @@ var projectsRoutesPlugin = {
15616
16312
  }
15617
16313
  });
15618
16314
  app.delete("/internal/projects/:id/keys/:keyId", async (c) => {
16315
+ const projectId = c.req.param("id");
15619
16316
  const keyId = c.req.param("keyId");
15620
16317
  const store = new ProjectsStore(sqlDbFor(c.env));
15621
- const revoked = await store.revokeKey(keyId);
16318
+ const revoked = await store.revokeKey(keyId, projectId);
15622
16319
  if (!revoked)
15623
16320
  return c.json({ error: "Key not found or already revoked" }, 404);
15624
16321
  return c.json({ success: true });
@@ -15925,13 +16622,9 @@ var replayReceiverPlugin = {
15925
16622
  register(app, runtime) {
15926
16623
  app.post("/v1/replays", async (c) => {
15927
16624
  const projectId = getProjectId(c);
15928
- let payload;
15929
- try {
15930
- payload = await c.req.json();
15931
- } catch {
15932
- return c.json({ error: "Invalid JSON body" }, 400);
15933
- }
15934
- if (!isSafeId(payload.sessionId) || payload.visitorId !== void 0 && !isSafeId(payload.visitorId) || !Number.isInteger(payload.sequenceNumber) || payload.sequenceNumber < 0 || !Array.isArray(payload.events)) {
16625
+ const payload = await readJsonObject(c);
16626
+ if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
16627
+ if (!isSafeId(payload.sessionId) || !isSafeId(payload.visitorId) || !Number.isInteger(payload.sequenceNumber) || payload.sequenceNumber < 0 || !Array.isArray(payload.events)) {
15935
16628
  return c.json({ error: "Invalid replay payload" }, 400);
15936
16629
  }
15937
16630
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -15951,7 +16644,7 @@ var replayReceiverPlugin = {
15951
16644
  await sqlDbFor(c.env).prepare(
15952
16645
  `INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
15953
16646
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
15954
- ON CONFLICT(session_id) DO UPDATE SET
16647
+ ON CONFLICT(project_id, session_id) DO UPDATE SET
15955
16648
  last_chunk_at = excluded.last_chunk_at,
15956
16649
  chunk_count = session_replay_metadata.chunk_count + 1,
15957
16650
  events_count = session_replay_metadata.events_count + excluded.events_count,
@@ -16385,12 +17078,8 @@ var usageReceiverPlugin = {
16385
17078
  register(app, runtime) {
16386
17079
  app.post("/v1/usage", async (c) => {
16387
17080
  const projectId = getProjectId(c);
16388
- let payload;
16389
- try {
16390
- payload = await c.req.json();
16391
- } catch {
16392
- return c.json({ error: "Invalid JSON body" }, 400);
16393
- }
17081
+ const payload = await readJsonObject(c);
17082
+ if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
16394
17083
  const rawEvents = payload.events || [];
16395
17084
  if (rawEvents.length > 200) {
16396
17085
  return c.json(
@@ -16521,6 +17210,71 @@ var utmEnrichmentPlugin = {
16521
17210
  }
16522
17211
  };
16523
17212
 
17213
+ // src/auth/login-rate-limit.ts
17214
+ var LoginRateLimiter = class {
17215
+ buckets = /* @__PURE__ */ new Map();
17216
+ maxFailures;
17217
+ windowMs;
17218
+ maxClients;
17219
+ now;
17220
+ constructor(options = {}) {
17221
+ this.maxFailures = Math.max(1, options.maxFailures ?? 10);
17222
+ this.windowMs = Math.max(1e3, options.windowMs ?? 15 * 6e4);
17223
+ this.maxClients = Math.max(1, options.maxClients ?? 1e4);
17224
+ this.now = options.now ?? Date.now;
17225
+ }
17226
+ check(key) {
17227
+ const bucket = this.live(key);
17228
+ if (!bucket || bucket.failures < this.maxFailures) {
17229
+ return { allowed: true, retryAfterSeconds: 0 };
17230
+ }
17231
+ const remaining = bucket.windowStartedAt + this.windowMs - this.now();
17232
+ return {
17233
+ allowed: false,
17234
+ retryAfterSeconds: Math.max(1, Math.ceil(remaining / 1e3))
17235
+ };
17236
+ }
17237
+ recordFailure(key) {
17238
+ const bucket = this.live(key);
17239
+ if (bucket) {
17240
+ bucket.failures += 1;
17241
+ return;
17242
+ }
17243
+ if (this.buckets.size >= this.maxClients) {
17244
+ const oldest = this.buckets.keys().next().value;
17245
+ if (oldest !== void 0) this.buckets.delete(oldest);
17246
+ }
17247
+ this.buckets.set(key, { failures: 1, windowStartedAt: this.now() });
17248
+ }
17249
+ reset(key) {
17250
+ this.buckets.delete(key);
17251
+ }
17252
+ /** Bucket for `key` if its window is still open; expired buckets are dropped. */
17253
+ live(key) {
17254
+ const bucket = this.buckets.get(key);
17255
+ if (!bucket) return void 0;
17256
+ if (bucket.windowStartedAt + this.windowMs <= this.now()) {
17257
+ this.buckets.delete(key);
17258
+ return void 0;
17259
+ }
17260
+ return bucket;
17261
+ }
17262
+ };
17263
+ var isCloudflareWorkersRuntime = () => typeof navigator !== "undefined" && typeof navigator.userAgent === "string" && navigator.userAgent.startsWith("Cloudflare-Workers");
17264
+ var loginClientKey = (headers, options = {}) => {
17265
+ const cf = headers.get("cf-connecting-ip")?.trim();
17266
+ if (options.trustProxyHeaders) {
17267
+ if (cf) return cf;
17268
+ const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
17269
+ if (forwarded) return forwarded;
17270
+ }
17271
+ const peer = options.clientAddress?.trim();
17272
+ if (peer) return peer;
17273
+ if (cf && isCloudflareWorkersRuntime()) return cf;
17274
+ return null;
17275
+ };
17276
+ var UNKEYED_LOGIN_CLIENT_WARNING = "[dashboard-auth] login rate limiting is inactive: no trustworthy client address. On Node, pass `loginRateLimit.clientAddress` (e.g. the TCP peer from @hono/node-server's getConnInfo); behind a trusted reverse proxy set `loginRateLimit.trustProxyHeaders`. Cloudflare Workers key on CF-Connecting-IP automatically.";
17277
+
16524
17278
  // src/auth/dashboard-auth.ts
16525
17279
  var SESSION_COOKIE = "obs_session";
16526
17280
  var SESSION_MAX_AGE = 7 * 24 * 60 * 60;
@@ -16588,37 +17342,75 @@ function getCookie(cookieHeader, name) {
16588
17342
  }
16589
17343
  function createDashboardAuth(config) {
16590
17344
  const middleware = async (c, next) => {
16591
- if (!config.password) {
17345
+ const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
17346
+ const configuredToken = config?.token ?? c.env.DASHBOARD_TOKEN ?? c.env.OBS_DASHBOARD_TOKEN;
17347
+ if (!configuredPassword && !configuredToken) {
16592
17348
  return c.json(
16593
17349
  {
16594
- error: "DASHBOARD_PASSWORD is not configured. Set the DASHBOARD_PASSWORD environment variable."
17350
+ error: "Neither DASHBOARD_PASSWORD nor DASHBOARD_TOKEN is configured. Set at least one environment variable."
16595
17351
  },
16596
17352
  500
16597
17353
  );
16598
17354
  }
16599
- const cookieHeader = c.req.header("Cookie");
16600
- const sessionValue = getCookie(cookieHeader, SESSION_COOKIE);
16601
- if (!sessionValue) {
17355
+ let authenticated = false;
17356
+ const authHeader = c.req.header("Authorization");
17357
+ if (authHeader) {
17358
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
17359
+ if (!match) {
17360
+ return c.json({ error: "Unauthorized" }, 401);
17361
+ }
17362
+ const bearerToken = match[1].trim();
17363
+ if (!bearerToken) {
17364
+ return c.json({ error: "Unauthorized" }, 401);
17365
+ }
17366
+ const ingestKey = c.env.INGEST_KEY;
17367
+ if (ingestKey && await timingSafeEqualStr(bearerToken, ingestKey)) {
17368
+ return c.json(
17369
+ {
17370
+ error: "Unauthorized: ingest keys cannot be used for dashboard/internal access"
17371
+ },
17372
+ 401
17373
+ );
17374
+ }
17375
+ if (!configuredToken) {
17376
+ return c.json({ error: "Unauthorized" }, 401);
17377
+ }
17378
+ const valid = await timingSafeEqualStr(bearerToken, configuredToken);
17379
+ if (!valid) {
17380
+ return c.json({ error: "Unauthorized" }, 401);
17381
+ }
17382
+ authenticated = true;
17383
+ } else {
17384
+ const cookieHeader = c.req.header("Cookie");
17385
+ const sessionValue = getCookie(cookieHeader, SESSION_COOKIE);
17386
+ if (sessionValue) {
17387
+ if (!configuredPassword) {
17388
+ return c.json({ error: "Unauthorized" }, 401);
17389
+ }
17390
+ const dotIndex = sessionValue.lastIndexOf(".");
17391
+ if (dotIndex === -1) {
17392
+ return c.json({ error: "Invalid session" }, 401);
17393
+ }
17394
+ const payload = sessionValue.substring(0, dotIndex);
17395
+ const sig = sessionValue.substring(dotIndex + 1);
17396
+ const valid = await verify(payload, sig, configuredPassword);
17397
+ if (!valid) {
17398
+ return c.json({ error: "Invalid session" }, 401);
17399
+ }
17400
+ const token = parseSessionToken(payload);
17401
+ if (!token || token.exp < Math.floor(Date.now() / 1e3)) {
17402
+ return c.json({ error: "Session expired" }, 401);
17403
+ }
17404
+ authenticated = true;
17405
+ }
17406
+ }
17407
+ if (!authenticated) {
16602
17408
  const accept = c.req.header("Accept") || "";
16603
17409
  if (accept.includes("text/html")) {
16604
17410
  return c.redirect("/dashboard/login");
16605
17411
  }
16606
17412
  return c.json({ error: "Unauthorized" }, 401);
16607
17413
  }
16608
- const dotIndex = sessionValue.lastIndexOf(".");
16609
- if (dotIndex === -1) {
16610
- return c.json({ error: "Invalid session" }, 401);
16611
- }
16612
- const payload = sessionValue.substring(0, dotIndex);
16613
- const sig = sessionValue.substring(dotIndex + 1);
16614
- const valid = await verify(payload, sig, config.password);
16615
- if (!valid) {
16616
- return c.json({ error: "Invalid session" }, 401);
16617
- }
16618
- const token = parseSessionToken(payload);
16619
- if (!token || token.exp < Math.floor(Date.now() / 1e3)) {
16620
- return c.json({ error: "Session expired" }, 401);
16621
- }
16622
17414
  const path = c.req.path;
16623
17415
  const needsProject = path.startsWith("/internal/") && !path.startsWith("/internal/projects");
16624
17416
  if (needsProject) {
@@ -16637,17 +17429,42 @@ function createDashboardAuth(config) {
16637
17429
  await next();
16638
17430
  };
16639
17431
  const registerRoutes = (app) => {
17432
+ const limiter = config?.loginRateLimit === false ? null : new LoginRateLimiter(config?.loginRateLimit ?? {});
17433
+ let warnedUnkeyedClient = false;
16640
17434
  app.post("/auth/login", async (c) => {
16641
- if (!config.password) {
17435
+ const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
17436
+ if (!configuredPassword) {
16642
17437
  return c.json({ error: "Dashboard password not configured" }, 500);
16643
17438
  }
16644
- const body = await c.req.json().catch(() => ({ password: void 0 }));
16645
- if (!body.password || !await timingSafeEqualStr(body.password, config.password)) {
17439
+ const limitConfig = config?.loginRateLimit || void 0;
17440
+ const clientKey = limiter ? loginClientKey(c.req.raw.headers, {
17441
+ clientAddress: limitConfig?.clientAddress?.(c),
17442
+ trustProxyHeaders: limitConfig?.trustProxyHeaders
17443
+ }) : null;
17444
+ if (limiter && clientKey === null && !warnedUnkeyedClient) {
17445
+ warnedUnkeyedClient = true;
17446
+ (limitConfig?.warn ?? console.warn)(UNKEYED_LOGIN_CLIENT_WARNING);
17447
+ }
17448
+ const verdict = clientKey === null ? null : limiter?.check(clientKey);
17449
+ if (verdict && !verdict.allowed) {
17450
+ c.header("Retry-After", String(verdict.retryAfterSeconds));
17451
+ return c.json({ error: "Too many failed login attempts" }, 429);
17452
+ }
17453
+ let body = null;
17454
+ try {
17455
+ body = await c.req.json();
17456
+ } catch (err) {
17457
+ if (err instanceof Error && err.name === "BodyLimitError") throw err;
17458
+ body = null;
17459
+ }
17460
+ if (!body?.password || typeof body.password !== "string" || !await timingSafeEqualStr(body.password, configuredPassword)) {
17461
+ if (clientKey !== null) limiter?.recordFailure(clientKey);
16646
17462
  return c.json({ error: "Invalid password" }, 401);
16647
17463
  }
17464
+ if (clientKey !== null) limiter?.reset(clientKey);
16648
17465
  const expiresAt = Math.floor(Date.now() / 1e3) + SESSION_MAX_AGE;
16649
17466
  const payload = createSessionToken(expiresAt);
16650
- const sig = await sign(payload, config.password);
17467
+ const sig = await sign(payload, configuredPassword);
16651
17468
  const cookieValue = `${payload}.${sig}`;
16652
17469
  const isSecure = c.req.url.startsWith("https:");
16653
17470
  c.header(
@@ -16657,9 +17474,27 @@ function createDashboardAuth(config) {
16657
17474
  return c.json({ success: true });
16658
17475
  });
16659
17476
  app.get("/auth/check", async (c) => {
17477
+ const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
17478
+ const configuredToken = config?.token ?? c.env.DASHBOARD_TOKEN ?? c.env.OBS_DASHBOARD_TOKEN;
17479
+ const authHeader = c.req.header("Authorization");
17480
+ if (authHeader) {
17481
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
17482
+ if (match && configuredToken) {
17483
+ const bearerToken = match[1].trim();
17484
+ const ingestKey = c.env.INGEST_KEY;
17485
+ if (ingestKey && await timingSafeEqualStr(bearerToken, ingestKey)) {
17486
+ return c.json({ authenticated: false });
17487
+ }
17488
+ const valid2 = await timingSafeEqualStr(bearerToken, configuredToken);
17489
+ if (valid2) {
17490
+ return c.json({ authenticated: true });
17491
+ }
17492
+ }
17493
+ return c.json({ authenticated: false });
17494
+ }
16660
17495
  const cookieHeader = c.req.header("Cookie");
16661
17496
  const sessionValue = getCookie(cookieHeader, SESSION_COOKIE);
16662
- if (!sessionValue) {
17497
+ if (!sessionValue || !configuredPassword) {
16663
17498
  return c.json({ authenticated: false });
16664
17499
  }
16665
17500
  const dotIndex = sessionValue.lastIndexOf(".");
@@ -16668,7 +17503,7 @@ function createDashboardAuth(config) {
16668
17503
  }
16669
17504
  const payload = sessionValue.substring(0, dotIndex);
16670
17505
  const sig = sessionValue.substring(dotIndex + 1);
16671
- const valid = await verify(payload, sig, config.password);
17506
+ const valid = await verify(payload, sig, configuredPassword);
16672
17507
  if (!valid) {
16673
17508
  return c.json({ authenticated: false });
16674
17509
  }
@@ -16812,8 +17647,7 @@ var ActionStore = class {
16812
17647
  tool_call_id, prompt_version, model_name, provider, total_cost_usd,
16813
17648
  attrs_json
16814
17649
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
16815
- ON CONFLICT(id) DO UPDATE SET
16816
- project_id = excluded.project_id,
17650
+ ON CONFLICT(project_id, id) DO UPDATE SET
16817
17651
  root_action_id = excluded.root_action_id,
16818
17652
  caused_by_action_id = excluded.caused_by_action_id,
16819
17653
  actor_type = excluded.actor_type,
@@ -16872,8 +17706,7 @@ var ActionStore = class {
16872
17706
  autonomy_level, status, error_message, total_cost_usd,
16873
17707
  total_duration_ms, metadata_json
16874
17708
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
16875
- ON CONFLICT(id) DO UPDATE SET
16876
- project_id = excluded.project_id,
17709
+ ON CONFLICT(project_id, id) DO UPDATE SET
16877
17710
  agent_id = excluded.agent_id,
16878
17711
  agent_name = excluded.agent_name,
16879
17712
  agent_version = excluded.agent_version,
@@ -16909,9 +17742,8 @@ var ActionStore = class {
16909
17742
  mcp_audit_json, mutation_before_json, mutation_after_json,
16910
17743
  mutation_diff_json, mutation_artifact_id
16911
17744
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
16912
- ON CONFLICT(id) DO UPDATE SET
17745
+ ON CONFLICT(project_id, id) DO UPDATE SET
16913
17746
  action_id = excluded.action_id,
16914
- project_id = excluded.project_id,
16915
17747
  tool_name = excluded.tool_name,
16916
17748
  args_hash = excluded.args_hash,
16917
17749
  result_hash = excluded.result_hash,
@@ -16950,9 +17782,8 @@ var ActionStore = class {
16950
17782
  id, action_id, project_id, retriever_name, query_hash, documents_json,
16951
17783
  total_results, max_relevance_score, duration_ms
16952
17784
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
16953
- ON CONFLICT(id) DO UPDATE SET
17785
+ ON CONFLICT(project_id, id) DO UPDATE SET
16954
17786
  action_id = excluded.action_id,
16955
- project_id = excluded.project_id,
16956
17787
  retriever_name = excluded.retriever_name,
16957
17788
  query_hash = excluded.query_hash,
16958
17789
  documents_json = excluded.documents_json,
@@ -16977,9 +17808,8 @@ var ActionStore = class {
16977
17808
  id, action_id, project_id, evaluator_name, evaluator_version, score,
16978
17809
  passed, reasoning, rubric_json
16979
17810
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
16980
- ON CONFLICT(id) DO UPDATE SET
17811
+ ON CONFLICT(project_id, id) DO UPDATE SET
16981
17812
  action_id = excluded.action_id,
16982
- project_id = excluded.project_id,
16983
17813
  evaluator_name = excluded.evaluator_name,
16984
17814
  evaluator_version = excluded.evaluator_version,
16985
17815
  score = excluded.score,
@@ -17004,9 +17834,8 @@ var ActionStore = class {
17004
17834
  id, action_id, project_id, artifact_name, artifact_type, storage_ref,
17005
17835
  size_bytes, sha256_hash, content_preview
17006
17836
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
17007
- ON CONFLICT(id) DO UPDATE SET
17837
+ ON CONFLICT(project_id, id) DO UPDATE SET
17008
17838
  action_id = excluded.action_id,
17009
- project_id = excluded.project_id,
17010
17839
  artifact_name = excluded.artifact_name,
17011
17840
  artifact_type = excluded.artifact_type,
17012
17841
  storage_ref = excluded.storage_ref,
@@ -17452,9 +18281,11 @@ var PostgresAdapter = class {
17452
18281
  dialect = postgresDialect;
17453
18282
  statementTimeoutMs;
17454
18283
  prepare(sql) {
18284
+ const translated = prepareSqlForPostgres(sql);
17455
18285
  return new PostgresStatement(
17456
18286
  this.pool,
17457
- translateD1Sql(rewriteQuestionMarks(sql))
18287
+ translated,
18288
+ mixedCaseAliases(translated)
17458
18289
  );
17459
18290
  }
17460
18291
  async batch(statements) {
@@ -17485,23 +18316,49 @@ var PostgresAdapter = class {
17485
18316
  }
17486
18317
  }
17487
18318
  };
17488
- var PostgresStatement = class {
17489
- constructor(pool, sql) {
18319
+ var PostgresStatement = class _PostgresStatement {
18320
+ constructor(pool, sql, aliases = /* @__PURE__ */ new Map(), bound = []) {
17490
18321
  this.pool = pool;
17491
18322
  this.sql = sql;
18323
+ this.aliases = aliases;
18324
+ this.bound = bound;
17492
18325
  }
17493
- bound = [];
18326
+ /**
18327
+ * Returns a NEW statement, matching D1 semantics. Stores prepare once and
18328
+ * bind per row (`calls.map((c) => stmt.bind(...))`), so mutating in place
18329
+ * would make every statement in a batch carry the last row's values.
18330
+ */
17494
18331
  bind(...args) {
17495
- this.bound = args;
17496
- return this;
18332
+ return new _PostgresStatement(this.pool, this.sql, this.aliases, args);
17497
18333
  }
17498
18334
  async first() {
17499
18335
  const result = await this.exec();
17500
- return result.rows[0] ?? null;
18336
+ const row = result.rows[0];
18337
+ return row === void 0 ? null : this.restoreAliases(row);
17501
18338
  }
17502
18339
  async all() {
17503
18340
  const result = await this.exec();
17504
- return { results: result.rows };
18341
+ return {
18342
+ results: result.rows.map(
18343
+ (row) => this.restoreAliases(row)
18344
+ )
18345
+ };
18346
+ }
18347
+ /**
18348
+ * Postgres folds unquoted identifiers to lower case, so `COUNT(*) AS
18349
+ * totalCalls` comes back as `totalcalls`. D1 preserves the alias. Put the
18350
+ * mixed-case spelling back so stores can read `row.totalCalls` on both.
18351
+ */
18352
+ restoreAliases(row) {
18353
+ if (this.aliases.size === 0 || !row || typeof row !== "object") return row;
18354
+ const out = row;
18355
+ for (const [lower, original] of this.aliases) {
18356
+ if (lower in out && !(original in out)) {
18357
+ out[original] = out[lower];
18358
+ delete out[lower];
18359
+ }
18360
+ }
18361
+ return row;
17505
18362
  }
17506
18363
  async run() {
17507
18364
  const result = await this.exec();
@@ -17510,19 +18367,100 @@ var PostgresStatement = class {
17510
18367
  // Internal — used by batch() to run inside an already-checked-out
17511
18368
  // client (so the BEGIN/COMMIT span the whole batch).
17512
18369
  async runOnClient(client) {
17513
- const r = await client.query(this.sql, this.bound);
18370
+ const r = await client.query(this.sql, [...this.bound]);
17514
18371
  return { meta: { changes: r.rowCount ?? 0 } };
17515
18372
  }
17516
18373
  async exec() {
17517
18374
  const client = await this.pool.connect();
17518
18375
  try {
17519
- const r = await client.query(this.sql, this.bound);
18376
+ const r = await client.query(this.sql, [...this.bound]);
17520
18377
  return { rows: r.rows, rowCount: r.rowCount };
17521
18378
  } finally {
17522
18379
  client.release();
17523
18380
  }
17524
18381
  }
17525
18382
  };
18383
+ var POSTGRES_TYPE_OIDS = {
18384
+ BOOL: 16,
18385
+ INT8: 20,
18386
+ JSON: 114,
18387
+ NUMERIC: 1700,
18388
+ JSONB: 3802
18389
+ };
18390
+ var parsePostgresNumber = (value, typeName) => {
18391
+ const parsed = Number(value);
18392
+ if (!Number.isFinite(parsed)) {
18393
+ throw new RangeError(
18394
+ `PostgresAdapter: cannot convert ${typeName} value ${JSON.stringify(value)} to a JS number`
18395
+ );
18396
+ }
18397
+ if (/^-?\d+$/.test(value) && !Number.isSafeInteger(parsed)) {
18398
+ throw new RangeError(
18399
+ `PostgresAdapter: ${typeName} value ${value} exceeds Number.MAX_SAFE_INTEGER (${Number.MAX_SAFE_INTEGER}); cast it to TEXT in SQL if the exact value is needed`
18400
+ );
18401
+ }
18402
+ return parsed;
18403
+ };
18404
+ var configurePostgresTypeParsers = (types) => {
18405
+ types.setTypeParser(
18406
+ POSTGRES_TYPE_OIDS.BOOL,
18407
+ (value) => value === "t" || value === "true" ? 1 : 0
18408
+ );
18409
+ types.setTypeParser(
18410
+ POSTGRES_TYPE_OIDS.INT8,
18411
+ (value) => parsePostgresNumber(value, "BIGINT")
18412
+ );
18413
+ types.setTypeParser(
18414
+ POSTGRES_TYPE_OIDS.NUMERIC,
18415
+ (value) => parsePostgresNumber(value, "NUMERIC")
18416
+ );
18417
+ types.setTypeParser(POSTGRES_TYPE_OIDS.JSON, (value) => value);
18418
+ types.setTypeParser(POSTGRES_TYPE_OIDS.JSONB, (value) => value);
18419
+ };
18420
+ var prepareSqlForPostgres = (sql) => translateD1Sql(rewriteQuestionMarks(stripSqlComments(sql)));
18421
+ var stripSqlComments = (sql) => {
18422
+ let out = "";
18423
+ let inString = false;
18424
+ for (let i = 0; i < sql.length; i++) {
18425
+ const ch = sql[i];
18426
+ if (ch === "'") {
18427
+ inString = !inString;
18428
+ out += ch;
18429
+ continue;
18430
+ }
18431
+ if (inString) {
18432
+ out += ch;
18433
+ continue;
18434
+ }
18435
+ if (ch === "-" && sql[i + 1] === "-") {
18436
+ const eol = sql.indexOf("\n", i);
18437
+ if (eol === -1) return out;
18438
+ i = eol - 1;
18439
+ continue;
18440
+ }
18441
+ if (ch === "/" && sql[i + 1] === "*") {
18442
+ const end = sql.indexOf("*/", i + 2);
18443
+ if (end === -1) return out;
18444
+ i = end + 1;
18445
+ continue;
18446
+ }
18447
+ out += ch;
18448
+ }
18449
+ return out;
18450
+ };
18451
+ var mixedCaseAliases = (sql) => {
18452
+ const aliases = /* @__PURE__ */ new Map();
18453
+ const pattern = /\bAS\s+([A-Za-z_][A-Za-z0-9_]*)/gi;
18454
+ stripSqlComments(sql).split("'").filter((_segment, index) => index % 2 === 0).forEach((segment) => {
18455
+ for (const match of segment.matchAll(pattern)) {
18456
+ const alias = match[1];
18457
+ if (alias !== alias.toLowerCase() && alias !== alias.toUpperCase()) {
18458
+ aliases.set(alias.toLowerCase(), alias);
18459
+ }
18460
+ }
18461
+ });
18462
+ return aliases;
18463
+ };
17526
18464
  var rewriteQuestionMarks = (sql) => {
17527
18465
  let out = "";
17528
18466
  let inString = false;
@@ -17543,45 +18481,167 @@ var rewriteQuestionMarks = (sql) => {
17543
18481
  }
17544
18482
  return out;
17545
18483
  };
18484
+ var UTC_NOW = "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')";
18485
+ var INTERVAL_UNITS = /^(hour|hours|minute|minutes|day|days|second|seconds)$/i;
18486
+ var sqliteModifierToInterval = (modifier) => {
18487
+ const literal = /^'-(\d+)\s+([a-z]+)'$/i.exec(modifier);
18488
+ if (literal && INTERVAL_UNITS.test(literal[2])) {
18489
+ return `INTERVAL '${literal[1]} ${literal[2].toLowerCase()}'`;
18490
+ }
18491
+ const concat = /^'-'\s*\|\|\s*(.+?)\s*\|\|\s*'\s*([a-z]+)'$/i.exec(modifier);
18492
+ if (concat && INTERVAL_UNITS.test(concat[2])) {
18493
+ return `(${concat[1].trim()}::text || ' ${concat[2].toLowerCase()}')::interval`;
18494
+ }
18495
+ return null;
18496
+ };
18497
+ var asUtcTimestamp = (expr) => `(${expr})::timestamptz AT TIME ZONE 'UTC'`;
18498
+ var STRFTIME_FORMATS = {
18499
+ "%Y-%m-%dT%H:%M:00Z": (expr) => `to_char(date_trunc('minute', ${asUtcTimestamp(expr)}), 'YYYY-MM-DD"T"HH24:MI:00"Z"')`,
18500
+ "%Y-%m-%dT%H:00:00Z": (expr) => `to_char(date_trunc('hour', ${asUtcTimestamp(expr)}), 'YYYY-MM-DD"T"HH24:00:00"Z"')`,
18501
+ "%s": (expr) => `EXTRACT(EPOCH FROM (${expr})::timestamptz)`
18502
+ };
18503
+ var unquote = (literal) => {
18504
+ const m = /^'((?:[^']|'')*)'$/.exec(literal.trim());
18505
+ return m ? m[1].replace(/''/g, "'") : null;
18506
+ };
18507
+ var jsonPathToPostgres = (expr, pathLiteral) => {
18508
+ const path = unquote(pathLiteral);
18509
+ if (!path?.startsWith("$.")) return null;
18510
+ const rest = path.slice(2);
18511
+ const quoted = /^"([^"]+)"$/.exec(rest);
18512
+ const escapeKey = (key) => key.replace(/\\u002E/g, ".").replace(/'/g, "''");
18513
+ if (quoted) return `((${expr})::jsonb ->> '${escapeKey(quoted[1])}')`;
18514
+ if (!/^[A-Za-z0-9_.\\]+$/.test(rest)) return null;
18515
+ const segments = rest.split(".").map(escapeKey);
18516
+ if (segments.length === 1) return `((${expr})::jsonb ->> '${segments[0]}')`;
18517
+ return `((${expr})::jsonb #>> '{${segments.join(",")}}')`;
18518
+ };
18519
+ var FUNCTION_REWRITES = {
18520
+ datetime: (args) => {
18521
+ if (args.length === 1 && /^'now'$/i.test(args[0])) {
18522
+ return postgresIsoText(UTC_NOW);
18523
+ }
18524
+ if (args.length === 2 && /^'now'$/i.test(args[0])) {
18525
+ const interval = sqliteModifierToInterval(args[1]);
18526
+ return interval ? postgresIsoText(`${UTC_NOW} - ${interval}`) : null;
18527
+ }
18528
+ if (args.length === 2 && /^'unixepoch'$/i.test(args[1])) {
18529
+ return `to_timestamp(${args[0]})`;
18530
+ }
18531
+ return null;
18532
+ },
18533
+ strftime: (args) => {
18534
+ const format = args.length >= 2 ? unquote(args[0]) : null;
18535
+ if (format === SQLITE_ISO_FORMAT && /^'now'$/i.test(args[1])) {
18536
+ if (args.length === 2) return postgresIsoText(UTC_NOW);
18537
+ if (args.length === 3) {
18538
+ const interval = sqliteModifierToInterval(args[2]);
18539
+ return interval ? postgresIsoText(`${UTC_NOW} - ${interval}`) : null;
18540
+ }
18541
+ return null;
18542
+ }
18543
+ if (args.length !== 2) return null;
18544
+ const render = format ? STRFTIME_FORMATS[format] : void 0;
18545
+ return render ? render(args[1]) : null;
18546
+ },
18547
+ julianday: (args) => args.length === 1 ? `(EXTRACT(EPOCH FROM (${args[0]})::timestamptz) / 86400.0 + 2440587.5)` : null,
18548
+ json_extract: (args) => args.length === 2 ? jsonPathToPostgres(args[0], args[1]) : null,
18549
+ json_object: (args) => `json_build_object(${args.join(", ")})`,
18550
+ json_array: (args) => `json_build_array(${args.join(", ")})`,
18551
+ json_group_array: (args) => `json_agg(${args.join(", ")})`,
18552
+ // SQLite's scalar MAX/MIN take several arguments; Postgres spells those
18553
+ // GREATEST/LEAST. Single-argument MAX/MIN stay aggregates.
18554
+ max: (args) => args.length > 1 ? `GREATEST(${args.join(", ")})` : null,
18555
+ min: (args) => args.length > 1 ? `LEAST(${args.join(", ")})` : null,
18556
+ // Postgres only defines ROUND(numeric, int); SQLite rounds REAL directly.
18557
+ round: (args) => args.length === 2 ? `ROUND((${args[0]})::numeric, ${args[1]})` : null
18558
+ };
18559
+ var FUNCTION_NAME_PATTERN = new RegExp(
18560
+ `\\b(${Object.keys(FUNCTION_REWRITES).join("|")})\\s*\\(`,
18561
+ "i"
18562
+ );
18563
+ var isIdentifierChar = (ch) => ch !== void 0 && /[A-Za-z0-9_.]/.test(ch);
18564
+ var findClosingParen = (sql, open) => {
18565
+ let depth = 0;
18566
+ let inString = false;
18567
+ for (let i = open; i < sql.length; i++) {
18568
+ const ch = sql[i];
18569
+ if (ch === "'") {
18570
+ inString = !inString;
18571
+ continue;
18572
+ }
18573
+ if (inString) continue;
18574
+ if (ch === "(") depth += 1;
18575
+ else if (ch === ")") {
18576
+ depth -= 1;
18577
+ if (depth === 0) return i;
18578
+ }
18579
+ }
18580
+ return -1;
18581
+ };
18582
+ var splitTopLevelArgs = (inner) => {
18583
+ const args = [];
18584
+ let depth = 0;
18585
+ let inString = false;
18586
+ let current = "";
18587
+ for (const ch of inner) {
18588
+ if (ch === "'") inString = !inString;
18589
+ if (!inString) {
18590
+ if (ch === "(") depth += 1;
18591
+ else if (ch === ")") depth -= 1;
18592
+ else if (ch === "," && depth === 0) {
18593
+ args.push(current.trim());
18594
+ current = "";
18595
+ continue;
18596
+ }
18597
+ }
18598
+ current += ch;
18599
+ }
18600
+ if (current.trim() !== "" || args.length > 0) args.push(current.trim());
18601
+ return args;
18602
+ };
18603
+ var insideStringLiteral = (sql, index) => {
18604
+ let inString = false;
18605
+ for (let i = 0; i < index; i++) {
18606
+ if (sql[i] === "'") inString = !inString;
18607
+ }
18608
+ return inString;
18609
+ };
18610
+ var translateFunctionCalls = (sql) => {
18611
+ let out = "";
18612
+ let rest = sql;
18613
+ for (; ; ) {
18614
+ const match = FUNCTION_NAME_PATTERN.exec(rest);
18615
+ if (!match) return out + rest;
18616
+ const start = match.index;
18617
+ const open = start + match[0].length - 1;
18618
+ if (insideStringLiteral(rest, start)) {
18619
+ const closeQuote = rest.indexOf("'", start);
18620
+ const cut = closeQuote === -1 ? rest.length : closeQuote + 1;
18621
+ out += rest.slice(0, cut);
18622
+ rest = rest.slice(cut);
18623
+ continue;
18624
+ }
18625
+ const precededByIdentifier = isIdentifierChar(rest[start - 1]);
18626
+ const close = findClosingParen(rest, open);
18627
+ if (precededByIdentifier || close === -1) {
18628
+ out += rest.slice(0, open + 1);
18629
+ rest = rest.slice(open + 1);
18630
+ continue;
18631
+ }
18632
+ const name = match[1].toLowerCase();
18633
+ const inner = translateFunctionCalls(rest.slice(open + 1, close));
18634
+ const replacement = FUNCTION_REWRITES[name](splitTopLevelArgs(inner));
18635
+ out += rest.slice(0, start);
18636
+ out += replacement ?? `${match[1]}(${inner})`;
18637
+ rest = rest.slice(close + 1);
18638
+ }
18639
+ };
17546
18640
  var translateD1Sql = (sql) => {
17547
18641
  let out = sql;
17548
18642
  const hadInsertOrIgnore = /\bINSERT\s+OR\s+IGNORE\s+INTO\b/i.test(out);
17549
18643
  out = out.replace(/\bINSERT\s+OR\s+IGNORE\s+INTO\b/gi, "INSERT INTO");
17550
- out = out.replace(
17551
- /datetime\('now',\s*'-'\s*\|\|\s*(\$\d+)\s*\|\|\s*'\s*hours'\)/gi,
17552
- "(CURRENT_TIMESTAMP - ($1::text || ' hours')::interval)"
17553
- );
17554
- out = out.replace(
17555
- /datetime\('now',\s*'-'\s*\|\|\s*(\$\d+)\s*\|\|\s*'\s*minutes'\)/gi,
17556
- "(CURRENT_TIMESTAMP - ($1::text || ' minutes')::interval)"
17557
- );
17558
- out = out.replace(
17559
- /datetime\('now',\s*'-(\d+)\s+(hour|hours|minute|minutes|day|days)'\)/gi,
17560
- "CURRENT_TIMESTAMP - INTERVAL '$1 $2'"
17561
- );
17562
- out = out.replace(/datetime\('now'\)/gi, "CURRENT_TIMESTAMP");
17563
- out = out.replace(
17564
- /json_extract\(([^,()]+),\s*'\$\.(?:"([^"]+)"|([A-Za-z0-9_\\.\\\\]+))'\)/gi,
17565
- (_match, expr, quotedKey, bareKey) => {
17566
- const key = (quotedKey ?? bareKey ?? "").replace(
17567
- /\\\\u002E|\\u002E/g,
17568
- "."
17569
- );
17570
- return `(${expr.trim()}::jsonb ->> '${key.replace(/'/g, "''")}')`;
17571
- }
17572
- );
17573
- out = out.replace(
17574
- /strftime\('%Y-%m-%dT%H:%M:00Z',\s*([^)]+)\)/gi,
17575
- `to_char(date_trunc('minute', $1::timestamp), 'YYYY-MM-DD"T"HH24:MI:00"Z"')`
17576
- );
17577
- out = out.replace(
17578
- /strftime\('%Y-%m-%dT%H:00:00Z',\s*([^)]+)\)/gi,
17579
- `to_char(date_trunc('hour', $1::timestamp), 'YYYY-MM-DD"T"HH24:00:00"Z"')`
17580
- );
17581
- out = out.replace(
17582
- /strftime\('%s',\s*([^)]+)\)/gi,
17583
- "EXTRACT(EPOCH FROM $1::timestamp)"
17584
- );
18644
+ out = translateFunctionCalls(out);
17585
18645
  if (hadInsertOrIgnore && /\bON\s+CONFLICT\b/i.test(out) === false) {
17586
18646
  out = out.replace(/(;?\s*)$/, " ON CONFLICT DO NOTHING$1");
17587
18647
  }
@@ -17653,6 +18713,7 @@ export {
17653
18713
  R2BlobStore,
17654
18714
  S3BlobStore,
17655
18715
  TailHub,
18716
+ actionRetentionCutoff,
17656
18717
  actionRoutesPlugin,
17657
18718
  aiReceiverPlugin,
17658
18719
  aiSpanPayloadsProcessorPlugin,
@@ -17662,6 +18723,7 @@ export {
17662
18723
  askRoutesPlugin,
17663
18724
  botFilterPlugin,
17664
18725
  compareValue,
18726
+ configurePostgresTypeParsers,
17665
18727
  connectedRoutesPlugin,
17666
18728
  consoleLogger,
17667
18729
  createAlertEvaluatorHandler,
@@ -17690,6 +18752,7 @@ export {
17690
18752
  platformRoutesPlugin,
17691
18753
  profileRoutesPlugin,
17692
18754
  projectsRoutesPlugin,
18755
+ purgeExpiredActionGraph,
17693
18756
  queryRoutesPlugin,
17694
18757
  redactionProcessorPlugin,
17695
18758
  replayQueryRoutesPlugin,