Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
82 changes: 82 additions & 0 deletions src/mcp/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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<Env, "POSTHOG_API_KEY" | "POSTHOG_HOST">;

/** 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;
}
127 changes: 127 additions & 0 deletions test/unit/mcp-telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown> };
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);
});
});
Loading