@obsunified/collector 2.0.1 → 2.1.1

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 (107) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/index.d.ts +322 -26
  6. package/dist/index.js +1478 -415
  7. package/dist/index.js.map +1 -1
  8. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  9. package/package.json +5 -5
  10. package/src/analyses/derive.ts +26 -25
  11. package/src/analyses/investigations.ts +8 -8
  12. package/src/analyses/tier0.ts +22 -22
  13. package/src/auth/dashboard-auth.test.ts +791 -0
  14. package/src/auth/dashboard-auth.ts +187 -38
  15. package/src/auth/login-rate-limit.test.ts +129 -0
  16. package/src/auth/login-rate-limit.ts +143 -0
  17. package/src/framework/body-limit.test.ts +165 -0
  18. package/src/framework/collector.ts +355 -115
  19. package/src/framework/env.ts +9 -0
  20. package/src/framework/limits.ts +31 -0
  21. package/src/framework/scheduled-handlers.test.ts +342 -0
  22. package/src/index.ts +16 -1
  23. package/src/lib/action-retention.test.ts +472 -0
  24. package/src/lib/action-retention.ts +420 -0
  25. package/src/lib/actions-store.ts +6 -12
  26. package/src/lib/ai-store/calls.ts +2 -2
  27. package/src/lib/ai-store/sessions.ts +3 -3
  28. package/src/lib/ai-store/spans.ts +1 -1
  29. package/src/lib/ai-store/types.ts +2 -1
  30. package/src/lib/alerts-store.test.ts +153 -0
  31. package/src/lib/alerts-store.ts +122 -21
  32. package/src/lib/analyses-runner.test.ts +75 -0
  33. package/src/lib/analyses-runner.ts +60 -3
  34. package/src/lib/analyses-store-latest.test.ts +61 -0
  35. package/src/lib/analyses-store.ts +17 -10
  36. package/src/lib/blob-store.ts +1 -1
  37. package/src/lib/housekeeping.test.ts +73 -0
  38. package/src/lib/housekeeping.ts +93 -0
  39. package/src/lib/identity-index/action-lookups.ts +6 -6
  40. package/src/lib/identity-index/key-lookups.ts +2 -2
  41. package/src/lib/identity-index/user-lookup.ts +8 -5
  42. package/src/lib/identity-index.test.ts +2 -3
  43. package/src/lib/metrics-store-series.test.ts +99 -0
  44. package/src/lib/metrics-store.test.ts +23 -0
  45. package/src/lib/metrics-store.ts +27 -3
  46. package/src/lib/projects-store.test.ts +25 -0
  47. package/src/lib/projects-store.ts +4 -3
  48. package/src/lib/sql-db-dialect.test.ts +12 -3
  49. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  50. package/src/lib/sql-db-postgres.test.ts +314 -0
  51. package/src/lib/sql-db-postgres.ts +392 -54
  52. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  53. package/src/lib/sql-db.ts +40 -6
  54. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  55. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  56. package/src/lib/webhook-url.test.ts +122 -0
  57. package/src/lib/webhook-url.ts +155 -0
  58. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  59. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  60. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  61. package/src/migrations/044_actions_started_at_index.sql +10 -0
  62. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  63. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  64. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  65. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  66. package/src/migrations/049_alert_state_notified.sql +17 -0
  67. package/src/migrations/alert-state-notified.test.ts +111 -0
  68. package/src/migrations/parity.test.ts +64 -0
  69. package/src/migrations/project-scoped-keys.test.ts +432 -0
  70. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  71. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  72. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  73. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  74. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  75. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  76. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  77. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  78. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  79. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  80. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  81. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  82. package/src/migrations-postgres/README.md +58 -5
  83. package/src/otlp/decode/body.test.ts +76 -0
  84. package/src/otlp/decode/body.ts +67 -6
  85. package/src/plugins/_json.ts +21 -0
  86. package/src/plugins/action-graph-processor.ts +6 -12
  87. package/src/plugins/ai-receiver.ts +5 -12
  88. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  89. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  90. package/src/plugins/alerts-evaluator.test.ts +464 -3
  91. package/src/plugins/alerts-evaluator.ts +165 -41
  92. package/src/plugins/alerts-routes.ts +10 -6
  93. package/src/plugins/ask-routes.ts +4 -5
  94. package/src/plugins/connected-routes.test.ts +106 -0
  95. package/src/plugins/connected-routes.ts +14 -5
  96. package/src/plugins/dashboard-routes.test.ts +120 -0
  97. package/src/plugins/dashboard-routes.ts +11 -2
  98. package/src/plugins/eval-cases-routes.ts +8 -17
  99. package/src/plugins/identity-receiver.ts +9 -11
  100. package/src/plugins/json-body.test.ts +91 -0
  101. package/src/plugins/onboarding-routes.test.ts +105 -0
  102. package/src/plugins/onboarding-routes.ts +26 -11
  103. package/src/plugins/projects-routes.ts +2 -1
  104. package/src/plugins/replay-isolation.test.ts +922 -0
  105. package/src/plugins/replay-receiver.ts +8 -8
  106. package/src/plugins/usage-receiver.ts +3 -6
  107. package/dist/chunk-UGWDOGGH.js.map +0 -1
@@ -53,9 +53,9 @@ export async function manifestBySession(
53
53
  .prepare(
54
54
  `SELECT first_chunk_at, last_chunk_at, chunk_count, events_count
55
55
  FROM session_replay_metadata
56
- WHERE session_id = ?`,
56
+ WHERE project_id = ? AND session_id = ?`,
57
57
  )
58
- .bind(sessionId)
58
+ .bind(projectId, sessionId)
59
59
  .first<{
60
60
  first_chunk_at: string;
61
61
  last_chunk_at: string;
@@ -13,10 +13,13 @@ export async function manifestByUser(
13
13
  const sessionLimit = Math.max(1, Math.min(opts.sessions ?? 5, 20));
14
14
 
15
15
  // Step 1 — resolve user_id → visitor_id. user_profiles is the only
16
- // table that owns the user_id ↔ visitor_id binding.
16
+ // table that owns the user_id ↔ visitor_id binding, keyed per project
17
+ // (the same user_id can exist in several projects — migration 045).
17
18
  const profile = await db
18
- .prepare(`SELECT visitor_id FROM user_profiles WHERE user_id = ? LIMIT 1`)
19
- .bind(userId)
19
+ .prepare(
20
+ `SELECT visitor_id FROM user_profiles WHERE project_id = ? AND user_id = ? LIMIT 1`,
21
+ )
22
+ .bind(projectId, userId)
20
23
  .first<{ visitor_id: string }>();
21
24
  if (!profile) {
22
25
  return {
@@ -105,10 +108,10 @@ export async function manifestByUser(
105
108
  .prepare(
106
109
  `SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
107
110
  FROM session_replay_metadata
108
- WHERE session_id IN (${placeholders})
111
+ WHERE project_id = ? AND session_id IN (${placeholders})
109
112
  ORDER BY last_chunk_at DESC LIMIT 1`,
110
113
  )
111
- .bind(...sessionIds)
114
+ .bind(projectId, ...sessionIds)
112
115
  .first<{
113
116
  session_id: string;
114
117
  first_chunk_at: string;
@@ -81,13 +81,12 @@ describe("IdentityIndex.bySession", () => {
81
81
  eventsCount: 42,
82
82
  });
83
83
 
84
- // All five queries pin (project_id, session_id) — replay lookup uses
85
- // just session_id since replay is global.
84
+ // All five queries pin (project_id, session_id).
86
85
  const allCalls = db.calls.filter((c) => c.op !== "first" || c.binds.length);
87
86
  const sessionQueries = allCalls.filter(
88
87
  (c) => c.sql.includes("session_id") && c.binds.includes("proj-1"),
89
88
  );
90
- expect(sessionQueries.length).toBeGreaterThanOrEqual(4);
89
+ expect(sessionQueries.length).toBe(5);
91
90
  });
92
91
 
93
92
  it("returns null replay when session_replay_metadata is empty", async () => {
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Two ingest batches that both see a brand-new metric identity used to race
3
+ * on `UNIQUE(project_id, identity)`: the loser's whole batch failed and its
4
+ * points were lost. The insert is now conflict-tolerant and the loser adopts
5
+ * the winner's series id. Executed against the real migrated schema.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import type { DecodedMetricPoint } from "../otlp/decode";
10
+ import { MetricsStore } from "./metrics-store";
11
+ import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
12
+
13
+ const point = (over: Partial<DecodedMetricPoint> = {}): DecodedMetricPoint => ({
14
+ name: "http.server.duration",
15
+ description: null,
16
+ unit: "ms",
17
+ type: "gauge",
18
+ isMonotonic: null,
19
+ temporality: null,
20
+ scopeName: "test",
21
+ scopeVersion: "1",
22
+ serviceName: "checkout",
23
+ resourceAttrsJson: "{}",
24
+ attributesJson: "{}",
25
+ identity: "identity-shared",
26
+ tsNs: "1800000000000000000",
27
+ startTsNs: null,
28
+ value: 1,
29
+ count: null,
30
+ sum: null,
31
+ min: null,
32
+ max: null,
33
+ boundsJson: null,
34
+ bucketCountsJson: null,
35
+ extraJson: null,
36
+ exemplarsJson: null,
37
+ ...over,
38
+ });
39
+
40
+ describe("MetricsStore series resolution under concurrency", () => {
41
+ it("lets concurrent first-sight ingests share one series row", async () => {
42
+ const db = createMigratedSqliteDb();
43
+ const store = new MetricsStore(db);
44
+ const now = new Date().toISOString();
45
+ const later = new Date(Date.now() + 3_600_000).toISOString();
46
+
47
+ // Both calls run their lookup (miss) before either inserts.
48
+ await Promise.all([
49
+ store.ingestBatch({
50
+ projectId: "alpha",
51
+ points: [point({ value: 1 })],
52
+ receivedAt: now,
53
+ expiresAt: later,
54
+ }),
55
+ store.ingestBatch({
56
+ projectId: "alpha",
57
+ points: [point({ value: 2 })],
58
+ receivedAt: now,
59
+ expiresAt: later,
60
+ }),
61
+ ]);
62
+
63
+ const series = await db
64
+ .prepare(`SELECT id FROM metric_series WHERE project_id = ?`)
65
+ .bind("alpha")
66
+ .all<{ id: string }>();
67
+ expect(series.results).toHaveLength(1);
68
+ const points = await db
69
+ .prepare(
70
+ `SELECT series_id, value FROM metric_point WHERE project_id = ? ORDER BY value`,
71
+ )
72
+ .bind("alpha")
73
+ .all<{ series_id: string; value: number }>();
74
+ expect(points.results.map((p) => p.value)).toEqual([1, 2]);
75
+ expect(new Set(points.results.map((p) => p.series_id))).toEqual(
76
+ new Set([series.results[0]?.id]),
77
+ );
78
+ db.close();
79
+ });
80
+
81
+ it("keeps series separate per project", async () => {
82
+ const db = createMigratedSqliteDb();
83
+ const store = new MetricsStore(db);
84
+ const now = new Date().toISOString();
85
+ for (const projectId of ["alpha", "beta"]) {
86
+ await store.ingestBatch({
87
+ projectId,
88
+ points: [point()],
89
+ receivedAt: now,
90
+ expiresAt: now,
91
+ });
92
+ }
93
+ const rows = await db
94
+ .prepare(`SELECT project_id FROM metric_series ORDER BY project_id`)
95
+ .all<{ project_id: string }>();
96
+ expect(rows.results.map((r) => r.project_id)).toEqual(["alpha", "beta"]);
97
+ db.close();
98
+ });
99
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { DecodedMetricPoint } from "../otlp/decode";
3
3
  import { MetricsStore } from "./metrics-store";
4
+ import { postgresDialect } from "./sql-db";
4
5
  import { MemSqlDb } from "./test-utils/mem-sql-db";
5
6
 
6
7
  const point = (
@@ -33,6 +34,28 @@ const point = (
33
34
  });
34
35
 
35
36
  describe("MetricsStore", () => {
37
+ it("purges expired exemplars and points with the dialect's text-rendered now()", async () => {
38
+ const sqlite = new MemSqlDb({ run: () => ({ changes: 2 }) });
39
+ expect(await new MetricsStore(sqlite).purgeExpired()).toBe(4);
40
+ expect(sqlite.calls.map((c) => c.sql)).toEqual([
41
+ "DELETE FROM metric_exemplars WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
42
+ "DELETE FROM metric_point WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
43
+ ]);
44
+
45
+ // metric_exemplars.received_at / expires_at are TEXT ISO on Postgres
46
+ // too (035 for fresh installs, 043 for databases created with the
47
+ // TIMESTAMPTZ version), so the comparison must be text < text.
48
+ const postgres = Object.assign(
49
+ new MemSqlDb({ run: () => ({ changes: 1 }) }),
50
+ { dialect: postgresDialect },
51
+ );
52
+ expect(await new MetricsStore(postgres).purgeExpired()).toBe(2);
53
+ expect(postgres.calls.map((c) => c.sql)).toEqual([
54
+ `DELETE FROM metric_exemplars WHERE expires_at < to_char((CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`,
55
+ `DELETE FROM metric_point WHERE expires_at < to_char((CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`,
56
+ ]);
57
+ });
58
+
36
59
  it("indexes metric exemplars by trace/span during ingest", async () => {
37
60
  const db = new MemSqlDb({
38
61
  all: (sql, binds) => {
@@ -272,20 +272,25 @@ export class MetricsStore {
272
272
  }
273
273
  }
274
274
 
275
- // Insert missing series.
275
+ // Insert missing series. Two concurrent batches can both miss the
276
+ // lookup for a brand-new identity; UNIQUE(project_id, identity) would
277
+ // then fail the loser's whole batch. INSERT OR IGNORE lets the loser
278
+ // proceed, and the re-select below swaps in the winner's series id.
276
279
  const insertStmt = this.db.prepare(`
277
- INSERT INTO metric_series (
280
+ INSERT OR IGNORE INTO metric_series (
278
281
  id, project_id, name, description, unit, type,
279
282
  is_monotonic, temporality, scope_name, scope_version, service_name,
280
283
  resource_attrs_json, attributes_json, identity, created_at
281
284
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
282
285
  `);
283
286
  const toInsert: SqlStatement[] = [];
287
+ const attemptedIds = new Set<string>();
284
288
  const createdAt = new Date().toISOString();
285
289
  for (const [identity, p] of byIdentity.entries()) {
286
290
  if (existing.has(identity)) continue;
287
291
  const id = crypto.randomUUID();
288
292
  existing.set(identity, { id, identity });
293
+ attemptedIds.add(identity);
289
294
  toInsert.push(
290
295
  insertStmt.bind(
291
296
  id,
@@ -306,7 +311,26 @@ export class MetricsStore {
306
311
  ),
307
312
  );
308
313
  }
309
- if (toInsert.length) await this.db.batch(toInsert);
314
+ if (toInsert.length === 0) return existing;
315
+ await this.db.batch(toInsert);
316
+
317
+ // Re-read the identities we tried to insert: an ignored insert means
318
+ // another writer won, and points must reference the stored id.
319
+ const attempted = identities.filter((identity) =>
320
+ attemptedIds.has(identity),
321
+ );
322
+ for (let i = 0; i < attempted.length; i += 50) {
323
+ const chunk = attempted.slice(i, i + 50);
324
+ const placeholders = chunk.map(() => "?").join(",");
325
+ const rows = await this.db
326
+ .prepare(
327
+ `SELECT id, identity FROM metric_series
328
+ WHERE project_id = ? AND identity IN (${placeholders})`,
329
+ )
330
+ .bind(projectId, ...chunk)
331
+ .all<SeriesRow>();
332
+ for (const row of rows.results) existing.set(row.identity, row);
333
+ }
310
334
  return existing;
311
335
  }
312
336
  }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { sha256Hex } from "./hash";
3
+ import { ProjectsStore } from "./projects-store";
4
+ import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
5
+
6
+ describe("ProjectsStore.revokeKey", () => {
7
+ it("only revokes a key through its own project", async () => {
8
+ const db = createMigratedSqliteDb();
9
+ const store = new ProjectsStore(db);
10
+ const alpha = await store.createProject({ name: "Alpha", slug: "alpha" });
11
+ const beta = await store.createProject({ name: "Beta", slug: "beta" });
12
+ const betaKey = await store.createKey(beta.id, "ci");
13
+ const hash = await sha256Hex(betaKey.key);
14
+
15
+ // Alpha's admin cannot revoke beta's key by guessing its id.
16
+ expect(await store.revokeKey(betaKey.id, alpha.id)).toBe(false);
17
+ expect((await store.findByKeyHash(hash))?.projectId).toBe(beta.id);
18
+
19
+ expect(await store.revokeKey(betaKey.id, beta.id)).toBe(true);
20
+ expect(await store.findByKeyHash(hash)).toBeNull();
21
+ // Already revoked: a second revoke reports nothing changed.
22
+ expect(await store.revokeKey(betaKey.id, beta.id)).toBe(false);
23
+ db.close();
24
+ });
25
+ });
@@ -158,12 +158,13 @@ export class ProjectsStore {
158
158
  };
159
159
  }
160
160
 
161
- async revokeKey(keyId: string): Promise<boolean> {
161
+ async revokeKey(keyId: string, projectId: string): Promise<boolean> {
162
162
  const result = await this.db
163
163
  .prepare(
164
- `UPDATE ingest_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`,
164
+ `UPDATE ingest_keys SET revoked_at = ?
165
+ WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,
165
166
  )
166
- .bind(new Date().toISOString(), keyId)
167
+ .bind(new Date().toISOString(), keyId, projectId)
167
168
  .run();
168
169
  return (result.meta?.changes ?? 0) > 0;
169
170
  }
@@ -4,16 +4,25 @@ import { postgresDialect, sqliteDialect } from "./sql-db";
4
4
  describe("SqlDialect", () => {
5
5
  it("renders native SQLite expressions", () => {
6
6
  expect(sqliteDialect.sinceHours("?")).toBe(
7
- "datetime('now', '-' || ? || ' hours')",
7
+ "strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' hours')",
8
8
  );
9
9
  expect(
10
10
  sqliteDialect.jsonText("attributes_json", '$."llm.cost.total_usd"'),
11
11
  ).toBe(`json_extract(attributes_json, '$."llm.cost.total_usd"')`);
12
12
  });
13
13
 
14
- it("renders native Postgres expressions without adapter regex translation", () => {
14
+ it("renders Postgres time windows as ISO text (timestamp columns are TEXT)", () => {
15
+ // `received_at` / `expires_at` are TEXT on Postgres too, and Postgres
16
+ // refuses `text >= timestamptz`, so windows must come back as text in
17
+ // the same `YYYY-MM-DDTHH:MM:SS.mmmZ` shape `Date#toISOString` writes.
15
18
  expect(postgresDialect.sinceHours("$1")).toBe(
16
- "(CURRENT_TIMESTAMP - ($1::text || ' hours')::interval)",
19
+ `to_char((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - ($1::text || ' hours')::interval, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`,
20
+ );
21
+ expect(postgresDialect.sinceMinutes("$2")).toBe(
22
+ `to_char((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - ($2::text || ' minutes')::interval, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`,
23
+ );
24
+ expect(postgresDialect.now()).toBe(
25
+ `to_char((CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`,
17
26
  );
18
27
  expect(
19
28
  postgresDialect.jsonText("attributes_json", '$."llm.cost.total_usd"'),