@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.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/index.d.ts +322 -26
- package/dist/index.js +1478 -415
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/package.json +5 -5
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +16 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +60 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/housekeeping.test.ts +73 -0
- package/src/lib/housekeeping.ts +93 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +64 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +165 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +14 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/eval-cases-routes.ts +8 -17
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- 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
|
-
//
|
|
33
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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 });
|