How Self-Host Telemetry Reporting Works in Open SEO: Complete Technical Breakdown
Open SEO's self-host telemetry system sends anonymized usage data from self-hosted instances to the hosted PostHog service via a production-only heartbeat mechanism that checks opt-out flags, claims rate-limited slots, aggregates counts, and resets MCP counters after each successful transmission.
Open SEO includes a built-in telemetry system that helps the development team understand how self-hosted deployments are used. This article explains exactly how self-host telemetry reporting to the hosted service works, walking through the complete implementation from request handling to data transmission.
When Telemetry Runs: The Request-Based Trigger
The telemetry heartbeat integrates directly into the server's request lifecycle. In src/server.ts, every incoming request schedules maybeSendSelfHostHeartbeat() via ctx.waitUntil():
// From src/server.ts
ctx.waitUntil(maybeSendSelfHostHeartbeat());
This fire-and-forget pattern ensures telemetry never blocks the response to users. The actual execution happens asynchronously after the request completes.
Multi-Layer Opt-Out and Environment Checks
Before any data leaves the server, three independent guards must pass:
1. Hosted Server Mode Detection
telemetryIsDisabled() first checks isHostedServerAuthMode() in src/shared/selfhost-checks.ts. Hosted instances (managed by Open SEO) never report telemetry—there's no need since the infrastructure is already known.
2. Environment Variable Opt-Out
The same file interprets OPENSEO_TELEMETRY_DISABLED and DO_NOT_TRACK. Critical detail: Any value disables telemetry except explicit "negative" strings. These four values explicitly enable telemetry: "off", "0", "false", "no".
// Example: disable telemetry via environment variable
process.env.OPENSEO_TELEMETRY_DISABLED = "true"; // disables
process.env.DO_NOT_TRACK = "1"; // disables
process.env.OPENSEO_TELEMETRY_DISABLED = "off"; // explicitly enables
3. Production Build Verification
isNonProductionBuild() returns true for:
- Development servers
- Vitest test runs
- Preview builds
If any of these conditions match, the heartbeat aborts immediately. This prevents polluting production analytics with development noise.
The Heartbeat Slot Claiming Mechanism
Telemetry uses a rate-limiting database row to ensure controlled reporting frequency. The claimHeartbeat(now) function in src/server/lib/self-host-telemetry.ts implements this:
- Reads or creates the
telemetry_staterow (SQLite via D1 or Postgres) - Calculates install age and time since last heartbeat
- Applies interval rules:
- First 2 hours: Maximum every 5 minutes (onboarding window)
- After 2 hours: Maximum once per day
- Atomically updates
lastHeartbeatAtwith a conditional write - Returns previous state only if the update succeeded (claim acquired)
This compare-and-swap pattern prevents race conditions when multiple requests trigger simultaneously.
Data Aggregation: Counts and Setup Issues
Once a slot is claimed, the system collects two data categories:
Instance Scale Metrics
collectCounts() executes SELECT count(*) queries against seven core tables:
| Table | Purpose |
|---|---|
user |
Total registered users |
projects |
SEO projects created |
audits |
Site audits run |
rankTrackingKeywords |
Keywords under rank tracking |
savedKeywords |
Saved keyword research |
gscConnections |
Google Search Console integrations |
samSessions |
SAM AI chat sessions |
Health Diagnostics
collectSetupIssues() calls getSetupIssueSummary() from src/server/lib/setup-status.ts to capture unhealthy configuration states like "dataforseo:error".
PostHog Transmission: Event Structure
The sendHeartbeat(installId, properties) function builds a PostHog client with hard-coded credentials:
const client = new PostHog(
'phc_xaXj4vE4LikxfvR7q6EHemAYNBSZW4hQkqor7fpf8aGT', // self-host project key
{ host: 'https://us.i.posthog.com' }
);
The captured event self_host.heartbeat includes:
Deployment Context
deployment_target:"cloudflare"or"docker"database_backend:"d1"or"postgres"version: Application version string
Feature Flags
is_first_run: Boolean for fresh installationshas_gsc_connection: Whether Google Search Console is configuredhas_used_sam_chat: Whether SAM AI has been usedhas_used_mcp_tools: Whether MCP tooling was invoked
Quantitative Data
- All seven count metrics
mcp_tool_call_countsince last heartbeat
Diagnostic Data
- Array of setup issue strings
MCP Tool Call Tracking and Counter Reset
MCP (Model Context Protocol) tool usage is tracked incrementally for accurate rate-limited reporting. In src/server/mcp/instrumentation.ts, each tool call triggers:
await incrementSelfHostMcpToolCallCount();
This increments mcpToolCallCount in the telemetry_state row. After a successful heartbeat, markHeartbeatSent() atomically subtracts the reported count from the stored value, ensuring the next transmission only contains new calls.
Manual Telemetry Operations
For debugging or cron-based scheduling, you can invoke the heartbeat directly:
// Manually trigger telemetry (respects all opt-out checks)
import { maybeSendSelfHostHeartbeat } from "@/server/lib/self-host-telemetry";
await maybeSendSelfHostHeartbeat();
To record MCP tool calls from custom instrumentation:
import { incrementSelfHostMcpToolCallCount } from "@/server/lib/self-host-telemetry";
await incrementSelfHostMcpToolCallCount();
Key Implementation Files
| File | Responsibility |
|---|---|
src/server/lib/self-host-telemetry.ts |
Core heartbeat orchestration, PostHog client, database operations |
src/shared/selfhost-checks.ts |
isHostedServerAuthMode() and environment variable parsing |
src/db/telemetry.schema.ts |
D1/SQLite telemetry_state table definition |
src/db/pg/telemetry.schema.ts |
Postgres telemetry_state table definition |
src/server.ts |
Request-level heartbeat scheduling |
src/server/mcp/instrumentation.ts |
MCP tool call counter increments |
src/server/lib/setup-status.ts |
Health check aggregation for getSetupIssueSummary |
Summary
- Opt-out by default: Telemetry runs unless
OPENSEO_TELEMETRY_DISABLEDorDO_NOT_TRACKis set to any non-"off" value - Production-only: Development, test, and preview builds never transmit data
- Rate-limited: 5-minute intervals during onboarding (2 hours), then daily
- Anonymous: Fixed PostHog project key, no secrets transmitted, install ID is non-identifying
- Comprehensive: Captures scale metrics, feature usage, and health diagnostics
- MCP-aware: Tracks tool call volume with atomic counter reset after each heartbeat
Frequently Asked Questions
What data does Open SEO's self-host telemetry collect?
Open SEO collects anonymized counts of users, projects, audits, tracked keywords, saved keywords, GSC connections, and SAM sessions. It also records boolean flags for feature usage (GSC connections, SAM chat, MCP tools), deployment target, database backend, version, and any setup health issues. No content, URLs, or personally identifying information is transmitted.
How do I completely disable telemetry on my self-hosted instance?
Set either OPENSEO_TELEMETRY_DISABLED=true or DO_NOT_TRACK=1 in your environment. Any value works except "off", "0", "false", or "no"—these four strings explicitly enable telemetry. The check happens in src/shared/selfhost-checks.ts via telemetryIsDisabled().
Why does telemetry only run in production builds?
The isNonProductionBuild() guard prevents development servers, Vitest tests, and preview builds from polluting analytics. This ensures metrics reflect actual production deployments rather than development activity. The function detects build context through environment indicators specific to the Open SEO build pipeline.
How does the rate limiting prevent excessive heartbeat calls?
The claimHeartbeat() function uses database-assisted rate limiting with conditional atomic updates. It compares the current time against lastHeartbeatAt and only permits new heartbeats after 5 minutes (during the 2-hour onboarding window) or 24 hours thereafter. Failed claims abort silently without error.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →