@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.
Files changed (45) hide show
  1. package/dist/auth.js +1 -1
  2. package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
  3. package/dist/chunk-2FALTDCE.js.map +1 -0
  4. package/dist/index.d.ts +61 -3
  5. package/dist/index.js +10933 -9769
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -3
  8. package/src/__tests__/autosre-closed-loop.test.ts +1554 -17
  9. package/src/auth/dashboard-auth.test.ts +61 -0
  10. package/src/auth/dashboard-auth.ts +17 -2
  11. package/src/lib/ai-store/calls.ts +43 -23
  12. package/src/lib/ai-store.ts +12 -1
  13. package/src/lib/analyses-runner.ts +44 -7
  14. package/src/lib/autosre-change-correlation.ts +39 -12
  15. package/src/lib/autosre-findings.ts +237 -44
  16. package/src/lib/autosre-ontology-cache.test.ts +78 -0
  17. package/src/lib/autosre-ontology.ts +52 -28
  18. package/src/lib/autosre-remediation-gate.ts +942 -348
  19. package/src/lib/housekeeping.test.ts +70 -0
  20. package/src/lib/housekeeping.ts +91 -83
  21. package/src/lib/projects-store.test.ts +38 -1
  22. package/src/lib/projects-store.ts +23 -4
  23. package/src/lib/sql-db-postgres.integration.test.ts +75 -2
  24. package/src/lib/store/ingest.ts +8 -56
  25. package/src/lib/usage-store.ts +9 -3
  26. package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
  27. package/src/migrations/052_remediation_callback.sql +10 -0
  28. package/src/migrations/053_remediation_callback_delivery.sql +7 -0
  29. package/src/migrations/alert-state-notified.test.ts +3 -3
  30. package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
  31. package/src/migrations-postgres/052_remediation_callback.sql +10 -0
  32. package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
  33. package/src/plugins/ai-call-by-id.test.ts +73 -0
  34. package/src/plugins/ai-receiver.ts +10 -0
  35. package/src/plugins/alerts-evaluator.ts +15 -3
  36. package/src/plugins/autosre-routes.ts +374 -108
  37. package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
  38. package/src/plugins/connected-routes/manifest.test.ts +142 -0
  39. package/src/plugins/connected-routes/manifest.ts +123 -6
  40. package/src/plugins/connected-routes-links.test.ts +96 -0
  41. package/src/plugins/connected-routes.test.ts +3 -3
  42. package/src/plugins/connected-routes.ts +56 -83
  43. package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
  44. package/src/plugins/evidence-retrieval-routes.ts +397 -25
  45. package/dist/chunk-KMEZKGSO.js.map +0 -1
@@ -789,3 +789,64 @@ describe("dashboard-auth middleware & programmatic token contract", () => {
789
789
  });
790
790
  });
791
791
  });
792
+
793
+ describe("session cookie Secure attribute", () => {
794
+ const loginCookie = async (
795
+ url: string,
796
+ headers: Record<string, string>,
797
+ trustProxyHeaders: boolean | undefined,
798
+ ): Promise<string> => {
799
+ const auth = createDashboardAuth({
800
+ password: TEST_PASSWORD,
801
+ token: TEST_TOKEN,
802
+ loginRateLimit: { trustProxyHeaders, warn: () => {} },
803
+ });
804
+ const app = new Hono<{ Bindings: CollectorEnv }>();
805
+ auth.registerRoutes(app);
806
+ const res = await app.fetch(
807
+ new Request(url, {
808
+ method: "POST",
809
+ headers: { "Content-Type": "application/json", ...headers },
810
+ body: JSON.stringify({ password: TEST_PASSWORD }),
811
+ }),
812
+ makeEnv(createTestDb()),
813
+ );
814
+ expect(res.status).toBe(200);
815
+ return res.headers.get("Set-Cookie") ?? "";
816
+ };
817
+
818
+ it("sets Secure for an https request URL", async () => {
819
+ expect(await loginCookie("https://x.test/auth/login", {}, false)).toContain(
820
+ "; Secure",
821
+ );
822
+ });
823
+
824
+ it("sets Secure for X-Forwarded-Proto: https when the proxy is trusted", async () => {
825
+ const cookie = await loginCookie(
826
+ "http://x.test/auth/login",
827
+ { "x-forwarded-proto": "https" },
828
+ true,
829
+ );
830
+ expect(cookie).toContain("; Secure");
831
+ });
832
+
833
+ it("ignores X-Forwarded-Proto when the proxy is not trusted, and on plain http", async () => {
834
+ expect(
835
+ await loginCookie(
836
+ "http://x.test/auth/login",
837
+ { "x-forwarded-proto": "https" },
838
+ undefined,
839
+ ),
840
+ ).not.toContain("Secure");
841
+ expect(
842
+ await loginCookie(
843
+ "http://x.test/auth/login",
844
+ { "x-forwarded-proto": "http" },
845
+ true,
846
+ ),
847
+ ).not.toContain("Secure");
848
+ expect(
849
+ await loginCookie("http://x.test/auth/login", {}, true),
850
+ ).not.toContain("Secure");
851
+ });
852
+ });
@@ -293,6 +293,21 @@ export function createDashboardAuth(config?: DashboardAuthConfig): {
293
293
  : new LoginRateLimiter(config?.loginRateLimit ?? {});
294
294
  let warnedUnkeyedClient = false;
295
295
 
296
+ // `Secure` when the request itself is https, or when the operator
297
+ // declared a trusted TLS-terminating proxy (`trustProxyHeaders`, i.e.
298
+ // TRUST_PROXY) that reports `X-Forwarded-Proto: https`.
299
+ const isSecureRequest = (
300
+ c: Context<{ Bindings: CollectorEnv }>,
301
+ limitConfig: { trustProxyHeaders?: boolean } | undefined,
302
+ ): boolean =>
303
+ c.req.url.startsWith("https:") ||
304
+ (limitConfig?.trustProxyHeaders === true &&
305
+ c.req
306
+ .header("x-forwarded-proto")
307
+ ?.split(",")[0]
308
+ ?.trim()
309
+ .toLowerCase() === "https");
310
+
296
311
  app.post("/auth/login", async (c) => {
297
312
  const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
298
313
  if (!configuredPassword) {
@@ -342,7 +357,7 @@ export function createDashboardAuth(config?: DashboardAuthConfig): {
342
357
  const sig = await sign(payload, configuredPassword);
343
358
  const cookieValue = `${payload}.${sig}`;
344
359
 
345
- const isSecure = c.req.url.startsWith("https:");
360
+ const isSecure = isSecureRequest(c, config?.loginRateLimit || undefined);
346
361
  c.header(
347
362
  "Set-Cookie",
348
363
  `${SESSION_COOKIE}=${encodeURIComponent(cookieValue)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE}${isSecure ? "; Secure" : ""}`,
@@ -405,7 +420,7 @@ export function createDashboardAuth(config?: DashboardAuthConfig): {
405
420
  });
406
421
 
407
422
  app.post("/auth/logout", (c) => {
408
- const isSecure = c.req.url.startsWith("https:");
423
+ const isSecure = isSecureRequest(c, config?.loginRateLimit || undefined);
409
424
  c.header(
410
425
  "Set-Cookie",
411
426
  `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0${isSecure ? "; Secure" : ""}`,
@@ -53,6 +53,48 @@ export async function ingestAICallBatch(
53
53
  await db.batch(batch);
54
54
  }
55
55
 
56
+ function mapAICallRow(row: AICallRow): AICallRecord {
57
+ return {
58
+ projectId: row.project_id ?? "default",
59
+ callId: row.call_id,
60
+ traceId: row.trace_id,
61
+ spanId: row.span_id,
62
+ serviceName: row.service_name,
63
+ modelName: row.model_name,
64
+ provider: row.provider,
65
+ callType: row.call_type,
66
+ requestJson: row.request_json,
67
+ responseJson: row.response_json,
68
+ promptTokens: row.prompt_tokens,
69
+ completionTokens: row.completion_tokens,
70
+ totalCostUsd: row.total_cost_usd,
71
+ latencyMs: row.latency_ms,
72
+ isError: row.is_error === 1 || row.is_error === true,
73
+ errorMessage: row.error_message,
74
+ occurredAt: row.occurred_at,
75
+ receivedAt: row.received_at,
76
+ expiresAt: row.expires_at,
77
+ sessionId: row.session_id ?? null,
78
+ interactionId: row.interaction_id ?? null,
79
+ };
80
+ }
81
+
82
+ /** One call by id, scoped to the project; `null` when absent. */
83
+ export async function getAICallById(
84
+ db: SqlDb,
85
+ projectId: string,
86
+ callId: string,
87
+ ): Promise<AICallRecord | null> {
88
+ if (!projectId) throw new Error("AIStore.getAICall: projectId is required");
89
+ const row = await db
90
+ .prepare(
91
+ `SELECT * FROM ai_calls WHERE project_id = ? AND call_id = ? LIMIT 1`,
92
+ )
93
+ .bind(projectId, callId)
94
+ .first<AICallRow>();
95
+ return row ? mapAICallRow(row) : null;
96
+ }
97
+
56
98
  export async function getAICallsOverview(
57
99
  db: SqlDb,
58
100
  options: AICallsOverviewOptions,
@@ -109,29 +151,7 @@ export async function getAICallsOverview(
109
151
  .bind(...summaryParams)
110
152
  .first<AICallSummaryRow>()) || {};
111
153
 
112
- const calls: AICallRecord[] = (results.results || []).map((row) => ({
113
- projectId: row.project_id ?? "default",
114
- callId: row.call_id,
115
- traceId: row.trace_id,
116
- spanId: row.span_id,
117
- serviceName: row.service_name,
118
- modelName: row.model_name,
119
- provider: row.provider,
120
- callType: row.call_type,
121
- requestJson: row.request_json,
122
- responseJson: row.response_json,
123
- promptTokens: row.prompt_tokens,
124
- completionTokens: row.completion_tokens,
125
- totalCostUsd: row.total_cost_usd,
126
- latencyMs: row.latency_ms,
127
- isError: row.is_error === 1 || row.is_error === true,
128
- errorMessage: row.error_message,
129
- occurredAt: row.occurred_at,
130
- receivedAt: row.received_at,
131
- expiresAt: row.expires_at,
132
- sessionId: row.session_id ?? null,
133
- interactionId: row.interaction_id ?? null,
134
- }));
154
+ const calls: AICallRecord[] = (results.results || []).map(mapAICallRow);
135
155
 
136
156
  return {
137
157
  calls,
@@ -10,7 +10,11 @@ import type {
10
10
  AISpansOverviewOptions,
11
11
  AISpansOverviewResponse,
12
12
  } from "@obsunified/types";
13
- import { getAICallsOverview, ingestAICallBatch } from "./ai-store/calls";
13
+ import {
14
+ getAICallById,
15
+ getAICallsOverview,
16
+ ingestAICallBatch,
17
+ } from "./ai-store/calls";
14
18
  import {
15
19
  getAIEvaluation,
16
20
  ingestAIEvaluations,
@@ -37,6 +41,13 @@ export class AIStore {
37
41
  return getAICallsOverview(this.db, options);
38
42
  }
39
43
 
44
+ async getAICall(
45
+ projectId: string,
46
+ callId: string,
47
+ ): Promise<AICallRecord | null> {
48
+ return getAICallById(this.db, projectId, callId);
49
+ }
50
+
40
51
  async purgeExpired(): Promise<number> {
41
52
  return purgeExpiredAIData(this.db);
42
53
  }
@@ -36,7 +36,11 @@ import {
36
36
  type Logger,
37
37
  } from "../framework/logger";
38
38
  import { AnalysesStore } from "./analyses-store";
39
- import { openOrDeduplicateIncident } from "./autosre-remediation-gate";
39
+ import {
40
+ evaluateRemediationWatchdogs,
41
+ openOrDeduplicateIncident,
42
+ resolveServiceIncidentTarget,
43
+ } from "./autosre-remediation-gate";
40
44
  import { generateNarrative, LlmCallError, type LlmConfig } from "./llm";
41
45
  import {
42
46
  computeSignature,
@@ -292,6 +296,20 @@ export async function runAllDueAnalyses(
292
296
  totals.narrated += summary.narrated;
293
297
  totals.skipped += summary.skipped ?? 0;
294
298
  }
299
+
300
+ // RFC 0013 § 5.B: the post-remediation watchdog (grant/claim timeouts and the
301
+ // +2m/+5m/+15m recovery checkpoints) rides the 1-minute tick so checkpoints
302
+ // are evaluated close to when they come due. Cross-project by design.
303
+ try {
304
+ const wd = await evaluateRemediationWatchdogs(db, { logger });
305
+ if (wd.evaluated + wd.timedOutClaims + wd.expiredGrants + wd.failed > 0) {
306
+ logger.info("[analyses] remediation watchdog summary", { ...wd });
307
+ }
308
+ } catch (error) {
309
+ logger.error("[analyses] remediation watchdog failed", {
310
+ error: error instanceof Error ? error.message : String(error),
311
+ });
312
+ }
295
313
  return totals;
296
314
  }
297
315
 
@@ -477,12 +495,31 @@ async function runDueAnalysesForProject(
477
495
  }
478
496
  await store.insertResult(result, expiresAt);
479
497
  if (result.status === "critical") {
480
- await openOrDeduplicateIncident(db, projectId, {
481
- environment: "default",
482
- targetNodeId: `analysis:${def.id}`,
483
- symptomAnchorKind: "analysis",
484
- symptomAnchorId: def.id,
485
- }).catch(() => undefined);
498
+ try {
499
+ const scopeService = def.scope?.service;
500
+ const target = await resolveServiceIncidentTarget(
501
+ db,
502
+ projectId,
503
+ typeof scopeService === "string" ? scopeService : null,
504
+ `analysis:${def.id}`,
505
+ logger,
506
+ );
507
+ await openOrDeduplicateIncident(db, projectId, {
508
+ environment: target.environment,
509
+ targetNodeId: target.targetNodeId,
510
+ symptomAnchorKind: "analysis",
511
+ symptomAnchorId: def.id,
512
+ });
513
+ } catch (incidentError) {
514
+ // Incident bookkeeping must not fail the analysis run.
515
+ logger.error("[analyses] failed to open incident", {
516
+ analysis_id: def.id,
517
+ error:
518
+ incidentError instanceof Error
519
+ ? incidentError.message
520
+ : String(incidentError),
521
+ });
522
+ }
486
523
  }
487
524
  // markRan releases the claim as a side-effect.
488
525
  await store.markRan(projectId, def.id, result.generatedAt);
@@ -12,6 +12,7 @@ import type {
12
12
  RelationshipCertainty,
13
13
  } from "@obsunified/types";
14
14
  import { buildCanonicalNodeId } from "@obsunified/types";
15
+ import { normalizeEnvironment } from "@obsunified/types/lineage";
15
16
  import { randomHex } from "./hash";
16
17
  import { parseJsonArray, parseJsonRecord } from "./json";
17
18
  import type { SqlDb } from "./sql-db";
@@ -73,14 +74,30 @@ export const mapChangeEventRow = (row: ChangeEventRow): ChangeEvent => ({
73
74
  expiresAt: row.expires_at,
74
75
  });
75
76
 
77
+ export class InvalidTimestampError extends Error {
78
+ constructor(field: string, value: string) {
79
+ super(`${field} must be a valid ISO-8601 timestamp, got "${value}"`);
80
+ this.name = "InvalidTimestampError";
81
+ }
82
+ }
83
+
84
+ /** Canonicalizes a client timestamp to `toISOString()` form so string comparison in SQL is sound. */
85
+ const normalizeTimestamp = (field: string, value: string): string => {
86
+ const ms = new Date(value).getTime();
87
+ if (!Number.isFinite(ms)) throw new InvalidTimestampError(field, value);
88
+ return new Date(ms).toISOString();
89
+ };
90
+
76
91
  export async function recordChangeEvent(
77
92
  db: SqlDb,
78
93
  projectId: string,
79
94
  input: ChangeEventInput,
80
95
  ): Promise<ChangeEvent> {
81
96
  const nowIso = new Date().toISOString();
82
- const occurredAt = input.occurredAt || nowIso;
83
- const environment = (input.environment || "default").trim().toLowerCase();
97
+ const occurredAt = input.occurredAt
98
+ ? normalizeTimestamp("occurredAt", input.occurredAt)
99
+ : nowIso;
100
+ const environment = normalizeEnvironment(input.environment);
84
101
  const namespace = (input.namespace || "default").trim();
85
102
  const serviceName = input.serviceName?.trim() || null;
86
103
 
@@ -257,7 +274,7 @@ export async function listChangeEvents(
257
274
  }
258
275
  if (options.since) {
259
276
  conditions.push("occurred_at >= ?");
260
- binds.push(options.since);
277
+ binds.push(normalizeTimestamp("since", options.since));
261
278
  }
262
279
 
263
280
  const limit = Math.max(1, Math.min(200, options.limit ?? 50));
@@ -346,8 +363,10 @@ const summarizeCohort = (
346
363
  requestCount > 0 ? downstreamCount / requestCount : 0;
347
364
  const avgDownstreamDurationMs =
348
365
  downstreamCount > 0
349
- ? downstreamSpans.reduce((acc, s) => acc + Number(s.duration_ms ?? 0), 0) /
350
- downstreamCount
366
+ ? downstreamSpans.reduce(
367
+ (acc, s) => acc + Number(s.duration_ms ?? 0),
368
+ 0,
369
+ ) / downstreamCount
351
370
  : null;
352
371
 
353
372
  return {
@@ -433,7 +452,9 @@ export async function findCrossTraceContention(
433
452
  }>(),
434
453
  ]);
435
454
 
436
- const isScoped = nodeRow ? nodeRow.is_scoped_identity === 1 : !options.targetNodeId.includes(":unscoped/");
455
+ const isScoped = nodeRow
456
+ ? nodeRow.is_scoped_identity === 1
457
+ : !options.targetNodeId.includes(":unscoped/");
437
458
  const relationshipCertainty: RelationshipCertainty = isScoped
438
459
  ? "exact"
439
460
  : "inferred";
@@ -455,7 +476,11 @@ export async function findCrossTraceContention(
455
476
  const extractFilePathsFromSpan = (span: SpanCohortSampleRow): string[] => {
456
477
  const found: string[] = [];
457
478
  const attrs = parseJsonRecord(span.attributes_json ?? "{}");
458
- for (const key of ["code.filepath", "obs.code.filepath", "exception.stacktrace"]) {
479
+ for (const key of [
480
+ "code.filepath",
481
+ "obs.code.filepath",
482
+ "exception.stacktrace",
483
+ ]) {
459
484
  const val = attrs[key];
460
485
  if (typeof val === "string") {
461
486
  found.push(val);
@@ -556,9 +581,9 @@ export async function computeChangeBlastRadius(
556
581
 
557
582
  const flagKey =
558
583
  change.kind === "feature_flag"
559
- ? (typeof change.attributes.flagKey === "string"
584
+ ? ((typeof change.attributes.flagKey === "string"
560
585
  ? change.attributes.flagKey
561
- : change.serviceName) ?? "flag"
586
+ : change.serviceName) ?? "flag")
562
587
  : null;
563
588
 
564
589
  const newCohortKey =
@@ -577,7 +602,10 @@ export async function computeChangeBlastRadius(
577
602
  const attrs = parseJsonRecord(s.attributes_json ?? "{}");
578
603
  const direct = attrs[`obs.feature_flags.${flagKey}`];
579
604
  if (direct !== undefined && direct !== null) return String(direct);
580
- if (attrs["feature_flag.key"] === flagKey && attrs["feature_flag.variant"] !== undefined) {
605
+ if (
606
+ attrs["feature_flag.key"] === flagKey &&
607
+ attrs["feature_flag.variant"] !== undefined
608
+ ) {
581
609
  return String(attrs["feature_flag.variant"]);
582
610
  }
583
611
  const nested = attrs["obs.feature_flags"];
@@ -732,8 +760,7 @@ export async function computeChangeBlastRadius(
732
760
  } else if (preBaseline.requestCount >= minSample) {
733
761
  // Instant 100% cutover: compare post-change v_new vs pre-change baseline
734
762
  rateDifference = newCohort.errorRate - preBaseline.errorRate;
735
- relativeRisk =
736
- newCohort.errorRate / Math.max(preBaseline.errorRate, 0.001);
763
+ relativeRisk = newCohort.errorRate / Math.max(preBaseline.errorRate, 0.001);
737
764
  const p95Ratio =
738
765
  newCohort.p95DurationMs !== null &&
739
766
  preBaseline.p95DurationMs !== null &&