rowboat/apps/x/apps/renderer/src/lib/analytics.ts
Ramnique Singh d9317f3ca1 feat(x): PostHog instrumentation for provider/model selection
Privacy rules encoded in one place (analytics/model-providers.ts): only
provider FLAVORS leave the app — never instance ids (future-proofing for
user-named instances), never apiKey/headers, never baseURL (local endpoints
can carry internal hostnames). Model ids allowed.

- llm_usage.provider now reports the flavor: captureLlmUsage maps the
  instance id centrally (lazy, cached, falls back to the raw value — which
  today equals the flavor, so history stays continuous)
- llm_provider_connected / _disconnected {flavor}: one event family across
  all surfaces — BYOK at the repo choke point (new entries only; key
  rotation is not a connect), rowboat at OAuth sign-in/out, codex at
  ChatGPT sign-in/out
- person properties synced on every launch and after provider/assistant
  changes: llm_provider_flavors (sorted, auth-derived included),
  llm_provider_count, assistant_model + assistant_model_flavor
- llm_initial_model_selected {flavor, model, recommended, source}:
  recommendation hit-rate for the initial-selection flow (connect /
  onboarding / sign_in)
- models_config_migrated {had_assistant, materialized_overrides,
  provider_count}: one-shot rollout health for the v2 migration
- ANALYTICS.md updated (llm_usage provider row, new lifecycle section,
  person-properties table)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:41:31 +05:30

351 lines
9.6 KiB
TypeScript

import posthog from 'posthog-js'
let appVersion: string | undefined
let apiUrl: string | undefined
function appVersionProperties(): Record<string, string> {
return appVersion ? { app_version: appVersion } : {}
}
export function configureAnalyticsContext(props: { appVersion?: string; apiUrl?: string }) {
appVersion = props.appVersion?.trim() || undefined
apiUrl = props.apiUrl?.trim() || undefined
// `platform` distinguishes desktop events from any other surface (e.g. the
// web dashboard's autocapture) sharing the PostHog project.
const eventProperties = { platform: 'desktop', ...appVersionProperties() }
posthog.register(eventProperties)
const personProperties = {
...(apiUrl ? { api_url: apiUrl } : {}),
...eventProperties,
}
posthog.people.set(personProperties)
}
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
posthog.identify(userId, {
...properties,
...appVersionProperties(),
})
}
export function resetAnalyticsIdentity() {
posthog.reset()
configureAnalyticsContext({ appVersion, apiUrl })
}
export function chatSessionCreated(runId: string) {
posthog.capture('chat_session_created', { run_id: runId })
}
export function chatMessageSent(props: {
voiceInput?: boolean
voiceOutput?: string
searchEnabled?: boolean
}) {
posthog.capture('chat_message_sent', {
voice_input: props.voiceInput ?? false,
voice_output: props.voiceOutput ?? false,
search_enabled: props.searchEnabled ?? false,
})
}
export function appOpened(folder: string) {
posthog.capture('app_opened', { folder })
}
export function oauthConnected(provider: string) {
posthog.capture('oauth_connected', { provider })
}
export function oauthDisconnected(provider: string) {
posthog.capture('oauth_disconnected', { provider })
}
export function voiceInputStarted() {
posthog.capture('voice_input_started')
}
export function callStarted(preset: 'voice' | 'video' | 'share' | 'practice') {
posthog.capture('call_started', { preset })
}
// Voice-to-voice latency breakdown for one call turn (all milliseconds):
// utterance accepted → message submitted → first TTS speak() → audio playing.
export function callTurnLatency(props: {
endpointToSubmitMs: number
submitToSpeakMs: number
speakToAudioMs: number
totalMs: number
}) {
posthog.capture('call_turn_latency', {
endpoint_to_submit_ms: Math.round(props.endpointToSubmitMs),
submit_to_speak_ms: Math.round(props.submitToSpeakMs),
speak_to_audio_ms: Math.round(props.speakToAudioMs),
total_ms: Math.round(props.totalMs),
})
}
// Client auto-update funnel: staged (main: update_failed on error) → prompted
// (here, when the restart card is shown) → restarted (main, on quitAndInstall)
// → client_updated (main, first launch on the new version).
export function updatePrompted() {
posthog.capture('update_prompted')
}
export function searchExecuted(types: string[]) {
posthog.capture('search_executed', { types })
}
export function noteExported(format: string) {
posthog.capture('note_exported', { format })
}
// ---------------------------------------------------------------------------
// Feature usage instrumentation. One `view_opened` per navigation (the
// feature-importance funnel), plus per-feature action events. Everything below
// answers "how many people use X and what do they do inside it".
// ---------------------------------------------------------------------------
export type AppView =
| 'chat'
| 'file'
| 'graph'
| 'task'
| 'suggested-topics'
| 'meetings'
| 'live-notes'
| 'email'
| 'workspace'
| 'knowledge-view'
| 'chat-history'
| 'home'
| 'code'
| 'bg-tasks'
| 'apps'
// Views that count as "using a feature" — first visit sets a person property
// so PostHog cohorts can answer "how many people have ever used meetings".
const FIRST_USE_VIEWS: Partial<Record<AppView, string>> = {
email: 'has_used_email',
meetings: 'has_used_meetings',
'live-notes': 'has_used_live_notes',
'bg-tasks': 'has_used_bg_agents',
apps: 'has_used_apps',
code: 'has_used_code',
}
export function viewOpened(view: AppView) {
posthog.capture('view_opened', { view })
const flag = FIRST_USE_VIEWS[view]
if (flag) posthog.people.set_once({ [flag]: true })
}
// --- Email ---
export function emailThreadOpened() {
posthog.capture('email_thread_opened')
}
export function emailComposeOpened(mode: string) {
posthog.capture('email_compose_opened', { mode })
}
export function emailSent(props: { mode: string; hasAttachments: boolean; aiAssisted: boolean }) {
posthog.capture('email_sent', {
mode: props.mode,
has_attachments: props.hasAttachments,
ai_assisted: props.aiAssisted,
})
}
export function emailAiDraftGenerated(mode: 'generate' | 'rewrite') {
posthog.capture('email_ai_draft_generated', { mode })
}
export function emailArchived() {
posthog.capture('email_archived')
}
export function emailTrashed() {
posthog.capture('email_trashed')
}
export function emailMarkedUnread() {
posthog.capture('email_marked_unread')
}
export function emailImportanceChanged(importance: string) {
posthog.capture('email_importance_changed', { importance })
}
export function emailCategoryChanged(category: string) {
posthog.capture('email_category_changed', { category })
}
export function emailCategoryArchived(category: string) {
posthog.capture('email_category_archived', { category })
}
export function emailSearched() {
posthog.capture('email_searched')
}
export function emailInstructionsSaved() {
posthog.capture('email_instructions_saved')
}
export function emailSyncTriggered() {
posthog.capture('email_sync_triggered')
}
// --- Meetings ---
export function meetingRecordingStarted(hasCalendarEvent: boolean) {
posthog.capture('meeting_recording_started', { has_calendar_event: hasCalendarEvent })
posthog.people.set_once({ has_used_meetings: true })
}
export function meetingRecordingStopped(durationSeconds: number) {
posthog.capture('meeting_recording_stopped', { duration_seconds: Math.round(durationSeconds) })
}
// meeting_popup_action is captured in the main process (the popup window runs
// without PostHog) — see apps/main/src/ipc.ts 'meetingDetect:action'.
export function meetingNoteOpened() {
posthog.capture('meeting_note_opened')
}
// --- Calls ---
export function callEnded(durationSeconds: number) {
posthog.capture('call_ended', { duration_seconds: Math.round(durationSeconds) })
}
// --- Background agents ---
export function bgAgentCreated(props: { method: 'manual' | 'coding' | 'copilot'; hasTriggers: boolean }) {
posthog.capture('bg_agent_created', { method: props.method, has_triggers: props.hasTriggers })
posthog.people.set_once({ has_created_bg_agent: true })
}
export function bgAgentUpdated() {
posthog.capture('bg_agent_updated')
}
export function bgAgentToggled(active: boolean) {
posthog.capture('bg_agent_toggled', { active })
}
export function bgAgentRunClicked() {
posthog.capture('bg_agent_run_clicked')
}
export function bgAgentStopped() {
posthog.capture('bg_agent_stopped')
}
export function bgAgentDeleted() {
posthog.capture('bg_agent_deleted')
}
// --- Live notes ---
export function liveNoteSaved() {
posthog.capture('live_note_saved')
}
export function liveNoteToggled(active: boolean) {
posthog.capture('live_note_toggled', { active })
}
export function liveNoteRunClicked() {
posthog.capture('live_note_run_clicked')
}
export function liveNoteStopped() {
posthog.capture('live_note_stopped')
}
export function liveNoteDeleted() {
posthog.capture('live_note_deleted')
}
export function liveNoteEditWithCopilotClicked() {
posthog.capture('live_note_edit_with_copilot_clicked')
}
// --- Search ---
export function searchOpened() {
posthog.capture('search_opened')
}
export function searchResultSelected(type: string) {
posthog.capture('search_result_selected', { type })
}
// Apps install/update/publish/star/delete events are captured in the main
// process (apps/main/src/ipc.ts) where the operations actually run.
// --- Billing ---
export function billingErrorShown(kind: string) {
posthog.capture('billing_error_shown', { kind })
}
export function billingUpgradeClicked(kind: string) {
posthog.capture('billing_upgrade_clicked', { kind })
}
// --- Failures ---
export function emailSendFailed() {
posthog.capture('email_send_failed')
}
export function meetingSummarizeFailed() {
posthog.capture('meeting_summarize_failed')
}
// --- Notes / settings / onboarding ---
export function noteCreated() {
posthog.capture('note_created')
}
// The autosave loop fires on every debounced keystroke burst, so dedupe to one
// event per note per app session — "was this note edited", not "how many saves".
const editedNotePaths = new Set<string>()
export function noteEdited(path: string) {
if (editedNotePaths.has(path)) return
editedNotePaths.add(path)
posthog.capture('note_edited')
}
export function settingsOpened(tab: string) {
posthog.capture('settings_opened', { tab })
}
export function settingsTabChanged(tab: string) {
posthog.capture('settings_tab_changed', { tab })
}
export function onboardingCompleted() {
posthog.capture('onboarding_completed')
}
// A provider connect seeded the assistant model (only happens when none was
// configured). `recommended` = the backend's flavor recommendation was in
// the provider's live list; false = first-listed fallback. Flavor only —
// never provider instance ids, keys, or endpoints.
export function llmInitialModelSelected(props: {
flavor: string
model: string
recommended: boolean
source: 'connect' | 'onboarding'
}) {
posthog.capture('llm_initial_model_selected', { ...props })
}