feat: tweak telemetry (telemachus!) heartbeat (#397)
This commit is contained in:
parent
eda5e1e354
commit
b981e83bf8
@ -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}"]
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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`.
|
||||||
|
|
||||||
|
|||||||
1
drizzle-pg/0013_sleepy_black_tarantula.sql
Normal file
1
drizzle-pg/0013_sleepy_black_tarantula.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "telemetry_state" ADD COLUMN "installed_at" timestamp with time zone;
|
||||||
3714
drizzle-pg/meta/0013_snapshot.json
Normal file
3714
drizzle-pg/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
1
drizzle/0036_curvy_silk_fever.sql
Normal file
1
drizzle/0036_curvy_silk_fever.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `telemetry_state` ADD `installed_at` integer;
|
||||||
3380
drizzle/meta/0036_snapshot.json
Normal file
3380
drizzle/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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,25 +121,42 @@ 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() })
|
.select({
|
||||||
.onConflictDoNothing();
|
installId: telemetryState.installId,
|
||||||
|
installedAt: telemetryState.installedAt,
|
||||||
|
lastHeartbeatAt: telemetryState.lastHeartbeatAt,
|
||||||
|
lastVersion: telemetryState.lastVersion,
|
||||||
|
mcpToolCallCount: telemetryState.mcpToolCallCount,
|
||||||
|
})
|
||||||
|
.from(telemetryState)
|
||||||
|
.where(eq(telemetryState.id, TELEMETRY_STATE_ID))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
const [previous] = await db
|
let [previous] = await selectState();
|
||||||
.select({
|
if (!previous) {
|
||||||
installId: telemetryState.installId,
|
await db
|
||||||
lastHeartbeatAt: telemetryState.lastHeartbeatAt,
|
.insert(telemetryState)
|
||||||
lastVersion: telemetryState.lastVersion,
|
.values({
|
||||||
mcpToolCallCount: telemetryState.mcpToolCallCount,
|
id: TELEMETRY_STATE_ID,
|
||||||
})
|
installId: crypto.randomUUID(),
|
||||||
.from(telemetryState)
|
installedAt: now,
|
||||||
.where(eq(telemetryState.id, TELEMETRY_STATE_ID))
|
})
|
||||||
.limit(1);
|
.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({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user