@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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-SQLite regression for the D1 time-window bug: every timestamp column
|
|
3
|
+
* stores `Date#toISOString()` text (`2026-10-13T05:04:53.123Z`), but the
|
|
4
|
+
* dialect and the shared analysis SQL used `datetime('now', …)`, which
|
|
5
|
+
* renders `2026-10-13 05:04:53`. In a text comparison the space sorts before
|
|
6
|
+
* `T`, so "last 5 minutes" matched the whole current UTC day and
|
|
7
|
+
* `expires_at < now` purges ran a day late. The expressions now render ISO
|
|
8
|
+
* text via `strftime('%Y-%m-%dT%H:%M:%fZ', …)`; these tests execute them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import type { StoredSpan } from "@obsunified/types";
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
16
|
+
import { deriveAnalysesForProject } from "../analyses/derive";
|
|
17
|
+
import { INVESTIGATION_ANALYSES } from "../analyses/investigations";
|
|
18
|
+
import { TIER0_ANALYSES } from "../analyses/tier0";
|
|
19
|
+
import { purgeExpiredAIData } from "./ai-store/retention";
|
|
20
|
+
import { runSqlAnalysis } from "./analyses-runner";
|
|
21
|
+
import { SQLITE_ISO_FORMAT, sqliteDialect, sqliteIsoNow } from "./sql-db";
|
|
22
|
+
import { ingestTelemetrySpans } from "./store/ingest";
|
|
23
|
+
import {
|
|
24
|
+
createMigratedSqliteDb,
|
|
25
|
+
type NodeSqliteDb,
|
|
26
|
+
} from "./test-utils/node-sqlite-db";
|
|
27
|
+
|
|
28
|
+
const ISO_TEXT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
29
|
+
const PROJECT = "alpha";
|
|
30
|
+
|
|
31
|
+
const minutesAgo = (minutes: number): string =>
|
|
32
|
+
new Date(Date.now() - minutes * 60_000).toISOString();
|
|
33
|
+
|
|
34
|
+
const span = (over: Partial<StoredSpan>): StoredSpan => ({
|
|
35
|
+
projectId: PROJECT,
|
|
36
|
+
traceId: "trace-1",
|
|
37
|
+
spanId: "span-1",
|
|
38
|
+
parentSpanId: null,
|
|
39
|
+
traceState: null,
|
|
40
|
+
serviceName: "checkout",
|
|
41
|
+
scopeName: null,
|
|
42
|
+
scopeVersion: null,
|
|
43
|
+
spanName: "GET /",
|
|
44
|
+
spanKind: 2,
|
|
45
|
+
statusCode: 1,
|
|
46
|
+
statusMessage: null,
|
|
47
|
+
startTime: minutesAgo(1),
|
|
48
|
+
endTime: minutesAgo(1),
|
|
49
|
+
durationMs: 12,
|
|
50
|
+
attributesJson: "{}",
|
|
51
|
+
droppedAttributesCount: 0,
|
|
52
|
+
resourceAttributesJson: "{}",
|
|
53
|
+
eventsJson: "[]",
|
|
54
|
+
droppedEventsCount: 0,
|
|
55
|
+
linksJson: "[]",
|
|
56
|
+
droppedLinksCount: 0,
|
|
57
|
+
receivedAt: minutesAgo(1),
|
|
58
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
59
|
+
sessionId: null,
|
|
60
|
+
interactionId: null,
|
|
61
|
+
...over,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("sqliteDialect time windows against ISO text columns", () => {
|
|
65
|
+
let db: NodeSqliteDb;
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
db = createMigratedSqliteDb();
|
|
69
|
+
await ingestTelemetrySpans(db, [
|
|
70
|
+
// inside the 5-minute window, and an error
|
|
71
|
+
span({
|
|
72
|
+
spanId: "recent-error",
|
|
73
|
+
statusCode: 2,
|
|
74
|
+
receivedAt: minutesAgo(2),
|
|
75
|
+
}),
|
|
76
|
+
// inside the 1-hour baseline only
|
|
77
|
+
span({ spanId: "base-1", receivedAt: minutesAgo(30) }),
|
|
78
|
+
span({ spanId: "base-2", receivedAt: minutesAgo(30) }),
|
|
79
|
+
span({ spanId: "base-3", receivedAt: minutesAgo(30) }),
|
|
80
|
+
// outside both windows, but still "today" in UTC for most of the day
|
|
81
|
+
span({ spanId: "old", receivedAt: minutesAgo(120) }),
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(() => db.close());
|
|
86
|
+
|
|
87
|
+
it("renders now() and relative windows in the shape Date#toISOString writes", async () => {
|
|
88
|
+
const row = await db
|
|
89
|
+
.prepare(
|
|
90
|
+
`SELECT ${sqliteDialect.now()} AS now_iso,
|
|
91
|
+
${sqliteDialect.sinceMinutes("?")} AS minutes_iso,
|
|
92
|
+
${sqliteDialect.sinceHours("?")} AS hours_iso`,
|
|
93
|
+
)
|
|
94
|
+
.bind(5, 1)
|
|
95
|
+
.first<{ now_iso: string; minutes_iso: string; hours_iso: string }>();
|
|
96
|
+
expect(row?.now_iso).toMatch(ISO_TEXT);
|
|
97
|
+
expect(row?.minutes_iso).toMatch(ISO_TEXT);
|
|
98
|
+
expect(row?.hours_iso).toMatch(ISO_TEXT);
|
|
99
|
+
const now = Date.parse(row?.now_iso ?? "");
|
|
100
|
+
expect(Math.abs(now - Date.now())).toBeLessThan(5_000);
|
|
101
|
+
expect(now - Date.parse(row?.minutes_iso ?? "")).toBe(5 * 60_000);
|
|
102
|
+
expect(now - Date.parse(row?.hours_iso ?? "")).toBe(60 * 60_000);
|
|
103
|
+
expect(sqliteIsoNow()).toBe(`strftime('${SQLITE_ISO_FORMAT}', 'now')`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("sinceMinutes / sinceHours select only rows inside the window", async () => {
|
|
107
|
+
const count = async (where: string, ...binds: unknown[]) => {
|
|
108
|
+
const row = await db
|
|
109
|
+
.prepare(
|
|
110
|
+
`SELECT COUNT(*) AS n FROM telemetry_spans WHERE project_id = ? AND ${where}`,
|
|
111
|
+
)
|
|
112
|
+
.bind(PROJECT, ...binds)
|
|
113
|
+
.first<{ n: number }>();
|
|
114
|
+
return row?.n ?? -1;
|
|
115
|
+
};
|
|
116
|
+
expect(
|
|
117
|
+
await count(`received_at >= ${sqliteDialect.sinceMinutes("?")}`, 5),
|
|
118
|
+
).toBe(1);
|
|
119
|
+
expect(
|
|
120
|
+
await count(`received_at >= ${sqliteDialect.sinceHours("?")}`, 1),
|
|
121
|
+
).toBe(4);
|
|
122
|
+
expect(
|
|
123
|
+
await count(
|
|
124
|
+
`received_at >= ${sqliteDialect.sinceHours("?")} AND received_at < ${sqliteDialect.sinceMinutes("?")}`,
|
|
125
|
+
1,
|
|
126
|
+
5,
|
|
127
|
+
),
|
|
128
|
+
).toBe(3);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("purges rows whose ISO expires_at has passed and keeps the rest", async () => {
|
|
132
|
+
const insert = db.prepare(
|
|
133
|
+
`INSERT INTO ai_span_payloads (project_id, trace_id, span_id, span_kind, received_at, expires_at)
|
|
134
|
+
VALUES (?, ?, ?, 'LLM', ?, ?)`,
|
|
135
|
+
);
|
|
136
|
+
await db.batch([
|
|
137
|
+
insert.bind(PROJECT, "t", "expired", minutesAgo(120), minutesAgo(1)),
|
|
138
|
+
insert.bind(
|
|
139
|
+
PROJECT,
|
|
140
|
+
"t",
|
|
141
|
+
"live",
|
|
142
|
+
minutesAgo(1),
|
|
143
|
+
new Date(Date.now() + 60_000).toISOString(),
|
|
144
|
+
),
|
|
145
|
+
]);
|
|
146
|
+
await purgeExpiredAIData(db);
|
|
147
|
+
const rows = await db
|
|
148
|
+
.prepare(`SELECT span_id FROM ai_span_payloads ORDER BY span_id`)
|
|
149
|
+
.all<{ span_id: string }>();
|
|
150
|
+
expect(rows.results.map((r) => r.span_id)).toEqual(["live"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("overall_error_rate sees only the last 5 minutes as the primary window", async () => {
|
|
154
|
+
const def = TIER0_ANALYSES.find((d) => d.id === "overall_error_rate");
|
|
155
|
+
if (!def) throw new Error("overall_error_rate analysis missing");
|
|
156
|
+
const result = await runSqlAnalysis(def, {
|
|
157
|
+
db,
|
|
158
|
+
projectId: PROJECT,
|
|
159
|
+
retentionHours: 24,
|
|
160
|
+
});
|
|
161
|
+
// 1 span in the last 5 minutes and it is an error.
|
|
162
|
+
expect(result.primaryValue).toBe(1);
|
|
163
|
+
// 3 ok spans in [-1h, -5m); the 2-hour-old span is outside both.
|
|
164
|
+
expect(result.baselineValue).toBe(0);
|
|
165
|
+
expect(result.status).toBe("critical");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("every built-in and derived analysis executes on SQLite", async () => {
|
|
169
|
+
const derived = await deriveAnalysesForProject(PROJECT, db);
|
|
170
|
+
const defs = [...TIER0_ANALYSES, ...INVESTIGATION_ANALYSES, ...derived];
|
|
171
|
+
expect(defs.length).toBeGreaterThan(TIER0_ANALYSES.length);
|
|
172
|
+
for (const def of defs) {
|
|
173
|
+
if (!def.sql) continue;
|
|
174
|
+
const result = await runSqlAnalysis(def, {
|
|
175
|
+
db,
|
|
176
|
+
projectId: PROJECT,
|
|
177
|
+
retentionHours: 24,
|
|
178
|
+
});
|
|
179
|
+
expect(result.analysisId, def.id).toBe(def.id);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("ships no datetime('now') windows in the analysis sources", () => {
|
|
184
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
185
|
+
const analysesDir = path.resolve(here, "../analyses");
|
|
186
|
+
const offenders: string[] = [];
|
|
187
|
+
for (const file of readdirSync(analysesDir)) {
|
|
188
|
+
if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue;
|
|
189
|
+
const source = readFileSync(path.join(analysesDir, file), "utf8");
|
|
190
|
+
if (/datetime\(\s*'now'/.test(source)) offenders.push(file);
|
|
191
|
+
}
|
|
192
|
+
expect(offenders).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
});
|
package/src/lib/sql-db.ts
CHANGED
|
@@ -96,21 +96,55 @@ export class D1Adapter implements SqlDb {
|
|
|
96
96
|
export const sqlDbFor = (env: { DB: D1Database | SqlDb }): SqlDb =>
|
|
97
97
|
isSqlDb(env.DB) ? env.DB : new D1Adapter(env.DB);
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* SQLite `strftime` format that reproduces `Date#toISOString()` output
|
|
101
|
+
* (`YYYY-MM-DDTHH:MM:SS.mmmZ`). Every stored timestamp column holds that
|
|
102
|
+
* shape, so time windows must be rendered the same way: `datetime('now')`
|
|
103
|
+
* yields `YYYY-MM-DD HH:MM:SS` and in a text comparison the space sorts
|
|
104
|
+
* before the `T`, which made "last 5 minutes" match the whole current UTC
|
|
105
|
+
* day on D1 and made `expires_at < now` purges run a day late.
|
|
106
|
+
*/
|
|
107
|
+
export const SQLITE_ISO_FORMAT = "%Y-%m-%dT%H:%M:%fZ";
|
|
108
|
+
|
|
109
|
+
/** Render SQLite `now` (optionally shifted by a modifier expression) as ISO text. */
|
|
110
|
+
export const sqliteIsoNow = (modifier?: string): string =>
|
|
111
|
+
modifier
|
|
112
|
+
? `strftime('${SQLITE_ISO_FORMAT}', 'now', ${modifier})`
|
|
113
|
+
: `strftime('${SQLITE_ISO_FORMAT}', 'now')`;
|
|
114
|
+
|
|
99
115
|
export const sqliteDialect: SqlDialect = {
|
|
100
116
|
name: "sqlite",
|
|
101
|
-
now: () =>
|
|
102
|
-
sinceHours: (param) => `
|
|
103
|
-
sinceMinutes: (param) => `
|
|
117
|
+
now: () => sqliteIsoNow(),
|
|
118
|
+
sinceHours: (param) => sqliteIsoNow(`'-' || ${param} || ' hours'`),
|
|
119
|
+
sinceMinutes: (param) => sqliteIsoNow(`'-' || ${param} || ' minutes'`),
|
|
104
120
|
jsonText: (expr, path) => `json_extract(${expr}, '${path}')`,
|
|
105
121
|
};
|
|
106
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The collector stores every timestamp column (`received_at`, `expires_at`,
|
|
125
|
+
* `occurred_at`, …) as TEXT holding `Date#toISOString()` output on both
|
|
126
|
+
* D1 and Postgres. Postgres will not compare TEXT against a
|
|
127
|
+
* `timestamptz`, so time-window expressions have to be rendered as ISO
|
|
128
|
+
* text in the same `YYYY-MM-DDTHH:MM:SS.mmmZ` shape the collector writes.
|
|
129
|
+
*/
|
|
130
|
+
const POSTGRES_ISO_TEXT_FORMAT = `'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'`;
|
|
131
|
+
const POSTGRES_UTC_NOW = "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')";
|
|
132
|
+
|
|
133
|
+
/** Render a Postgres `timestamp` expression as collector-style ISO text. */
|
|
134
|
+
export const postgresIsoText = (expr: string): string =>
|
|
135
|
+
`to_char(${expr}, ${POSTGRES_ISO_TEXT_FORMAT})`;
|
|
136
|
+
|
|
107
137
|
export const postgresDialect: SqlDialect = {
|
|
108
138
|
name: "postgres",
|
|
109
|
-
now: () =>
|
|
139
|
+
now: () => postgresIsoText(POSTGRES_UTC_NOW),
|
|
110
140
|
sinceHours: (param) =>
|
|
111
|
-
|
|
141
|
+
postgresIsoText(
|
|
142
|
+
`${POSTGRES_UTC_NOW} - (${param}::text || ' hours')::interval`,
|
|
143
|
+
),
|
|
112
144
|
sinceMinutes: (param) =>
|
|
113
|
-
|
|
145
|
+
postgresIsoText(
|
|
146
|
+
`${POSTGRES_UTC_NOW} - (${param}::text || ' minutes')::interval`,
|
|
147
|
+
),
|
|
114
148
|
jsonText: (expr, path) => {
|
|
115
149
|
const key = path
|
|
116
150
|
.replace(/^\$\./, "")
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builders for RFC 0010 action-graph rows used by the retention tests on
|
|
3
|
+
* both SQLite (`action-retention.test.ts`) and live Postgres
|
|
4
|
+
* (`sql-db-postgres.integration.test.ts`). Everything is written through
|
|
5
|
+
* `ActionStore`, i.e. the same SQL the ingest path uses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type ActionRecord,
|
|
10
|
+
ActionStore,
|
|
11
|
+
type AgentRunRecord,
|
|
12
|
+
type ArtifactRecord,
|
|
13
|
+
type EvalResultRecord,
|
|
14
|
+
type RetrievalEventRecord,
|
|
15
|
+
type ToolCallRecord,
|
|
16
|
+
} from "../actions-store";
|
|
17
|
+
import type { SqlDb } from "../sql-db";
|
|
18
|
+
|
|
19
|
+
/** Fixed clock for deterministic cutoffs: 2026-10-12T12:00:00Z. */
|
|
20
|
+
export const FIXTURE_NOW_MS = Date.UTC(2026, 9, 12, 12, 0, 0);
|
|
21
|
+
export const FIXTURE_RETENTION_HOURS = 72;
|
|
22
|
+
|
|
23
|
+
/** ISO timestamp `hours` before `FIXTURE_NOW_MS`. */
|
|
24
|
+
export const hoursAgo = (hours: number): string =>
|
|
25
|
+
new Date(FIXTURE_NOW_MS - hours * 3_600_000).toISOString();
|
|
26
|
+
|
|
27
|
+
export const EXPIRED_START = hoursAgo(100);
|
|
28
|
+
export const FRESH_START = hoursAgo(1);
|
|
29
|
+
|
|
30
|
+
export const actionRecord = (
|
|
31
|
+
over: Partial<ActionRecord> = {},
|
|
32
|
+
): ActionRecord => {
|
|
33
|
+
const id = over.id ?? "act-1";
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
projectId: "alpha",
|
|
37
|
+
rootActionId: id,
|
|
38
|
+
causedByActionId: null,
|
|
39
|
+
actorType: "agent",
|
|
40
|
+
actorId: "agent-1",
|
|
41
|
+
actionKind: "agent.run",
|
|
42
|
+
name: "run",
|
|
43
|
+
status: "ok",
|
|
44
|
+
startedAt: EXPIRED_START,
|
|
45
|
+
endedAt: EXPIRED_START,
|
|
46
|
+
durationMs: 10,
|
|
47
|
+
traceId: null,
|
|
48
|
+
spanId: null,
|
|
49
|
+
sessionId: null,
|
|
50
|
+
interactionId: null,
|
|
51
|
+
userId: null,
|
|
52
|
+
agentRunId: null,
|
|
53
|
+
stepId: null,
|
|
54
|
+
toolCallId: null,
|
|
55
|
+
promptVersion: null,
|
|
56
|
+
modelName: null,
|
|
57
|
+
provider: null,
|
|
58
|
+
totalCostUsd: null,
|
|
59
|
+
causalConfidence: "explicit",
|
|
60
|
+
attrsJson: null,
|
|
61
|
+
...over,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const agentRunRecord = (
|
|
66
|
+
over: Partial<AgentRunRecord> = {},
|
|
67
|
+
): AgentRunRecord => ({
|
|
68
|
+
id: "act-1",
|
|
69
|
+
projectId: "alpha",
|
|
70
|
+
agentId: "agent-1",
|
|
71
|
+
agentName: "Agent",
|
|
72
|
+
agentVersion: "1",
|
|
73
|
+
goal: null,
|
|
74
|
+
outcome: null,
|
|
75
|
+
autonomyLevel: "autonomous_write",
|
|
76
|
+
status: "success",
|
|
77
|
+
errorMessage: null,
|
|
78
|
+
totalCostUsd: 0,
|
|
79
|
+
totalDurationMs: 10,
|
|
80
|
+
metadataJson: null,
|
|
81
|
+
...over,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const toolCallRecord = (
|
|
85
|
+
over: Partial<ToolCallRecord> = {},
|
|
86
|
+
): ToolCallRecord => ({
|
|
87
|
+
id: "tc-1",
|
|
88
|
+
actionId: "act-1",
|
|
89
|
+
projectId: "alpha",
|
|
90
|
+
toolName: "sql_query",
|
|
91
|
+
argsHash: "a",
|
|
92
|
+
resultHash: "r",
|
|
93
|
+
errorType: null,
|
|
94
|
+
sideEffect: 0,
|
|
95
|
+
approvalState: null,
|
|
96
|
+
argsRedacted: null,
|
|
97
|
+
resultRedacted: null,
|
|
98
|
+
mcpAuditJson: null,
|
|
99
|
+
mutationBeforeJson: null,
|
|
100
|
+
mutationAfterJson: null,
|
|
101
|
+
mutationDiffJson: null,
|
|
102
|
+
mutationArtifactId: null,
|
|
103
|
+
...over,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export const retrievalEventRecord = (
|
|
107
|
+
over: Partial<RetrievalEventRecord> = {},
|
|
108
|
+
): RetrievalEventRecord => ({
|
|
109
|
+
id: "re-1",
|
|
110
|
+
actionId: "act-1",
|
|
111
|
+
projectId: "alpha",
|
|
112
|
+
retrieverName: "vector-db",
|
|
113
|
+
queryHash: "q",
|
|
114
|
+
documentsJson: null,
|
|
115
|
+
totalResults: 0,
|
|
116
|
+
maxRelevanceScore: null,
|
|
117
|
+
durationMs: null,
|
|
118
|
+
...over,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export const evalResultRecord = (
|
|
122
|
+
over: Partial<EvalResultRecord> = {},
|
|
123
|
+
): EvalResultRecord => ({
|
|
124
|
+
id: "er-1",
|
|
125
|
+
actionId: "act-1",
|
|
126
|
+
projectId: "alpha",
|
|
127
|
+
evaluatorName: "groundedness",
|
|
128
|
+
evaluatorVersion: "1",
|
|
129
|
+
score: 1,
|
|
130
|
+
passed: 1,
|
|
131
|
+
reasoning: null,
|
|
132
|
+
rubricJson: null,
|
|
133
|
+
...over,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const artifactRecord = (
|
|
137
|
+
over: Partial<ArtifactRecord> = {},
|
|
138
|
+
): ArtifactRecord => ({
|
|
139
|
+
id: "af-1",
|
|
140
|
+
actionId: "act-1",
|
|
141
|
+
projectId: "alpha",
|
|
142
|
+
artifactName: "invoice-pdf",
|
|
143
|
+
artifactType: "file",
|
|
144
|
+
storageRef: "s3://bucket/invoice.pdf",
|
|
145
|
+
sizeBytes: 1,
|
|
146
|
+
sha256Hash: null,
|
|
147
|
+
contentPreview: null,
|
|
148
|
+
...over,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export interface SeedTreeOptions {
|
|
152
|
+
projectId?: string;
|
|
153
|
+
/** Root action id; also the `agent_runs.id`. */
|
|
154
|
+
rootId: string;
|
|
155
|
+
rootStartedAt?: string;
|
|
156
|
+
childStartedAt?: string;
|
|
157
|
+
/** `null` leaves the child in progress. */
|
|
158
|
+
childEndedAt?: string | null;
|
|
159
|
+
agentRunStatus?: string;
|
|
160
|
+
/** Attach one row of every leaf table to the child action. */
|
|
161
|
+
withLeaves?: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Seed a two-action tree (root `agent.run` + child `tool.call`) with an
|
|
166
|
+
* `agent_runs` row for the root and, optionally, one row in each leaf table
|
|
167
|
+
* hanging off the child. Returns the ids so tests can assert on them.
|
|
168
|
+
*/
|
|
169
|
+
export async function seedTree(db: SqlDb, options: SeedTreeOptions) {
|
|
170
|
+
const store = new ActionStore(db);
|
|
171
|
+
const projectId = options.projectId ?? "alpha";
|
|
172
|
+
const rootId = options.rootId;
|
|
173
|
+
const childId = `${rootId}-child`;
|
|
174
|
+
const rootStartedAt = options.rootStartedAt ?? EXPIRED_START;
|
|
175
|
+
const childStartedAt = options.childStartedAt ?? rootStartedAt;
|
|
176
|
+
const childEndedAt =
|
|
177
|
+
options.childEndedAt === undefined ? childStartedAt : options.childEndedAt;
|
|
178
|
+
|
|
179
|
+
await store.upsertAction(
|
|
180
|
+
actionRecord({
|
|
181
|
+
id: rootId,
|
|
182
|
+
projectId,
|
|
183
|
+
rootActionId: rootId,
|
|
184
|
+
agentRunId: rootId,
|
|
185
|
+
startedAt: rootStartedAt,
|
|
186
|
+
endedAt: rootStartedAt,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
await store.upsertAgentRun(
|
|
190
|
+
agentRunRecord({
|
|
191
|
+
id: rootId,
|
|
192
|
+
projectId,
|
|
193
|
+
status: options.agentRunStatus ?? "success",
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
await store.upsertAction(
|
|
197
|
+
actionRecord({
|
|
198
|
+
id: childId,
|
|
199
|
+
projectId,
|
|
200
|
+
rootActionId: rootId,
|
|
201
|
+
causedByActionId: rootId,
|
|
202
|
+
agentRunId: rootId,
|
|
203
|
+
actionKind: "tool.call",
|
|
204
|
+
startedAt: childStartedAt,
|
|
205
|
+
endedAt: childEndedAt,
|
|
206
|
+
toolCallId: `${childId}-tc`,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const leaves = {
|
|
211
|
+
toolCallId: `${childId}-tc`,
|
|
212
|
+
retrievalEventId: `${childId}-re`,
|
|
213
|
+
evalResultId: `${childId}-er`,
|
|
214
|
+
artifactId: `${childId}-af`,
|
|
215
|
+
};
|
|
216
|
+
if (options.withLeaves ?? true) {
|
|
217
|
+
await store.upsertArtifact(
|
|
218
|
+
artifactRecord({ id: leaves.artifactId, actionId: childId, projectId }),
|
|
219
|
+
);
|
|
220
|
+
await store.upsertToolCall(
|
|
221
|
+
toolCallRecord({
|
|
222
|
+
id: leaves.toolCallId,
|
|
223
|
+
actionId: childId,
|
|
224
|
+
projectId,
|
|
225
|
+
mutationArtifactId: leaves.artifactId,
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
await store.upsertRetrievalEvent(
|
|
229
|
+
retrievalEventRecord({
|
|
230
|
+
id: leaves.retrievalEventId,
|
|
231
|
+
actionId: childId,
|
|
232
|
+
projectId,
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
await store.upsertEvalResult(
|
|
236
|
+
evalResultRecord({
|
|
237
|
+
id: leaves.evalResultId,
|
|
238
|
+
actionId: childId,
|
|
239
|
+
projectId,
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { projectId, rootId, childId, ...leaves };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Minimal `eval_cases` row pointing at production entities. */
|
|
248
|
+
export async function seedEvalCase(
|
|
249
|
+
db: SqlDb,
|
|
250
|
+
over: {
|
|
251
|
+
id: string;
|
|
252
|
+
projectId?: string;
|
|
253
|
+
sourceActionId?: string | null;
|
|
254
|
+
sourceAgentRunId?: string | null;
|
|
255
|
+
sourceToolCallId?: string | null;
|
|
256
|
+
},
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
const now = new Date(FIXTURE_NOW_MS).toISOString();
|
|
259
|
+
await db
|
|
260
|
+
.prepare(
|
|
261
|
+
`INSERT INTO eval_cases (
|
|
262
|
+
id, project_id, source_entity_type, source_entity_id,
|
|
263
|
+
source_agent_run_id, source_action_id, source_tool_call_id,
|
|
264
|
+
name, metadata_json, created_at, updated_at
|
|
265
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
266
|
+
)
|
|
267
|
+
.bind(
|
|
268
|
+
over.id,
|
|
269
|
+
over.projectId ?? "alpha",
|
|
270
|
+
over.sourceActionId
|
|
271
|
+
? "action"
|
|
272
|
+
: over.sourceToolCallId
|
|
273
|
+
? "tool_call"
|
|
274
|
+
: "agent_run",
|
|
275
|
+
over.sourceActionId ??
|
|
276
|
+
over.sourceToolCallId ??
|
|
277
|
+
over.sourceAgentRunId ??
|
|
278
|
+
"unknown",
|
|
279
|
+
over.sourceAgentRunId ?? null,
|
|
280
|
+
over.sourceActionId ?? null,
|
|
281
|
+
over.sourceToolCallId ?? null,
|
|
282
|
+
`case ${over.id}`,
|
|
283
|
+
"{}",
|
|
284
|
+
now,
|
|
285
|
+
now,
|
|
286
|
+
)
|
|
287
|
+
.run();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Row counts across the whole action graph for a project. */
|
|
291
|
+
export async function countActionGraph(db: SqlDb, projectId: string) {
|
|
292
|
+
const count = async (table: string) => {
|
|
293
|
+
const row = await db
|
|
294
|
+
.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE project_id = ?`)
|
|
295
|
+
.bind(projectId)
|
|
296
|
+
.first<{ n: number | string | bigint }>();
|
|
297
|
+
return Number(row?.n ?? 0);
|
|
298
|
+
};
|
|
299
|
+
return {
|
|
300
|
+
actions: await count("actions"),
|
|
301
|
+
agent_runs: await count("agent_runs"),
|
|
302
|
+
tool_calls: await count("tool_calls"),
|
|
303
|
+
retrieval_events: await count("retrieval_events"),
|
|
304
|
+
eval_results: await count("eval_results"),
|
|
305
|
+
artifacts: await count("artifacts"),
|
|
306
|
+
};
|
|
307
|
+
}
|