feat: tweak telemetry (telemachus!) heartbeat (#397)

This commit is contained in:
Ben Senescu 2026-07-18 22:22:01 -04:00 committed by GitHub
parent eda5e1e354
commit b981e83bf8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 7250 additions and 21 deletions

View File

@ -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", "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}"] CMD ["sh", "-c", "echo 'OpenSEO sends an anonymous 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

@ -130,7 +130,7 @@ Access. OpenSEO will use a shared workspace for everyone allowed by the policy.
## Telemetry ## 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. OpenSEO collects anonymized telemetry for core usage events: heartbeats with aggregate counts (installs, users, projects, feature usage) tied to a random install ID — every 5 minutes during the first two hours after install, then at most once daily. 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. 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.

View File

@ -40,7 +40,7 @@ You can also persist it in `.env`.
## Telemetry ## 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. OpenSEO collects anonymized telemetry for core usage events: heartbeats with aggregate counts (installs, users, projects, feature usage) tied to a random install ID — every 5 minutes during the first two hours after install, then at most once daily. 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`. 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`.

View File

@ -0,0 +1 @@
ALTER TABLE "telemetry_state" ADD COLUMN "installed_at" timestamp with time zone;

File diff suppressed because it is too large Load Diff

View File

@ -92,6 +92,13 @@
"when": 1784423181488, "when": 1784423181488,
"tag": "0012_dashboard", "tag": "0012_dashboard",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1784426969390,
"tag": "0013_sleepy_black_tarantula",
"breakpoints": true
} }
] ]
} }

View File

@ -0,0 +1 @@
ALTER TABLE `telemetry_state` ADD `installed_at` integer;

File diff suppressed because it is too large Load Diff

View File

@ -253,6 +253,13 @@
"when": 1784423179842, "when": 1784423179842,
"tag": "0035_dashboard", "tag": "0035_dashboard",
"breakpoints": true "breakpoints": true
},
{
"idx": 36,
"version": "6",
"when": 1784426967421,
"tag": "0036_curvy_silk_fever",
"breakpoints": true
} }
] ]
} }

View File

@ -3,6 +3,10 @@ import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const telemetryState = pgTable("telemetry_state", { export const telemetryState = pgTable("telemetry_state", {
id: integer("id").primaryKey().default(1), id: integer("id").primaryKey().default(1),
installId: text("install_id").notNull(), installId: text("install_id").notNull(),
installedAt: timestamp("installed_at", {
mode: "date",
withTimezone: true,
}),
lastHeartbeatAt: timestamp("last_heartbeat_at", { lastHeartbeatAt: timestamp("last_heartbeat_at", {
mode: "date", mode: "date",
withTimezone: true, withTimezone: true,

View File

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

View File

@ -1,11 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SelfHostTelemetryDependencies } from "./self-host-telemetry"; import type { SelfHostTelemetryDependencies } from "./self-host-telemetry";
import {
getCheckIntervalMs,
getHeartbeatIntervalMs,
} from "./self-host-telemetry";
vi.mock("cloudflare:workers", () => ({ env: {} })); vi.mock("cloudflare:workers", () => ({ env: {} }));
vi.mock("@/db", () => ({ db: {} })); vi.mock("@/db", () => ({ db: {} }));
type StoredState = { type StoredState = {
installId: string; installId: string;
installedAt: Date | null;
lastHeartbeatAt: Date | null; lastHeartbeatAt: Date | null;
lastVersion: string | null; lastVersion: string | null;
mcpToolCallCount: number; mcpToolCallCount: number;
@ -29,6 +34,7 @@ function createHarness(
) { ) {
const state: StoredState = { const state: StoredState = {
installId: "install-1", installId: "install-1",
installedAt: new Date(NOW.getTime() - 3 * 60 * 60 * 1000),
lastHeartbeatAt: null, lastHeartbeatAt: null,
lastVersion: null, lastVersion: null,
mcpToolCallCount: 0, mcpToolCallCount: 0,
@ -79,6 +85,39 @@ async function runHeartbeat(harness: ReturnType<typeof createHarness>) {
}); });
} }
describe("getHeartbeatIntervalMs", () => {
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
it("uses the 5-minute onboarding cadence during the first two hours", () => {
expect(getHeartbeatIntervalMs(0)).toBe(5 * MINUTE);
expect(getHeartbeatIntervalMs(2 * HOUR - 1)).toBe(5 * MINUTE);
});
it("uses the daily cadence from two hours onward", () => {
expect(getHeartbeatIntervalMs(2 * HOUR)).toBe(24 * HOUR);
expect(getHeartbeatIntervalMs(Number.POSITIVE_INFINITY)).toBe(24 * HOUR);
});
});
describe("getCheckIntervalMs", () => {
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
it("checks immediately when the install age is unknown", () => {
expect(getCheckIntervalMs(null)).toBe(0);
});
it("polls every minute during onboarding so 5-minute beats don't alias", () => {
expect(getCheckIntervalMs(0)).toBe(MINUTE);
expect(getCheckIntervalMs(2 * HOUR - 1)).toBe(MINUTE);
});
it("polls every 15 minutes once onboarding is over", () => {
expect(getCheckIntervalMs(2 * HOUR)).toBe(15 * MINUTE);
});
});
describe("maybeSendSelfHostHeartbeat", () => { describe("maybeSendSelfHostHeartbeat", () => {
beforeEach(() => { beforeEach(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
@ -171,6 +210,28 @@ describe("maybeSendSelfHostHeartbeat", () => {
expect(harness.markHeartbeatSent).toHaveBeenCalledTimes(1); expect(harness.markHeartbeatSent).toHaveBeenCalledTimes(1);
}); });
it("reports minutesSinceInstall from the stored install time", async () => {
const harness = createHarness({
installedAt: new Date(NOW.getTime() - 25 * 60 * 1000),
});
await runHeartbeat(harness);
expect(harness.sendHeartbeat.mock.calls[0]?.[1]).toMatchObject({
minutesSinceInstall: 25,
});
});
it("omits minutesSinceInstall when the install time is unknown", async () => {
const harness = createHarness({ installedAt: null });
await runHeartbeat(harness);
expect(harness.sendHeartbeat.mock.calls[0]?.[1]).not.toHaveProperty(
"minutesSinceInstall",
);
});
it("includes prevVersion only when the version changes", async () => { it("includes prevVersion only when the version changes", async () => {
const changed = createHarness( const changed = createHarness(
{ {

View File

@ -23,12 +23,35 @@ const SELF_HOST_POSTHOG_KEY =
"phc_xaXj4vE4LikxfvR7q6EHemAYNBSZW4hQkqor7fpf8aGT"; "phc_xaXj4vE4LikxfvR7q6EHemAYNBSZW4hQkqor7fpf8aGT";
const SELF_HOST_POSTHOG_HOST = "https://us.i.posthog.com"; const SELF_HOST_POSTHOG_HOST = "https://us.i.posthog.com";
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000; const DAILY_HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
const CHECK_INTERVAL_MS = 15 * 60 * 1000; // During the first two hours after install, heartbeat every 5 minutes so the
// day-0 snapshots show how far onboarding got before an install went quiet.
const ONBOARDING_WINDOW_MS = 2 * 60 * 60 * 1000;
const ONBOARDING_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
// In-memory DB-check throttle. During onboarding this must divide the
// 5-minute heartbeat interval cleanly — a coarser value (e.g. 4 minutes)
// aliases against it and stretches the effective cadence to 8+ minutes.
// An unknown age (fresh isolate) checks immediately to populate the cache.
const ONBOARDING_CHECK_INTERVAL_MS = 60 * 1000;
const STEADY_CHECK_INTERVAL_MS = 15 * 60 * 1000;
const TELEMETRY_STATE_ID = 1; const TELEMETRY_STATE_ID = 1;
export function getHeartbeatIntervalMs(installAgeMs: number) {
return installAgeMs < ONBOARDING_WINDOW_MS
? ONBOARDING_HEARTBEAT_INTERVAL_MS
: DAILY_HEARTBEAT_INTERVAL_MS;
}
export function getCheckIntervalMs(installAgeMs: number | null) {
if (installAgeMs === null) return 0;
return installAgeMs < ONBOARDING_WINDOW_MS
? ONBOARDING_CHECK_INTERVAL_MS
: STEADY_CHECK_INTERVAL_MS;
}
type ClaimedHeartbeat = { type ClaimedHeartbeat = {
installId: string; installId: string;
installedAt: Date | null;
lastHeartbeatAt: Date | null; lastHeartbeatAt: Date | null;
lastVersion: string | null; lastVersion: string | null;
mcpToolCallCount: number; mcpToolCallCount: number;
@ -50,6 +73,7 @@ type HeartbeatProperties = HeartbeatCounts & {
version: string; version: string;
prevVersion?: string; prevVersion?: string;
firstRun: boolean; firstRun: boolean;
minutesSinceInstall?: number;
mcpToolCalls: number; mcpToolCalls: number;
$process_person_profile: false; $process_person_profile: false;
}; };
@ -78,6 +102,9 @@ type SelfHostTelemetryOptions = {
}; };
let lastCheckedAt: number | null = null; let lastCheckedAt: number | null = null;
// Populated by claimHeartbeat so the memory throttle can pick the right
// check interval without a DB read. Epoch 0 marks "old install, age unknown".
let cachedInstalledAt: Date | null = null;
// Only production builds report: this excludes `vite dev`, vitest, and // Only production builds report: this excludes `vite dev`, vitest, and
// preview deployments (`vite build --mode preview`), whose per-PR databases // preview deployments (`vite build --mode preview`), whose per-PR databases
@ -94,14 +121,11 @@ async function telemetryIsDisabled() {
} }
async function claimHeartbeat(now: Date): Promise<ClaimedHeartbeat | null> { async function claimHeartbeat(now: Date): Promise<ClaimedHeartbeat | null> {
await db const selectState = () =>
.insert(telemetryState) db
.values({ id: TELEMETRY_STATE_ID, installId: crypto.randomUUID() })
.onConflictDoNothing();
const [previous] = await db
.select({ .select({
installId: telemetryState.installId, installId: telemetryState.installId,
installedAt: telemetryState.installedAt,
lastHeartbeatAt: telemetryState.lastHeartbeatAt, lastHeartbeatAt: telemetryState.lastHeartbeatAt,
lastVersion: telemetryState.lastVersion, lastVersion: telemetryState.lastVersion,
mcpToolCallCount: telemetryState.mcpToolCallCount, mcpToolCallCount: telemetryState.mcpToolCallCount,
@ -110,9 +134,29 @@ async function claimHeartbeat(now: Date): Promise<ClaimedHeartbeat | null> {
.where(eq(telemetryState.id, TELEMETRY_STATE_ID)) .where(eq(telemetryState.id, TELEMETRY_STATE_ID))
.limit(1); .limit(1);
let [previous] = await selectState();
if (!previous) {
await db
.insert(telemetryState)
.values({
id: TELEMETRY_STATE_ID,
installId: crypto.randomUUID(),
installedAt: now,
})
.onConflictDoNothing();
[previous] = await selectState();
}
if (!previous) return null; if (!previous) return null;
const cutoff = new Date(now.getTime() - HEARTBEAT_INTERVAL_MS); cachedInstalledAt = previous.installedAt ?? new Date(0);
// Rows created before the installedAt column existed (or with a wiped
// value) fall back to the daily cadence.
const installAgeMs = previous.installedAt
? now.getTime() - previous.installedAt.getTime()
: Number.POSITIVE_INFINITY;
const cutoff = new Date(now.getTime() - getHeartbeatIntervalMs(installAgeMs));
const [claimed] = await db const [claimed] = await db
.update(telemetryState) .update(telemetryState)
.set({ lastHeartbeatAt: now }) .set({ lastHeartbeatAt: now })
@ -229,10 +273,13 @@ export async function maybeSendSelfHostHeartbeat(
if (dependencies.isNonProductionBuild()) return; if (dependencies.isNonProductionBuild()) return;
const now = dependencies.now(); const now = dependencies.now();
const checkIntervalMs = getCheckIntervalMs(
cachedInstalledAt ? now.getTime() - cachedInstalledAt.getTime() : null,
);
if ( if (
!options.skipMemoryThrottle && !options.skipMemoryThrottle &&
lastCheckedAt !== null && lastCheckedAt !== null &&
now.getTime() - lastCheckedAt < CHECK_INTERVAL_MS now.getTime() - lastCheckedAt < checkIntervalMs
) { ) {
return; return;
} }
@ -248,12 +295,17 @@ export async function maybeSendSelfHostHeartbeat(
? state.lastVersion ? state.lastVersion
: undefined; : undefined;
const minutesSinceInstall = state.installedAt
? Math.round((now.getTime() - state.installedAt.getTime()) / 60_000)
: undefined;
await dependencies.sendHeartbeat(state.installId, { await dependencies.sendHeartbeat(state.installId, {
deployTarget: authMode === "local_noauth" ? "docker" : "cloudflare", deployTarget: authMode === "local_noauth" ? "docker" : "cloudflare",
dbBackend: dependencies.getDbBackend(), dbBackend: dependencies.getDbBackend(),
version: dependencies.version, version: dependencies.version,
...(prevVersion ? { prevVersion } : {}), ...(prevVersion ? { prevVersion } : {}),
firstRun: state.lastHeartbeatAt === null, firstRun: state.lastHeartbeatAt === null,
...(minutesSinceInstall === undefined ? {} : { minutesSinceInstall }),
...counts, ...counts,
mcpToolCalls: state.mcpToolCallCount, mcpToolCalls: state.mcpToolCallCount,
$process_person_profile: false, $process_person_profile: false,
@ -277,6 +329,7 @@ export async function incrementSelfHostMcpToolCallCount() {
.values({ .values({
id: TELEMETRY_STATE_ID, id: TELEMETRY_STATE_ID,
installId: crypto.randomUUID(), installId: crypto.randomUUID(),
installedAt: new Date(),
mcpToolCallCount: 1, mcpToolCallCount: 1,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({