mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
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>
351 lines
9.6 KiB
TypeScript
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 })
|
|
}
|