# How Self-Host Telemetry Reporting Works in Open SEO: Complete Technical Breakdown

> Discover how Open SEO self-host telemetry works. Learn about the heartbeat mechanism, opt-out flags, and data aggregation for anonymized usage reporting to PostHog.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: internals
- Published: 2026-08-10

---

**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`](https://github.com/every-app/open-seo/blob/main/src/server.ts), every incoming request schedules `maybeSendSelfHostHeartbeat()` via `ctx.waitUntil()`:

```typescript
// 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`](https://github.com/every-app/open-seo/blob/main/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"`.

```typescript
// 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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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:

```typescript
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`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/instrumentation.ts), each tool call triggers:

```typescript
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:

```typescript
// 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:

```typescript
import { incrementSelfHostMcpToolCallCount } from "@/server/lib/self-host-telemetry";

await incrementSelfHostMcpToolCallCount();

```

## Key Implementation Files

| File | Responsibility |
|------|--------------|
| [`src/server/lib/self-host-telemetry.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/self-host-telemetry.ts) | Core heartbeat orchestration, PostHog client, database operations |
| [`src/shared/selfhost-checks.ts`](https://github.com/every-app/open-seo/blob/main/src/shared/selfhost-checks.ts) | `isHostedServerAuthMode()` and environment variable parsing |
| [`src/db/telemetry.schema.ts`](https://github.com/every-app/open-seo/blob/main/src/db/telemetry.schema.ts) | D1/SQLite `telemetry_state` table definition |
| [`src/db/pg/telemetry.schema.ts`](https://github.com/every-app/open-seo/blob/main/src/db/pg/telemetry.schema.ts) | Postgres `telemetry_state` table definition |
| [`src/server.ts`](https://github.com/every-app/open-seo/blob/main/src/server.ts) | Request-level heartbeat scheduling |
| [`src/server/mcp/instrumentation.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/instrumentation.ts) | MCP tool call counter increments |
| [`src/server/lib/setup-status.ts`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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.