@obsunified/collector 2.0.1 → 2.2.0

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 (126) 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/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -11,7 +11,14 @@ import {
11
11
  type Logger,
12
12
  } from "../framework/logger";
13
13
  import { AlertsStore, compareValue } from "../lib/alerts-store";
14
+ import { openOrDeduplicateIncident } from "../lib/autosre-remediation-gate";
14
15
  import { sqlDbFor } from "../lib/sql-db";
16
+ import {
17
+ assertSafeWebhookUrl,
18
+ type HostLookup,
19
+ redactWebhookUrl,
20
+ resolveWebhookHost,
21
+ } from "../lib/webhook-url";
15
22
 
16
23
  const DEFAULT_RULE_CONCURRENCY = 5;
17
24
  const DEFAULT_RULE_TIMEOUT_MS = 10_000;
@@ -19,7 +26,22 @@ const DEFAULT_RULE_TIMEOUT_MS = 10_000;
19
26
  interface AlertEvaluatorStore {
20
27
  listEnabledRules(): Promise<AlertRule[]>;
21
28
  evaluateRule(rule: AlertRule): Promise<number>;
22
- getState(ruleId: string): Promise<{ current_state: AlertState } | null>;
29
+ getState(
30
+ ruleId: string,
31
+ projectId: string,
32
+ ): Promise<{
33
+ current_state: AlertState;
34
+ last_state_change: string;
35
+ /** 0 while the transition's notification is owed; absent pre-049 (= delivered). */
36
+ notified?: number;
37
+ } | null>;
38
+ /** Flip the exact transition `(state, lastStateChange)` to delivered. */
39
+ markNotified(
40
+ ruleId: string,
41
+ projectId: string,
42
+ state: AlertState,
43
+ lastStateChange: string,
44
+ ): Promise<boolean>;
23
45
  getAnalysisNarrative(
24
46
  projectId: string,
25
47
  analysisId: string,
@@ -65,12 +87,56 @@ interface WebhookPayload {
65
87
  };
66
88
  }
67
89
 
90
+ export interface WebhookDeliveryOptions {
91
+ /** Permit targets on loopback / private / link-local hosts. */
92
+ allowPrivateWebhookTargets?: boolean;
93
+ /**
94
+ * Resolver used to refuse public names that point at private addresses.
95
+ * Hosts that can resolve (Node: `dns.lookup`) pass one; Workers cannot
96
+ * reach private networks from `fetch` and leave it unset.
97
+ */
98
+ hostLookup?: HostLookup;
99
+ }
100
+
68
101
  async function fireWebhook(
69
102
  channel: AlertWebhookChannel,
70
103
  payload: WebhookPayload,
71
104
  logger: Logger,
72
105
  tracer?: ChildSpanRunner,
106
+ delivery: WebhookDeliveryOptions = {},
73
107
  ): Promise<boolean> {
108
+ // Hook URLs carry secrets in the path (Slack, PagerDuty…): logs and span
109
+ // attributes only ever see the redacted form.
110
+ const target = redactWebhookUrl(channel.url);
111
+ // Re-check at fire time: rules created before the host guard existed
112
+ // (or with the opt-in later removed) must not reach internal addresses.
113
+ try {
114
+ const url = assertSafeWebhookUrl(channel.url, {
115
+ allowPrivateNetworks: delivery.allowPrivateWebhookTargets,
116
+ });
117
+ if (!delivery.allowPrivateWebhookTargets && delivery.hostLookup) {
118
+ const resolution = await resolveWebhookHost(
119
+ url.hostname,
120
+ delivery.hostLookup,
121
+ );
122
+ if (resolution === "private") {
123
+ throw new Error(
124
+ `channel.url host "${url.hostname}" resolves to a private address`,
125
+ );
126
+ }
127
+ if (resolution === "unresolvable") {
128
+ throw new Error(
129
+ `channel.url host "${url.hostname}" could not be resolved; refusing to deliver blind`,
130
+ );
131
+ }
132
+ }
133
+ } catch (err) {
134
+ logger.error("[alerts-evaluator] webhook target refused", {
135
+ url: target,
136
+ error: err instanceof Error ? err.message : String(err),
137
+ });
138
+ return false;
139
+ }
74
140
  const exec = async (
75
141
  span: { setAttribute(k: string, v: unknown): void } | null,
76
142
  ): Promise<boolean> => {
@@ -82,13 +148,24 @@ async function fireWebhook(
82
148
  ...(channel.headers ?? {}),
83
149
  },
84
150
  body: JSON.stringify(payload),
151
+ // Never follow redirects: a 3xx could re-aim the POST (and its
152
+ // headers) at an arbitrary host. A redirecting hook is a
153
+ // misconfiguration and counts as a failed delivery.
154
+ redirect: "manual",
85
155
  signal: AbortSignal.timeout(5000),
86
156
  });
87
157
  if (span) span.setAttribute("http.response.status_code", response.status);
158
+ if (response.status >= 300 && response.status < 400) {
159
+ logger.error("[alerts-evaluator] webhook responded with a redirect", {
160
+ url: target,
161
+ status: response.status,
162
+ });
163
+ return false;
164
+ }
88
165
  return response.ok;
89
166
  } catch (err) {
90
167
  logger.error("[alerts-evaluator] webhook delivery failed", {
91
- url: channel.url,
168
+ url: target,
92
169
  error: err instanceof Error ? err.message : String(err),
93
170
  });
94
171
  return false;
@@ -97,7 +174,7 @@ async function fireWebhook(
97
174
  if (!tracer) return exec(null);
98
175
  return tracer("webhook.alert", async (span) => exec(span), {
99
176
  "http.method": "POST",
100
- "http.url": channel.url,
177
+ "http.url": target,
101
178
  "alert.state": payload.state,
102
179
  });
103
180
  }
@@ -107,12 +184,13 @@ async function fireChannels(
107
184
  payload: WebhookPayload,
108
185
  logger: Logger,
109
186
  tracer?: ChildSpanRunner,
187
+ delivery: WebhookDeliveryOptions = {},
110
188
  ): Promise<boolean> {
111
189
  if (channels.length === 0) return true;
112
190
  const results = await Promise.all(
113
191
  channels
114
192
  .filter((ch): ch is AlertWebhookChannel => ch.type === "webhook")
115
- .map((ch) => fireWebhook(ch, payload, logger, tracer)),
193
+ .map((ch) => fireWebhook(ch, payload, logger, tracer, delivery)),
116
194
  );
117
195
  return results.every(Boolean);
118
196
  }
@@ -160,7 +238,10 @@ async function evaluateOneRule(
160
238
  logger: Logger,
161
239
  tracer: ChildSpanRunner | undefined,
162
240
  ruleTimeoutMs: number,
163
- ): Promise<{ fired: number; resolved: number }> {
241
+ delivery: WebhookDeliveryOptions,
242
+ onAlertFiring?: (rule: AlertRule) => Promise<void>,
243
+ ): Promise<{ fired: number; resolved: number; redelivered: number }> {
244
+ const none = { fired: 0, resolved: 0, redelivered: 0 };
164
245
  try {
165
246
  const value = await withTimeout(
166
247
  store.evaluateRule(rule),
@@ -168,7 +249,7 @@ async function evaluateOneRule(
168
249
  `rule evaluation timed out after ${ruleTimeoutMs}ms`,
169
250
  );
170
251
  const shouldFire = compareValue(value, rule.threshold, rule.comparison);
171
- const priorState = await store.getState(rule.id);
252
+ const priorState = await store.getState(rule.id, rule.projectId);
172
253
  const previous: AlertState = priorState?.current_state ?? "ok";
173
254
  const next: AlertState = shouldFire ? "firing" : "ok";
174
255
  const now = new Date().toISOString();
@@ -191,54 +272,81 @@ async function evaluateOneRule(
191
272
  .catch(() => undefined)
192
273
  : undefined;
193
274
 
194
- if (previous === "ok" && next === "firing") {
195
- const ok = await fireChannels(
275
+ const deliver = (state: AlertState) =>
276
+ fireChannels(
196
277
  rule.channels,
197
278
  {
198
279
  rule: { id: rule.id, name: rule.name, signal: rule.signal },
199
280
  value,
200
281
  threshold: rule.threshold,
201
282
  comparison: rule.comparison,
202
- state: "firing",
283
+ state,
203
284
  evaluatedAt: now,
204
285
  projectId: rule.projectId,
205
286
  analysis: analysisAttachment,
206
287
  },
207
288
  logger,
208
289
  tracer,
290
+ delivery,
209
291
  );
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 };
292
+
293
+ if (previous !== next) {
294
+ // The stored state is what the dashboard shows and must follow the
295
+ // signal, not the webhook: record the transition first (with its
296
+ // notification outstanding, `alert_state.notified = 0`), then
297
+ // notify. On accepted delivery the flag is flipped for exactly this
298
+ // transition; otherwise the same-state branch below retries on the
299
+ // next tick without a second transition.
300
+ await store.transitionState(rule.id, rule.projectId, next, now);
301
+ if (next === "firing" && onAlertFiring) {
302
+ await onAlertFiring(rule).catch(() => undefined);
303
+ }
304
+ const ok = await deliver(next);
305
+ // Acknowledge before writing history: if the evaluation insert
306
+ // fails, the delivered flag is already durable and the next tick
307
+ // will not send the same alert again.
308
+ if (ok) {
309
+ await store.markNotified(rule.id, rule.projectId, next, now);
310
+ }
311
+ await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
312
+ if (!ok) {
313
+ logger.warn(
314
+ "[alerts-evaluator] state recorded but delivery failed; will retry",
315
+ { rule_id: rule.id, project_id: rule.projectId, state: next },
316
+ );
317
+ }
318
+ return next === "firing"
319
+ ? { fired: 1, resolved: 0, redelivered: 0 }
320
+ : { fired: 0, resolved: 1, redelivered: 0 };
219
321
  }
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 };
322
+
323
+ // Same state. While the transition into it is still marked undelivered,
324
+ // retry the notification; the state itself is already correct. A row
325
+ // without the flag (schema before migration 049) counts as delivered.
326
+ const owed = priorState !== null && priorState.notified === 0;
327
+ if (owed) {
328
+ const ok = await deliver(previous);
329
+ if (ok) {
330
+ await store.markNotified(
331
+ rule.id,
332
+ rule.projectId,
333
+ previous,
334
+ priorState.last_state_change,
335
+ );
336
+ }
337
+ await store.recordEvaluation(rule.id, rule.projectId, value, next, ok);
338
+ if (!ok) {
339
+ logger.warn("[alerts-evaluator] delivery retry failed; will retry", {
340
+ rule_id: rule.id,
341
+ project_id: rule.projectId,
342
+ state: previous,
343
+ });
344
+ return none;
345
+ }
346
+ return { fired: 0, resolved: 0, redelivered: 1 };
239
347
  }
240
348
 
241
- // Same state — write an evaluation but do not fire channels.
349
+ // Same state, already notified — write an evaluation, do not fire.
242
350
  await store.recordEvaluation(rule.id, rule.projectId, value, next, false);
243
351
  } catch (err) {
244
352
  logger.error("[alerts-evaluator] rule evaluation failed", {
@@ -248,7 +356,7 @@ async function evaluateOneRule(
248
356
  });
249
357
  }
250
358
 
251
- return { fired: 0, resolved: 0 };
359
+ return none;
252
360
  }
253
361
 
254
362
  export async function evaluateRuleBatch(
@@ -259,13 +367,22 @@ export async function evaluateRuleBatch(
259
367
  tracer?: ChildSpanRunner;
260
368
  concurrency?: number;
261
369
  ruleTimeoutMs?: number;
370
+ allowPrivateWebhookTargets?: boolean;
371
+ hostLookup?: HostLookup;
372
+ onAlertFiring?: (rule: AlertRule) => Promise<void>;
262
373
  },
263
374
  ): Promise<{
264
375
  evaluated: number;
265
376
  fired: number;
266
377
  resolved: number;
378
+ /** Notifications for an earlier transition that were delivered on this tick. */
379
+ redelivered: number;
267
380
  }> {
268
381
  const logger = options?.logger ?? consoleLogger;
382
+ const delivery: WebhookDeliveryOptions = {
383
+ allowPrivateWebhookTargets: options?.allowPrivateWebhookTargets,
384
+ hostLookup: options?.hostLookup,
385
+ };
269
386
  const concurrency = Math.max(
270
387
  1,
271
388
  Math.floor(options?.concurrency ?? DEFAULT_RULE_CONCURRENCY),
@@ -275,12 +392,21 @@ export async function evaluateRuleBatch(
275
392
  Math.floor(options?.ruleTimeoutMs ?? DEFAULT_RULE_TIMEOUT_MS),
276
393
  );
277
394
  const results = await mapWithConcurrency(rules, concurrency, (rule) =>
278
- evaluateOneRule(store, rule, logger, options?.tracer, ruleTimeoutMs),
395
+ evaluateOneRule(
396
+ store,
397
+ rule,
398
+ logger,
399
+ options?.tracer,
400
+ ruleTimeoutMs,
401
+ delivery,
402
+ options?.onAlertFiring,
403
+ ),
279
404
  );
280
405
  return {
281
406
  evaluated: rules.length,
282
407
  fired: results.reduce((sum, result) => sum + result.fired, 0),
283
408
  resolved: results.reduce((sum, result) => sum + result.resolved, 0),
409
+ redelivered: results.reduce((sum, result) => sum + result.redelivered, 0),
284
410
  };
285
411
  }
286
412
 
@@ -291,21 +417,35 @@ export async function evaluateAllRules(
291
417
  tracer?: ChildSpanRunner;
292
418
  concurrency?: number;
293
419
  ruleTimeoutMs?: number;
420
+ hostLookup?: HostLookup;
294
421
  },
295
422
  ): Promise<{
296
423
  evaluated: number;
297
424
  fired: number;
298
425
  resolved: number;
426
+ redelivered: number;
299
427
  }> {
300
428
  const logger = options?.logger ?? consoleLogger;
301
429
  const tracer = options?.tracer;
302
- const store = new AlertsStore(sqlDbFor(env));
430
+ const allowPrivateWebhookTargets = env.ALERT_WEBHOOK_ALLOW_PRIVATE === "true";
431
+ const db = sqlDbFor(env);
432
+ const store = new AlertsStore(db, { allowPrivateWebhookTargets });
303
433
  const rules = await store.listEnabledRules();
304
434
  return evaluateRuleBatch(store, rules, {
305
435
  logger,
306
436
  tracer,
307
437
  concurrency: options?.concurrency,
308
438
  ruleTimeoutMs: options?.ruleTimeoutMs,
439
+ allowPrivateWebhookTargets,
440
+ hostLookup: options?.hostLookup,
441
+ onAlertFiring: async (rule) => {
442
+ await openOrDeduplicateIncident(db, rule.projectId, {
443
+ environment: "default",
444
+ targetNodeId: `alert:${rule.id}`,
445
+ symptomAnchorKind: "alert",
446
+ symptomAnchorId: rule.id,
447
+ });
448
+ },
309
449
  });
310
450
  }
311
451
 
@@ -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
  }