@obsunified/collector 2.2.0 → 2.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.js +1 -1
- package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
- package/dist/chunk-2FALTDCE.js.map +1 -0
- package/dist/index.d.ts +61 -3
- package/dist/index.js +10933 -9769
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
- package/src/__tests__/autosre-closed-loop.test.ts +1554 -17
- package/src/auth/dashboard-auth.test.ts +61 -0
- package/src/auth/dashboard-auth.ts +17 -2
- package/src/lib/ai-store/calls.ts +43 -23
- package/src/lib/ai-store.ts +12 -1
- package/src/lib/analyses-runner.ts +44 -7
- package/src/lib/autosre-change-correlation.ts +39 -12
- package/src/lib/autosre-findings.ts +237 -44
- package/src/lib/autosre-ontology-cache.test.ts +78 -0
- package/src/lib/autosre-ontology.ts +52 -28
- package/src/lib/autosre-remediation-gate.ts +942 -348
- package/src/lib/housekeeping.test.ts +70 -0
- package/src/lib/housekeeping.ts +91 -83
- package/src/lib/projects-store.test.ts +38 -1
- package/src/lib/projects-store.ts +23 -4
- package/src/lib/sql-db-postgres.integration.test.ts +75 -2
- package/src/lib/store/ingest.ts +8 -56
- package/src/lib/usage-store.ts +9 -3
- package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
- package/src/migrations/052_remediation_callback.sql +10 -0
- package/src/migrations/053_remediation_callback_delivery.sql +7 -0
- package/src/migrations/alert-state-notified.test.ts +3 -3
- package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
- package/src/migrations-postgres/052_remediation_callback.sql +10 -0
- package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
- package/src/plugins/ai-call-by-id.test.ts +73 -0
- package/src/plugins/ai-receiver.ts +10 -0
- package/src/plugins/alerts-evaluator.ts +15 -3
- package/src/plugins/autosre-routes.ts +374 -108
- package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
- package/src/plugins/connected-routes/manifest.test.ts +142 -0
- package/src/plugins/connected-routes/manifest.ts +123 -6
- package/src/plugins/connected-routes-links.test.ts +96 -0
- package/src/plugins/connected-routes.test.ts +3 -3
- package/src/plugins/connected-routes.ts +56 -83
- package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
- package/src/plugins/evidence-retrieval-routes.ts +397 -25
- package/dist/chunk-KMEZKGSO.js.map +0 -1
|
@@ -62,6 +62,7 @@ describe("purgeSecondaryRows", () => {
|
|
|
62
62
|
ontology_nodes: 0,
|
|
63
63
|
change_events: 0,
|
|
64
64
|
incident_investigations: 0,
|
|
65
|
+
incident_leases_reaped: 0,
|
|
65
66
|
remediation_proposals: 0,
|
|
66
67
|
investigation_findings: 0,
|
|
67
68
|
});
|
|
@@ -150,4 +151,73 @@ describe("purgeSecondaryRows", () => {
|
|
|
150
151
|
]);
|
|
151
152
|
db.close();
|
|
152
153
|
});
|
|
154
|
+
|
|
155
|
+
it("reaps expired leases back to open without touching live leases or non-leased incidents (RFC 0013 § 11)", async () => {
|
|
156
|
+
const db = createMigratedSqliteDb();
|
|
157
|
+
const ins = db.raw.prepare(
|
|
158
|
+
`INSERT INTO incident_investigations (project_id, environment, id, dedup_key, status, target_node_id, symptom_anchor_kind, symptom_anchor_id, lease_owner_id, lease_expires_at, opened_at, updated_at, expires_at)
|
|
159
|
+
VALUES ('alpha', 'prod', ?, ?, ?, 'n', 'trace', 't', ?, ?, ?, ?, ?)`,
|
|
160
|
+
);
|
|
161
|
+
const farFuture = iso(-24 * 365);
|
|
162
|
+
ins.run(
|
|
163
|
+
"lease-expired",
|
|
164
|
+
"k1",
|
|
165
|
+
"leased",
|
|
166
|
+
"runner-a",
|
|
167
|
+
iso(1),
|
|
168
|
+
iso(5),
|
|
169
|
+
iso(5),
|
|
170
|
+
farFuture,
|
|
171
|
+
);
|
|
172
|
+
ins.run(
|
|
173
|
+
"lease-live",
|
|
174
|
+
"k2",
|
|
175
|
+
"leased",
|
|
176
|
+
"runner-b",
|
|
177
|
+
iso(-1),
|
|
178
|
+
iso(5),
|
|
179
|
+
iso(5),
|
|
180
|
+
farFuture,
|
|
181
|
+
);
|
|
182
|
+
ins.run(
|
|
183
|
+
"remediating",
|
|
184
|
+
"k3",
|
|
185
|
+
"remediating",
|
|
186
|
+
"runner-c",
|
|
187
|
+
iso(1),
|
|
188
|
+
iso(5),
|
|
189
|
+
iso(5),
|
|
190
|
+
farFuture,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const summary = await purgeSecondaryRows(db, {
|
|
194
|
+
retentionHours: 72,
|
|
195
|
+
now: () => NOW,
|
|
196
|
+
});
|
|
197
|
+
expect(summary.incident_leases_reaped).toBe(1);
|
|
198
|
+
const rows = db.raw
|
|
199
|
+
.prepare(
|
|
200
|
+
`SELECT id, status, lease_owner_id FROM incident_investigations ORDER BY id`,
|
|
201
|
+
)
|
|
202
|
+
.all() as Array<{
|
|
203
|
+
id: string;
|
|
204
|
+
status: string;
|
|
205
|
+
lease_owner_id: string | null;
|
|
206
|
+
}>;
|
|
207
|
+
expect(rows).toEqual([
|
|
208
|
+
{ id: "lease-expired", status: "open", lease_owner_id: null },
|
|
209
|
+
{ id: "lease-live", status: "leased", lease_owner_id: "runner-b" },
|
|
210
|
+
{ id: "remediating", status: "remediating", lease_owner_id: "runner-c" },
|
|
211
|
+
]);
|
|
212
|
+
db.close();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("propagates storage errors instead of silently swallowing them", async () => {
|
|
216
|
+
const db = createMigratedSqliteDb();
|
|
217
|
+
db.raw.exec("DROP TABLE remediation_proposals");
|
|
218
|
+
await expect(
|
|
219
|
+
purgeSecondaryRows(db, { retentionHours: 72, now: () => NOW }),
|
|
220
|
+
).rejects.toThrow(/remediation_proposals/);
|
|
221
|
+
db.close();
|
|
222
|
+
});
|
|
153
223
|
});
|
package/src/lib/housekeeping.ts
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
* the signal they reference); expansions follow their ref.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { evaluateRemediationWatchdogs } from "./autosre-remediation-gate";
|
|
17
16
|
import type { SqlDb } from "./sql-db";
|
|
18
17
|
|
|
19
18
|
export const ALERT_EVALUATIONS_MAX_AGE_HOURS = 720;
|
|
@@ -29,6 +28,8 @@ export interface HousekeepingSummary {
|
|
|
29
28
|
ontology_nodes?: number;
|
|
30
29
|
change_events?: number;
|
|
31
30
|
incident_investigations?: number;
|
|
31
|
+
/** `leased` incidents whose lease expired and were returned to `open` (RFC 0013 § 11). */
|
|
32
|
+
incident_leases_reaped?: number;
|
|
32
33
|
investigation_findings?: number;
|
|
33
34
|
remediation_proposals?: number;
|
|
34
35
|
}
|
|
@@ -92,90 +93,96 @@ export async function purgeSecondaryRows(
|
|
|
92
93
|
.run();
|
|
93
94
|
|
|
94
95
|
// RFC 0013 — AutoSRE ontology, change events, incident investigations,
|
|
95
|
-
// investigation findings, and remediation proposals retention
|
|
96
|
+
// investigation findings, and remediation proposals retention, plus lease
|
|
97
|
+
// reaping. Errors propagate (the retention caller logs them); the
|
|
98
|
+
// post-remediation watchdog runs on the 1-minute analyses tick instead.
|
|
96
99
|
const thirtyDaysAgoIso = isoBefore(30 * 24);
|
|
97
100
|
const ninetyDaysAgoIso = isoBefore(90 * 24);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
101
|
+
const edgesRes = await db
|
|
102
|
+
.prepare(`DELETE FROM ontology_edges WHERE last_seen_at < ?`)
|
|
103
|
+
.bind(thirtyDaysAgoIso)
|
|
104
|
+
.run();
|
|
105
|
+
const ontologyEdgesPurged = changes(edgesRes);
|
|
106
|
+
|
|
107
|
+
const nodesRes = await db
|
|
108
|
+
.prepare(
|
|
109
|
+
`DELETE FROM ontology_nodes
|
|
110
|
+
WHERE last_seen_at < ?
|
|
111
|
+
AND NOT EXISTS (
|
|
112
|
+
SELECT 1 FROM ontology_edges e
|
|
113
|
+
WHERE e.project_id = ontology_nodes.project_id
|
|
114
|
+
AND e.environment = ontology_nodes.environment
|
|
115
|
+
AND (e.source_node_id = ontology_nodes.id OR e.target_node_id = ontology_nodes.id)
|
|
116
|
+
)`,
|
|
117
|
+
)
|
|
118
|
+
.bind(thirtyDaysAgoIso)
|
|
119
|
+
.run();
|
|
120
|
+
const ontologyNodesPurged = changes(nodesRes);
|
|
121
|
+
|
|
122
|
+
const proposalsRes = await db
|
|
123
|
+
.prepare(
|
|
124
|
+
`DELETE FROM remediation_proposals
|
|
125
|
+
WHERE (expires_at IS NOT NULL AND expires_at < ?)
|
|
126
|
+
OR (expires_at IS NULL AND created_at < ?)`,
|
|
127
|
+
)
|
|
128
|
+
.bind(nowIso, ninetyDaysAgoIso)
|
|
129
|
+
.run();
|
|
130
|
+
const remediationProposalsPurged = changes(proposalsRes);
|
|
131
|
+
|
|
132
|
+
const findingsRes = await db
|
|
133
|
+
.prepare(
|
|
134
|
+
`DELETE FROM investigation_findings
|
|
135
|
+
WHERE causal_verification_status NOT IN ('counterfactual_verified', 'operationally_corroborated')
|
|
136
|
+
AND ((expires_at IS NOT NULL AND expires_at < ?)
|
|
137
|
+
OR (expires_at IS NULL AND created_at < ?))`,
|
|
138
|
+
)
|
|
139
|
+
.bind(nowIso, ninetyDaysAgoIso)
|
|
140
|
+
.run();
|
|
141
|
+
const investigationFindingsPurged = changes(findingsRes);
|
|
142
|
+
|
|
143
|
+
// Preserve change_events referenced by active investigation_findings
|
|
144
|
+
const changesRes = await db
|
|
145
|
+
.prepare(
|
|
146
|
+
`DELETE FROM change_events
|
|
147
|
+
WHERE ((expires_at IS NOT NULL AND expires_at < ?)
|
|
148
|
+
OR (expires_at IS NULL AND occurred_at < ?))
|
|
149
|
+
AND NOT EXISTS (
|
|
150
|
+
SELECT 1 FROM investigation_findings f
|
|
151
|
+
WHERE f.project_id = change_events.project_id
|
|
152
|
+
AND f.environment = change_events.environment
|
|
153
|
+
AND f.contributing_change_id = change_events.id
|
|
154
|
+
)`,
|
|
155
|
+
)
|
|
156
|
+
.bind(nowIso, ninetyDaysAgoIso)
|
|
157
|
+
.run();
|
|
158
|
+
const changeEventsPurged = changes(changesRes);
|
|
159
|
+
|
|
160
|
+
const incidentsRes = await db
|
|
161
|
+
.prepare(
|
|
162
|
+
`DELETE FROM incident_investigations
|
|
163
|
+
WHERE status IN ('resolved', 'escalated')
|
|
164
|
+
AND ((expires_at IS NOT NULL AND expires_at < ?)
|
|
165
|
+
OR (expires_at IS NULL AND updated_at < ?))`,
|
|
166
|
+
)
|
|
167
|
+
.bind(nowIso, ninetyDaysAgoIso)
|
|
168
|
+
.run();
|
|
169
|
+
const incidentInvestigationsPurged = changes(incidentsRes);
|
|
170
|
+
|
|
171
|
+
// Lease reaping (RFC 0013 § 11): a runner that died holding a lease must not
|
|
172
|
+
// strand the incident. Expired `leased` incidents go back to `open` so the
|
|
173
|
+
// next runner can pick them up. `remediating`/`verifying` are owned by the
|
|
174
|
+
// proposal claim/watchdog timeouts, not by this reaper.
|
|
175
|
+
const leasesRes = await db
|
|
176
|
+
.prepare(
|
|
177
|
+
`UPDATE incident_investigations
|
|
178
|
+
SET status = 'open', lease_owner_id = NULL, lease_expires_at = NULL, updated_at = ?
|
|
179
|
+
WHERE status = 'leased'
|
|
180
|
+
AND lease_expires_at IS NOT NULL
|
|
181
|
+
AND lease_expires_at < ?`,
|
|
182
|
+
)
|
|
183
|
+
.bind(nowIso, nowIso)
|
|
184
|
+
.run();
|
|
185
|
+
const incidentLeasesReaped = changes(leasesRes);
|
|
179
186
|
|
|
180
187
|
return {
|
|
181
188
|
alert_evaluations: changes(alertEvaluations),
|
|
@@ -187,6 +194,7 @@ export async function purgeSecondaryRows(
|
|
|
187
194
|
ontology_nodes: ontologyNodesPurged,
|
|
188
195
|
change_events: changeEventsPurged,
|
|
189
196
|
incident_investigations: incidentInvestigationsPurged,
|
|
197
|
+
incident_leases_reaped: incidentLeasesReaped,
|
|
190
198
|
investigation_findings: investigationFindingsPurged,
|
|
191
199
|
remediation_proposals: remediationProposalsPurged,
|
|
192
200
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { sha256Hex } from "./hash";
|
|
3
|
-
import { ProjectsStore } from "./projects-store";
|
|
3
|
+
import { displayPrefix, ProjectsStore } from "./projects-store";
|
|
4
4
|
import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
|
|
5
5
|
|
|
6
6
|
describe("ProjectsStore.revokeKey", () => {
|
|
@@ -23,3 +23,40 @@ describe("ProjectsStore.revokeKey", () => {
|
|
|
23
23
|
db.close();
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
describe("bootstrap key display prefix", () => {
|
|
28
|
+
it("never exposes a short bootstrap key in full", async () => {
|
|
29
|
+
const db = createMigratedSqliteDb();
|
|
30
|
+
const store = new ProjectsStore(db);
|
|
31
|
+
await store.bootstrapEnvKey("short-key-12345"); // 15 chars
|
|
32
|
+
const [key] = (await store.listKeys("default")).filter(
|
|
33
|
+
(k) => k.name === "bootstrap",
|
|
34
|
+
);
|
|
35
|
+
expect(key?.keyPrefix).toBe("short");
|
|
36
|
+
expect("short-key-12345".startsWith(key?.keyPrefix ?? "")).toBe(true);
|
|
37
|
+
expect(key?.keyPrefix.length).toBeLessThan("short-key-12345".length);
|
|
38
|
+
db.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps 16 chars for long keys, matching minted keys", () => {
|
|
42
|
+
expect(displayPrefix("a".repeat(64))).toHaveLength(16);
|
|
43
|
+
expect(displayPrefix("abc")).toBe("a");
|
|
44
|
+
expect(displayPrefix("")).toBe("");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("shrinks a legacy full-length prefix on the next bootstrap", async () => {
|
|
48
|
+
const db = createMigratedSqliteDb();
|
|
49
|
+
const store = new ProjectsStore(db);
|
|
50
|
+
await store.bootstrapEnvKey("legacy-key-1234");
|
|
51
|
+
await db
|
|
52
|
+
.prepare(`UPDATE ingest_keys SET key_prefix = ? WHERE name = 'bootstrap'`)
|
|
53
|
+
.bind("legacy-key-1234")
|
|
54
|
+
.run();
|
|
55
|
+
await store.bootstrapEnvKey("legacy-key-1234");
|
|
56
|
+
const [key] = (await store.listKeys("default")).filter(
|
|
57
|
+
(k) => k.name === "bootstrap",
|
|
58
|
+
);
|
|
59
|
+
expect(key?.keyPrefix).toBe("legac");
|
|
60
|
+
db.close();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -31,6 +31,15 @@ const rowToKey = (row: IngestKeyRow): IngestKey => ({
|
|
|
31
31
|
|
|
32
32
|
const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$/;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Display identifier for a secret: at most 16 chars (what minted keys show),
|
|
36
|
+
* but never more than a third of the secret, so a short operator-chosen
|
|
37
|
+
* INGEST_KEY can never be returned in full by `keys list`.
|
|
38
|
+
*/
|
|
39
|
+
export function displayPrefix(plaintext: string): string {
|
|
40
|
+
return plaintext.slice(0, Math.min(16, Math.floor(plaintext.length / 3)));
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
export class ProjectsStore {
|
|
35
44
|
constructor(private readonly db: SqlDb) {}
|
|
36
45
|
|
|
@@ -189,14 +198,24 @@ export class ProjectsStore {
|
|
|
189
198
|
if (!plaintext) return;
|
|
190
199
|
await this.ensureDefaultProject();
|
|
191
200
|
const keyHash = await sha256Hex(plaintext);
|
|
201
|
+
const keyPrefix = displayPrefix(plaintext);
|
|
192
202
|
const existing = await this.db
|
|
193
|
-
.prepare(`SELECT id FROM ingest_keys WHERE key_hash = ?`)
|
|
203
|
+
.prepare(`SELECT id, key_prefix FROM ingest_keys WHERE key_hash = ?`)
|
|
194
204
|
.bind(keyHash)
|
|
195
|
-
.first<{ id: string }>();
|
|
196
|
-
if (existing)
|
|
205
|
+
.first<{ id: string; key_prefix: string }>();
|
|
206
|
+
if (existing) {
|
|
207
|
+
// Rows written before the prefix was length-capped may hold a short
|
|
208
|
+
// bootstrap key in full; shrink them to the safe display prefix.
|
|
209
|
+
if (existing.key_prefix.length > keyPrefix.length) {
|
|
210
|
+
await this.db
|
|
211
|
+
.prepare(`UPDATE ingest_keys SET key_prefix = ? WHERE id = ?`)
|
|
212
|
+
.bind(keyPrefix, existing.id)
|
|
213
|
+
.run();
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
197
217
|
|
|
198
218
|
const id = randomHex(16);
|
|
199
|
-
const keyPrefix = plaintext.slice(0, Math.min(16, plaintext.length));
|
|
200
219
|
const createdAt = new Date().toISOString();
|
|
201
220
|
await this.db
|
|
202
221
|
.prepare(
|
|
@@ -28,20 +28,22 @@ import type {
|
|
|
28
28
|
StoredSpan,
|
|
29
29
|
UsageEventRecord,
|
|
30
30
|
} from "@obsunified/types";
|
|
31
|
-
import type
|
|
31
|
+
import { Hono, type MiddlewareHandler } from "hono";
|
|
32
32
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
33
33
|
import { deriveAnalysesForProject } from "../analyses/derive";
|
|
34
34
|
import { INVESTIGATION_ANALYSES } from "../analyses/investigations";
|
|
35
35
|
import { TIER0_ANALYSES } from "../analyses/tier0";
|
|
36
36
|
import {
|
|
37
|
-
|
|
37
|
+
CollectorRuntime,
|
|
38
38
|
createTelemetryCollectorApp,
|
|
39
39
|
type SpanProcessorPlugin,
|
|
40
40
|
} from "../framework/collector";
|
|
41
41
|
import type { CollectorEnv, CollectorRouteContext } from "../framework/env";
|
|
42
42
|
import type { DecodedMetricPoint } from "../otlp/decode";
|
|
43
43
|
import { aiSpanPayloadsProcessorPlugin } from "../plugins/ai-span-payloads-processor";
|
|
44
|
+
import { connectedRoutesPlugin } from "../plugins/connected-routes";
|
|
44
45
|
import { identityReceiverPlugin } from "../plugins/identity-receiver";
|
|
46
|
+
import { usageQueryRoutesPlugin } from "../plugins/usage-query-routes";
|
|
45
47
|
import { purgeExpiredActionGraph } from "./action-retention";
|
|
46
48
|
import { ActionStore } from "./actions-store";
|
|
47
49
|
import { getAICallsOverview, ingestAICallBatch } from "./ai-store/calls";
|
|
@@ -1222,4 +1224,75 @@ describe.skipIf(!databaseUrl)("PostgresAdapter against a live Postgres", () => {
|
|
|
1222
1224
|
expect(errorRate.primaryValue).toBe(0.5);
|
|
1223
1225
|
expect(Array.isArray(errorRate.payload.sparkline)).toBe(true);
|
|
1224
1226
|
});
|
|
1227
|
+
|
|
1228
|
+
it("serves the change connected-rail and usage sessions routes without Postgres query errors", async () => {
|
|
1229
|
+
const now = isoNow();
|
|
1230
|
+
const later = isoIn(60 * 60 * 1000);
|
|
1231
|
+
const insertSpan = (spanId: string, startTime: string) =>
|
|
1232
|
+
db
|
|
1233
|
+
.prepare(
|
|
1234
|
+
`INSERT INTO telemetry_spans (
|
|
1235
|
+
project_id, environment, trace_id, span_id, service_name, service_version,
|
|
1236
|
+
span_name, status_code, start_time, end_time, duration_ms, received_at, expires_at
|
|
1237
|
+
) VALUES ('default', 'default', 'trace-pg-1', ?, 'checkout', 'v9.9.9', 'POST /pay', 2, ?, ?, 5, ?, ?)`,
|
|
1238
|
+
)
|
|
1239
|
+
.bind(spanId, startTime, startTime, now, later)
|
|
1240
|
+
.run();
|
|
1241
|
+
await insertSpan("span-pg-a", "2026-01-01T00:00:00.000Z");
|
|
1242
|
+
await insertSpan("span-pg-b", "2026-01-01T00:00:01.000Z");
|
|
1243
|
+
await db
|
|
1244
|
+
.prepare(
|
|
1245
|
+
`INSERT INTO change_events (
|
|
1246
|
+
project_id, environment, id, target_node_id, kind, source, summary,
|
|
1247
|
+
service_name, version, occurred_at, received_at
|
|
1248
|
+
) VALUES ('default', 'default', 'chg-pg-1', 'service:checkout', 'deploy', 'explicit', 'deploy v9.9.9', 'checkout', 'v9.9.9', ?, ?)`,
|
|
1249
|
+
)
|
|
1250
|
+
.bind(now, now)
|
|
1251
|
+
.run();
|
|
1252
|
+
await new UsageStore(db).ingest([
|
|
1253
|
+
usageEvent({
|
|
1254
|
+
eventId: "pg-s1-a",
|
|
1255
|
+
sessionId: "pg-sess-1",
|
|
1256
|
+
pagePath: "/a",
|
|
1257
|
+
}),
|
|
1258
|
+
usageEvent({
|
|
1259
|
+
eventId: "pg-s1-b",
|
|
1260
|
+
sessionId: "pg-sess-1",
|
|
1261
|
+
pagePath: "/b",
|
|
1262
|
+
eventType: "interaction",
|
|
1263
|
+
}),
|
|
1264
|
+
]);
|
|
1265
|
+
|
|
1266
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
1267
|
+
const runtime = new CollectorRuntime();
|
|
1268
|
+
connectedRoutesPlugin.register(app, runtime);
|
|
1269
|
+
usageQueryRoutesPlugin.register(app, runtime);
|
|
1270
|
+
const env = { DB: db as unknown as D1Database } as CollectorEnv;
|
|
1271
|
+
|
|
1272
|
+
const change = await app.request(
|
|
1273
|
+
"/internal/connected/change/chg-pg-1",
|
|
1274
|
+
{},
|
|
1275
|
+
env,
|
|
1276
|
+
);
|
|
1277
|
+
expect(change.status).toBe(200);
|
|
1278
|
+
const manifest = (await change.json()) as {
|
|
1279
|
+
across: Array<{ links: Array<{ entityId: string }> }>;
|
|
1280
|
+
};
|
|
1281
|
+
const spanIds = manifest.across.flatMap((s) =>
|
|
1282
|
+
s.links.map((l) => l.entityId),
|
|
1283
|
+
);
|
|
1284
|
+
// Most recent first, one link per span.
|
|
1285
|
+
expect(spanIds).toEqual(["trace-pg-1:span-pg-b", "trace-pg-1:span-pg-a"]);
|
|
1286
|
+
|
|
1287
|
+
const sessions = await app.request(
|
|
1288
|
+
"/internal/usage/sessions?hours=24",
|
|
1289
|
+
{},
|
|
1290
|
+
env,
|
|
1291
|
+
);
|
|
1292
|
+
expect(sessions.status).toBe(200);
|
|
1293
|
+
const body = (await sessions.json()) as {
|
|
1294
|
+
sessions: Array<{ sessionId: string; lastPath: string | null }>;
|
|
1295
|
+
};
|
|
1296
|
+
expect(body.sessions.map((r) => r.sessionId)).toContain("pg-sess-1");
|
|
1297
|
+
});
|
|
1225
1298
|
});
|
package/src/lib/store/ingest.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { SqlDb } from "../sql-db";
|
|
|
8
8
|
export async function ingestTelemetrySpans(
|
|
9
9
|
db: SqlDb,
|
|
10
10
|
spans: StoredSpan[],
|
|
11
|
+
logger: { warn: (message: string) => void } = console,
|
|
11
12
|
): Promise<{ inserted: number; traceCount: number }> {
|
|
12
13
|
if (spans.length === 0) return { inserted: 0, traceCount: 0 };
|
|
13
14
|
|
|
@@ -82,65 +83,16 @@ export async function ingestTelemetrySpans(
|
|
|
82
83
|
),
|
|
83
84
|
);
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
await db.batch(statements);
|
|
87
|
-
} catch (err) {
|
|
88
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
89
|
-
if (msg.includes("no such column: environment")) {
|
|
90
|
-
// Fallback for unit tests running against pre-050 partial schemas
|
|
91
|
-
const legacyStatements = enrichedSpans.map((span) =>
|
|
92
|
-
db
|
|
93
|
-
.prepare(`
|
|
94
|
-
INSERT OR IGNORE INTO telemetry_spans (
|
|
95
|
-
project_id, trace_id, span_id, parent_span_id, trace_state,
|
|
96
|
-
service_name, scope_name, scope_version, span_name, span_kind,
|
|
97
|
-
status_code, status_message, start_time, end_time, duration_ms,
|
|
98
|
-
attributes_json, dropped_attributes_count,
|
|
99
|
-
resource_attributes_json, events_json, dropped_events_count,
|
|
100
|
-
links_json, dropped_links_count, received_at, expires_at,
|
|
101
|
-
session_id, interaction_id, telemetry_sdk_name
|
|
102
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
103
|
-
`)
|
|
104
|
-
.bind(
|
|
105
|
-
span.projectId,
|
|
106
|
-
span.traceId,
|
|
107
|
-
span.spanId,
|
|
108
|
-
span.parentSpanId,
|
|
109
|
-
span.traceState,
|
|
110
|
-
span.serviceName,
|
|
111
|
-
span.scopeName,
|
|
112
|
-
span.scopeVersion,
|
|
113
|
-
span.spanName,
|
|
114
|
-
span.spanKind,
|
|
115
|
-
span.statusCode,
|
|
116
|
-
span.statusMessage,
|
|
117
|
-
span.startTime,
|
|
118
|
-
span.endTime,
|
|
119
|
-
span.durationMs,
|
|
120
|
-
span.attributesJson,
|
|
121
|
-
span.droppedAttributesCount,
|
|
122
|
-
span.resourceAttributesJson,
|
|
123
|
-
span.eventsJson,
|
|
124
|
-
span.droppedEventsCount,
|
|
125
|
-
span.linksJson,
|
|
126
|
-
span.droppedLinksCount,
|
|
127
|
-
span.receivedAt,
|
|
128
|
-
span.expiresAt,
|
|
129
|
-
span.sessionId ?? null,
|
|
130
|
-
span.interactionId ?? null,
|
|
131
|
-
span.telemetrySdkName ?? null,
|
|
132
|
-
),
|
|
133
|
-
);
|
|
134
|
-
await db.batch(legacyStatements);
|
|
135
|
-
} else {
|
|
136
|
-
throw err;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
86
|
+
await db.batch(statements);
|
|
139
87
|
|
|
140
88
|
try {
|
|
141
89
|
await upsertOntologyFromSpans(db, enrichedSpans);
|
|
142
|
-
} catch {
|
|
143
|
-
//
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// Ontology enrichment must never block span ingest, but a failure here
|
|
92
|
+
// means the causal graph is silently going stale, so surface it.
|
|
93
|
+
logger.warn(
|
|
94
|
+
`upsertOntologyFromSpans failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
95
|
+
);
|
|
144
96
|
}
|
|
145
97
|
|
|
146
98
|
// Trace instrumentation gaps are computed lazily at read time (see
|
package/src/lib/usage-store.ts
CHANGED
|
@@ -503,15 +503,21 @@ export class UsageStore {
|
|
|
503
503
|
SUM(CASE WHEN event_type = 'interaction' THEN 1 ELSE 0 END) AS interaction_count,
|
|
504
504
|
SUM(CASE WHEN event_type = 'frontend_error' OR severity = 'error' THEN 1 ELSE 0 END) AS error_count,
|
|
505
505
|
MAX(CASE WHEN event_type = 'page_view' THEN CAST(COALESCE(${loadMsExpr}, ${loadTimeMsExpr}, ${durationMsExpr}) AS REAL) ELSE NULL END) AS max_load_ms,
|
|
506
|
-
(SELECT referrer FROM usage_events ref WHERE ref.project_id =
|
|
507
|
-
(SELECT page_path FROM usage_events lp WHERE lp.project_id =
|
|
506
|
+
(SELECT referrer FROM usage_events ref WHERE ref.project_id = ? AND ref.session_id = ue.session_id ORDER BY occurred_at ASC LIMIT 1) AS referrer,
|
|
507
|
+
(SELECT page_path FROM usage_events lp WHERE lp.project_id = ? AND lp.session_id = ue.session_id ORDER BY occurred_at DESC LIMIT 1) AS last_path
|
|
508
508
|
FROM usage_events ue
|
|
509
509
|
WHERE project_id = ? AND received_at >= ?
|
|
510
510
|
GROUP BY session_id
|
|
511
511
|
ORDER BY last_seen DESC
|
|
512
512
|
LIMIT ?`,
|
|
513
513
|
)
|
|
514
|
-
.bind(
|
|
514
|
+
.bind(
|
|
515
|
+
options.projectId,
|
|
516
|
+
options.projectId,
|
|
517
|
+
options.projectId,
|
|
518
|
+
cutoff,
|
|
519
|
+
limit * 3,
|
|
520
|
+
)
|
|
515
521
|
.all<{
|
|
516
522
|
session_id: string;
|
|
517
523
|
visitor_id: string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Make active-incident deduplication race-free (RFC 0013 § 6.A, governance review B4).
|
|
2
|
+
--
|
|
3
|
+
-- `openOrDeduplicateIncident` used to SELECT-then-INSERT, so two concurrent
|
|
4
|
+
-- alerts for the same symptom could each open an incident. A partial UNIQUE
|
|
5
|
+
-- index lets the database arbitrate; the application inserts with
|
|
6
|
+
-- ON CONFLICT DO NOTHING and re-reads the winner.
|
|
7
|
+
|
|
8
|
+
-- Collapse any pre-existing duplicate active rows (keep the newest per key) so
|
|
9
|
+
-- the index can be built. Older duplicates are closed as escalated, not deleted.
|
|
10
|
+
UPDATE incident_investigations
|
|
11
|
+
SET status = 'escalated'
|
|
12
|
+
WHERE status IN ('open', 'dispatched', 'leased', 'remediating', 'verifying')
|
|
13
|
+
AND EXISTS (
|
|
14
|
+
SELECT 1 FROM incident_investigations o
|
|
15
|
+
WHERE o.project_id = incident_investigations.project_id
|
|
16
|
+
AND o.environment = incident_investigations.environment
|
|
17
|
+
AND o.dedup_key = incident_investigations.dedup_key
|
|
18
|
+
AND o.status IN ('open', 'dispatched', 'leased', 'remediating', 'verifying')
|
|
19
|
+
AND (
|
|
20
|
+
o.opened_at > incident_investigations.opened_at
|
|
21
|
+
OR (o.opened_at = incident_investigations.opened_at AND o.id > incident_investigations.id)
|
|
22
|
+
)
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_incidents_active_dedup
|
|
26
|
+
ON incident_investigations (project_id, environment, dedup_key)
|
|
27
|
+
WHERE status IN ('open', 'dispatched', 'leased', 'remediating', 'verifying');
|
|
28
|
+
|
|
29
|
+
-- Watchdog (governance review B11): a finding is only `recovered` after two
|
|
30
|
+
-- consecutive checkpoints with sufficient traffic and a healthy error rate.
|
|
31
|
+
ALTER TABLE investigation_findings ADD COLUMN consecutive_ok_checks INTEGER NOT NULL DEFAULT 0;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Persisted grant-handoff callback (RFC 0013 § 6.B, journey review J17).
|
|
2
|
+
--
|
|
3
|
+
-- A proposal parked for human approval may carry a webhook that receives the
|
|
4
|
+
-- one-time grant token when the approval happens later. `callback_url` is the
|
|
5
|
+
-- target (SSRF-guarded again at delivery time); `callback_secret` is the HMAC
|
|
6
|
+
-- signing secret, stored as supplied (the collector has no at-rest encryption
|
|
7
|
+
-- helper) and never returned by any GET. Both are cleared once the grant is
|
|
8
|
+
-- delivered, rejected or expired.
|
|
9
|
+
ALTER TABLE remediation_proposals ADD COLUMN callback_url TEXT;
|
|
10
|
+
ALTER TABLE remediation_proposals ADD COLUMN callback_secret TEXT;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Last grant-handoff webhook delivery outcome (RFC 0013 § 6.B).
|
|
2
|
+
--
|
|
3
|
+
-- JSON `{status: 'delivered'|'failed', attempts, lastError, at}` recorded after
|
|
4
|
+
-- the grant webhook is attempted (up to 3 attempts). It never contains the
|
|
5
|
+
-- callback URL or secret, so unlike callback_url it is kept after approval so
|
|
6
|
+
-- the dashboard can flag a failed handoff.
|
|
7
|
+
ALTER TABLE remediation_proposals ADD COLUMN callback_delivery TEXT;
|
|
@@ -44,9 +44,9 @@ describe("migration 049 (alert_state.notified)", () => {
|
|
|
44
44
|
.map((c) => (c as { name: string }).name);
|
|
45
45
|
expect(columnsBefore).not.toContain("notified");
|
|
46
46
|
|
|
47
|
-
expect(
|
|
48
|
-
|
|
49
|
-
)
|
|
47
|
+
expect(applySqliteMigrations(db.raw, { from: "049", upTo: "049" })).toEqual(
|
|
48
|
+
["049_alert_state_notified.sql"],
|
|
49
|
+
);
|
|
50
50
|
|
|
51
51
|
const notified = db.raw
|
|
52
52
|
.prepare("PRAGMA table_info(alert_state)")
|