@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
package/src/lib/blob-store.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - `R2BlobStore` — Cloudflare R2 binding (default on Workers).
|
|
7
7
|
* - `S3BlobStore` (sibling file) — AWS S3 / MinIO / any S3-compatible.
|
|
8
8
|
*
|
|
9
|
-
* Replay chunks live under `replays/<session_id>/<chunk_id>`; pprof
|
|
9
|
+
* Replay chunks live under `replays/<project_id>/<session_id>/<chunk_id>`; pprof
|
|
10
10
|
* profiles live under `profiles/<service>/<trace_id>/<span_id>`. The
|
|
11
11
|
* adapter never sees these paths — they're constructed by the callers
|
|
12
12
|
* (`replay-receiver`, `profile-routes`).
|
|
@@ -76,6 +76,10 @@ export interface EvalRunCandidate {
|
|
|
76
76
|
modelProvider: string | null;
|
|
77
77
|
model: string | null;
|
|
78
78
|
modelVersion: string | null;
|
|
79
|
+
environment?: string | null;
|
|
80
|
+
serviceVersion?: string | null;
|
|
81
|
+
commitSha?: string | null;
|
|
82
|
+
configDigest?: string | null;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
export interface EvalRunSourceCase {
|
|
@@ -99,6 +103,12 @@ export interface EvalRun {
|
|
|
99
103
|
evalCaseId: string | null;
|
|
100
104
|
status: EvalRunStatus;
|
|
101
105
|
candidate: EvalRunCandidate;
|
|
106
|
+
environment?: string;
|
|
107
|
+
serviceVersion?: string | null;
|
|
108
|
+
commitSha?: string | null;
|
|
109
|
+
configDigest?: string | null;
|
|
110
|
+
inputPayloadHash?: string | null;
|
|
111
|
+
failureFingerprint?: string | null;
|
|
102
112
|
startedAt: string | null;
|
|
103
113
|
endedAt: string | null;
|
|
104
114
|
totalCount: number;
|
|
@@ -117,6 +127,12 @@ export interface EvalRunInput {
|
|
|
117
127
|
evalCaseId?: string | null;
|
|
118
128
|
status?: EvalRunStatus;
|
|
119
129
|
candidate?: Partial<EvalRunCandidate>;
|
|
130
|
+
environment?: string | null;
|
|
131
|
+
serviceVersion?: string | null;
|
|
132
|
+
commitSha?: string | null;
|
|
133
|
+
configDigest?: string | null;
|
|
134
|
+
inputPayloadHash?: string | null;
|
|
135
|
+
failureFingerprint?: string | null;
|
|
120
136
|
startedAt?: string | null;
|
|
121
137
|
endedAt?: string | null;
|
|
122
138
|
totalCount?: number;
|
|
@@ -144,6 +160,12 @@ interface EvalRunRow {
|
|
|
144
160
|
candidate_model_provider: string | null;
|
|
145
161
|
candidate_model: string | null;
|
|
146
162
|
candidate_model_version: string | null;
|
|
163
|
+
environment?: string | null;
|
|
164
|
+
service_version?: string | null;
|
|
165
|
+
commit_sha?: string | null;
|
|
166
|
+
config_digest?: string | null;
|
|
167
|
+
input_payload_hash?: string | null;
|
|
168
|
+
failure_fingerprint?: string | null;
|
|
147
169
|
status: string;
|
|
148
170
|
started_at: string | null;
|
|
149
171
|
ended_at: string | null;
|
|
@@ -412,7 +434,17 @@ const rowToEvalRun = (row: EvalRunRow): EvalRun => {
|
|
|
412
434
|
modelProvider: row.candidate_model_provider ?? null,
|
|
413
435
|
model: row.candidate_model ?? null,
|
|
414
436
|
modelVersion: row.candidate_model_version ?? null,
|
|
437
|
+
environment: row.environment ?? "default",
|
|
438
|
+
serviceVersion: row.service_version ?? null,
|
|
439
|
+
commitSha: row.commit_sha ?? null,
|
|
440
|
+
configDigest: row.config_digest ?? null,
|
|
415
441
|
},
|
|
442
|
+
environment: row.environment ?? "default",
|
|
443
|
+
serviceVersion: row.service_version ?? null,
|
|
444
|
+
commitSha: row.commit_sha ?? null,
|
|
445
|
+
configDigest: row.config_digest ?? null,
|
|
446
|
+
inputPayloadHash: row.input_payload_hash ?? null,
|
|
447
|
+
failureFingerprint: row.failure_fingerprint ?? null,
|
|
416
448
|
startedAt: row.started_at ?? null,
|
|
417
449
|
endedAt: row.ended_at ?? null,
|
|
418
450
|
totalCount: row.total_count ?? 0,
|
|
@@ -560,6 +592,16 @@ export class EvalCasesStore {
|
|
|
560
592
|
}
|
|
561
593
|
|
|
562
594
|
const candidate = input.candidate ?? {};
|
|
595
|
+
const environment =
|
|
596
|
+
input.environment?.trim() || candidate.environment?.trim() || "default";
|
|
597
|
+
const serviceVersion =
|
|
598
|
+
input.serviceVersion?.trim() || candidate.serviceVersion?.trim() || null;
|
|
599
|
+
const commitSha =
|
|
600
|
+
input.commitSha?.trim() || candidate.commitSha?.trim() || null;
|
|
601
|
+
const configDigest =
|
|
602
|
+
input.configDigest?.trim() || candidate.configDigest?.trim() || null;
|
|
603
|
+
const inputPayloadHash = input.inputPayloadHash?.trim() || null;
|
|
604
|
+
const failureFingerprint = input.failureFingerprint?.trim() || null;
|
|
563
605
|
const totalCount = nonNegativeInteger(input.totalCount);
|
|
564
606
|
const passCount = nonNegativeInteger(input.passCount);
|
|
565
607
|
const failCount = nonNegativeInteger(input.failCount);
|
|
@@ -580,10 +622,12 @@ export class EvalCasesStore {
|
|
|
580
622
|
candidate_agent_id, candidate_agent_version,
|
|
581
623
|
candidate_prompt_id, candidate_prompt_version,
|
|
582
624
|
candidate_model_provider, candidate_model, candidate_model_version,
|
|
625
|
+
environment, service_version, commit_sha, config_digest,
|
|
626
|
+
input_payload_hash, failure_fingerprint,
|
|
583
627
|
status, started_at, ended_at,
|
|
584
628
|
total_count, pass_count, fail_count, average_score,
|
|
585
629
|
metadata_json, created_at
|
|
586
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
630
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
587
631
|
)
|
|
588
632
|
.bind(
|
|
589
633
|
id,
|
|
@@ -596,6 +640,12 @@ export class EvalCasesStore {
|
|
|
596
640
|
candidate.modelProvider ?? null,
|
|
597
641
|
candidate.model ?? null,
|
|
598
642
|
candidate.modelVersion ?? null,
|
|
643
|
+
environment,
|
|
644
|
+
serviceVersion,
|
|
645
|
+
commitSha,
|
|
646
|
+
configDigest,
|
|
647
|
+
inputPayloadHash,
|
|
648
|
+
failureFingerprint,
|
|
599
649
|
status,
|
|
600
650
|
startedAt,
|
|
601
651
|
endedAt,
|
|
@@ -679,6 +729,14 @@ export class EvalCasesStore {
|
|
|
679
729
|
average_score = (
|
|
680
730
|
SELECT AVG(score) FROM eval_case_results
|
|
681
731
|
WHERE project_id = ? AND run_id = ? AND score IS NOT NULL
|
|
732
|
+
),
|
|
733
|
+
input_payload_hash = COALESCE(
|
|
734
|
+
input_payload_hash,
|
|
735
|
+
(SELECT input_payload_hash FROM eval_case_results WHERE project_id = ? AND run_id = ? AND input_payload_hash IS NOT NULL LIMIT 1)
|
|
736
|
+
),
|
|
737
|
+
failure_fingerprint = COALESCE(
|
|
738
|
+
failure_fingerprint,
|
|
739
|
+
(SELECT failure_fingerprint FROM eval_case_results WHERE project_id = ? AND run_id = ? AND failure_fingerprint IS NOT NULL LIMIT 1)
|
|
682
740
|
)
|
|
683
741
|
WHERE project_id = ? AND id = ?`,
|
|
684
742
|
)
|
|
@@ -693,6 +751,10 @@ export class EvalCasesStore {
|
|
|
693
751
|
id,
|
|
694
752
|
projectId,
|
|
695
753
|
id,
|
|
754
|
+
projectId,
|
|
755
|
+
id,
|
|
756
|
+
projectId,
|
|
757
|
+
id,
|
|
696
758
|
)
|
|
697
759
|
.run();
|
|
698
760
|
}
|
|
@@ -146,6 +146,12 @@ export const evidenceRouteFor = (
|
|
|
146
146
|
return traceRoute(entityId);
|
|
147
147
|
case "docs":
|
|
148
148
|
return entityId.startsWith("#/") ? entityId : `#/docs/${entityId}`;
|
|
149
|
+
case "change":
|
|
150
|
+
return `#/changes/${encodeURIComponent(entityId)}`;
|
|
151
|
+
case "finding":
|
|
152
|
+
return `#/findings/${encodeURIComponent(entityId)}`;
|
|
153
|
+
case "incident":
|
|
154
|
+
return `#/incidents/${encodeURIComponent(entityId)}`;
|
|
149
155
|
}
|
|
150
156
|
const exhaustive: never = entityKind;
|
|
151
157
|
return exhaustive;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { purgeSecondaryRows } from "./housekeeping";
|
|
3
|
+
import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
|
|
4
|
+
|
|
5
|
+
const HOUR = 3_600_000;
|
|
6
|
+
const NOW = Date.UTC(2026, 9, 13, 12, 0, 0);
|
|
7
|
+
const iso = (hoursAgo: number) => new Date(NOW - hoursAgo * HOUR).toISOString();
|
|
8
|
+
|
|
9
|
+
describe("purgeSecondaryRows", () => {
|
|
10
|
+
it("removes only rows no API window or ingest path can still reach", async () => {
|
|
11
|
+
const db = createMigratedSqliteDb();
|
|
12
|
+
const raw = db.raw;
|
|
13
|
+
raw.exec(`
|
|
14
|
+
INSERT INTO projects (id, name, slug, created_at) VALUES ('alpha', 'A', 'a', '${iso(1000)}');
|
|
15
|
+
INSERT INTO alert_rules (id, project_id, name, signal, query_json, threshold, window_mins, comparison, channels_json, enabled, created_at, updated_at)
|
|
16
|
+
VALUES ('r1', 'alpha', 'r', 'spans', '{}', 1, 5, '>', '[]', 1, '${iso(1000)}', '${iso(1000)}');
|
|
17
|
+
`);
|
|
18
|
+
const ev = raw.prepare(
|
|
19
|
+
`INSERT INTO alert_evaluations (id, rule_id, project_id, evaluated_at, value, state, notified) VALUES (?, 'r1', 'alpha', ?, 1, 'ok', 0)`,
|
|
20
|
+
);
|
|
21
|
+
ev.run("old", iso(721));
|
|
22
|
+
ev.run("edge", iso(719));
|
|
23
|
+
const ask = raw.prepare(
|
|
24
|
+
`INSERT INTO ask_evidence_events (project_id, analysis_id, asked_at) VALUES ('alpha', 'x', ?)`,
|
|
25
|
+
);
|
|
26
|
+
ask.run(NOW - 31 * 24 * HOUR);
|
|
27
|
+
ask.run(NOW - 29 * 24 * HOUR);
|
|
28
|
+
const series = raw.prepare(
|
|
29
|
+
`INSERT INTO metric_series (id, project_id, name, type, identity, created_at) VALUES (?, 'alpha', 'm', 'gauge', ?, ?)`,
|
|
30
|
+
);
|
|
31
|
+
series.run("orphan-old", "i1", iso(100)); // no points, older than window
|
|
32
|
+
series.run("orphan-new", "i2", iso(1)); // no points but just created
|
|
33
|
+
series.run("live", "i3", iso(100)); // old but still has a point
|
|
34
|
+
raw
|
|
35
|
+
.prepare(
|
|
36
|
+
`INSERT INTO metric_point (id, series_id, project_id, ts_ns, received_at, expires_at) VALUES ('p1', 'live', 'alpha', '1', ?, ?)`,
|
|
37
|
+
)
|
|
38
|
+
.run(iso(1), iso(-1));
|
|
39
|
+
const ref = raw.prepare(
|
|
40
|
+
`INSERT INTO evidence_retrieval_refs (ref_id, project_id, kind, anchor_kind, anchor_id, source, expires_at) VALUES (?, 'alpha', 'logs', 'trace', 't', 'test', ?)`,
|
|
41
|
+
);
|
|
42
|
+
ref.run("expired", iso(1));
|
|
43
|
+
ref.run("fresh", iso(-1));
|
|
44
|
+
ref.run("no-expiry", null);
|
|
45
|
+
const exp = raw.prepare(
|
|
46
|
+
`INSERT INTO evidence_ref_expansions (id, ref_id, project_id, kind, operation, result_status) VALUES (?, ?, 'alpha', 'logs', 'expand', 'ok')`,
|
|
47
|
+
);
|
|
48
|
+
exp.run("e1", "expired");
|
|
49
|
+
exp.run("e2", "fresh");
|
|
50
|
+
|
|
51
|
+
const summary = await purgeSecondaryRows(db, {
|
|
52
|
+
retentionHours: 72,
|
|
53
|
+
now: () => NOW,
|
|
54
|
+
});
|
|
55
|
+
expect(summary).toEqual({
|
|
56
|
+
alert_evaluations: 1,
|
|
57
|
+
ask_evidence_events: 1,
|
|
58
|
+
metric_series: 1,
|
|
59
|
+
evidence_refs: 1,
|
|
60
|
+
evidence_ref_expansions: 1,
|
|
61
|
+
ontology_edges: 0,
|
|
62
|
+
ontology_nodes: 0,
|
|
63
|
+
change_events: 0,
|
|
64
|
+
incident_investigations: 0,
|
|
65
|
+
remediation_proposals: 0,
|
|
66
|
+
investigation_findings: 0,
|
|
67
|
+
});
|
|
68
|
+
const ids = (sql: string) =>
|
|
69
|
+
(raw.prepare(sql).all() as Array<{ id: string }>).map((r) => r.id).sort();
|
|
70
|
+
expect(ids("SELECT id FROM alert_evaluations")).toEqual(["edge"]);
|
|
71
|
+
expect(ids("SELECT id FROM metric_series")).toEqual(["live", "orphan-new"]);
|
|
72
|
+
expect(ids("SELECT ref_id AS id FROM evidence_retrieval_refs")).toEqual([
|
|
73
|
+
"fresh",
|
|
74
|
+
"no-expiry",
|
|
75
|
+
]);
|
|
76
|
+
expect(ids("SELECT id FROM evidence_ref_expansions")).toEqual(["e2"]);
|
|
77
|
+
db.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("enforces RFC 0013 AutoSRE retention windows while preserving counterfactual_verified and operationally_corroborated findings", async () => {
|
|
81
|
+
const db = createMigratedSqliteDb();
|
|
82
|
+
const raw = db.raw;
|
|
83
|
+
raw.exec(`
|
|
84
|
+
INSERT INTO projects (id, name, slug, created_at) VALUES ('alpha', 'A', 'a', '${iso(3000)}');
|
|
85
|
+
`);
|
|
86
|
+
// Ontology edges/nodes (30d = 720h)
|
|
87
|
+
raw
|
|
88
|
+
.prepare(
|
|
89
|
+
`INSERT INTO ontology_nodes (id, project_id, environment, kind, name, first_seen_at, last_seen_at) VALUES (?, 'alpha', 'prod', 'service', ?, ?, ?)`,
|
|
90
|
+
)
|
|
91
|
+
.run("n-old", "old-svc", iso(800), iso(750));
|
|
92
|
+
raw
|
|
93
|
+
.prepare(
|
|
94
|
+
`INSERT INTO ontology_nodes (id, project_id, environment, kind, name, first_seen_at, last_seen_at) VALUES (?, 'alpha', 'prod', 'service', ?, ?, ?)`,
|
|
95
|
+
)
|
|
96
|
+
.run("n-fresh", "fresh-svc", iso(100), iso(10));
|
|
97
|
+
raw
|
|
98
|
+
.prepare(
|
|
99
|
+
`INSERT INTO ontology_edges (id, project_id, environment, source_node_id, predicate, target_node_id, relationship_certainty, first_seen_at, last_seen_at) VALUES (?, 'alpha', 'prod', 'n-old', 'calls', 'n-fresh', 'exact', ?, ?)`,
|
|
100
|
+
)
|
|
101
|
+
.run("edge-old", iso(800), iso(750));
|
|
102
|
+
|
|
103
|
+
// Change events (90d = 2160h)
|
|
104
|
+
raw
|
|
105
|
+
.prepare(
|
|
106
|
+
`INSERT INTO change_events (id, project_id, environment, kind, source, target_node_id, summary, occurred_at, received_at) VALUES (?, 'alpha', 'prod', 'deploy', 'ci_webhook', 'n-fresh', 'old deploy', ?, ?)`,
|
|
107
|
+
)
|
|
108
|
+
.run("chg-old", iso(2200), iso(2200));
|
|
109
|
+
raw
|
|
110
|
+
.prepare(
|
|
111
|
+
`INSERT INTO change_events (id, project_id, environment, kind, source, target_node_id, summary, occurred_at, received_at) VALUES (?, 'alpha', 'prod', 'deploy', 'ci_webhook', 'n-fresh', 'recent deploy', ?, ?)`,
|
|
112
|
+
)
|
|
113
|
+
.run("chg-recent", iso(100), iso(100));
|
|
114
|
+
|
|
115
|
+
// Findings (90d = 2160h, unless counterfactual_verified or operationally_corroborated)
|
|
116
|
+
const insFinding = raw.prepare(
|
|
117
|
+
`INSERT INTO investigation_findings (id, project_id, environment, fingerprint, fingerprint_input_json, title, summary, causal_proof_tier, causal_verification_status, confidence, confidence_breakdown_json, symptom_entity_kind, symptom_entity_id, fault_locus_entity_kind, fault_locus_entity_id, causal_chain_json, cited_evidence_ids_json, actor_type, actor_id, created_at, updated_at) VALUES (?, 'alpha', 'prod', 'fp_1', '{}', 't', 's', 'corroborated_candidate', ?, 0.8, '{}', 'trace', 't1', 'span', 's1', '[]', '["t1"]', 'agent', 'autosre', ?, ?)`,
|
|
118
|
+
);
|
|
119
|
+
insFinding.run("fnd-old-unverified", "unverified", iso(2200), iso(2200));
|
|
120
|
+
insFinding.run(
|
|
121
|
+
"fnd-old-verified",
|
|
122
|
+
"counterfactual_verified",
|
|
123
|
+
iso(2200),
|
|
124
|
+
iso(2200),
|
|
125
|
+
);
|
|
126
|
+
insFinding.run(
|
|
127
|
+
"fnd-old-corroborated",
|
|
128
|
+
"operationally_corroborated",
|
|
129
|
+
iso(2200),
|
|
130
|
+
iso(2200),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const summary = await purgeSecondaryRows(db, {
|
|
134
|
+
retentionHours: 72,
|
|
135
|
+
now: () => NOW,
|
|
136
|
+
});
|
|
137
|
+
expect(summary.ontology_edges).toBe(1);
|
|
138
|
+
expect(summary.ontology_nodes).toBe(1);
|
|
139
|
+
expect(summary.change_events).toBe(1);
|
|
140
|
+
expect(summary.investigation_findings).toBe(1);
|
|
141
|
+
|
|
142
|
+
const remainingFindings = (
|
|
143
|
+
raw
|
|
144
|
+
.prepare(`SELECT id FROM investigation_findings ORDER BY id ASC`)
|
|
145
|
+
.all() as Array<{ id: string }>
|
|
146
|
+
).map((r) => r.id);
|
|
147
|
+
expect(remainingFindings).toEqual([
|
|
148
|
+
"fnd-old-corroborated",
|
|
149
|
+
"fnd-old-verified",
|
|
150
|
+
]);
|
|
151
|
+
db.close();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retention for the secondary tables the hourly sweep did not cover. Each
|
|
3
|
+
* rule is tied to an existing contract rather than a new policy:
|
|
4
|
+
*
|
|
5
|
+
* - `alert_evaluations`: `/internal/alerts/evaluations` accepts at most a
|
|
6
|
+
* 720-hour window, so rows older than that are unreachable through the API.
|
|
7
|
+
* - `ask_evidence_events`: feeds `getTopAskedAnalyses`, whose trailing window
|
|
8
|
+
* is 7 days; rows older than 30 days cannot influence it.
|
|
9
|
+
* - `metric_series`: identity rows whose points have all expired and that
|
|
10
|
+
* were created before the retention window. They hold no data; the next
|
|
11
|
+
* ingest of that identity recreates the row.
|
|
12
|
+
* - `evidence_retrieval_refs`: rows carry their own `expires_at` (copied from
|
|
13
|
+
* the signal they reference); expansions follow their ref.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { evaluateRemediationWatchdogs } from "./autosre-remediation-gate";
|
|
17
|
+
import type { SqlDb } from "./sql-db";
|
|
18
|
+
|
|
19
|
+
export const ALERT_EVALUATIONS_MAX_AGE_HOURS = 720;
|
|
20
|
+
export const ASK_EVIDENCE_EVENTS_MAX_AGE_HOURS = 30 * 24;
|
|
21
|
+
|
|
22
|
+
export interface HousekeepingSummary {
|
|
23
|
+
alert_evaluations: number;
|
|
24
|
+
ask_evidence_events: number;
|
|
25
|
+
metric_series: number;
|
|
26
|
+
evidence_refs: number;
|
|
27
|
+
evidence_ref_expansions: number;
|
|
28
|
+
ontology_edges?: number;
|
|
29
|
+
ontology_nodes?: number;
|
|
30
|
+
change_events?: number;
|
|
31
|
+
incident_investigations?: number;
|
|
32
|
+
investigation_findings?: number;
|
|
33
|
+
remediation_proposals?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface HousekeepingOptions {
|
|
37
|
+
/** Window that bounds orphaned metric series; same value the main sweep uses. */
|
|
38
|
+
retentionHours: number;
|
|
39
|
+
/** Clock override for tests. */
|
|
40
|
+
now?: () => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const changes = (r: { meta?: { changes?: number } }) => r.meta?.changes ?? 0;
|
|
44
|
+
|
|
45
|
+
export async function purgeSecondaryRows(
|
|
46
|
+
db: SqlDb,
|
|
47
|
+
options: HousekeepingOptions,
|
|
48
|
+
): Promise<HousekeepingSummary> {
|
|
49
|
+
const nowMs = options.now?.() ?? Date.now();
|
|
50
|
+
const isoBefore = (hours: number) =>
|
|
51
|
+
new Date(nowMs - hours * 3_600_000).toISOString();
|
|
52
|
+
const nowIso = isoBefore(0);
|
|
53
|
+
|
|
54
|
+
const alertEvaluations = await db
|
|
55
|
+
.prepare(`DELETE FROM alert_evaluations WHERE evaluated_at < ?`)
|
|
56
|
+
.bind(isoBefore(ALERT_EVALUATIONS_MAX_AGE_HOURS))
|
|
57
|
+
.run();
|
|
58
|
+
|
|
59
|
+
const askEvents = await db
|
|
60
|
+
.prepare(`DELETE FROM ask_evidence_events WHERE asked_at < ?`)
|
|
61
|
+
.bind(nowMs - ASK_EVIDENCE_EVENTS_MAX_AGE_HOURS * 3_600_000)
|
|
62
|
+
.run();
|
|
63
|
+
|
|
64
|
+
// Points and exemplars for the window are purged by MetricsStore first;
|
|
65
|
+
// a series created before the window with no remaining points is inert.
|
|
66
|
+
const series = await db
|
|
67
|
+
.prepare(
|
|
68
|
+
`DELETE FROM metric_series
|
|
69
|
+
WHERE created_at < ?
|
|
70
|
+
AND NOT EXISTS (
|
|
71
|
+
SELECT 1 FROM metric_point p WHERE p.series_id = metric_series.id
|
|
72
|
+
)`,
|
|
73
|
+
)
|
|
74
|
+
.bind(isoBefore(options.retentionHours))
|
|
75
|
+
.run();
|
|
76
|
+
|
|
77
|
+
const refs = await db
|
|
78
|
+
.prepare(
|
|
79
|
+
`DELETE FROM evidence_retrieval_refs
|
|
80
|
+
WHERE expires_at IS NOT NULL AND expires_at < ?`,
|
|
81
|
+
)
|
|
82
|
+
.bind(nowIso)
|
|
83
|
+
.run();
|
|
84
|
+
const expansions = await db
|
|
85
|
+
.prepare(
|
|
86
|
+
`DELETE FROM evidence_ref_expansions
|
|
87
|
+
WHERE NOT EXISTS (
|
|
88
|
+
SELECT 1 FROM evidence_retrieval_refs r
|
|
89
|
+
WHERE r.ref_id = evidence_ref_expansions.ref_id
|
|
90
|
+
)`,
|
|
91
|
+
)
|
|
92
|
+
.run();
|
|
93
|
+
|
|
94
|
+
// RFC 0013 — AutoSRE ontology, change events, incident investigations,
|
|
95
|
+
// investigation findings, and remediation proposals retention + watchdog.
|
|
96
|
+
const thirtyDaysAgoIso = isoBefore(30 * 24);
|
|
97
|
+
const ninetyDaysAgoIso = isoBefore(90 * 24);
|
|
98
|
+
let ontologyEdgesPurged = 0;
|
|
99
|
+
let ontologyNodesPurged = 0;
|
|
100
|
+
let changeEventsPurged = 0;
|
|
101
|
+
let incidentInvestigationsPurged = 0;
|
|
102
|
+
let investigationFindingsPurged = 0;
|
|
103
|
+
let remediationProposalsPurged = 0;
|
|
104
|
+
try {
|
|
105
|
+
const edgesRes = await db
|
|
106
|
+
.prepare(`DELETE FROM ontology_edges WHERE last_seen_at < ?`)
|
|
107
|
+
.bind(thirtyDaysAgoIso)
|
|
108
|
+
.run();
|
|
109
|
+
ontologyEdgesPurged = changes(edgesRes);
|
|
110
|
+
|
|
111
|
+
const nodesRes = await db
|
|
112
|
+
.prepare(
|
|
113
|
+
`DELETE FROM ontology_nodes
|
|
114
|
+
WHERE last_seen_at < ?
|
|
115
|
+
AND NOT EXISTS (
|
|
116
|
+
SELECT 1 FROM ontology_edges e
|
|
117
|
+
WHERE e.project_id = ontology_nodes.project_id
|
|
118
|
+
AND e.environment = ontology_nodes.environment
|
|
119
|
+
AND (e.source_node_id = ontology_nodes.id OR e.target_node_id = ontology_nodes.id)
|
|
120
|
+
)`,
|
|
121
|
+
)
|
|
122
|
+
.bind(thirtyDaysAgoIso)
|
|
123
|
+
.run();
|
|
124
|
+
ontologyNodesPurged = changes(nodesRes);
|
|
125
|
+
|
|
126
|
+
const proposalsRes = await db
|
|
127
|
+
.prepare(
|
|
128
|
+
`DELETE FROM remediation_proposals
|
|
129
|
+
WHERE (expires_at IS NOT NULL AND expires_at < ?)
|
|
130
|
+
OR (expires_at IS NULL AND created_at < ?)`,
|
|
131
|
+
)
|
|
132
|
+
.bind(nowIso, ninetyDaysAgoIso)
|
|
133
|
+
.run();
|
|
134
|
+
remediationProposalsPurged = changes(proposalsRes);
|
|
135
|
+
|
|
136
|
+
const findingsRes = await db
|
|
137
|
+
.prepare(
|
|
138
|
+
`DELETE FROM investigation_findings
|
|
139
|
+
WHERE causal_verification_status NOT IN ('counterfactual_verified', 'operationally_corroborated')
|
|
140
|
+
AND ((expires_at IS NOT NULL AND expires_at < ?)
|
|
141
|
+
OR (expires_at IS NULL AND created_at < ?))`,
|
|
142
|
+
)
|
|
143
|
+
.bind(nowIso, ninetyDaysAgoIso)
|
|
144
|
+
.run();
|
|
145
|
+
investigationFindingsPurged = changes(findingsRes);
|
|
146
|
+
|
|
147
|
+
// Preserve change_events referenced by active investigation_findings
|
|
148
|
+
const changesRes = await db
|
|
149
|
+
.prepare(
|
|
150
|
+
`DELETE FROM change_events
|
|
151
|
+
WHERE ((expires_at IS NOT NULL AND expires_at < ?)
|
|
152
|
+
OR (expires_at IS NULL AND occurred_at < ?))
|
|
153
|
+
AND NOT EXISTS (
|
|
154
|
+
SELECT 1 FROM investigation_findings f
|
|
155
|
+
WHERE f.project_id = change_events.project_id
|
|
156
|
+
AND f.environment = change_events.environment
|
|
157
|
+
AND f.contributing_change_id = change_events.id
|
|
158
|
+
)`,
|
|
159
|
+
)
|
|
160
|
+
.bind(nowIso, ninetyDaysAgoIso)
|
|
161
|
+
.run();
|
|
162
|
+
changeEventsPurged = changes(changesRes);
|
|
163
|
+
|
|
164
|
+
const incidentsRes = await db
|
|
165
|
+
.prepare(
|
|
166
|
+
`DELETE FROM incident_investigations
|
|
167
|
+
WHERE status IN ('resolved', 'escalated')
|
|
168
|
+
AND ((expires_at IS NOT NULL AND expires_at < ?)
|
|
169
|
+
OR (expires_at IS NULL AND updated_at < ?))`,
|
|
170
|
+
)
|
|
171
|
+
.bind(nowIso, ninetyDaysAgoIso)
|
|
172
|
+
.run();
|
|
173
|
+
incidentInvestigationsPurged = changes(incidentsRes);
|
|
174
|
+
|
|
175
|
+
await evaluateRemediationWatchdogs(db, { nowIso });
|
|
176
|
+
} catch {
|
|
177
|
+
// Gracefully ignore when running against partial test schemas without 050
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
alert_evaluations: changes(alertEvaluations),
|
|
182
|
+
ask_evidence_events: changes(askEvents),
|
|
183
|
+
metric_series: changes(series),
|
|
184
|
+
evidence_refs: changes(refs),
|
|
185
|
+
evidence_ref_expansions: changes(expansions),
|
|
186
|
+
ontology_edges: ontologyEdgesPurged,
|
|
187
|
+
ontology_nodes: ontologyNodesPurged,
|
|
188
|
+
change_events: changeEventsPurged,
|
|
189
|
+
incident_investigations: incidentInvestigationsPurged,
|
|
190
|
+
investigation_findings: investigationFindingsPurged,
|
|
191
|
+
remediation_proposals: remediationProposalsPurged,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -313,10 +313,10 @@ export async function manifestByAction(
|
|
|
313
313
|
.prepare(
|
|
314
314
|
`SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
315
315
|
FROM session_replay_metadata
|
|
316
|
-
WHERE session_id IN (${sessionPlaceholders})
|
|
316
|
+
WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
|
|
317
317
|
ORDER BY last_chunk_at DESC LIMIT 1`,
|
|
318
318
|
)
|
|
319
|
-
.bind(...sessionIds)
|
|
319
|
+
.bind(projectId, ...sessionIds)
|
|
320
320
|
.first<{
|
|
321
321
|
session_id: string;
|
|
322
322
|
first_chunk_at: string;
|
|
@@ -546,10 +546,10 @@ export async function manifestByAgentRun(
|
|
|
546
546
|
.prepare(
|
|
547
547
|
`SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
548
548
|
FROM session_replay_metadata
|
|
549
|
-
WHERE session_id IN (${sessionPlaceholders})
|
|
549
|
+
WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
|
|
550
550
|
ORDER BY last_chunk_at DESC LIMIT 1`,
|
|
551
551
|
)
|
|
552
|
-
.bind(...sessionIds)
|
|
552
|
+
.bind(projectId, ...sessionIds)
|
|
553
553
|
.first<{
|
|
554
554
|
session_id: string;
|
|
555
555
|
first_chunk_at: string;
|
|
@@ -767,10 +767,10 @@ export async function manifestByActor(
|
|
|
767
767
|
.prepare(
|
|
768
768
|
`SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
769
769
|
FROM session_replay_metadata
|
|
770
|
-
WHERE session_id IN (${sessionPlaceholders})
|
|
770
|
+
WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
|
|
771
771
|
ORDER BY last_chunk_at DESC LIMIT 1`,
|
|
772
772
|
)
|
|
773
|
-
.bind(...sessionIds)
|
|
773
|
+
.bind(projectId, ...sessionIds)
|
|
774
774
|
.first<{
|
|
775
775
|
session_id: string;
|
|
776
776
|
first_chunk_at: string;
|
|
@@ -53,9 +53,9 @@ export async function manifestBySession(
|
|
|
53
53
|
.prepare(
|
|
54
54
|
`SELECT first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
55
55
|
FROM session_replay_metadata
|
|
56
|
-
WHERE session_id = ?`,
|
|
56
|
+
WHERE project_id = ? AND session_id = ?`,
|
|
57
57
|
)
|
|
58
|
-
.bind(sessionId)
|
|
58
|
+
.bind(projectId, sessionId)
|
|
59
59
|
.first<{
|
|
60
60
|
first_chunk_at: string;
|
|
61
61
|
last_chunk_at: string;
|
|
@@ -13,10 +13,13 @@ export async function manifestByUser(
|
|
|
13
13
|
const sessionLimit = Math.max(1, Math.min(opts.sessions ?? 5, 20));
|
|
14
14
|
|
|
15
15
|
// Step 1 — resolve user_id → visitor_id. user_profiles is the only
|
|
16
|
-
// table that owns the user_id ↔ visitor_id binding
|
|
16
|
+
// table that owns the user_id ↔ visitor_id binding, keyed per project
|
|
17
|
+
// (the same user_id can exist in several projects — migration 045).
|
|
17
18
|
const profile = await db
|
|
18
|
-
.prepare(
|
|
19
|
-
|
|
19
|
+
.prepare(
|
|
20
|
+
`SELECT visitor_id FROM user_profiles WHERE project_id = ? AND user_id = ? LIMIT 1`,
|
|
21
|
+
)
|
|
22
|
+
.bind(projectId, userId)
|
|
20
23
|
.first<{ visitor_id: string }>();
|
|
21
24
|
if (!profile) {
|
|
22
25
|
return {
|
|
@@ -105,10 +108,10 @@ export async function manifestByUser(
|
|
|
105
108
|
.prepare(
|
|
106
109
|
`SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
|
|
107
110
|
FROM session_replay_metadata
|
|
108
|
-
WHERE session_id IN (${placeholders})
|
|
111
|
+
WHERE project_id = ? AND session_id IN (${placeholders})
|
|
109
112
|
ORDER BY last_chunk_at DESC LIMIT 1`,
|
|
110
113
|
)
|
|
111
|
-
.bind(...sessionIds)
|
|
114
|
+
.bind(projectId, ...sessionIds)
|
|
112
115
|
.first<{
|
|
113
116
|
session_id: string;
|
|
114
117
|
first_chunk_at: string;
|
|
@@ -81,13 +81,12 @@ describe("IdentityIndex.bySession", () => {
|
|
|
81
81
|
eventsCount: 42,
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
// All five queries pin (project_id, session_id)
|
|
85
|
-
// just session_id since replay is global.
|
|
84
|
+
// All five queries pin (project_id, session_id).
|
|
86
85
|
const allCalls = db.calls.filter((c) => c.op !== "first" || c.binds.length);
|
|
87
86
|
const sessionQueries = allCalls.filter(
|
|
88
87
|
(c) => c.sql.includes("session_id") && c.binds.includes("proj-1"),
|
|
89
88
|
);
|
|
90
|
-
expect(sessionQueries.length).
|
|
89
|
+
expect(sessionQueries.length).toBe(5);
|
|
91
90
|
});
|
|
92
91
|
|
|
93
92
|
it("returns null replay when session_replay_metadata is empty", async () => {
|