Holo uses PostHog for first-party product analytics across the web app, the gateway, and the worker. This doc is the single source of truth for the event taxonomy and the rules for adding new events.
- Region: EU (
https://eu.i.posthog.com). - Optional. If
NEXT_PUBLIC_POSTHOG_KEY/POSTHOG_API_KEYare unset, every PostHog code path becomes a no-op and the apps boot, build, and run identically to a vanilla self-host. There is no fallback ingestion to a hosted Holo endpoint — analytics simply do not exist. - Session replay is disabled everywhere.
- We never send message content, file content, query strings beyond fixed enums, or any indexed source data to PostHog.
- The browser POSTs to
/ingest/*on Holo's own origin (apps/web/next.config.mjsrewrites), which proxies to PostHog. This survives ad blockers that target*.posthog.com.
-
Create a project on eu.posthog.com.
-
Grab the project's public key (begins with
phc_…) from Project settings → Project API key. -
Set environment variables (see
.env.example):NEXT_PUBLIC_POSTHOG_KEY=phc_... NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com # optional POSTHOG_API_KEY=phc_... # same value is fine -
Restart the web, gateway, and worker processes. Verify in PostHog Live events: a page load should produce a
$pageview, signing in should produce an$identifyplus a workspace group, and an MCP query should producemcp_tool_invokedfrom the gateway.
To turn analytics off in any environment, unset the keys and restart.
- Landing page (unauthenticated): anonymous distinct_id only. No
person profile is ever created
(
person_profiles: 'identified_only'is set in the browser config). - Authenticated dashboard:
posthog.identify(userId, { email, name })plusposthog.group('organization', orgId, { name, slug }). Sign-out callsposthog.reset()so identity does not bleed across users on shared machines. - Gateway / worker: server-side captures use the same
userIdasdistinctIdand the same organization group, so a single funnel can cross the client → API boundary.
Treat the event as part of the feature, not an afterthought.
- Pick the right surface. Fire from the side that knows the action actually happened. UI clicks → web client. OAuth completions, sync lifecycle, MCP/bot invocations → gateway or worker.
- Declare the event in the relevant taxonomy module:
- Web:
apps/web/src/lib/posthog/events.ts— add toWebEventMap, then calltrackEvent('your_event', {...}). - Gateway: capture through the
Posthoginstance threaded into the mount options for your handler (seeapps/gateway/src/mcp/transport.tsfor the pattern). - Worker: use
getWorkerPosthog().capture(...)fromapps/worker/src/posthog.ts.
- Web:
- Property rules. Allowed: identifiers (org/source/provider IDs), counts, durations, fixed-enum statuses, success/failure flags. Not allowed: raw user input, message bodies, file contents, stringified errors with embedded user data, or any field whose cardinality is bounded only by user behavior.
- Update the taxonomy table below in the same PR. The three
events.tsfiles carry a// keep in sync with docs/analytics.mdcomment for exactly this reason.
noun_verb_pasttense, all lowercase, snake_case. Good:connector_connected,sync_job_failed. Bad:ConnectorConnect,SyncFails.- Property keys also snake_case in server events
(
tool_name,duration_ms). On the web client we mirror that for consistency with server events going to the same project. - Group identifier for workspaces is always
organization(PostHog group type).
| Event | Properties | Notes |
|---|---|---|
$pageview |
$current_url, $pathname |
Sent manually on every route change so client-side nav is captured. |
landing_cta_clicked |
location, isAuthed |
Primary CTA on hero / final CTA. |
landing_install_copy |
location |
curl … | bash copy button. The closest thing to a self-host conversion. |
landing_github_clicked |
location |
Outbound to the GitHub repo. |
landing_section_viewed |
section |
Once per page load via IntersectionObserver. |
| Event | Properties | Fired from |
|---|---|---|
workspace_created |
orgId |
workspaces/new/create-workspace-form.tsx |
workspace_switched |
fromOrgId, toOrgId |
org-switcher.tsx |
connector_wizard_opened |
provider |
connector-row.tsx |
connector_connected |
provider |
connector-row.tsx — flip from disconnected → connected while wizard is open |
connector_disconnected |
provider |
connector-manage-sheet.tsx |
chat_message_sent |
messageLength, hasAttachments |
chat-panel.tsx — never includes the message body |
mcp_install_copied |
client ('claude' / 'cursor' / 'other') |
connect-agent-panel/ |
agent_invite_sent |
role |
settings/team/invite-form.tsx |
sample_data_seeded |
— | sample-connector-row.tsx |
| Event | Properties | Notes |
|---|---|---|
mcp_session_started |
agent_identity |
New MCP session in mcp/transport.ts. |
mcp_tool_invoked |
tool_name, ok, latency_ms, error_code?, agent_identity |
Both success and failure paths. |
slack_bot_mentioned |
team_id, channel, custom_app |
slack/events.ts. |
teams_bot_messaged |
tenant_id, conversation_type, custom_app |
teams-bot/messages.ts. |
google_chat_app_messaged |
space_type |
google-chat-app/events.ts. |
| Event | Properties | Notes |
|---|---|---|
sync_job_started |
provider, queue, source_id |
queues/sync-processor-base.ts. |
sync_job_succeeded |
provider, queue, duration_ms, artifact_count, skip_reason? |
Same file. |
sync_job_failed |
provider, queue, duration_ms, error_code, cancelled |
Same file. |
- Session replay (DOM playback).
- The body of chat messages, MCP tool inputs/outputs, or any indexed content.
- File names of uploaded user content.
- Free-text fields submitted by the user (workspace names, invite messages).
- IP addresses beyond the standard PostHog GeoIP enrichment, which can be turned off per-project in PostHog settings.
If you're not sure whether a property is safe, leave it out and discuss in the PR.