@obsunified/collector 2.2.1 → 2.3.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.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
- package/dist/chunk-2FALTDCE.js.map +1 -0
- package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
- package/dist/chunk-RASSTGNB.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +2892 -2240
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BmP5-f96.d.ts} +32 -1
- package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
- package/package.json +4 -3
- package/src/__tests__/autosre-closed-loop.test.ts +700 -0
- package/src/__tests__/db-parity.integration.test.ts +961 -0
- package/src/__tests__/internal-contracts-routes.test.ts +451 -0
- package/src/auth/dashboard-auth.test.ts +61 -0
- package/src/auth/dashboard-auth.ts +17 -2
- package/src/framework/env.ts +105 -0
- package/src/index.ts +13 -4
- package/src/lib/ai-store/calls.ts +43 -23
- package/src/lib/ai-store/evaluations.test.ts +3 -3
- package/src/lib/ai-store.ts +12 -1
- package/src/lib/analyses-store.test.ts +6 -6
- package/src/lib/autosre-findings.ts +169 -14
- package/src/lib/autosre-remediation-gate.ts +156 -26
- 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/projects-store.test.ts +38 -1
- package/src/lib/projects-store.ts +23 -4
- package/src/lib/sql-db-postgres.integration.test.ts +75 -2
- package/src/lib/stage6.test.ts +1 -1
- package/src/lib/usage-store.ts +9 -3
- package/src/migrations/052_remediation_callback.sql +10 -0
- package/src/migrations/053_remediation_callback_delivery.sql +7 -0
- package/src/migrations/parity.test.ts +105 -3
- package/src/migrations-postgres/052_remediation_callback.sql +10 -0
- package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
- package/src/plugins/ai-call-by-id.test.ts +73 -0
- package/src/plugins/ai-receiver.ts +15 -0
- package/src/plugins/autosre-routes.ts +409 -147
- package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
- package/src/plugins/connected-routes/manifest.test.ts +224 -0
- package/src/plugins/connected-routes/manifest.ts +131 -28
- package/src/plugins/connected-routes-links.test.ts +96 -0
- package/src/plugins/connected-routes.test.ts +13 -11
- package/src/plugins/connected-routes.ts +53 -20
- package/src/plugins/eval-cases-routes.ts +33 -10
- package/src/plugins/evidence-retrieval-routes.ts +89 -24
- package/dist/chunk-KMEZKGSO.js.map +0 -1
- package/dist/chunk-ZSF5XP3B.js.map +0 -1
- /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
|
@@ -3,7 +3,10 @@ import type {
|
|
|
3
3
|
EvidenceReferenceContract as EvidenceReferenceContractType,
|
|
4
4
|
JsonValue,
|
|
5
5
|
} from "@obsunified/types";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
dashboardRouteToHref,
|
|
8
|
+
EVIDENCE_REFERENCE_CONTRACT,
|
|
9
|
+
} from "@obsunified/types";
|
|
7
10
|
import { sourceLinkEvidenceReferences } from "./evidence-references";
|
|
8
11
|
import { randomHex } from "./hash";
|
|
9
12
|
import { parseJsonArray, parseJsonValue } from "./json";
|
|
@@ -345,7 +348,10 @@ const evalCaseEvidenceReferences = (evalCase: EvalCase): EvidenceReference[] =>
|
|
|
345
348
|
sourceLabel: `Eval case "${evalCase.name}"`,
|
|
346
349
|
sourceId: evalCase.id,
|
|
347
350
|
sourceKind: "eval_case",
|
|
348
|
-
sourceRoute:
|
|
351
|
+
sourceRoute: dashboardRouteToHref({
|
|
352
|
+
tab: "evaluations",
|
|
353
|
+
evaluationId: evalCase.id,
|
|
354
|
+
}),
|
|
349
355
|
sourceName: evalCase.name,
|
|
350
356
|
},
|
|
351
357
|
evalCase,
|
|
@@ -384,7 +390,10 @@ const evalRunEvidenceReferences = (run: EvalRun): EvidenceReference[] => {
|
|
|
384
390
|
sourceLabel: `Eval run "${run.id}"`,
|
|
385
391
|
sourceId: run.id,
|
|
386
392
|
sourceKind: "eval_run",
|
|
387
|
-
sourceRoute:
|
|
393
|
+
sourceRoute: dashboardRouteToHref({
|
|
394
|
+
tab: "evaluations",
|
|
395
|
+
evaluationId: run.evalCaseId ?? run.id,
|
|
396
|
+
}),
|
|
388
397
|
sourceName: run.sourceEvalCase.name,
|
|
389
398
|
},
|
|
390
399
|
run.sourceEvalCase,
|
|
@@ -413,7 +422,10 @@ const rowToEvalRun = (row: EvalRunRow): EvalRun => {
|
|
|
413
422
|
sourceLabel: `Eval case "${sourceEvalCase.name}"`,
|
|
414
423
|
sourceId: sourceEvalCase.id,
|
|
415
424
|
sourceKind: "eval_case",
|
|
416
|
-
sourceRoute:
|
|
425
|
+
sourceRoute: dashboardRouteToHref({
|
|
426
|
+
tab: "evaluations",
|
|
427
|
+
evaluationId: sourceEvalCase.id,
|
|
428
|
+
}),
|
|
417
429
|
sourceName: sourceEvalCase.name,
|
|
418
430
|
},
|
|
419
431
|
sourceEvalCase,
|
|
@@ -10,9 +10,10 @@ import type {
|
|
|
10
10
|
EvidenceReference,
|
|
11
11
|
JsonValue,
|
|
12
12
|
} from "@obsunified/types";
|
|
13
|
+
import { dashboardRouteToHref, hrefForEntity } from "@obsunified/types";
|
|
13
14
|
|
|
14
15
|
const analysisRoute = (analysisId: string) =>
|
|
15
|
-
|
|
16
|
+
dashboardRouteToHref({ tab: "investigate", investigationId: analysisId });
|
|
16
17
|
|
|
17
18
|
export const analysisEvidenceReference = (
|
|
18
19
|
evidence: AskEvidence,
|
|
@@ -103,10 +104,10 @@ const valueFor = (
|
|
|
103
104
|
};
|
|
104
105
|
|
|
105
106
|
const traceRoute = (traceId: string) =>
|
|
106
|
-
|
|
107
|
+
dashboardRouteToHref({ tab: "traces", traceId });
|
|
107
108
|
|
|
108
109
|
const spanRoute = (traceId: string, spanId: string) =>
|
|
109
|
-
|
|
110
|
+
dashboardRouteToHref({ tab: "traces", traceId, spanId });
|
|
110
111
|
|
|
111
112
|
export const evidenceRouteFor = (
|
|
112
113
|
entityKind: EvidenceEntityKind,
|
|
@@ -117,41 +118,48 @@ export const evidenceRouteFor = (
|
|
|
117
118
|
case "analysis":
|
|
118
119
|
return analysisRoute(entityId);
|
|
119
120
|
case "action":
|
|
120
|
-
return
|
|
121
|
+
return dashboardRouteToHref({ tab: "actions", actionId: entityId });
|
|
121
122
|
case "alert":
|
|
122
|
-
return
|
|
123
|
+
return dashboardRouteToHref({ tab: "alerts", alertId: entityId });
|
|
123
124
|
case "agent_run":
|
|
124
|
-
return
|
|
125
|
+
return dashboardRouteToHref({ tab: "agent-runs", agentRunId: entityId });
|
|
125
126
|
case "ai_call":
|
|
126
|
-
return
|
|
127
|
+
return dashboardRouteToHref({ tab: "ai", aiCallId: entityId });
|
|
127
128
|
case "eval":
|
|
128
|
-
return
|
|
129
|
+
return dashboardRouteToHref({
|
|
130
|
+
tab: "evaluations",
|
|
131
|
+
evaluationId: entityId,
|
|
132
|
+
});
|
|
129
133
|
case "log":
|
|
130
|
-
return
|
|
134
|
+
return dashboardRouteToHref({ tab: "logs", logId: entityId });
|
|
131
135
|
case "profile":
|
|
132
|
-
return
|
|
133
|
-
|
|
134
|
-
:
|
|
136
|
+
return dashboardRouteToHref({
|
|
137
|
+
tab: "profiles",
|
|
138
|
+
profileId: entityId,
|
|
139
|
+
...(traceId ? { traceId } : {}),
|
|
140
|
+
});
|
|
135
141
|
case "replay":
|
|
136
|
-
return
|
|
142
|
+
return dashboardRouteToHref({ tab: "replay", sessionId: entityId });
|
|
137
143
|
case "service":
|
|
138
|
-
return
|
|
144
|
+
return dashboardRouteToHref({ tab: "service-map", service: entityId });
|
|
139
145
|
case "span": {
|
|
140
146
|
const [tracePart, spanPart] = entityId.split(":");
|
|
141
|
-
return spanPart
|
|
147
|
+
return spanPart
|
|
148
|
+
? spanRoute(tracePart, spanPart)
|
|
149
|
+
: hrefForEntity("span", entityId, traceId ? { traceId } : undefined);
|
|
142
150
|
}
|
|
143
151
|
case "tool_call":
|
|
144
|
-
return
|
|
152
|
+
return dashboardRouteToHref({ tab: "tool-calls", toolCallId: entityId });
|
|
145
153
|
case "trace":
|
|
146
154
|
return traceRoute(entityId);
|
|
147
155
|
case "docs":
|
|
148
|
-
return
|
|
156
|
+
return hrefForEntity("docs", entityId);
|
|
149
157
|
case "change":
|
|
150
|
-
return
|
|
158
|
+
return dashboardRouteToHref({ tab: "changes", changeId: entityId });
|
|
151
159
|
case "finding":
|
|
152
|
-
return
|
|
160
|
+
return dashboardRouteToHref({ tab: "findings", findingId: entityId });
|
|
153
161
|
case "incident":
|
|
154
|
-
return
|
|
162
|
+
return dashboardRouteToHref({ tab: "incidents", incidentId: entityId });
|
|
155
163
|
}
|
|
156
164
|
const exhaustive: never = entityKind;
|
|
157
165
|
return exhaustive;
|
|
@@ -232,7 +240,7 @@ const instrumentationGapEvidenceReferences = (
|
|
|
232
240
|
const thresholdText = thresholdVersion
|
|
233
241
|
? ` Thresholds: ${thresholdVersion}.`
|
|
234
242
|
: "";
|
|
235
|
-
const docsRoute = "
|
|
243
|
+
const docsRoute = hrefForEntity("docs", "howto/ebpf");
|
|
236
244
|
return {
|
|
237
245
|
evidenceId: `analysis:${result.analysisId}:instrumentation-gap:${index}:span:${entityId}`,
|
|
238
246
|
entityKind: "span",
|
|
@@ -262,7 +270,7 @@ const instrumentationGapEvidenceReferences = (
|
|
|
262
270
|
label: "Open trace gap data",
|
|
263
271
|
entityKind: "trace",
|
|
264
272
|
entityId: traceId,
|
|
265
|
-
route:
|
|
273
|
+
route: traceRoute(traceId),
|
|
266
274
|
reason:
|
|
267
275
|
"Review all structured instrumentation blindspots for this trace.",
|
|
268
276
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { sha256Hex } from "./hash";
|
|
3
|
-
import { ProjectsStore } from "./projects-store";
|
|
3
|
+
import { displayPrefix, ProjectsStore } from "./projects-store";
|
|
4
4
|
import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
|
|
5
5
|
|
|
6
6
|
describe("ProjectsStore.revokeKey", () => {
|
|
@@ -23,3 +23,40 @@ describe("ProjectsStore.revokeKey", () => {
|
|
|
23
23
|
db.close();
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
describe("bootstrap key display prefix", () => {
|
|
28
|
+
it("never exposes a short bootstrap key in full", async () => {
|
|
29
|
+
const db = createMigratedSqliteDb();
|
|
30
|
+
const store = new ProjectsStore(db);
|
|
31
|
+
await store.bootstrapEnvKey("short-key-12345"); // 15 chars
|
|
32
|
+
const [key] = (await store.listKeys("default")).filter(
|
|
33
|
+
(k) => k.name === "bootstrap",
|
|
34
|
+
);
|
|
35
|
+
expect(key?.keyPrefix).toBe("short");
|
|
36
|
+
expect("short-key-12345".startsWith(key?.keyPrefix ?? "")).toBe(true);
|
|
37
|
+
expect(key?.keyPrefix.length).toBeLessThan("short-key-12345".length);
|
|
38
|
+
db.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps 16 chars for long keys, matching minted keys", () => {
|
|
42
|
+
expect(displayPrefix("a".repeat(64))).toHaveLength(16);
|
|
43
|
+
expect(displayPrefix("abc")).toBe("a");
|
|
44
|
+
expect(displayPrefix("")).toBe("");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("shrinks a legacy full-length prefix on the next bootstrap", async () => {
|
|
48
|
+
const db = createMigratedSqliteDb();
|
|
49
|
+
const store = new ProjectsStore(db);
|
|
50
|
+
await store.bootstrapEnvKey("legacy-key-1234");
|
|
51
|
+
await db
|
|
52
|
+
.prepare(`UPDATE ingest_keys SET key_prefix = ? WHERE name = 'bootstrap'`)
|
|
53
|
+
.bind("legacy-key-1234")
|
|
54
|
+
.run();
|
|
55
|
+
await store.bootstrapEnvKey("legacy-key-1234");
|
|
56
|
+
const [key] = (await store.listKeys("default")).filter(
|
|
57
|
+
(k) => k.name === "bootstrap",
|
|
58
|
+
);
|
|
59
|
+
expect(key?.keyPrefix).toBe("legac");
|
|
60
|
+
db.close();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -31,6 +31,15 @@ const rowToKey = (row: IngestKeyRow): IngestKey => ({
|
|
|
31
31
|
|
|
32
32
|
const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$/;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Display identifier for a secret: at most 16 chars (what minted keys show),
|
|
36
|
+
* but never more than a third of the secret, so a short operator-chosen
|
|
37
|
+
* INGEST_KEY can never be returned in full by `keys list`.
|
|
38
|
+
*/
|
|
39
|
+
export function displayPrefix(plaintext: string): string {
|
|
40
|
+
return plaintext.slice(0, Math.min(16, Math.floor(plaintext.length / 3)));
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
export class ProjectsStore {
|
|
35
44
|
constructor(private readonly db: SqlDb) {}
|
|
36
45
|
|
|
@@ -189,14 +198,24 @@ export class ProjectsStore {
|
|
|
189
198
|
if (!plaintext) return;
|
|
190
199
|
await this.ensureDefaultProject();
|
|
191
200
|
const keyHash = await sha256Hex(plaintext);
|
|
201
|
+
const keyPrefix = displayPrefix(plaintext);
|
|
192
202
|
const existing = await this.db
|
|
193
|
-
.prepare(`SELECT id FROM ingest_keys WHERE key_hash = ?`)
|
|
203
|
+
.prepare(`SELECT id, key_prefix FROM ingest_keys WHERE key_hash = ?`)
|
|
194
204
|
.bind(keyHash)
|
|
195
|
-
.first<{ id: string }>();
|
|
196
|
-
if (existing)
|
|
205
|
+
.first<{ id: string; key_prefix: string }>();
|
|
206
|
+
if (existing) {
|
|
207
|
+
// Rows written before the prefix was length-capped may hold a short
|
|
208
|
+
// bootstrap key in full; shrink them to the safe display prefix.
|
|
209
|
+
if (existing.key_prefix.length > keyPrefix.length) {
|
|
210
|
+
await this.db
|
|
211
|
+
.prepare(`UPDATE ingest_keys SET key_prefix = ? WHERE id = ?`)
|
|
212
|
+
.bind(keyPrefix, existing.id)
|
|
213
|
+
.run();
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
197
217
|
|
|
198
218
|
const id = randomHex(16);
|
|
199
|
-
const keyPrefix = plaintext.slice(0, Math.min(16, plaintext.length));
|
|
200
219
|
const createdAt = new Date().toISOString();
|
|
201
220
|
await this.db
|
|
202
221
|
.prepare(
|
|
@@ -28,20 +28,22 @@ import type {
|
|
|
28
28
|
StoredSpan,
|
|
29
29
|
UsageEventRecord,
|
|
30
30
|
} from "@obsunified/types";
|
|
31
|
-
import type
|
|
31
|
+
import { Hono, type MiddlewareHandler } from "hono";
|
|
32
32
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
33
33
|
import { deriveAnalysesForProject } from "../analyses/derive";
|
|
34
34
|
import { INVESTIGATION_ANALYSES } from "../analyses/investigations";
|
|
35
35
|
import { TIER0_ANALYSES } from "../analyses/tier0";
|
|
36
36
|
import {
|
|
37
|
-
|
|
37
|
+
CollectorRuntime,
|
|
38
38
|
createTelemetryCollectorApp,
|
|
39
39
|
type SpanProcessorPlugin,
|
|
40
40
|
} from "../framework/collector";
|
|
41
41
|
import type { CollectorEnv, CollectorRouteContext } from "../framework/env";
|
|
42
42
|
import type { DecodedMetricPoint } from "../otlp/decode";
|
|
43
43
|
import { aiSpanPayloadsProcessorPlugin } from "../plugins/ai-span-payloads-processor";
|
|
44
|
+
import { connectedRoutesPlugin } from "../plugins/connected-routes";
|
|
44
45
|
import { identityReceiverPlugin } from "../plugins/identity-receiver";
|
|
46
|
+
import { usageQueryRoutesPlugin } from "../plugins/usage-query-routes";
|
|
45
47
|
import { purgeExpiredActionGraph } from "./action-retention";
|
|
46
48
|
import { ActionStore } from "./actions-store";
|
|
47
49
|
import { getAICallsOverview, ingestAICallBatch } from "./ai-store/calls";
|
|
@@ -1222,4 +1224,75 @@ describe.skipIf(!databaseUrl)("PostgresAdapter against a live Postgres", () => {
|
|
|
1222
1224
|
expect(errorRate.primaryValue).toBe(0.5);
|
|
1223
1225
|
expect(Array.isArray(errorRate.payload.sparkline)).toBe(true);
|
|
1224
1226
|
});
|
|
1227
|
+
|
|
1228
|
+
it("serves the change connected-rail and usage sessions routes without Postgres query errors", async () => {
|
|
1229
|
+
const now = isoNow();
|
|
1230
|
+
const later = isoIn(60 * 60 * 1000);
|
|
1231
|
+
const insertSpan = (spanId: string, startTime: string) =>
|
|
1232
|
+
db
|
|
1233
|
+
.prepare(
|
|
1234
|
+
`INSERT INTO telemetry_spans (
|
|
1235
|
+
project_id, environment, trace_id, span_id, service_name, service_version,
|
|
1236
|
+
span_name, status_code, start_time, end_time, duration_ms, received_at, expires_at
|
|
1237
|
+
) VALUES ('default', 'default', 'trace-pg-1', ?, 'checkout', 'v9.9.9', 'POST /pay', 2, ?, ?, 5, ?, ?)`,
|
|
1238
|
+
)
|
|
1239
|
+
.bind(spanId, startTime, startTime, now, later)
|
|
1240
|
+
.run();
|
|
1241
|
+
await insertSpan("span-pg-a", "2026-01-01T00:00:00.000Z");
|
|
1242
|
+
await insertSpan("span-pg-b", "2026-01-01T00:00:01.000Z");
|
|
1243
|
+
await db
|
|
1244
|
+
.prepare(
|
|
1245
|
+
`INSERT INTO change_events (
|
|
1246
|
+
project_id, environment, id, target_node_id, kind, source, summary,
|
|
1247
|
+
service_name, version, occurred_at, received_at
|
|
1248
|
+
) VALUES ('default', 'default', 'chg-pg-1', 'service:checkout', 'deploy', 'explicit', 'deploy v9.9.9', 'checkout', 'v9.9.9', ?, ?)`,
|
|
1249
|
+
)
|
|
1250
|
+
.bind(now, now)
|
|
1251
|
+
.run();
|
|
1252
|
+
await new UsageStore(db).ingest([
|
|
1253
|
+
usageEvent({
|
|
1254
|
+
eventId: "pg-s1-a",
|
|
1255
|
+
sessionId: "pg-sess-1",
|
|
1256
|
+
pagePath: "/a",
|
|
1257
|
+
}),
|
|
1258
|
+
usageEvent({
|
|
1259
|
+
eventId: "pg-s1-b",
|
|
1260
|
+
sessionId: "pg-sess-1",
|
|
1261
|
+
pagePath: "/b",
|
|
1262
|
+
eventType: "interaction",
|
|
1263
|
+
}),
|
|
1264
|
+
]);
|
|
1265
|
+
|
|
1266
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
1267
|
+
const runtime = new CollectorRuntime();
|
|
1268
|
+
connectedRoutesPlugin.register(app, runtime);
|
|
1269
|
+
usageQueryRoutesPlugin.register(app, runtime);
|
|
1270
|
+
const env = { DB: db as unknown as D1Database } as CollectorEnv;
|
|
1271
|
+
|
|
1272
|
+
const change = await app.request(
|
|
1273
|
+
"/internal/connected/change/chg-pg-1",
|
|
1274
|
+
{},
|
|
1275
|
+
env,
|
|
1276
|
+
);
|
|
1277
|
+
expect(change.status).toBe(200);
|
|
1278
|
+
const manifest = (await change.json()) as {
|
|
1279
|
+
across: Array<{ links: Array<{ entityId: string }> }>;
|
|
1280
|
+
};
|
|
1281
|
+
const spanIds = manifest.across.flatMap((s) =>
|
|
1282
|
+
s.links.map((l) => l.entityId),
|
|
1283
|
+
);
|
|
1284
|
+
// Most recent first, one link per span.
|
|
1285
|
+
expect(spanIds).toEqual(["trace-pg-1:span-pg-b", "trace-pg-1:span-pg-a"]);
|
|
1286
|
+
|
|
1287
|
+
const sessions = await app.request(
|
|
1288
|
+
"/internal/usage/sessions?hours=24",
|
|
1289
|
+
{},
|
|
1290
|
+
env,
|
|
1291
|
+
);
|
|
1292
|
+
expect(sessions.status).toBe(200);
|
|
1293
|
+
const body = (await sessions.json()) as {
|
|
1294
|
+
sessions: Array<{ sessionId: string; lastPath: string | null }>;
|
|
1295
|
+
};
|
|
1296
|
+
expect(body.sessions.map((r) => r.sessionId)).toContain("pg-sess-1");
|
|
1297
|
+
});
|
|
1225
1298
|
});
|
package/src/lib/stage6.test.ts
CHANGED
|
@@ -201,7 +201,7 @@ describe("AlertsStore.listEvaluations evidence references", () => {
|
|
|
201
201
|
evidenceId: "alert:rule-1:rule",
|
|
202
202
|
entityKind: "alert",
|
|
203
203
|
entityId: "rule-1",
|
|
204
|
-
route: "#/alerts?
|
|
204
|
+
route: "#/alerts?id=rule-1",
|
|
205
205
|
source: "alerts.rule_evaluation_preview",
|
|
206
206
|
}),
|
|
207
207
|
expect.objectContaining({
|
package/src/lib/usage-store.ts
CHANGED
|
@@ -503,15 +503,21 @@ export class UsageStore {
|
|
|
503
503
|
SUM(CASE WHEN event_type = 'interaction' THEN 1 ELSE 0 END) AS interaction_count,
|
|
504
504
|
SUM(CASE WHEN event_type = 'frontend_error' OR severity = 'error' THEN 1 ELSE 0 END) AS error_count,
|
|
505
505
|
MAX(CASE WHEN event_type = 'page_view' THEN CAST(COALESCE(${loadMsExpr}, ${loadTimeMsExpr}, ${durationMsExpr}) AS REAL) ELSE NULL END) AS max_load_ms,
|
|
506
|
-
(SELECT referrer FROM usage_events ref WHERE ref.project_id =
|
|
507
|
-
(SELECT page_path FROM usage_events lp WHERE lp.project_id =
|
|
506
|
+
(SELECT referrer FROM usage_events ref WHERE ref.project_id = ? AND ref.session_id = ue.session_id ORDER BY occurred_at ASC LIMIT 1) AS referrer,
|
|
507
|
+
(SELECT page_path FROM usage_events lp WHERE lp.project_id = ? AND lp.session_id = ue.session_id ORDER BY occurred_at DESC LIMIT 1) AS last_path
|
|
508
508
|
FROM usage_events ue
|
|
509
509
|
WHERE project_id = ? AND received_at >= ?
|
|
510
510
|
GROUP BY session_id
|
|
511
511
|
ORDER BY last_seen DESC
|
|
512
512
|
LIMIT ?`,
|
|
513
513
|
)
|
|
514
|
-
.bind(
|
|
514
|
+
.bind(
|
|
515
|
+
options.projectId,
|
|
516
|
+
options.projectId,
|
|
517
|
+
options.projectId,
|
|
518
|
+
cutoff,
|
|
519
|
+
limit * 3,
|
|
520
|
+
)
|
|
515
521
|
.all<{
|
|
516
522
|
session_id: string;
|
|
517
523
|
visitor_id: string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Persisted grant-handoff callback (RFC 0013 § 6.B, journey review J17).
|
|
2
|
+
--
|
|
3
|
+
-- A proposal parked for human approval may carry a webhook that receives the
|
|
4
|
+
-- one-time grant token when the approval happens later. `callback_url` is the
|
|
5
|
+
-- target (SSRF-guarded again at delivery time); `callback_secret` is the HMAC
|
|
6
|
+
-- signing secret, stored as supplied (the collector has no at-rest encryption
|
|
7
|
+
-- helper) and never returned by any GET. Both are cleared once the grant is
|
|
8
|
+
-- delivered, rejected or expired.
|
|
9
|
+
ALTER TABLE remediation_proposals ADD COLUMN callback_url TEXT;
|
|
10
|
+
ALTER TABLE remediation_proposals ADD COLUMN callback_secret TEXT;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Last grant-handoff webhook delivery outcome (RFC 0013 § 6.B).
|
|
2
|
+
--
|
|
3
|
+
-- JSON `{status: 'delivered'|'failed', attempts, lastError, at}` recorded after
|
|
4
|
+
-- the grant webhook is attempted (up to 3 attempts). It never contains the
|
|
5
|
+
-- callback URL or secret, so unlike callback_url it is kept after approval so
|
|
6
|
+
-- the dashboard can flag a failed handoff.
|
|
7
|
+
ALTER TABLE remediation_proposals ADD COLUMN callback_delivery TEXT;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { readdirSync, readFileSync } from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
+
import { DatabaseSync } from "node:sqlite";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { describe, expect, it } from "vitest";
|
|
13
14
|
|
|
@@ -34,13 +35,74 @@ const RETENTION_TABLES = [
|
|
|
34
35
|
"remediation_proposals",
|
|
35
36
|
];
|
|
36
37
|
|
|
38
|
+
export function checkMigrationFilePairings(
|
|
39
|
+
sqliteFiles: readonly string[],
|
|
40
|
+
postgresFiles: readonly string[],
|
|
41
|
+
): string[] {
|
|
42
|
+
const errors: string[] = [];
|
|
43
|
+
const sqliteSet = new Set(sqliteFiles);
|
|
44
|
+
const pgSet = new Set(postgresFiles);
|
|
45
|
+
for (const f of sqliteFiles) {
|
|
46
|
+
if (!pgSet.has(f)) {
|
|
47
|
+
errors.push(`Missing Postgres migration counterpart for ${f}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const f of postgresFiles) {
|
|
51
|
+
if (!sqliteSet.has(f)) {
|
|
52
|
+
errors.push(`Missing SQLite migration counterpart for ${f}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const seenPrefixes = new Set<string>();
|
|
56
|
+
for (const f of sqliteFiles) {
|
|
57
|
+
const prefix = f.slice(0, 3);
|
|
58
|
+
if (seenPrefixes.has(prefix)) {
|
|
59
|
+
errors.push(`Duplicate migration prefix ${prefix} in ${f}`);
|
|
60
|
+
}
|
|
61
|
+
seenPrefixes.add(prefix);
|
|
62
|
+
}
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const extractCreatedTables = (sql: string): string[] => {
|
|
67
|
+
const names: string[] = [];
|
|
68
|
+
for (const m of sql.matchAll(
|
|
69
|
+
/CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+([a-zA-Z0-9_]+)/gi,
|
|
70
|
+
)) {
|
|
71
|
+
const table = m[1].toLowerCase();
|
|
72
|
+
if (!table.endsWith("_new")) {
|
|
73
|
+
names.push(table);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return [...new Set(names)].sort();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const extractAddedColumns = (sql: string): string[] => {
|
|
80
|
+
const cols: string[] = [];
|
|
81
|
+
for (const m of sql.matchAll(
|
|
82
|
+
/ALTER\s+TABLE\s+([a-zA-Z0-9_]+)\s+ADD\s+COLUMN(?:\s+IF\s+NOT\s+EXISTS)?\s+([a-zA-Z0-9_]+)/gi,
|
|
83
|
+
)) {
|
|
84
|
+
cols.push(`${m[1].toLowerCase()}.${m[2].toLowerCase()}`);
|
|
85
|
+
}
|
|
86
|
+
return [...new Set(cols)].sort();
|
|
87
|
+
};
|
|
88
|
+
|
|
37
89
|
describe("migration parity", () => {
|
|
38
|
-
it("has the same numbered files on SQLite/D1 and Postgres", () => {
|
|
90
|
+
it("has the same numbered files on SQLite/D1 and Postgres and catches missing pairs", () => {
|
|
39
91
|
const sqlite = sqlFiles(sqliteDir);
|
|
40
92
|
const postgres = sqlFiles(postgresDir);
|
|
41
93
|
expect(postgres).toEqual(sqlite);
|
|
42
|
-
|
|
43
|
-
expect(
|
|
94
|
+
expect(sqlite.length).toBeGreaterThanOrEqual(53);
|
|
95
|
+
expect(checkMigrationFilePairings(sqlite, postgres)).toEqual([]);
|
|
96
|
+
|
|
97
|
+
// Regression check: missing or duplicate migration files must be flagged.
|
|
98
|
+
expect(
|
|
99
|
+
checkMigrationFilePairings([...sqlite, "099_new_feature.sql"], postgres),
|
|
100
|
+
).toEqual([
|
|
101
|
+
"Missing Postgres migration counterpart for 099_new_feature.sql",
|
|
102
|
+
]);
|
|
103
|
+
expect(
|
|
104
|
+
checkMigrationFilePairings(sqlite, [...postgres, "099_pg_only.sql"]),
|
|
105
|
+
).toEqual(["Missing SQLite migration counterpart for 099_pg_only.sql"]);
|
|
44
106
|
});
|
|
45
107
|
|
|
46
108
|
it("declares retention timestamps as TEXT in every Postgres CREATE TABLE", () => {
|
|
@@ -65,4 +127,44 @@ describe("migration parity", () => {
|
|
|
65
127
|
}
|
|
66
128
|
expect(offenders).toEqual([]);
|
|
67
129
|
});
|
|
130
|
+
|
|
131
|
+
it("creates and alters the same tables and columns per migration file", () => {
|
|
132
|
+
const mismatches: string[] = [];
|
|
133
|
+
for (const file of sqlFiles(sqliteDir)) {
|
|
134
|
+
const sqliteSql = readFileSync(path.join(sqliteDir, file), "utf8");
|
|
135
|
+
const pgSql = readFileSync(path.join(postgresDir, file), "utf8");
|
|
136
|
+
const sTables = extractCreatedTables(sqliteSql);
|
|
137
|
+
const pTables = extractCreatedTables(pgSql);
|
|
138
|
+
if (JSON.stringify(sTables) !== JSON.stringify(pTables)) {
|
|
139
|
+
mismatches.push(
|
|
140
|
+
`${file} CREATE TABLE mismatch: sqlite=${JSON.stringify(sTables)} pg=${JSON.stringify(pTables)}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const sCols = extractAddedColumns(sqliteSql);
|
|
144
|
+
const pCols = extractAddedColumns(pgSql);
|
|
145
|
+
if (JSON.stringify(sCols) !== JSON.stringify(pCols)) {
|
|
146
|
+
mismatches.push(
|
|
147
|
+
`${file} ALTER TABLE ADD COLUMN mismatch: sqlite=${JSON.stringify(sCols)} pg=${JSON.stringify(pCols)}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
expect(mismatches).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("applies all SQLite migrations cleanly in-memory without leftover staging tables", () => {
|
|
155
|
+
const db = new DatabaseSync(":memory:");
|
|
156
|
+
try {
|
|
157
|
+
for (const file of sqlFiles(sqliteDir)) {
|
|
158
|
+
db.exec(readFileSync(path.join(sqliteDir, file), "utf8"));
|
|
159
|
+
}
|
|
160
|
+
const leftover = db
|
|
161
|
+
.prepare(
|
|
162
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%_new'",
|
|
163
|
+
)
|
|
164
|
+
.all();
|
|
165
|
+
expect(leftover).toEqual([]);
|
|
166
|
+
} finally {
|
|
167
|
+
db.close();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
68
170
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- (PostgreSQL mirror) Persisted grant-handoff callback (RFC 0013 § 6.B, journey review J17).
|
|
2
|
+
--
|
|
3
|
+
-- A proposal parked for human approval may carry a webhook that receives the
|
|
4
|
+
-- one-time grant token when the approval happens later. `callback_url` is the
|
|
5
|
+
-- target (SSRF-guarded again at delivery time); `callback_secret` is the HMAC
|
|
6
|
+
-- signing secret, stored as supplied (the collector has no at-rest encryption
|
|
7
|
+
-- helper) and never returned by any GET. Both are cleared once the grant is
|
|
8
|
+
-- delivered, rejected or expired.
|
|
9
|
+
ALTER TABLE remediation_proposals ADD COLUMN IF NOT EXISTS callback_url TEXT;
|
|
10
|
+
ALTER TABLE remediation_proposals ADD COLUMN IF NOT EXISTS callback_secret TEXT;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- (PostgreSQL mirror) Last grant-handoff webhook delivery outcome (RFC 0013 § 6.B).
|
|
2
|
+
--
|
|
3
|
+
-- JSON `{status: 'delivered'|'failed', attempts, lastError, at}` recorded after
|
|
4
|
+
-- the grant webhook is attempted (up to 3 attempts). It never contains the
|
|
5
|
+
-- callback URL or secret, so unlike callback_url it is kept after approval so
|
|
6
|
+
-- the dashboard can flag a failed handoff.
|
|
7
|
+
ALTER TABLE remediation_proposals ADD COLUMN IF NOT EXISTS callback_delivery TEXT;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createTelemetryCollectorApp } from "../framework/collector";
|
|
4
|
+
import type { CollectorEnv } from "../framework/env";
|
|
5
|
+
import { MemSqlDb } from "../lib/test-utils/mem-sql-db";
|
|
6
|
+
import { aiReceiverPlugin } from "./ai-receiver";
|
|
7
|
+
|
|
8
|
+
const passthrough: MiddlewareHandler<{
|
|
9
|
+
Bindings: CollectorEnv;
|
|
10
|
+
Variables: { projectId: string };
|
|
11
|
+
}> = async (c, next) => {
|
|
12
|
+
c.set("projectId", "default");
|
|
13
|
+
await next();
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const app = createTelemetryCollectorApp({
|
|
17
|
+
plugins: [aiReceiverPlugin],
|
|
18
|
+
auth: { middleware: passthrough },
|
|
19
|
+
dashboardAuth: { middleware: passthrough, registerRoutes: () => {} },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const row = {
|
|
23
|
+
project_id: "default",
|
|
24
|
+
call_id: "call-1",
|
|
25
|
+
trace_id: "trace-1",
|
|
26
|
+
span_id: "span-1",
|
|
27
|
+
service_name: "svc",
|
|
28
|
+
model_name: "gpt-4.1",
|
|
29
|
+
provider: "openai",
|
|
30
|
+
call_type: "chat",
|
|
31
|
+
request_json: null,
|
|
32
|
+
response_json: null,
|
|
33
|
+
prompt_tokens: 10,
|
|
34
|
+
completion_tokens: 5,
|
|
35
|
+
total_cost_usd: 0.01,
|
|
36
|
+
latency_ms: 120,
|
|
37
|
+
is_error: 0,
|
|
38
|
+
error_message: null,
|
|
39
|
+
occurred_at: "2026-05-01T00:00:00.000Z",
|
|
40
|
+
received_at: "2026-05-01T00:00:01.000Z",
|
|
41
|
+
expires_at: "2026-05-02T00:00:00.000Z",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe("GET /internal/ai/calls/:callId", () => {
|
|
45
|
+
it("returns the call with its trace and span ids", async () => {
|
|
46
|
+
const db = new MemSqlDb({
|
|
47
|
+
first: (sql, binds) =>
|
|
48
|
+
sql.includes("FROM ai_calls") && binds[1] === "call-1" ? row : null,
|
|
49
|
+
});
|
|
50
|
+
const res = await app.request("/internal/ai/calls/call-1", {}, {
|
|
51
|
+
DB: db,
|
|
52
|
+
} as unknown as CollectorEnv);
|
|
53
|
+
expect(res.status).toBe(200);
|
|
54
|
+
const body = (await res.json()) as {
|
|
55
|
+
call: { callId: string; modelName: string };
|
|
56
|
+
traceId: string;
|
|
57
|
+
spanId: string;
|
|
58
|
+
};
|
|
59
|
+
expect(body.call.callId).toBe("call-1");
|
|
60
|
+
expect(body.call.modelName).toBe("gpt-4.1");
|
|
61
|
+
expect(body.traceId).toBe("trace-1");
|
|
62
|
+
expect(body.spanId).toBe("span-1");
|
|
63
|
+
expect(db.calls[0].binds).toEqual(["default", "call-1"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("answers 404 for an unknown call id", async () => {
|
|
67
|
+
const db = new MemSqlDb({ first: () => null });
|
|
68
|
+
const res = await app.request("/internal/ai/calls/nope", {}, {
|
|
69
|
+
DB: db,
|
|
70
|
+
} as unknown as CollectorEnv);
|
|
71
|
+
expect(res.status).toBe(404);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -16,6 +16,7 @@ import { AIStore, type IngestEvaluation } from "../lib/ai-store";
|
|
|
16
16
|
import { sqlDbFor } from "../lib/sql-db";
|
|
17
17
|
import { getProjectId } from "./_context";
|
|
18
18
|
import { readJsonObject } from "./_json";
|
|
19
|
+
import { respondWithContract } from "./autosre-routes";
|
|
19
20
|
|
|
20
21
|
export const aiReceiverPlugin: CollectorPlugin = {
|
|
21
22
|
name: "ai-receiver",
|
|
@@ -97,6 +98,20 @@ export const aiReceiverPlugin: CollectorPlugin = {
|
|
|
97
98
|
return c.json(response);
|
|
98
99
|
});
|
|
99
100
|
|
|
101
|
+
// One AI call by its call id, with the trace/span it belongs to. Backs
|
|
102
|
+
// the `#/ai/calls/<id>` deep link when the call is outside the loaded list.
|
|
103
|
+
app.get("/internal/ai/calls/:callId", async (c) => {
|
|
104
|
+
const projectId = getProjectId(c);
|
|
105
|
+
const store = new AIStore(sqlDbFor(c.env));
|
|
106
|
+
const call = await store.getAICall(projectId, c.req.param("callId"));
|
|
107
|
+
if (!call) return c.json({ error: "AI call not found" }, 404);
|
|
108
|
+
return respondWithContract(c, "GET /internal/ai/calls/:callId", {
|
|
109
|
+
call,
|
|
110
|
+
traceId: call.traceId,
|
|
111
|
+
spanId: call.spanId,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
100
115
|
// OpenInference-kind spans (LLM/TOOL/RETRIEVER/CHAIN/AGENT/...) joined
|
|
101
116
|
// with their payload side-table. This is the replacement for the flat
|
|
102
117
|
// ai_calls endpoint — it returns typed spans that live inside the
|