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
|
# 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
|
# 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.
|
# 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:
|
open-seo:
|
||||||
image: ${OPEN_SEO_IMAGE:-ghcr.io/every-app/open-seo:latest}
|
image: ${OPEN_SEO_IMAGE:-ghcr.io/every-app/open-seo:latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Anonymous usage heartbeat controls: docs/SELF_HOSTING_DOCKER.md#telemetry
|
||||||
environment:
|
environment:
|
||||||
# Required for local Docker self-hosting: exposes Compose env vars to cloudflare:workers bindings.
|
# Required for local Docker self-hosting: exposes Compose env vars to cloudflare:workers bindings.
|
||||||
- CLOUDFLARE_INCLUDE_PROCESS_ENV=true
|
- CLOUDFLARE_INCLUDE_PROCESS_ENV=true
|
||||||
- PORT=${PORT:-3001}
|
- PORT=${PORT:-3001}
|
||||||
- ALLOWED_HOST=${ALLOWED_HOST:-}
|
- ALLOWED_HOST=${ALLOWED_HOST:-}
|
||||||
- AUTH_MODE=local_noauth
|
- AUTH_MODE=local_noauth
|
||||||
|
- OPENSEO_TELEMETRY_DISABLED=${OPENSEO_TELEMETRY_DISABLED:-}
|
||||||
|
- DO_NOT_TRACK=${DO_NOT_TRACK:-}
|
||||||
- DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY}
|
- DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY}
|
||||||
# Optional: Google Search Console. See
|
# Optional: Google Search Console. See
|
||||||
# docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md
|
# 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
|
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.
|
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
|
## 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.
|
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`.
|
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
|
## Pin to a specific image tag
|
||||||
|
|
||||||
Set `OPEN_SEO_IMAGE` in `.env` and restart:
|
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,
|
"when": 1784070000325,
|
||||||
"tag": "0010_overrated_amazoness",
|
"tag": "0010_overrated_amazoness",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1784069998475,
|
||||||
"tag": "0033_first_rick_jones",
|
"tag": "0033_first_rick_jones",
|
||||||
"breakpoints": true
|
"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 "../billing.schema";
|
||||||
export * from "../gsc.schema";
|
export * from "../gsc.schema";
|
||||||
export * from "../reddit-attribution.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 "./billing.schema";
|
||||||
export * from "./gsc.schema";
|
export * from "./gsc.schema";
|
||||||
export * from "./reddit-attribution.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 sqliteBilling from "./billing.schema";
|
||||||
import * as sqliteGsc from "./gsc.schema";
|
import * as sqliteGsc from "./gsc.schema";
|
||||||
import * as sqliteReddit from "./reddit-attribution.schema";
|
import * as sqliteReddit from "./reddit-attribution.schema";
|
||||||
|
import * as sqliteTelemetry from "./telemetry.schema";
|
||||||
import * as pgApp from "./pg/app.schema";
|
import * as pgApp from "./pg/app.schema";
|
||||||
import * as pgSam from "./pg/sam.schema";
|
import * as pgSam from "./pg/sam.schema";
|
||||||
import * as pgAuth from "./pg/better-auth-schema";
|
import * as pgAuth from "./pg/better-auth-schema";
|
||||||
import * as pgBilling from "./pg/billing.schema";
|
import * as pgBilling from "./pg/billing.schema";
|
||||||
import * as pgGsc from "./pg/gsc.schema";
|
import * as pgGsc from "./pg/gsc.schema";
|
||||||
import * as pgReddit from "./pg/reddit-attribution.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
|
// Guards the ONE structural artifact `db:generate` does not regenerate: the
|
||||||
// hand-written Postgres schema. The provider-aware `db`/`@/db/schema` barrel
|
// hand-written Postgres schema. The provider-aware `db`/`@/db/schema` barrel
|
||||||
@ -137,8 +139,16 @@ const sqliteAppTables = tablesFrom(
|
|||||||
sqliteBilling,
|
sqliteBilling,
|
||||||
sqliteGsc,
|
sqliteGsc,
|
||||||
sqliteReddit,
|
sqliteReddit,
|
||||||
|
sqliteTelemetry,
|
||||||
|
);
|
||||||
|
const pgAppTables = tablesFrom(
|
||||||
|
pgApp,
|
||||||
|
pgSam,
|
||||||
|
pgBilling,
|
||||||
|
pgGsc,
|
||||||
|
pgReddit,
|
||||||
|
pgTelemetry,
|
||||||
);
|
);
|
||||||
const pgAppTables = tablesFrom(pgApp, pgSam, pgBilling, pgGsc, pgReddit);
|
|
||||||
const sqliteAuthTables = tablesFrom(sqliteAuth);
|
const sqliteAuthTables = tablesFrom(sqliteAuth);
|
||||||
const pgAuthTables = tablesFrom(pgAuth);
|
const pgAuthTables = tablesFrom(pgAuth);
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import * as sqliteAuth from "./better-auth-schema";
|
|||||||
import * as sqliteBilling from "./billing.schema";
|
import * as sqliteBilling from "./billing.schema";
|
||||||
import * as sqliteGsc from "./gsc.schema";
|
import * as sqliteGsc from "./gsc.schema";
|
||||||
import * as sqliteReddit from "./reddit-attribution.schema";
|
import * as sqliteReddit from "./reddit-attribution.schema";
|
||||||
|
import * as sqliteTelemetry from "./telemetry.schema";
|
||||||
import * as pgApp from "./pg/app.schema";
|
import * as pgApp from "./pg/app.schema";
|
||||||
import * as pgAudit from "./pg/audit.schema";
|
import * as pgAudit from "./pg/audit.schema";
|
||||||
import * as pgSam from "./pg/sam.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 pgBilling from "./pg/billing.schema";
|
||||||
import * as pgGsc from "./pg/gsc.schema";
|
import * as pgGsc from "./pg/gsc.schema";
|
||||||
import * as pgReddit from "./pg/reddit-attribution.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
|
// Canonical schema barrel. Repositories import their tables from here and the
|
||||||
// provider-aware `db` from "@/db", so each repository is written ONCE for both
|
// provider-aware `db` from "@/db", so each repository is written ONCE for both
|
||||||
@ -30,7 +32,8 @@ type AppSchema = typeof sqliteApp &
|
|||||||
typeof sqliteAuth &
|
typeof sqliteAuth &
|
||||||
typeof sqliteBilling &
|
typeof sqliteBilling &
|
||||||
typeof sqliteGsc &
|
typeof sqliteGsc &
|
||||||
typeof sqliteReddit;
|
typeof sqliteReddit &
|
||||||
|
typeof sqliteTelemetry;
|
||||||
|
|
||||||
const runtimeSchema =
|
const runtimeSchema =
|
||||||
getDatabaseProvider() === "postgres"
|
getDatabaseProvider() === "postgres"
|
||||||
@ -42,6 +45,7 @@ const runtimeSchema =
|
|||||||
...pgBilling,
|
...pgBilling,
|
||||||
...pgGsc,
|
...pgGsc,
|
||||||
...pgReddit,
|
...pgReddit,
|
||||||
|
...pgTelemetry,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
...sqliteApp,
|
...sqliteApp,
|
||||||
@ -51,6 +55,7 @@ const runtimeSchema =
|
|||||||
...sqliteBilling,
|
...sqliteBilling,
|
||||||
...sqliteGsc,
|
...sqliteGsc,
|
||||||
...sqliteReddit,
|
...sqliteReddit,
|
||||||
|
...sqliteTelemetry,
|
||||||
};
|
};
|
||||||
|
|
||||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- guarded by schema-parity.test.ts
|
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- guarded by schema-parity.test.ts
|
||||||
@ -84,4 +89,5 @@ export const {
|
|||||||
billingCustomerStatus,
|
billingCustomerStatus,
|
||||||
gscConnections,
|
gscConnections,
|
||||||
redditAttributions,
|
redditAttributions,
|
||||||
|
telemetryState,
|
||||||
} = schema;
|
} = 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,
|
AUTUMN_WEBHOOK_PATH,
|
||||||
handleAutumnWebhookRequest,
|
handleAutumnWebhookRequest,
|
||||||
} from "@/server/billing/autumn-webhook";
|
} from "@/server/billing/autumn-webhook";
|
||||||
|
import { maybeSendSelfHostHeartbeat } from "@/server/lib/self-host-telemetry";
|
||||||
|
|
||||||
const appFetch = createStartHandler(defaultStreamHandler);
|
const appFetch = createStartHandler(defaultStreamHandler);
|
||||||
const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch);
|
const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch);
|
||||||
@ -137,6 +138,8 @@ function handleFetch(
|
|||||||
env: Env,
|
env: Env,
|
||||||
ctx: ExecutionContext,
|
ctx: ExecutionContext,
|
||||||
): Response | Promise<Response> {
|
): Response | Promise<Response> {
|
||||||
|
ctx.waitUntil(maybeSendSelfHostHeartbeat());
|
||||||
|
|
||||||
const authMode = getAuthMode(env.AUTH_MODE);
|
const authMode = getAuthMode(env.AUTH_MODE);
|
||||||
const publicRequest = requestWithPublicOrigin(request);
|
const publicRequest = requestWithPublicOrigin(request);
|
||||||
const pathname = new URL(publicRequest.url).pathname;
|
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(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
captureServerError: vi.fn(),
|
captureServerError: vi.fn(),
|
||||||
captureServerEvent: vi.fn(),
|
captureServerEvent: vi.fn(),
|
||||||
|
incrementSelfHostMcpToolCallCount: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// waitUntil runs the capture promise inline so assertions see the call.
|
// waitUntil runs the capture promise inline so assertions see the call.
|
||||||
@ -23,6 +24,10 @@ vi.mock("@/server/lib/posthog", () => ({
|
|||||||
captureServerEvent: mocks.captureServerEvent,
|
captureServerEvent: mocks.captureServerEvent,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/lib/self-host-telemetry", () => ({
|
||||||
|
incrementSelfHostMcpToolCallCount: mocks.incrementSelfHostMcpToolCallCount,
|
||||||
|
}));
|
||||||
|
|
||||||
const toolExtra: ToolExtra = {
|
const toolExtra: ToolExtra = {
|
||||||
signal: new AbortController().signal,
|
signal: new AbortController().signal,
|
||||||
requestId: 1,
|
requestId: 1,
|
||||||
@ -51,6 +56,7 @@ describe("instrumentMcpToolHandler", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.captureServerError.mockReset();
|
mocks.captureServerError.mockReset();
|
||||||
mocks.captureServerEvent.mockReset();
|
mocks.captureServerEvent.mockReset();
|
||||||
|
mocks.incrementSelfHostMcpToolCallCount.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes a valid result through without reporting", async () => {
|
it("passes a valid result through without reporting", async () => {
|
||||||
@ -113,6 +119,7 @@ describe("instrumentMcpToolHandler", () => {
|
|||||||
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
|
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
|
||||||
|
|
||||||
expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1);
|
expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.incrementSelfHostMcpToolCallCount).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
|
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
|
||||||
distinctId: "user-1",
|
distinctId: "user-1",
|
||||||
event: "mcp:tool_call",
|
event: "mcp:tool_call",
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { asAppError } from "@/server/lib/errors";
|
|||||||
import { captureServerError, captureServerEvent } from "@/server/lib/posthog";
|
import { captureServerError, captureServerEvent } from "@/server/lib/posthog";
|
||||||
import { shouldCaptureAppErrorCode } from "@/shared/error-codes";
|
import { shouldCaptureAppErrorCode } from "@/shared/error-codes";
|
||||||
import { getAuth, type ToolExtra } from "@/server/mcp/context";
|
import { getAuth, type ToolExtra } from "@/server/mcp/context";
|
||||||
|
import { incrementSelfHostMcpToolCallCount } from "@/server/lib/self-host-telemetry";
|
||||||
|
|
||||||
type ToolHandler<TArgs> = (
|
type ToolHandler<TArgs> = (
|
||||||
args: TArgs,
|
args: TArgs,
|
||||||
@ -29,6 +30,8 @@ function captureMcpToolCall(
|
|||||||
extra: ToolExtra,
|
extra: ToolExtra,
|
||||||
outcome: { success: boolean; errorCode?: string },
|
outcome: { success: boolean; errorCode?: string },
|
||||||
) {
|
) {
|
||||||
|
waitUntil(incrementSelfHostMcpToolCallCount());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = getAuth(extra);
|
const auth = getAuth(extra);
|
||||||
waitUntil(
|
waitUntil(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user