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:

  1. Reads or creates the telemetry_state row (SQLite via D1 or Postgres)
  2. Calculates install age and time since last heartbeat
  3. Applies interval rules:
    • First 2 hours: Maximum every 5 minutes (onboarding window)
    • After 2 hours: Maximum once per day
  4. Atomically updates lastHeartbeatAt with a conditional write
  5. 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 installations
  • has_gsc_connection: Whether Google Search Console is configured
  • has_used_sam_chat: Whether SAM AI has been used
  • has_used_mcp_tools: Whether MCP tooling was invoked

Quantitative Data

  • All seven count metrics
  • mcp_tool_call_count since 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_DISABLED or DO_NOT_TRACK is 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →