capturePageview now section-normalizes the path (strip query/hash, collapse UUID and issue-key resource segments) and dedupes consecutive same-section views, so navigating between issues/agents/etc. no longer fires a billed PostHog event per resource. The web tracker keys on pathname only (not searchParams), removing ~17% pure query-string-churn pageviews and keeping OAuth code/state out of $current_url. MUL-3081 Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
34 KiB
Product Analytics
This document is the source of truth for the analytics events Multica ships to PostHog. Events feed the acquisition → activation → expansion funnel that drives our weekly Active Workspaces (WAW) north-star metric.
See MUL-1122 for the design context.
PostHog is reserved for user/product-behaviour events. High-volume operational / execution-lifecycle telemetry — runtime lifecycle (
runtime_registered/runtime_ready/runtime_failed/runtime_offline), the agent task lifecycle (agent_task_*), and autopilot run lifecycle (autopilot_run_started/autopilot_run_completed/autopilot_run_failed) — is Prometheus-only and is not shipped to PostHog. Grafana already covers it and the per-event PostHog ingestion cost (these events dominate volume and bill at the identified-event rate) is not justified. The runtime/autopilot events are flagged byanalytics.IsMetricsOnly, whichmetrics.RecordEventconsults to skip the PostHogCapturewhile still incrementing the Prometheus counter; theagent_task_*lifecycle is recorded straight to Prometheus via the typedBusinessMetrics.RecordTask*methods and has noanalytics.Eventat all.
Configuration
All analytics shipping is toggled by environment variables (see .env.example):
| Variable | Meaning | Default |
|---|---|---|
POSTHOG_API_KEY |
PostHog project API key. Empty = no events are shipped. | "" |
POSTHOG_HOST |
PostHog host (US or EU cloud, or self-hosted URL). | https://us.i.posthog.com |
ANALYTICS_ENVIRONMENT |
Optional override for the standard environment event property. Normalized to production, staging, or dev; defaults from APP_ENV. |
APP_ENV / dev |
ANALYTICS_DISABLED |
Set to true/1 to force the no-op client even when POSTHOG_API_KEY is set. |
"" |
Local dev and self-hosted instances run with POSTHOG_API_KEY="", so no
events leave the process unless the operator explicitly opts in.
Self-hosted instances
Self-hosters should never inherit a Multica-issued POSTHOG_API_KEY —
that would route their users' behavior to our analytics project. The
defaults guarantee this:
.env.exampleshipsPOSTHOG_API_KEY=empty. The Docker self-host compose does not set a default either.- With the key unset,
NewFromEnvreturnsNoopClientand logsanalytics: POSTHOG_API_KEY not set, using noop clientat startup — a visible confirmation that nothing is shipped. - Operators who want their own analytics can set
POSTHOG_API_KEYandPOSTHOG_HOSTto point at their own PostHog project (Cloud or self-hosted PostHog). - The frontend receives the key via
/api/config(planned for PR 2), so self-hosts' blank server config also disables frontend event shipping automatically — no separate frontend opt-out plumbing required.
Architecture
handler → analytics.Client.Capture(Event) ← non-blocking, returns immediately
│
▼
bounded queue (1024 events)
│
▼
background worker: batch + POST /batch/
│
▼
PostHog
analytics.Captureis never allowed to block a request handler. A broken backend must not degrade the product — when the queue is full, events are dropped and counted (visible viaslog+ thedroppedcounter on shutdown).- Batches flush either when
BatchSizeis reached or everyFlushEvery(default 10 s), whichever comes first. Close()drains remaining events during graceful shutdown. Called fromserver/cmd/server/main.goviadefer.
Identity model
distinct_id— always the user's UUID for logged-in events. The frontend'sposthog.identify(user.id)merges any prior anonymous events under the same identity, so acquisition attribution (UTM / referrer) stays intact across signup.workspace_id— added to every event as a property when present. v1 uses event property filtering (free tier) rather than PostHog Groups Analytics (paid) to compute workspace-level metrics.- PII — events carry
email_domain(e.g.gmail.com), not the full email. Full email is stored once in person properties via$set_onceso it's available for individual debugging but not broadcast with every event. - Person properties (
$set) — use for mutable cohort signals (role, use_case, team_size, platform_preference) that a user can legitimately change during onboarding.Event.Seton the backend maps to$set; the frontend helper issetPersonProperties()in@multica/core/analytics. Use$set_onceonly for values that must never be overwritten (email, initial attribution, first-completion timestamp).
Taxonomy
Every event is assigned to one dashboard category:
| Category | Events |
|---|---|
core_loop |
workspace_created, agent_created, issue_created, chat_message_sent, issue_executed, autopilot_created, squad_created |
onboarding_support |
onboarding_started, onboarding_questionnaire_submitted, onboarding_completed, onboarding_runtime_path_selected, onboarding_runtime_detected |
acquisition |
signup, download_intent_expressed, download_page_viewed, download_initiated, cloud_waitlist_joined, contact_sales_submitted |
ops_feedback |
feedback_opened, feedback_submitted |
system/noise |
$pageview, $set, $identify, $autocapture, $rageclick |
operational (Prometheus-only — NOT in PostHog) |
runtime_registered, runtime_ready, runtime_failed, runtime_offline, agent_task_queued, agent_task_dispatched, agent_task_started, agent_task_completed, agent_task_failed, agent_task_cancelled, autopilot_run_started, autopilot_run_completed, autopilot_run_failed |
The v0 core dashboard must use only core_loop plus the specific
onboarding_support steps used by the activation funnel. Acquisition,
feedback, and system/noise events stay in separate dashboards. The
operational row is not shipped to PostHog — those signals live in
Grafana via multica_* business counters (see server/internal/metrics).
Standard core properties
Canonical core events should carry these properties whenever the entity exists:
| Property | Type | Notes |
|---|---|---|
environment |
string | production / staging / dev; stamped by backend and frontend analytics clients. |
event_schema_version |
int | Current version: 2. |
user_id |
string UUID | Human user ID when known. Agent/system events may omit it. |
workspace_id |
string UUID | Required for workspace-scoped events. |
agent_id |
string UUID | Required for agent/task events. |
task_id |
string UUID | Required for agent_task_* events. |
issue_id / chat_session_id / autopilot_run_id |
string UUID | Relevant source entity for the task/entry event. |
source |
string | Canonical values: onboarding, manual, chat, autopilot, api. UI surface details use surface or trigger_source. |
runtime_mode |
string | cloud / local when a runtime/agent task is involved. |
provider |
string | claude, codex, cursor, etc. when a runtime/agent task is involved. |
is_demo |
bool | Currently always false; reserved for future demo/test workspace filtering. |
Task terminal events additionally carry duration_ms; failures carry
failure_reason, error_type, and will_retry. Runtime failure events carry
recoverable; runtime ready events carry runtime_id, ready_duration_ms
only when it is actually measured, and daemon_id for local runtimes.
Schema v2 is the first canonical core-metrics schema. It replaces early v1
drafts that mirrored failure_reason into error_type, used recoverable
for task/autopilot failures, and emitted ready_duration_ms: 0 before the
registration path had a measured duration.
Event contract
signup
Fires when a new user is created. Covers both verification-code and Google
OAuth entry points (findOrCreateUser is the single emission site).
| Property | Type | Description |
|---|---|---|
email_domain |
string | Lower-cased domain portion of the user's email. |
signup_source |
string | Opaque attribution bundle from the frontend cookie multica_signup_source (UTM + referrer). Empty when the cookie is absent. |
auth_method |
string | Optional. "google" for Google OAuth signups. Absent for verification-code signups. |
Person properties set with $set_once:
| Property | Type | Description |
|---|---|---|
email |
string | Full email. Never broadcast per-event. |
signup_source |
string | Same as above; kept on the person for later segmentation. |
workspace_created
Fires after a CreateWorkspace transaction commits successfully.
| Property | Type | Description |
|---|---|---|
workspace_id |
string (UUID) | Added globally; present here for clarity. |
Note on "first workspace" segmentation — we deliberately do not stamp
an is_first_workspace boolean at emit time. Computing it correctly would
require an extra column or transaction-scoped logic that still races under
concurrent creates. Instead, PostHog answers the same question exactly by
looking at whether the user has a prior workspace_created event (use a
funnel with "first time user does X" or a cohort on
person_properties.$initial_event). No information is lost.
runtime_registered
Prometheus-only — not shipped to PostHog (see the note at the top of this doc). The
analytics.Eventis still constructed sometrics.IncForEventcan derive the Prometheus counter; the fields below are that event shape, not a PostHog contract. Only the low-cardinality fields (runtime_mode,provider) become Prometheus labels — ids likeruntime_id/daemon_idare not labels.
Fires the first time a (workspace_id, daemon_id, provider) tuple is
upserted. Heartbeats and repeat registrations never re-emit. First-time
detection uses Postgres xmax = 0 on the upsert RETURNING clause — no
extra query, no race.
| Property | Type | Description |
|---|---|---|
runtime_id |
string (UUID) | The newly created agent_runtime row id. |
daemon_id |
string | Local daemon identity when available. |
runtime_mode |
string | Currently local; reserved for cloud runtimes. |
provider |
string | e.g. "codex", "claude". |
runtime_version |
string | Version of the agent runtime binary. |
cli_version |
string | Version of the multica CLI that registered it. |
distinct_id is the authenticated owner's user id when the daemon was
registered via a member's JWT/PAT; daemon-token registrations fall back to
workspace:<workspace_id> so PostHog doesn't bucket unrelated daemons
under a single "anonymous" person.
runtime_ready
Prometheus-only — not shipped to PostHog.
Fires when a runtime is first registered in an online/ready state. This is the
activation-funnel step that should replace treating runtime_registered as
proof of readiness. The backend emits this only on the INSERT path for a new
agent_runtime row; ordinary daemon reconnects update the existing row and do
not emit another runtime_ready. Dashboard funnels should still count
distinct runtime_id.
| Property | Type | Description |
|---|---|---|
runtime_id |
string (UUID) | The agent_runtime row id. |
daemon_id |
string | Local daemon identity when available. |
ready_duration_ms |
int64 | Optional. Time from registration start to ready; omitted until the registration path can measure it. |
runtime_mode |
string | local / cloud. |
provider |
string | Runtime provider. |
runtime_failed
Prometheus-only — not shipped to PostHog.
Fires when runtime setup/registration fails before a ready runtime can be recorded. Today this is scoped to backend registration persistence failures; future setup flows should reuse it for provider detection or daemon boot failures.
| Property | Type | Description |
|---|---|---|
daemon_id |
string | Local daemon identity when available. |
provider |
string | Runtime provider attempted. |
failure_reason |
string | Stable coarse reason. |
error_type |
string | Stable error classifier. |
recoverable |
bool | Whether retrying setup may succeed. |
runtime_offline
Prometheus-only — not shipped to PostHog.
Fires when a runtime is explicitly deregistered or the backend sweeper marks it offline after missed heartbeats. This is not an activation step; it supports local runtime retention and drop-off diagnosis.
issue_created
Fires after an issue row is created, including manual UI/API issue creation,
quick-create issue creation by an agent, and autopilot create_issue runs.
| Property | Type | Description |
|---|---|---|
issue_id |
string (UUID) | Created issue. |
agent_id |
string (UUID) | Agent assignee or creating agent when applicable. |
task_id |
string (UUID) | Present for quick-create issue creation. |
autopilot_run_id |
string (UUID) | Present for autopilot-created issues. |
source |
string | manual, api, or autopilot. |
chat_message_sent
Fires after a user chat message is persisted and the corresponding agent task is queued.
| Property | Type | Description |
|---|---|---|
chat_session_id |
string (UUID) | Chat session. |
task_id |
string (UUID) | Queued agent task. |
agent_id |
string (UUID) | Chat agent. |
source |
string | Always chat. |
agent task lifecycle (Prometheus-only)
Not shipped to PostHog and has no
analytics.Event. The agent task lifecycle is recorded directly to Prometheus by the typedBusinessMetrics.RecordTask*methods inserver/internal/service/task.go. The old PostHog event names (agent_task_queued/dispatched/started/completed/failed/cancelled) and their properties (task_id,agent_id,issue_id,chat_session_id,autopilot_run_id,duration_ms,error_type,will_retry) no longer exist anywhere — those high-cardinality ids were never Prometheus labels and must not be used in dashboards or reconciliation.
The actual metrics (defined in server/internal/metrics/business.go; label
sets in server/internal/metrics/labels.go):
| Metric | Type | Labels |
|---|---|---|
multica_agent_task_enqueued_total |
counter | source, runtime_mode |
multica_agent_task_dispatched_total |
counter | source, runtime_mode |
multica_agent_task_started_total |
counter | source, runtime_mode, provider |
multica_agent_task_terminal_total |
counter | source, runtime_mode, terminal_status |
multica_agent_task_failed_total |
counter | source, runtime_mode, failure_reason |
multica_agent_task_queue_wait_seconds |
histogram | source, runtime_mode |
multica_agent_task_run_seconds |
histogram | source, runtime_mode, terminal_status |
multica_agent_task_total_seconds |
histogram | source, runtime_mode, terminal_status |
terminal_statusis the task's finalagent_task_queue.status—completed/failed/cancelled. There is no separate completed/cancelled metric: all three land onmultica_agent_task_terminal_total{terminal_status=…}. Failures additionally incrementmultica_agent_task_failed_totalcarrying the coarsefailure_reason(agent_task_queue.failure_reason, defaultagent_error).- Task wall-clock lives in the
*_secondshistograms (queue wait / run / total), replacing the oldduration_msevent property. source/runtime_mode/providerare the normalized label values (NormalizeTaskSource/NormalizeRuntimeMode/NormalizeRuntimeProvider).
autopilot_run_started / autopilot_run_completed / autopilot_run_failed
Prometheus-only — not shipped to PostHog. The
analytics.*constructors are retained only sometrics.IncForEventcan derive the Prometheus counter;analytics.IsMetricsOnlykeeps them out of PostHog. Onlycadence,trigger_kind, andterminal_statusbecome Prometheus labels — theautopilot_id/autopilot_run_id/agent_idfields below are event shape, not labels.
Fires from autopilot_run lifecycle changes. source is always
autopilot; the trigger origin is carried in trigger_source (manual,
schedule, webhook, or api).
| Property | Type | Description |
|---|---|---|
autopilot_id |
string (UUID) | Autopilot definition. |
autopilot_run_id |
string (UUID) | Run row. |
agent_id |
string (UUID) | Assigned agent. |
trigger_source |
string | manual, schedule, webhook, or api. |
duration_ms |
int64 | Terminal events only. |
failure_reason |
string | Failed events only. |
error_type |
string | Failed events only; stable coarse classifier such as configuration, issue_terminal, dispatch_error, task_error, or autopilot_error. |
will_retry |
bool | Failed events only; currently false because autopilot retry cadence is owned by triggers/schedules. |
issue_executed
Fires at most once per issue — when the first task on that issue
reaches terminal done state. Backed by an atomic
UPDATE issue SET first_executed_at = now() WHERE id = $1 AND first_executed_at IS NULL RETURNING *;
retries, re-assignments, and comment-triggered follow-up tasks all hit the
WHERE clause and no-op, so the ≥1 / ≥2 / ≥5 / ≥10 funnel buckets count
distinct issues, not tasks.
| Property | Type | Description |
|---|---|---|
issue_id |
string (UUID) | |
task_id |
string (UUID) | Completing task. |
agent_id |
string (UUID) | Completing agent. |
source |
string | manual, chat, or autopilot. |
runtime_mode |
string | local / cloud. |
provider |
string | Runtime provider. |
task_duration_ms |
int64 | Wall-clock time between task.started_at and task.completed_at. Zero when the task was created in a completed state (rare). |
distinct_id prefers the issue's human creator so agent-executed events
flow into the issue-author's person profile (same place signup and
workspace_created land). Agent-created issues prefix with agent: to
keep PostHog from merging the agent into a user record.
Note on workspace-Nth ordinals — we deliberately do not stamp
nth_issue_for_workspace at emit time. Computing it correctly would
require either a serialised transaction or an advisory lock per workspace;
two concurrent first-completions could otherwise both read count=1 and
emit n=1. PostHog answers the same question at query time via
row_number() OVER (PARTITION BY properties.workspace_id ORDER BY timestamp),
and funnel steps of the form "workspace has had ≥2 issue_executed
events" are expressible without the property. No information is lost.
issue_executed is the canonical PostHog core-loop success signal (the
agent_task_* lifecycle that previously served per-task success dashboards is
now Prometheus-only). Per-task completion counts live in Grafana via
BusinessMetrics.RecordTaskTerminal; use issue_executed for the
PostHog-side activation funnel and filter by source as needed.
team_invite_sent
Fires from CreateInvitation after the DB row is written.
| Property | Type | Description |
|---|---|---|
invited_email_domain |
string | Lower-cased domain; full email lives in the invitation row, not the event. |
invite_method |
string | Currently always "email". Future non-email invite flows (share link, SCIM) should pass their own value. |
distinct_id is the inviter's user id.
team_invite_accepted
Fires from AcceptInvitation after both the invitation row is marked
accepted and the member row is inserted in the same transaction.
| Property | Type | Description |
|---|---|---|
days_since_invite |
int64 | Whole days from invitation creation to acceptance. Lets us segment "accepted same day" (warm) from "dug out of email weeks later" (cold). |
distinct_id is the invitee's user id — this is the event that closes the
expansion funnel.
onboarding_started
Fires once when the onboarding shell mounts and the initial workspace list has
resolved. Existing-workspace users carry workspace_id; brand-new users do
not have a workspace yet.
| Property | Type | Description |
|---|---|---|
workspace_id |
string (UUID) | Present only when the user already has a workspace. |
source |
string | Always onboarding. |
onboarding_questionnaire_submitted
Fires on the first PatchOnboarding that transitions the user's questionnaire JSONB from "at least one slot empty" to "all three filled" (team_size, role, use_case). Revisions past that point don't re-emit — the funnel counts users, not edits.
| Property | Type | Description |
|---|---|---|
team_size |
string | solo / team / other. |
role |
string | developer / product_lead / writer / founder / other. |
use_case |
string | coding / planning / writing_research / explore / other. |
team_size_has_other |
bool | true when the user filled the Q1 free-text escape. |
role_has_other |
bool | Ditto Q2. |
use_case_has_other |
bool | Ditto Q3. |
Person properties set with $set (not once — users can go back and
change answers before submitting again):
| Property | Type | Description |
|---|---|---|
team_size |
string | Mirrors the event property for cohort queries. |
role |
string | Same. |
use_case |
string | Same. |
distinct_id is the user's id. No workspace_id — the questionnaire is
per-user, not per-workspace.
agent_created
Fires on every successful POST /api/workspaces/:id/agents. Not
onboarding-specific — the is_first_agent_in_workspace property
isolates the Step 4 signal from later agent additions.
| Property | Type | Description |
|---|---|---|
agent_id |
string (UUID) | |
provider |
string | Runtime provider the agent is bound to (claude, codex, etc). |
runtime_mode |
string | Runtime mode copied from the bound runtime. |
template |
string | Template slug used to seed the agent (coding / planning / writing / assistant). Empty when the caller didn't come from a template picker. |
is_first_agent_in_workspace |
bool | true when the workspace had zero agents before this insert. |
distinct_id is the authenticated owner's user id.
onboarding_completed
Fires from CompleteOnboarding on the first call that actually flips
user.onboarded_at from NULL. Retries are idempotent server-side but
deliberately do NOT re-emit, so the funnel counts first-completions
only. The client sends completion_path in the POST body to label
which exit the user took.
| Property | Type | Description |
|---|---|---|
workspace_id |
string (UUID) | Present for workspace-linked onboarding completions. |
completion_path |
string | One of full / runtime_skipped / cloud_waitlist / skip_existing / invite_accept / unknown. See below. |
joined_cloud_waitlist |
bool | Derived from user.cloud_waitlist_email. Orthogonal to completion_path — a user may submit the waitlist form and still pick CLI. |
Person properties set with $set_once:
| Property | Type | Description |
|---|---|---|
onboarded_at |
string (RFC3339) | Timestamp the first completion landed. Enables cohort queries like "users onboarded before X" directly from person_properties. |
completion_path values:
full— Reached Step 5 (first_issue) with a runtime connected.runtime_skipped— Completed without connecting a runtime (user hit Skip in Step 3).cloud_waitlist— Submitted the cloud waitlist form and skipped Step 3.skip_existing— "I've done this before" from Welcome. The user already had a workspace.invite_accept— Accepted at least one workspace invitation.unknown— Legacy fallback when the client didn't send a path. Should stay near zero after rollout.
cloud_waitlist_joined
Fires from JoinCloudWaitlist whenever a user submits the Step 3 cloud waitlist form. Not a completion signal — it's orthogonal to the main funnel and used to size hosted-runtime interest.
| Property | Type | Description |
|---|---|---|
has_reason |
bool | Presence flag for the free-text reason field. The free text stays in the DB; we don't broadcast it. |
distinct_id is the user's id.
contact_sales_submitted
Fires from CreateContactSales after the contact_sales_inquiry row is
inserted. The endpoint is public and unauthenticated, so the
distinct_id is the inquiry id (no user identity to attach to). The
free-text goals field stays in the DB and is never broadcast.
| Property | Type | Description |
|---|---|---|
inquiry_id |
string | Stable inquiry id; same as distinct_id. Useful for joining to operational data. |
company_size |
string | Closed enum from the form dropdown (1-10, 11-50, 51-200, 201-500, 501-1000, 1000+). |
country_region |
string | Country / region label submitted from the dropdown. |
use_case |
string | Closed enum (evaluate / adopt_team / self_host / integrate / partner / other). |
has_goals |
bool | Presence flag for the free-text goals field. |
feedback_submitted
Fires from CreateFeedback after the feedback row is inserted and the
hourly per-user rate-limit check has passed. Retries within the same hour
that were rate-limited (429) don't emit. The free-text message is stored
in the DB and never broadcast.
| Property | Type | Description |
|---|---|---|
message_length_bucket |
string | 0-100 / 100-500 / 500-2000 / 2000+ — coarse bucket of len(message) so we can tell "quick note" from "bug report with repro steps" without leaking content. |
has_images |
bool | true when the markdown contains at least one  image reference — signals bug reports with visual evidence. |
platform |
string | Client platform from X-Client-Platform header (web / desktop). Omitted when the header is absent. |
app_version |
string | Client version from X-Client-Version header. Omitted when absent. |
distinct_id is the submitter's user id; workspace_id is attached from
the modal's current-workspace context and may be empty when feedback is
sent from a pre-workspace surface.
Frontend-only events
-
$pageview— fired by the web tracker (apps/web/components/pageview-tracker.tsx) on Next.js App Router pathname changes, and by the desktop tracker (apps/desktop/.../pageview-tracker.tsx) on visible-surface changes. Both mount once at the root and drive the acquisition funnel's/ → signupstep. posthog-js's automatic pageview capture is disabled ininitAnalyticsso we own the event shape.capturePageview(packages/core/analytics) section-normalizes the path before emitting: query string / hash are stripped and resource-id segments are collapsed, so/acme/issues/8d5c…and/acme/issues/MUL-12both report as/acme/issues, and consecutive views of the same section are deduplicated. This keeps PostHog at section granularity rather than billing a$pageviewper resource or per filter/sort/search change. The tracker is deliberately NOT keyed on the query string. -
onboarding_runtime_path_selected— fired frompackages/views/onboarding/steps/step-platform-fork.tsxwhen the web user clicks one of the three Step 3 fork cards (before any server call happens, so it's frontend-only). Properties:path(download_desktop/cli/cloud_waitlist),source(onboarding),surface(step3),workspace_id, andis_mac. Also writesplatform_preference(web/desktop) to person properties so every subsequent event on the user can be broken down by chosen platform. Note: semantic "download intent" is now better served bydownload_intent_expressedbelow —path: "download_desktop"signals Step 3 path choice specifically, not actual download start. -
onboarding_runtime_detected— fired frompackages/views/onboarding/steps/step-runtime-connect.tsx(desktop Step 3) once per mount, when the scanning phase resolves — either immediately on first runtime registration, or after the 5 s empty timeout. Answers the question "did the user have any AI CLI installed on this machine when they hit Step 3" — currently unanswerable from the existing funnel because the bundled daemon fails to register at all when zero CLIs are on PATH, soruntime_registeredis silent on that cohort. Splitscompletion_path=runtime_skippedinto "had CLIs, skipped anyway" vs "no CLIs available, had no choice". Properties:source:onboarding.surface:step3_desktop.workspace_id: current onboarding workspace.outcome:found(at least one runtime registered before the 5 s grace window expired) orempty(none registered by then).runtime_count: number of runtimes visible to this user at resolution time.online_count: subset ofruntime_countwhosestatusisonline.providers: sorted array of distinct provider names (e.g.["claude", "codex"]).has_claude/has_codex/has_cursor: convenience booleans derived fromprovidersfor funnel breakdowns without array filtering in HogQL.detect_ms: wall-clock ms from component mount to resolution. Surfaces daemon boot latency —foundevents with a highdetect_msapproach the timeout threshold and inform whether to lengthen the grace period.
Person properties set with
$set:has_any_cli: boolean — cohort signal for "user has at least one local AI CLI detected on this machine".detected_cli_count: number — granular cohort signal.
Not emitted from the web Step 3 (
step-platform-fork.tsx) — web users don't run the bundled daemon, so their runtime list reflects daemons from other machines and would corrupt the "CLI installed locally" signal. -
download_intent_expressed— fired whenever a user clicks a CTA that points at the/downloadpage. Surfaces five sources across the funnel, letting the top-of-funnel entry be split cleanly. Wrapper lives inpackages/core/analytics/download.ts(captureDownloadIntent). Properties:source:landing_hero/landing_footer/login/welcome/step3Also writesplatform_preference: "desktop"to person properties.
-
download_page_viewed— fired once per/downloadmount after OS detect resolves (apps/web/app/(landing)/download/download-client.tsx). Properties:detected_os:mac/windows/linux/unknowndetected_arch:arm64/x64/unknowndetect_confident:truewhen detect useduserAgentData.getHighEntropyValues(Chromium);falsewhen it fell back to the UA string (Safari on Mac always lands here — lets us isolate the arm64-default-for-Intel risk cohort).version_available:falsewhen the GitHub API fetch failed and the page is in the "Version unavailable" degraded state. Also writesfirst_detected_os/first_detected_archvia$set_onceso every downstream event gains a platform dimension without re-emitting.
-
download_initiated— fired when the user clicks a specific installer link on/download. Both the hero CTA and the All Platforms matrix rows emit this; split byprimary_cta. Properties:platform:mac/windows/linuxarch:arm64/x64format:dmg/zip/exe/appimage/deb/rpmversion: release tag (e.g.v0.2.13) — correlates adoption with release cadence.primary_cta:truefor the hero-recommended installer,falsefor a manual pick from the All Platforms matrix.matched_detect:truewhen the chosen platform+arch matches what the page detected.falselets us quantify detect misses from the single event (no cross-join needed).
-
feedback_opened— fired when the in-app Feedback modal mounts (user clicked "Feedback" in the Help launcher). Paired with the backend'sfeedback_submittedto give a completion rate for the form. Wrapper lives inpackages/core/analytics/feedback.ts(captureFeedbackOpened). Properties:source:help_menu(reserved — future entry points like keyboard shortcut or error-toast CTA will pass their own value)workspace_id: string (UUID) when the modal opens inside a workspace. Omitted on pre-workspace surfaces.
-
Attribution is NOT a separate event; UTM + referrer origin are written to the
multica_signup_sourcecookie on the first anonymous pageview and read by the backend'ssignupemission. The cookie carries a JSON payload URL-encoded at write time (encodeURIComponent) and URL-decoded at read time (url.QueryUnescape) — the JSON is never mid-truncated; individual values are capped at 96 chars beforeJSON.stringify, and the entire payload is dropped if it still exceeds 512 chars. That way PostHog sees either intact JSON or nothing at all.
Reconciliation
Per-task completion is no longer shipped to PostHog. Task success now
reconciles DB ↔ Prometheus instead of DB ↔ PostHog: the
BusinessMetrics.RecordTaskTerminal counter (exported as a multica_* task
metric) should track the operational source of truth:
SELECT date_trunc('day', completed_at AT TIME ZONE 'UTC') AS day,
count(*) AS db_completed_tasks
FROM agent_task_queue
WHERE status = 'completed'
AND completed_at >= now() - interval '30 days'
GROUP BY 1
ORDER BY 1;
Compare against the equivalent Prometheus counter in Grafana. The expected difference should be near zero; sustained drift means either an emission site is missing or the metrics pipeline is unhealthy.
On the PostHog side, issue_executed remains the product-level success signal
(at most one per issue) and can be reconciled against
issue.first_executed_at if needed.
Governance
Before adding, renaming, or removing any event:
- Update this document first.
- Update
server/internal/analytics/events.goconstants and helpers to match. - PR description must state which existing funnel / insight is affected.