# How the Open SEO Self-Host Telemetry Heartbeat Works: Function and Data Reported

> Discover how the Open SEO self-host telemetry heartbeat sends usage metrics to PostHog. Learn about reported data like entity counts and setup health, and understand its throttling and opt-out features.

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

---

**The Open SEO self-host telemetry heartbeat periodically reports install-level usage metrics to PostHog via the `maybeSendSelfHostHeartbeat` 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), capturing entity counts, setup health, and environment metadata while respecting strict throttling and opt-out controls.**

The self-host telemetry system in Open SEO provides production installs with a privacy-conscious mechanism to share anonymous usage statistics. According to the source code in the `every-app/open-seo` repository, this system operates through a carefully throttled heartbeat routine that aggregates database metrics and configuration states without exposing personally identifiable information.

## Core Implementation in [`self-host-telemetry.ts`](https://github.com/every-app/open-seo/blob/main/self-host-telemetry.ts)

The heartbeat implementation centers on a single orchestration function that manages timing, data collection, and transmission.

### The Entry Point and Guards

The **`maybeSendSelfHostHeartbeat`** function serves as the primary entry point for all telemetry activities. Before executing any logic, it performs mandatory environment checks defined at lines 15-34. The function immediately aborts for non-production builds—including development servers, Vitest test environments, and preview builds—to prevent test data from polluting production metrics.

Following the build check, the function evaluates three separate opt-out conditions: hosted-server mode, the `OPENSEO_TELEMETRY_DISABLED` environment variable, and the `DO_NOT_TRACK` standard. If any condition is true, the heartbeat skips execution entirely.

### Memory Throttling and Timing Intervals

To minimize database load, the heartbeat implements a **memory throttle** that regulates how often the `telemetryState` table is queried. As implemented in `getCheckIntervalMs` at lines 47-52, the check frequency varies by install age:

- **First two hours**: During the `ONBOARDING_WINDOW_MS` period, checks run every minute (`ONBOARDING_CHECK_INTERVAL_MS`)
- **After onboarding**: Checks throttle to every 15 minutes (`STEADY_CHECK_INTERVAL_MS`)

### The Claim Mechanism and Slot Reservation

The **`claimHeartbeat`** function manages the actual heartbeat scheduling by reading the singleton row (ID = 1) from the `telemetryState` table. If no row exists, the function initializes one with a fresh `installId`. This mechanism ensures only one heartbeat fires per install per time window.

At lines 70-88, the function calculates the install's age and determines a cutoff timestamp based on either `ONBOARDING_HEARTBEAT_INTERVAL_MS` or `DAILY_HEARTBEAT_INTERVAL_MS`, depending on the lifecycle phase. If the existing `lastHeartbeatAt` timestamp exceeds this cutoff, the function updates the row with the current timestamp and returns a claim object; otherwise, it returns `null` to suppress redundant transmission.

## Data Collected by the Heartbeat

When a heartbeat slot is successfully claimed, the system aggregates a comprehensive payload defined by the `HeartbeatProperties` type at lines 72-84.

### Usage Metrics and Entity Counts

The **`collectCounts`** function (lines 95-122) queries the database to enumerate:
- **`userCount`**: Registered users in the system
- **`projectCount`**: Total projects created
- **`siteAuditCount`**: Site audit records
- **`rankTrackingKeywordCount`**: Keywords under rank tracking
- **`savedKeywordCount`**: Saved keyword tags
- **`gscConnected`**: Boolean indicating Google Search Console integration status
- **`samChatUsed`**: Boolean tracking whether the SAM chat feature has been accessed
- **`mcpToolCalls`**: Counter of MCP tool invocations since the last heartbeat

### Setup Health and Environment Details

The payload includes a **`setupIssues`** array populated by `getSetupIssueSummary` from [`src/server/lib/setup-status.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/setup-status.ts) (lines 81-89). This function runs the same pre-flight checks used during Docker initialization, returning compact error codes (e.g., `"dataforseo:error"`) for any checks not returning `"ok"`.

Environment metadata captures:
- **`deployTarget`**: `"docker"` for local-noauth installs, otherwise `"cloudflare"`
- **`dbBackend`**: `"d1"` or `"postgres"` based on the configured provider
- **`$process_person_profile`**: Always `false` to comply with PostHog's anonymous event schema

### Version Tracking and Install History

The heartbeat records versioning information to track upgrade patterns:
- **`version`**: Current Open SEO version
- **`prevVersion`**: Previous version (if upgraded since last heartbeat)
- **`firstRun`**: Boolean indicating whether this is the install's first heartbeat
- **`minutesSinceInstall`**: Approximate elapsed time since initialization

## Transmitting to PostHog

Once assembled, the payload transmits to PostHog using a dedicated client instance.

### Event Dispatch and Client Configuration

The **`sendHeartbeat`** function (lines 25-42) initializes a PostHog client with the self-host project key `phc_xaXj4vE4LikxfvR7q6EHemAYNBSZW4hQkqor7fpf8aGT` and dispatches an event named **`self_host.heartbeat`** containing the assembled `properties` object.

To ensure immediate transmission without batching delays, the client configures `flushAt: 1` and `flushInterval: 0`, forcing synchronous delivery before the function completes.

### Post-Heartbeat State Management

Following successful transmission, **`markHeartbeatSent`** updates the `telemetryState` table (lines 47-63) to set `lastVersion` to the current version and decrements the MCP tool call counter by the amount reported, resetting the counter for the next interval.

## How to Interact with the Telemetry System

Developers and administrators can manually trigger or influence the telemetry system using the exported utility functions.

To manually trigger a heartbeat in scripts or administrative tools:

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

await maybeSendSelfHostHeartbeat(); // Respects throttling, opt-out, and environment guards

```

To increment the MCP tool call counter when building custom integrations:

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

await incrementSelfHostMcpToolCallCount();

```

## Summary

- The **self-host telemetry heartbeat** runs exclusively in production builds via `maybeSendSelfHostHeartbeat` in [`src/server/lib/self-host-telemetry.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/self-host-telemetry.ts)
- **Throttling logic** adapts check frequency based on install age, running every minute initially then every 15 minutes
- The **claim mechanism** uses a singleton `telemetryState` table row to prevent duplicate transmissions and enforce intervals
- Reported **data includes** entity counts (users, projects, audits), setup health checks, deployment environment, and version history
- Transmission occurs via **PostHog** with immediate flushing, sending to the `self_host.heartbeat` event stream
- **Opt-out controls** respect `OPENSEO_TELEMETRY_DISABLED`, `DO_NOT_TRACK`, and hosted-server mode flags

## Frequently Asked Questions

### How can I disable the self-host telemetry heartbeat?

Set the **`OPENSEO_TELEMETRY_DISABLED`** environment variable to any truthy value, or set **`DO_NOT_TRACK=1`** in your environment. Additionally, running in hosted-server mode automatically disables telemetry. When any of these conditions are met, the `maybeSendSelfHostHeartbeat` function returns early without executing database queries or network calls.

### How often does the heartbeat actually transmit data to PostHog?

During the **first two hours** after installation, the heartbeat transmits every minute if data has changed. After the onboarding window expires, transmission throttles to **once every 15 minutes** during steady-state operation. However, the actual PostHog event only fires if the `claimHeartbeat` function successfully reserves a slot based on these intervals.

### What database tables store the telemetry state?

The system uses the **`telemetryState`** table defined in [`src/db/telemetry.schema.ts`](https://github.com/every-app/open-seo/blob/main/src/db/telemetry.schema.ts) (D1) and [`src/db/pg/telemetry.schema.ts`](https://github.com/every-app/open-seo/blob/main/src/db/pg/telemetry.schema.ts) (Postgres). This table maintains a singleton row (ID = 1) containing the `installId`, `lastHeartbeatAt` timestamp, `lastVersion` string, and MCP tool call counters. Schema definitions are available in the respective database provider directories.

### Does the heartbeat expose any personal user data?

No. According to the `HeartbeatProperties` type definition, the payload contains only **aggregated counts** and **boolean flags**. The system explicitly sets `$process_person_profile: false` for all PostHog events, and no email addresses, names, or content data are transmitted. The `setupIssues` array contains only diagnostic error codes, not configuration values or secrets.