@obsunified/collector 2.2.3 → 2.4.3
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 +2 -1
- package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
- package/dist/chunk-RASSTGNB.js.map +1 -0
- package/dist/index.d.ts +264 -62
- package/dist/index.js +3390 -2350
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BTowieER.d.ts} +96 -1
- package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
- package/package.json +4 -2
- package/src/__tests__/autosre-closed-loop.test.ts +110 -0
- package/src/__tests__/db-parity.integration.test.ts +961 -0
- package/src/__tests__/internal-contracts-routes.test.ts +451 -0
- package/src/framework/env.ts +113 -0
- package/src/index.ts +27 -4
- package/src/lib/ai-store/evaluations.test.ts +3 -3
- package/src/lib/alert-email.ts +104 -0
- package/src/lib/alerts-store.test.ts +116 -2
- package/src/lib/alerts-store.ts +421 -57
- package/src/lib/analyses-store.test.ts +6 -6
- package/src/lib/db-parity.ts +883 -0
- package/src/lib/eval-cases-store.test.ts +1 -1
- package/src/lib/eval-cases-store.ts +16 -4
- package/src/lib/evidence-references.ts +30 -22
- package/src/lib/metric-alerts.test.ts +769 -0
- package/src/lib/metric-alerts.ts +640 -0
- package/src/lib/metrics-store.ts +9 -2
- package/src/lib/sql-db-postgres.integration.test.ts +247 -0
- package/src/lib/stage6.test.ts +1 -1
- package/src/lib/store/trace-queries.ts +1 -1
- package/src/migrations/054_alert_rules_pending_secs.sql +11 -0
- package/src/migrations/055_alert_state_pending_since.sql +12 -0
- package/src/migrations/056_metric_point_series_received.sql +9 -0
- package/src/migrations/057_alert_evaluations_outcome.sql +11 -0
- package/src/migrations/058_alert_state_pending_seen_at.sql +12 -0
- package/src/migrations/059_alert_evaluations_error.sql +11 -0
- package/src/migrations/parity.test.ts +105 -3
- package/src/migrations-postgres/054_alert_rules_pending_secs.sql +7 -0
- package/src/migrations-postgres/055_alert_state_pending_since.sql +6 -0
- package/src/migrations-postgres/056_metric_point_series_received.sql +7 -0
- package/src/migrations-postgres/057_alert_evaluations_outcome.sql +6 -0
- package/src/migrations-postgres/058_alert_state_pending_seen_at.sql +6 -0
- package/src/migrations-postgres/059_alert_evaluations_error.sql +6 -0
- package/src/migrations-postgres/README.md +13 -0
- package/src/plugins/ai-receiver.ts +6 -1
- package/src/plugins/alerts-evaluator-pending.test.ts +322 -0
- package/src/plugins/alerts-evaluator-retention.test.ts +2 -0
- package/src/plugins/alerts-evaluator.test.ts +10 -2
- package/src/plugins/alerts-evaluator.ts +239 -40
- package/src/plugins/alerts-routes.ts +17 -2
- package/src/plugins/autosre-routes.ts +251 -140
- package/src/plugins/connected-routes/manifest.test.ts +114 -32
- package/src/plugins/connected-routes/manifest.ts +59 -76
- package/src/plugins/connected-routes.test.ts +10 -8
- package/src/plugins/connected-routes.ts +42 -12
- package/src/plugins/eval-cases-routes.ts +33 -10
- package/src/plugins/evidence-retrieval-routes.ts +60 -25
- package/dist/chunk-ZSF5XP3B.js.map +0 -1
- /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ContractMismatchError,
|
|
3
|
+
createInternalClient,
|
|
4
|
+
InternalClientHttpError,
|
|
5
|
+
type InternalEndpoints,
|
|
6
|
+
} from "@obsunified/types";
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { CollectorRuntime } from "../framework/collector";
|
|
10
|
+
import type { CollectorEnv } from "../framework/env";
|
|
11
|
+
import { upsertOntologyFromSpans } from "../lib/autosre-ontology";
|
|
12
|
+
import {
|
|
13
|
+
createMigratedSqliteDb,
|
|
14
|
+
type NodeSqliteDb,
|
|
15
|
+
} from "../lib/test-utils/node-sqlite-db";
|
|
16
|
+
import { aiReceiverPlugin } from "../plugins/ai-receiver";
|
|
17
|
+
import {
|
|
18
|
+
autosreRoutesPlugin,
|
|
19
|
+
respondWithContract,
|
|
20
|
+
} from "../plugins/autosre-routes";
|
|
21
|
+
import { connectedRoutesPlugin } from "../plugins/connected-routes";
|
|
22
|
+
import { evalCasesRoutesPlugin } from "../plugins/eval-cases-routes";
|
|
23
|
+
import { evidenceRetrievalRoutesPlugin } from "../plugins/evidence-retrieval-routes";
|
|
24
|
+
|
|
25
|
+
const createTestApp = (db: NodeSqliteDb) => {
|
|
26
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
27
|
+
const runtime = new CollectorRuntime();
|
|
28
|
+
autosreRoutesPlugin.register(app, runtime);
|
|
29
|
+
evalCasesRoutesPlugin.register(app, runtime);
|
|
30
|
+
connectedRoutesPlugin.register(app, runtime);
|
|
31
|
+
evidenceRetrievalRoutesPlugin.register(app, runtime);
|
|
32
|
+
aiReceiverPlugin.register(app, runtime);
|
|
33
|
+
const env: CollectorEnv = {
|
|
34
|
+
DB: db as unknown as D1Database,
|
|
35
|
+
};
|
|
36
|
+
return { app, env };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("Collector internal HTTP contract integration (RFC 0014 WS5)", () => {
|
|
40
|
+
it("respondWithContract throws ContractMismatchError if a handler attempts to return a mistyped payload", () => {
|
|
41
|
+
const fakeContext = {
|
|
42
|
+
json: (data: unknown, status = 200) =>
|
|
43
|
+
new Response(JSON.stringify(data), { status }),
|
|
44
|
+
};
|
|
45
|
+
expect(() =>
|
|
46
|
+
respondWithContract(
|
|
47
|
+
fakeContext,
|
|
48
|
+
"GET /internal/incidents",
|
|
49
|
+
// Intentionally mistyped payload
|
|
50
|
+
{
|
|
51
|
+
incidents: "not-an-array",
|
|
52
|
+
} as unknown as InternalEndpoints["GET /internal/incidents"]["response"],
|
|
53
|
+
),
|
|
54
|
+
).toThrow(ContractMismatchError);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("createInternalClient round-trips changes, incidents, findings, counterfactuals, remediations, evals, connected, AI calls, and evidence against collector routes", async () => {
|
|
58
|
+
const db = createMigratedSqliteDb();
|
|
59
|
+
const { app, env } = createTestApp(db);
|
|
60
|
+
|
|
61
|
+
const makeClient = (defaultHeaders?: Record<string, string>) =>
|
|
62
|
+
createInternalClient({
|
|
63
|
+
baseUrl: "http://localhost",
|
|
64
|
+
defaultHeaders,
|
|
65
|
+
fetchImpl: (input, init) => app.request(input, init, env),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const client = makeClient();
|
|
69
|
+
|
|
70
|
+
// Seed a trace + span so eval cases and ontology have a source entity
|
|
71
|
+
db.raw
|
|
72
|
+
.prepare(
|
|
73
|
+
`INSERT INTO telemetry_spans (
|
|
74
|
+
project_id, trace_id, span_id, parent_span_id, service_name,
|
|
75
|
+
span_name, span_kind, start_time, end_time, duration_ms,
|
|
76
|
+
status_code, status_message, attributes_json, resource_attributes_json,
|
|
77
|
+
events_json, links_json, received_at, expires_at,
|
|
78
|
+
environment, service_version, commit_sha, target_node_id, error_signature
|
|
79
|
+
) VALUES (
|
|
80
|
+
'default', 'trace-ws5', 'span-ws5', NULL, 'checkout',
|
|
81
|
+
'POST /charge', 2, '2026-04-16T00:00:00.000Z', '2026-04-16T00:00:00.500Z', 500,
|
|
82
|
+
2, 'Timeout', '{}', '{}',
|
|
83
|
+
'[]', '[]', '2026-04-16T00:00:00.500Z', '2099-01-01T00:00:00.000Z',
|
|
84
|
+
'production', '2.1.0', 'abc1234', 'service:default:checkout', 'Timeout'
|
|
85
|
+
)`,
|
|
86
|
+
)
|
|
87
|
+
.run();
|
|
88
|
+
await upsertOntologyFromSpans(db, [
|
|
89
|
+
{
|
|
90
|
+
projectId: "default",
|
|
91
|
+
traceId: "trace-ws5",
|
|
92
|
+
spanId: "span-ws5",
|
|
93
|
+
parentSpanId: null,
|
|
94
|
+
traceState: null,
|
|
95
|
+
serviceName: "checkout",
|
|
96
|
+
spanName: "POST /charge",
|
|
97
|
+
spanKind: 2,
|
|
98
|
+
statusCode: 2,
|
|
99
|
+
statusMessage: "Timeout",
|
|
100
|
+
startTime: "2026-04-16T00:00:00.000Z",
|
|
101
|
+
endTime: "2026-04-16T00:00:00.500Z",
|
|
102
|
+
durationMs: 500,
|
|
103
|
+
environment: "prod",
|
|
104
|
+
scopeName: null,
|
|
105
|
+
scopeVersion: null,
|
|
106
|
+
attributesJson: "{}",
|
|
107
|
+
resourceAttributesJson: "{}",
|
|
108
|
+
eventsJson: "[]",
|
|
109
|
+
linksJson: "[]",
|
|
110
|
+
droppedAttributesCount: 0,
|
|
111
|
+
droppedEventsCount: 0,
|
|
112
|
+
droppedLinksCount: 0,
|
|
113
|
+
receivedAt: "2026-04-16T00:00:00.500Z",
|
|
114
|
+
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
115
|
+
},
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
// 1. Changes
|
|
119
|
+
const createdChange = await client.request("POST /v1/changes", {
|
|
120
|
+
body: {
|
|
121
|
+
environment: "prod",
|
|
122
|
+
kind: "deploy",
|
|
123
|
+
serviceName: "checkout",
|
|
124
|
+
version: "2.1.0",
|
|
125
|
+
previousVersion: "2.0.0",
|
|
126
|
+
commitSha: "abc1234",
|
|
127
|
+
summary: "Deploy checkout 2.1.0",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
expect(createdChange.id).toBeTruthy();
|
|
131
|
+
expect(createdChange.change.id).toBe(createdChange.id);
|
|
132
|
+
expect(createdChange.changeEvent?.id).toBe(createdChange.id);
|
|
133
|
+
|
|
134
|
+
const listedChanges = await client.request("GET /internal/changes", {
|
|
135
|
+
query: { environment: "prod", serviceName: "checkout" },
|
|
136
|
+
});
|
|
137
|
+
expect(listedChanges.changes.some((c) => c.id === createdChange.id)).toBe(
|
|
138
|
+
true,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const fetchedChange = await client.request("GET /internal/changes/:id", {
|
|
142
|
+
params: { id: createdChange.id },
|
|
143
|
+
});
|
|
144
|
+
expect(fetchedChange.change.id).toBe(createdChange.id);
|
|
145
|
+
|
|
146
|
+
const blastRadius = await client.request(
|
|
147
|
+
"GET /internal/changes/:id/blast-radius",
|
|
148
|
+
{
|
|
149
|
+
params: { id: createdChange.id },
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
expect(blastRadius.change.id).toBe(createdChange.id);
|
|
153
|
+
expect(blastRadius.blastRadius.change.id).toBe(createdChange.id);
|
|
154
|
+
|
|
155
|
+
// 2. Ontology
|
|
156
|
+
const ontology = await client.request("GET /internal/ontology", {
|
|
157
|
+
query: { environment: "prod", traceId: "trace-ws5" },
|
|
158
|
+
});
|
|
159
|
+
expect(Array.isArray(ontology.nodes)).toBe(true);
|
|
160
|
+
|
|
161
|
+
// 3. Incidents (both /internal/incidents and /internal/incidents/open)
|
|
162
|
+
const openedIncident = await client.request("POST /internal/incidents", {
|
|
163
|
+
body: {
|
|
164
|
+
environment: "prod",
|
|
165
|
+
targetNodeId: "service:default:checkout",
|
|
166
|
+
symptomAnchorKind: "trace",
|
|
167
|
+
symptomAnchorId: "trace-ws5",
|
|
168
|
+
symptomFingerprint: "fp-checkout-timeout",
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
expect(openedIncident.deduplicated).toBe(false);
|
|
172
|
+
|
|
173
|
+
const dedupedIncident = await client.request(
|
|
174
|
+
"POST /internal/incidents/open",
|
|
175
|
+
{
|
|
176
|
+
body: {
|
|
177
|
+
environment: "prod",
|
|
178
|
+
targetNodeId: "service:default:checkout",
|
|
179
|
+
symptomAnchorKind: "trace",
|
|
180
|
+
symptomAnchorId: "trace-ws5",
|
|
181
|
+
symptomFingerprint: "fp-checkout-timeout",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
expect(dedupedIncident.deduplicated).toBe(true);
|
|
186
|
+
expect(dedupedIncident.incident.id).toBe(openedIncident.incident.id);
|
|
187
|
+
|
|
188
|
+
const leaseResult = await client.request(
|
|
189
|
+
"POST /internal/incidents/:id/lease",
|
|
190
|
+
{
|
|
191
|
+
params: { id: openedIncident.incident.id },
|
|
192
|
+
body: {
|
|
193
|
+
runnerId: "runner-ws5",
|
|
194
|
+
leaseDurationSeconds: 120,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
expect(leaseResult.acquired).toBe(true);
|
|
199
|
+
|
|
200
|
+
const incidentsList = await client.request("GET /internal/incidents", {
|
|
201
|
+
query: { environment: "prod" },
|
|
202
|
+
});
|
|
203
|
+
expect(
|
|
204
|
+
incidentsList.incidents.some((i) => i.id === openedIncident.incident.id),
|
|
205
|
+
).toBe(true);
|
|
206
|
+
|
|
207
|
+
// 4. Findings & Counterfactuals
|
|
208
|
+
const createdFinding = await client.request("POST /internal/findings", {
|
|
209
|
+
body: {
|
|
210
|
+
environment: "prod",
|
|
211
|
+
incidentId: openedIncident.incident.id,
|
|
212
|
+
title: "Checkout timeout after 2.1.0 deploy",
|
|
213
|
+
summary: "Cohort isolated to version 2.1.0",
|
|
214
|
+
symptomEntityKind: "trace",
|
|
215
|
+
symptomEntityId: "trace-ws5",
|
|
216
|
+
faultLocusEntityKind: "span",
|
|
217
|
+
faultLocusEntityId: "trace-ws5:span-ws5",
|
|
218
|
+
rootCauseEntityKind: "change",
|
|
219
|
+
rootCauseEntityId: createdChange.id,
|
|
220
|
+
contributingChangeId: createdChange.id,
|
|
221
|
+
causalChain: [
|
|
222
|
+
{
|
|
223
|
+
subjectKind: "change",
|
|
224
|
+
subjectId: createdChange.id,
|
|
225
|
+
predicate: "degraded_after_change",
|
|
226
|
+
objectKind: "span",
|
|
227
|
+
objectId: "trace-ws5:span-ws5",
|
|
228
|
+
relationshipCertainty: "exact",
|
|
229
|
+
causalProofTier: "corroborated_candidate",
|
|
230
|
+
confidence: 0.9,
|
|
231
|
+
reason: "Version 2.1.0 deployed immediately before errors",
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
citedEvidenceIds: [`change:${createdChange.id}`, "trace:trace-ws5"],
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
expect(createdFinding.finding.id).toBe(createdFinding.id);
|
|
238
|
+
|
|
239
|
+
const fetchedFinding = await client.request("GET /internal/findings/:id", {
|
|
240
|
+
params: { id: createdFinding.id },
|
|
241
|
+
});
|
|
242
|
+
expect(fetchedFinding.finding.id).toBe(createdFinding.id);
|
|
243
|
+
|
|
244
|
+
const findingsList = await client.request("GET /internal/findings", {
|
|
245
|
+
query: { incidentId: openedIncident.incident.id },
|
|
246
|
+
});
|
|
247
|
+
expect(findingsList.findings.some((f) => f.id === createdFinding.id)).toBe(
|
|
248
|
+
true,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// Eval case + runs for counterfactual verification
|
|
252
|
+
const createdCase = await client.request("POST /internal/eval-cases", {
|
|
253
|
+
body: {
|
|
254
|
+
sourceEntityType: "trace",
|
|
255
|
+
sourceEntityId: "trace-ws5",
|
|
256
|
+
name: "Checkout timeout regression case",
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
expect(createdCase.evalCase.id).toBeTruthy();
|
|
260
|
+
|
|
261
|
+
const listedCases = await client.request("GET /internal/eval-cases", {
|
|
262
|
+
query: { sourceEntityType: "trace" },
|
|
263
|
+
});
|
|
264
|
+
expect(
|
|
265
|
+
listedCases.evalCases.some((ec) => ec.id === createdCase.evalCase.id),
|
|
266
|
+
).toBe(true);
|
|
267
|
+
|
|
268
|
+
const failingRun = await client.request("POST /internal/eval-runs", {
|
|
269
|
+
body: {
|
|
270
|
+
evalCaseId: createdCase.evalCase.id,
|
|
271
|
+
status: "failed",
|
|
272
|
+
environment: "prod",
|
|
273
|
+
serviceVersion: "2.1.0",
|
|
274
|
+
commitSha: "abc1234",
|
|
275
|
+
inputPayloadHash: "hash-ws5",
|
|
276
|
+
failureFingerprint: createdFinding.finding.fingerprint,
|
|
277
|
+
totalCount: 1,
|
|
278
|
+
passCount: 0,
|
|
279
|
+
failCount: 1,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
await client.request("POST /internal/eval-cases/:id/results", {
|
|
283
|
+
params: { id: createdCase.evalCase.id },
|
|
284
|
+
body: {
|
|
285
|
+
runId: failingRun.evalRun.id,
|
|
286
|
+
passed: false,
|
|
287
|
+
inputPayloadHash: "hash-ws5",
|
|
288
|
+
failureFingerprint: createdFinding.finding.fingerprint,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const passingRunIds: string[] = [];
|
|
293
|
+
for (let i = 0; i < 3; i += 1) {
|
|
294
|
+
const passRun = await client.request("POST /internal/eval-runs", {
|
|
295
|
+
body: {
|
|
296
|
+
evalCaseId: createdCase.evalCase.id,
|
|
297
|
+
status: "completed",
|
|
298
|
+
environment: "prod",
|
|
299
|
+
serviceVersion: "2.0.0",
|
|
300
|
+
commitSha: "prev000",
|
|
301
|
+
inputPayloadHash: "hash-ws5",
|
|
302
|
+
totalCount: 1,
|
|
303
|
+
passCount: 1,
|
|
304
|
+
failCount: 0,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
await client.request("POST /internal/eval-cases/:id/results", {
|
|
308
|
+
params: { id: createdCase.evalCase.id },
|
|
309
|
+
body: {
|
|
310
|
+
runId: passRun.evalRun.id,
|
|
311
|
+
passed: true,
|
|
312
|
+
inputPayloadHash: "hash-ws5",
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
passingRunIds.push(passRun.evalRun.id);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const evalRunSummary = await client.request(
|
|
319
|
+
"GET /internal/evals/runs/:id",
|
|
320
|
+
{
|
|
321
|
+
params: { id: failingRun.evalRun.id },
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
expect(evalRunSummary.run.id).toBe(failingRun.evalRun.id);
|
|
325
|
+
|
|
326
|
+
const counterfactual = await client.request(
|
|
327
|
+
"POST /internal/findings/:id/counterfactual",
|
|
328
|
+
{
|
|
329
|
+
params: { id: createdFinding.id },
|
|
330
|
+
body: {
|
|
331
|
+
environment: "prod",
|
|
332
|
+
evalCaseId: createdCase.evalCase.id,
|
|
333
|
+
failingRunId: failingRun.evalRun.id,
|
|
334
|
+
passingRunIds,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
expect(counterfactual.verified).toBe(true);
|
|
339
|
+
expect(counterfactual.finding.causalProofTier).toBe(
|
|
340
|
+
"counterfactual_verified",
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// 5. Remediations
|
|
344
|
+
const proposedHuman = await client.request(
|
|
345
|
+
"POST /internal/remediations/propose",
|
|
346
|
+
{
|
|
347
|
+
body: {
|
|
348
|
+
environment: "prod",
|
|
349
|
+
incidentId: openedIncident.incident.id,
|
|
350
|
+
findingId: createdFinding.id,
|
|
351
|
+
idempotencyKey: "idem-ws5-human",
|
|
352
|
+
actionKind: "rollback_deploy",
|
|
353
|
+
targetNodeId: "service:default:checkout",
|
|
354
|
+
expectedCurrentState: { version: "2.1.0" },
|
|
355
|
+
desiredTargetState: { version: "2.0.0" },
|
|
356
|
+
requestedAutonomy: "human_approved_write",
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
expect(proposedHuman.proposal.status).toBe("pending_human_approval");
|
|
361
|
+
|
|
362
|
+
// Bearer token approval must fail with 403 InternalClientHttpError
|
|
363
|
+
const bearerClient = makeClient({
|
|
364
|
+
Authorization: "Bearer obs_secret_agent_token",
|
|
365
|
+
});
|
|
366
|
+
await expect(
|
|
367
|
+
bearerClient.request("POST /internal/remediations/:id/approve", {
|
|
368
|
+
params: { id: proposedHuman.proposal.id },
|
|
369
|
+
body: {},
|
|
370
|
+
}),
|
|
371
|
+
).rejects.toThrow(InternalClientHttpError);
|
|
372
|
+
|
|
373
|
+
// Cookie session approval succeeds and mints grantToken
|
|
374
|
+
const humanSessionClient = makeClient({
|
|
375
|
+
Cookie: "obs_session=valid_human_session_cookie",
|
|
376
|
+
});
|
|
377
|
+
const approved = await humanSessionClient.request(
|
|
378
|
+
"POST /internal/remediations/:id/approve",
|
|
379
|
+
{
|
|
380
|
+
params: { id: proposedHuman.proposal.id },
|
|
381
|
+
body: {},
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
expect(approved.proposal.status).toBe("grant_issued");
|
|
385
|
+
expect(typeof approved.grantToken).toBe("string");
|
|
386
|
+
|
|
387
|
+
const claimed = await client.request(
|
|
388
|
+
"POST /internal/remediations/:id/claim",
|
|
389
|
+
{
|
|
390
|
+
params: { id: proposedHuman.proposal.id },
|
|
391
|
+
body: {
|
|
392
|
+
grantToken: approved.grantToken as string,
|
|
393
|
+
runnerId: "runner-ws5",
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
expect(claimed.claimed).toBe(true);
|
|
398
|
+
expect(claimed.proposal.status).toBe("claimed_executing");
|
|
399
|
+
|
|
400
|
+
const completed = await client.request(
|
|
401
|
+
"POST /internal/remediations/:id/complete",
|
|
402
|
+
{
|
|
403
|
+
params: { id: proposedHuman.proposal.id },
|
|
404
|
+
body: {
|
|
405
|
+
runnerId: "runner-ws5",
|
|
406
|
+
resultingChangeId: createdChange.id,
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
expect(completed.proposal.status).toBe("completed");
|
|
411
|
+
|
|
412
|
+
const watchdog = await client.request(
|
|
413
|
+
"POST /internal/remediations/watchdog",
|
|
414
|
+
{
|
|
415
|
+
body: {},
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
expect(typeof watchdog.summary.evaluated).toBe("number");
|
|
419
|
+
|
|
420
|
+
const incidentDetail = await client.request("GET /internal/incidents/:id", {
|
|
421
|
+
params: { id: openedIncident.incident.id },
|
|
422
|
+
});
|
|
423
|
+
expect(incidentDetail.incident.id).toBe(openedIncident.incident.id);
|
|
424
|
+
expect(incidentDetail.findings.length).toBeGreaterThanOrEqual(1);
|
|
425
|
+
expect(incidentDetail.proposals.length).toBeGreaterThanOrEqual(1);
|
|
426
|
+
|
|
427
|
+
// 6. Connected, Evidence Bundle, Evidence Stats
|
|
428
|
+
const connected = await client.request(
|
|
429
|
+
"GET /internal/connected/:kind/:id",
|
|
430
|
+
{
|
|
431
|
+
params: { kind: "incident", id: openedIncident.incident.id },
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
expect(connected.entity.id).toBe(openedIncident.incident.id);
|
|
435
|
+
|
|
436
|
+
const bundle = await client.request("POST /internal/evidence/bundle", {
|
|
437
|
+
body: {
|
|
438
|
+
anchor: {
|
|
439
|
+
entityKind: "incident",
|
|
440
|
+
entityId: openedIncident.incident.id,
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
expect(bundle.schemaVersion).toBe("obs-unified.evidence-bundle.v1");
|
|
445
|
+
|
|
446
|
+
const stats = await client.request("GET /internal/evidence/stats", {
|
|
447
|
+
query: { limit: 10 },
|
|
448
|
+
});
|
|
449
|
+
expect(stats.projectId).toBe("default");
|
|
450
|
+
});
|
|
451
|
+
});
|
package/src/framework/env.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Context, Hono } from "hono";
|
|
2
|
+
import type { AlertEmailTransport } from "../lib/alert-email";
|
|
2
3
|
import type { Logger } from "./logger";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -22,6 +23,12 @@ export interface CollectorEnv {
|
|
|
22
23
|
/** Durable Object namespace for live-tail SSE pub/sub. Optional — when
|
|
23
24
|
* unbound the /internal/telemetry/tail endpoint returns 503. */
|
|
24
25
|
TAIL_HUB?: DurableObjectNamespace;
|
|
26
|
+
/**
|
|
27
|
+
* SMTP transport for email alert channels. Bound by hosts that can send
|
|
28
|
+
* mail (the Node collector, from `ALERT_SMTP_*`); never set on Cloudflare
|
|
29
|
+
* Workers. Without it, rules with an email channel are refused.
|
|
30
|
+
*/
|
|
31
|
+
ALERT_EMAIL?: AlertEmailTransport;
|
|
25
32
|
/** Write-only API key for SDK ingest endpoints (/v1/*) */
|
|
26
33
|
INGEST_KEY?: string;
|
|
27
34
|
/** Password for dashboard login */
|
|
@@ -89,6 +96,112 @@ export interface CollectorEnv {
|
|
|
89
96
|
TELEMETRY_QUERY_TOKEN?: string;
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Optional string settings on `CollectorEnv` that host adapters (such as
|
|
101
|
+
* `apps/collector-node`) forward directly from the process environment after
|
|
102
|
+
* trimming whitespace and coercing blank strings to `undefined`.
|
|
103
|
+
*/
|
|
104
|
+
export const COLLECTOR_OPTIONAL_STRING_ENV_KEYS = [
|
|
105
|
+
"RETENTION_HOURS",
|
|
106
|
+
"ANTHROPIC_API_KEY",
|
|
107
|
+
"OPENAI_API_KEY",
|
|
108
|
+
"OPENAI_BASE_URL",
|
|
109
|
+
"NARRATIVE_MODEL",
|
|
110
|
+
"NARRATIVE_BUDGET_PER_HOUR",
|
|
111
|
+
"ALERT_WEBHOOK_ALLOW_PRIVATE",
|
|
112
|
+
"MAX_BODY_BYTES",
|
|
113
|
+
"TELEMETRY_REDACT_FIELDS",
|
|
114
|
+
"OBS_PAYLOAD_CAPTURE_DEFAULT",
|
|
115
|
+
] as const;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* String settings on `CollectorEnv` that require explicit host handling (e.g.
|
|
119
|
+
* required auth secrets, CORS origin parsing, Worker-only self-instrumentation
|
|
120
|
+
* vars, or deprecated Worker tokens) and must not be blindly forwarded by the
|
|
121
|
+
* generic optional-env loop.
|
|
122
|
+
*/
|
|
123
|
+
export const COLLECTOR_HOST_MANAGED_ENV_KEYS = [
|
|
124
|
+
"INGEST_KEY",
|
|
125
|
+
"DASHBOARD_PASSWORD",
|
|
126
|
+
"DASHBOARD_TOKEN",
|
|
127
|
+
"ALLOWED_ORIGINS",
|
|
128
|
+
"ALLOW_UNAUTHENTICATED",
|
|
129
|
+
"OBS_DASHBOARD_INGEST_KEY",
|
|
130
|
+
"OBS_COLLECTOR_SELF_URL",
|
|
131
|
+
"OBS_SELF_AWAIT_EXPORTS",
|
|
132
|
+
"TELEMETRY_INGEST_TOKEN",
|
|
133
|
+
"USAGE_INGEST_TOKEN",
|
|
134
|
+
"TELEMETRY_QUERY_TOKEN",
|
|
135
|
+
] as const;
|
|
136
|
+
|
|
137
|
+
export const COLLECTOR_STRING_ENV_MANIFEST = {
|
|
138
|
+
forwarded: COLLECTOR_OPTIONAL_STRING_ENV_KEYS,
|
|
139
|
+
hostManaged: COLLECTOR_HOST_MANAGED_ENV_KEYS,
|
|
140
|
+
} as const;
|
|
141
|
+
|
|
142
|
+
export type CollectorOptionalStringEnvKey =
|
|
143
|
+
(typeof COLLECTOR_OPTIONAL_STRING_ENV_KEYS)[number];
|
|
144
|
+
|
|
145
|
+
export type CollectorHostManagedEnvKey =
|
|
146
|
+
(typeof COLLECTOR_HOST_MANAGED_ENV_KEYS)[number];
|
|
147
|
+
|
|
148
|
+
export type CollectorManifestStringEnvKey =
|
|
149
|
+
| CollectorOptionalStringEnvKey
|
|
150
|
+
| CollectorHostManagedEnvKey;
|
|
151
|
+
|
|
152
|
+
export type CollectorObjectBindingKey =
|
|
153
|
+
| "DB"
|
|
154
|
+
| "REPLAYS_BUCKET"
|
|
155
|
+
| "PROFILES_BUCKET"
|
|
156
|
+
| "TAIL_HUB"
|
|
157
|
+
| "ALERT_EMAIL";
|
|
158
|
+
|
|
159
|
+
export type CollectorStringEnvKey = Exclude<
|
|
160
|
+
keyof CollectorEnv,
|
|
161
|
+
CollectorObjectBindingKey
|
|
162
|
+
>;
|
|
163
|
+
|
|
164
|
+
type AssertNever<T extends never> = T;
|
|
165
|
+
type _UnaccountedCollectorStringEnvKeys = AssertNever<
|
|
166
|
+
Exclude<CollectorStringEnvKey, CollectorManifestStringEnvKey>
|
|
167
|
+
>;
|
|
168
|
+
type _UnknownManifestStringEnvKeys = AssertNever<
|
|
169
|
+
Exclude<CollectorManifestStringEnvKey, CollectorStringEnvKey>
|
|
170
|
+
>;
|
|
171
|
+
type _OverlappingManifestStringEnvKeys = AssertNever<
|
|
172
|
+
Extract<CollectorOptionalStringEnvKey, CollectorHostManagedEnvKey>
|
|
173
|
+
>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Verifies that every string key in `declaredKeys` is categorized in either
|
|
177
|
+
* `COLLECTOR_OPTIONAL_STRING_ENV_KEYS` or `COLLECTOR_HOST_MANAGED_ENV_KEYS`
|
|
178
|
+
* without overlap. Throws if any key is uncategorized or duplicated.
|
|
179
|
+
*/
|
|
180
|
+
export function assertCollectorEnvManifestCompleteness(
|
|
181
|
+
declaredKeys: readonly string[],
|
|
182
|
+
manifest: {
|
|
183
|
+
readonly forwarded: readonly string[];
|
|
184
|
+
readonly hostManaged: readonly string[];
|
|
185
|
+
} = COLLECTOR_STRING_ENV_MANIFEST,
|
|
186
|
+
): void {
|
|
187
|
+
const forwardedSet = new Set(manifest.forwarded);
|
|
188
|
+
const hostManagedSet = new Set(manifest.hostManaged);
|
|
189
|
+
const overlap = manifest.forwarded.filter((k) => hostManagedSet.has(k));
|
|
190
|
+
if (overlap.length > 0) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`CollectorEnv manifest keys cannot be both forwarded and host-managed: ${overlap.join(", ")}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const unaccounted = declaredKeys.filter(
|
|
196
|
+
(k) => !forwardedSet.has(k) && !hostManagedSet.has(k),
|
|
197
|
+
);
|
|
198
|
+
if (unaccounted.length > 0) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Uncategorized CollectorEnv string setting(s): ${unaccounted.join(", ")}. Add each key to COLLECTOR_OPTIONAL_STRING_ENV_KEYS or COLLECTOR_HOST_MANAGED_ENV_KEYS.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
92
205
|
export interface CollectorRouteContext {
|
|
93
206
|
hono: Context<{ Bindings: CollectorEnv }>;
|
|
94
207
|
env: CollectorEnv;
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { aiSpanPayloadsProcessorPlugin } from "./plugins/ai-span-payloads-proces
|
|
|
9
9
|
import {
|
|
10
10
|
createAlertEvaluatorHandler,
|
|
11
11
|
evaluateAllRules,
|
|
12
|
+
evaluateRuleBatch,
|
|
12
13
|
} from "./plugins/alerts-evaluator";
|
|
13
14
|
import { alertsRoutesPlugin } from "./plugins/alerts-routes";
|
|
14
15
|
import { analysesRoutesPlugin } from "./plugins/analyses-routes";
|
|
@@ -68,10 +69,19 @@ export {
|
|
|
68
69
|
type UsageEventProcessorPlugin,
|
|
69
70
|
} from "./framework/collector";
|
|
70
71
|
|
|
71
|
-
export
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
export {
|
|
73
|
+
assertCollectorEnvManifestCompleteness,
|
|
74
|
+
COLLECTOR_HOST_MANAGED_ENV_KEYS,
|
|
75
|
+
COLLECTOR_OPTIONAL_STRING_ENV_KEYS,
|
|
76
|
+
COLLECTOR_STRING_ENV_MANIFEST,
|
|
77
|
+
type CollectorApp,
|
|
78
|
+
type CollectorEnv,
|
|
79
|
+
type CollectorHostManagedEnvKey,
|
|
80
|
+
type CollectorManifestStringEnvKey,
|
|
81
|
+
type CollectorObjectBindingKey,
|
|
82
|
+
type CollectorOptionalStringEnvKey,
|
|
83
|
+
type CollectorRouteContext,
|
|
84
|
+
type CollectorStringEnvKey,
|
|
75
85
|
} from "./framework/env";
|
|
76
86
|
|
|
77
87
|
export type { Logger } from "./framework/logger";
|
|
@@ -91,6 +101,13 @@ export {
|
|
|
91
101
|
type RetrievalEventRecord,
|
|
92
102
|
type ToolCallRecord,
|
|
93
103
|
} from "./lib/actions-store";
|
|
104
|
+
export {
|
|
105
|
+
type AlertEmailMessage,
|
|
106
|
+
type AlertEmailTransport,
|
|
107
|
+
type AlertNotificationPayload,
|
|
108
|
+
formatAlertEmail,
|
|
109
|
+
isValidEmailAddress,
|
|
110
|
+
} from "./lib/alert-email";
|
|
94
111
|
export { AlertsStore, compareValue } from "./lib/alerts-store";
|
|
95
112
|
export {
|
|
96
113
|
computeChangeBlastRadius,
|
|
@@ -159,6 +176,11 @@ export {
|
|
|
159
176
|
isEvalCaseSourceType,
|
|
160
177
|
isEvalRunStatus,
|
|
161
178
|
} from "./lib/eval-cases-store";
|
|
179
|
+
export {
|
|
180
|
+
evaluateMetricQuery,
|
|
181
|
+
reduceSeries,
|
|
182
|
+
validateMetricQuery,
|
|
183
|
+
} from "./lib/metric-alerts";
|
|
162
184
|
export { MetricsStore } from "./lib/metrics-store";
|
|
163
185
|
export { ProjectsStore } from "./lib/projects-store";
|
|
164
186
|
export { D1Adapter, type SqlDb, type SqlStatement } from "./lib/sql-db";
|
|
@@ -183,6 +205,7 @@ export {
|
|
|
183
205
|
defaultSpanEnrichmentPlugin,
|
|
184
206
|
evalCasesRoutesPlugin,
|
|
185
207
|
evaluateAllRules,
|
|
208
|
+
evaluateRuleBatch,
|
|
186
209
|
evidenceRetrievalRoutesPlugin,
|
|
187
210
|
genAiNormalizerPlugin,
|
|
188
211
|
identityReceiverPlugin,
|
|
@@ -25,20 +25,20 @@ describe("mapEvaluationRows", () => {
|
|
|
25
25
|
evidenceId: "ai-evaluation:eval-1",
|
|
26
26
|
entityKind: "eval",
|
|
27
27
|
entityId: "eval-1",
|
|
28
|
-
route: "#/
|
|
28
|
+
route: "#/evaluations/eval-1",
|
|
29
29
|
source: "ai_span_evaluations.llm_judge",
|
|
30
30
|
citations: [
|
|
31
31
|
{
|
|
32
32
|
label: "span span-1",
|
|
33
33
|
entityKind: "span",
|
|
34
34
|
entityId: "trace-1:span-1",
|
|
35
|
-
route: "#/traces
|
|
35
|
+
route: "#/traces?trace=trace-1&span=span-1",
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
label: "trace trace-1",
|
|
39
39
|
entityKind: "trace",
|
|
40
40
|
entityId: "trace-1",
|
|
41
|
-
route: "#/traces
|
|
41
|
+
route: "#/traces?trace=trace-1",
|
|
42
42
|
},
|
|
43
43
|
],
|
|
44
44
|
}),
|