feat: anonymous self-host telemetry heartbeat (#395)
This commit is contained in:
parent
8460df1f29
commit
1398605ddc
@ -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}"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
7
drizzle-pg/0011_friendly_morlun.sql
Normal file
7
drizzle-pg/0011_friendly_morlun.sql
Normal 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
|
||||
);
|
||||
3482
drizzle-pg/meta/0011_snapshot.json
Normal file
3482
drizzle-pg/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -78,6 +78,13 @@
|
||||
"when": 1784070000325,
|
||||
"tag": "0010_overrated_amazoness",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1784410131656,
|
||||
"tag": "0011_friendly_morlun",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
7
drizzle/0034_wonderful_skrulls.sql
Normal file
7
drizzle/0034_wonderful_skrulls.sql
Normal 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
|
||||
);
|
||||
3149
drizzle/meta/0034_snapshot.json
Normal file
3149
drizzle/meta/0034_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
12
src/db/pg/telemetry.schema.ts
Normal file
12
src/db/pg/telemetry.schema.ts
Normal 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),
|
||||
});
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
9
src/db/telemetry.schema.ts
Normal file
9
src/db/telemetry.schema.ts
Normal 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),
|
||||
});
|
||||
@ -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;
|
||||
|
||||
201
src/server/lib/self-host-telemetry.test.ts
Normal file
201
src/server/lib/self-host-telemetry.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
291
src/server/lib/self-host-telemetry.ts
Normal file
291
src/server/lib/self-host-telemetry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user