diff --git a/docs/system-prompt.md b/docs/system-prompt.md index fb5bb1305c..3b7de1ba03 100644 --- a/docs/system-prompt.md +++ b/docs/system-prompt.md @@ -62,5 +62,4 @@ You are in a git worktree at ${workspacePath} } ``` - {/* END SYSTEM_PROMPT_DOCS */} diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 0746bffe4a..f7fb5498bb 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -33,6 +33,7 @@ import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; import { getThinkingLevelKey } from "@/common/constants/storage"; import type { BranchListResult } from "@/common/orpc/types"; import { useTelemetry } from "./hooks/useTelemetry"; +import { getRuntimeTypeForTelemetry } from "@/common/telemetry"; import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; import { useAPI } from "@/browser/contexts/API"; import { AuthTokenModal } from "@/browser/components/AuthTokenModal"; @@ -640,7 +641,10 @@ function AppInner() { }); // Track telemetry - telemetry.workspaceCreated(metadata.id); + telemetry.workspaceCreated( + metadata.id, + getRuntimeTypeForTelemetry(metadata.runtimeConfig) + ); // Clear pending state clearPendingWorkspaceCreation(); diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 34566b370d..91dfa2bb66 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -33,6 +33,7 @@ import { WorkspaceHeader } from "./WorkspaceHeader"; import { getModelName } from "@/common/utils/ai/models"; import type { DisplayedMessage } from "@/common/types/message"; import type { RuntimeConfig } from "@/common/types/runtime"; +import { getRuntimeTypeForTelemetry } from "@/common/telemetry"; import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds"; import { evictModelFromLRU } from "@/browser/hooks/useModelLRU"; import { QueuedMessage } from "./Messages/QueuedMessage"; @@ -620,6 +621,7 @@ const AIViewInner: React.FC = ({ = (props) => { const editingMessage = variant === "workspace" ? props.editingMessage : undefined; const isCompacting = variant === "workspace" ? (props.isCompacting ?? false) : false; const canInterrupt = variant === "workspace" ? (props.canInterrupt ?? false) : false; + // runtimeType for telemetry - defaults to "worktree" if not provided + const runtimeType = variant === "workspace" ? (props.runtimeType ?? "worktree") : "worktree"; // Storage keys differ by variant const storageKeys = (() => { @@ -1015,7 +1017,13 @@ export const ChatInput: React.FC = (props) => { setImageAttachments(previousImageAttachments); } else { // Track telemetry for successful message send - telemetry.messageSent(sendMessageOptions.model, mode, actualMessageText.length); + telemetry.messageSent( + sendMessageOptions.model, + mode, + actualMessageText.length, + runtimeType, + sendMessageOptions.thinkingLevel ?? "off" + ); // Exit editing mode if we were editing if (editingMessage && props.onCancelEdit) { diff --git a/src/browser/components/ChatInput/types.ts b/src/browser/components/ChatInput/types.ts index e4ca1f1a26..9d7f0b4e47 100644 --- a/src/browser/components/ChatInput/types.ts +++ b/src/browser/components/ChatInput/types.ts @@ -1,5 +1,6 @@ import type { ImagePart } from "@/common/orpc/types"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { TelemetryRuntimeType } from "@/common/telemetry/payload"; import type { AutoCompactionCheckResult } from "@/browser/utils/compaction/autoCompactionCheck"; export interface ChatInputAPI { @@ -14,6 +15,8 @@ export interface ChatInputAPI { export interface ChatInputWorkspaceVariant { variant: "workspace"; workspaceId: string; + /** Runtime type for the workspace (for telemetry) - no sensitive details like SSH host */ + runtimeType?: TelemetryRuntimeType; onMessageSent?: () => void; onTruncateHistory: (percentage?: number) => Promise; onProviderConfig?: (provider: string, keyPath: string[], value: string) => Promise; diff --git a/src/browser/hooks/useTelemetry.ts b/src/browser/hooks/useTelemetry.ts index a807be0a58..301b92226b 100644 --- a/src/browser/hooks/useTelemetry.ts +++ b/src/browser/hooks/useTelemetry.ts @@ -1,80 +1,95 @@ import { useCallback } from "react"; -import { trackEvent, roundToBase2 } from "@/common/telemetry"; -import type { ErrorContext } from "@/common/telemetry/payload"; +import { + trackWorkspaceCreated, + trackWorkspaceSwitched, + trackMessageSent, + trackStreamCompleted, + trackProviderConfigured, + trackCommandUsed, + trackVoiceTranscription, + trackErrorOccurred, +} from "@/common/telemetry"; +import type { + ErrorContext, + TelemetryRuntimeType, + TelemetryThinkingLevel, + TelemetryCommandType, +} from "@/common/telemetry/payload"; /** * Hook for clean telemetry integration in React components * - * Provides type-safe telemetry tracking. Base properties (version, platform, etc.) - * are automatically added by the backend TelemetryService. + * Provides stable callback references for telemetry tracking. + * All numeric values are automatically rounded for privacy. * * Usage: * * ```tsx * const telemetry = useTelemetry(); * - * // Track workspace switch * telemetry.workspaceSwitched(fromId, toId); - * - * // Track workspace creation - * telemetry.workspaceCreated(workspaceId); - * - * // Track message sent - * telemetry.messageSent(model, mode, messageLength); - * - * // Track error + * telemetry.workspaceCreated(workspaceId, runtimeType); + * telemetry.messageSent(model, mode, messageLength, runtimeType, thinkingLevel); + * telemetry.streamCompleted(model, wasInterrupted, durationSecs, outputTokens); + * telemetry.providerConfigured(provider, keyType); + * telemetry.commandUsed(commandType); + * telemetry.voiceTranscription(audioDurationSecs, success); * telemetry.errorOccurred(errorType, context); * ``` */ export function useTelemetry() { const workspaceSwitched = useCallback((fromWorkspaceId: string, toWorkspaceId: string) => { - console.debug("[useTelemetry] workspaceSwitched called", { fromWorkspaceId, toWorkspaceId }); - trackEvent({ - event: "workspace_switched", - properties: { - fromWorkspaceId, - toWorkspaceId, - }, - }); + trackWorkspaceSwitched(fromWorkspaceId, toWorkspaceId); + }, []); + + const workspaceCreated = useCallback((workspaceId: string, runtimeType: TelemetryRuntimeType) => { + trackWorkspaceCreated(workspaceId, runtimeType); + }, []); + + const messageSent = useCallback( + ( + model: string, + mode: string, + messageLength: number, + runtimeType: TelemetryRuntimeType, + thinkingLevel: TelemetryThinkingLevel + ) => { + trackMessageSent(model, mode, messageLength, runtimeType, thinkingLevel); + }, + [] + ); + + const streamCompleted = useCallback( + (model: string, wasInterrupted: boolean, durationSecs: number, outputTokens: number) => { + trackStreamCompleted(model, wasInterrupted, durationSecs, outputTokens); + }, + [] + ); + + const providerConfigured = useCallback((provider: string, keyType: string) => { + trackProviderConfigured(provider, keyType); }, []); - const workspaceCreated = useCallback((workspaceId: string) => { - console.debug("[useTelemetry] workspaceCreated called", { workspaceId }); - trackEvent({ - event: "workspace_created", - properties: { - workspaceId, - }, - }); + const commandUsed = useCallback((command: TelemetryCommandType) => { + trackCommandUsed(command); }, []); - const messageSent = useCallback((model: string, mode: string, messageLength: number) => { - console.debug("[useTelemetry] messageSent called", { model, mode, messageLength }); - trackEvent({ - event: "message_sent", - properties: { - model, - mode, - message_length_b2: roundToBase2(messageLength), - }, - }); + const voiceTranscription = useCallback((audioDurationSecs: number, success: boolean) => { + trackVoiceTranscription(audioDurationSecs, success); }, []); const errorOccurred = useCallback((errorType: string, context: ErrorContext) => { - console.debug("[useTelemetry] errorOccurred called", { errorType, context }); - trackEvent({ - event: "error_occurred", - properties: { - errorType, - context, - }, - }); + trackErrorOccurred(errorType, context); }, []); return { workspaceSwitched, workspaceCreated, messageSent, + streamCompleted, + providerConfigured, + commandUsed, + voiceTranscription, errorOccurred, }; } diff --git a/src/browser/hooks/useVoiceInput.ts b/src/browser/hooks/useVoiceInput.ts index 8971a7233e..fa72679ac3 100644 --- a/src/browser/hooks/useVoiceInput.ts +++ b/src/browser/hooks/useVoiceInput.ts @@ -9,6 +9,7 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import type { APIClient } from "@/browser/contexts/API"; +import { trackVoiceTranscription } from "@/common/telemetry"; export type VoiceInputState = "idle" | "recording" | "transcribing"; @@ -117,6 +118,9 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul const shouldSendRef = useRef(false); const wasCancelledRef = useRef(false); + // Track recording start time for duration telemetry + const recordingStartTimeRef = useRef(0); + // Keep callbacks fresh without recreating functions const callbacksRef = useRef(options); useEffect(() => { @@ -134,6 +138,9 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul const shouldSend = shouldSendRef.current; shouldSendRef.current = false; + // Calculate recording duration for telemetry + const audioDurationSecs = (Date.now() - recordingStartTimeRef.current) / 1000; + try { // Encode audio as base64 for IPC transport const buffer = await audioBlob.arrayBuffer(); @@ -144,6 +151,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul const api = callbacksRef.current.api; if (!api) { callbacksRef.current.onError?.("Voice API not available"); + trackVoiceTranscription(audioDurationSecs, false); return; } @@ -151,11 +159,19 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul if (!result.success) { callbacksRef.current.onError?.(result.error); + trackVoiceTranscription(audioDurationSecs, false); return; } const text = result.data.trim(); - if (!text) return; // Empty transcription, nothing to do + if (!text) { + // Track empty transcription as success (API worked, just no speech) + trackVoiceTranscription(audioDurationSecs, true); + return; + } + + // Track successful transcription + trackVoiceTranscription(audioDurationSecs, true); callbacksRef.current.onTranscript(text); @@ -166,6 +182,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul } catch (err) { const msg = err instanceof Error ? err.message : String(err); callbacksRef.current.onError?.(`Transcription failed: ${msg}`); + trackVoiceTranscription(audioDurationSecs, false); } finally { setState("idle"); } @@ -235,6 +252,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul recorderRef.current = recorder; setMediaRecorder(recorder); recorder.start(); + recordingStartTimeRef.current = Date.now(); setState("recording"); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 73a6537262..d3d124fd52 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -18,6 +18,7 @@ import { isQueuedMessageChanged, isRestoreToInput, } from "@/common/orpc/types"; +import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; import { MapStore } from "./MapStore"; import { collectUsageHistory, createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager"; @@ -25,6 +26,7 @@ import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; import type { TokenConsumer } from "@/common/types/chatStats"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { createFreshRetryState } from "@/browser/utils/messages/retryState"; +import { trackStreamCompleted } from "@/common/telemetry"; export interface WorkspaceState { name: string; // User-facing workspace name (e.g., "feature-branch") @@ -155,23 +157,43 @@ export class WorkspaceStore { this.states.bump(workspaceId); }, "stream-end": (workspaceId, aggregator, data) => { - aggregator.handleStreamEnd(data as never); - aggregator.clearTokenState((data as { messageId: string }).messageId); + const streamEndData = data as StreamEndEvent; + aggregator.handleStreamEnd(streamEndData as never); + aggregator.clearTokenState(streamEndData.messageId); + + // Track stream completion telemetry + this.trackStreamCompletedTelemetry(streamEndData, false); // Reset retry state on successful stream completion updatePersistedState(getRetryStateKey(workspaceId), createFreshRetryState()); this.states.bump(workspaceId); this.checkAndBumpRecencyIfChanged(); - this.finalizeUsageStats(workspaceId, (data as { metadata?: never }).metadata); + this.finalizeUsageStats(workspaceId, streamEndData.metadata); }, "stream-abort": (workspaceId, aggregator, data) => { - aggregator.clearTokenState((data as { messageId: string }).messageId); - aggregator.handleStreamAbort(data as never); + const streamAbortData = data as StreamAbortEvent; + aggregator.clearTokenState(streamAbortData.messageId); + aggregator.handleStreamAbort(streamAbortData as never); + + // Track stream interruption telemetry (get model from aggregator) + const model = aggregator.getCurrentModel(); + if (model) { + this.trackStreamCompletedTelemetry( + { + metadata: { + model, + usage: streamAbortData.metadata?.usage, + duration: streamAbortData.metadata?.duration, + }, + }, + true + ); + } this.states.bump(workspaceId); this.dispatchResumeCheck(workspaceId); - this.finalizeUsageStats(workspaceId, (data as { metadata?: never }).metadata); + this.finalizeUsageStats(workspaceId, streamAbortData.metadata); }, "tool-call-start": (workspaceId, aggregator, data) => { aggregator.handleToolCallStart(data as never); @@ -282,6 +304,27 @@ export class WorkspaceStore { window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { workspaceId })); } + /** + * Track stream completion telemetry + */ + private trackStreamCompletedTelemetry( + data: { + metadata: { + model: string; + usage?: { outputTokens?: number }; + duration?: number; + }; + }, + wasInterrupted: boolean + ): void { + const { metadata } = data; + const durationSecs = metadata.duration ? metadata.duration / 1000 : 0; + const outputTokens = metadata.usage?.outputTokens ?? 0; + + // trackStreamCompleted handles rounding internally + trackStreamCompleted(metadata.model, wasInterrupted, durationSecs, outputTokens); + } + /** * Check if any workspace's recency changed and bump global recency if so. * Uses cached recency values from aggregators for O(1) comparison per workspace. diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 1283e6646b..f55bc9f9bf 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -36,6 +36,7 @@ import { // ============================================================================ import { createCommandToast } from "@/browser/components/ChatInputToasts"; +import { trackCommandUsed, trackProviderConfigured } from "@/common/telemetry"; export interface ForkOptions { client: RouterClient; @@ -146,6 +147,9 @@ export async function processSlashCommand( try { await context.onProviderConfig(parsed.provider, parsed.keyPath, parsed.value); + // Track successful provider configuration + trackCommandUsed("providers"); + trackProviderConfigured(parsed.provider, parsed.keyPath[0] ?? "unknown"); setToast({ id: Date.now().toString(), type: "success", @@ -216,6 +220,7 @@ export async function processSlashCommand( setInput(""); setPreferredModel(modelString); onModelChange?.(modelString); + trackCommandUsed("model"); setToast({ id: Date.now().toString(), type: "success", @@ -227,6 +232,7 @@ export async function processSlashCommand( if (parsed.type === "vim-toggle") { setInput(""); setVimEnabled((prev) => !prev); + trackCommandUsed("vim"); return { clearInput: true, toastShown: false }; } @@ -295,6 +301,7 @@ async function handleClearCommand( try { await onTruncateHistory(1.0); + trackCommandUsed("clear"); setToast({ id: Date.now().toString(), type: "success", @@ -385,6 +392,7 @@ async function handleForkCommand( }); return { clearInput: false, toastShown: true }; } else { + trackCommandUsed("fork"); setToast({ id: Date.now().toString(), type: "success", @@ -786,6 +794,7 @@ export async function handleNewCommand( return { clearInput: false, toastShown: true }; } + trackCommandUsed("new"); setToast({ id: Date.now().toString(), type: "success", @@ -859,6 +868,7 @@ export async function handleCompactCommand( return { clearInput: false, toastShown: true }; } + trackCommandUsed("compact"); setToast({ id: Date.now().toString(), type: "success", diff --git a/src/common/orpc/schemas/telemetry.ts b/src/common/orpc/schemas/telemetry.ts index 3a5edae19f..6dec67c06c 100644 --- a/src/common/orpc/schemas/telemetry.ts +++ b/src/common/orpc/schemas/telemetry.ts @@ -19,13 +19,40 @@ const ErrorContextSchema = z.enum([ "git-operation", ]); +// Runtime type enum (matches payload.ts TelemetryRuntimeType) +const TelemetryRuntimeTypeSchema = z.enum(["local", "worktree", "ssh"]); + +// Frontend platform info (matches payload.ts FrontendPlatformInfo) +const FrontendPlatformInfoSchema = z.object({ + userAgent: z.string(), + platform: z.string(), +}); + +// Thinking level enum (matches payload.ts TelemetryThinkingLevel) +const TelemetryThinkingLevelSchema = z.enum(["off", "low", "medium", "high"]); + +// Command type enum (matches payload.ts TelemetryCommandType) +const TelemetryCommandTypeSchema = z.enum([ + "clear", + "compact", + "new", + "fork", + "vim", + "model", + "mode", + "providers", +]); + // Individual event payload schemas const AppStartedPropertiesSchema = z.object({ isFirstLaunch: z.boolean(), + vimModeEnabled: z.boolean(), }); const WorkspaceCreatedPropertiesSchema = z.object({ workspaceId: z.string(), + runtimeType: TelemetryRuntimeTypeSchema, + frontendPlatform: FrontendPlatformInfoSchema, }); const WorkspaceSwitchedPropertiesSchema = z.object({ @@ -37,6 +64,30 @@ const MessageSentPropertiesSchema = z.object({ model: z.string(), mode: z.string(), message_length_b2: z.number(), + runtimeType: TelemetryRuntimeTypeSchema, + frontendPlatform: FrontendPlatformInfoSchema, + thinkingLevel: TelemetryThinkingLevelSchema, +}); + +const StreamCompletedPropertiesSchema = z.object({ + model: z.string(), + wasInterrupted: z.boolean(), + duration_b2: z.number(), + output_tokens_b2: z.number(), +}); + +const ProviderConfiguredPropertiesSchema = z.object({ + provider: z.string(), + keyType: z.string(), +}); + +const CommandUsedPropertiesSchema = z.object({ + command: TelemetryCommandTypeSchema, +}); + +const VoiceTranscriptionPropertiesSchema = z.object({ + audio_duration_b2: z.number(), + success: z.boolean(), }); const ErrorOccurredPropertiesSchema = z.object({ @@ -62,6 +113,22 @@ export const TelemetryEventSchema = z.discriminatedUnion("event", [ event: z.literal("message_sent"), properties: MessageSentPropertiesSchema, }), + z.object({ + event: z.literal("stream_completed"), + properties: StreamCompletedPropertiesSchema, + }), + z.object({ + event: z.literal("provider_configured"), + properties: ProviderConfiguredPropertiesSchema, + }), + z.object({ + event: z.literal("command_used"), + properties: CommandUsedPropertiesSchema, + }), + z.object({ + event: z.literal("voice_transcription"), + properties: VoiceTranscriptionPropertiesSchema, + }), z.object({ event: z.literal("error_occurred"), properties: ErrorOccurredPropertiesSchema, diff --git a/src/common/telemetry/index.ts b/src/common/telemetry/index.ts index c35e6f317f..5eb740abf2 100644 --- a/src/common/telemetry/index.ts +++ b/src/common/telemetry/index.ts @@ -5,9 +5,35 @@ * Events are forwarded to the backend via ORPC to avoid ad-blocker issues. * Backend controls whether telemetry is enabled (MUX_DISABLE_TELEMETRY env var). * See payload.ts for all data structures sent to PostHog. + * + * USAGE: + * - Use the track* functions for event tracking (they handle rounding internally) + * - Use getRuntimeTypeForTelemetry to convert RuntimeConfig to telemetry-safe type */ -export { initTelemetry, trackEvent, shutdownTelemetry } from "./client"; +export { initTelemetry, shutdownTelemetry } from "./client"; export { trackAppStarted } from "./lifecycle"; -export type { TelemetryEventPayload, ErrorContext } from "./payload"; -export { roundToBase2 } from "./utils"; + +// Tracking functions - callers pass raw values, rounding handled internally +export { + trackWorkspaceCreated, + trackWorkspaceSwitched, + trackMessageSent, + trackStreamCompleted, + trackProviderConfigured, + trackCommandUsed, + trackVoiceTranscription, + trackErrorOccurred, +} from "./tracking"; + +// Utility for converting RuntimeConfig to telemetry-safe runtime type +export { getRuntimeTypeForTelemetry } from "./utils"; + +// Type exports for callers that need them +export type { + TelemetryEventPayload, + ErrorContext, + TelemetryRuntimeType, + TelemetryThinkingLevel, + TelemetryCommandType, +} from "./payload"; diff --git a/src/common/telemetry/lifecycle.ts b/src/common/telemetry/lifecycle.ts index 86273ac0b4..2b0e0e2248 100644 --- a/src/common/telemetry/lifecycle.ts +++ b/src/common/telemetry/lifecycle.ts @@ -4,7 +4,8 @@ * Handles app startup events */ -import { trackEvent } from "./index"; +import { trackEvent } from "./client"; +import { VIM_ENABLED_KEY } from "@/common/constants/storage"; // Storage key for first launch tracking const FIRST_LAUNCH_KEY = "mux_first_launch_complete"; @@ -24,19 +25,28 @@ function checkFirstLaunch(): boolean { return true; } +/** + * Check if vim mode is enabled + */ +function checkVimModeEnabled(): boolean { + return localStorage.getItem(VIM_ENABLED_KEY) === "true"; +} + /** * Track app startup * Should be called once when the app initializes */ export function trackAppStarted(): void { const isFirstLaunch = checkFirstLaunch(); + const vimModeEnabled = checkVimModeEnabled(); - console.debug("[Telemetry] trackAppStarted", { isFirstLaunch }); + console.debug("[Telemetry] trackAppStarted", { isFirstLaunch, vimModeEnabled }); trackEvent({ event: "app_started", properties: { isFirstLaunch, + vimModeEnabled, }, }); } diff --git a/src/common/telemetry/payload.ts b/src/common/telemetry/payload.ts index b95d28bdd5..45c42415fc 100644 --- a/src/common/telemetry/payload.ts +++ b/src/common/telemetry/payload.ts @@ -15,9 +15,9 @@ * base-2 rounding (e.g., 128, 256, 512) to preserve privacy while enabling analysis. * - When in doubt, don't send it. Privacy is paramount. * - * NOTE: Base properties (version, platform, electronVersion) are automatically - * added by the backend TelemetryService. Frontend code only needs to provide - * event-specific properties. + * NOTE: Base properties (version, backend_platform, electronVersion, nodeVersion, + * bunVersion) are automatically added by the backend TelemetryService. Frontend + * code only needs to provide event-specific properties. */ /** @@ -27,10 +27,14 @@ export interface BaseTelemetryProperties { /** Application version */ version: string; - /** Operating system platform (darwin, win32, linux) */ - platform: NodeJS.Platform | "unknown"; - /** Electron version */ + /** Backend operating system platform (darwin, win32, linux) - where Node.js/backend runs */ + backend_platform: NodeJS.Platform | "unknown"; + /** Electron version (if running in Electron) */ electronVersion: string; + /** Node.js version */ + nodeVersion: string; + /** Bun version (if running in Bun) */ + bunVersion: string; } /** @@ -39,6 +43,25 @@ export interface BaseTelemetryProperties { export interface AppStartedPayload { /** Whether this is the first app launch */ isFirstLaunch: boolean; + /** Whether vim mode is enabled at startup */ + vimModeEnabled: boolean; +} + +/** + * Runtime type for telemetry - normalized from RuntimeConfig + * Values: 'local' (project-dir), 'worktree' (git worktree isolation), 'ssh' (remote execution) + */ +export type TelemetryRuntimeType = "local" | "worktree" | "ssh"; + +/** + * Frontend platform info - browser/client environment + * Useful when backend runs on different machine (e.g., mux server mode) + */ +export interface FrontendPlatformInfo { + /** Browser user agent string (safe, widely shared) */ + userAgent: string; + /** Client platform from navigator.platform */ + platform: string; } /** @@ -47,6 +70,10 @@ export interface AppStartedPayload { export interface WorkspaceCreatedPayload { /** Workspace ID (randomly generated, safe to send) */ workspaceId: string; + /** Runtime type for the workspace */ + runtimeType: TelemetryRuntimeType; + /** Frontend platform info */ + frontendPlatform: FrontendPlatformInfo; } export interface WorkspaceSwitchedPayload { @@ -56,6 +83,11 @@ export interface WorkspaceSwitchedPayload { toWorkspaceId: string; } +/** + * Thinking level for extended thinking feature + */ +export type TelemetryThinkingLevel = "off" | "low" | "medium" | "high"; + /** * Chat/AI interaction events */ @@ -66,6 +98,68 @@ export interface MessageSentPayload { mode: string; /** Message length rounded to nearest power of 2 (e.g., 128, 256, 512, 1024) */ message_length_b2: number; + /** Runtime type for the workspace */ + runtimeType: TelemetryRuntimeType; + /** Frontend platform info */ + frontendPlatform: FrontendPlatformInfo; + /** Extended thinking level */ + thinkingLevel: TelemetryThinkingLevel; +} + +/** + * Stream completion event - tracks when AI responses finish + */ +export interface StreamCompletedPayload { + /** Model used for generation */ + model: string; + /** Whether the stream was interrupted by user vs natural completion */ + wasInterrupted: boolean; + /** Duration in seconds, rounded to nearest power of 2 */ + duration_b2: number; + /** Output tokens, rounded to nearest power of 2 */ + output_tokens_b2: number; +} + +/** + * Provider configuration event - tracks when users set up providers + * Note: Only tracks that a key was set, never the actual value + */ +export interface ProviderConfiguredPayload { + /** Provider name (e.g., 'anthropic', 'openai', 'mux-gateway') */ + provider: string; + /** Key type that was configured (e.g., 'apiKey', 'couponCode', 'baseUrl') */ + keyType: string; +} + +/** + * Slash command types for telemetry (no arguments/values) + */ +export type TelemetryCommandType = + | "clear" + | "compact" + | "new" + | "fork" + | "vim" + | "model" + | "mode" + | "providers"; + +/** + * Command usage event - tracks slash command usage patterns + */ +export interface CommandUsedPayload { + /** Command type (without arguments for privacy) */ + command: TelemetryCommandType; +} + +/** + * Voice transcription event - tracks voice input usage + */ +export interface VoiceTranscriptionPayload { + /** Duration of audio in seconds, rounded to nearest power of 2 */ + audio_duration_b2: number; + /** Whether the transcription succeeded */ + success: boolean; } /** @@ -100,4 +194,8 @@ export type TelemetryEventPayload = | { event: "workspace_created"; properties: WorkspaceCreatedPayload } | { event: "workspace_switched"; properties: WorkspaceSwitchedPayload } | { event: "message_sent"; properties: MessageSentPayload } + | { event: "stream_completed"; properties: StreamCompletedPayload } + | { event: "provider_configured"; properties: ProviderConfiguredPayload } + | { event: "command_used"; properties: CommandUsedPayload } + | { event: "voice_transcription"; properties: VoiceTranscriptionPayload } | { event: "error_occurred"; properties: ErrorOccurredPayload }; diff --git a/src/common/telemetry/tracking.ts b/src/common/telemetry/tracking.ts new file mode 100644 index 0000000000..162cdb139a --- /dev/null +++ b/src/common/telemetry/tracking.ts @@ -0,0 +1,162 @@ +/** + * Telemetry tracking functions + * + * These functions provide a clean API for tracking telemetry events. + * Callers pass raw values; rounding and formatting happen internally. + * This ensures consistent privacy-preserving transformations. + */ + +import { trackEvent } from "./client"; +import { roundToBase2 } from "./utils"; +import type { + TelemetryRuntimeType, + TelemetryThinkingLevel, + TelemetryCommandType, + FrontendPlatformInfo, +} from "./payload"; + +/** + * Get frontend platform information for telemetry. + * Uses browser APIs (navigator) which are safe to send and widely shared. + */ +function getFrontendPlatform(): FrontendPlatformInfo { + if (typeof navigator === "undefined") { + return { userAgent: "unknown", platform: "unknown" }; + } + return { + userAgent: navigator.userAgent, + platform: navigator.platform, + }; +} + +// ============================================================================= +// Tracking Functions +// ============================================================================= + +/** + * Track workspace creation + */ +export function trackWorkspaceCreated( + workspaceId: string, + runtimeType: TelemetryRuntimeType +): void { + trackEvent({ + event: "workspace_created", + properties: { + workspaceId, + runtimeType, + frontendPlatform: getFrontendPlatform(), + }, + }); +} + +/** + * Track workspace switch + */ +export function trackWorkspaceSwitched(fromWorkspaceId: string, toWorkspaceId: string): void { + trackEvent({ + event: "workspace_switched", + properties: { fromWorkspaceId, toWorkspaceId }, + }); +} + +/** + * Track message sent + * @param messageLength - Raw character count (will be rounded to base-2) + */ +export function trackMessageSent( + model: string, + mode: string, + messageLength: number, + runtimeType: TelemetryRuntimeType, + thinkingLevel: TelemetryThinkingLevel +): void { + trackEvent({ + event: "message_sent", + properties: { + model, + mode, + message_length_b2: roundToBase2(messageLength), + runtimeType, + frontendPlatform: getFrontendPlatform(), + thinkingLevel, + }, + }); +} + +/** + * Track stream completion + * @param durationSecs - Raw duration in seconds (will be rounded to base-2) + * @param outputTokens - Raw token count (will be rounded to base-2) + */ +export function trackStreamCompleted( + model: string, + wasInterrupted: boolean, + durationSecs: number, + outputTokens: number +): void { + trackEvent({ + event: "stream_completed", + properties: { + model, + wasInterrupted, + duration_b2: roundToBase2(durationSecs), + output_tokens_b2: roundToBase2(outputTokens), + }, + }); +} + +/** + * Track provider configuration (not the key value, just that it was configured) + */ +export function trackProviderConfigured(provider: string, keyType: string): void { + trackEvent({ + event: "provider_configured", + properties: { provider, keyType }, + }); +} + +/** + * Track slash command usage + */ +export function trackCommandUsed(command: TelemetryCommandType): void { + trackEvent({ + event: "command_used", + properties: { command }, + }); +} + +/** + * Track voice transcription + * @param audioDurationSecs - Raw duration in seconds (will be rounded to base-2) + */ +export function trackVoiceTranscription(audioDurationSecs: number, success: boolean): void { + trackEvent({ + event: "voice_transcription", + properties: { + audio_duration_b2: roundToBase2(audioDurationSecs), + success, + }, + }); +} + +/** + * Track error occurrence + */ +export function trackErrorOccurred( + errorType: string, + context: + | "workspace-creation" + | "workspace-deletion" + | "workspace-switch" + | "message-send" + | "message-stream" + | "project-add" + | "project-remove" + | "git-operation" +): void { + trackEvent({ + event: "error_occurred", + properties: { errorType, context }, + }); +} diff --git a/src/common/telemetry/utils.ts b/src/common/telemetry/utils.ts index 9534655fb9..9d3c92a21a 100644 --- a/src/common/telemetry/utils.ts +++ b/src/common/telemetry/utils.ts @@ -2,6 +2,9 @@ * Telemetry utility functions */ +import type { RuntimeConfig } from "@/common/types/runtime"; +import type { FrontendPlatformInfo, TelemetryRuntimeType } from "./payload"; + /** * Round a number to the nearest power of 2 for privacy-preserving metrics * E.g., 350 -> 512, 1200 -> 2048 @@ -13,3 +16,54 @@ export function roundToBase2(value: number): number { // Find the next power of 2 return Math.pow(2, Math.ceil(Math.log2(value))); } + +/** + * Get frontend platform information for telemetry. + * Uses browser APIs (navigator) which are safe to send and widely shared. + */ +export function getFrontendPlatformInfo(): FrontendPlatformInfo { + // Safe defaults for non-browser environments (SSR, tests) + if (typeof navigator === "undefined") { + return { + userAgent: "unknown", + platform: "unknown", + }; + } + + return { + userAgent: navigator.userAgent, + platform: navigator.platform, + }; +} + +/** + * Convert RuntimeConfig to telemetry-friendly runtime type. + * Handles legacy "local with srcBaseDir" as worktree. + */ +export function getRuntimeTypeForTelemetry( + runtimeConfig: RuntimeConfig | undefined +): TelemetryRuntimeType { + if (!runtimeConfig) { + // Default is worktree mode + return "worktree"; + } + + if (runtimeConfig.type === "ssh") { + return "ssh"; + } + + if (runtimeConfig.type === "worktree") { + return "worktree"; + } + + // "local" type - check if it has srcBaseDir (legacy worktree) + if (runtimeConfig.type === "local") { + if ("srcBaseDir" in runtimeConfig && runtimeConfig.srcBaseDir) { + return "worktree"; // Legacy worktree config + } + return "local"; // True project-dir local + } + + // Fallback + return "worktree"; +} diff --git a/src/node/services/telemetryService.ts b/src/node/services/telemetryService.ts index c4da3a637f..98f0446c7e 100644 --- a/src/node/services/telemetryService.ts +++ b/src/node/services/telemetryService.ts @@ -122,8 +122,10 @@ export class TelemetryService { private getBaseProperties(): BaseTelemetryProperties { return { version: getVersionString(), - platform: process.platform, + backend_platform: process.platform, electronVersion: process.versions.electron ?? "unknown", + nodeVersion: process.versions.node ?? "unknown", + bunVersion: process.versions.bun ?? "unknown", }; }