Analytics Events
FocusFlow analytics event names use result-style names such as Started, Completed, Viewed, Created, Copied, and Tapped. Add or update rows here in the same change that adds or renames an AnalyticsEvent constant.
Describe every property a row sends — what it means and its type — rather than only naming it. For a property with a fixed set of values (an enum), list all the values. Use None when an event sends no properties.
Desktop Events
Source: apps/clients/src/renderer/core/telemetry/events.ts
| Event | Trigger | Properties |
|---|---|---|
Accessibility Permission Completed | Accessibility permission request resolves or fails during onboarding. | permissionStatus |
Account Restricted Shown | The account-restricted surface first becomes visible (operator kill-switch active). Fires once per transition to restricted — covers both the reactive 403 IPC broadcast and the proactive entitlements snapshot path. | None |
App Open | The desktop app finishes launching and analytics initializes — fires once per app launch, anonymous before sign-in. Mirrors the iOS App Open lifecycle event. | None |
Calendar Link Screen Viewed | The connect-Google-Calendar onboarding step is shown (once per mount) — the gate between the onboarding questions and the permission screen. Makes drop-off at the calendar step visible in the onboarding funnel. | None |
Calendar Connect Skipped | The user takes the skip exit on the Google-connect onboarding step after a failed or declined connect attempt. Shown only for the intents that have the step (email triage, meeting notes). | onboardingId, path |
Calendar Sign In Completed | Google Calendar connection completes and the calendar link state is confirmed as linked. | None |
Desktop App CTA Clicked | User clicks a download or installed-app action on a desktop-only CTA in the web build. | source (string) — which feature surfaced the CTA, one of recording, dictation, meetingDetection, template, or desktopNudge.placement (optional string) — where the CTA rendered, one of meetingBanner, templateLanding, desktopDownloadPopup, or desktopDownloadSidebar.platform (optional string) — targeted desktop OS for a desktop nudge, one of mac or windows.followUp (optional string) — nudge state, one of download or finishSetup.action (optional string) — desktop nudge action, one of downloadInstaller or openDesktopApp. |
Desktop App CTA Viewed | A web feature surface renders the download-desktop CTA in the web build (once per visible surface). | source (string) — which feature surfaced the CTA, one of recording, dictation, meetingDetection, template, or desktopNudge.placement (optional string) — where the CTA rendered, one of meetingBanner, templateLanding, desktopDownloadPopup, or desktopDownloadSidebar.platform (optional string) — targeted desktop OS for a desktop nudge, one of mac or windows.followUp (optional string) — nudge state, one of download or finishSetup. Email nudge also uses placement = templatePostPaywall, onboardingPostPaywall, reminderPopup, or sidebar; surface = reminder or email; downloadMethod = email; browser = mobile or desktop; additional platform values mobileWeb or otherDesktop. |
Desktop Download Email Requested | Signed-in web user explicitly requests a desktop link and the backend accepts the durable request. Emitted by the web client once per accepted reference in the screen session, keyed on the ref the request endpoint returns. | source = desktopNudge; placement = templatePostPaywall, onboardingPostPaywall, reminderPopup, or sidebar; browser = mobile or desktop; opaque UUID ref; downloadMethod = email. |
Desktop Download Email Sent | Emitted by the backend worker when it observes a completed CRM transactional send task with a provider message ID. This means provider acceptance, not inbox delivery. | source = desktopNudge; placement = templatePostPaywall, onboardingPostPaywall, reminderPopup, or sidebar; browser = mobile or desktop; opaque UUID ref; downloadMethod = email. Firebase user ID; stable insert ID per reference. |
Desktop App Activated | Emitted by the backend when it first observes an authenticated desktop session for the account. Existing activated accounts do not replay. | desktopPlatform; for an owned email reference authenticated within 30 days: source = desktopNudge, original placement, opaque ref, downloadMethod = email. Email forwarding alone never attributes activation. |
Desktop Download Nudge Snoozed | User closes a web-to-desktop popup or sidebar reminder, successfully requesting the server-owned 15-day snooze. | placement (string) — one of desktopDownloadPopup or desktopDownloadSidebar.platform (string) — one of mac or windows.followUp (string) — one of download or finishSetup. |
Dictation Failed | A dictation attempt ends in an error of any kind (connect, provider, finalize, or paste failure). | cause (string) — failure classification, one of engine_dead, internal_error, websocket_close, websocket_error, auth, rate_limit, timeout, parse_error, or unknown (fallback for anything unclassified). |
Dictation Limit Reached | The rolling dictation-seconds cap is hit — fired once per cap event from the main-process dictation pipeline, covering the admission 429s, the finalize-time 429, and the mid-session budget stop. | stage (string) — where the cap surfaced, one of start (session blocked at the credential mint), retry (blocked re-attempt), finalize (blocked while transcribing after key-up), or cutoff (stopped mid-dictation by the budget signal). |
Dictation Started | Dictation recording begins after the trigger shortcut is pressed and the microphone is authorized. | None |
Dictation Succeeded | A dictation attempt finishes with text pasted (auto-paste, manual paste, or recovered after retry). | durationSeconds (number) — whole seconds from recording start to paste, including processing time.pasteResult (string) — how the text landed, one of autoPasted (inserted into the focused field) or manualPaste (left on the clipboard for the user to paste). |
Document Created | A workspace document is created. | None |
Document Duplicated | A workspace document is duplicated from the sidebar row menu. | None |
HabitatZero Desktop App Installed | First time the desktop app opens after install (once per installation). Anonymous at fire time; stitches to the user via the desktop device_id at sign-in. Top of the install → onboarding funnel. | acquisition_source (string) — where the installation originated, one of microsoft-store or website. Default is website. |
HabitatZero First Note Created | A user's first desktop note is created with active cross-promo attribution still inside the activation window. | placement, ref, source, trialDays, optional UTM properties |
Install via Referrer | Fires once per installation, the first time a referrer resolves. Web/desktop: the website-cookie cross-promo attribution, available once entitlements resolve after sign-in. Mobile: the native Adjust attribution, polled from app boot before sign-in — there is no website cookie on a native install. Also persists the resolved acquisition UTMs as user properties (referrerUtmSource, referrerUtmCampaign, referrerUtmMedium, referrerUtmTerm, referrerUtmContent) so cohorts can filter by acquisition source, not just this one event. Mirrors the iOS Install via Referrer acquisition event. | Web/desktop: placement, ref, source, trialDays, optional UTM properties. Mobile: optional UTM properties only (utmSource required for the event to fire; utmCampaign, utmMedium, utmTerm optional). |
Meeting Duration Auto Ended | The desktop meeting soft cap stops an active recording after the warning was not postponed. | None |
Meeting Duration Postponed | A user taps Postpone on the meeting duration warning. | postponeCount |
Meeting Duration Warning Shown | The desktop meeting soft-cap warning is shown on the notification overlay. | postponeCount |
Meeting Max Length Reached | The server hard backstop ends an active meeting through meeting_max_duration or meeting.terminate. | None |
Meeting Transcription Lost | The desktop app ended a recording because transcription could not be restored, and told the user it stopped. cause distinguishes reconnectBudgetExhausted (retried for two minutes and gave up) from unrecoverableStatus (an immediate 403/404/410 that retrying cannot fix). | cause |
Meeting Note Created | A meeting note is created from the tray, deeplink, or note creation flow. | None |
Meeting Recording Ended | A recording session finalizes (manual stop or its note is deleted mid-recording). | durationSeconds (number) — whole seconds of wall-clock from recording start to finalize, including any paused time.hasTranscript (boolean) — whether any transcript content was captured during the session.transcriptWordCount (number) — words captured during the session (0 when none). |
Meeting Recording Started | Recording starts for a note. | source |
Meeting Summary Completed | A transcript summary websocket event completes for a note. | None |
Meeting Transcribed | A recording session first receives transcript content. | None |
Microphone Permission Completed | Microphone permission request resolves during onboarding. | permissionStatus |
Notification Opened | A user taps an accepted mobile calendar reminder notification. | notificationId (string) — the stable id that joins this event to the server-side Notification Sent event. |
Notification Sent | FCM accepted a mobile calendar reminder notification for delivery. | notificationId (string), appId (string), deviceCount (number), platform (string) — always mobile, sent as a top-level Amplitude event property rather than an event property |
On App Background | The desktop main window is minimized or hidden (closing the window hides it in this tray app). Fired from the main process on window minimize/hide — deliberately not on focus loss/occlusion while the window is still visible. Mirrors the iOS On App Background lifecycle event. | None |
Onboarding Completed | The user reaches the real end of the onboarding flow (after permissions and any dictation opt-in). | onboardingId, path (the chosen intent, when known), fastPath (boolean) — true when onboarding was fast-pathed for a template arrival (survey and calendar skipped). |
Onboarding Question Answered | A user answers one onboarding question. | questionId, optionId, optionLabel, optionPosition (1-based displayed position; the intent question's option order is randomized), step, onboardingId |
Onboarding Questions Completed | The onboarding questions flow completes. | Onboarding answer user-property payload (usage_context, starting_point, onboardingAnswers), onboardingId |
Onboarding Questions Started | The onboarding questions flow first mounts. | source, onboardingId |
Onboarding Started | A signed-in user begins onboarding for the first tracked app session. | source |
Paywall Viewed | The offer sheet is shown, whatever raised it: the welcome ask, a template arrival, a limit hit, the launch popup, print, the desktop sidebar CTA, or the always-visible mobile plan indicator. For a trial experiment, the first event for each trialAssignmentId is the exposure; later openings remain product events but do not create another experiment exposure. Analysis joins that ID to the stored server-side arm. | trigger, source (onboarding_welcome, template_web_signin, app_open_popup, sidebar_cta, mobile_plan_indicator, print, limit_dictation, limit_meetings, …), featureKey, templateSlug, offerKind, offerDays, trialAssignmentId (opt-in offers only). Template arrivals also carry the template purchase attribution properties. |
Permission Screen Viewed | The permission screen is shown during onboarding. | None |
Purchase Success | A subscription first becomes active or trialing after checkout/webhook processing, from either payment provider. Nova emits web purchase events; FocusFlow emits store purchase events. | ownerId, workspaceId, planCode, priceLookupKey, currency, amount, interval, stripeSubscriptionId, source (the product/paywall origin captured when purchase started, such as sidebar_cta, mobile_plan_indicator, template_web_signin, plans_and_billing, or trial_banner; unknown for legacy or invalid metadata). HZ-template purchases also carry the template purchase attribution properties.See provider properties for the fields an App Store purchase adds. |
Quarter Goal Created | A quarterly planning goal is created. | None |
Sales Success | A Stripe invoice with a positive paid amount succeeds, including the first paid invoice and recurring invoices. | ownerId (string) — ID of the subscription owner.workspaceId (string) — ID of the workspace that owns the subscription.planCode (optional string) — billing plan code from the subscription snapshot.currency (optional string) — ISO 4217 currency code from the subscription snapshot.amount (number) — paid invoice amount in Stripe minor currency units, not the subscription price.interval (optional string) — subscription billing interval: month or year.stripeSubscriptionId (string) — Stripe subscription ID.stripeInvoiceId (string) — Stripe invoice ID.currentPeriodEnd (optional ISO 8601 date-time string) — end of the current subscription billing period.source (string) — event origin; always webhook for this event. |
Share Link Copied | A share URL is copied from sharing UI. | entityType, source |
Share Link Tapped | The share toggle/action is tapped from the sharing popover. | entityType, source |
Subscription Manage Opened | A user opens the Stripe billing portal from Plans and Billing. | None |
Subscription Purchase Failed | A checkout start/create call fails before Stripe checkout can be completed. | planCode, currency, errorMessage |
Subscription Purchase Started | A user starts hosted or embedded checkout from Plans and Billing. | planCode, currency, source. HZ-template purchases also carry the template purchase attribution properties. |
Trial Offer Accepted | FocusFlow proves from a signed Stripe webhook that the exact subscription accepted an assigned trial offer. This non-revenue event bridges the FocusFlow assignment to Nova-owned purchase and invoice events. Join billing events by stripeSubscriptionId, also exposed as Name and Reference. Join the experiment assignment by the optional remoteConfigInstallationId. | ownerId, workspaceId, stripeSubscriptionId, trialAssignmentId, offerDays, optional remoteConfigInstallationId, source (always stripe_webhook), Origin (always stripe). |
Subscription Purchase Succeeded | Client-side constant reserved for checkout success UI; authoritative server-side first activation is tracked to Amplitude as Purchase Success. | None |
System Audio Permission Completed | System audio permission request resolves, fails, or times out during onboarding. | permissionStatus |
Template Open In App Tapped | A visitor on the web templates landing (/templates/<slug>) taps "Already have the app? Open it" — the only action that fires the desktop deeplink now that boot no longer auto-fires it. | placement (string) — always templateLanding.source (string) — always template.templateSlug (string) — the resolved template slug. |
Template Cloned | A template clone succeeded and the document opened, from any path. | templateSlug, templateName, source (string) — gallery, webHandoff, deeplink, or coldInstall. |
Template Personalize Tapped | The user taps the personalize/use button on a template detail page (primary pill or worksheet preview open). | templateName, templateSlug |
Template Sign In Viewed | The web build shows the Canva-style sign-in preview for a resolved template after a signed-out visitor lands on /templates/<slug>. | templateName, templateSlug, plus the template purchase attribution properties. |
Todo Created | A non-goal todo is created. | None |
Top Goal Created | A daily top goal is created or promoted. | goalPosition, source |
Web App Viewed | The web build finishes loading and analytics initializes — the web analogue of App Open; fires once per page load. | None |
Web Sign In Completed | The web OAuth redirect returns and the code exchange plus browser-session creation succeed. | source (string) — always template — and the template purchase attribution properties, present only when Google sign-in was actually started from a /templates/<slug> arrival (the direct-Google handoff, or the landing screen's own Google button). A sign-in started anywhere else is not attributed to the template, even when a template visit is still pending in the same browser session. Absent otherwise. |
Web Sign In Started | Google web sign-in is invoked, by a user action or the direct-Google template handoff, before the OAuth redirect. | The template purchase attribution properties, when sign-in came from an HZ template. |
Weekly Goal Created | A weekly planning card is created. | source |
Weekly Goal Planned | An unplanned weekly card is scheduled into a day or next week. | placement |
Template purchase attribution
The HZ template funnel uses the analytics properties that already describe
cross-product traffic: utmSource is the sending app (fabulous, clarify,
and so on), placement is its surface or row, ref is the deployment,
experiment, or campaign reference, and utmMedium, utmCampaign,
utmContent, and templateSlug preserve the rest of the handoff context.
These fields are additive. They never replace source: a purchase that began
at an HZ web-template paywall continues to report
source = template_web_signin. This makes the paywall path stable while
utmSource and placement answer which app and surface produced it.
Billing provider properties
Server-side billing events are emitted for every payment provider under the same event
names, so no dashboard forks by provider. A store purchase reports the same planCode a
web purchase does.
| Property | Meaning |
|---|---|
provider | stripe, app_store or play_store. Absent means Stripe, for compatibility with events emitted before store support existed. Sent to Amplitude as Origin. |
environment | Production, Sandbox or Xcode, on App Store events only. Play has no equivalent field. |
appStoreOriginalTransactionId | Apple's stable subscription identifier. Stands in for stripeSubscriptionId in Name and Reference. |
appStoreTransactionId | Apple's identifier for the individual transaction. |
playStoreOrderId | Google's order id for a Play subscription. Stands in for stripeSubscriptionId in Name and Reference, and is the dedup key. Never the purchase token. |
Three behaviours follow from these and are easy to misread as bugs:
platformfollows the provider:iosfor App Store,androidfor Play Store,desktopfor Stripe and for an absent provider. Changing thedesktopdefault would break every historical series that splits by platform.- A non-
ProductionApp Store event carries no revenue fields.price,revenue,quantity,productIdandrevenueTypeare omitted for Sandbox and Xcode purchases, and so areValue,Currency, andamountin the event properties — an amount that is not real money is reported nowhere, so a dashboard readingValuecannot count it either. A TestFlight purchase is real access but not real money, and counting it would inflate every revenue series with internal testing. The event itself still fires, so the conversion funnel stays complete. - A Play Store event carries no revenue fields at all, top-level or in the event properties. Google's verification response supplies no trusted price, so the only amount available came from the device — and a device must not be able to write our revenue series. The conversion is recorded and the money is omitted everywhere.
Who emits which purchase
| Purchase | Emitted by | Where from |
|---|---|---|
| App Store (iOS) | this backend | POST /api/billing/store/verify, after Nova confirms the purchase |
| Play Store (Android) | this backend | the same route |
| Stripe (web, desktop) | Nova | its own Stripe webhook handling |
A native client cannot be trusted to report its own conversion, so a store purchase's event
waits for provider-confirmed verification and is then emitted server-side. That is also
why the mobile builds having no Amplitude client key does not affect it — this event carries
its user_id explicitly and never passes through the device.
The store event is emitted at most once per subscription, and that is enforced in our own
database rather than by Amplitude. The backend claims the provider reference on the workspace
(storePurchaseConversions) before publishing, and only the caller that wins the claim sends
the event. Amplitude's own deduplication is a backstop for the seconds-apart race only: it
matches a repeated insert_id just "within the past 7 days", and only alongside a matching
device_id, which a server-side event does not send — so a customer restoring a year later
would otherwise be counted twice.
The event is still keyed on the provider's stable identifier — appStoreOriginalTransactionId
for Apple, playStoreOrderId for Google — and nothing renewable is put on it:
currentPeriodEnd in particular is deliberately absent, because eventInsertId would use it
as its state marker and a renewal would then mint a new id. Purchase tokens never reach
analytics — a token is a credential, and it rotates across renewals, which would make the
key unstable in both directions.
Frontend Public Share Events
Source: apps/frontend/src/lib/analytics/events.ts
| Event | Trigger | Properties |
|---|---|---|
HabitatZero Download Page Visited | A visitor views the Habitat Zero download page. | Optional placement, ref, source, trialDays, and UTM properties |
HabitatZero Download Started | A visitor starts a platform download via the /download/mac or /download/win link. | platform (mac or windows), optional placement, ref, source, trialDays, and UTM properties |
Open With Habitat Zero Tapped | A visitor taps an "Open with Habitat Zero" call to action on a public share page. | placement |
Public Share Viewed | A public share page is viewed. | entityType |