diff --git a/package-lock.json b/package-lock.json index c4c71a0291..d89c709c41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "hono": "^4.12.27", "ioredis": "^5.11.1", "pg": "^8.22.0", + "posthog-node": "^5.44.0", "sharp": "^0.34.5", "yaml": "^2.9.0", "zod": "^4.4.3" @@ -4462,6 +4463,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@posthog/core": { + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.42.1.tgz", + "integrity": "sha512-cZojBmvf92gZAn0MGf/J2S+3t2sZqb9E2/6mjY4jJsImwz7PuYqdNj870Wx2iwwlf++wfhRuH4K4bpDy9GIoCA==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.395.0" + } + }, + "node_modules/@posthog/types": { + "version": "1.395.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.395.0.tgz", + "integrity": "sha512-Ty0gbVc6d9ku9H3caF54PmEfu4PHUGpJKTriMyDb4rjCTsEYOOjD4r6Fw/UMYSSWg8Ls3KnCtTow8LL6ciqNDg==", + "license": "MIT" + }, "node_modules/@puppeteer/browsers": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.2.4.tgz", @@ -17271,6 +17287,26 @@ "node": ">=0.10.0" } }, + "node_modules/posthog-node": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.44.0.tgz", + "integrity": "sha512-dTKnUffCFfA4lSD8dD7sQ7SnGb0g8S42jEbhyGJxNim6SPHd95OBvhWSf9uDc/1X26XLZRGEln01IRPjLey+yQ==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.42.1" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index b249d271d8..e6e0116479 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "hono": "^4.12.27", "ioredis": "^5.11.1", "pg": "^8.22.0", + "posthog-node": "^5.44.0", "sharp": "^0.34.5", "yaml": "^2.9.0", "zod": "^4.4.3" diff --git a/src/env.d.ts b/src/env.d.ts index e2542d3493..51dff10728 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -553,6 +553,16 @@ declare global { * token is sufficient — this probe never writes). A secret — never commit a real value. See * CLOUDFLARE_D1_MONITOR_ACCOUNT_ID. */ CLOUDFLARE_D1_MONITOR_API_TOKEN?: string; + /** Opt-in MCP telemetry (#6228/#6235): the PostHog project API key the typed `src/mcp/telemetry.ts` + * wrapper sends anonymized tool-call counters to. Unset (default — every self-hoster who doesn't opt in) + * ⇒ recordMcpToolCall is a safe no-op that records nothing, byte-identical to before this module existed. + * Only the #6228 allowlist is ever sent — tool name, caller type, ok, coarse duration — never arguments, + * source, or any wallet/hotkey/trust-score data. A secret — inject via `wrangler secret`, never commit. */ + POSTHOG_API_KEY?: string; + /** Opt-in MCP telemetry host override (#6235): the PostHog ingestion host recordMcpToolCall points at + * (e.g. https://eu.i.posthog.com for EU-cloud). Unset ⇒ the US-cloud default (https://us.i.posthog.com). + * Only meaningful alongside POSTHOG_API_KEY; ignored when telemetry is unconfigured. */ + POSTHOG_HOST?: string; } } diff --git a/src/mcp/telemetry.ts b/src/mcp/telemetry.ts new file mode 100644 index 0000000000..44b9d5347a --- /dev/null +++ b/src/mcp/telemetry.ts @@ -0,0 +1,82 @@ +import { PostHog } from "posthog-node"; + +// MCP telemetry wrapper (#6235, foundation of #6228). A thin, typed seam around the PostHog Node SDK so the +// rest of this MCP-telemetry work has ONE place to record a tool call — no other module ever constructs a raw +// PostHog event. The tracked-field allowlist decided in #6228 (tool name + caller type + success + coarse +// latency, and NOTHING else — no arguments, no source, no wallet/hotkey/trust-score data) is enforced here at +// the type level: the only way in is {@link recordMcpToolCall}, whose event shape is exactly the allowlist. +// +// SAFE NO-OP WHEN UNCONFIGURED: telemetry is opt-in. A deployment that never sets POSTHOG_API_KEY — every +// self-hoster who doesn't opt in — records nothing and behaves byte-identically to before this module existed. +// The wrapper also never throws: a PostHog init/capture failure degrades to recording nothing, exactly like +// the unconfigured path, so it can never surface an error into the MCP tool caller. +// +// NOT WIRED YET: per #6235 this module is deliberately NOT called from the tool-dispatch path — that (and the +// client lifecycle/flush strategy a live Worker needs) is the separate instrumentation issue's job. + +/** PostHog US-cloud ingestion host — the default when POSTHOG_HOST isn't set. */ +const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; + +/** The PostHog event name every MCP tool call is recorded under. */ +const MCP_TOOL_CALL_EVENT = "mcp_tool_call"; + +/** Anonymous, constant distinct id: the fleet telemetry carries NO per-actor identity by design (#6228), so + * every event shares one handle and there is no per-user person to build up. */ +const MCP_TELEMETRY_DISTINCT_ID = "loopover-mcp"; + +/** Which MCP surface a recorded tool call came through: the remote MCP (src/mcp/server.ts) is `"remote"`, the + * local stdio MCP (@loopover/mcp) is `"local"`. This is the caller-type dimension #6228 tracks. */ +export type McpTelemetryCallerType = "remote" | "local"; + +/** The COMPLETE, allowlisted shape of an MCP tool-call telemetry event (#6228). These four fields are the only + * thing ever sent to PostHog; the type is the enforcement — a caller cannot smuggle in an argument, a repo, or + * any wallet/hotkey/trust-score field, because there is nowhere in this shape to put it. */ +export interface McpToolCallEvent { + /** The MCP tool name, e.g. `"predict_gate"`. */ + tool: string; + /** Which MCP surface dispatched the call. */ + callerType: McpTelemetryCallerType; + /** Whether the tool call succeeded. */ + ok: boolean; + /** Coarse wall-clock duration of the call, in milliseconds. */ + durationMs: number; +} + +/** The env slice this wrapper reads. Both vars are opt-in secrets declared in `src/env.d.ts`; a live Worker + * passes its own `Env`, which is structurally assignable here. */ +export type McpTelemetryEnv = Pick; + +/** Record a single MCP tool call to PostHog. Safe no-op when telemetry is unconfigured (no POSTHOG_API_KEY), + * and never throws — a PostHog init/capture failure degrades to recording nothing (#6235). */ +export function recordMcpToolCall(env: McpTelemetryEnv, event: McpToolCallEvent): void { + const apiKey = trimmedOrUndefined(env.POSTHOG_API_KEY); + // Unconfigured ⇒ record nothing, byte-identical to before this module existed. + if (!apiKey) return; + + const host = trimmedOrUndefined(env.POSTHOG_HOST) ?? DEFAULT_POSTHOG_HOST; + try { + const client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 }); + client.capture({ + distinctId: MCP_TELEMETRY_DISTINCT_ID, + event: MCP_TOOL_CALL_EVENT, + // Exactly the #6228 allowlist — nothing more. + properties: { + tool: event.tool, + caller_type: event.callerType, + ok: event.ok, + duration_ms: event.durationMs, + }, + // No IP-based geo enrichment: the event is anonymous fleet telemetry, not a user location. + disableGeoip: true, + }); + } catch { + // Telemetry is best-effort and MUST NOT throw into the MCP tool caller (#6235): a PostHog init/capture + // failure degrades to recording nothing, identical to the unconfigured path above. + } +} + +/** Trim a possibly-undefined env string, treating blank/whitespace as absent. */ +function trimmedOrUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/test/unit/mcp-telemetry.test.ts b/test/unit/mcp-telemetry.test.ts new file mode 100644 index 0000000000..8a4446ebe1 --- /dev/null +++ b/test/unit/mcp-telemetry.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the PostHog Node SDK so nothing hits the network: the class records every constructor + capture call +// on hoisted spies, and per-test flags let us force an init/capture failure to exercise the never-throw path. +const h = vi.hoisted(() => ({ + constructSpy: vi.fn(), + captureSpy: vi.fn(), + state: { throwOnConstruct: false, throwOnCapture: false }, +})); + +vi.mock("posthog-node", () => ({ + PostHog: class { + constructor(apiKey: string, options: unknown) { + h.constructSpy(apiKey, options); + if (h.state.throwOnConstruct) throw new Error("posthog init failed"); + } + capture(message: unknown): void { + h.captureSpy(message); + if (h.state.throwOnCapture) throw new Error("posthog capture failed"); + } + }, +})); + +import { recordMcpToolCall, type McpToolCallEvent } from "../../src/mcp/telemetry"; + +const EVENT: McpToolCallEvent = { tool: "predict_gate", callerType: "remote", ok: true, durationMs: 42 }; + +describe("recordMcpToolCall", () => { + beforeEach(() => { + h.constructSpy.mockClear(); + h.captureSpy.mockClear(); + h.state.throwOnConstruct = false; + h.state.throwOnCapture = false; + }); + + it("is a safe no-op when POSTHOG_API_KEY is unset (unconfigured deployment)", () => { + recordMcpToolCall({}, EVENT); + expect(h.constructSpy).not.toHaveBeenCalled(); + expect(h.captureSpy).not.toHaveBeenCalled(); + }); + + it("treats a blank/whitespace API key as unconfigured", () => { + recordMcpToolCall({ POSTHOG_API_KEY: " " }, EVENT); + expect(h.constructSpy).not.toHaveBeenCalled(); + expect(h.captureSpy).not.toHaveBeenCalled(); + }); + + it("captures exactly the allowlisted fields against the US-cloud default host when configured", () => { + recordMcpToolCall({ POSTHOG_API_KEY: "phc_test" }, EVENT); + + expect(h.constructSpy).toHaveBeenCalledTimes(1); + expect(h.constructSpy).toHaveBeenCalledWith("phc_test", { + host: "https://us.i.posthog.com", + flushAt: 1, + flushInterval: 0, + }); + + expect(h.captureSpy).toHaveBeenCalledTimes(1); + const message = h.captureSpy.mock.calls[0]![0] as { + distinctId: string; + event: string; + properties: Record; + disableGeoip: boolean; + }; + expect(message.distinctId).toBe("loopover-mcp"); + expect(message.event).toBe("mcp_tool_call"); + expect(message.disableGeoip).toBe(true); + expect(message.properties).toEqual({ + tool: "predict_gate", + caller_type: "remote", + ok: true, + duration_ms: 42, + }); + // The allowlist is the whole payload — no argument/source/wallet/hotkey/trust-score field can ride along. + expect(Object.keys(message.properties).sort()).toEqual(["caller_type", "duration_ms", "ok", "tool"]); + }); + + it("honors a POSTHOG_HOST override and carries a local caller / failed call verbatim", () => { + recordMcpToolCall( + { POSTHOG_API_KEY: "phc_test", POSTHOG_HOST: "https://eu.i.posthog.com" }, + { tool: "check_slop_risk", callerType: "local", ok: false, durationMs: 0 }, + ); + + expect(h.constructSpy).toHaveBeenCalledWith("phc_test", { + host: "https://eu.i.posthog.com", + flushAt: 1, + flushInterval: 0, + }); + const message = h.captureSpy.mock.calls[0]![0] as { properties: Record }; + expect(message.properties).toEqual({ + tool: "check_slop_risk", + caller_type: "local", + ok: false, + duration_ms: 0, + }); + }); + + it("trims surrounding whitespace from the API key and host", () => { + recordMcpToolCall({ POSTHOG_API_KEY: " phc_test ", POSTHOG_HOST: " https://eu.i.posthog.com " }, EVENT); + expect(h.constructSpy).toHaveBeenCalledWith("phc_test", { + host: "https://eu.i.posthog.com", + flushAt: 1, + flushInterval: 0, + }); + }); + + it("falls back to the default host when POSTHOG_HOST is blank", () => { + recordMcpToolCall({ POSTHOG_API_KEY: "phc_test", POSTHOG_HOST: " " }, EVENT); + expect(h.constructSpy).toHaveBeenCalledWith("phc_test", { + host: "https://us.i.posthog.com", + flushAt: 1, + flushInterval: 0, + }); + }); + + it("never throws when the PostHog client fails to initialize", () => { + h.state.throwOnConstruct = true; + expect(() => recordMcpToolCall({ POSTHOG_API_KEY: "phc_test" }, EVENT)).not.toThrow(); + expect(h.captureSpy).not.toHaveBeenCalled(); + }); + + it("never throws when capture itself fails", () => { + h.state.throwOnCapture = true; + expect(() => recordMcpToolCall({ POSTHOG_API_KEY: "phc_test" }, EVENT)).not.toThrow(); + expect(h.captureSpy).toHaveBeenCalledTimes(1); + }); +});