Developers

Analytics

Scope

This is a code-derived inventory of what Char sends to PostHog across:

  • apps/web (browser events)
  • apps/desktop + plugins/analytics (desktop events)
  • apps/api + proxy/subscription crates (server-side events)

Collection paths

Web (apps/web)

  • PostHog is initialized in apps/web/src/providers/posthog.tsx only when:
    • VITE_POSTHOG_API_KEY is set
    • build is not dev (!import.meta.env.DEV)
  • Enabled options:
    • autocapture: true
    • capture_pageview: true

Desktop (apps/desktop + plugins/analytics)

  • Frontend calls analyticsCommands.event, analyticsCommands.setProperties, and analyticsCommands.identify.
  • Rust plugin forwards to hypr_analytics::AnalyticsClient in plugins/analytics/src/ext.rs.
  • Distinct ID for desktop telemetry is hypr_host::fingerprint() (hashed machine UID).

API/server (apps/api + crates)

  • API builds a PostHog client in production in apps/api/src/main.rs.
  • Request middleware maps x-device-fingerprint and auth user ID into request extensions in apps/api/src/auth.rs.
  • LLM/STT/trial analytics emit from backend crates (details below).

Identity and distinct IDs

SurfaceDistinct IDIdentify behavior
Desktop custom eventsMachine fingerprint (hypr_host::fingerprint())identify(userId, payload) sends PostHog $identify with $anon_distinct_id = machine fingerprint.
Web custom/autocapture eventsPostHog browser distinct IDAuth callback calls posthog.identify(userId, { email }).
API $ai_generationx-device-fingerprint if present, else generation_idOptional user_id also included as event property.
API $stt_requestx-device-fingerprint if present, else random UUIDOptional user_id also included as event property.
API trial eventsx-device-fingerprint if present (desktop), else authenticated user_iduser_id is included as an event property when distinct ID is fingerprint. No separate $identify call here.

Automatic desktop event enrichment

Every desktop event(...) call is enriched in plugins/analytics/src/ext.rs with:

PropertyValue
app_versionenv!("APP_VERSION")
app_identifierTauri app identifier
git_hashtauri_plugin_misc::get_git_hash()
bundle_idTauri app identifier
$set.app_versionuser property update on each event

This enrichment applies to desktop frontend events and Rust plugin event_fire_and_forget events (for example notification/window events).

Event catalog

Web custom events

EventPropertiesSource
hero_section_viewedtimestampapps/web/src/routes/_view/index.tsx
download_clickedHomepage: platform, timestampapps/web/src/components/download-button.tsx
download_clickedDownload page: platform, spec, source ("download_page")apps/web/src/routes/_view/download/index.tsx
reminder_requestedplatform, timestamp, emailapps/web/src/routes/_view/index.tsx
os_waitlist_joinedplatform, timestamp, emailapps/web/src/routes/_view/index.tsx

Notes:

  • PostHog autocapture and pageview are also on (production only), so PostHog default browser events are collected in addition to the custom events above.
  • Web auth callback calls identify(userId, { email }) in apps/web/src/routes/_view/callback/auth.tsx.

Desktop product events

EventPropertiesSource
show_main_windownone (plus auto-enriched desktop props)plugins/windows/src/ext.rs
onboarding_step_viewedstep, platformapps/desktop/src/onboarding/index.tsx
onboarding_completednoneapps/desktop/src/onboarding/final.tsx
user_signed_innoneapps/desktop/src/auth/context.tsx
trial_flow_client_errorproperties.error (nested object)apps/desktop/src/onboarding/account/trial.tsx
trial_flow_skippedproperties.reason (already_pro or already_trialing)apps/desktop/src/onboarding/account/trial.tsx
data_importedsourceapps/desktop/src/settings/data/index.tsx
note_createdhas_event_idapps/desktop/src/store/tinybase/store/sessions.ts, apps/desktop/src/shared/main/useNewNote.ts
file_uploadedAudio: file_type = "audio"; Transcript: file_type = "transcript", token_countapps/desktop/src/session/components/floating/options-menu.tsx
session_startedhas_calendar_event, stt_provider, stt_modelapps/desktop/src/stt/useStartListening.ts
tab_openedviewapps/desktop/src/store/zustand/tabs/basic.ts
search_performednoneapps/desktop/src/search/contexts/ui.tsx
note_editedhas_content (currently emitted as true)apps/desktop/src/session/components/note-input/raw.tsx
note_enhancedVariant A: is_auto; Variant B: is_auto, llm_provider, llm_model, template_idapps/desktop/src/session/components/note-input/header.tsx, apps/desktop/src/services/enhancer/index.ts
message_sentnoneapps/desktop/src/chat/components/input/hooks.ts
session_exportedModal export: format, include_summary, include_transcriptapps/desktop/src/session/components/outer-header/overflow/export-modal.tsx
session_exportedPDF export: format = "pdf", view_type, has_transcript, has_enhanced, has_memoapps/desktop/src/session/components/outer-header/overflow/export-pdf.tsx
session_exportedTranscript export: format = "vtt", word_countapps/desktop/src/session/components/outer-header/overflow/export-transcript.tsx
session_deletedincludes_recording (currently always true)apps/desktop/src/session/components/outer-header/overflow/delete.tsx
settings_changedautostart, notification_detect, save_recordings, telemetry_consentapps/desktop/src/settings/general/index.tsx
ai_provider_configuredproviderapps/desktop/src/settings/ai/shared/index.tsx
upgrade_clickedplan ("pro")apps/desktop/src/settings/general/account.tsx
user_signed_outnoneapps/desktop/src/settings/general/account.tsx

Desktop notification events

EventPropertiesSource
collapsed_confirmnoneplugins/notification/src/handler.rs
expanded_acceptnoneplugins/notification/src/handler.rs
dismissnoneplugins/notification/src/handler.rs
collapsed_timeoutnoneplugins/notification/src/handler.rs
option_selectednoneplugins/notification/src/handler.rs

API/server events

EventPropertiesSource
$stt_request$stt_provider, $stt_duration, optional user_idcrates/transcribe-proxy/src/analytics.rs
$ai_generation$ai_provider, $ai_model, $ai_input_tokens, $ai_output_tokens, $ai_latency, $ai_trace_id, $ai_http_status, $ai_base_url, optional $ai_total_cost_usd, optional user_idcrates/llm-proxy/src/analytics.rs
trial_startedplan, source (desktop or web)crates/api-subscription/src/trial.rs, crates/api-subscription/src/routes/billing.rs
trial_skippedreason = "not_eligible", sourcecrates/api-subscription/src/trial.rs, crates/api-subscription/src/routes/billing.rs
trial_failedreason (stripe_error, customer_error, rpc_error), sourcecrates/api-subscription/src/trial.rs, crates/api-subscription/src/routes/billing.rs

User journey funnel

The user lifecycle is divided into 8 stages. Each stage lists the PostHog events that measure it, how identity linking works at that point, and known gaps.

Stage 1: Acquisition (website visits)

Goal: measure traffic to the website.

EventPropertiesNotes
PostHog autocaptureautomaticPage clicks, form interactions. Production only.
PostHog pageviewautomaticEvery page load. Production only.
hero_section_viewedtimestampExplicit signal that a visitor saw the main landing section.
reminder_requestedplatform, timestamp, emailMobile app waitlist signup.
os_waitlist_joinedplatform, timestamp, emailDesktop waitlist signup.

Identity: anonymous PostHog browser distinct ID. No user identity yet.

Stage 2: Converting visits to installs

Goal: measure how many website visitors download and open the app.

EventPropertiesWhere
download_clickedHomepage: platform, timestamp; Download page: platform, spec, sourceWeb
show_main_windownone (auto-enriched with app_version, git_hash, bundle_id)Desktop

Identity linking: download_clicked fires with anonymous browser ID. show_main_window fires with machine fingerprint. These two IDs are not linked at this point — there is no mechanism to pass the browser identity into the desktop app at download time. Conversion rate between these two events can only be measured at cohort level (e.g., X downloads this week, Y first app opens this week), not per-user.

Gap: no explicit install-complete event. Install is inferred from first show_main_window.

43
const handleClick = () => {
44
track("download_clicked", {
45
platform: platform,
46
timestamp: new Date().toISOString(),
47
});
48
};
76
fn prepare_show(&self, app: &AppHandle<tauri::Wry>) {
77
#[cfg(target_os = "macos")]
78
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
79
80
if matches!(self, Self::Main) {
81
use tauri_plugin_analytics::{AnalyticsPayload, AnalyticsPluginExt};
82
83
let e = AnalyticsPayload::builder("show_main_window").build();
84
app.analytics().event_fire_and_forget(e);
85
}

Stage 3: Onboarding

Goal: measure onboarding progress and completion.

EventPropertiesNotes
onboarding_step_viewedstep, platformFired per step. macOS steps: permissionslogincalendarfinal. Other platforms: loginfinal.
user_signed_innoneFires on auth state change. Also triggers identify(supabaseUserId, { email, account_created_date, is_signed_up, app_version, os_version, platform }).
trial_startedplan, sourceServer-side. Fires when trial is successfully created.
trial_flow_skippedproperties.reason (already_pro or already_trialing)Desktop. User already has a subscription.
trial_flow_client_errorproperties.errorDesktop. Error during trial activation.
onboarding_completednoneFires when user clicks "Get Started" on the final onboarding screen.

Identity linking: desktop sign-in opens char.com/auth in the user's default browser. The web auth callback calls posthog.identify(supabaseUserId), which merges the anonymous browser ID with the Supabase user ID. The desktop then calls identify(supabaseUserId) with $anon_distinct_id = machine fingerprint. This links browser → Supabase user ID → machine fingerprint. Because the login opens in the same browser where the user may have previously clicked download, PostHog can retroactively merge download_clicked with the authenticated user — but only if the same browser is used for both download and login.

114
async function trackAuthEvent(
115
event: AuthChangeEvent,
116
session: Session | null,
117
): Promise<void> {
118
if ((event === "SIGNED_IN" || event === "INITIAL_SESSION") && session) {
119
if (session.user.id === trackedUserId) {
120
return;
121
}
122
123
trackedUserId = session.user.id;
273
const signIn = useCallback(async () => {
274
const url = await buildWebAppUrl("/auth");
275
await openerCommands.openUrl(url, null);
276
}, []);
109
pub async fn identify(
110
&self,
111
user_id: impl Into<String>,
112
payload: hypr_analytics::PropertiesPayload,
113
) -> Result<(), crate::Error> {
114
if !self.is_disabled()? {
115
let machine_id = hypr_host::fingerprint();
116
let user_id = user_id.into();
117
118
let client = self.manager.state::<crate::ManagedState>();
94
useEffect(() => {
95
void analyticsCommands.event({
96
event: "onboarding_step_viewed",
97
step: currentStep,
98
platform: platform(),
99
});
100
}, [currentStep]);
58
export async function finishOnboarding(onContinue?: () => void) {
59
await sfxCommands.stop("BGM").catch(console.error);
60
await new Promise((resolve) => setTimeout(resolve, 100));
61
await commands.setOnboardingNeeded(false).catch(console.error);
62
await new Promise((resolve) => setTimeout(resolve, 100));
63
await analyticsCommands.event({ event: "onboarding_completed" });
64
onContinue?.();
65
}

Stage 4: Activation (first summary)

Goal: measure whether a user gets value from the product by generating their first AI summary.

EventPropertiesWhereSignal
note_createdhas_event_idDesktopUser created a note (standalone or calendar-backed).
session_startedhas_calendar_event, stt_provider, stt_modelDesktopUser started transcription. This is intent.
$stt_request$stt_provider, $stt_durationServerTranscription actually happened. Stronger signal than session_started.
note_enhancedis_auto, llm_provider, llm_model, template_idDesktopSummary generated. is_auto distinguishes automatic vs manual trigger.
$ai_generation$ai_provider, $ai_model, $ai_input_tokens, $ai_output_tokens, $ai_latencyServerLLM call actually happened. Stronger signal than note_enhanced.

Activation funnel sequence: note_createdsession_started$stt_requestnote_enhanced / $ai_generation.

For the activation milestone, $ai_generation (server-side) is the strongest "user got value" signal because it confirms the summary was actually produced, not just requested.

19
export function createSession(store: Store, title?: string): string {
20
const sessionId = id();
21
store.setRow("sessions", sessionId, {
22
title: title ?? "",
23
created_at: new Date().toISOString(),
24
raw_md: "",
25
user_id: DEFAULT_USER_ID,
26
});
27
void analyticsCommands.event({
28
event: "note_created",
56
user_id: user_id ?? "",
57
created_at: new Date().toISOString(),
58
started_at: startedAt,
59
words: "[]",
60
speaker_hints: "[]",
61
memo_md: typeof memoMd === "string" ? memoMd : "",
202
const llmConn = getLLMConn();
203
void analyticsCommands.event({
204
event: "note_enhanced",
205
is_auto: opts?.isAuto ?? false,
206
llm_provider: llmConn?.providerId,
207
llm_model: llmConn?.modelId,
208
template_id: templateId,
209
});

Stage 5: Building habits (multiple meeting notes)

Goal: measure whether a user moves from first use to repeated use.

EventWhat to measure
note_createdCount per user over time. Look for users with 2+, 5+, 10+ notes.
session_started / $stt_requestRepeated transcription sessions.
note_enhanced / $ai_generationRepeated summary generation.
file_uploadedfile_type (audio or transcript). Importing recordings shows deepening usage.
message_sentChat engagement with notes.
search_performedSearching past notes indicates accumulated value.
session_exportedformat. Exporting notes means the output is useful outside the app.
ai_provider_configuredprovider. Configuring a custom AI provider shows investment in the tool.
data_importedsource. Importing data from other tools.

Habit signals: look for users who fire note_created or $stt_request on 3+ distinct days within their first 14 days.

Stage 6: Retention (coming back)

Goal: measure whether users return to the app over time.

EventWhat to measure
show_main_windowFires every time the main window is shown (not just first launch). Count distinct days per user.
tab_openedview. Indicates active navigation within the app.
note_editedRevisiting and editing past notes.
search_performedSearching past content means the user has accumulated value worth returning to.

Retention measurement: count distinct days with show_main_window per user per week/month. A retained user has show_main_window on multiple distinct days across weeks.

Stage 7: Conversion (trial to pro)

Goal: measure monetization.

EventPropertiesWhereNotes
trial_startedplan, sourceServerTrial begins. trial_end_date user property is set to UTC now + 14 days.
trial_skippedreason = "not_eligible", sourceServerUser was not eligible for trial.
trial_failedreason (stripe_error, customer_error, rpc_error), sourceServerTrial creation failed.
upgrade_clickedplan ("pro")DesktopUser clicked upgrade button. This is intent, not completion.

User properties for segmentation: plan (set on trial start), trial_end_date (set on trial start).

Gap: no explicit subscription_started or payment_completed event. Conversion from trial to paid is currently inferred from the plan user property or Stripe data, not a PostHog event.

69
Self::Started(interval) => {
70
let plan = match interval {
71
Interval::Monthly => "pro_monthly",
72
Interval::Yearly => "pro_yearly",
73
};
74
AnalyticsPayload::builder("trial_started")
75
.with("plan", plan)
76
.build()
77
}

Stage 8: Retention (keep paying)

Goal: measure ongoing engagement from paying users.

No stage-specific events. Use the same product events from stages 4-6 filtered to users where plan = "pro":

SignalHow to measure
Continued usage$stt_request and $ai_generation events per week for pro users.
Feature depthtemplate_id on note_enhanced (using templates), message_sent (chat), session_exported (export).
Settings engagementsettings_changed, ai_provider_configured.
Churn riskAbsence of show_main_window for 7+ days. user_signed_out event.

Gap: no explicit subscription_cancelled or payment_failed event in PostHog. Churn detection relies on usage drop-off or Stripe webhooks outside PostHog.

Suggested PostHog funnel definition

For a single end-to-end activation funnel in PostHog:

  1. download_clicked (web)
  2. show_main_window (desktop)
  3. user_signed_in (desktop)
  4. onboarding_completed (desktop)
  5. note_created (desktop)
  6. $stt_request (server, optionally filter $stt_duration >= 300)
  7. $ai_generation (server)

Note: steps 1→2 cannot be linked at per-user level without sign-in (see Stage 2 identity notes). Steps 2→7 are linked via machine fingerprint and Supabase user ID after sign-in.

User property catalog

PostHog user properties are set via $set, $set_once, and $identify payloads.

PropertyHow it is setSource
emailidentify(..., { email }) (desktop and web)apps/desktop/src/auth/context.tsx, apps/web/src/routes/_view/callback/auth.tsx
account_created_dateidentify(..., { set: { ... } })apps/desktop/src/auth/context.tsx
is_signed_uptrue on sign-in identify, false on sign-out setPropertiesapps/desktop/src/auth/context.tsx, apps/desktop/src/settings/general/account.tsx
platformidentify(..., set.platform)apps/desktop/src/auth/context.tsx
os_versionidentify(..., set.os_version)apps/desktop/src/auth/context.tsx
app_versionidentify(..., set.app_version) and per-event $set.app_version enrichmentapps/desktop/src/auth/context.tsx, plugins/analytics/src/ext.rs
telemetry_opt_outsetProperties({ set: { telemetry_opt_out } })apps/desktop/src/settings/general/index.tsx
has_configured_aisetProperties({ set: { has_configured_ai: true } })apps/desktop/src/settings/ai/shared/index.tsx
spoken_languagessettings persister setPropertiesapps/desktop/src/store/tinybase/persister/settings/persister.ts
current_stt_providersettings persister setPropertiesapps/desktop/src/store/tinybase/persister/settings/persister.ts
current_stt_modelsettings persister setPropertiesapps/desktop/src/store/tinybase/persister/settings/persister.ts
current_llm_providersettings persister setPropertiesapps/desktop/src/store/tinybase/persister/settings/persister.ts
current_llm_modelsettings persister setPropertiesapps/desktop/src/store/tinybase/persister/settings/persister.ts
planserver set_properties on successful trial startcrates/api-subscription/src/trial.rs
trial_end_dateserver set_properties on successful trial start (UTC now + 14 days)crates/api-subscription/src/trial.rs

Telemetry controls and environment behavior

  • Desktop opt-out:
    • telemetry_consent config side effect calls analyticsCommands.setDisabled(!value).
    • When disabled, desktop plugin drops event, setProperties, and identify calls.
    • Source: apps/desktop/src/shared/config/registry.ts, plugins/analytics/src/ext.rs.
  • Desktop PostHog initialization:
    • Release builds require POSTHOG_API_KEY at compile time.
    • Debug builds use option_env!("POSTHOG_API_KEY"); if missing, events are not sent to PostHog (they only hit local tracing fallback).
    • Source: plugins/analytics/src/lib.rs, crates/analytics/src/lib.rs.
  • Web:
    • PostHog is not initialized in dev mode.
    • Source: apps/web/src/providers/posthog.tsx.
  • API:
    • PostHog client is active only in non-debug builds (production requires POSTHOG_API_KEY).
    • Source: apps/api/src/main.rs.
  • Note on scope:
    • Desktop telemetry_consent only controls the desktop plugin path. No code path currently applies that toggle to server-side $stt_request / $ai_generation / trial events.

Feature flags

Feature flag checks are wired through PostHog capability in hypr_analytics, but current desktop feature strategy is hardcoded:

  • Feature::Chat => FlagStrategy::Hardcoded(true)
  • Source: plugins/flag/src/feature.rs.

If a feature uses FlagStrategy::Posthog(key), the check resolves via is_feature_enabled(flag_key, distinct_id) with desktop machine fingerprint as distinct ID.

How to update this document

  1. Search for all emitters:
    • analyticsCommands.event(
    • analyticsCommands.setProperties(
    • analyticsCommands.identify(
    • AnalyticsPayload::builder("...)`
    • posthog.capture( / posthog.identify(
  2. Verify payload keys at each callsite (watch for nested objects like properties: {...}).
  3. Re-run this inventory after any analytics refactor in plugins/analytics, crates/analytics, crates/llm-proxy, crates/transcribe-proxy, or crates/api-subscription.