@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
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Dashboard auth used to guard `/dashboard/login` itself, and the SPA
3
+ * fallback redirected `/dashboard/*` to `/dashboard/` — which it also
4
+ * matched. A browser hitting `/` on a collector without an assets binding
5
+ * bounced forever. These tests follow the redirect chain by hand.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { createDashboardAuth } from "../auth/dashboard-auth";
10
+ import {
11
+ createTelemetryCollectorApp,
12
+ isDashboardLoginPath,
13
+ } from "../framework/collector";
14
+ import type { CollectorEnv } from "../framework/env";
15
+ import { MemSqlDb } from "../lib/test-utils/mem-sql-db";
16
+ import { dashboardRoutesPlugin } from "./dashboard-routes";
17
+
18
+ const HTML = { Accept: "text/html,application/xhtml+xml" };
19
+
20
+ const makeApp = (auth: Parameters<typeof createDashboardAuth>[0]) =>
21
+ createTelemetryCollectorApp({
22
+ plugins: [dashboardRoutesPlugin],
23
+ dashboardAuth: createDashboardAuth(auth),
24
+ });
25
+
26
+ const env = (extra: Record<string, unknown> = {}) =>
27
+ ({ DB: new MemSqlDb(), ...extra }) as unknown as CollectorEnv;
28
+
29
+ /** Follow same-origin redirects; returns every path visited plus the final response. */
30
+ const follow = async (
31
+ app: ReturnType<typeof makeApp>,
32
+ path: string,
33
+ headers: Record<string, string>,
34
+ maxHops = 6,
35
+ ) => {
36
+ const visited: string[] = [];
37
+ let current = path;
38
+ for (let hop = 0; hop < maxHops; hop += 1) {
39
+ visited.push(current);
40
+ const res = await app.request(current, { headers }, env());
41
+ const location = res.headers.get("Location");
42
+ if (res.status < 300 || res.status >= 400 || !location) {
43
+ return { visited, res };
44
+ }
45
+ current = location;
46
+ }
47
+ throw new Error(`redirect loop: ${visited.join(" -> ")}`);
48
+ };
49
+
50
+ describe("dashboard routes + auth", () => {
51
+ it("isDashboardLoginPath matches the login page and its sub-routes only", () => {
52
+ expect(isDashboardLoginPath("/dashboard/login")).toBe(true);
53
+ expect(isDashboardLoginPath("/dashboard/login/")).toBe(true);
54
+ expect(isDashboardLoginPath("/dashboard/login/forgot")).toBe(true);
55
+ expect(isDashboardLoginPath("/dashboard/loginx")).toBe(false);
56
+ expect(isDashboardLoginPath("/dashboard/")).toBe(false);
57
+ expect(isDashboardLoginPath("/internal/login")).toBe(false);
58
+ });
59
+
60
+ it("an unauthenticated browser at / reaches the login page without looping", async () => {
61
+ const app = makeApp({ password: "pw" });
62
+ const { visited, res } = await follow(app, "/", HTML);
63
+ expect(visited).toEqual(["/", "/dashboard/", "/dashboard/login"]);
64
+ // No assets binding: the login page cannot be served here, but the
65
+ // chain must end instead of bouncing back to /dashboard/.
66
+ expect(res.status).toBe(404);
67
+ expect(res.headers.get("Location")).toBeNull();
68
+ await expect(res.json()).resolves.toMatchObject({
69
+ error: expect.stringContaining("Dashboard assets are not configured"),
70
+ });
71
+ });
72
+
73
+ it("still redirects unauthenticated HTML requests for other dashboard pages to login", async () => {
74
+ const app = makeApp({ password: "pw" });
75
+ const res = await app.request(
76
+ "/dashboard/traces",
77
+ { headers: HTML },
78
+ env(),
79
+ );
80
+ expect(res.status).toBe(302);
81
+ expect(res.headers.get("Location")).toBe("/dashboard/login");
82
+ });
83
+
84
+ it("does not redirect /dashboard/login to itself", async () => {
85
+ const app = makeApp({ password: "pw" });
86
+ const res = await app.request("/dashboard/login", { headers: HTML }, env());
87
+ expect(res.status).toBe(404);
88
+ expect(res.headers.get("Location")).toBeNull();
89
+ });
90
+
91
+ it("an authenticated request without assets does not loop on /dashboard/", async () => {
92
+ const app = makeApp({ token: "tok" });
93
+ const headers = { ...HTML, Authorization: "Bearer tok" };
94
+ const { visited, res } = await follow(app, "/", headers);
95
+ expect(visited).toEqual(["/", "/dashboard/"]);
96
+ expect(res.status).toBe(404);
97
+ });
98
+
99
+ it("serves the SPA entrypoint from the assets binding for deep links", async () => {
100
+ const app = makeApp({ token: "tok" });
101
+ const fetched: string[] = [];
102
+ const assets = {
103
+ fetch: async (req: Request) => {
104
+ fetched.push(new URL(req.url).pathname);
105
+ return new Response("<html>spa</html>", {
106
+ status: 200,
107
+ headers: { "content-type": "text/html" },
108
+ });
109
+ },
110
+ };
111
+ const res = await app.request(
112
+ "/dashboard/traces/abc",
113
+ { headers: { ...HTML, Authorization: "Bearer tok" } },
114
+ env({ ASSETS: assets }),
115
+ );
116
+ expect(res.status).toBe(200);
117
+ expect(await res.text()).toBe("<html>spa</html>");
118
+ expect(fetched).toEqual(["/dashboard/"]);
119
+ });
120
+ });
@@ -29,8 +29,17 @@ export const dashboardRoutesPlugin: CollectorPlugin = {
29
29
  url.pathname = "/dashboard/";
30
30
  return assets.fetch(new Request(url.toString(), c.req.raw));
31
31
  }
32
- // Fallback: redirects to SPA root
33
- return c.redirect("/dashboard/");
32
+ // No assets binding: there is nothing to serve. Redirecting to
33
+ // `/dashboard/` here would match this same route and loop forever
34
+ // (and bounce unauthenticated browsers between `/dashboard/` and
35
+ // `/dashboard/login` endlessly).
36
+ return c.json(
37
+ {
38
+ error:
39
+ "Dashboard assets are not configured on this collector. Serve @obsunified/dashboard separately or bind [assets] in wrangler.toml.",
40
+ },
41
+ 404,
42
+ );
34
43
  });
35
44
 
36
45
  // Root redirect: visiting the collector root goes to dashboard
@@ -19,6 +19,7 @@ import { sourceLinkEvidenceReferences } from "../lib/evidence-references";
19
19
  import { randomHex } from "../lib/hash";
20
20
  import { sqlDbFor } from "../lib/sql-db";
21
21
  import { getProjectId } from "./_context";
22
+ import { readJsonObject } from "./_json";
22
23
 
23
24
  interface EvalCaseRequestBody {
24
25
  sourceEntityType?: unknown;
@@ -123,12 +124,8 @@ export const evalCasesRoutesPlugin: CollectorPlugin = {
123
124
  name: "eval-cases-routes",
124
125
  register(app) {
125
126
  app.post("/internal/eval-cases", async (c) => {
126
- let body: EvalCaseRequestBody;
127
- try {
128
- body = (await c.req.json()) as EvalCaseRequestBody;
129
- } catch {
130
- return c.json(badRequest("body must be JSON"), 400);
131
- }
127
+ const body = await readJsonObject<EvalCaseRequestBody>(c);
128
+ if (!body) return c.json(badRequest("body must be a JSON object"), 400);
132
129
 
133
130
  const sourceType = body.sourceEntityType ?? body.source?.type;
134
131
  if (!isEvalCaseSourceType(sourceType)) {
@@ -236,12 +233,8 @@ export const evalCasesRoutesPlugin: CollectorPlugin = {
236
233
  });
237
234
 
238
235
  app.post("/internal/eval-runs", async (c) => {
239
- let body: EvalRunRequestBody;
240
- try {
241
- body = (await c.req.json()) as EvalRunRequestBody;
242
- } catch {
243
- return c.json(badRequest("body must be JSON"), 400);
244
- }
236
+ const body = await readJsonObject<EvalRunRequestBody>(c);
237
+ if (!body) return c.json(badRequest("body must be a JSON object"), 400);
245
238
 
246
239
  const id = asOptionalString(body.id ?? body.runId)?.trim();
247
240
  if ((body.id ?? body.runId) !== undefined && !id) {
@@ -378,11 +371,9 @@ export const evalCasesRoutesPlugin: CollectorPlugin = {
378
371
  actualOutcome?: unknown;
379
372
  details?: unknown;
380
373
  };
381
- try {
382
- body = (await c.req.json()) as typeof body;
383
- } catch {
384
- return c.json(badRequest("body must be JSON"), 400);
385
- }
374
+ const parsed = await readJsonObject<typeof body>(c);
375
+ if (!parsed) return c.json(badRequest("body must be a JSON object"), 400);
376
+ body = parsed;
386
377
 
387
378
  if (body.passed === undefined || typeof body.passed !== "boolean") {
388
379
  return c.json(badRequest("passed must be a boolean"), 400);
@@ -2,18 +2,15 @@ import type { IdentifyInput } from "@obsunified/types";
2
2
  import type { CollectorPlugin } from "../framework/collector";
3
3
  import { dialectFor, sqlDbFor } from "../lib/sql-db";
4
4
  import { getProjectId } from "./_context";
5
+ import { readJsonObject } from "./_json";
5
6
 
6
7
  export const identityReceiverPlugin: CollectorPlugin = {
7
8
  name: "identity-receiver",
8
9
  register(app) {
9
10
  app.post("/v1/identify", async (c) => {
10
11
  const projectId = getProjectId(c);
11
- let payload: IdentifyInput;
12
- try {
13
- payload = await c.req.json<IdentifyInput>();
14
- } catch {
15
- return c.json({ error: "Invalid JSON body" }, 400);
16
- }
12
+ const payload = await readJsonObject<IdentifyInput>(c);
13
+ if (!payload) return c.json({ error: "Invalid JSON body" }, 400);
17
14
  if (!payload.userId || typeof payload.userId !== "string") {
18
15
  return c.json({ error: "userId is required" }, 400);
19
16
  }
@@ -51,10 +48,11 @@ export const identityReceiverPlugin: CollectorPlugin = {
51
48
  ? JSON.stringify(payload.properties)
52
49
  : null;
53
50
 
54
- // user_id is the PK today (single-tenant legacy). For MVP, we keep
55
- // that contract — user_id is treated as globally unique and the
56
- // first project to identify them "owns" the profile. project_id on
57
- // the row reflects where the user was first seen.
51
+ // Profiles are keyed by (project_id, user_id) — migration 045.
52
+ // user_id values are caller-chosen (often an email), so two
53
+ // projects identifying the same id must get two independent rows;
54
+ // an ingest key for one project can never read or rewrite another
55
+ // project's profile through this endpoint.
58
56
  //
59
57
  // On conflict we keep the EARLIEST first_seen_at via MIN() — once
60
58
  // observed, a user's first-seen is monotonic; an identify() call
@@ -69,7 +67,7 @@ export const identityReceiverPlugin: CollectorPlugin = {
69
67
  .prepare(
70
68
  `INSERT INTO user_profiles (project_id, user_id, visitor_id, email, name, properties_json, first_seen_at, last_seen_at)
71
69
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
72
- ON CONFLICT(user_id) DO UPDATE SET
70
+ ON CONFLICT(project_id, user_id) DO UPDATE SET
73
71
  visitor_id = excluded.visitor_id,
74
72
  email = COALESCE(excluded.email, user_profiles.email),
75
73
  name = COALESCE(excluded.name, user_profiles.name),
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `c.req.json()` resolves `null`, arrays and primitives for bodies such as
3
+ * `null` or `"x"`. Every JSON ingest and admin route used to dereference the
4
+ * result straight away and answer 500. They must all answer 400 now, and the
5
+ * replay receiver must reject a chunk before writing it to blob storage.
6
+ */
7
+
8
+ import type { MiddlewareHandler } from "hono";
9
+ import { describe, expect, it } from "vitest";
10
+ import { createTelemetryCollectorApp } from "../framework/collector";
11
+ import type { CollectorEnv } from "../framework/env";
12
+ import { MemSqlDb } from "../lib/test-utils/mem-sql-db";
13
+ import { aiReceiverPlugin } from "./ai-receiver";
14
+ import { askRoutesPlugin } from "./ask-routes";
15
+ import { evalCasesRoutesPlugin } from "./eval-cases-routes";
16
+ import { identityReceiverPlugin } from "./identity-receiver";
17
+ import { replayReceiverPlugin } from "./replay-receiver";
18
+ import { usageReceiverPlugin } from "./usage-receiver";
19
+
20
+ const passthrough: MiddlewareHandler<{
21
+ Bindings: CollectorEnv;
22
+ Variables: { projectId: string };
23
+ }> = async (c, next) => {
24
+ c.set("projectId", "default");
25
+ await next();
26
+ };
27
+
28
+ const app = createTelemetryCollectorApp({
29
+ plugins: [
30
+ aiReceiverPlugin,
31
+ usageReceiverPlugin,
32
+ replayReceiverPlugin,
33
+ identityReceiverPlugin,
34
+ askRoutesPlugin,
35
+ evalCasesRoutesPlugin,
36
+ ],
37
+ auth: { middleware: passthrough },
38
+ dashboardAuth: { middleware: passthrough, registerRoutes: () => {} },
39
+ });
40
+
41
+ const ROUTES = [
42
+ "/v1/ai",
43
+ "/v1/ai/evaluations",
44
+ "/v1/usage",
45
+ "/v1/replays",
46
+ "/v1/identify",
47
+ "/internal/ask",
48
+ "/internal/eval-cases",
49
+ "/internal/eval-runs",
50
+ "/internal/eval-cases/case-1/results",
51
+ ];
52
+
53
+ const NON_OBJECT_BODIES = ["null", "[]", '"text"', "42", "true"];
54
+
55
+ const post = (path: string, body: string, env: Partial<CollectorEnv> = {}) =>
56
+ app.request(
57
+ path,
58
+ { method: "POST", headers: { "content-type": "application/json" }, body },
59
+ { DB: new MemSqlDb() as unknown as D1Database, ...env } as CollectorEnv,
60
+ );
61
+
62
+ describe("JSON body validation", () => {
63
+ for (const path of ROUTES) {
64
+ it(`${path} answers 400 for non-object JSON bodies`, async () => {
65
+ for (const body of NON_OBJECT_BODIES) {
66
+ const res = await post(path, body);
67
+ expect(res.status, `${path} <- ${body}`).toBe(400);
68
+ const json = (await res.json()) as { error?: string };
69
+ expect(typeof json.error, `${path} <- ${body}`).toBe("string");
70
+ }
71
+ const malformed = await post(path, "{not json");
72
+ expect(malformed.status, `${path} <- malformed`).toBe(400);
73
+ });
74
+ }
75
+
76
+ it("/v1/replays rejects a chunk without visitorId before touching blob storage", async () => {
77
+ const puts: string[] = [];
78
+ const bucket = {
79
+ put: async (key: string) => {
80
+ puts.push(key);
81
+ },
82
+ };
83
+ const res = await post(
84
+ "/v1/replays",
85
+ JSON.stringify({ sessionId: "sess-1", sequenceNumber: 0, events: [] }),
86
+ { REPLAYS_BUCKET: bucket as unknown as R2Bucket },
87
+ );
88
+ expect(res.status).toBe(400);
89
+ expect(puts).toEqual([]);
90
+ });
91
+ });
@@ -0,0 +1,105 @@
1
+ import type { StoredSpan } from "@obsunified/types";
2
+ import type { MiddlewareHandler } from "hono";
3
+ import { describe, expect, it } from "vitest";
4
+ import { createTelemetryCollectorApp } from "../framework/collector";
5
+ import type { CollectorEnv } from "../framework/env";
6
+ import { ingestTelemetrySpans } from "../lib/store/ingest";
7
+ import { createMigratedSqliteDb } from "../lib/test-utils/node-sqlite-db";
8
+ import { onboardingRoutesPlugin } from "./onboarding-routes";
9
+
10
+ const NOW = new Date().toISOString();
11
+ const LATER = new Date(Date.now() + 3_600_000).toISOString();
12
+
13
+ const span = (
14
+ projectId: string,
15
+ spanId: string,
16
+ interactionId: string | null,
17
+ ) =>
18
+ ({
19
+ projectId,
20
+ traceId: `trace-${projectId}`,
21
+ spanId,
22
+ parentSpanId: null,
23
+ traceState: null,
24
+ serviceName: "web",
25
+ scopeName: null,
26
+ scopeVersion: null,
27
+ spanName: "GET /",
28
+ spanKind: 2,
29
+ statusCode: 1,
30
+ statusMessage: null,
31
+ startTime: NOW,
32
+ endTime: NOW,
33
+ durationMs: 1,
34
+ attributesJson: "{}",
35
+ droppedAttributesCount: 0,
36
+ resourceAttributesJson: "{}",
37
+ eventsJson: "[]",
38
+ droppedEventsCount: 0,
39
+ linksJson: "[]",
40
+ droppedLinksCount: 0,
41
+ receivedAt: NOW,
42
+ expiresAt: LATER,
43
+ sessionId: null,
44
+ interactionId,
45
+ }) satisfies StoredSpan;
46
+
47
+ describe("/internal/onboarding/counts", () => {
48
+ it("counts only the caller's project", async () => {
49
+ const db = createMigratedSqliteDb();
50
+ await ingestTelemetrySpans(db, [
51
+ span("alpha", "a1", "click-1"),
52
+ span("alpha", "a2", null),
53
+ span("beta", "b1", null),
54
+ ]);
55
+ db.raw
56
+ .prepare(
57
+ `INSERT INTO user_profiles (project_id, user_id, visitor_id, first_seen_at, last_seen_at)
58
+ VALUES ('beta', 'u1', 'v1', ?, ?)`,
59
+ )
60
+ .run(NOW, NOW);
61
+
62
+ const setProject: MiddlewareHandler<{
63
+ Bindings: CollectorEnv;
64
+ Variables: { projectId: string };
65
+ }> = async (c, next) => {
66
+ const header = c.req.header("x-project-id");
67
+ if (header) c.set("projectId", header);
68
+ await next();
69
+ };
70
+ const app = createTelemetryCollectorApp({
71
+ plugins: [onboardingRoutesPlugin],
72
+ dashboardAuth: { middleware: setProject, registerRoutes: () => {} },
73
+ });
74
+ const counts = async (projectId: string) => {
75
+ const res = await app.request(
76
+ "/internal/onboarding/counts",
77
+ { headers: { "x-project-id": projectId } },
78
+ { DB: db as unknown as D1Database } as CollectorEnv,
79
+ );
80
+ expect(res.status).toBe(200);
81
+ return res.json();
82
+ };
83
+
84
+ expect(await counts("alpha")).toMatchObject({
85
+ spans: 2,
86
+ spansWithInteraction: 1,
87
+ identifiedUsers: 0,
88
+ });
89
+ expect(await counts("beta")).toMatchObject({
90
+ spans: 1,
91
+ spansWithInteraction: 0,
92
+ identifiedUsers: 1,
93
+ });
94
+ expect(await counts("gamma")).toMatchObject({
95
+ spans: 0,
96
+ logs: 0,
97
+ usageEvents: 0,
98
+ identifiedUsers: 0,
99
+ aiCalls: 0,
100
+ replayChunks: 0,
101
+ spansWithInteraction: 0,
102
+ });
103
+ db.close();
104
+ });
105
+ });
@@ -1,5 +1,6 @@
1
1
  import type { CollectorPlugin } from "../framework/collector";
2
2
  import { sqlDbFor } from "../lib/sql-db";
3
+ import { getProjectId } from "./_context";
3
4
 
4
5
  /**
5
6
  * Drives the in-dashboard Onboarding tab — returns counts of signal
@@ -14,17 +15,23 @@ export const onboardingRoutesPlugin: CollectorPlugin = {
14
15
  register(app) {
15
16
  app.get("/internal/onboarding/counts", async (c) => {
16
17
  const db = sqlDbFor(c.env);
17
- const counts = await Promise.all([
18
- safeCount(db, "SELECT COUNT(*) AS n FROM telemetry_spans"),
19
- safeCount(db, "SELECT COUNT(*) AS n FROM logs"),
20
- safeCount(db, "SELECT COUNT(*) AS n FROM usage_events"),
21
- safeCount(db, "SELECT COUNT(*) AS n FROM user_profiles"),
22
- safeCount(db, "SELECT COUNT(*) AS n FROM ai_calls"),
23
- safeCount(db, "SELECT COUNT(*) AS n FROM session_replay_metadata"),
18
+ // Every signal table is project-scoped; counting across tenants
19
+ // would leak other projects' volumes and hide an empty project.
20
+ const projectId = getProjectId(c);
21
+ const count = (table: string, extra = "") =>
24
22
  safeCount(
25
23
  db,
26
- "SELECT COUNT(*) AS n FROM telemetry_spans WHERE interaction_id IS NOT NULL",
27
- ),
24
+ `SELECT COUNT(*) AS n FROM ${table} WHERE project_id = ?${extra}`,
25
+ projectId,
26
+ );
27
+ const counts = await Promise.all([
28
+ count("telemetry_spans"),
29
+ count("logs"),
30
+ count("usage_events"),
31
+ count("user_profiles"),
32
+ count("ai_calls"),
33
+ count("session_replay_metadata"),
34
+ count("telemetry_spans", " AND interaction_id IS NOT NULL"),
28
35
  ]);
29
36
 
30
37
  return c.json({
@@ -41,11 +48,19 @@ export const onboardingRoutesPlugin: CollectorPlugin = {
41
48
  };
42
49
 
43
50
  async function safeCount(
44
- db: { prepare: (sql: string) => { first: <T>() => Promise<T | null> } },
51
+ db: {
52
+ prepare: (sql: string) => {
53
+ bind: (...args: unknown[]) => { first: <T>() => Promise<T | null> };
54
+ };
55
+ },
45
56
  sql: string,
57
+ ...binds: unknown[]
46
58
  ): Promise<number> {
47
59
  try {
48
- const row = await db.prepare(sql).first<{ n: number }>();
60
+ const row = await db
61
+ .prepare(sql)
62
+ .bind(...binds)
63
+ .first<{ n: number }>();
49
64
  return Number(row?.n ?? 0);
50
65
  } catch {
51
66
  // Table doesn't exist yet — fresh DB, no signals of this type.
@@ -81,9 +81,10 @@ export const projectsRoutesPlugin: CollectorPlugin = {
81
81
  });
82
82
 
83
83
  app.delete("/internal/projects/:id/keys/:keyId", async (c) => {
84
+ const projectId = c.req.param("id");
84
85
  const keyId = c.req.param("keyId");
85
86
  const store = new ProjectsStore(sqlDbFor(c.env));
86
- const revoked = await store.revokeKey(keyId);
87
+ const revoked = await store.revokeKey(keyId, projectId);
87
88
  if (!revoked)
88
89
  return c.json({ error: "Key not found or already revoked" }, 404);
89
90
  return c.json({ success: true });