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.tsxonly when:VITE_POSTHOG_API_KEYis set- build is not dev (
!import.meta.env.DEV)
- Enabled options:
autocapture: truecapture_pageview: true
Desktop (apps/desktop + plugins/analytics)
- Frontend calls
analyticsCommands.event,analyticsCommands.setProperties, andanalyticsCommands.identify. - Rust plugin forwards to
hypr_analytics::AnalyticsClientinplugins/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-fingerprintand auth user ID into request extensions inapps/api/src/auth.rs. - LLM/STT/trial analytics emit from backend crates (details below).
Identity and distinct IDs
| Surface | Distinct ID | Identify behavior |
|---|---|---|
| Desktop custom events | Machine fingerprint (hypr_host::fingerprint()) | identify(userId, payload) sends PostHog $identify with $anon_distinct_id = machine fingerprint. |
| Web custom/autocapture events | PostHog browser distinct ID | Auth callback calls posthog.identify(userId, { email }). |
API $ai_generation | x-device-fingerprint if present, else generation_id | Optional user_id also included as event property. |
API $stt_request | x-device-fingerprint if present, else random UUID | Optional user_id also included as event property. |
| API trial events | x-device-fingerprint if present (desktop), else authenticated user_id | user_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:
| Property | Value |
|---|---|
app_version | env!("APP_VERSION") |
app_identifier | Tauri app identifier |
git_hash | tauri_plugin_misc::get_git_hash() |
bundle_id | Tauri app identifier |
$set.app_version | user 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
| Event | Properties | Source |
|---|---|---|
hero_section_viewed | timestamp | apps/web/src/routes/_view/index.tsx |
download_clicked | Homepage: platform, timestamp | apps/web/src/components/download-button.tsx |
download_clicked | Download page: platform, spec, source ("download_page") | apps/web/src/routes/_view/download/index.tsx |
reminder_requested | platform, timestamp, email | apps/web/src/routes/_view/index.tsx |
os_waitlist_joined | platform, timestamp, email | apps/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 })inapps/web/src/routes/_view/callback/auth.tsx.
Desktop product events
| Event | Properties | Source |
|---|---|---|
show_main_window | none (plus auto-enriched desktop props) | plugins/windows/src/ext.rs |
onboarding_step_viewed | step, platform | apps/desktop/src/onboarding/index.tsx |
onboarding_completed | none | apps/desktop/src/onboarding/final.tsx |
user_signed_in | none | apps/desktop/src/auth/context.tsx |
trial_flow_client_error | properties.error (nested object) | apps/desktop/src/onboarding/account/trial.tsx |
trial_flow_skipped | properties.reason (already_pro or already_trialing) | apps/desktop/src/onboarding/account/trial.tsx |
data_imported | source | apps/desktop/src/settings/data/index.tsx |
note_created | has_event_id | apps/desktop/src/store/tinybase/store/sessions.ts, apps/desktop/src/shared/main/useNewNote.ts |
file_uploaded | Audio: file_type = "audio"; Transcript: file_type = "transcript", token_count | apps/desktop/src/session/components/floating/options-menu.tsx |
session_started | has_calendar_event, stt_provider, stt_model | apps/desktop/src/stt/useStartListening.ts |
tab_opened | view | apps/desktop/src/store/zustand/tabs/basic.ts |
search_performed | none | apps/desktop/src/search/contexts/ui.tsx |
note_edited | has_content (currently emitted as true) | apps/desktop/src/session/components/note-input/raw.tsx |
note_enhanced | Variant A: is_auto; Variant B: is_auto, llm_provider, llm_model, template_id | apps/desktop/src/session/components/note-input/header.tsx, apps/desktop/src/services/enhancer/index.ts |
message_sent | none | apps/desktop/src/chat/components/input/hooks.ts |
session_exported | Modal export: format, include_summary, include_transcript | apps/desktop/src/session/components/outer-header/overflow/export-modal.tsx |
session_exported | PDF export: format = "pdf", view_type, has_transcript, has_enhanced, has_memo | apps/desktop/src/session/components/outer-header/overflow/export-pdf.tsx |
session_exported | Transcript export: format = "vtt", word_count | apps/desktop/src/session/components/outer-header/overflow/export-transcript.tsx |
session_deleted | includes_recording (currently always true) | apps/desktop/src/session/components/outer-header/overflow/delete.tsx |
settings_changed | autostart, notification_detect, save_recordings, telemetry_consent | apps/desktop/src/settings/general/index.tsx |
ai_provider_configured | provider | apps/desktop/src/settings/ai/shared/index.tsx |
upgrade_clicked | plan ("pro") | apps/desktop/src/settings/general/account.tsx |
user_signed_out | none | apps/desktop/src/settings/general/account.tsx |
Desktop notification events
| Event | Properties | Source |
|---|---|---|
collapsed_confirm | none | plugins/notification/src/handler.rs |
expanded_accept | none | plugins/notification/src/handler.rs |
dismiss | none | plugins/notification/src/handler.rs |
collapsed_timeout | none | plugins/notification/src/handler.rs |
option_selected | none | plugins/notification/src/handler.rs |
API/server events
| Event | Properties | Source |
|---|---|---|
$stt_request | $stt_provider, $stt_duration, optional user_id | crates/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_id | crates/llm-proxy/src/analytics.rs |
trial_started | plan, source (desktop or web) | crates/api-subscription/src/trial.rs, crates/api-subscription/src/routes/billing.rs |
trial_skipped | reason = "not_eligible", source | crates/api-subscription/src/trial.rs, crates/api-subscription/src/routes/billing.rs |
trial_failed | reason (stripe_error, customer_error, rpc_error), source | crates/api-subscription/src/trial.rs, crates/api-subscription/src/routes/billing.rs |
UTM parameters on owned links
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
| Parameter | Description | Values |
|---|---|---|
utm_source | Where the link lives | github, app, website |
utm_medium | Type of content | readme, contributing, settings, changelog, onboarding, blog, docs |
utm_campaign | Campaign type | organic for all evergreen links |
Where UTMs are applied
| Source | utm_source | utm_medium | Example file |
|---|---|---|---|
| GitHub README | github | readme | README.md |
| GitHub CONTRIBUTING | github | contributing | CONTRIBUTING.md |
| Desktop app settings | app | settings | apps/desktop/src/settings/ai/llm/shared.tsx |
| Desktop app changelog | app | changelog | apps/desktop/src/changelog/index.tsx |
| In-app onboarding | app | onboarding | crates/db-user/assets/onboarding-raw.html |
| Blog article CTAs | website | blog | apps/web/content/articles/*.mdx |
| Documentation | website | docs | apps/web/content/docs/about/0.hello-world.mdx |
Rules
- Append
?utm_source=...&utm_medium=...&utm_campaign=organicto 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_clickedevents 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_mediumbreakdown 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.
| Event | Properties | Notes |
|---|---|---|
| PostHog autocapture | automatic | Page clicks, form interactions. Production only. |
| PostHog pageview | automatic | Every page load. Production only. |
hero_section_viewed | timestamp | Explicit signal that a visitor saw the main landing section. |
reminder_requested | platform, timestamp, email | Mobile app waitlist signup. |
os_waitlist_joined | platform, timestamp, email | Desktop 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.
| Event | Properties | Where |
|---|---|---|
download_clicked | Homepage: platform, timestamp; Download page: platform, spec, source | Web |
show_main_window | none (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.
| Event | Properties | Notes |
|---|---|---|
onboarding_step_viewed | step, platform | Fired per step. macOS steps: permissions → login → calendar → final. Other platforms: login → final. |
user_signed_in | none | Fires on auth state change. Also triggers identify(supabaseUserId, { email, account_created_date, is_signed_up, app_version, os_version, platform }). |
trial_started | plan, source | Server-side. Fires when trial is successfully created. |
trial_flow_skipped | properties.reason (already_pro or already_trialing) | Desktop. User already has a subscription. |
trial_flow_client_error | properties.error | Desktop. Error during trial activation. |
onboarding_completed | none | Fires 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.
| Event | Properties | Where | Signal |
|---|---|---|---|
note_created | has_event_id | Desktop | User created a note (standalone or calendar-backed). |
session_started | has_calendar_event, stt_provider, stt_model | Desktop | User started transcription. This is intent. |
$stt_request | $stt_provider, $stt_duration | Server | Transcription actually happened. Stronger signal than session_started. |
note_enhanced | is_auto, llm_provider, llm_model, template_id | Desktop | Summary generated. is_auto distinguishes automatic vs manual trigger. |
$ai_generation | $ai_provider, $ai_model, $ai_input_tokens, $ai_output_tokens, $ai_latency | Server | LLM call actually happened. Stronger signal than note_enhanced. |
Activation funnel sequence: note_created → session_started → $stt_request → note_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.
| Event | What to measure |
|---|---|
note_created | Count per user over time. Look for users with 2+, 5+, 10+ notes. |
session_started / $stt_request | Repeated transcription sessions. |
note_enhanced / $ai_generation | Repeated summary generation. |
file_uploaded | file_type (audio or transcript). Importing recordings shows deepening usage. |
message_sent | Chat engagement with notes. |
search_performed | Searching past notes indicates accumulated value. |
session_exported | format. Exporting notes means the output is useful outside the app. |
ai_provider_configured | provider. Configuring a custom AI provider shows investment in the tool. |
data_imported | source. 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.
| Event | What to measure |
|---|---|
show_main_window | Fires every time the main window is shown (not just first launch). Count distinct days per user. |
tab_opened | view. Indicates active navigation within the app. |
note_edited | Revisiting and editing past notes. |
search_performed | Searching 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.
| Event | Properties | Where | Notes |
|---|---|---|---|
trial_started | plan, source | Server | Trial begins. trial_end_date user property is set to UTC now + 14 days. |
trial_skipped | reason = "not_eligible", source | Server | User was not eligible for trial. |
trial_failed | reason (stripe_error, customer_error, rpc_error), source | Server | Trial creation failed. |
upgrade_clicked | plan ("pro") | Desktop | User 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":
| Signal | How to measure |
|---|---|
| Continued usage | $stt_request and $ai_generation events per week for pro users. |
| Feature depth | template_id on note_enhanced (using templates), message_sent (chat), session_exported (export). |
| Settings engagement | settings_changed, ai_provider_configured. |
| Churn risk | Absence 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:
download_clicked(web)show_main_window(desktop)user_signed_in(desktop)onboarding_completed(desktop)note_created(desktop)$stt_request(server, optionally filter$stt_duration >= 300)$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.
| Property | How it is set | Source |
|---|---|---|
email | identify(..., { email }) (desktop and web) | apps/desktop/src/auth/context.tsx, apps/web/src/routes/_view/callback/auth.tsx |
account_created_date | identify(..., { set: { ... } }) | apps/desktop/src/auth/context.tsx |
is_signed_up | true on sign-in identify, false on sign-out setProperties | apps/desktop/src/auth/context.tsx, apps/desktop/src/settings/general/account.tsx |
platform | identify(..., set.platform) | apps/desktop/src/auth/context.tsx |
os_version | identify(..., set.os_version) | apps/desktop/src/auth/context.tsx |
app_version | identify(..., set.app_version) and per-event $set.app_version enrichment | apps/desktop/src/auth/context.tsx, plugins/analytics/src/ext.rs |
telemetry_opt_out | setProperties({ set: { telemetry_opt_out } }) | apps/desktop/src/settings/general/index.tsx |
has_configured_ai | setProperties({ set: { has_configured_ai: true } }) | apps/desktop/src/settings/ai/shared/index.tsx |
spoken_languages | settings persister setProperties | apps/desktop/src/store/tinybase/persister/settings/persister.ts |
current_stt_provider | settings persister setProperties | apps/desktop/src/store/tinybase/persister/settings/persister.ts |
current_stt_model | settings persister setProperties | apps/desktop/src/store/tinybase/persister/settings/persister.ts |
current_llm_provider | settings persister setProperties | apps/desktop/src/store/tinybase/persister/settings/persister.ts |
current_llm_model | settings persister setProperties | apps/desktop/src/store/tinybase/persister/settings/persister.ts |
plan | server set_properties on successful trial start | crates/api-subscription/src/trial.rs |
trial_end_date | server 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_consentconfig side effect callsanalyticsCommands.setDisabled(!value).- When disabled, desktop plugin drops
event,setProperties, andidentifycalls. - Source:
apps/desktop/src/shared/config/registry.ts,plugins/analytics/src/ext.rs.
- Desktop PostHog initialization:
- Release builds require
POSTHOG_API_KEYat 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.
- Release builds require
- 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.
- PostHog client is active only in non-debug builds (production requires
- Note on scope:
- Desktop
telemetry_consentonly controls the desktop plugin path. No code path currently applies that toggle to server-side$stt_request/$ai_generation/ trial events.
- Desktop
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
- Search for all emitters:
analyticsCommands.event(analyticsCommands.setProperties(analyticsCommands.identify(AnalyticsPayload::builder("...)`posthog.capture(/posthog.identify(
- Verify payload keys at each callsite (watch for nested objects like
properties: {...}). - Re-run this inventory after any analytics refactor in
plugins/analytics,crates/analytics,crates/llm-proxy,crates/transcribe-proxy, orcrates/api-subscription.