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

All owned outbound links that drive traffic to char.com carry UTM parameters so installs and page visits can be attributed by source in PostHog.

Convention

ParameterDescriptionValues
utm_sourceWhere the link livesgithub, app, website
utm_mediumType of contentreadme, contributing, settings, changelog, onboarding, blog, docs
utm_campaignCampaign typeorganic for all evergreen links

Where UTMs are applied

Sourceutm_sourceutm_mediumExample file
GitHub READMEgithubreadmeREADME.md
GitHub CONTRIBUTINGgithubcontributingCONTRIBUTING.md
Desktop app settingsappsettingsapps/desktop/src/settings/ai/llm/shared.tsx
Desktop app changelogappchangelogapps/desktop/src/changelog/index.tsx
In-app onboardingapponboardingcrates/db-user/assets/onboarding-raw.html
Blog article CTAswebsiteblogapps/web/content/articles/*.mdx
Documentationwebsitedocsapps/web/content/docs/about/0.hello-world.mdx

Rules

  • Append ?utm_source=...&utm_medium=...&utm_campaign=organic to the URL.
  • If the URL already has query parameters, use & instead of ?.
  • Place UTM parameters before any #fragment (e.g., ?utm_source=app&utm_medium=settings&utm_campaign=organic#section).
  • Do not add UTMs to internal same-site navigation (relative links), API endpoints, OG meta tags, CI config URLs, or links to third-party sites.
  • Blog-to-blog cross-links (char.com/blog/...char.com/blog/...) do not get UTMs since they are internal navigation.

How this connects to PostHog

PostHog automatically captures UTM parameters from the landing page URL as properties on the user's first pageview event. This means:

  • download_clicked events can be segmented by the UTM properties of the session that led to them.
  • Stage 1 (Acquisition) and Stage 2 (Install conversion) attribution improves because you can see which source drove the visit.
  • The utm_source / utm_medium breakdown directly answers "where do our installs come from?"

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.