@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
@@ -12,6 +12,12 @@ import {
12
12
  } from "../framework/logger";
13
13
  import { AlertsStore, compareValue } from "../lib/alerts-store";
14
14
  import { sqlDbFor } from "../lib/sql-db";
15
+ import {
16
+ assertSafeWebhookUrl,
17
+ type HostLookup,
18
+ redactWebhookUrl,
19
+ resolveWebhookHost,
20
+ } from "../lib/webhook-url";
15
21
 
16
22
  const DEFAULT_RULE_CONCURRENCY = 5;
17
23
  const DEFAULT_RULE_TIMEOUT_MS = 10_000;
@@ -19,7 +25,22 @@ const DEFAULT_RULE_TIMEOUT_MS = 10_000;
19
25
  interface AlertEvaluatorStore {
20
26
  listEnabledRules(): Promise<AlertRule[]>;
21
27
  evaluateRule(rule: AlertRule): Promise<number>;
22
- getState(ruleId: string): Promise<{ current_state: AlertState } | null>;
28
+ getState(
29
+ ruleId: string,
30
+ projectId: string,
31
+ ): Promise<{
32
+ current_state: AlertState;
33
+ last_state_change: string;
34
+ /** 0 while the transition's notification is owed; absent pre-049 (= delivered). */
35
+ notified?: number;
36
+ } | null>;
37
+ /** Flip the exact transition `(state, lastStateChange)` to delivered. */
38
+ markNotified(
39
+ ruleId: string,
40
+ projectId: string,
41
+ state: AlertState,
42
+ lastStateChange: string,
43
+ ): Promise<boolean>;
23
44
  getAnalysisNarrative(
24
45
  projectId: string,
25
46
  analysisId: string,
@@ -65,12 +86,56 @@ interface WebhookPayload {
65
86
  };
66
87
  }
67
88
 
89
+ export interface WebhookDeliveryOptions {
90
+ /** Permit targets on loopback / private / link-local hosts. */
91
+ allowPrivateWebhookTargets?: boolean;
92
+ /**
93
+ * Resolver used to refuse public names that point at private addresses.
94
+ * Hosts that can resolve (Node: `dns.lookup`) pass one; Workers cannot
95
+ * reach private networks from `fetch` and leave it unset.
96
+ */
97
+ hostLookup?: HostLookup;
98
+ }
99
+
68
100
  async function fireWebhook(
69
101
  channel: AlertWebhookChannel,
70
102
  payload: WebhookPayload,
71
103
  logger: Logger,
72
104
  tracer?: ChildSpanRunner,
105
+ delivery: WebhookDeliveryOptions = {},
73
106
  ): Promise<boolean> {
107
+ // Hook URLs carry secrets in the path (Slack, PagerDuty…): logs and span
108
+ // attributes only ever see the redacted form.
109
+ const target = redactWebhookUrl(channel.url);
110
+ // Re-check at fire time: rules created before the host guard existed
111
+ // (or with the opt-in later removed) must not reach internal addresses.
112
+ try {
113
+ const url = assertSafeWebhookUrl(channel.url, {
114
+ allowPrivateNetworks: delivery.allowPrivateWebhookTargets,
115
+ });
116
+ if (!delivery.allowPrivateWebhookTargets && delivery.hostLookup) {
117
+ const resolution = await resolveWebhookHost(
118
+ url.hostname,
119
+ delivery.hostLookup,
120
+ );
121
+ if (resolution === "private") {
122
+ throw new Error(
123
+ `channel.url host "${url.hostname}" resolves to a private address`,
124
+ );
125
+ }
126
+ if (resolution === "unresolvable") {
127
+ throw new Error(
128
+ `channel.url host "${url.hostname}" could not be resolved; refusing to deliver blind`,
129
+ );
130
+ }
131
+ }
132
+ } catch (err) {
133
+ logger.error("[alerts-evaluator] webhook target refused", {
134
+ url: target,
135
+ error: err instanceof Error ? err.message : String(err),
136
+ });
137
+ return false;
138
+ }
74
139
  const exec = async (
75
140
  span: { setAttribute(k: string, v: unknown): void } | null,
76
141
  ): Promise<boolean> => {
@@ -82,13 +147,24 @@ async function fireWebhook(
82
147
  ...(channel.headers ?? {}),
83
148
  },
84
149
  body: JSON.stringify(payload),
150
+ // Never follow redirects: a 3xx could re-aim the POST (and its
151
+ // headers) at an arbitrary host. A redirecting hook is a
152
+ // misconfiguration and counts as a failed delivery.
153
+ redirect: "manual",
85
154
  signal: AbortSignal.timeout(5000),
86
155
  });
87
156
  if (span) span.setAttribute("http.response.status_code", response.status);
157
+ if (response.status >= 300 && response.status < 400) {
158
+ logger.error("[alerts-evaluator] webhook responded with a redirect", {
159
+ url: target,
160
+ status: response.status,
161
+ });
162
+ return false;
163
+ }
88
164
  return response.ok;
89
165
  } catch (err) {
90
166
  logger.error("[alerts-evaluator] webhook delivery failed", {
91
- url: channel.url,
167
+ url: target,
92
168
  error: err instanceof Error ? err.message : String(err),
93
169
  });
94
170
  return false;
@@ -97,7 +173,7 @@ async function fireWebhook(
97
173
  if (!tracer) return exec(null);
98
174
  return tracer("webhook.alert", async (span) => exec(span), {
99
175
  "http.method": "POST",
100
- "http.url": channel.url,
176
+ "http.url": target,
101
177
  "alert.state": payload.state,
102
178
  });
103
179
  }
@@ -107,12 +183,13 @@ async function fireChannels(
107
183
  payload: WebhookPayload,
108
184
  logger: Logger,
109
185
  tracer?: ChildSpanRunner,
186
+ delivery: WebhookDeliveryOptions = {},
110
187
  ): Promise<boolean> {
111
188
  if (channels.length === 0) return true;
112
189
  const results = await Promise.all(
113
190
  channels
114
191
  .filter((ch): ch is AlertWebhookChannel => ch.type === "webhook")
115
- .map((ch) => fireWebhook(ch, payload, logger, tracer)),
192
+ .map((ch) => fireWebhook(ch, payload, logger, tracer, delivery)),
116
193
  );
117
194
  return results.every(Boolean);
118
195
  }
@@ -160,7 +237,9 @@ async function evaluateOneRule(
160
237
  logger: Logger,
161
238
  tracer: ChildSpanRunner | undefined,
162
239
  ruleTimeoutMs: number,
163
- ): Promise<{ fired: number; resolved: number }> {
240
+ delivery: WebhookDeliveryOptions,
241
+ ): Promise<{ fired: number; resolved: number; redelivered: number }> {
242
+ const none = { fired: 0, resolved: 0, redelivered: 0 };
164
243
  try {
165
244
  const value = await withTimeout(
166
245
  store.evaluateRule(rule),
@@ -168,7 +247,7 @@ async function evaluateOneRule(
168
247
  `rule evaluation timed out after ${ruleTimeoutMs}ms`,
169
248
  );
170
249
  const shouldFire = compareValue(value, rule.threshold, rule.comparison);
171
- const priorState = await store.getState(rule.id);
250
+ const priorState = await store.getState(rule.id, rule.projectId);
172
251
  const previous: AlertState = priorState?.current_state ?? "ok";
173
252
  const next: AlertState = shouldFire ? "firing" : "ok";
174
253
  const now = new Date().toISOString();
@@ -191,54 +270,78 @@ async function evaluateOneRule(
191
270
  .catch(() => undefined)
192
271
  : undefined;
193
272
 
194
- if (previous === "ok" && next === "firing") {
195
- const ok = await fireChannels(
273
+ const deliver = (state: AlertState) =>
274
+ fireChannels(
196
275
  rule.channels,
197
276
  {
198
277
  rule: { id: rule.id, name: rule.name, signal: rule.signal },
199
278
  value,
200
279
  threshold: rule.threshold,
201
280
  comparison: rule.comparison,
202
- state: "firing",
281
+ state,
203
282
  evaluatedAt: now,
204
283
  projectId: rule.projectId,
205
284
  analysis: analysisAttachment,
206
285
  },
207
286
  logger,
208
287
  tracer,
288
+ delivery,
209
289
  );
210
- await store.transitionState(rule.id, rule.projectId, "firing", now);
211
- await store.recordEvaluation(
212
- rule.id,
213
- rule.projectId,
214
- value,
215
- "firing",
216
- ok,
217
- );
218
- return { fired: 1, resolved: 0 };
290
+
291
+ if (previous !== next) {
292
+ // The stored state is what the dashboard shows and must follow the
293
+ // signal, not the webhook: record the transition first (with its
294
+ // notification outstanding, `alert_state.notified = 0`), then
295
+ // notify. On accepted delivery the flag is flipped for exactly this
296
+ // transition; otherwise the same-state branch below retries on the
297
+ // next tick without a second transition.
298
+ await store.transitionState(rule.id, rule.projectId, next, now);
299
+ const ok = await deliver(next);
300
+ // Acknowledge before writing history: if the evaluation insert
301
+ // fails, the delivered flag is already durable and the next tick
302
+ // will not send the same alert again.
303
+ if (ok) {
304
+ await store.markNotified(rule.id, rule.projectId, next, now);
305
+ }
306
+ await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
307
+ if (!ok) {
308
+ logger.warn(
309
+ "[alerts-evaluator] state recorded but delivery failed; will retry",
310
+ { rule_id: rule.id, project_id: rule.projectId, state: next },
311
+ );
312
+ }
313
+ return next === "firing"
314
+ ? { fired: 1, resolved: 0, redelivered: 0 }
315
+ : { fired: 0, resolved: 1, redelivered: 0 };
219
316
  }
220
- if (previous === "firing" && next === "ok") {
221
- const ok = await fireChannels(
222
- rule.channels,
223
- {
224
- rule: { id: rule.id, name: rule.name, signal: rule.signal },
225
- value,
226
- threshold: rule.threshold,
227
- comparison: rule.comparison,
228
- state: "ok",
229
- evaluatedAt: now,
230
- projectId: rule.projectId,
231
- analysis: analysisAttachment,
232
- },
233
- logger,
234
- tracer,
235
- );
236
- await store.transitionState(rule.id, rule.projectId, "ok", now);
237
- await store.recordEvaluation(rule.id, rule.projectId, value, "ok", ok);
238
- return { fired: 0, resolved: 1 };
317
+
318
+ // Same state. While the transition into it is still marked undelivered,
319
+ // retry the notification; the state itself is already correct. A row
320
+ // without the flag (schema before migration 049) counts as delivered.
321
+ const owed = priorState !== null && priorState.notified === 0;
322
+ if (owed) {
323
+ const ok = await deliver(previous);
324
+ if (ok) {
325
+ await store.markNotified(
326
+ rule.id,
327
+ rule.projectId,
328
+ previous,
329
+ priorState.last_state_change,
330
+ );
331
+ }
332
+ await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
333
+ if (!ok) {
334
+ logger.warn("[alerts-evaluator] delivery retry failed; will retry", {
335
+ rule_id: rule.id,
336
+ project_id: rule.projectId,
337
+ state: previous,
338
+ });
339
+ return none;
340
+ }
341
+ return { fired: 0, resolved: 0, redelivered: 1 };
239
342
  }
240
343
 
241
- // Same state — write an evaluation but do not fire channels.
344
+ // Same state, already notified — write an evaluation, do not fire.
242
345
  await store.recordEvaluation(rule.id, rule.projectId, value, next, false);
243
346
  } catch (err) {
244
347
  logger.error("[alerts-evaluator] rule evaluation failed", {
@@ -248,7 +351,7 @@ async function evaluateOneRule(
248
351
  });
249
352
  }
250
353
 
251
- return { fired: 0, resolved: 0 };
354
+ return none;
252
355
  }
253
356
 
254
357
  export async function evaluateRuleBatch(
@@ -259,13 +362,21 @@ export async function evaluateRuleBatch(
259
362
  tracer?: ChildSpanRunner;
260
363
  concurrency?: number;
261
364
  ruleTimeoutMs?: number;
365
+ allowPrivateWebhookTargets?: boolean;
366
+ hostLookup?: HostLookup;
262
367
  },
263
368
  ): Promise<{
264
369
  evaluated: number;
265
370
  fired: number;
266
371
  resolved: number;
372
+ /** Notifications for an earlier transition that were delivered on this tick. */
373
+ redelivered: number;
267
374
  }> {
268
375
  const logger = options?.logger ?? consoleLogger;
376
+ const delivery: WebhookDeliveryOptions = {
377
+ allowPrivateWebhookTargets: options?.allowPrivateWebhookTargets,
378
+ hostLookup: options?.hostLookup,
379
+ };
269
380
  const concurrency = Math.max(
270
381
  1,
271
382
  Math.floor(options?.concurrency ?? DEFAULT_RULE_CONCURRENCY),
@@ -275,12 +386,20 @@ export async function evaluateRuleBatch(
275
386
  Math.floor(options?.ruleTimeoutMs ?? DEFAULT_RULE_TIMEOUT_MS),
276
387
  );
277
388
  const results = await mapWithConcurrency(rules, concurrency, (rule) =>
278
- evaluateOneRule(store, rule, logger, options?.tracer, ruleTimeoutMs),
389
+ evaluateOneRule(
390
+ store,
391
+ rule,
392
+ logger,
393
+ options?.tracer,
394
+ ruleTimeoutMs,
395
+ delivery,
396
+ ),
279
397
  );
280
398
  return {
281
399
  evaluated: rules.length,
282
400
  fired: results.reduce((sum, result) => sum + result.fired, 0),
283
401
  resolved: results.reduce((sum, result) => sum + result.resolved, 0),
402
+ redelivered: results.reduce((sum, result) => sum + result.redelivered, 0),
284
403
  };
285
404
  }
286
405
 
@@ -291,21 +410,26 @@ export async function evaluateAllRules(
291
410
  tracer?: ChildSpanRunner;
292
411
  concurrency?: number;
293
412
  ruleTimeoutMs?: number;
413
+ hostLookup?: HostLookup;
294
414
  },
295
415
  ): Promise<{
296
416
  evaluated: number;
297
417
  fired: number;
298
418
  resolved: number;
419
+ redelivered: number;
299
420
  }> {
300
421
  const logger = options?.logger ?? consoleLogger;
301
422
  const tracer = options?.tracer;
302
- const store = new AlertsStore(sqlDbFor(env));
423
+ const allowPrivateWebhookTargets = env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true";
424
+ const store = new AlertsStore(sqlDbFor(env), { allowPrivateWebhookTargets });
303
425
  const rules = await store.listEnabledRules();
304
426
  return evaluateRuleBatch(store, rules, {
305
427
  logger,
306
428
  tracer,
307
429
  concurrency: options?.concurrency,
308
430
  ruleTimeoutMs: options?.ruleTimeoutMs,
431
+ allowPrivateWebhookTargets,
432
+ hostLookup: options?.hostLookup,
309
433
  });
310
434
  }
311
435
 
@@ -6,12 +6,16 @@ import { alertEvidenceReferences } from "../lib/evidence-references";
6
6
  import { sqlDbFor } from "../lib/sql-db";
7
7
  import { getProjectId } from "./_context";
8
8
 
9
+ const storeOptions = (env: { ALERT_WEBHOOK_ALLOW_PRIVATE?: string }) => ({
10
+ allowPrivateWebhookTargets: env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
11
+ });
12
+
9
13
  export const alertsRoutesPlugin: CollectorPlugin = {
10
14
  name: "alerts-routes",
11
15
  register(app) {
12
16
  app.get("/internal/alerts/rules", async (c) => {
13
17
  const projectId = getProjectId(c);
14
- const store = new AlertsStore(sqlDbFor(c.env));
18
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
15
19
  const rules = await store.listRules(projectId);
16
20
  return c.json({ rules });
17
21
  });
@@ -24,7 +28,7 @@ export const alertsRoutesPlugin: CollectorPlugin = {
24
28
  } catch {
25
29
  return c.json({ error: "Invalid JSON body" }, 400);
26
30
  }
27
- const store = new AlertsStore(sqlDbFor(c.env));
31
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
28
32
  try {
29
33
  const rule = await store.createRule(projectId, input);
30
34
  return c.json({ rule }, 201);
@@ -43,7 +47,7 @@ export const alertsRoutesPlugin: CollectorPlugin = {
43
47
  } catch {
44
48
  return c.json({ error: "Invalid JSON body" }, 400);
45
49
  }
46
- const store = new AlertsStore(sqlDbFor(c.env));
50
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
47
51
  try {
48
52
  const rule = await store.updateRule(id, projectId, patch);
49
53
  if (!rule) return c.json({ error: "Rule not found" }, 404);
@@ -57,7 +61,7 @@ export const alertsRoutesPlugin: CollectorPlugin = {
57
61
  app.delete("/internal/alerts/rules/:id", async (c) => {
58
62
  const projectId = getProjectId(c);
59
63
  const id = c.req.param("id");
60
- const store = new AlertsStore(sqlDbFor(c.env));
64
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
61
65
  const deleted = await store.deleteRule(id, projectId);
62
66
  if (!deleted) return c.json({ error: "Rule not found" }, 404);
63
67
  return c.json({ success: true });
@@ -71,7 +75,7 @@ export const alertsRoutesPlugin: CollectorPlugin = {
71
75
  1,
72
76
  Math.min(720, Number.parseInt(c.req.query("hours") || "24", 10) || 24),
73
77
  );
74
- const store = new AlertsStore(sqlDbFor(c.env));
78
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
75
79
  const response = await store.listEvaluations({
76
80
  ruleId,
77
81
  projectId,
@@ -83,7 +87,7 @@ export const alertsRoutesPlugin: CollectorPlugin = {
83
87
  app.post("/internal/alerts/rules/:id/test", async (c) => {
84
88
  const projectId = getProjectId(c);
85
89
  const id = c.req.param("id");
86
- const store = new AlertsStore(sqlDbFor(c.env));
90
+ const store = new AlertsStore(sqlDbFor(c.env), storeOptions(c.env));
87
91
  const rule = await store.getRule(id, projectId);
88
92
  if (!rule) return c.json({ error: "Rule not found" }, 404);
89
93
  const value = await store.evaluateRule(rule);
@@ -22,6 +22,7 @@ import { runAsk } from "../lib/ask";
22
22
  import type { LlmConfig } from "../lib/llm";
23
23
  import { sqlDbFor } from "../lib/sql-db";
24
24
  import { getProjectId } from "./_context";
25
+ import { readJsonObject } from "./_json";
25
26
 
26
27
  const MAX_QUESTION_CHARS = 1000;
27
28
 
@@ -29,12 +30,10 @@ export const askRoutesPlugin: CollectorPlugin = {
29
30
  name: "ask-routes",
30
31
  register(app, runtime) {
31
32
  app.post("/internal/ask", async (c) => {
32
- let body: Partial<AskRequest> = {};
33
- try {
34
- body = (await c.req.json()) as Partial<AskRequest>;
35
- } catch {
33
+ const body = await readJsonObject<Partial<AskRequest>>(c);
34
+ if (!body) {
36
35
  return c.json(
37
- { error: "Bad Request", message: "body must be JSON" },
36
+ { error: "Bad Request", message: "body must be a JSON object" },
38
37
  400,
39
38
  );
40
39
  }
@@ -1789,3 +1789,109 @@ describe("ConnectedRail manifest — agent action graph entities (RFC 0010)", ()
1789
1789
  expect(otherTools?.links[0].label).toContain("other_tool");
1790
1790
  });
1791
1791
  });
1792
+
1793
+ describe("ConnectedRail manifest — span parent session link", () => {
1794
+ it("surfaces parent session link under Up when interaction usage event has session_id", async () => {
1795
+ const db = new MemSqlDb({
1796
+ all: (sql) => {
1797
+ if (sql.includes("FROM telemetry_spans")) {
1798
+ return [
1799
+ {
1800
+ trace_id: "tr-payment",
1801
+ span_id: "sp-payment",
1802
+ parent_span_id: null,
1803
+ service_name: "checkout-api",
1804
+ span_name: "payment.charge",
1805
+ status_code: 1,
1806
+ status_message: null,
1807
+ start_time: "2026-05-04T10:00:00Z",
1808
+ duration_ms: 700,
1809
+ interaction_id: "int-checkout-click",
1810
+ },
1811
+ ];
1812
+ }
1813
+ if (sql.includes("FROM usage_events")) {
1814
+ return [
1815
+ {
1816
+ event_id: "evt-click-1",
1817
+ event_type: "interaction",
1818
+ event_name: "click_checkout",
1819
+ page_path: "/api/checkout",
1820
+ severity: null,
1821
+ occurred_at: "2026-05-04T10:00:00Z",
1822
+ interaction_id: "int-checkout-click",
1823
+ session_id: "session-abc-123456",
1824
+ },
1825
+ ];
1826
+ }
1827
+ return [];
1828
+ },
1829
+ first: () => null,
1830
+ });
1831
+ const fetch = setup(db);
1832
+ const m = await fetch("/internal/connected/span/tr-payment:sp-payment");
1833
+
1834
+ // Preserves parent trace link
1835
+ const traceSection = m.up.find((s) => s.label === "Trace");
1836
+ expect(traceSection).toBeDefined();
1837
+ expect(traceSection?.links[0]).toMatchObject({
1838
+ entityKind: "trace",
1839
+ entityId: "tr-payment",
1840
+ });
1841
+
1842
+ // Surfaces parent session link via linkToSession
1843
+ const sessionSection = m.up.find((s) =>
1844
+ s.label.toLowerCase().includes("session"),
1845
+ );
1846
+ expect(sessionSection).toBeDefined();
1847
+ expect(sessionSection?.links[0]).toMatchObject({
1848
+ entityKind: "replay",
1849
+ sample: "session-abc-123456",
1850
+ href: "#/replay?session=session-abc-123456",
1851
+ });
1852
+
1853
+ // Preserves originating click link in Related
1854
+ const clickSection = m.related.find((s) =>
1855
+ s.label.toLowerCase().includes("click"),
1856
+ );
1857
+ expect(clickSection).toBeDefined();
1858
+ expect(clickSection?.links.length).toBeGreaterThan(0);
1859
+ });
1860
+
1861
+ it("preserves informative absence when span has no interaction or session", async () => {
1862
+ const db = new MemSqlDb({
1863
+ all: (sql) => {
1864
+ if (sql.includes("FROM telemetry_spans")) {
1865
+ return [
1866
+ {
1867
+ trace_id: "tr-cron",
1868
+ span_id: "sp-cron",
1869
+ parent_span_id: null,
1870
+ service_name: "worker",
1871
+ span_name: "cron.tick",
1872
+ status_code: 1,
1873
+ status_message: null,
1874
+ start_time: "2026-05-04T10:00:00Z",
1875
+ duration_ms: 100,
1876
+ interaction_id: null,
1877
+ },
1878
+ ];
1879
+ }
1880
+ return [];
1881
+ },
1882
+ first: () => null,
1883
+ });
1884
+ const fetch = setup(db);
1885
+ const m = await fetch("/internal/connected/span/tr-cron:sp-cron");
1886
+
1887
+ expect(m.up).toHaveLength(1);
1888
+ expect(m.up[0].label).toBe("Trace");
1889
+
1890
+ const clickSection = m.related.find((s) =>
1891
+ s.label.toLowerCase().includes("click"),
1892
+ );
1893
+ expect(clickSection).toBeDefined();
1894
+ expect(clickSection?.links).toEqual([]);
1895
+ expect(clickSection?.emptyReason).toContain("Server-originated work");
1896
+ });
1897
+ });
@@ -289,12 +289,25 @@ export const connectedRoutesPlugin: CollectorPlugin = {
289
289
  const otherSpans = traceManifest.spans.filter(
290
290
  (s) => s.spanId !== span.spanId,
291
291
  );
292
+ const interactionManifest = span.interactionId
293
+ ? await index.byInteraction(projectId, span.interactionId)
294
+ : null;
295
+ const parentSessionId = interactionManifest?.usageEvents.find((e) =>
296
+ Boolean(e.sessionId),
297
+ )?.sessionId;
298
+
292
299
  manifest.up = [
293
300
  {
294
301
  label: "Trace",
295
302
  links: [linkToTrace(span.traceId, "Parent trace")],
296
303
  },
297
304
  ];
305
+ if (parentSessionId) {
306
+ manifest.up.push({
307
+ label: "User session",
308
+ links: [linkToSession(parentSessionId)],
309
+ });
310
+ }
298
311
  manifest.across = [
299
312
  linksFromSpans(otherSpans, `Other spans in this trace`),
300
313
  linksFromLogs(traceManifest.logs, "Logs in this trace"),
@@ -339,11 +352,7 @@ export const connectedRoutesPlugin: CollectorPlugin = {
339
352
  "Metric exemplars in this trace",
340
353
  ),
341
354
  ];
342
- if (span.interactionId) {
343
- const interactionManifest = await index.byInteraction(
344
- projectId,
345
- span.interactionId,
346
- );
355
+ if (interactionManifest) {
347
356
  manifest.related = [
348
357
  linksFromUsage(
349
358
  interactionManifest.usageEvents,