feat: anonymous self-host telemetry heartbeat (#395)

This commit is contained in:
Ben Senescu 2026-07-18 20:07:42 -04:00 committed by GitHub
parent 8460df1f29
commit 1398605ddc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 7221 additions and 3 deletions

View File

@ -20,4 +20,4 @@ EXPOSE 3001
# self-hoster only chooses AUTH_MODE at runtime via Compose. Building here lets
# that runtime value bake into the bundle. NODE_OPTIONS raises the V8 heap ceiling
# so the SSR build of ~7400 modules doesn't OOM under Node's ~2GB default.
CMD ["sh", "-c", "pnpm run db:migrate:local && NODE_OPTIONS=--max-old-space-size=4096 pnpm run build && pnpm exec vite preview --host 0.0.0.0 --port ${PORT:-3001}"]
CMD ["sh", "-c", "echo 'OpenSEO sends an anonymous daily usage heartbeat (counts only). Disable: OPENSEO_TELEMETRY_DISABLED=1. Details: docs/SELF_HOSTING_DOCKER.md#telemetry' && pnpm run db:migrate:local && NODE_OPTIONS=--max-old-space-size=4096 pnpm run build && pnpm exec vite preview --host 0.0.0.0 --port ${PORT:-3001}"]

View File

@ -2,12 +2,15 @@ services:
open-seo:
image: ${OPEN_SEO_IMAGE:-ghcr.io/every-app/open-seo:latest}
restart: unless-stopped
# Anonymous usage heartbeat controls: docs/SELF_HOSTING_DOCKER.md#telemetry
environment:
# Required for local Docker self-hosting: exposes Compose env vars to cloudflare:workers bindings.
- CLOUDFLARE_INCLUDE_PROCESS_ENV=true
- PORT=${PORT:-3001}
- ALLOWED_HOST=${ALLOWED_HOST:-}
- AUTH_MODE=local_noauth
- OPENSEO_TELEMETRY_DISABLED=${OPENSEO_TELEMETRY_DISABLED:-}
- DO_NOT_TRACK=${DO_NOT_TRACK:-}
- DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY}
# Optional: Google Search Console. See
# docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md

View File

@ -128,6 +128,12 @@ Screenshots from the setup flow:
After saving, teammates can open your OpenSEO URL and sign in through Cloudflare
Access. OpenSEO will use a shared workspace for everyone allowed by the policy.
## Telemetry
OpenSEO collects anonymized telemetry for core usage events: a single daily heartbeat with aggregate counts (installs, users, projects, feature usage) tied to a random install ID. No URLs, keywords, prompts, emails, or IP-derived location are collected, and idle installs send nothing.
To disable it, add `OPENSEO_TELEMETRY_DISABLED=1` (or `DO_NOT_TRACK=1`) as a Worker variable under **Settings → Variables & Secrets**, then redeploy or restart the Worker.
## Manual deploy with Wrangler
Use this flow if the Deploy to Cloudflare button fails with `Cannot provision a KV Namespace with the title "open-seo" because it already exists`. The reliable path is to create Cloudflare resources yourself, put their IDs into `wrangler.jsonc`, then deploy with Wrangler.

View File

@ -38,6 +38,12 @@ ALLOWED_HOST=yourdomain.com docker compose up -d
You can also persist it in `.env`.
## Telemetry
OpenSEO collects anonymized telemetry for core usage events: a single daily heartbeat with aggregate counts (installs, users, projects, feature usage) tied to a random install ID. No URLs, keywords, prompts, emails, or IP-derived location are collected, and idle installs send nothing.
To disable it, set `OPENSEO_TELEMETRY_DISABLED=1` (or `DO_NOT_TRACK=1`) in `.env`, then run `docker compose up -d --force-recreate open-seo`.
## Pin to a specific image tag
Set `OPEN_SEO_IMAGE` in `.env` and restart:

View File

@ -0,0 +1,7 @@
CREATE TABLE "telemetry_state" (
"id" integer PRIMARY KEY DEFAULT 1 NOT NULL,
"install_id" text NOT NULL,
"last_heartbeat_at" timestamp with time zone,
"last_version" text,
"mcp_tool_call_count" integer DEFAULT 0 NOT NULL
);

File diff suppressed because it is too large Load Diff

View File

@ -78,6 +78,13 @@
"when": 1784070000325,
"tag": "0010_overrated_amazoness",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1784410131656,
"tag": "0011_friendly_morlun",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,7 @@
CREATE TABLE `telemetry_state` (
`id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
`install_id` text NOT NULL,
`last_heartbeat_at` integer,
`last_version` text,
`mcp_tool_call_count` integer DEFAULT 0 NOT NULL
);

File diff suppressed because it is too large Load Diff

View File

@ -239,6 +239,13 @@
"when": 1784069998475,
"tag": "0033_first_rick_jones",
"breakpoints": true
},
{
"idx": 34,
"version": "6",
"when": 1784410130115,
"tag": "0034_wonderful_skrulls",
"breakpoints": true
}
]
}

View File

@ -8,3 +8,4 @@ export * from "../better-auth-schema";
export * from "../billing.schema";
export * from "../gsc.schema";
export * from "../reddit-attribution.schema";
export * from "../telemetry.schema";

View File

@ -5,3 +5,4 @@ export * from "./better-auth-schema";
export * from "./billing.schema";
export * from "./gsc.schema";
export * from "./reddit-attribution.schema";
export * from "./telemetry.schema";

View File

@ -0,0 +1,12 @@
import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const telemetryState = pgTable("telemetry_state", {
id: integer("id").primaryKey().default(1),
installId: text("install_id").notNull(),
lastHeartbeatAt: timestamp("last_heartbeat_at", {
mode: "date",
withTimezone: true,
}),
lastVersion: text("last_version"),
mcpToolCallCount: integer("mcp_tool_call_count").notNull().default(0),
});

View File

@ -10,12 +10,14 @@ import * as sqliteAuth from "./better-auth-schema";
import * as sqliteBilling from "./billing.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteReddit from "./reddit-attribution.schema";
import * as sqliteTelemetry from "./telemetry.schema";
import * as pgApp from "./pg/app.schema";
import * as pgSam from "./pg/sam.schema";
import * as pgAuth from "./pg/better-auth-schema";
import * as pgBilling from "./pg/billing.schema";
import * as pgGsc from "./pg/gsc.schema";
import * as pgReddit from "./pg/reddit-attribution.schema";
import * as pgTelemetry from "./pg/telemetry.schema";
// Guards the ONE structural artifact `db:generate` does not regenerate: the
// hand-written Postgres schema. The provider-aware `db`/`@/db/schema` barrel
@ -137,8 +139,16 @@ const sqliteAppTables = tablesFrom(
sqliteBilling,
sqliteGsc,
sqliteReddit,
sqliteTelemetry,
);
const pgAppTables = tablesFrom(
pgApp,
pgSam,
pgBilling,
pgGsc,
pgReddit,
pgTelemetry,
);
const pgAppTables = tablesFrom(pgApp, pgSam, pgBilling, pgGsc, pgReddit);
const sqliteAuthTables = tablesFrom(sqliteAuth);
const pgAuthTables = tablesFrom(pgAuth);

View File

@ -6,6 +6,7 @@ import * as sqliteAuth from "./better-auth-schema";
import * as sqliteBilling from "./billing.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteReddit from "./reddit-attribution.schema";
import * as sqliteTelemetry from "./telemetry.schema";
import * as pgApp from "./pg/app.schema";
import * as pgAudit from "./pg/audit.schema";
import * as pgSam from "./pg/sam.schema";
@ -13,6 +14,7 @@ import * as pgAuth from "./pg/better-auth-schema";
import * as pgBilling from "./pg/billing.schema";
import * as pgGsc from "./pg/gsc.schema";
import * as pgReddit from "./pg/reddit-attribution.schema";
import * as pgTelemetry from "./pg/telemetry.schema";
// Canonical schema barrel. Repositories import their tables from here and the
// provider-aware `db` from "@/db", so each repository is written ONCE for both
@ -30,7 +32,8 @@ type AppSchema = typeof sqliteApp &
typeof sqliteAuth &
typeof sqliteBilling &
typeof sqliteGsc &
typeof sqliteReddit;
typeof sqliteReddit &
typeof sqliteTelemetry;
const runtimeSchema =
getDatabaseProvider() === "postgres"
@ -42,6 +45,7 @@ const runtimeSchema =
...pgBilling,
...pgGsc,
...pgReddit,
...pgTelemetry,
}
: {
...sqliteApp,
@ -51,6 +55,7 @@ const runtimeSchema =
...sqliteBilling,
...sqliteGsc,
...sqliteReddit,
...sqliteTelemetry,
};
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- guarded by schema-parity.test.ts
@ -84,4 +89,5 @@ export const {
billingCustomerStatus,
gscConnections,
redditAttributions,
telemetryState,
} = schema;

View File

@ -0,0 +1,9 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const telemetryState = sqliteTable("telemetry_state", {
id: integer("id").primaryKey().default(1),
installId: text("install_id").notNull(),
lastHeartbeatAt: integer("last_heartbeat_at", { mode: "timestamp_ms" }),
lastVersion: text("last_version"),
mcpToolCallCount: integer("mcp_tool_call_count").notNull().default(0),
});

View File

@ -22,6 +22,7 @@ import {
AUTUMN_WEBHOOK_PATH,
handleAutumnWebhookRequest,
} from "@/server/billing/autumn-webhook";
import { maybeSendSelfHostHeartbeat } from "@/server/lib/self-host-telemetry";
const appFetch = createStartHandler(defaultStreamHandler);
const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch);
@ -137,6 +138,8 @@ function handleFetch(
env: Env,
ctx: ExecutionContext,
): Response | Promise<Response> {
ctx.waitUntil(maybeSendSelfHostHeartbeat());
const authMode = getAuthMode(env.AUTH_MODE);
const publicRequest = requestWithPublicOrigin(request);
const pathname = new URL(publicRequest.url).pathname;

View File

@ -0,0 +1,201 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SelfHostTelemetryDependencies } from "./self-host-telemetry";
vi.mock("cloudflare:workers", () => ({ env: {} }));
vi.mock("@/db", () => ({ db: {} }));
type StoredState = {
installId: string;
lastHeartbeatAt: Date | null;
lastVersion: string | null;
mcpToolCallCount: number;
};
const NOW = new Date("2026-07-18T12:00:00.000Z");
const DAY_MS = 24 * 60 * 60 * 1000;
const emptyCounts = {
userCount: 0,
projectCount: 0,
siteAuditCount: 0,
rankTrackingKeywordCount: 0,
savedKeywordCount: 0,
gscConnected: false,
samChatUsed: false,
};
function createHarness(
initialState?: Partial<StoredState>,
appVersion = "1.0.0",
) {
const state: StoredState = {
installId: "install-1",
lastHeartbeatAt: null,
lastVersion: null,
mcpToolCallCount: 0,
...initialState,
};
const sendHeartbeat = vi.fn<SelfHostTelemetryDependencies["sendHeartbeat"]>();
const claimHeartbeat = vi.fn(async (now: Date) => {
if (
state.lastHeartbeatAt &&
now.getTime() - state.lastHeartbeatAt.getTime() <= DAY_MS
) {
return null;
}
const previous = { ...state };
state.lastHeartbeatAt = now;
return previous;
});
const markHeartbeatSent = vi.fn(async (currentVersion: string) => {
state.lastVersion = currentVersion;
state.mcpToolCallCount = 0;
});
const dependencies: Partial<SelfHostTelemetryDependencies> = {
now: () => NOW,
isNonProductionBuild: () => false,
claimHeartbeat,
collectCounts: async () => emptyCounts,
sendHeartbeat,
markHeartbeatSent,
getDbBackend: () => "d1",
version: appVersion,
};
return {
state,
dependencies,
sendHeartbeat,
claimHeartbeat,
markHeartbeatSent,
};
}
async function runHeartbeat(harness: ReturnType<typeof createHarness>) {
const { maybeSendSelfHostHeartbeat } = await import("./self-host-telemetry");
await maybeSendSelfHostHeartbeat({
dependencies: harness.dependencies,
skipMemoryThrottle: true,
});
}
describe("maybeSendSelfHostHeartbeat", () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("AUTH_MODE", "cloudflare_access");
vi.stubEnv("OPENSEO_TELEMETRY_DISABLED", "");
vi.stubEnv("DO_NOT_TRACK", "");
});
it("does not send in hosted mode", async () => {
vi.stubEnv("AUTH_MODE", "hosted");
const harness = createHarness();
await runHeartbeat(harness);
expect(harness.claimHeartbeat).not.toHaveBeenCalled();
expect(harness.sendHeartbeat).not.toHaveBeenCalled();
});
it("does not send when OPENSEO_TELEMETRY_DISABLED is set", async () => {
vi.stubEnv("OPENSEO_TELEMETRY_DISABLED", "1");
const harness = createHarness();
await runHeartbeat(harness);
expect(harness.claimHeartbeat).not.toHaveBeenCalled();
expect(harness.sendHeartbeat).not.toHaveBeenCalled();
});
it("does not send when DO_NOT_TRACK is set", async () => {
vi.stubEnv("DO_NOT_TRACK", "1");
const harness = createHarness();
await runHeartbeat(harness);
expect(harness.claimHeartbeat).not.toHaveBeenCalled();
expect(harness.sendHeartbeat).not.toHaveBeenCalled();
});
it("does not send from non-production builds (dev, test, preview)", async () => {
const harness = createHarness();
// Omit the isNonProductionBuild override: the production gate reads
// import.meta.env.MODE, which is "test" under vitest and must block.
delete harness.dependencies.isNonProductionBuild;
await runHeartbeat(harness);
expect(harness.claimHeartbeat).not.toHaveBeenCalled();
expect(harness.sendHeartbeat).not.toHaveBeenCalled();
});
it("allows only one concurrent caller to claim a heartbeat", async () => {
const harness = createHarness();
await Promise.all([runHeartbeat(harness), runHeartbeat(harness)]);
expect(harness.claimHeartbeat).toHaveBeenCalledTimes(2);
expect(harness.sendHeartbeat).toHaveBeenCalledTimes(1);
});
it("does not send within 24 hours", async () => {
const harness = createHarness({
lastHeartbeatAt: new Date(NOW.getTime() - DAY_MS + 1),
});
await runHeartbeat(harness);
expect(harness.sendHeartbeat).not.toHaveBeenCalled();
});
it("sends after 24 hours", async () => {
const harness = createHarness({
lastHeartbeatAt: new Date(NOW.getTime() - DAY_MS - 1),
});
await runHeartbeat(harness);
expect(harness.sendHeartbeat).toHaveBeenCalledTimes(1);
});
it("marks the first heartbeat and resets the reported MCP counter", async () => {
const harness = createHarness({ mcpToolCallCount: 7 });
await runHeartbeat(harness);
expect(harness.sendHeartbeat.mock.calls[0]?.[1]).toMatchObject({
firstRun: true,
mcpToolCalls: 7,
});
expect(harness.state.mcpToolCallCount).toBe(0);
expect(harness.markHeartbeatSent).toHaveBeenCalledTimes(1);
});
it("includes prevVersion only when the version changes", async () => {
const changed = createHarness(
{
lastHeartbeatAt: new Date(NOW.getTime() - DAY_MS - 1),
lastVersion: "0.9.0",
},
"1.0.0",
);
await runHeartbeat(changed);
expect(changed.sendHeartbeat.mock.calls[0]?.[1]).toMatchObject({
version: "1.0.0",
prevVersion: "0.9.0",
firstRun: false,
});
const unchanged = createHarness(
{
lastHeartbeatAt: new Date(NOW.getTime() - DAY_MS - 1),
lastVersion: "1.0.0",
},
"1.0.0",
);
await runHeartbeat(unchanged);
expect(unchanged.sendHeartbeat.mock.calls[0]?.[1]).not.toHaveProperty(
"prevVersion",
);
});
});

View File

@ -0,0 +1,291 @@
import { PostHog } from "posthog-node";
import { and, count, eq, isNull, lt, or, sql } from "drizzle-orm";
import { version } from "../../../package.json";
import { db } from "@/db";
import { getDatabaseProvider } from "@/db/provider";
import {
audits,
gscConnections,
projects,
rankTrackingKeywords,
samSessions,
savedKeywords,
telemetryState,
user,
} from "@/db/schema";
import { getAuthMode } from "@/lib/auth-mode";
import {
getOptionalEnvValue,
isHostedServerAuthMode,
} from "@/server/lib/runtime-env";
const SELF_HOST_POSTHOG_KEY =
"phc_xaXj4vE4LikxfvR7q6EHemAYNBSZW4hQkqor7fpf8aGT";
const SELF_HOST_POSTHOG_HOST = "https://us.i.posthog.com";
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
const CHECK_INTERVAL_MS = 15 * 60 * 1000;
const TELEMETRY_STATE_ID = 1;
type ClaimedHeartbeat = {
installId: string;
lastHeartbeatAt: Date | null;
lastVersion: string | null;
mcpToolCallCount: number;
};
type HeartbeatCounts = {
userCount: number;
projectCount: number;
siteAuditCount: number;
rankTrackingKeywordCount: number;
savedKeywordCount: number;
gscConnected: boolean;
samChatUsed: boolean;
};
type HeartbeatProperties = HeartbeatCounts & {
deployTarget: "cloudflare" | "docker";
dbBackend: "d1" | "postgres";
version: string;
prevVersion?: string;
firstRun: boolean;
mcpToolCalls: number;
$process_person_profile: false;
};
export type SelfHostTelemetryDependencies = {
now: () => Date;
isNonProductionBuild: () => boolean;
claimHeartbeat: (now: Date) => Promise<ClaimedHeartbeat | null>;
collectCounts: () => Promise<HeartbeatCounts>;
sendHeartbeat: (
installId: string,
properties: HeartbeatProperties,
) => Promise<void>;
markHeartbeatSent: (
currentVersion: string,
reportedMcpToolCalls: number,
) => Promise<void>;
getDbBackend: () => "d1" | "postgres";
version: string;
};
type SelfHostTelemetryOptions = {
dependencies?: Partial<SelfHostTelemetryDependencies>;
/** Lets unit tests exercise the database CAS as if calls came from separate isolates. */
skipMemoryThrottle?: boolean;
};
let lastCheckedAt: number | null = null;
// Only production builds report: this excludes `vite dev`, vitest, and
// preview deployments (`vite build --mode preview`), whose per-PR databases
// would otherwise each register as a fresh self-host install.
function isNonProductionBuild() {
return import.meta.env.MODE !== "production";
}
async function telemetryIsDisabled() {
if (await isHostedServerAuthMode()) return true;
if (await getOptionalEnvValue("OPENSEO_TELEMETRY_DISABLED")) return true;
if (await getOptionalEnvValue("DO_NOT_TRACK")) return true;
return false;
}
async function claimHeartbeat(now: Date): Promise<ClaimedHeartbeat | null> {
await db
.insert(telemetryState)
.values({ id: TELEMETRY_STATE_ID, installId: crypto.randomUUID() })
.onConflictDoNothing();
const [previous] = await db
.select({
installId: telemetryState.installId,
lastHeartbeatAt: telemetryState.lastHeartbeatAt,
lastVersion: telemetryState.lastVersion,
mcpToolCallCount: telemetryState.mcpToolCallCount,
})
.from(telemetryState)
.where(eq(telemetryState.id, TELEMETRY_STATE_ID))
.limit(1);
if (!previous) return null;
const cutoff = new Date(now.getTime() - HEARTBEAT_INTERVAL_MS);
const [claimed] = await db
.update(telemetryState)
.set({ lastHeartbeatAt: now })
.where(
and(
eq(telemetryState.id, TELEMETRY_STATE_ID),
or(
isNull(telemetryState.lastHeartbeatAt),
lt(telemetryState.lastHeartbeatAt, cutoff),
),
),
)
.returning({ id: telemetryState.id });
return claimed ? previous : null;
}
// No session-based activity counts: self-host auth is delegated per request
// (Cloudflare Access / local_noauth) and never creates better-auth session
// rows, so those queries would always report zero. Install-level activity
// falls out of heartbeat cadence instead — a heartbeat means an active day.
async function collectCounts(): Promise<HeartbeatCounts> {
const [
[userRow],
[projectRow],
[auditRow],
[rankKeywordRow],
[savedKeywordRow],
[gscRow],
[samRow],
] = await Promise.all([
db.select({ value: count() }).from(user),
db.select({ value: count() }).from(projects),
db.select({ value: count() }).from(audits),
db.select({ value: count() }).from(rankTrackingKeywords),
db.select({ value: count() }).from(savedKeywords),
db.select({ value: count() }).from(gscConnections),
db.select({ value: count() }).from(samSessions),
]);
return {
userCount: userRow?.value ?? 0,
projectCount: projectRow?.value ?? 0,
siteAuditCount: auditRow?.value ?? 0,
rankTrackingKeywordCount: rankKeywordRow?.value ?? 0,
savedKeywordCount: savedKeywordRow?.value ?? 0,
gscConnected: (gscRow?.value ?? 0) > 0,
samChatUsed: (samRow?.value ?? 0) > 0,
};
}
async function sendHeartbeat(
installId: string,
properties: HeartbeatProperties,
) {
const client = new PostHog(SELF_HOST_POSTHOG_KEY, {
host: SELF_HOST_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0,
disableGeoip: true,
});
try {
client.capture({
distinctId: installId,
event: "self_host.heartbeat",
properties,
});
} finally {
await client.shutdown();
}
}
async function markHeartbeatSent(
currentVersion: string,
reportedMcpToolCalls: number,
) {
await db
.update(telemetryState)
.set({
lastVersion: currentVersion,
// Preserve tool calls that race with the heartbeat send while clearing
// exactly the count included in this event.
mcpToolCallCount: sql`case
when ${telemetryState.mcpToolCallCount} >= ${reportedMcpToolCalls}
then ${telemetryState.mcpToolCallCount} - ${reportedMcpToolCalls}
else 0
end`,
})
.where(eq(telemetryState.id, TELEMETRY_STATE_ID));
}
const productionDependencies: SelfHostTelemetryDependencies = {
now: () => new Date(),
isNonProductionBuild,
claimHeartbeat,
collectCounts,
sendHeartbeat,
markHeartbeatSent,
getDbBackend: getDatabaseProvider,
version,
};
export async function maybeSendSelfHostHeartbeat(
options: SelfHostTelemetryOptions = {},
) {
try {
if (await telemetryIsDisabled()) return;
const dependencies = {
...productionDependencies,
...options.dependencies,
};
if (dependencies.isNonProductionBuild()) return;
const now = dependencies.now();
if (
!options.skipMemoryThrottle &&
lastCheckedAt !== null &&
now.getTime() - lastCheckedAt < CHECK_INTERVAL_MS
) {
return;
}
lastCheckedAt = now.getTime();
const state = await dependencies.claimHeartbeat(now);
if (!state) return;
const authMode = getAuthMode(await getOptionalEnvValue("AUTH_MODE"));
const counts = await dependencies.collectCounts();
const prevVersion =
state.lastVersion && state.lastVersion !== dependencies.version
? state.lastVersion
: undefined;
await dependencies.sendHeartbeat(state.installId, {
deployTarget: authMode === "local_noauth" ? "docker" : "cloudflare",
dbBackend: dependencies.getDbBackend(),
version: dependencies.version,
...(prevVersion ? { prevVersion } : {}),
firstRun: state.lastHeartbeatAt === null,
...counts,
mcpToolCalls: state.mcpToolCallCount,
$process_person_profile: false,
});
await dependencies.markHeartbeatSent(
dependencies.version,
state.mcpToolCallCount,
);
} catch (error) {
console.debug("self-host telemetry heartbeat failed", error);
}
}
export async function incrementSelfHostMcpToolCallCount() {
try {
if (await telemetryIsDisabled()) return;
if (isNonProductionBuild()) return;
await db
.insert(telemetryState)
.values({
id: TELEMETRY_STATE_ID,
installId: crypto.randomUUID(),
mcpToolCallCount: 1,
})
.onConflictDoUpdate({
target: telemetryState.id,
set: {
mcpToolCallCount: sql`${telemetryState.mcpToolCallCount} + 1`,
},
});
} catch (error) {
console.debug("self-host telemetry MCP counter failed", error);
}
}

View File

@ -11,6 +11,7 @@ import { AppError } from "@/server/lib/errors";
const mocks = vi.hoisted(() => ({
captureServerError: vi.fn(),
captureServerEvent: vi.fn(),
incrementSelfHostMcpToolCallCount: vi.fn(),
}));
// waitUntil runs the capture promise inline so assertions see the call.
@ -23,6 +24,10 @@ vi.mock("@/server/lib/posthog", () => ({
captureServerEvent: mocks.captureServerEvent,
}));
vi.mock("@/server/lib/self-host-telemetry", () => ({
incrementSelfHostMcpToolCallCount: mocks.incrementSelfHostMcpToolCallCount,
}));
const toolExtra: ToolExtra = {
signal: new AbortController().signal,
requestId: 1,
@ -51,6 +56,7 @@ describe("instrumentMcpToolHandler", () => {
beforeEach(() => {
mocks.captureServerError.mockReset();
mocks.captureServerEvent.mockReset();
mocks.incrementSelfHostMcpToolCallCount.mockReset();
});
it("passes a valid result through without reporting", async () => {
@ -113,6 +119,7 @@ describe("instrumentMcpToolHandler", () => {
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1);
expect(mocks.incrementSelfHostMcpToolCallCount).toHaveBeenCalledTimes(1);
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
distinctId: "user-1",
event: "mcp:tool_call",

View File

@ -11,6 +11,7 @@ import { asAppError } from "@/server/lib/errors";
import { captureServerError, captureServerEvent } from "@/server/lib/posthog";
import { shouldCaptureAppErrorCode } from "@/shared/error-codes";
import { getAuth, type ToolExtra } from "@/server/mcp/context";
import { incrementSelfHostMcpToolCallCount } from "@/server/lib/self-host-telemetry";
type ToolHandler<TArgs> = (
args: TArgs,
@ -29,6 +30,8 @@ function captureMcpToolCall(
extra: ToolExtra,
outcome: { success: boolean; errorCode?: string },
) {
waitUntil(incrementSelfHostMcpToolCallCount());
try {
const auth = getAuth(extra);
waitUntil(