@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
|
@@ -11,14 +11,26 @@ 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
|
+
getChangeEvent,
|
|
17
19
|
listChangeEvents,
|
|
18
20
|
} from "../lib/autosre-change-correlation";
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
import {
|
|
22
|
+
fingerprintInputFromSpan,
|
|
23
|
+
getInvestigationFinding,
|
|
24
|
+
searchInvestigationFindings,
|
|
25
|
+
} from "../lib/autosre-findings";
|
|
26
|
+
import {
|
|
27
|
+
getOntologyGraph,
|
|
28
|
+
loadTraceFaultLocusFromDb,
|
|
29
|
+
} from "../lib/autosre-ontology";
|
|
30
|
+
import {
|
|
31
|
+
getIncidentInvestigation,
|
|
32
|
+
listRemediationProposals,
|
|
33
|
+
} from "../lib/autosre-remediation-gate";
|
|
22
34
|
import type {
|
|
23
35
|
ActionRef,
|
|
24
36
|
EntityManifestExtended,
|
|
@@ -1468,11 +1480,56 @@ const buildActionGraphBundle = (
|
|
|
1468
1480
|
return bundle;
|
|
1469
1481
|
};
|
|
1470
1482
|
|
|
1483
|
+
/**
|
|
1484
|
+
* Resolves the trace to enrich an anchor with. Prefers the anchor's own
|
|
1485
|
+
* symptom entity (a trace id, or the trace owning a span); only when that
|
|
1486
|
+
* cannot be resolved does it fall back to the most recent error span in the
|
|
1487
|
+
* environment (a heuristic: it may belong to an unrelated request).
|
|
1488
|
+
*/
|
|
1489
|
+
const resolveAnchorTraceId = async (
|
|
1490
|
+
db: SqlDb,
|
|
1491
|
+
projectId: string,
|
|
1492
|
+
environment: string,
|
|
1493
|
+
symptom?: { kind: string; id: string } | null,
|
|
1494
|
+
): Promise<string | null> => {
|
|
1495
|
+
if (symptom?.id) {
|
|
1496
|
+
if (symptom.kind === "trace") {
|
|
1497
|
+
const row = await db
|
|
1498
|
+
.prepare(
|
|
1499
|
+
`SELECT trace_id FROM telemetry_spans
|
|
1500
|
+
WHERE project_id = ? AND trace_id = ? LIMIT 1`,
|
|
1501
|
+
)
|
|
1502
|
+
.bind(projectId, symptom.id)
|
|
1503
|
+
.first<{ trace_id: string }>();
|
|
1504
|
+
if (row?.trace_id) return row.trace_id;
|
|
1505
|
+
} else if (symptom.kind === "span") {
|
|
1506
|
+
const row = await db
|
|
1507
|
+
.prepare(
|
|
1508
|
+
`SELECT trace_id FROM telemetry_spans
|
|
1509
|
+
WHERE project_id = ? AND span_id = ? LIMIT 1`,
|
|
1510
|
+
)
|
|
1511
|
+
.bind(projectId, symptom.id)
|
|
1512
|
+
.first<{ trace_id: string }>();
|
|
1513
|
+
if (row?.trace_id) return row.trace_id;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
const recent = await db
|
|
1517
|
+
.prepare(
|
|
1518
|
+
`SELECT trace_id FROM telemetry_spans
|
|
1519
|
+
WHERE project_id = ? AND environment = ? AND status_code = 2
|
|
1520
|
+
ORDER BY start_time DESC LIMIT 1`,
|
|
1521
|
+
)
|
|
1522
|
+
.bind(projectId, environment)
|
|
1523
|
+
.first<{ trace_id: string }>();
|
|
1524
|
+
return recent?.trace_id ?? null;
|
|
1525
|
+
};
|
|
1526
|
+
|
|
1471
1527
|
const attachAutoSreTriageContext = async (
|
|
1472
1528
|
db: SqlDb,
|
|
1473
1529
|
projectId: string,
|
|
1474
1530
|
traceId: string | null,
|
|
1475
1531
|
bundle: EvidenceBundle,
|
|
1532
|
+
logger?: { warn: (message: string, meta?: Record<string, unknown>) => void },
|
|
1476
1533
|
): Promise<EvidenceBundle> => {
|
|
1477
1534
|
try {
|
|
1478
1535
|
const faultLocus = traceId
|
|
@@ -1483,11 +1540,24 @@ const attachAutoSreTriageContext = async (
|
|
|
1483
1540
|
bundle.environment ??
|
|
1484
1541
|
"default"
|
|
1485
1542
|
).toLowerCase();
|
|
1486
|
-
const
|
|
1543
|
+
const locusService = faultLocus?.faultLocusSpan?.service_name ?? null;
|
|
1544
|
+
const allRecentChanges = await listChangeEvents(db, {
|
|
1487
1545
|
projectId,
|
|
1488
1546
|
environment,
|
|
1489
|
-
limit: 5,
|
|
1547
|
+
limit: locusService ? 25 : 5,
|
|
1490
1548
|
});
|
|
1549
|
+
// When the fault locus service is known, only changes that target it
|
|
1550
|
+
// (or its node) are candidates; unrelated deploys are noise.
|
|
1551
|
+
const recentChanges = locusService
|
|
1552
|
+
? allRecentChanges
|
|
1553
|
+
.filter(
|
|
1554
|
+
(ch) =>
|
|
1555
|
+
ch.serviceName === locusService ||
|
|
1556
|
+
(faultLocus?.faultLocusNodeId &&
|
|
1557
|
+
ch.targetNodeId === faultLocus.faultLocusNodeId),
|
|
1558
|
+
)
|
|
1559
|
+
.slice(0, 5)
|
|
1560
|
+
: allRecentChanges;
|
|
1491
1561
|
const blastRadii = (
|
|
1492
1562
|
await Promise.all(
|
|
1493
1563
|
recentChanges
|
|
@@ -1539,13 +1609,36 @@ const attachAutoSreTriageContext = async (
|
|
|
1539
1609
|
limit: 5,
|
|
1540
1610
|
});
|
|
1541
1611
|
}
|
|
1542
|
-
|
|
1543
|
-
|
|
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, {
|
|
1544
1627
|
projectId,
|
|
1545
1628
|
environment,
|
|
1629
|
+
fingerprint,
|
|
1546
1630
|
limit: 5,
|
|
1547
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
|
+
}
|
|
1548
1639
|
}
|
|
1640
|
+
// No unfiltered fallback: unrelated findings would read as relevant
|
|
1641
|
+
// prior art. An empty list is the honest answer.
|
|
1549
1642
|
const priorFindings = priorFindingsList.map((f) => ({
|
|
1550
1643
|
findingId: f.id,
|
|
1551
1644
|
title: f.title,
|
|
@@ -1567,8 +1660,7 @@ const attachAutoSreTriageContext = async (
|
|
|
1567
1660
|
source: br.overlappingCodePaths.length
|
|
1568
1661
|
? "stack_frame_overlap"
|
|
1569
1662
|
: "cohort_comparison",
|
|
1570
|
-
confidence:
|
|
1571
|
-
br.cohortVerdict === "isolated_to_new_version" ? 0.9 : 0.65,
|
|
1663
|
+
confidence: br.cohortVerdict === "isolated_to_new_version" ? 0.9 : 0.65,
|
|
1572
1664
|
relationshipCertainty: br.relationshipCertainty,
|
|
1573
1665
|
causalProofTier: br.causalProofTier,
|
|
1574
1666
|
reason: br.summary,
|
|
@@ -1624,13 +1716,28 @@ const attachAutoSreTriageContext = async (
|
|
|
1624
1716
|
});
|
|
1625
1717
|
}
|
|
1626
1718
|
|
|
1719
|
+
let isBlackoutSuspected = false;
|
|
1720
|
+
if (faultLocus?.faultLocusNodeId) {
|
|
1721
|
+
try {
|
|
1722
|
+
const graph = await getOntologyGraph(db, { projectId, environment });
|
|
1723
|
+
isBlackoutSuspected = Boolean(
|
|
1724
|
+
graph.nodes.find((n) => n.id === faultLocus.faultLocusNodeId)
|
|
1725
|
+
?.isBlackoutSuspected,
|
|
1726
|
+
);
|
|
1727
|
+
} catch (err) {
|
|
1728
|
+
logger?.warn("[evidence-retrieval] blackout check failed", {
|
|
1729
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1627
1734
|
bundle.autosre = {
|
|
1628
1735
|
faultLocus: faultLocus?.faultLocusNodeId
|
|
1629
1736
|
? {
|
|
1630
1737
|
nodeId: faultLocus.faultLocusNodeId,
|
|
1631
1738
|
spanId: faultLocus.faultLocusSpan?.span_id,
|
|
1632
1739
|
relationshipCertainty: faultLocus.relationshipCertainty,
|
|
1633
|
-
isBlackoutSuspected
|
|
1740
|
+
isBlackoutSuspected,
|
|
1634
1741
|
}
|
|
1635
1742
|
: undefined,
|
|
1636
1743
|
candidateChanges,
|
|
@@ -1638,13 +1745,39 @@ const attachAutoSreTriageContext = async (
|
|
|
1638
1745
|
priorFindings,
|
|
1639
1746
|
};
|
|
1640
1747
|
if (bundle.budget) {
|
|
1748
|
+
// Keep the autosre section inside the caller's token budget by
|
|
1749
|
+
// dropping the least-relevant tail entries (and their evidence refs).
|
|
1750
|
+
const autosre = bundle.autosre;
|
|
1751
|
+
const target = bundle.budget.targetTokens ?? Number.POSITIVE_INFINITY;
|
|
1752
|
+
const dropRef = (evidenceId: string) => {
|
|
1753
|
+
bundle.evidenceReferences = bundle.evidenceReferences.filter(
|
|
1754
|
+
(ref) => ref.evidenceId !== evidenceId,
|
|
1755
|
+
);
|
|
1756
|
+
};
|
|
1757
|
+
while (estimateTokens(bundle) > target) {
|
|
1758
|
+
if (autosre.priorFindings.length > 0) {
|
|
1759
|
+
const dropped = autosre.priorFindings.pop();
|
|
1760
|
+
if (dropped) dropRef(`finding:${dropped.findingId}`);
|
|
1761
|
+
} else if (autosre.contentionCandidates.length > 0) {
|
|
1762
|
+
autosre.contentionCandidates.pop();
|
|
1763
|
+
} else if (autosre.candidateChanges.length > 0) {
|
|
1764
|
+
const dropped = autosre.candidateChanges.pop();
|
|
1765
|
+
if (dropped) dropRef(`change:${dropped.changeId}`);
|
|
1766
|
+
} else {
|
|
1767
|
+
break;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1641
1770
|
bundle.budget = {
|
|
1642
1771
|
...bundle.budget,
|
|
1643
1772
|
estimatedTokens: estimateTokens(bundle),
|
|
1644
1773
|
};
|
|
1645
1774
|
}
|
|
1646
|
-
} catch {
|
|
1647
|
-
// Gracefully degrade if AutoSRE tables are absent in minimal test setups
|
|
1775
|
+
} catch (err) {
|
|
1776
|
+
// Gracefully degrade if AutoSRE tables are absent in minimal test setups.
|
|
1777
|
+
logger?.warn("[evidence-retrieval] autosre triage enrichment skipped", {
|
|
1778
|
+
trace_id: traceId,
|
|
1779
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1780
|
+
});
|
|
1648
1781
|
}
|
|
1649
1782
|
return bundle;
|
|
1650
1783
|
};
|
|
@@ -1679,11 +1812,17 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
|
|
|
1679
1812
|
);
|
|
1680
1813
|
}
|
|
1681
1814
|
|
|
1815
|
+
const isAutoSreAnchor =
|
|
1816
|
+
anchor.entityKind === "incident" ||
|
|
1817
|
+
anchor.entityKind === "change" ||
|
|
1818
|
+
anchor.entityKind === "finding";
|
|
1682
1819
|
const intent =
|
|
1683
1820
|
typeof body.intent === "string" &&
|
|
1684
1821
|
EVIDENCE_INTENTS.has(body.intent as EvidenceBundleIntent)
|
|
1685
1822
|
? (body.intent as EvidenceBundleIntent)
|
|
1686
|
-
:
|
|
1823
|
+
: isAutoSreAnchor
|
|
1824
|
+
? "triage_incident"
|
|
1825
|
+
: "debug_failure";
|
|
1687
1826
|
const budget = body.budget as Record<string, unknown> | undefined;
|
|
1688
1827
|
const targetTokens = clampInt(
|
|
1689
1828
|
budget?.targetTokens,
|
|
@@ -1755,6 +1894,7 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
|
|
|
1755
1894
|
projectId,
|
|
1756
1895
|
traceIds[0] ?? null,
|
|
1757
1896
|
bundle,
|
|
1897
|
+
runtime.logger,
|
|
1758
1898
|
);
|
|
1759
1899
|
}
|
|
1760
1900
|
return c.json(await materializeBundle(db, projectId, bundle));
|
|
@@ -1772,14 +1912,12 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
|
|
|
1772
1912
|
404,
|
|
1773
1913
|
);
|
|
1774
1914
|
}
|
|
1775
|
-
const
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
.bind(projectId, incident.environment)
|
|
1782
|
-
.first<{ trace_id: string }>();
|
|
1915
|
+
const anchorTraceId = await resolveAnchorTraceId(
|
|
1916
|
+
db,
|
|
1917
|
+
projectId,
|
|
1918
|
+
incident.environment,
|
|
1919
|
+
{ kind: incident.symptomAnchorKind, id: incident.symptomAnchorId },
|
|
1920
|
+
);
|
|
1783
1921
|
|
|
1784
1922
|
const incRoute = `#/incidents/${encodeURIComponent(incident.id)}`;
|
|
1785
1923
|
const baseBundle: EvidenceBundle = {
|
|
@@ -1826,7 +1964,8 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
|
|
|
1826
1964
|
entityKind: "incident",
|
|
1827
1965
|
entityId: incident.id,
|
|
1828
1966
|
route: incRoute,
|
|
1829
|
-
reason:
|
|
1967
|
+
reason:
|
|
1968
|
+
"Inspect incident investigation and candidate changes.",
|
|
1830
1969
|
},
|
|
1831
1970
|
],
|
|
1832
1971
|
},
|
|
@@ -1845,8 +1984,240 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
|
|
|
1845
1984
|
const enriched = await attachAutoSreTriageContext(
|
|
1846
1985
|
db,
|
|
1847
1986
|
projectId,
|
|
1848
|
-
|
|
1987
|
+
anchorTraceId,
|
|
1988
|
+
baseBundle,
|
|
1989
|
+
runtime.logger,
|
|
1990
|
+
);
|
|
1991
|
+
return c.json(await materializeBundle(db, projectId, enriched));
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
if (anchor.entityKind === "change") {
|
|
1995
|
+
const change = await getChangeEvent(db, projectId, anchor.entityId);
|
|
1996
|
+
if (!change) {
|
|
1997
|
+
return c.json(
|
|
1998
|
+
{ error: "Not Found", message: "Change event not found" },
|
|
1999
|
+
404,
|
|
2000
|
+
);
|
|
2001
|
+
}
|
|
2002
|
+
const blastRadius = await computeChangeBlastRadius(
|
|
2003
|
+
db,
|
|
2004
|
+
projectId,
|
|
2005
|
+
change.id,
|
|
2006
|
+
{ environment: change.environment },
|
|
2007
|
+
);
|
|
2008
|
+
const anchorTraceId = await resolveAnchorTraceId(
|
|
2009
|
+
db,
|
|
2010
|
+
projectId,
|
|
2011
|
+
change.environment,
|
|
2012
|
+
null,
|
|
2013
|
+
);
|
|
2014
|
+
|
|
2015
|
+
const changeRoute = `#/changes/${encodeURIComponent(change.id)}`;
|
|
2016
|
+
const summary =
|
|
2017
|
+
blastRadius?.summary ??
|
|
2018
|
+
`Change ${change.id} (${change.kind} ${change.version ?? ""}) in ${change.environment}: ${change.summary}`;
|
|
2019
|
+
const baseBundle: EvidenceBundle = {
|
|
2020
|
+
schemaVersion: BUNDLE_SCHEMA_VERSION,
|
|
2021
|
+
intent,
|
|
2022
|
+
environment: change.environment,
|
|
2023
|
+
anchor: { entityKind: "change", entityId: change.id },
|
|
2024
|
+
budget: { targetTokens, detailLevel },
|
|
2025
|
+
summary,
|
|
2026
|
+
derivedSummaries: [
|
|
2027
|
+
{
|
|
2028
|
+
title: `Change ${change.id} (${change.kind})`,
|
|
2029
|
+
reason: summary,
|
|
2030
|
+
confidence:
|
|
2031
|
+
blastRadius?.cohortVerdict === "isolated_to_new_version"
|
|
2032
|
+
? 0.9
|
|
2033
|
+
: 0.7,
|
|
2034
|
+
evidenceIds: [`change:${change.id}`],
|
|
2035
|
+
retrievalRefIds: [],
|
|
2036
|
+
},
|
|
2037
|
+
],
|
|
2038
|
+
findings: blastRadius
|
|
2039
|
+
? [
|
|
2040
|
+
{
|
|
2041
|
+
title: `Blast radius (${blastRadius.cohortVerdict})`,
|
|
2042
|
+
reason: blastRadius.summary,
|
|
2043
|
+
confidence:
|
|
2044
|
+
blastRadius.cohortVerdict === "isolated_to_new_version"
|
|
2045
|
+
? 0.9
|
|
2046
|
+
: 0.65,
|
|
2047
|
+
evidenceIds: [`change:${change.id}`],
|
|
2048
|
+
retrievalRefIds: [],
|
|
2049
|
+
},
|
|
2050
|
+
]
|
|
2051
|
+
: [],
|
|
2052
|
+
compactions: [],
|
|
2053
|
+
evidenceReferences: [
|
|
2054
|
+
{
|
|
2055
|
+
evidenceId: `change:${change.id}`,
|
|
2056
|
+
entityKind: "change",
|
|
2057
|
+
entityId: change.id,
|
|
2058
|
+
route: changeRoute,
|
|
2059
|
+
source: "change_events",
|
|
2060
|
+
confidence: 1.0,
|
|
2061
|
+
relationshipCertainty:
|
|
2062
|
+
blastRadius?.relationshipCertainty ??
|
|
2063
|
+
change.relationshipCertainty,
|
|
2064
|
+
causalProofTier: blastRadius?.causalProofTier,
|
|
2065
|
+
reason: summary,
|
|
2066
|
+
citations: [
|
|
2067
|
+
{
|
|
2068
|
+
label: `${change.kind} ${change.version ?? change.id}`,
|
|
2069
|
+
entityKind: "change",
|
|
2070
|
+
entityId: change.id,
|
|
2071
|
+
route: changeRoute,
|
|
2072
|
+
},
|
|
2073
|
+
],
|
|
2074
|
+
suggestedNextPivots: [
|
|
2075
|
+
{
|
|
2076
|
+
label: "Inspect change blast radius",
|
|
2077
|
+
entityKind: "change",
|
|
2078
|
+
entityId: change.id,
|
|
2079
|
+
route: changeRoute,
|
|
2080
|
+
reason:
|
|
2081
|
+
"Inspect traffic-normalized cohort lift and contention.",
|
|
2082
|
+
},
|
|
2083
|
+
],
|
|
2084
|
+
},
|
|
2085
|
+
],
|
|
2086
|
+
retrievalRefs: [],
|
|
2087
|
+
suggestedNextPivots: [
|
|
2088
|
+
{
|
|
2089
|
+
label: "Inspect change blast radius",
|
|
2090
|
+
entityKind: "change",
|
|
2091
|
+
entityId: change.id,
|
|
2092
|
+
route: changeRoute,
|
|
2093
|
+
reason: "Inspect traffic-normalized cohort lift and contention.",
|
|
2094
|
+
},
|
|
2095
|
+
],
|
|
2096
|
+
};
|
|
2097
|
+
const enriched = await attachAutoSreTriageContext(
|
|
2098
|
+
db,
|
|
2099
|
+
projectId,
|
|
2100
|
+
anchorTraceId,
|
|
2101
|
+
baseBundle,
|
|
2102
|
+
runtime.logger,
|
|
2103
|
+
);
|
|
2104
|
+
return c.json(await materializeBundle(db, projectId, enriched));
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
if (anchor.entityKind === "finding") {
|
|
2108
|
+
const finding = await getInvestigationFinding(
|
|
2109
|
+
db,
|
|
2110
|
+
projectId,
|
|
2111
|
+
anchor.entityId,
|
|
2112
|
+
);
|
|
2113
|
+
if (!finding) {
|
|
2114
|
+
return c.json(
|
|
2115
|
+
{ error: "Not Found", message: "Finding not found" },
|
|
2116
|
+
404,
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
const proposals = await listRemediationProposals(db, {
|
|
2120
|
+
projectId,
|
|
2121
|
+
environment: finding.environment,
|
|
2122
|
+
findingId: finding.id,
|
|
2123
|
+
limit: 10,
|
|
2124
|
+
});
|
|
2125
|
+
const anchorTraceId = await resolveAnchorTraceId(
|
|
2126
|
+
db,
|
|
2127
|
+
projectId,
|
|
2128
|
+
finding.environment,
|
|
2129
|
+
{ kind: finding.symptomEntityKind, id: finding.symptomEntityId },
|
|
2130
|
+
);
|
|
2131
|
+
|
|
2132
|
+
const findingRoute = `#/findings/${encodeURIComponent(finding.id)}`;
|
|
2133
|
+
const summary = `${finding.title} (${finding.causalProofTier}, verification=${finding.causalVerificationStatus}, recovery=${finding.recoveryStatus}): ${finding.summary}`;
|
|
2134
|
+
const baseBundle: EvidenceBundle = {
|
|
2135
|
+
schemaVersion: BUNDLE_SCHEMA_VERSION,
|
|
2136
|
+
intent,
|
|
2137
|
+
environment: finding.environment,
|
|
2138
|
+
incidentId: finding.incidentId ?? undefined,
|
|
2139
|
+
symptomFingerprint: finding.fingerprint,
|
|
2140
|
+
anchor: { entityKind: "finding", entityId: finding.id },
|
|
2141
|
+
budget: { targetTokens, detailLevel },
|
|
2142
|
+
summary,
|
|
2143
|
+
derivedSummaries: [
|
|
2144
|
+
{
|
|
2145
|
+
title: finding.title,
|
|
2146
|
+
reason: summary,
|
|
2147
|
+
confidence: finding.confidence,
|
|
2148
|
+
evidenceIds: [`finding:${finding.id}`],
|
|
2149
|
+
retrievalRefIds: [],
|
|
2150
|
+
},
|
|
2151
|
+
],
|
|
2152
|
+
findings: [
|
|
2153
|
+
{
|
|
2154
|
+
title: finding.title,
|
|
2155
|
+
reason: finding.summary,
|
|
2156
|
+
confidence: finding.confidence,
|
|
2157
|
+
evidenceIds: [
|
|
2158
|
+
`finding:${finding.id}`,
|
|
2159
|
+
...finding.citedEvidenceIds,
|
|
2160
|
+
],
|
|
2161
|
+
retrievalRefIds: [],
|
|
2162
|
+
},
|
|
2163
|
+
...proposals.map((p) => ({
|
|
2164
|
+
title: `Remediation proposal ${p.id} (${p.actionKind})`,
|
|
2165
|
+
reason: `status=${p.status}, autonomy=${p.effectiveAutonomy} — ${p.policyDecisionReason}`,
|
|
2166
|
+
confidence: finding.confidence,
|
|
2167
|
+
evidenceIds: [`finding:${finding.id}`],
|
|
2168
|
+
retrievalRefIds: [],
|
|
2169
|
+
})),
|
|
2170
|
+
],
|
|
2171
|
+
compactions: [],
|
|
2172
|
+
evidenceReferences: [
|
|
2173
|
+
{
|
|
2174
|
+
evidenceId: `finding:${finding.id}`,
|
|
2175
|
+
entityKind: "finding",
|
|
2176
|
+
entityId: finding.id,
|
|
2177
|
+
route: findingRoute,
|
|
2178
|
+
source: "investigation_findings",
|
|
2179
|
+
confidence: finding.confidence,
|
|
2180
|
+
relationshipCertainty: "exact",
|
|
2181
|
+
causalProofTier: finding.causalProofTier,
|
|
2182
|
+
reason: summary,
|
|
2183
|
+
citations: [
|
|
2184
|
+
{
|
|
2185
|
+
label: finding.title,
|
|
2186
|
+
entityKind: "finding",
|
|
2187
|
+
entityId: finding.id,
|
|
2188
|
+
route: findingRoute,
|
|
2189
|
+
},
|
|
2190
|
+
],
|
|
2191
|
+
suggestedNextPivots: [
|
|
2192
|
+
{
|
|
2193
|
+
label: "Open finding",
|
|
2194
|
+
entityKind: "finding",
|
|
2195
|
+
entityId: finding.id,
|
|
2196
|
+
route: findingRoute,
|
|
2197
|
+
reason:
|
|
2198
|
+
"Inspect causal chain, counterfactual verification, and remediation proposals.",
|
|
2199
|
+
},
|
|
2200
|
+
],
|
|
2201
|
+
},
|
|
2202
|
+
],
|
|
2203
|
+
retrievalRefs: [],
|
|
2204
|
+
suggestedNextPivots: [
|
|
2205
|
+
{
|
|
2206
|
+
label: "Open finding",
|
|
2207
|
+
entityKind: "finding",
|
|
2208
|
+
entityId: finding.id,
|
|
2209
|
+
route: findingRoute,
|
|
2210
|
+
reason:
|
|
2211
|
+
"Inspect causal chain, counterfactual verification, and remediation proposals.",
|
|
2212
|
+
},
|
|
2213
|
+
],
|
|
2214
|
+
};
|
|
2215
|
+
const enriched = await attachAutoSreTriageContext(
|
|
2216
|
+
db,
|
|
2217
|
+
projectId,
|
|
2218
|
+
anchorTraceId,
|
|
1849
2219
|
baseBundle,
|
|
2220
|
+
runtime.logger,
|
|
1850
2221
|
);
|
|
1851
2222
|
return c.json(await materializeBundle(db, projectId, enriched));
|
|
1852
2223
|
}
|
|
@@ -1856,7 +2227,7 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
|
|
|
1856
2227
|
{
|
|
1857
2228
|
error: "Bad Request",
|
|
1858
2229
|
message:
|
|
1859
|
-
"Supported evidence bundle anchors are trace, action, agent_run, tool_call, and incident.",
|
|
2230
|
+
"Supported evidence bundle anchors are trace, action, agent_run, tool_call, change, finding, and incident.",
|
|
1860
2231
|
},
|
|
1861
2232
|
400,
|
|
1862
2233
|
);
|
|
@@ -1886,12 +2257,13 @@ export const evidenceRetrievalRoutesPlugin: CollectorPlugin = {
|
|
|
1886
2257
|
detailLevel,
|
|
1887
2258
|
hours,
|
|
1888
2259
|
);
|
|
1889
|
-
if (intent === "triage_incident"
|
|
2260
|
+
if (intent === "triage_incident") {
|
|
1890
2261
|
bundle = await attachAutoSreTriageContext(
|
|
1891
2262
|
db,
|
|
1892
2263
|
projectId,
|
|
1893
2264
|
traceId,
|
|
1894
2265
|
bundle,
|
|
2266
|
+
runtime.logger,
|
|
1895
2267
|
);
|
|
1896
2268
|
}
|
|
1897
2269
|
return c.json(await materializeBundle(db, projectId, bundle));
|
|
@@ -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"]}
|