Skip to content

Commit 66992a2

Browse files
authored
🤖 feat: enhance telemetry with runtime, platform, and usage tracking (#932)
## Summary Add comprehensive telemetry enhancements for better product insights. ### Runtime & Platform Data - Add `runtimeType` (local/worktree/ssh) to `workspace_created` and `message_sent` - Add frontend platform info (userAgent, platform) for `mux server` mode - Enhance backend platform data with `nodeVersion` and `bunVersion` ### New Events | Event | Purpose | Fields | |-------|---------|--------| | `stream_completed` | Track completion vs interruption | model, wasInterrupted, duration_b2, output_tokens_b2 | | `provider_configured` | Track provider setup (not keys!) | provider, keyType | | `command_used` | Track slash command usage | command type | | `voice_transcription` | Track voice input adoption | audio_duration_b2, success | ### Enhanced Events | Event | Enhancement | |-------|-------------| | `app_started` | Added `vimModeEnabled` | | `message_sent` | Added `thinkingLevel` | ### Privacy All metrics use base-2 rounding for privacy-preserving numerical data. No sensitive information (SSH hosts, API keys, project names, etc.) is ever sent. --- _Generated with `mux`_
1 parent f14c08d commit 66992a2

File tree

16 files changed

+590
-69
lines changed

16 files changed

+590
-69
lines changed

‎docs/system-prompt.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,4 @@ You are in a git worktree at ${workspacePath}
6262
}
6363
```
6464

65-
6665
{/* END SYSTEM_PROMPT_DOCS */}

‎src/browser/App.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents";
3333
import { getThinkingLevelKey } from "@/common/constants/storage";
3434
import type { BranchListResult } from "@/common/orpc/types";
3535
import { useTelemetry } from "./hooks/useTelemetry";
36+
import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
3637
import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation";
3738
import { useAPI } from "@/browser/contexts/API";
3839
import { AuthTokenModal } from "@/browser/components/AuthTokenModal";
@@ -640,7 +641,10 @@ function AppInner() {
640641
});
641642

642643
// Track telemetry
643-
telemetry.workspaceCreated(metadata.id);
644+
telemetry.workspaceCreated(
645+
metadata.id,
646+
getRuntimeTypeForTelemetry(metadata.runtimeConfig)
647+
);
644648

645649
// Clear pending state
646650
clearPendingWorkspaceCreation();

‎src/browser/components/AIView.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { WorkspaceHeader } from "./WorkspaceHeader";
3333
import { getModelName } from "@/common/utils/ai/models";
3434
import type { DisplayedMessage } from "@/common/types/message";
3535
import type { RuntimeConfig } from "@/common/types/runtime";
36+
import { getRuntimeTypeForTelemetry } from "@/common/telemetry";
3637
import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds";
3738
import { evictModelFromLRU } from "@/browser/hooks/useModelLRU";
3839
import { QueuedMessage } from "./Messages/QueuedMessage";
@@ -620,6 +621,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
620621
<ChatInput
621622
variant="workspace"
622623
workspaceId={workspaceId}
624+
runtimeType={getRuntimeTypeForTelemetry(runtimeConfig)}
623625
onMessageSent={handleMessageSent}
624626
onTruncateHistory={handleClearHistory}
625627
onProviderConfig={handleProviderConfig}

‎src/browser/components/ChatInput/index.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
119119
const editingMessage = variant === "workspace" ? props.editingMessage : undefined;
120120
const isCompacting = variant === "workspace" ? (props.isCompacting ?? false) : false;
121121
const canInterrupt = variant === "workspace" ? (props.canInterrupt ?? false) : false;
122+
// runtimeType for telemetry - defaults to "worktree" if not provided
123+
const runtimeType = variant === "workspace" ? (props.runtimeType ?? "worktree") : "worktree";
122124

123125
// Storage keys differ by variant
124126
const storageKeys = (() => {
@@ -1015,7 +1017,13 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10151017
setImageAttachments(previousImageAttachments);
10161018
} else {
10171019
// Track telemetry for successful message send
1018-
telemetry.messageSent(sendMessageOptions.model, mode, actualMessageText.length);
1020+
telemetry.messageSent(
1021+
sendMessageOptions.model,
1022+
mode,
1023+
actualMessageText.length,
1024+
runtimeType,
1025+
sendMessageOptions.thinkingLevel ?? "off"
1026+
);
10191027

10201028
// Exit editing mode if we were editing
10211029
if (editingMessage && props.onCancelEdit) {

‎src/browser/components/ChatInput/types.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ImagePart } from "@/common/orpc/types";
22
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
3+
import type { TelemetryRuntimeType } from "@/common/telemetry/payload";
34
import type { AutoCompactionCheckResult } from "@/browser/utils/compaction/autoCompactionCheck";
45

56
export interface ChatInputAPI {
@@ -14,6 +15,8 @@ export interface ChatInputAPI {
1415
export interface ChatInputWorkspaceVariant {
1516
variant: "workspace";
1617
workspaceId: string;
18+
/** Runtime type for the workspace (for telemetry) - no sensitive details like SSH host */
19+
runtimeType?: TelemetryRuntimeType;
1720
onMessageSent?: () => void;
1821
onTruncateHistory: (percentage?: number) => Promise<void>;
1922
onProviderConfig?: (provider: string, keyPath: string[], value: string) => Promise<void>;
Lines changed: 62 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,95 @@
11
import { useCallback } from "react";
2-
import { trackEvent, roundToBase2 } from "@/common/telemetry";
3-
import type { ErrorContext } from "@/common/telemetry/payload";
2+
import {
3+
trackWorkspaceCreated,
4+
trackWorkspaceSwitched,
5+
trackMessageSent,
6+
trackStreamCompleted,
7+
trackProviderConfigured,
8+
trackCommandUsed,
9+
trackVoiceTranscription,
10+
trackErrorOccurred,
11+
} from "@/common/telemetry";
12+
import type {
13+
ErrorContext,
14+
TelemetryRuntimeType,
15+
TelemetryThinkingLevel,
16+
TelemetryCommandType,
17+
} from "@/common/telemetry/payload";
418

519
/**
620
* Hook for clean telemetry integration in React components
721
*
8-
* Provides type-safe telemetry tracking. Base properties (version, platform, etc.)
9-
* are automatically added by the backend TelemetryService.
22+
* Provides stable callback references for telemetry tracking.
23+
* All numeric values are automatically rounded for privacy.
1024
*
1125
* Usage:
1226
*
1327
* ```tsx
1428
* const telemetry = useTelemetry();
1529
*
16-
* // Track workspace switch
1730
* telemetry.workspaceSwitched(fromId, toId);
18-
*
19-
* // Track workspace creation
20-
* telemetry.workspaceCreated(workspaceId);
21-
*
22-
* // Track message sent
23-
* telemetry.messageSent(model, mode, messageLength);
24-
*
25-
* // Track error
31+
* telemetry.workspaceCreated(workspaceId, runtimeType);
32+
* telemetry.messageSent(model, mode, messageLength, runtimeType, thinkingLevel);
33+
* telemetry.streamCompleted(model, wasInterrupted, durationSecs, outputTokens);
34+
* telemetry.providerConfigured(provider, keyType);
35+
* telemetry.commandUsed(commandType);
36+
* telemetry.voiceTranscription(audioDurationSecs, success);
2637
* telemetry.errorOccurred(errorType, context);
2738
* ```
2839
*/
2940
export function useTelemetry() {
3041
const workspaceSwitched = useCallback((fromWorkspaceId: string, toWorkspaceId: string) => {
31-
console.debug("[useTelemetry] workspaceSwitched called", { fromWorkspaceId, toWorkspaceId });
32-
trackEvent({
33-
event: "workspace_switched",
34-
properties: {
35-
fromWorkspaceId,
36-
toWorkspaceId,
37-
},
38-
});
42+
trackWorkspaceSwitched(fromWorkspaceId, toWorkspaceId);
43+
}, []);
44+
45+
const workspaceCreated = useCallback((workspaceId: string, runtimeType: TelemetryRuntimeType) => {
46+
trackWorkspaceCreated(workspaceId, runtimeType);
47+
}, []);
48+
49+
const messageSent = useCallback(
50+
(
51+
model: string,
52+
mode: string,
53+
messageLength: number,
54+
runtimeType: TelemetryRuntimeType,
55+
thinkingLevel: TelemetryThinkingLevel
56+
) => {
57+
trackMessageSent(model, mode, messageLength, runtimeType, thinkingLevel);
58+
},
59+
[]
60+
);
61+
62+
const streamCompleted = useCallback(
63+
(model: string, wasInterrupted: boolean, durationSecs: number, outputTokens: number) => {
64+
trackStreamCompleted(model, wasInterrupted, durationSecs, outputTokens);
65+
},
66+
[]
67+
);
68+
69+
const providerConfigured = useCallback((provider: string, keyType: string) => {
70+
trackProviderConfigured(provider, keyType);
3971
}, []);
4072

41-
const workspaceCreated = useCallback((workspaceId: string) => {
42-
console.debug("[useTelemetry] workspaceCreated called", { workspaceId });
43-
trackEvent({
44-
event: "workspace_created",
45-
properties: {
46-
workspaceId,
47-
},
48-
});
73+
const commandUsed = useCallback((command: TelemetryCommandType) => {
74+
trackCommandUsed(command);
4975
}, []);
5076

51-
const messageSent = useCallback((model: string, mode: string, messageLength: number) => {
52-
console.debug("[useTelemetry] messageSent called", { model, mode, messageLength });
53-
trackEvent({
54-
event: "message_sent",
55-
properties: {
56-
model,
57-
mode,
58-
message_length_b2: roundToBase2(messageLength),
59-
},
60-
});
77+
const voiceTranscription = useCallback((audioDurationSecs: number, success: boolean) => {
78+
trackVoiceTranscription(audioDurationSecs, success);
6179
}, []);
6280

6381
const errorOccurred = useCallback((errorType: string, context: ErrorContext) => {
64-
console.debug("[useTelemetry] errorOccurred called", { errorType, context });
65-
trackEvent({
66-
event: "error_occurred",
67-
properties: {
68-
errorType,
69-
context,
70-
},
71-
});
82+
trackErrorOccurred(errorType, context);
7283
}, []);
7384

7485
return {
7586
workspaceSwitched,
7687
workspaceCreated,
7788
messageSent,
89+
streamCompleted,
90+
providerConfigured,
91+
commandUsed,
92+
voiceTranscription,
7893
errorOccurred,
7994
};
8095
}

‎src/browser/hooks/useVoiceInput.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { useState, useCallback, useRef, useEffect } from "react";
1010
import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
1111
import type { APIClient } from "@/browser/contexts/API";
12+
import { trackVoiceTranscription } from "@/common/telemetry";
1213

1314
export type VoiceInputState = "idle" | "recording" | "transcribing";
1415

@@ -117,6 +118,9 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
117118
const shouldSendRef = useRef(false);
118119
const wasCancelledRef = useRef(false);
119120

121+
// Track recording start time for duration telemetry
122+
const recordingStartTimeRef = useRef<number>(0);
123+
120124
// Keep callbacks fresh without recreating functions
121125
const callbacksRef = useRef(options);
122126
useEffect(() => {
@@ -134,6 +138,9 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
134138
const shouldSend = shouldSendRef.current;
135139
shouldSendRef.current = false;
136140

141+
// Calculate recording duration for telemetry
142+
const audioDurationSecs = (Date.now() - recordingStartTimeRef.current) / 1000;
143+
137144
try {
138145
// Encode audio as base64 for IPC transport
139146
const buffer = await audioBlob.arrayBuffer();
@@ -144,18 +151,27 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
144151
const api = callbacksRef.current.api;
145152
if (!api) {
146153
callbacksRef.current.onError?.("Voice API not available");
154+
trackVoiceTranscription(audioDurationSecs, false);
147155
return;
148156
}
149157

150158
const result = await api.voice.transcribe({ audioBase64: base64 });
151159

152160
if (!result.success) {
153161
callbacksRef.current.onError?.(result.error);
162+
trackVoiceTranscription(audioDurationSecs, false);
154163
return;
155164
}
156165

157166
const text = result.data.trim();
158-
if (!text) return; // Empty transcription, nothing to do
167+
if (!text) {
168+
// Track empty transcription as success (API worked, just no speech)
169+
trackVoiceTranscription(audioDurationSecs, true);
170+
return;
171+
}
172+
173+
// Track successful transcription
174+
trackVoiceTranscription(audioDurationSecs, true);
159175

160176
callbacksRef.current.onTranscript(text);
161177

@@ -166,6 +182,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
166182
} catch (err) {
167183
const msg = err instanceof Error ? err.message : String(err);
168184
callbacksRef.current.onError?.(`Transcription failed: ${msg}`);
185+
trackVoiceTranscription(audioDurationSecs, false);
169186
} finally {
170187
setState("idle");
171188
}
@@ -235,6 +252,7 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
235252
recorderRef.current = recorder;
236253
setMediaRecorder(recorder);
237254
recorder.start();
255+
recordingStartTimeRef.current = Date.now();
238256
setState("recording");
239257
} catch (err) {
240258
const msg = err instanceof Error ? err.message : String(err);

‎src/browser/stores/WorkspaceStore.ts‎

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import {
1818
isQueuedMessageChanged,
1919
isRestoreToInput,
2020
} from "@/common/orpc/types";
21+
import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream";
2122
import { MapStore } from "./MapStore";
2223
import { collectUsageHistory, createDisplayUsage } from "@/common/utils/tokens/displayUsage";
2324
import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager";
2425
import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator";
2526
import type { TokenConsumer } from "@/common/types/chatStats";
2627
import type { LanguageModelV2Usage } from "@ai-sdk/provider";
2728
import { createFreshRetryState } from "@/browser/utils/messages/retryState";
29+
import { trackStreamCompleted } from "@/common/telemetry";
2830

2931
export interface WorkspaceState {
3032
name: string; // User-facing workspace name (e.g., "feature-branch")
@@ -155,23 +157,43 @@ export class WorkspaceStore {
155157
this.states.bump(workspaceId);
156158
},
157159
"stream-end": (workspaceId, aggregator, data) => {
158-
aggregator.handleStreamEnd(data as never);
159-
aggregator.clearTokenState((data as { messageId: string }).messageId);
160+
const streamEndData = data as StreamEndEvent;
161+
aggregator.handleStreamEnd(streamEndData as never);
162+
aggregator.clearTokenState(streamEndData.messageId);
163+
164+
// Track stream completion telemetry
165+
this.trackStreamCompletedTelemetry(streamEndData, false);
160166

161167
// Reset retry state on successful stream completion
162168
updatePersistedState(getRetryStateKey(workspaceId), createFreshRetryState());
163169

164170
this.states.bump(workspaceId);
165171
this.checkAndBumpRecencyIfChanged();
166-
this.finalizeUsageStats(workspaceId, (data as { metadata?: never }).metadata);
172+
this.finalizeUsageStats(workspaceId, streamEndData.metadata);
167173
},
168174
"stream-abort": (workspaceId, aggregator, data) => {
169-
aggregator.clearTokenState((data as { messageId: string }).messageId);
170-
aggregator.handleStreamAbort(data as never);
175+
const streamAbortData = data as StreamAbortEvent;
176+
aggregator.clearTokenState(streamAbortData.messageId);
177+
aggregator.handleStreamAbort(streamAbortData as never);
178+
179+
// Track stream interruption telemetry (get model from aggregator)
180+
const model = aggregator.getCurrentModel();
181+
if (model) {
182+
this.trackStreamCompletedTelemetry(
183+
{
184+
metadata: {
185+
model,
186+
usage: streamAbortData.metadata?.usage,
187+
duration: streamAbortData.metadata?.duration,
188+
},
189+
},
190+
true
191+
);
192+
}
171193

172194
this.states.bump(workspaceId);
173195
this.dispatchResumeCheck(workspaceId);
174-
this.finalizeUsageStats(workspaceId, (data as { metadata?: never }).metadata);
196+
this.finalizeUsageStats(workspaceId, streamAbortData.metadata);
175197
},
176198
"tool-call-start": (workspaceId, aggregator, data) => {
177199
aggregator.handleToolCallStart(data as never);
@@ -282,6 +304,27 @@ export class WorkspaceStore {
282304
window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { workspaceId }));
283305
}
284306

307+
/**
308+
* Track stream completion telemetry
309+
*/
310+
private trackStreamCompletedTelemetry(
311+
data: {
312+
metadata: {
313+
model: string;
314+
usage?: { outputTokens?: number };
315+
duration?: number;
316+
};
317+
},
318+
wasInterrupted: boolean
319+
): void {
320+
const { metadata } = data;
321+
const durationSecs = metadata.duration ? metadata.duration / 1000 : 0;
322+
const outputTokens = metadata.usage?.outputTokens ?? 0;
323+
324+
// trackStreamCompleted handles rounding internally
325+
trackStreamCompleted(metadata.model, wasInterrupted, durationSecs, outputTokens);
326+
}
327+
285328
/**
286329
* Check if any workspace's recency changed and bump global recency if so.
287330
* Uses cached recency values from aggregators for O(1) comparison per workspace.

0 commit comments

Comments
 (0)