@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.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/index.d.ts +322 -26
- package/dist/index.js +1478 -415
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/package.json +5 -5
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +16 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +60 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/housekeeping.test.ts +73 -0
- package/src/lib/housekeeping.ts +93 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +64 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +165 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +14 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/eval-cases-routes.ts +8 -17
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- 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(
|
|
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:
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
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 (
|
|
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,
|