Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/system-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,4 @@ You are in a git worktree at ${workspacePath}
}
```


{/* END SYSTEM_PROMPT_DOCS */}
6 changes: 5 additions & 1 deletion src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -640,7 +641,10 @@ function AppInner() {
});

// Track telemetry
telemetry.workspaceCreated(metadata.id);
telemetry.workspaceCreated(
metadata.id,
getRuntimeTypeForTelemetry(metadata.runtimeConfig)
);

// Clear pending state
clearPendingWorkspaceCreation();
Expand Down
2 changes: 2 additions & 0 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -620,6 +621,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
<ChatInput
variant="workspace"
workspaceId={workspaceId}
runtimeType={getRuntimeTypeForTelemetry(runtimeConfig)}
onMessageSent={handleMessageSent}
onTruncateHistory={handleClearHistory}
onProviderConfig={handleProviderConfig}
Expand Down
10 changes: 9 additions & 1 deletion src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ export const ChatInput: React.FC<ChatInputProps> = (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 = (() => {
Expand Down Expand Up @@ -1015,7 +1017,13 @@ export const ChatInput: React.FC<ChatInputProps> = (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) {
Expand Down
3 changes: 3 additions & 0 deletions src/browser/components/ChatInput/types.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<void>;
onProviderConfig?: (provider: string, keyPath: string[], value: string) => Promise<void>;
Expand Down
109 changes: 62 additions & 47 deletions src/browser/hooks/useTelemetry.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
20 changes: 19 additions & 1 deletion src/browser/hooks/useVoiceInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<number>(0);

// Keep callbacks fresh without recreating functions
const callbacksRef = useRef(options);
useEffect(() => {
Expand All @@ -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();
Expand All @@ -144,18 +151,27 @@ export function useVoiceInput(options: UseVoiceInputOptions): UseVoiceInputResul
const api = callbacksRef.current.api;
if (!api) {
callbacksRef.current.onError?.("Voice API not available");
trackVoiceTranscription(audioDurationSecs, false);
return;
}

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

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);

Expand All @@ -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");
}
Expand Down Expand Up @@ -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);
Expand Down
55 changes: 49 additions & 6 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ 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";
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")
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
Loading