@obsunified/collector 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
- package/dist/chunk-ZSF5XP3B.js.map +1 -0
- package/dist/index.d.ts +630 -27
- package/dist/index.js +5819 -853
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
- package/package.json +5 -5
- package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
- 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 +59 -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 +69 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/autosre-change-correlation.ts +780 -0
- package/src/lib/autosre-findings.ts +725 -0
- package/src/lib/autosre-ontology.ts +914 -0
- package/src/lib/autosre-remediation-gate.ts +1663 -0
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/eval-cases-store.ts +63 -1
- package/src/lib/evidence-references.ts +6 -0
- package/src/lib/housekeeping.test.ts +153 -0
- package/src/lib/housekeeping.ts +193 -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/store/ingest.ts +94 -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/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +68 -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/050_autosre_causal_ontology.sql +216 -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 +181 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/autosre-routes.ts +1019 -0
- package/src/plugins/connected-routes/manifest.ts +13 -1
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +285 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/default-span-enrichment.ts +13 -1
- package/src/plugins/eval-cases-routes.ts +65 -19
- package/src/plugins/evidence-retrieval-routes.ts +300 -4
- 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
- package/dist/chunk-XKQTVIRE.js.map +0 -1
- /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 049 — `alert_state.notified`, applied to the real SQLite/D1
|
|
3
|
+
* schema, plus the store's behaviour on a database that has not applied it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { AlertsStore } from "../lib/alerts-store";
|
|
8
|
+
import { ProjectsStore } from "../lib/projects-store";
|
|
9
|
+
import {
|
|
10
|
+
applySqliteMigrations,
|
|
11
|
+
createMigratedSqliteDb,
|
|
12
|
+
} from "../lib/test-utils/node-sqlite-db";
|
|
13
|
+
|
|
14
|
+
const ruleInput = {
|
|
15
|
+
name: "errors",
|
|
16
|
+
signal: "spans" as const,
|
|
17
|
+
query: {},
|
|
18
|
+
threshold: 1,
|
|
19
|
+
windowMins: 5,
|
|
20
|
+
comparison: ">" as const,
|
|
21
|
+
channels: [{ type: "webhook" as const, url: "https://hooks.example.com/a" }],
|
|
22
|
+
enabled: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe("migration 049 (alert_state.notified)", () => {
|
|
26
|
+
it("adds the column and backfills existing state rows as delivered", async () => {
|
|
27
|
+
const db = createMigratedSqliteDb({ upTo: "048" });
|
|
28
|
+
const project = await new ProjectsStore(db).createProject({
|
|
29
|
+
name: "A",
|
|
30
|
+
slug: "a",
|
|
31
|
+
});
|
|
32
|
+
const store = new AlertsStore(db);
|
|
33
|
+
const rule = await store.createRule(project.id, ruleInput);
|
|
34
|
+
// The pre-049 evaluator only wrote a transition after delivery.
|
|
35
|
+
db.raw
|
|
36
|
+
.prepare(
|
|
37
|
+
`INSERT INTO alert_state (rule_id, project_id, current_state, last_state_change)
|
|
38
|
+
VALUES (?, ?, 'firing', '2026-05-31T00:00:00.000Z')`,
|
|
39
|
+
)
|
|
40
|
+
.run(rule.id, project.id);
|
|
41
|
+
const columnsBefore = db.raw
|
|
42
|
+
.prepare("PRAGMA table_info(alert_state)")
|
|
43
|
+
.all()
|
|
44
|
+
.map((c) => (c as { name: string }).name);
|
|
45
|
+
expect(columnsBefore).not.toContain("notified");
|
|
46
|
+
|
|
47
|
+
expect(
|
|
48
|
+
applySqliteMigrations(db.raw, { from: "049", upTo: "049" }),
|
|
49
|
+
).toEqual(["049_alert_state_notified.sql"]);
|
|
50
|
+
|
|
51
|
+
const notified = db.raw
|
|
52
|
+
.prepare("PRAGMA table_info(alert_state)")
|
|
53
|
+
.all()
|
|
54
|
+
.find((c) => (c as { name: string }).name === "notified") as {
|
|
55
|
+
type: string;
|
|
56
|
+
notnull: number;
|
|
57
|
+
dflt_value: string;
|
|
58
|
+
};
|
|
59
|
+
expect(notified).toMatchObject({
|
|
60
|
+
type: "INTEGER",
|
|
61
|
+
notnull: 1,
|
|
62
|
+
dflt_value: "1",
|
|
63
|
+
});
|
|
64
|
+
expect(await store.getState(rule.id, project.id)).toMatchObject({
|
|
65
|
+
current_state: "firing",
|
|
66
|
+
notified: 1,
|
|
67
|
+
});
|
|
68
|
+
// Other state is untouched and the new write path works afterwards.
|
|
69
|
+
await store.transitionState(
|
|
70
|
+
rule.id,
|
|
71
|
+
project.id,
|
|
72
|
+
"ok",
|
|
73
|
+
"2026-05-31T01:00:00.000Z",
|
|
74
|
+
);
|
|
75
|
+
expect((await store.getState(rule.id, project.id))?.notified).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("keeps transitions working on a database that has not applied 049 yet", async () => {
|
|
79
|
+
const db = createMigratedSqliteDb({ upTo: "048" });
|
|
80
|
+
const project = await new ProjectsStore(db).createProject({
|
|
81
|
+
name: "A",
|
|
82
|
+
slug: "a",
|
|
83
|
+
});
|
|
84
|
+
const store = new AlertsStore(db);
|
|
85
|
+
const rule = await store.createRule(project.id, ruleInput);
|
|
86
|
+
const t1 = "2026-05-31T00:00:00.000Z";
|
|
87
|
+
|
|
88
|
+
// Falls back to the legacy statement instead of failing the tick.
|
|
89
|
+
await store.transitionState(rule.id, project.id, "firing", t1);
|
|
90
|
+
const row = await store.getState(rule.id, project.id);
|
|
91
|
+
expect(row).toMatchObject({
|
|
92
|
+
current_state: "firing",
|
|
93
|
+
last_state_change: t1,
|
|
94
|
+
});
|
|
95
|
+
expect(row?.notified).toBeUndefined();
|
|
96
|
+
// Nothing to acknowledge; the evaluator treats the row as delivered.
|
|
97
|
+
expect(await store.markNotified(rule.id, project.id, "firing", t1)).toBe(
|
|
98
|
+
false,
|
|
99
|
+
);
|
|
100
|
+
// Upserting over the existing row also works.
|
|
101
|
+
await store.transitionState(
|
|
102
|
+
rule.id,
|
|
103
|
+
project.id,
|
|
104
|
+
"ok",
|
|
105
|
+
"2026-05-31T01:00:00.000Z",
|
|
106
|
+
);
|
|
107
|
+
expect((await store.getState(rule.id, project.id))?.current_state).toBe(
|
|
108
|
+
"ok",
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Postgres migrations in `../migrations-postgres/` must mirror the
|
|
3
|
+
* SQLite/D1 files here one-to-one (see migrations-postgres/README.md
|
|
4
|
+
* "Numbering"). Both runners apply files by sorted name and record them in
|
|
5
|
+
* `schema_migrations`, so a number that exists on one side only would drift
|
|
6
|
+
* the two schemas apart silently.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const sqliteDir = here;
|
|
16
|
+
const postgresDir = path.resolve(here, "../migrations-postgres");
|
|
17
|
+
|
|
18
|
+
const sqlFiles = (dir: string) =>
|
|
19
|
+
readdirSync(dir)
|
|
20
|
+
.filter((f) => f.endsWith(".sql"))
|
|
21
|
+
.sort();
|
|
22
|
+
|
|
23
|
+
/** Tables the retention purge deletes from by comparing `expires_at` to text. */
|
|
24
|
+
const RETENTION_TABLES = [
|
|
25
|
+
"telemetry_spans",
|
|
26
|
+
"logs",
|
|
27
|
+
"usage_events",
|
|
28
|
+
"ai_calls",
|
|
29
|
+
"metric_point",
|
|
30
|
+
"metric_exemplars",
|
|
31
|
+
"change_events",
|
|
32
|
+
"incident_investigations",
|
|
33
|
+
"investigation_findings",
|
|
34
|
+
"remediation_proposals",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
describe("migration parity", () => {
|
|
38
|
+
it("has the same numbered files on SQLite/D1 and Postgres", () => {
|
|
39
|
+
const sqlite = sqlFiles(sqliteDir);
|
|
40
|
+
const postgres = sqlFiles(postgresDir);
|
|
41
|
+
expect(postgres).toEqual(sqlite);
|
|
42
|
+
const prefixes = sqlite.map((f) => f.slice(0, 3));
|
|
43
|
+
expect(new Set(prefixes).size).toBe(prefixes.length);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("declares retention timestamps as TEXT in every Postgres CREATE TABLE", () => {
|
|
47
|
+
// postgresDialect.now() renders ISO text; Postgres refuses
|
|
48
|
+
// `timestamptz < text`, so any retention table created with
|
|
49
|
+
// TIMESTAMPTZ breaks purgeExpired (the 035 metric_exemplars bug).
|
|
50
|
+
const offenders: string[] = [];
|
|
51
|
+
for (const file of sqlFiles(postgresDir)) {
|
|
52
|
+
const sql = readFileSync(path.join(postgresDir, file), "utf8");
|
|
53
|
+
const tables = sql.matchAll(
|
|
54
|
+
/CREATE TABLE(?: IF NOT EXISTS)?\s+(\w+)\s*\(([^;]*?)\);/gis,
|
|
55
|
+
);
|
|
56
|
+
for (const [, table, body] of tables) {
|
|
57
|
+
if (!RETENTION_TABLES.includes(table)) continue;
|
|
58
|
+
for (const column of ["received_at", "expires_at"]) {
|
|
59
|
+
const decl = new RegExp(`^\\s*${column}\\s+(\\w+)`, "im").exec(body);
|
|
60
|
+
if (decl && decl[1].toUpperCase() !== "TEXT") {
|
|
61
|
+
offenders.push(`${file}: ${table}.${column} ${decl[1]}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
expect(offenders).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrations 045–048 move four row families from globally unique keys to
|
|
3
|
+
* project-scoped composite keys. These tests run the real D1 migration
|
|
4
|
+
* files through node:sqlite and then exercise the real write paths
|
|
5
|
+
* (/v1/identify, ActionStore, the AI span payload processor, span ingest)
|
|
6
|
+
* with two projects that emit identical ids.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { StoredSpan } from "@obsunified/types";
|
|
10
|
+
import type { MiddlewareHandler } from "hono";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
type CollectorRuntime,
|
|
14
|
+
createTelemetryCollectorApp,
|
|
15
|
+
type SpanProcessorPlugin,
|
|
16
|
+
} from "../framework/collector";
|
|
17
|
+
import type { CollectorEnv, CollectorRouteContext } from "../framework/env";
|
|
18
|
+
import { ActionStore } from "../lib/actions-store";
|
|
19
|
+
import { getAISpansOverview } from "../lib/ai-store/spans";
|
|
20
|
+
import { manifestByUser } from "../lib/identity-index/user-lookup";
|
|
21
|
+
import { ingestTelemetrySpans } from "../lib/store/ingest";
|
|
22
|
+
import { actionRecord } from "../lib/test-utils/action-graph-fixtures";
|
|
23
|
+
import {
|
|
24
|
+
applySqliteMigrations,
|
|
25
|
+
createMigratedSqliteDb,
|
|
26
|
+
indexNames,
|
|
27
|
+
type NodeSqliteDb,
|
|
28
|
+
primaryKeyColumns,
|
|
29
|
+
} from "../lib/test-utils/node-sqlite-db";
|
|
30
|
+
import { aiSpanPayloadsProcessorPlugin } from "../plugins/ai-span-payloads-processor";
|
|
31
|
+
import { identityReceiverPlugin } from "../plugins/identity-receiver";
|
|
32
|
+
|
|
33
|
+
const NOW = "2026-10-13T05:00:00.000Z";
|
|
34
|
+
const LATER = "2026-10-14T05:00:00.000Z";
|
|
35
|
+
|
|
36
|
+
const EXPECTED_KEYS: Record<string, string[]> = {
|
|
37
|
+
user_profiles: ["project_id", "user_id"],
|
|
38
|
+
actions: ["project_id", "id"],
|
|
39
|
+
agent_runs: ["project_id", "id"],
|
|
40
|
+
tool_calls: ["project_id", "id"],
|
|
41
|
+
retrieval_events: ["project_id", "id"],
|
|
42
|
+
eval_results: ["project_id", "id"],
|
|
43
|
+
artifacts: ["project_id", "id"],
|
|
44
|
+
ai_span_payloads: ["project_id", "trace_id", "span_id"],
|
|
45
|
+
telemetry_spans: ["project_id", "trace_id", "span_id"],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const EXPECTED_INDEXES: Record<string, string[]> = {
|
|
49
|
+
user_profiles: ["idx_user_visitor", "idx_users_project_last"],
|
|
50
|
+
actions: [
|
|
51
|
+
"idx_actions_project_actor",
|
|
52
|
+
"idx_actions_project_agent_run",
|
|
53
|
+
"idx_actions_project_interaction",
|
|
54
|
+
"idx_actions_project_root",
|
|
55
|
+
"idx_actions_project_trace",
|
|
56
|
+
"idx_actions_started_at",
|
|
57
|
+
],
|
|
58
|
+
agent_runs: ["idx_agent_runs_project_agent"],
|
|
59
|
+
tool_calls: ["idx_tool_calls_action", "idx_tool_calls_name"],
|
|
60
|
+
retrieval_events: ["idx_retrieval_events_action"],
|
|
61
|
+
eval_results: ["idx_eval_results_action"],
|
|
62
|
+
artifacts: ["idx_artifacts_action"],
|
|
63
|
+
ai_span_payloads: [
|
|
64
|
+
"idx_ai_payloads_action",
|
|
65
|
+
"idx_ai_payloads_kind",
|
|
66
|
+
"idx_ai_payloads_project",
|
|
67
|
+
"idx_ai_payloads_received",
|
|
68
|
+
"idx_ai_payloads_session",
|
|
69
|
+
"idx_ai_payloads_user",
|
|
70
|
+
],
|
|
71
|
+
telemetry_spans: [
|
|
72
|
+
"idx_spans_interaction",
|
|
73
|
+
"idx_spans_project_received",
|
|
74
|
+
"idx_spans_project_service_received",
|
|
75
|
+
"idx_spans_received_at",
|
|
76
|
+
"idx_spans_root",
|
|
77
|
+
"idx_spans_sdk_name",
|
|
78
|
+
"idx_spans_service",
|
|
79
|
+
"idx_spans_session",
|
|
80
|
+
"idx_spans_status",
|
|
81
|
+
"idx_spans_trace_id",
|
|
82
|
+
"idx_spans_with_links",
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const span = (over: Partial<StoredSpan>): StoredSpan => ({
|
|
87
|
+
projectId: "alpha",
|
|
88
|
+
traceId: "trace-shared",
|
|
89
|
+
spanId: "span-shared",
|
|
90
|
+
parentSpanId: null,
|
|
91
|
+
traceState: null,
|
|
92
|
+
serviceName: "agent",
|
|
93
|
+
scopeName: null,
|
|
94
|
+
scopeVersion: null,
|
|
95
|
+
spanName: "llm",
|
|
96
|
+
spanKind: 1,
|
|
97
|
+
statusCode: 1,
|
|
98
|
+
statusMessage: null,
|
|
99
|
+
startTime: NOW,
|
|
100
|
+
endTime: NOW,
|
|
101
|
+
durationMs: 5,
|
|
102
|
+
attributesJson: "{}",
|
|
103
|
+
droppedAttributesCount: 0,
|
|
104
|
+
resourceAttributesJson: "{}",
|
|
105
|
+
eventsJson: "[]",
|
|
106
|
+
droppedEventsCount: 0,
|
|
107
|
+
linksJson: "[]",
|
|
108
|
+
droppedLinksCount: 0,
|
|
109
|
+
receivedAt: new Date().toISOString(),
|
|
110
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
111
|
+
sessionId: null,
|
|
112
|
+
interactionId: null,
|
|
113
|
+
...over,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const aiSpan = (projectId: string, input: string): StoredSpan =>
|
|
117
|
+
span({
|
|
118
|
+
projectId,
|
|
119
|
+
attributesJson: JSON.stringify({
|
|
120
|
+
"openinference.span.kind": "LLM",
|
|
121
|
+
"ai.payload.input": input,
|
|
122
|
+
"ai.payload.output": `${input}-output`,
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const runPayloadProcessor = async (db: NodeSqliteDb, spans: StoredSpan[]) => {
|
|
127
|
+
const processors: SpanProcessorPlugin[] = [];
|
|
128
|
+
aiSpanPayloadsProcessorPlugin.register(
|
|
129
|
+
{} as Parameters<typeof aiSpanPayloadsProcessorPlugin.register>[0],
|
|
130
|
+
{
|
|
131
|
+
addSpanProcessor(p: SpanProcessorPlugin) {
|
|
132
|
+
processors.push(p);
|
|
133
|
+
},
|
|
134
|
+
} as unknown as CollectorRuntime,
|
|
135
|
+
);
|
|
136
|
+
const process = processors[0]?.process;
|
|
137
|
+
if (!process) throw new Error("span processor was not registered");
|
|
138
|
+
return process(spans, {
|
|
139
|
+
env: { DB: db, OBS_PAYLOAD_CAPTURE_DEFAULT: "true" },
|
|
140
|
+
now: new Date(),
|
|
141
|
+
logger: { ...console, error: () => {} },
|
|
142
|
+
} as unknown as CollectorRouteContext);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const identifyApp = () => {
|
|
146
|
+
const setProject: MiddlewareHandler<{
|
|
147
|
+
Bindings: CollectorEnv;
|
|
148
|
+
Variables: { projectId: string };
|
|
149
|
+
}> = async (c, next) => {
|
|
150
|
+
const header = c.req.header("x-project-id");
|
|
151
|
+
if (header) c.set("projectId", header);
|
|
152
|
+
await next();
|
|
153
|
+
};
|
|
154
|
+
return createTelemetryCollectorApp({
|
|
155
|
+
plugins: [identityReceiverPlugin],
|
|
156
|
+
auth: { middleware: setProject },
|
|
157
|
+
dashboardAuth: { middleware: setProject, registerRoutes: () => {} },
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const identify = (
|
|
162
|
+
app: ReturnType<typeof identifyApp>,
|
|
163
|
+
db: NodeSqliteDb,
|
|
164
|
+
projectId: string,
|
|
165
|
+
body: Record<string, unknown>,
|
|
166
|
+
) =>
|
|
167
|
+
app.request(
|
|
168
|
+
"/v1/identify",
|
|
169
|
+
{
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: {
|
|
172
|
+
"content-type": "application/json",
|
|
173
|
+
"x-project-id": projectId,
|
|
174
|
+
},
|
|
175
|
+
body: JSON.stringify(body),
|
|
176
|
+
},
|
|
177
|
+
{ DB: db as unknown as D1Database } as CollectorEnv,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
describe("migrations 045–048 (project-scoped composite keys)", () => {
|
|
181
|
+
it("rebuild each table in place, keeping rows, defaults and indexes", async () => {
|
|
182
|
+
const db = createMigratedSqliteDb({ upTo: "044" });
|
|
183
|
+
const raw = db.raw;
|
|
184
|
+
|
|
185
|
+
// Legacy rows written under the old global keys (project_id defaults
|
|
186
|
+
// to 'default' on user_profiles / telemetry_spans).
|
|
187
|
+
raw.exec(`
|
|
188
|
+
INSERT INTO user_profiles (user_id, visitor_id, email, first_seen_at, last_seen_at)
|
|
189
|
+
VALUES ('ada@example.com', 'vis-1', 'ada@example.com', '${NOW}', '${NOW}');
|
|
190
|
+
INSERT INTO actions (id, project_id, root_action_id, actor_type, action_kind, started_at)
|
|
191
|
+
VALUES ('01ACTION', 'alpha', '01ACTION', 'agent', 'agent.run', '${NOW}');
|
|
192
|
+
INSERT INTO agent_runs (id, project_id, agent_id, agent_name, agent_version, autonomy_level)
|
|
193
|
+
VALUES ('01ACTION', 'alpha', 'bot', 'Bot', '1', 'read_only');
|
|
194
|
+
INSERT INTO tool_calls (id, action_id, project_id, tool_name, args_hash, result_hash, mutation_diff_json)
|
|
195
|
+
VALUES ('span-1', '01ACTION', 'alpha', 'sql_query', 'a', 'r', '{"diff":true}');
|
|
196
|
+
INSERT INTO retrieval_events (id, action_id, project_id, retriever_name, query_hash)
|
|
197
|
+
VALUES ('span-2', '01ACTION', 'alpha', 'vector', 'q');
|
|
198
|
+
INSERT INTO eval_results (id, action_id, project_id, evaluator_name, evaluator_version, score)
|
|
199
|
+
VALUES ('span-3', '01ACTION', 'alpha', 'groundedness', '1', 0.9);
|
|
200
|
+
INSERT INTO artifacts (id, action_id, project_id, artifact_name, artifact_type)
|
|
201
|
+
VALUES ('span-4', '01ACTION', 'alpha', 'invoice', 'file');
|
|
202
|
+
INSERT INTO ai_span_payloads (project_id, trace_id, span_id, span_kind, input_json, received_at, expires_at, input_hash, action_id)
|
|
203
|
+
VALUES ('alpha', 't1', 's1', 'LLM', '"prompt"', '${NOW}', '${LATER}', 'hash', '01ACTION');
|
|
204
|
+
INSERT INTO telemetry_spans (trace_id, span_id, span_name, start_time, end_time, duration_ms, received_at, expires_at, telemetry_sdk_name)
|
|
205
|
+
VALUES ('t1', 's1', 'llm', '${NOW}', '${NOW}', 5, '${NOW}', '${LATER}', 'beyla');
|
|
206
|
+
`);
|
|
207
|
+
|
|
208
|
+
const applied = applySqliteMigrations(raw, { from: "045", upTo: "048" });
|
|
209
|
+
expect(applied.map((f) => f.slice(0, 3))).toEqual([
|
|
210
|
+
"045",
|
|
211
|
+
"046",
|
|
212
|
+
"047",
|
|
213
|
+
"048",
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
for (const [table, key] of Object.entries(EXPECTED_KEYS)) {
|
|
217
|
+
expect(primaryKeyColumns(raw, table), table).toEqual(key);
|
|
218
|
+
expect(indexNames(raw, table), table).toEqual(EXPECTED_INDEXES[table]);
|
|
219
|
+
expect(
|
|
220
|
+
raw.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get(),
|
|
221
|
+
table,
|
|
222
|
+
).toEqual({ n: 1 });
|
|
223
|
+
expect(
|
|
224
|
+
raw
|
|
225
|
+
.prepare(`SELECT name FROM sqlite_master WHERE name = ?`)
|
|
226
|
+
.get(`${table}_new`),
|
|
227
|
+
`${table}_new left behind`,
|
|
228
|
+
).toBeUndefined();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
expect(
|
|
232
|
+
raw.prepare(`SELECT project_id, email FROM user_profiles`).get(),
|
|
233
|
+
).toEqual({ project_id: "default", email: "ada@example.com" });
|
|
234
|
+
expect(
|
|
235
|
+
raw
|
|
236
|
+
.prepare(
|
|
237
|
+
`SELECT project_id, status, mutation_diff_json, side_effect FROM tool_calls JOIN actions USING (project_id)`,
|
|
238
|
+
)
|
|
239
|
+
.get(),
|
|
240
|
+
).toEqual({
|
|
241
|
+
project_id: "alpha",
|
|
242
|
+
status: "ok",
|
|
243
|
+
mutation_diff_json: '{"diff":true}',
|
|
244
|
+
side_effect: 0,
|
|
245
|
+
});
|
|
246
|
+
expect(
|
|
247
|
+
raw
|
|
248
|
+
.prepare(
|
|
249
|
+
`SELECT project_id, telemetry_sdk_name, dropped_links_count FROM telemetry_spans`,
|
|
250
|
+
)
|
|
251
|
+
.get(),
|
|
252
|
+
).toEqual({
|
|
253
|
+
project_id: "default",
|
|
254
|
+
telemetry_sdk_name: "beyla",
|
|
255
|
+
dropped_links_count: 0,
|
|
256
|
+
});
|
|
257
|
+
expect(
|
|
258
|
+
raw.prepare(`SELECT input_hash, action_id FROM ai_span_payloads`).get(),
|
|
259
|
+
).toEqual({ input_hash: "hash", action_id: "01ACTION" });
|
|
260
|
+
|
|
261
|
+
// Re-applying the rebuild files is a no-op in effect.
|
|
262
|
+
applySqliteMigrations(raw, { from: "045", upTo: "048" });
|
|
263
|
+
for (const [table, key] of Object.entries(EXPECTED_KEYS)) {
|
|
264
|
+
expect(primaryKeyColumns(raw, table), table).toEqual(key);
|
|
265
|
+
expect(
|
|
266
|
+
raw.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get(),
|
|
267
|
+
table,
|
|
268
|
+
).toEqual({ n: 1 });
|
|
269
|
+
}
|
|
270
|
+
db.close();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("tenant isolation on project-scoped keys", () => {
|
|
275
|
+
it("/v1/identify keeps one profile per project for the same userId", async () => {
|
|
276
|
+
const db = createMigratedSqliteDb();
|
|
277
|
+
const app = identifyApp();
|
|
278
|
+
const userId = "ada@example.com";
|
|
279
|
+
|
|
280
|
+
const alpha = await identify(app, db, "alpha", {
|
|
281
|
+
userId,
|
|
282
|
+
visitorId: "vis-alpha",
|
|
283
|
+
email: userId,
|
|
284
|
+
name: "Ada (alpha)",
|
|
285
|
+
properties: { plan: "alpha-plan" },
|
|
286
|
+
});
|
|
287
|
+
expect(alpha.status).toBe(200);
|
|
288
|
+
const beta = await identify(app, db, "beta", {
|
|
289
|
+
userId,
|
|
290
|
+
visitorId: "vis-beta",
|
|
291
|
+
name: "Ada (beta)",
|
|
292
|
+
});
|
|
293
|
+
expect(beta.status).toBe(200);
|
|
294
|
+
|
|
295
|
+
const rows = await db
|
|
296
|
+
.prepare(
|
|
297
|
+
`SELECT project_id, visitor_id, email, name, properties_json
|
|
298
|
+
FROM user_profiles WHERE user_id = ? ORDER BY project_id`,
|
|
299
|
+
)
|
|
300
|
+
.bind(userId)
|
|
301
|
+
.all();
|
|
302
|
+
expect(rows.results).toEqual([
|
|
303
|
+
{
|
|
304
|
+
project_id: "alpha",
|
|
305
|
+
visitor_id: "vis-alpha",
|
|
306
|
+
email: userId,
|
|
307
|
+
name: "Ada (alpha)",
|
|
308
|
+
properties_json: JSON.stringify({ plan: "alpha-plan" }),
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
project_id: "beta",
|
|
312
|
+
visitor_id: "vis-beta",
|
|
313
|
+
email: null,
|
|
314
|
+
name: "Ada (beta)",
|
|
315
|
+
properties_json: null,
|
|
316
|
+
},
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
// A repeat identify from beta updates beta only.
|
|
320
|
+
await identify(app, db, "beta", { userId, visitorId: "vis-beta-2" });
|
|
321
|
+
const visitors = await db
|
|
322
|
+
.prepare(
|
|
323
|
+
`SELECT project_id, visitor_id FROM user_profiles WHERE user_id = ? ORDER BY project_id`,
|
|
324
|
+
)
|
|
325
|
+
.bind(userId)
|
|
326
|
+
.all();
|
|
327
|
+
expect(visitors.results).toEqual([
|
|
328
|
+
{ project_id: "alpha", visitor_id: "vis-alpha" },
|
|
329
|
+
{ project_id: "beta", visitor_id: "vis-beta-2" },
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
// The identity index resolves the visitor inside the caller's project.
|
|
333
|
+
const alphaCalls = db.raw.prepare(
|
|
334
|
+
`INSERT INTO usage_events (project_id, event_id, session_id, visitor_id, event_type, event_name, context_json, properties_json, occurred_at, received_at, expires_at)
|
|
335
|
+
VALUES (?, ?, ?, ?, 'page_view', 'page_view', '{}', '{}', ?, ?, ?)`,
|
|
336
|
+
);
|
|
337
|
+
alphaCalls.run("alpha", "evt-a", "sess-a", "vis-alpha", NOW, NOW, LATER);
|
|
338
|
+
alphaCalls.run("beta", "evt-b", "sess-b", "vis-beta-2", NOW, NOW, LATER);
|
|
339
|
+
const alphaManifest = await manifestByUser(db, "alpha", userId);
|
|
340
|
+
const betaManifest = await manifestByUser(db, "beta", userId);
|
|
341
|
+
expect(alphaManifest.usageEvents.map((e) => e.sessionId)).toEqual([
|
|
342
|
+
"sess-a",
|
|
343
|
+
]);
|
|
344
|
+
expect(betaManifest.usageEvents.map((e) => e.sessionId)).toEqual([
|
|
345
|
+
"sess-b",
|
|
346
|
+
]);
|
|
347
|
+
db.close();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("ActionStore keeps the same action id separate per project", async () => {
|
|
351
|
+
const db = createMigratedSqliteDb();
|
|
352
|
+
const store = new ActionStore(db);
|
|
353
|
+
await store.upsertAction(
|
|
354
|
+
actionRecord({ id: "01SHARED", projectId: "alpha", name: "alpha run" }),
|
|
355
|
+
);
|
|
356
|
+
await store.upsertAction(
|
|
357
|
+
actionRecord({ id: "01SHARED", projectId: "beta", name: "beta run" }),
|
|
358
|
+
);
|
|
359
|
+
// A second write from beta must not reach alpha.
|
|
360
|
+
await store.upsertAction(
|
|
361
|
+
actionRecord({ id: "01SHARED", projectId: "beta", name: "beta run v2" }),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
expect((await store.getAction("alpha", "01SHARED"))?.name).toBe(
|
|
365
|
+
"alpha run",
|
|
366
|
+
);
|
|
367
|
+
expect((await store.getAction("beta", "01SHARED"))?.name).toBe(
|
|
368
|
+
"beta run v2",
|
|
369
|
+
);
|
|
370
|
+
const count = await db
|
|
371
|
+
.prepare(`SELECT COUNT(*) AS n FROM actions WHERE id = ?`)
|
|
372
|
+
.bind("01SHARED")
|
|
373
|
+
.first<{ n: number }>();
|
|
374
|
+
expect(count?.n).toBe(2);
|
|
375
|
+
db.close();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("AI span payloads with identical trace/span ids stay with their project", async () => {
|
|
379
|
+
const db = createMigratedSqliteDb();
|
|
380
|
+
await runPayloadProcessor(db, [aiSpan("alpha", "alpha prompt")]);
|
|
381
|
+
await runPayloadProcessor(db, [aiSpan("beta", "beta prompt")]);
|
|
382
|
+
await runPayloadProcessor(db, [aiSpan("beta", "beta prompt v2")]);
|
|
383
|
+
|
|
384
|
+
const rows = await db
|
|
385
|
+
.prepare(
|
|
386
|
+
`SELECT project_id, input_json FROM ai_span_payloads
|
|
387
|
+
WHERE trace_id = ? AND span_id = ? ORDER BY project_id`,
|
|
388
|
+
)
|
|
389
|
+
.bind("trace-shared", "span-shared")
|
|
390
|
+
.all<{ project_id: string; input_json: string }>();
|
|
391
|
+
expect(rows.results).toEqual([
|
|
392
|
+
{ project_id: "alpha", input_json: "alpha prompt" },
|
|
393
|
+
{ project_id: "beta", input_json: "beta prompt v2" },
|
|
394
|
+
]);
|
|
395
|
+
db.close();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("span ingest keeps colliding ids per project and the AI span join stays in-project", async () => {
|
|
399
|
+
const db = createMigratedSqliteDb();
|
|
400
|
+
const alphaSpan = span({ projectId: "alpha", spanName: "alpha-llm" });
|
|
401
|
+
const betaSpan = span({ projectId: "beta", spanName: "beta-llm" });
|
|
402
|
+
await ingestTelemetrySpans(db, [alphaSpan]);
|
|
403
|
+
// Previously INSERT OR IGNORE dropped this row: same (trace_id, span_id).
|
|
404
|
+
await ingestTelemetrySpans(db, [betaSpan]);
|
|
405
|
+
|
|
406
|
+
const names = await db
|
|
407
|
+
.prepare(
|
|
408
|
+
`SELECT project_id, span_name FROM telemetry_spans
|
|
409
|
+
WHERE trace_id = ? AND span_id = ? ORDER BY project_id`,
|
|
410
|
+
)
|
|
411
|
+
.bind("trace-shared", "span-shared")
|
|
412
|
+
.all();
|
|
413
|
+
expect(names.results).toEqual([
|
|
414
|
+
{ project_id: "alpha", span_name: "alpha-llm" },
|
|
415
|
+
{ project_id: "beta", span_name: "beta-llm" },
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
// Only alpha has a payload row; beta's span must not be joined to it.
|
|
419
|
+
await runPayloadProcessor(db, [aiSpan("alpha", "alpha prompt")]);
|
|
420
|
+
const alpha = await getAISpansOverview(db, {
|
|
421
|
+
projectId: "alpha",
|
|
422
|
+
hours: 24,
|
|
423
|
+
});
|
|
424
|
+
const beta = await getAISpansOverview(db, {
|
|
425
|
+
projectId: "beta",
|
|
426
|
+
hours: 24,
|
|
427
|
+
});
|
|
428
|
+
expect(alpha.spans.map((s) => s.spanName)).toEqual(["alpha-llm"]);
|
|
429
|
+
expect(beta.spans).toEqual([]);
|
|
430
|
+
db.close();
|
|
431
|
+
});
|
|
432
|
+
});
|
|
@@ -10,10 +10,10 @@ CREATE INDEX IF NOT EXISTS idx_projects_slug ON projects (slug);
|
|
|
10
10
|
CREATE TABLE IF NOT EXISTS ingest_keys (
|
|
11
11
|
id TEXT PRIMARY KEY,
|
|
12
12
|
project_id TEXT NOT NULL,
|
|
13
|
-
--
|
|
14
|
-
-- the column
|
|
15
|
-
--
|
|
16
|
-
key_hash
|
|
13
|
+
-- SHA-256 hex digest of the plaintext key, exactly as D1 stores it.
|
|
14
|
+
-- ProjectsStore binds the hex string, so the column must be TEXT; an
|
|
15
|
+
-- earlier BYTEA version is converted by 041_ingest_key_hash_text.sql.
|
|
16
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
17
17
|
key_prefix TEXT NOT NULL,
|
|
18
18
|
name TEXT NOT NULL,
|
|
19
19
|
created_at TEXT NOT NULL,
|
|
@@ -6,7 +6,7 @@ INSERT INTO ingest_keys (id, project_id, key_hash, key_prefix, name, created_at)
|
|
|
6
6
|
VALUES (
|
|
7
7
|
'obs-dashboard-bootstrap',
|
|
8
8
|
'obs-dashboard',
|
|
9
|
-
|
|
9
|
+
'6aa18dc1261b312e98b236d5a7dae698d1a90ecbe33d8024744063a066a35b9b',
|
|
10
10
|
'obs_dashboard_65',
|
|
11
11
|
'bootstrap',
|
|
12
12
|
now()::text
|
|
@@ -15,8 +15,11 @@ CREATE TABLE IF NOT EXISTS metric_exemplars (
|
|
|
15
15
|
span_id TEXT,
|
|
16
16
|
ts_ns TEXT NOT NULL,
|
|
17
17
|
value DOUBLE PRECISION NOT NULL,
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
-- TEXT ISO timestamps like every other retention table; see
|
|
19
|
+
-- 043_metric_exemplars_text_timestamps.sql for databases that were
|
|
20
|
+
-- created with the earlier TIMESTAMPTZ version of this file.
|
|
21
|
+
received_at TEXT NOT NULL,
|
|
22
|
+
expires_at TEXT NOT NULL
|
|
20
23
|
);
|
|
21
24
|
|
|
22
25
|
CREATE INDEX IF NOT EXISTS idx_metric_exemplars_project_trace
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- Convert ingest_keys.key_hash from BYTEA to TEXT on databases migrated
|
|
2
|
+
-- before 009 was corrected.
|
|
3
|
+
--
|
|
4
|
+
-- ProjectsStore binds the SHA-256 digest as a hex string. Against a BYTEA
|
|
5
|
+
-- column Postgres parsed that string as escape-format bytes, so:
|
|
6
|
+
-- * keys created by the collector (bootstrap INGEST_KEY, minted keys)
|
|
7
|
+
-- were stored as the 64 ASCII bytes of the hex text;
|
|
8
|
+
-- * the obs-dashboard seed in 026 used decode(..., 'hex') and stored the
|
|
9
|
+
-- 32 raw digest bytes, which never matched a lookup.
|
|
10
|
+
-- Both shapes are mapped back to the 64-char lowercase hex digest here.
|
|
11
|
+
-- Fresh installs already create the column as TEXT and this is a no-op.
|
|
12
|
+
DO $$
|
|
13
|
+
BEGIN
|
|
14
|
+
IF EXISTS (
|
|
15
|
+
SELECT 1
|
|
16
|
+
FROM information_schema.columns
|
|
17
|
+
WHERE table_schema = current_schema()
|
|
18
|
+
AND table_name = 'ingest_keys'
|
|
19
|
+
AND column_name = 'key_hash'
|
|
20
|
+
AND data_type = 'bytea'
|
|
21
|
+
) THEN
|
|
22
|
+
ALTER TABLE ingest_keys
|
|
23
|
+
ALTER COLUMN key_hash TYPE TEXT
|
|
24
|
+
USING (
|
|
25
|
+
CASE
|
|
26
|
+
WHEN octet_length(key_hash) = 32 THEN encode(key_hash, 'hex')
|
|
27
|
+
ELSE convert_from(key_hash, 'UTF8')
|
|
28
|
+
END
|
|
29
|
+
);
|
|
30
|
+
END IF;
|
|
31
|
+
END
|
|
32
|
+
$$;
|