@obsunified/collector 2.2.1 → 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 +8 -0
- package/dist/index.js +497 -96
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
- package/src/__tests__/autosre-closed-loop.test.ts +590 -0
- 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/autosre-findings.ts +169 -14
- package/src/lib/autosre-remediation-gate.ts +156 -26
- 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/usage-store.ts +9 -3
- package/src/migrations/052_remediation_callback.sql +10 -0
- package/src/migrations/053_remediation_callback_delivery.sql +7 -0
- package/src/migrations-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/autosre-routes.ts +167 -16
- 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 +127 -7
- 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 +11 -8
- package/src/plugins/evidence-retrieval-routes.ts +30 -0
- package/dist/chunk-KMEZKGSO.js.map +0 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
hrefForEntity,
|
|
4
|
+
linksFromArtifacts,
|
|
5
|
+
linksFromEvalResults,
|
|
6
|
+
linksFromRetrievalEvents,
|
|
7
|
+
linksFromToolCalls,
|
|
8
|
+
MAX_LINKS_INLINE,
|
|
9
|
+
resolveFaultLocusTarget,
|
|
10
|
+
} from "./manifest";
|
|
11
|
+
|
|
12
|
+
// Tabs served by apps/web/src/app/router.ts (KNOWN_TABS). Generated hrefs
|
|
13
|
+
// must start with one of these or the app falls back to Health.
|
|
14
|
+
const ROUTER_TABS = new Set([
|
|
15
|
+
"health",
|
|
16
|
+
"investigate",
|
|
17
|
+
"traces",
|
|
18
|
+
"service-map",
|
|
19
|
+
"issues",
|
|
20
|
+
"logs",
|
|
21
|
+
"ai",
|
|
22
|
+
"evidence",
|
|
23
|
+
"usage",
|
|
24
|
+
"replay",
|
|
25
|
+
"alerts",
|
|
26
|
+
"users",
|
|
27
|
+
"agent-runs",
|
|
28
|
+
"actions",
|
|
29
|
+
"tool-calls",
|
|
30
|
+
"profiles",
|
|
31
|
+
"tool-reliability",
|
|
32
|
+
"evaluations",
|
|
33
|
+
"changes",
|
|
34
|
+
"findings",
|
|
35
|
+
"incidents",
|
|
36
|
+
"remediations",
|
|
37
|
+
]);
|
|
38
|
+
const tabOf = (href: string) =>
|
|
39
|
+
href.replace(/^#\//, "").split(/[/?#]/)[0] ?? "";
|
|
40
|
+
|
|
41
|
+
describe("connected manifest hrefs target real router tabs", () => {
|
|
42
|
+
it("tool call overflow, retrievals, evals and artifacts avoid unknown tabs", () => {
|
|
43
|
+
const many = Array.from({ length: MAX_LINKS_INLINE + 1 }, (_, i) => ({
|
|
44
|
+
id: `t${i}`,
|
|
45
|
+
actionId: "a1",
|
|
46
|
+
toolName: "search",
|
|
47
|
+
}));
|
|
48
|
+
const hrefs = [
|
|
49
|
+
linksFromToolCalls(many as never, "Tools").links[0]?.href,
|
|
50
|
+
linksFromRetrievalEvents(
|
|
51
|
+
[
|
|
52
|
+
{
|
|
53
|
+
id: "r1",
|
|
54
|
+
actionId: "a1",
|
|
55
|
+
retrieverName: "kb",
|
|
56
|
+
totalResults: 3,
|
|
57
|
+
},
|
|
58
|
+
] as never,
|
|
59
|
+
"R",
|
|
60
|
+
).links[0]?.href,
|
|
61
|
+
linksFromEvalResults(
|
|
62
|
+
[{ id: "e1", actionId: "a1", evaluatorName: "g", passed: 1 }] as never,
|
|
63
|
+
"E",
|
|
64
|
+
).links[0]?.href,
|
|
65
|
+
linksFromArtifacts(
|
|
66
|
+
[
|
|
67
|
+
{
|
|
68
|
+
id: "x1",
|
|
69
|
+
actionId: "a1",
|
|
70
|
+
artifactName: "plan",
|
|
71
|
+
artifactType: "json",
|
|
72
|
+
},
|
|
73
|
+
] as never,
|
|
74
|
+
"A",
|
|
75
|
+
).links[0]?.href,
|
|
76
|
+
];
|
|
77
|
+
expect(hrefs).toEqual([
|
|
78
|
+
"#/tool-calls",
|
|
79
|
+
"#/actions/a1",
|
|
80
|
+
"#/evaluations/e1",
|
|
81
|
+
"#/actions/a1",
|
|
82
|
+
]);
|
|
83
|
+
for (const href of hrefs)
|
|
84
|
+
expect(ROUTER_TABS.has(tabOf(href ?? ""))).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("hrefForEntity only emits router tabs for every evidence kind", () => {
|
|
88
|
+
for (const kind of [
|
|
89
|
+
"trace",
|
|
90
|
+
"span",
|
|
91
|
+
"alert",
|
|
92
|
+
"analysis",
|
|
93
|
+
"service",
|
|
94
|
+
"agent_run",
|
|
95
|
+
"action",
|
|
96
|
+
"tool_call",
|
|
97
|
+
"ai_call",
|
|
98
|
+
"profile",
|
|
99
|
+
"change",
|
|
100
|
+
"finding",
|
|
101
|
+
"incident",
|
|
102
|
+
"eval",
|
|
103
|
+
"log",
|
|
104
|
+
"replay",
|
|
105
|
+
"docs",
|
|
106
|
+
]) {
|
|
107
|
+
expect(ROUTER_TABS.has(tabOf(hrefForEntity(kind, "id-1"))), kind).toBe(
|
|
108
|
+
true,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
expect(hrefForEntity("trace", "abc")).toBe("#/traces?trace=abc");
|
|
112
|
+
expect(hrefForEntity("alert", "al 1")).toBe("#/alerts?id=al%201");
|
|
113
|
+
expect(hrefForEntity("ai_call", "c1")).toBe("#/ai/calls/c1");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("resolveFaultLocusTarget", () => {
|
|
118
|
+
const trace = "a".repeat(32);
|
|
119
|
+
const span = "b".repeat(16);
|
|
120
|
+
it("classifies service nodes, bare services and spans", () => {
|
|
121
|
+
expect(
|
|
122
|
+
resolveFaultLocusTarget("service:prod:default:checkout-api"),
|
|
123
|
+
).toEqual({
|
|
124
|
+
entityKind: "service",
|
|
125
|
+
entityId: "service:prod:default:checkout-api",
|
|
126
|
+
href: "#/service-map?service=checkout-api",
|
|
127
|
+
});
|
|
128
|
+
expect(resolveFaultLocusTarget("checkout-api")).toMatchObject({
|
|
129
|
+
entityKind: "service",
|
|
130
|
+
href: "#/service-map?service=checkout-api",
|
|
131
|
+
});
|
|
132
|
+
expect(resolveFaultLocusTarget(`${trace}:${span}`)).toEqual({
|
|
133
|
+
entityKind: "span",
|
|
134
|
+
entityId: `${trace}:${span}`,
|
|
135
|
+
href: `#/traces?trace=${trace}&span=${span}`,
|
|
136
|
+
});
|
|
137
|
+
expect(resolveFaultLocusTarget(span)).toMatchObject({
|
|
138
|
+
entityKind: "span",
|
|
139
|
+
href: `#/evidence?kind=span&id=${span}`,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CausalProofTier,
|
|
3
|
+
EvidenceEntityKind,
|
|
4
|
+
RelationshipCertainty,
|
|
5
|
+
} from "@obsunified/types";
|
|
2
6
|
import type {
|
|
3
7
|
ActionRef,
|
|
4
8
|
AICallRef,
|
|
@@ -55,7 +59,7 @@ export interface ConnectedLink {
|
|
|
55
59
|
href: string;
|
|
56
60
|
count?: number;
|
|
57
61
|
sample?: string;
|
|
58
|
-
entityKind?: ConnectedEntityKind |
|
|
62
|
+
entityKind?: ConnectedEntityKind | EvidenceEntityKind;
|
|
59
63
|
entityId?: string;
|
|
60
64
|
source?: string;
|
|
61
65
|
confidence?: number;
|
|
@@ -344,7 +348,9 @@ export const linksFromAi = (
|
|
|
344
348
|
label: prefix,
|
|
345
349
|
links: calls.map((c) => ({
|
|
346
350
|
label: `${c.provider} · ${c.modelName}`,
|
|
347
|
-
href: `#/ai
|
|
351
|
+
href: `#/ai/calls/${encodeURIComponent(c.callId)}`,
|
|
352
|
+
entityKind: "ai_call",
|
|
353
|
+
entityId: c.callId,
|
|
348
354
|
})),
|
|
349
355
|
};
|
|
350
356
|
};
|
|
@@ -451,7 +457,7 @@ export const linksFromToolCalls = (
|
|
|
451
457
|
links: [
|
|
452
458
|
{
|
|
453
459
|
label: `${calls.length} tool calls`,
|
|
454
|
-
href: `#/
|
|
460
|
+
href: `#/tool-calls`,
|
|
455
461
|
count: calls.length,
|
|
456
462
|
sample: truncate(calls[0].toolName),
|
|
457
463
|
entityKind: "tool_call",
|
|
@@ -488,7 +494,8 @@ export const linksFromRetrievalEvents = (
|
|
|
488
494
|
label: prefix,
|
|
489
495
|
links: events.map((r) => ({
|
|
490
496
|
label: `retrieve: ${r.retrieverName} (${r.totalResults} docs)`,
|
|
491
|
-
|
|
497
|
+
// No dedicated retrieval tab: the owning action's detail page lists it.
|
|
498
|
+
href: `#/actions/${encodeURIComponent(r.actionId)}`,
|
|
492
499
|
})),
|
|
493
500
|
};
|
|
494
501
|
};
|
|
@@ -511,7 +518,9 @@ export const linksFromEvalResults = (
|
|
|
511
518
|
label: prefix,
|
|
512
519
|
links: results.map((e) => ({
|
|
513
520
|
label: `eval: ${e.evaluatorName} (${e.passed ? "passed" : "failed"})`,
|
|
514
|
-
|
|
521
|
+
// The Evaluations tab resolves this id to an eval case, falling back
|
|
522
|
+
// to the case saved from the same action.
|
|
523
|
+
href: `#/evaluations/${encodeURIComponent(e.id)}`,
|
|
515
524
|
entityKind: "eval",
|
|
516
525
|
entityId: e.id,
|
|
517
526
|
...metadataForAction?.(e.actionId),
|
|
@@ -534,7 +543,118 @@ export const linksFromArtifacts = (
|
|
|
534
543
|
label: prefix,
|
|
535
544
|
links: arts.map((a) => ({
|
|
536
545
|
label: `artifact: ${a.artifactName} (${a.artifactType})`,
|
|
537
|
-
|
|
546
|
+
// No dedicated artifact tab: the owning action's detail page lists it.
|
|
547
|
+
href: `#/actions/${encodeURIComponent(a.actionId)}`,
|
|
538
548
|
})),
|
|
539
549
|
};
|
|
540
550
|
};
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Dashboard destination for an evidence entity, using only routes the web
|
|
554
|
+
* router (`apps/web/src/app/router.ts`) actually serves. Unknown kinds fall
|
|
555
|
+
* back to the evidence explorer so a link never lands on the Health tab.
|
|
556
|
+
*/
|
|
557
|
+
export const hrefForEntity = (kind: string, id: string): string => {
|
|
558
|
+
const enc = encodeURIComponent;
|
|
559
|
+
switch (kind) {
|
|
560
|
+
case "trace":
|
|
561
|
+
return `#/traces?trace=${enc(id)}`;
|
|
562
|
+
case "span": {
|
|
563
|
+
const [traceId, spanId] = id.split(":");
|
|
564
|
+
return spanId && traceId
|
|
565
|
+
? `#/traces?trace=${enc(traceId)}&span=${enc(spanId)}`
|
|
566
|
+
: `#/evidence?kind=span&id=${enc(id)}`;
|
|
567
|
+
}
|
|
568
|
+
case "alert":
|
|
569
|
+
return `#/alerts?id=${enc(id)}`;
|
|
570
|
+
case "analysis":
|
|
571
|
+
return "#/health";
|
|
572
|
+
case "service":
|
|
573
|
+
return `#/service-map?service=${enc(id)}`;
|
|
574
|
+
case "agent_run":
|
|
575
|
+
return `#/agent-runs/${enc(id)}`;
|
|
576
|
+
case "action":
|
|
577
|
+
return `#/actions/${enc(id)}`;
|
|
578
|
+
case "tool_call":
|
|
579
|
+
return `#/tool-calls/${enc(id)}`;
|
|
580
|
+
case "ai_call":
|
|
581
|
+
return `#/ai/calls/${enc(id)}`;
|
|
582
|
+
case "profile":
|
|
583
|
+
return `#/profiles/${enc(id)}`;
|
|
584
|
+
case "change":
|
|
585
|
+
return `#/changes/${enc(id)}`;
|
|
586
|
+
case "finding":
|
|
587
|
+
return `#/findings/${enc(id)}`;
|
|
588
|
+
case "incident":
|
|
589
|
+
return `#/incidents/${enc(id)}`;
|
|
590
|
+
case "eval":
|
|
591
|
+
return `#/evaluations/${enc(id)}`;
|
|
592
|
+
case "log":
|
|
593
|
+
return `#/logs?id=${enc(id)}`;
|
|
594
|
+
case "replay":
|
|
595
|
+
return `#/replay?session=${enc(id)}`;
|
|
596
|
+
default:
|
|
597
|
+
return `#/evidence?kind=${enc(kind)}&id=${enc(id)}`;
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
export interface FaultLocusTarget {
|
|
602
|
+
entityKind: "service" | "span";
|
|
603
|
+
entityId: string;
|
|
604
|
+
href: string;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const HEX_SPAN_PAIR = /^[0-9a-f]{32}:[0-9a-f]{16}$/i;
|
|
608
|
+
const HEX_SPAN_ID = /^[0-9a-f]{16}$/i;
|
|
609
|
+
const NODE_KINDS = new Set([
|
|
610
|
+
"service",
|
|
611
|
+
"endpoint",
|
|
612
|
+
"database",
|
|
613
|
+
"queue",
|
|
614
|
+
"messaging_topic",
|
|
615
|
+
"topic",
|
|
616
|
+
"cache",
|
|
617
|
+
"external",
|
|
618
|
+
]);
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* A finding's `faultLocusEntityId` is a canonical ontology node id
|
|
622
|
+
* (`service:<env>:<ns>:<name>`, `endpoint:<env>:<ns>:<svc>:...`), a
|
|
623
|
+
* `traceId:spanId` pair, a bare span id, or a bare service name. Resolve it to
|
|
624
|
+
* the entity kind it really is and the surface that shows that kind.
|
|
625
|
+
*/
|
|
626
|
+
export const resolveFaultLocusTarget = (
|
|
627
|
+
faultLocusEntityId: string,
|
|
628
|
+
): FaultLocusTarget => {
|
|
629
|
+
const id = faultLocusEntityId;
|
|
630
|
+
if (HEX_SPAN_PAIR.test(id)) {
|
|
631
|
+
return {
|
|
632
|
+
entityKind: "span",
|
|
633
|
+
entityId: id,
|
|
634
|
+
href: hrefForEntity("span", id),
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
if (HEX_SPAN_ID.test(id)) {
|
|
638
|
+
return {
|
|
639
|
+
entityKind: "span",
|
|
640
|
+
entityId: id,
|
|
641
|
+
href: hrefForEntity("span", id),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const segments = id.split(":");
|
|
645
|
+
if (segments.length > 1 && NODE_KINDS.has(segments[0] ?? "")) {
|
|
646
|
+
// service:<env>:<ns>:<name> and endpoint:<env>:<ns>:<svc>:... both carry
|
|
647
|
+
// the owning service name at index 3.
|
|
648
|
+
const name = segments[3] || segments[segments.length - 1] || id;
|
|
649
|
+
return {
|
|
650
|
+
entityKind: "service",
|
|
651
|
+
entityId: id,
|
|
652
|
+
href: hrefForEntity("service", name),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
return {
|
|
656
|
+
entityKind: "service",
|
|
657
|
+
entityId: id,
|
|
658
|
+
href: hrefForEntity("service", id),
|
|
659
|
+
};
|
|
660
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { CollectorRuntime } from "../framework/collector";
|
|
4
|
+
import type { CollectorEnv } from "../framework/env";
|
|
5
|
+
import { recordInvestigationFinding } from "../lib/autosre-findings";
|
|
6
|
+
import { openOrDeduplicateIncident } from "../lib/autosre-remediation-gate";
|
|
7
|
+
import { createMigratedSqliteDb } from "../lib/test-utils/node-sqlite-db";
|
|
8
|
+
import { connectedRoutesPlugin } from "./connected-routes";
|
|
9
|
+
|
|
10
|
+
type Link = { href: string; entityKind?: string; entityId?: string };
|
|
11
|
+
type Manifest = { up: { links: Link[] }[]; across: { links: Link[] }[] };
|
|
12
|
+
|
|
13
|
+
const SERVICE_NODE = "service:prod:default:checkout-api";
|
|
14
|
+
|
|
15
|
+
const setup = () => {
|
|
16
|
+
const db = createMigratedSqliteDb();
|
|
17
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
18
|
+
connectedRoutesPlugin.register(app, new CollectorRuntime());
|
|
19
|
+
const env = { DB: db as unknown as D1Database } as CollectorEnv;
|
|
20
|
+
const get = async (path: string) =>
|
|
21
|
+
(await (await app.request(path, {}, env)).json()) as Manifest;
|
|
22
|
+
return { db, get };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const openIncident = async (
|
|
26
|
+
db: ReturnType<typeof setup>["db"],
|
|
27
|
+
kind: "trace" | "alert",
|
|
28
|
+
anchorId: string,
|
|
29
|
+
) =>
|
|
30
|
+
(
|
|
31
|
+
await openOrDeduplicateIncident(db, "default", {
|
|
32
|
+
environment: "prod",
|
|
33
|
+
targetNodeId: SERVICE_NODE,
|
|
34
|
+
symptomAnchorKind: kind,
|
|
35
|
+
symptomAnchorId: anchorId,
|
|
36
|
+
symptomFingerprint: `fp_${kind}`,
|
|
37
|
+
symptomCategory: `fp_${kind}`,
|
|
38
|
+
})
|
|
39
|
+
).incident;
|
|
40
|
+
|
|
41
|
+
describe("connected links carry the real entity kind and a matching href", () => {
|
|
42
|
+
it("incident trigger signal for a trace anchor is a trace link", async () => {
|
|
43
|
+
const { db, get } = setup();
|
|
44
|
+
const incident = await openIncident(db, "trace", "trace-123");
|
|
45
|
+
const m = await get(
|
|
46
|
+
`/internal/connected/incident/${incident.id}?environment=prod`,
|
|
47
|
+
);
|
|
48
|
+
expect(m.up[0]?.links[0]).toMatchObject({
|
|
49
|
+
entityKind: "trace",
|
|
50
|
+
entityId: "trace-123",
|
|
51
|
+
href: "#/traces?trace=trace-123",
|
|
52
|
+
});
|
|
53
|
+
db.close();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("incident trigger signal for an alert anchor stays an alert link", async () => {
|
|
57
|
+
const { db, get } = setup();
|
|
58
|
+
const incident = await openIncident(db, "alert", "rule-9");
|
|
59
|
+
const m = await get(
|
|
60
|
+
`/internal/connected/incident/${incident.id}?environment=prod`,
|
|
61
|
+
);
|
|
62
|
+
expect(m.up[0]?.links[0]).toMatchObject({
|
|
63
|
+
entityKind: "alert",
|
|
64
|
+
entityId: "rule-9",
|
|
65
|
+
href: "#/alerts?id=rule-9",
|
|
66
|
+
});
|
|
67
|
+
db.close();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it.each([
|
|
71
|
+
[SERVICE_NODE, "service", "#/service-map?service=checkout-api"],
|
|
72
|
+
["checkout-api", "service", "#/service-map?service=checkout-api"],
|
|
73
|
+
])("finding fault locus %s links as %s", async (locus, kind, href) => {
|
|
74
|
+
const { db, get } = setup();
|
|
75
|
+
const incident = await openIncident(db, "trace", "t1");
|
|
76
|
+
const finding = await recordInvestigationFinding(db, "default", {
|
|
77
|
+
environment: "prod",
|
|
78
|
+
incidentId: incident.id,
|
|
79
|
+
title: "Regression",
|
|
80
|
+
summary: "Errors after deploy",
|
|
81
|
+
causalProofTier: "corroborated_candidate",
|
|
82
|
+
symptomEntityKind: "trace",
|
|
83
|
+
symptomEntityId: "t1",
|
|
84
|
+
faultLocusEntityKind: "service",
|
|
85
|
+
faultLocusEntityId: locus,
|
|
86
|
+
causalChain: [],
|
|
87
|
+
citedEvidenceIds: [],
|
|
88
|
+
});
|
|
89
|
+
const m = await get(
|
|
90
|
+
`/internal/connected/finding/${finding.id}?environment=prod`,
|
|
91
|
+
);
|
|
92
|
+
const link = m.across[0]?.links[0];
|
|
93
|
+
expect(link).toMatchObject({ entityKind: kind, entityId: locus, href });
|
|
94
|
+
db.close();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -470,7 +470,7 @@ describe("ConnectedRail manifest — raw signal action back-links", () => {
|
|
|
470
470
|
m.down.find((s) => s.label === "Evaluations for this span")?.links[0],
|
|
471
471
|
).toMatchObject({
|
|
472
472
|
label: "eval: groundedness (passed)",
|
|
473
|
-
href: "#/
|
|
473
|
+
href: "#/evaluations/eval-explicit",
|
|
474
474
|
});
|
|
475
475
|
expect(
|
|
476
476
|
m.related.find((s) => s.label === "Agent runs for this span")?.links[0],
|
|
@@ -749,7 +749,7 @@ describe("ConnectedRail manifest — raw signal action back-links", () => {
|
|
|
749
749
|
).toMatchObject({ href: "#/tool-calls/tool-explicit" });
|
|
750
750
|
expect(
|
|
751
751
|
m.down.find((s) => s.label === "Evaluations for this AI call")?.links[0],
|
|
752
|
-
).toMatchObject({ href: "#/
|
|
752
|
+
).toMatchObject({ href: "#/evaluations/eval-explicit" });
|
|
753
753
|
expect(
|
|
754
754
|
m.related.find((s) => s.label === "Agent runs for this AI call")
|
|
755
755
|
?.links[0],
|
|
@@ -1280,7 +1280,7 @@ describe("ConnectedRail scenario acceptance contracts", () => {
|
|
|
1280
1280
|
const aiSection = userManifest.across.find((s) =>
|
|
1281
1281
|
s.label.toLowerCase().includes("ai call"),
|
|
1282
1282
|
);
|
|
1283
|
-
expect(aiSection?.links[0].href).toBe("#/ai
|
|
1283
|
+
expect(aiSection?.links[0].href).toBe("#/ai/calls/ai-heavy-1");
|
|
1284
1284
|
|
|
1285
1285
|
const sessionManifest = await fetch(
|
|
1286
1286
|
"/internal/connected/usage/sess-heavy?session_id=sess-heavy",
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type ActionContextLinkMetadata,
|
|
43
43
|
type ConnectedEntityKind,
|
|
44
44
|
type ConnectedManifest,
|
|
45
|
+
hrefForEntity,
|
|
45
46
|
KNOWN_KINDS,
|
|
46
47
|
linksFromActions,
|
|
47
48
|
linksFromAi,
|
|
@@ -59,6 +60,7 @@ import {
|
|
|
59
60
|
linkToTrace,
|
|
60
61
|
MAX_LINKS_INLINE,
|
|
61
62
|
profileLinksForTrace,
|
|
63
|
+
resolveFaultLocusTarget,
|
|
62
64
|
} from "./connected-routes/manifest";
|
|
63
65
|
|
|
64
66
|
export type {
|
|
@@ -1115,10 +1117,11 @@ export const connectedRoutesPlugin: CollectorPlugin = {
|
|
|
1115
1117
|
const spans = changeRow.version
|
|
1116
1118
|
? await db
|
|
1117
1119
|
.prepare(
|
|
1118
|
-
`SELECT
|
|
1120
|
+
`SELECT trace_id, span_id, service_name, span_name
|
|
1119
1121
|
FROM telemetry_spans
|
|
1120
1122
|
WHERE project_id = ? AND environment = ? AND service_version = ? AND status_code = 2
|
|
1121
|
-
|
|
1123
|
+
GROUP BY trace_id, span_id, service_name, span_name
|
|
1124
|
+
ORDER BY MAX(start_time) DESC LIMIT 5`,
|
|
1122
1125
|
)
|
|
1123
1126
|
.bind(projectId, changeRow.environment, changeRow.version)
|
|
1124
1127
|
.all<{
|
|
@@ -1195,7 +1198,7 @@ export const connectedRoutesPlugin: CollectorPlugin = {
|
|
|
1195
1198
|
links: [
|
|
1196
1199
|
{
|
|
1197
1200
|
label: `fault locus: ${findingRow.faultLocusEntityId}`,
|
|
1198
|
-
|
|
1201
|
+
...resolveFaultLocusTarget(findingRow.faultLocusEntityId),
|
|
1199
1202
|
relationshipCertainty: "exact",
|
|
1200
1203
|
causalProofTier: proofTier,
|
|
1201
1204
|
},
|
|
@@ -1249,11 +1252,11 @@ export const connectedRoutesPlugin: CollectorPlugin = {
|
|
|
1249
1252
|
links: [
|
|
1250
1253
|
{
|
|
1251
1254
|
label: `${incRow.symptomAnchorKind}: ${incRow.symptomAnchorId}`,
|
|
1252
|
-
href:
|
|
1253
|
-
|
|
1254
|
-
incRow.
|
|
1255
|
-
|
|
1256
|
-
|
|
1255
|
+
href: hrefForEntity(
|
|
1256
|
+
incRow.symptomAnchorKind,
|
|
1257
|
+
incRow.symptomAnchorId,
|
|
1258
|
+
),
|
|
1259
|
+
entityKind: incRow.symptomAnchorKind,
|
|
1257
1260
|
entityId: incRow.symptomAnchorId,
|
|
1258
1261
|
relationshipCertainty: "exact",
|
|
1259
1262
|
},
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
TelemetrySpanDetail,
|
|
12
12
|
TelemetryTraceDetailResponse,
|
|
13
13
|
} from "@obsunified/types";
|
|
14
|
+
import { buildFindingFingerprint } from "@obsunified/types";
|
|
14
15
|
import type { CollectorPlugin } from "../framework/collector";
|
|
15
16
|
import {
|
|
16
17
|
computeChangeBlastRadius,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
listChangeEvents,
|
|
19
20
|
} from "../lib/autosre-change-correlation";
|
|
20
21
|
import {
|
|
22
|
+
fingerprintInputFromSpan,
|
|
21
23
|
getInvestigationFinding,
|
|
22
24
|
searchInvestigationFindings,
|
|
23
25
|
} from "../lib/autosre-findings";
|
|
@@ -1607,6 +1609,34 @@ const attachAutoSreTriageContext = async (
|
|
|
1607
1609
|
limit: 5,
|
|
1608
1610
|
});
|
|
1609
1611
|
}
|
|
1612
|
+
// Secondary match: the same recurrence fingerprint (service + route +
|
|
1613
|
+
// error signature) in this project, so a failure that recurs on a
|
|
1614
|
+
// different span id still surfaces the findings and fixes recorded
|
|
1615
|
+
// before. Entity-id matches stay first.
|
|
1616
|
+
if (faultLocus?.faultLocusSpan) {
|
|
1617
|
+
const spanFp = fingerprintInputFromSpan(
|
|
1618
|
+
faultLocus.faultLocusSpan,
|
|
1619
|
+
environment,
|
|
1620
|
+
);
|
|
1621
|
+
const fingerprint = buildFindingFingerprint({
|
|
1622
|
+
...spanFp,
|
|
1623
|
+
normalizedOperation: spanFp.normalizedOperation ?? "",
|
|
1624
|
+
errorSignature: spanFp.errorSignature ?? "",
|
|
1625
|
+
}).fingerprint;
|
|
1626
|
+
const byFingerprint = await searchInvestigationFindings(db, {
|
|
1627
|
+
projectId,
|
|
1628
|
+
environment,
|
|
1629
|
+
fingerprint,
|
|
1630
|
+
limit: 5,
|
|
1631
|
+
});
|
|
1632
|
+
const seen = new Set(priorFindingsList.map((f) => f.id));
|
|
1633
|
+
for (const f of byFingerprint) {
|
|
1634
|
+
if (!seen.has(f.id) && priorFindingsList.length < 5) {
|
|
1635
|
+
priorFindingsList.push(f);
|
|
1636
|
+
seen.add(f.id);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1610
1640
|
// No unfiltered fallback: unrelated findings would read as relevant
|
|
1611
1641
|
// prior art. An empty list is the honest answer.
|
|
1612
1642
|
const priorFindings = priorFindingsList.map((f) => ({
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/lib/hash.ts","../src/lib/projects-store.ts","../src/lib/sql-db.ts","../src/auth/ingest-auth.ts"],"sourcesContent":["/**\n * Cross-runtime crypto helpers using Web Crypto API.\n * Works in Cloudflare Workers, Node 20+, Deno, Bun.\n */\n\n/** SHA-256 hash of a string, returned as lowercase hex. */\nexport async function sha256Hex(input: string): Promise<string> {\n\tconst encoder = new TextEncoder();\n\tconst digest = await crypto.subtle.digest(\"SHA-256\", encoder.encode(input));\n\treturn bytesToHex(new Uint8Array(digest));\n}\n\n/** Generate `bytes` cryptographically random bytes and return as lowercase hex. */\nexport function randomHex(bytes: number): string {\n\tconst buf = new Uint8Array(bytes);\n\tcrypto.getRandomValues(buf);\n\treturn bytesToHex(buf);\n}\n\nfunction bytesToHex(bytes: Uint8Array): string {\n\tlet out = \"\";\n\tfor (let i = 0; i < bytes.length; i++) {\n\t\tout += bytes[i].toString(16).padStart(2, \"0\");\n\t}\n\treturn out;\n}\n","import type {\n\tIngestKey,\n\tIngestKeyRow,\n\tIngestKeyWithPlaintext,\n\tProject,\n\tProjectRow,\n} from \"@obsunified/types\";\nimport { randomHex, sha256Hex } from \"./hash\";\nimport type { SqlDb } from \"./sql-db\";\n\nconst rowToProject = (row: ProjectRow): Project => ({\n\tid: row.id,\n\tname: row.name,\n\tslug: row.slug,\n\tcreatedAt: row.created_at,\n\tpayloadCaptureEnabled:\n\t\trow.payload_capture_enabled === true ||\n\t\trow.payload_capture_enabled === 1 ||\n\t\trow.payload_capture_enabled === \"1\" ||\n\t\trow.payload_capture_enabled === \"true\",\n});\n\nconst rowToKey = (row: IngestKeyRow): IngestKey => ({\n\tid: row.id,\n\tprojectId: row.project_id,\n\tname: row.name,\n\tkeyPrefix: row.key_prefix,\n\tcreatedAt: row.created_at,\n\trevokedAt: row.revoked_at,\n});\n\nconst SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$/;\n\nexport class ProjectsStore {\n\tconstructor(private readonly db: SqlDb) {}\n\n\tasync listProjects(): Promise<Project[]> {\n\t\tconst rs = await this.db\n\t\t\t.prepare(`SELECT * FROM projects ORDER BY created_at ASC`)\n\t\t\t.all<ProjectRow>();\n\t\treturn (rs.results ?? []).map(rowToProject);\n\t}\n\n\tasync getProject(id: string): Promise<Project | null> {\n\t\tconst row = await this.db\n\t\t\t.prepare(`SELECT * FROM projects WHERE id = ?`)\n\t\t\t.bind(id)\n\t\t\t.first<ProjectRow>();\n\t\treturn row ? rowToProject(row) : null;\n\t}\n\n\tasync createProject(input: { name: string; slug: string }): Promise<Project> {\n\t\tconst name = input.name.trim();\n\t\tconst slug = input.slug.trim().toLowerCase();\n\t\tif (!name) throw new Error(\"Project name is required\");\n\t\tif (!SLUG_PATTERN.test(slug))\n\t\t\tthrow new Error(\n\t\t\t\t\"Slug must be lowercase letters, digits, and hyphens (max 50 chars)\",\n\t\t\t);\n\n\t\tconst id = randomHex(16);\n\t\tconst createdAt = new Date().toISOString();\n\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT INTO projects (id, name, slug, created_at) VALUES (?, ?, ?, ?)`,\n\t\t\t)\n\t\t\t.bind(id, name, slug, createdAt)\n\t\t\t.run();\n\n\t\treturn { id, name, slug, createdAt, payloadCaptureEnabled: false };\n\t}\n\n\tasync updateProject(\n\t\tid: string,\n\t\tpatch: { name?: string; payloadCaptureEnabled?: boolean },\n\t): Promise<Project | null> {\n\t\tconst existing = await this.getProject(id);\n\t\tif (!existing) return null;\n\n\t\tconst name = patch.name?.trim();\n\t\tlet next = existing;\n\t\tif (name !== undefined && name !== existing.name) {\n\t\t\tawait this.db\n\t\t\t\t.prepare(`UPDATE projects SET name = ? WHERE id = ?`)\n\t\t\t\t.bind(name, id)\n\t\t\t\t.run();\n\t\t\tnext = { ...next, name };\n\t\t}\n\t\tif (\n\t\t\tpatch.payloadCaptureEnabled !== undefined &&\n\t\t\tpatch.payloadCaptureEnabled !== existing.payloadCaptureEnabled\n\t\t) {\n\t\t\tawait this.db\n\t\t\t\t.prepare(`UPDATE projects SET payload_capture_enabled = ? WHERE id = ?`)\n\t\t\t\t.bind(patch.payloadCaptureEnabled ? 1 : 0, id)\n\t\t\t\t.run();\n\t\t\tnext = {\n\t\t\t\t...next,\n\t\t\t\tpayloadCaptureEnabled: patch.payloadCaptureEnabled,\n\t\t\t};\n\t\t}\n\t\treturn next;\n\t}\n\n\tasync ensureDefaultProject(): Promise<void> {\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT OR IGNORE INTO projects (id, name, slug, created_at) VALUES ('default', 'Default', 'default', ?)`,\n\t\t\t)\n\t\t\t.bind(new Date().toISOString())\n\t\t\t.run();\n\t}\n\n\t// ── Ingest keys ──\n\n\tasync listKeys(projectId: string): Promise<IngestKey[]> {\n\t\tconst rs = await this.db\n\t\t\t.prepare(\n\t\t\t\t`SELECT * FROM ingest_keys WHERE project_id = ? ORDER BY created_at DESC`,\n\t\t\t)\n\t\t\t.bind(projectId)\n\t\t\t.all<IngestKeyRow>();\n\t\treturn (rs.results ?? []).map(rowToKey);\n\t}\n\n\tasync createKey(\n\t\tprojectId: string,\n\t\tname: string,\n\t): Promise<IngestKeyWithPlaintext> {\n\t\tconst project = await this.getProject(projectId);\n\t\tif (!project) throw new Error(\"Project not found\");\n\n\t\tconst trimmedName = name.trim() || \"unnamed\";\n\t\tconst id = randomHex(16);\n\t\tconst plaintext = `obs_${project.slug}_${randomHex(24)}`;\n\t\tconst keyHash = await sha256Hex(plaintext);\n\t\t// Prefix: show enough to identify but not recover (first 16 chars).\n\t\tconst keyPrefix = plaintext.slice(0, 16);\n\t\tconst createdAt = new Date().toISOString();\n\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT INTO ingest_keys (id, project_id, key_hash, key_prefix, name, created_at) VALUES (?, ?, ?, ?, ?, ?)`,\n\t\t\t)\n\t\t\t.bind(id, projectId, keyHash, keyPrefix, trimmedName, createdAt)\n\t\t\t.run();\n\n\t\treturn {\n\t\t\tid,\n\t\t\tprojectId,\n\t\t\tname: trimmedName,\n\t\t\tkeyPrefix,\n\t\t\tcreatedAt,\n\t\t\trevokedAt: null,\n\t\t\tkey: plaintext,\n\t\t\twarning: \"This key will not be shown again. Store it somewhere safe.\",\n\t\t};\n\t}\n\n\tasync revokeKey(keyId: string, projectId: string): Promise<boolean> {\n\t\tconst result = await this.db\n\t\t\t.prepare(\n\t\t\t\t`UPDATE ingest_keys SET revoked_at = ?\n\t\t\t\t WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,\n\t\t\t)\n\t\t\t.bind(new Date().toISOString(), keyId, projectId)\n\t\t\t.run();\n\t\treturn (result.meta?.changes ?? 0) > 0;\n\t}\n\n\tasync findByKeyHash(\n\t\tkeyHash: string,\n\t): Promise<{ projectId: string; keyId: string } | null> {\n\t\tconst row = await this.db\n\t\t\t.prepare(\n\t\t\t\t`SELECT id, project_id FROM ingest_keys WHERE key_hash = ? AND revoked_at IS NULL`,\n\t\t\t)\n\t\t\t.bind(keyHash)\n\t\t\t.first<{ id: string; project_id: string }>();\n\t\treturn row ? { projectId: row.project_id, keyId: row.id } : null;\n\t}\n\n\t/**\n\t * Idempotently register the legacy env INGEST_KEY as a bootstrap key on the\n\t * default project, so existing deployments keep working without manual migration.\n\t */\n\tasync bootstrapEnvKey(plaintext: string): Promise<void> {\n\t\tif (!plaintext) return;\n\t\tawait this.ensureDefaultProject();\n\t\tconst keyHash = await sha256Hex(plaintext);\n\t\tconst existing = await this.db\n\t\t\t.prepare(`SELECT id FROM ingest_keys WHERE key_hash = ?`)\n\t\t\t.bind(keyHash)\n\t\t\t.first<{ id: string }>();\n\t\tif (existing) return;\n\n\t\tconst id = randomHex(16);\n\t\tconst keyPrefix = plaintext.slice(0, Math.min(16, plaintext.length));\n\t\tconst createdAt = new Date().toISOString();\n\t\tawait this.db\n\t\t\t.prepare(\n\t\t\t\t`INSERT INTO ingest_keys (id, project_id, key_hash, key_prefix, name, created_at) VALUES (?, 'default', ?, ?, 'bootstrap', ?)`,\n\t\t\t)\n\t\t\t.bind(id, keyHash, keyPrefix, createdAt)\n\t\t\t.run();\n\t}\n}\n","/**\n * Storage seam for the collector — RFC 0008.\n *\n * Stores and plugins target this interface instead of `D1Database` directly,\n * so a future Node / better-sqlite3 / ClickHouse runtime is one new adapter\n * away rather than a fork-and-rewrite. The shape is deliberately D1's\n * existing `prepare → bind → first|all|run` chain so the migration of\n * existing stores is mechanical (search-and-replace the constructor type).\n *\n * Today there is one implementation, `D1Adapter`. A `MemSqlDb` test double\n * lives next door under `test-utils/`; `BetterSqliteAdapter` and\n * `ClickHouseAdapter` are deferred until they have a real runtime to serve.\n */\n\nexport interface SqlDb {\n\treadonly dialect?: SqlDialect;\n\tprepare(sql: string): SqlStatement;\n\t/**\n\t * Run multiple prepared statements as a batch. On D1 this maps to a\n\t * single network round-trip; on a future better-sqlite3 adapter it\n\t * wraps the calls in a transaction. Returns one result per statement\n\t * in input order, mirroring D1's `batch`.\n\t *\n\t * Statements MUST have been produced by this same `SqlDb` instance —\n\t * mixing adapters is an error and runtime behavior is undefined.\n\t */\n\tbatch(\n\t\tstatements: SqlStatement[],\n\t): Promise<Array<{ meta: { changes: number } }>>;\n}\n\nexport type SqlDialectName = \"sqlite\" | \"postgres\";\n\nexport interface SqlDialect {\n\treadonly name: SqlDialectName;\n\tnow(): string;\n\tsinceHours(param: string): string;\n\tsinceMinutes(param: string): string;\n\tjsonText(expr: string, path: string): string;\n}\n\nexport interface SqlStatement {\n\tbind(...args: unknown[]): SqlStatement;\n\tfirst<T = Record<string, unknown>>(): Promise<T | null>;\n\tall<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n\trun(): Promise<{ meta: { changes: number } }>;\n}\n\n/**\n * D1 already conforms to this shape almost exactly; the adapter normalizes\n * two small differences:\n * - `all()` on D1 returns `results?: T[]` (optional). We coerce to `[]`.\n * - `run()`'s `meta.changes` is optional on D1. We coerce to `0`.\n * Both differences are runtime-undetectable for the queries this codebase\n * issues (every successful all() returns results; every run() against an\n * INSERT/UPDATE/DELETE reports changes), but coercing keeps the typed\n * interface honest.\n */\nexport class D1Adapter implements SqlDb {\n\treadonly dialect = sqliteDialect;\n\n\tconstructor(private readonly d1: D1Database) {}\n\n\tprepare(sql: string): SqlStatement {\n\t\treturn new D1StatementAdapter(this.d1.prepare(sql));\n\t}\n\n\tasync batch(\n\t\tstatements: SqlStatement[],\n\t): Promise<Array<{ meta: { changes: number } }>> {\n\t\t// Unwrap each statement back to its D1 form — D1's batch takes\n\t\t// D1PreparedStatement[], not our typed wrapper.\n\t\tconst unwrapped = statements.map((s) => {\n\t\t\tif (!(s instanceof D1StatementAdapter)) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"D1Adapter.batch: statement was not produced by D1Adapter\",\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn s.unwrap();\n\t\t});\n\t\tconst results = await this.d1.batch(unwrapped);\n\t\treturn results.map((r) => ({\n\t\t\tmeta: { changes: r.meta?.changes ?? 0 },\n\t\t}));\n\t}\n}\n\n/**\n * Convenience wrapper for the common case: take a `CollectorEnv`-shaped\n * object and return an `SqlDb`. Most plugins don't have access to the\n * `CollectorRuntime` (they get it at register time, not handler time)\n * so they call this directly. Plugins that *do* hold the runtime should\n * prefer `runtime.getSqlDb(env)` so a host-supplied `sqlDb` factory in\n * `CollectorConfig` takes effect.\n */\nexport const sqlDbFor = (env: { DB: D1Database | SqlDb }): SqlDb =>\n\tisSqlDb(env.DB) ? env.DB : new D1Adapter(env.DB);\n\n/**\n * SQLite `strftime` format that reproduces `Date#toISOString()` output\n * (`YYYY-MM-DDTHH:MM:SS.mmmZ`). Every stored timestamp column holds that\n * shape, so time windows must be rendered the same way: `datetime('now')`\n * yields `YYYY-MM-DD HH:MM:SS` and in a text comparison the space sorts\n * before the `T`, which made \"last 5 minutes\" match the whole current UTC\n * day on D1 and made `expires_at < now` purges run a day late.\n */\nexport const SQLITE_ISO_FORMAT = \"%Y-%m-%dT%H:%M:%fZ\";\n\n/** Render SQLite `now` (optionally shifted by a modifier expression) as ISO text. */\nexport const sqliteIsoNow = (modifier?: string): string =>\n\tmodifier\n\t\t? `strftime('${SQLITE_ISO_FORMAT}', 'now', ${modifier})`\n\t\t: `strftime('${SQLITE_ISO_FORMAT}', 'now')`;\n\nexport const sqliteDialect: SqlDialect = {\n\tname: \"sqlite\",\n\tnow: () => sqliteIsoNow(),\n\tsinceHours: (param) => sqliteIsoNow(`'-' || ${param} || ' hours'`),\n\tsinceMinutes: (param) => sqliteIsoNow(`'-' || ${param} || ' minutes'`),\n\tjsonText: (expr, path) => `json_extract(${expr}, '${path}')`,\n};\n\n/**\n * The collector stores every timestamp column (`received_at`, `expires_at`,\n * `occurred_at`, …) as TEXT holding `Date#toISOString()` output on both\n * D1 and Postgres. Postgres will not compare TEXT against a\n * `timestamptz`, so time-window expressions have to be rendered as ISO\n * text in the same `YYYY-MM-DDTHH:MM:SS.mmmZ` shape the collector writes.\n */\nconst POSTGRES_ISO_TEXT_FORMAT = `'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'`;\nconst POSTGRES_UTC_NOW = \"(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')\";\n\n/** Render a Postgres `timestamp` expression as collector-style ISO text. */\nexport const postgresIsoText = (expr: string): string =>\n\t`to_char(${expr}, ${POSTGRES_ISO_TEXT_FORMAT})`;\n\nexport const postgresDialect: SqlDialect = {\n\tname: \"postgres\",\n\tnow: () => postgresIsoText(POSTGRES_UTC_NOW),\n\tsinceHours: (param) =>\n\t\tpostgresIsoText(\n\t\t\t`${POSTGRES_UTC_NOW} - (${param}::text || ' hours')::interval`,\n\t\t),\n\tsinceMinutes: (param) =>\n\t\tpostgresIsoText(\n\t\t\t`${POSTGRES_UTC_NOW} - (${param}::text || ' minutes')::interval`,\n\t\t),\n\tjsonText: (expr, path) => {\n\t\tconst key = path\n\t\t\t.replace(/^\\$\\./, \"\")\n\t\t\t.replace(/^\"|\"$/g, \"\")\n\t\t\t.replace(/\\\\u002E/g, \".\");\n\t\treturn `(${expr}::jsonb ->> '${key.replace(/'/g, \"''\")}')`;\n\t},\n};\n\nexport const dialectFor = (db: SqlDb): SqlDialect =>\n\tdb.dialect ?? sqliteDialect;\n\nconst isSqlDb = (db: unknown): db is SqlDb => {\n\tif (!db || typeof db !== \"object\") return false;\n\tconst candidate = db as {\n\t\tprepare?: unknown;\n\t\tbatch?: unknown;\n\t\texec?: unknown;\n\t};\n\treturn (\n\t\ttypeof candidate.prepare === \"function\" &&\n\t\ttypeof candidate.batch === \"function\" &&\n\t\ttypeof candidate.exec !== \"function\"\n\t);\n};\n\nclass D1StatementAdapter implements SqlStatement {\n\tconstructor(private readonly stmt: D1PreparedStatement) {}\n\n\t/** Internal escape hatch for `D1Adapter.batch` to recover the wrapped\n\t * D1 statement. Not part of the public `SqlStatement` shape. */\n\tunwrap(): D1PreparedStatement {\n\t\treturn this.stmt;\n\t}\n\n\tbind(...args: unknown[]): SqlStatement {\n\t\treturn new D1StatementAdapter(this.stmt.bind(...args));\n\t}\n\n\tfirst<T = Record<string, unknown>>(): Promise<T | null> {\n\t\treturn this.stmt.first<T>();\n\t}\n\n\tasync all<T = Record<string, unknown>>(): Promise<{ results: T[] }> {\n\t\tconst r = await this.stmt.all<T>();\n\t\treturn { results: r.results ?? [] };\n\t}\n\n\tasync run(): Promise<{ meta: { changes: number } }> {\n\t\tconst r = await this.stmt.run();\n\t\treturn { meta: { changes: r.meta?.changes ?? 0 } };\n\t}\n}\n","import type { MiddlewareHandler } from \"hono\";\nimport type { CollectorEnv } from \"../framework/env\";\nimport { sha256Hex } from \"../lib/hash\";\nimport { ProjectsStore } from \"../lib/projects-store\";\nimport { sqlDbFor } from \"../lib/sql-db\";\n\nconst CACHE_TTL_MS = 60_000; // 60 seconds\nconst CACHE_MAX_ENTRIES = 500;\n\ninterface CacheEntry {\n\tprojectId: string;\n\texpiresAt: number;\n}\n\nlet keyCaches = new WeakMap<object, Map<string, CacheEntry>>();\nlet bootstrapDone = false;\n\nfunction cacheFor(dbKey: object): Map<string, CacheEntry> {\n\tlet cache = keyCaches.get(dbKey);\n\tif (!cache) {\n\t\tcache = new Map();\n\t\tkeyCaches.set(dbKey, cache);\n\t}\n\treturn cache;\n}\n\nfunction getCached(\n\tcache: Map<string, CacheEntry>,\n\thash: string,\n\tnow: number,\n): string | null {\n\tpruneKeyCache(cache, now);\n\tconst entry = cache.get(hash);\n\tif (!entry) return null;\n\tif (entry.expiresAt <= now) {\n\t\tcache.delete(hash);\n\t\treturn null;\n\t}\n\treturn entry.projectId;\n}\n\nfunction setCached(\n\tcache: Map<string, CacheEntry>,\n\thash: string,\n\tprojectId: string,\n\tnow: number,\n): void {\n\tpruneKeyCache(cache, now);\n\twhile (cache.size >= CACHE_MAX_ENTRIES) {\n\t\tconst oldestKey = cache.keys().next().value;\n\t\tif (!oldestKey) break;\n\t\tcache.delete(oldestKey);\n\t}\n\tcache.set(hash, { projectId, expiresAt: now + CACHE_TTL_MS });\n}\n\nfunction pruneKeyCache(cache: Map<string, CacheEntry>, now: number): void {\n\tfor (const [hash, entry] of cache) {\n\t\tif (entry.expiresAt <= now) cache.delete(hash);\n\t}\n}\n\n/** Test helper — clears the in-memory cache and bootstrap flag. */\nexport function resetIngestAuthCache(): void {\n\tkeyCaches = new WeakMap();\n\tbootstrapDone = false;\n}\n\n/**\n * Creates middleware that validates ingest API keys against the ingest_keys table.\n *\n * Accepts the key from either:\n * - Authorization: Bearer <key>\n * - X-API-Key: <key>\n *\n * On success, sets `projectId` on the Hono context via `c.set('projectId', ...)`.\n *\n * On the first request per isolate, if `env.INGEST_KEY` is set, the middleware\n * registers it as a `bootstrap` key on the default project — so existing\n * single-tenant deployments continue to work without manual migration.\n */\nexport function createIngestAuth(config?: {\n\tallowUnauthenticated?: boolean;\n}): MiddlewareHandler<{\n\tBindings: CollectorEnv;\n\tVariables: { projectId: string };\n}> {\n\treturn async (c, next) => {\n\t\t// Lazy bootstrap of legacy env key (idempotent).\n\t\tif (!bootstrapDone) {\n\t\t\tconst legacy = c.env.INGEST_KEY || c.env.TELEMETRY_INGEST_TOKEN;\n\t\t\tif (legacy) {\n\t\t\t\tconst store = new ProjectsStore(sqlDbFor(c.env));\n\t\t\t\ttry {\n\t\t\t\t\tawait store.bootstrapEnvKey(legacy);\n\t\t\t\t\t// Latch only after success: a transient DB error on the first\n\t\t\t\t\t// request must not permanently disable legacy-key bootstrap for\n\t\t\t\t\t// the isolate. bootstrapEnvKey is idempotent, so concurrent\n\t\t\t\t\t// first-requests racing here are harmless.\n\t\t\t\t\tbootstrapDone = true;\n\t\t\t\t} catch (err) {\n\t\t\t\t\tconsole.error(\"[ingest-auth] bootstrap failed:\", err);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Nothing to bootstrap — don't keep re-checking on every request.\n\t\t\t\tbootstrapDone = true;\n\t\t\t}\n\t\t}\n\n\t\tconst bearer = c.req.header(\"Authorization\")?.replace(/^Bearer\\s+/i, \"\");\n\t\tconst apiKey = c.req.header(\"X-API-Key\");\n\t\tconst token = bearer || apiKey;\n\n\t\tif (!token) {\n\t\t\tif (\n\t\t\t\tconfig?.allowUnauthenticated ||\n\t\t\t\tc.env.ALLOW_UNAUTHENTICATED === \"true\"\n\t\t\t) {\n\t\t\t\tc.set(\"projectId\", \"default\");\n\t\t\t\treturn next();\n\t\t\t}\n\t\t\treturn c.json({ error: \"Unauthorized\" }, 401);\n\t\t}\n\n\t\tconst now = Date.now();\n\t\tconst hash = await sha256Hex(token);\n\t\tconst rawDb =\n\t\t\t(c.env.DB as unknown as Record<string, unknown>)?.__unwrapped_db ||\n\t\t\tc.env.DB;\n\t\tconst cache = cacheFor(rawDb);\n\t\tconst cached = getCached(cache, hash, now);\n\t\tif (cached) {\n\t\t\tc.set(\"projectId\", cached);\n\t\t\treturn next();\n\t\t}\n\n\t\tconst store = new ProjectsStore(sqlDbFor(c.env));\n\t\tconst match = await store.findByKeyHash(hash);\n\t\tif (!match) {\n\t\t\treturn c.json({ error: \"Unauthorized\" }, 401);\n\t\t}\n\n\t\tsetCached(cache, hash, match.projectId, now);\n\t\tc.set(\"projectId\", match.projectId);\n\t\treturn next();\n\t};\n}\n"],"mappings":"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","names":["store"]}
|