diff --git a/apps/server/src/libs/middlewares/track.test.ts b/apps/server/src/libs/middlewares/track.test.ts index 5e874085..566eb795 100644 --- a/apps/server/src/libs/middlewares/track.test.ts +++ b/apps/server/src/libs/middlewares/track.test.ts @@ -11,7 +11,7 @@ import { Hono } from "hono"; import type { Variables } from "@/types"; -import { cliTrackMiddleware } from "./track"; +import { apiTrackMiddleware } from "./track"; // @openstatus/analytics is swapped for a double (test.importmap.json) whose // setupAnalytics/track spies are exposed here on globalThis. @@ -34,7 +34,7 @@ const TEST_WORKSPACE = { /** * Minimal app with `workspace` pre-set (simulating `authMiddleware`) and - * `cliTrackMiddleware` mounted the way the V1 router mounts it. + * `apiTrackMiddleware` mounted the way the V1 router mounts it. */ function makeApp(opts: { withWorkspace?: boolean } = {}) { const app = new Hono<{ Variables: Variables }>(); @@ -42,8 +42,9 @@ function makeApp(opts: { withWorkspace?: boolean } = {}) { if (opts.withWorkspace !== false) c.set("workspace", TEST_WORKSPACE); await next(); }); - app.use("*", cliTrackMiddleware()); + app.use("*", apiTrackMiddleware()); app.get("/whoami", (c) => c.text("ok")); + app.get("/monitor/:id", (c) => c.text("missing", 404)); return app; } @@ -61,21 +62,26 @@ function cliCommandCalls() { ); } -describe("cliTrackMiddleware", () => { +describe("apiTrackMiddleware", () => { beforeEach(() => { mockSetupAnalytics.mockClear(); mockTrack.mockClear(); }); - test("skips requests without CLI headers", async () => { - const res = await makeApp().request("/whoami", { + test("fires api_request for GET requests with the route pattern", async () => { + const res = await makeApp().request("/monitor/123", { headers: { "user-agent": "curl/8" }, }); await flush(); - expect(res.status).toBe(200); - expect(mockSetupAnalytics).not.toHaveBeenCalled(); - expect(mockTrack).not.toHaveBeenCalled(); + expect(res.status).toBe(404); + expect(mockTrack).toHaveBeenCalledTimes(1); + expect(mockTrack).toHaveBeenCalledWith({ + ...Events.ApiRequest, + service: "v1", + method: "GET /monitor/:id", + success: false, + }); }); test("skips requests without a workspace", async () => { @@ -89,7 +95,7 @@ describe("cliTrackMiddleware", () => { expect(mockTrack).not.toHaveBeenCalled(); }); - test("fires cli_command once per invocation", async () => { + test("tags CLI requests and fires cli_command once per invocation", async () => { const app = makeApp(); const invocation = crypto.randomUUID(); @@ -98,6 +104,14 @@ describe("cliTrackMiddleware", () => { await flush(); } + expect(mockTrack).toHaveBeenCalledWith({ + ...Events.ApiRequest, + service: "v1", + method: "GET /whoami", + success: true, + cliCommand: "whoami", + cliVersion: "1.3.2", + }); expect(cliCommandCalls()).toHaveLength(1); expect(cliCommandCalls()[0][0]).toEqual({ ...Events.CliCommand, diff --git a/apps/server/src/libs/middlewares/track.ts b/apps/server/src/libs/middlewares/track.ts index a0fe349b..915b2cf0 100644 --- a/apps/server/src/libs/middlewares/track.ts +++ b/apps/server/src/libs/middlewares/track.ts @@ -1,10 +1,12 @@ import { getLogger } from "@logtape/logtape"; import { type EventProps, + Events, parseInputToProps, setupAnalytics, } from "@openstatus/analytics"; import type { Context, Next } from "hono"; +import { routePath } from "hono/route"; import { apiAnalyticsIdentity } from "@/libs/analytics-identity"; import { parseCliHeaders, trackCliCommand } from "@/libs/cli-telemetry"; @@ -45,27 +47,46 @@ export function trackMiddleware(event: EventProps, eventProps?: string[]) { } /** - * Fires `cli_command` for the first request of each openstatus CLI run. V1 is - * deprecated and gets no `api_request` volume tracking, but some CLI commands - * (`whoami`) only ever call it, so their runs would go uncounted without this. - * Mount after `authMiddleware`; counts the run whatever the response status. + * Fires `api_request` for every authenticated V1 call — reads included, and + * failures too (`success: false`) — mirroring the RPC tracking interceptor so + * API volume is countable across both surfaces. `method` is the matched route + * pattern (`GET /v1/monitor/:id`), never the raw URL, to keep it low-cardinality. + * + * Requests from the openstatus CLI also carry `cliCommand`/`cliVersion`, and + * the first request of each CLI run fires one `cli_command`. + * + * Mount after `authMiddleware`; requests it rejects carry no workspace and are + * skipped. Per-route domain events stay with `trackMiddleware`. */ -export function cliTrackMiddleware() { +export function apiTrackMiddleware() { return async (c: Context<{ Variables: Variables }, "/*">, next: Next) => { - const cli = parseCliHeaders(c.req.raw.headers); + await next(); + const workspace = c.get("workspace"); + if (!workspace) return; - if (cli && workspace) { - setupAnalytics(apiAnalyticsIdentity(workspace, c.req.raw.headers)) - .then((analytics) => trackCliCommand(analytics, workspace.id, cli)) - .catch(() => { - logger.warn( - "Failed to send CLI analytics event for workspace {workspaceId}", - { workspaceId: workspace.id }, - ); - }); - } + const cli = parseCliHeaders(c.req.raw.headers); + const success = c.res.status.toString().startsWith("2") && !c.error; - await next(); + setupAnalytics(apiAnalyticsIdentity(workspace, c.req.raw.headers)) + .then((analytics) => + Promise.all([ + analytics.track({ + ...Events.ApiRequest, + service: "v1", + method: `${c.req.method} ${routePath(c, -1)}`, + success, + ...(cli ? { cliCommand: cli.command } : {}), + ...(cli?.version ? { cliVersion: cli.version } : {}), + }), + ...(cli ? [trackCliCommand(analytics, workspace.id, cli)] : []), + ]), + ) + .catch(() => { + logger.warn( + "Failed to send API analytics events for workspace {workspaceId}", + { workspaceId: workspace.id }, + ); + }); }; } diff --git a/apps/server/src/routes/rpc/interceptors/tracking.ts b/apps/server/src/routes/rpc/interceptors/tracking.ts index e2404a77..4db826c1 100644 --- a/apps/server/src/routes/rpc/interceptors/tracking.ts +++ b/apps/server/src/routes/rpc/interceptors/tracking.ts @@ -156,7 +156,8 @@ export const RPC_EVENT_MAP: Record = { * * Every authenticated call fires an `api_request` event — reads included, and * failures too (`success: false`) — so API volume is countable per workspace - * the same way `mcp_request` counts MCP traffic. Successful calls listed in + * the same way `mcp_request` counts MCP traffic. V1 REST fires the same event + * from `apiTrackMiddleware`. Successful calls listed in * `RPC_EVENT_MAP` additionally fire their domain event (e.g. `monitor_created`). * * Requests from the openstatus CLI also carry `cliCommand`/`cliVersion` on diff --git a/apps/server/src/routes/v1/index.ts b/apps/server/src/routes/v1/index.ts index dc2c35c0..3866c045 100644 --- a/apps/server/src/routes/v1/index.ts +++ b/apps/server/src/routes/v1/index.ts @@ -8,7 +8,7 @@ import { env } from "@/env"; import { handleZodError } from "@/libs/errors"; import { authMiddleware, - cliTrackMiddleware, + apiTrackMiddleware, requireWriteScope, } from "@/libs/middlewares"; @@ -149,7 +149,7 @@ api.get( * Middlewares */ api.use("/*", authMiddleware); -api.use("/*", cliTrackMiddleware()); +api.use("/*", apiTrackMiddleware()); // Primary scope enforcement for V1: routes here use inline Drizzle // queries instead of `@openstatus/services`, so the service-level // `requireScope` won't run. After per-route migration to services,