Files
cline/apps/vscode/src/services/telemetry/TelemetryService.ts
T

2574 lines
81 KiB
TypeScript

import { HostProvider } from "@hosts/host-provider"
import type { BrowserSettings } from "@shared/BrowserSettings"
import { ApiFormat, apiFormatToJSON } from "@shared/proto/cline/models"
import { ShowMessageType } from "@shared/proto/host/window"
import type { TaskFeedbackType } from "@shared/WebviewMessage"
import * as os from "os"
import { ClineAccountUserInfo } from "@/services/auth/AuthService"
import { Setting } from "@/shared/proto/index.host"
import { Logger } from "@/shared/services/Logger"
import { Mode } from "@/shared/storage/types"
import { version as extensionVersion } from "../../../package.json"
import { setDistinctId } from "../logging/distinctId"
import type { ITelemetryProvider, TelemetryProperties } from "./providers/ITelemetryProvider"
import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
/**
* Represents telemetry event categories that can be individually enabled or disabled
* When adding a new category, add it both here and to the initial values in telemetryCategoryEnabled
* Ensure `if (!this.isCategoryEnabled('<category_name>')` is added to the capture method
*/
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain" | "subagents" | "skills" | "hooks"
/**
* Terminal type for telemetry differentiation
*/
export type TerminalType = "vscode" | "standalone"
/**
* VSCode-specific output capture methods
*/
export type VscodeOutputMethod = "shell_integration" | "clipboard" | "none"
/**
* Standalone-specific output capture methods
*/
export type StandaloneOutputMethod = "child_process" | "child_process_error"
/**
* Combined type for terminal output methods
*/
export type TerminalOutputMethod = VscodeOutputMethod | StandaloneOutputMethod
/**
* Enum for terminal output failure reasons
*/
export enum TerminalOutputFailureReason {
TIMEOUT = "timeout",
NO_SHELL_INTEGRATION = "no_shell_integration",
}
/**
* Enum for terminal user intervention actions
*/
export enum TerminalUserInterventionAction {
PROCESS_WHILE_RUNNING = "process_while_running",
CANCELLED = "cancelled",
}
/**
* Enum for terminal hang stages
*/
export enum TerminalHangStage {
WAITING_FOR_COMPLETION = "waiting_for_completion",
BUFFER_STUCK = "buffer_stuck",
}
export type TelemetryMetadata = {
/**
* The extension or cline-core version. JetBrains and CLI have different
* versioning than the VSCode Extension, but on those platforms this will be the _cline-core version_
* which uses the same as the versioning as the VSCode extension.
*/
extension_version: string
/**
* The type of cline distribution, e.g VSCode Extension, JetBrains Plugin or CLI. This
* is different than the `platform` because there are many variants of VSCode and JetBrains but they
* all use the same extension or plugin.
*/
cline_type: string
/** The name of the host IDE or environment e.g. VSCode, Cursor, IntelliJ Professional Edition, etc. */
platform: string
/** The version of the host environment */
platform_version: string
/** The operating system type, e.g. darwin, win32. This is the value returned by os.platform() */
os_type: string
/** The operating system version e.g. 'Windows 10 Pro', 'Darwin Kernel Version 21.6.0...'
* This is the value returned by os.version() */
os_version: string
/** Whether the current workspace is a VS Code remote workspace */
is_remote_workspace: boolean
/** Whether the extension is running in development mode */
is_dev: string | undefined
}
/**
* Token usage data shared across telemetry capture methods.
* Used by both `captureTokenUsage` and `captureConversationTurnEvent`.
*/
export interface TokenUsage {
tokensIn?: number
tokensOut?: number
cacheWriteTokens?: number
cacheReadTokens?: number
totalCost?: number
}
/**
* Maximum length for error messages to prevent excessive data
*/
const MAX_ERROR_MESSAGE_LENGTH = 500
/**
* TelemetryService handles telemetry event tracking for the Cline extension
* Uses an abstracted telemetry provider to support multiple analytics backends
* Respects user privacy settings and VSCode's global telemetry configuration
*/
export class TelemetryService {
// Map to control specific telemetry categories (event types)
private telemetryCategoryEnabled: Map<TelemetryCategory, boolean> = new Map([
["checkpoints", true], // Checkpoints telemetry enabled
["browser", true], // Browser telemetry enabled
["focus_chain", true], // Focus Chain telemetry enabled
["subagents", true], // CLI Subagents telemetry enabled
["skills", true], // Skills telemetry enabled
["hooks", true], // Hooks telemetry enabled
])
private userId?: string
private activeOrg: {
organization_id: string
organization_name: string
member_id: string
} | null = null
private taskTurnCounts = new Map<string, number>()
private taskToolCallCounts = new Map<string, number>()
private taskErrorCounts = new Map<string, number>()
public static readonly METRICS = {
TASK: {
TURNS_TOTAL: "cline.turns.total",
TURNS_PER_TASK: "cline.turns.per_task",
TOKENS_INPUT_TOTAL: "cline.tokens.input.total",
TOKENS_INPUT_PER_RESPONSE: "cline.tokens.input.per_response",
TOKENS_OUTPUT_TOTAL: "cline.tokens.output.total",
TOKENS_OUTPUT_PER_RESPONSE: "cline.tokens.output.per_response",
COST_TOTAL: "cline.cost.total",
COST_PER_EVENT: "cline.cost.per_event",
},
CACHE: {
WRITE_TOTAL: "cline.cache.write.tokens.total",
WRITE_PER_EVENT: "cline.cache.write.tokens.per_event",
READ_TOTAL: "cline.cache.read.tokens.total",
READ_PER_EVENT: "cline.cache.read.tokens.per_event",
HITS_TOTAL: "cline.cache.hits.total",
},
TOOLS: {
CALLS_TOTAL: "cline.tool.calls.total",
CALLS_PER_TASK: "cline.tool.calls.per_task",
},
ERRORS: {
TOTAL: "cline.errors.total",
PER_TASK: "cline.errors.per_task",
},
API: {
TTFT_SECONDS: "cline.api.ttft.seconds",
DURATION_SECONDS: "cline.api.duration.seconds",
THROUGHPUT_TOKENS_PER_SECOND: "cline.api.throughput.tokens_per_second",
},
HOOKS: {
EXECUTIONS_TOTAL: "cline.hooks.executions.total",
DURATION_SECONDS: "cline.hooks.duration.seconds",
FAILURES_TOTAL: "cline.hooks.failures.total",
CANCELLATIONS_TOTAL: "cline.hooks.cancellations.total",
CONTEXT_MODIFICATIONS_TOTAL: "cline.hooks.context_modifications.total",
CACHE_ACCESSES_TOTAL: "cline.hooks.cache.accesses.total",
},
AI_OUTPUT: {
ACCEPTED_LINES_ADDED: "cline.ai_output.accepted.lines_added.total",
ACCEPTED_LINES_DELETED: "cline.ai_output.accepted.lines_deleted.total",
ACCEPTED_LINES_CHANGED: "cline.ai_output.accepted.lines_changed.total",
ACCEPTED_FILES_CREATED: "cline.ai_output.accepted.files_created.total",
ACCEPTED_FILES_DELETED: "cline.ai_output.accepted.files_deleted.total",
ACCEPTED_FILES_MOVED: "cline.ai_output.accepted.files_moved.total",
REJECTED_LINES_ADDED: "cline.ai_output.rejected.lines_added.total",
REJECTED_LINES_DELETED: "cline.ai_output.rejected.lines_deleted.total",
REJECTED_LINES_CHANGED: "cline.ai_output.rejected.lines_changed.total",
REJECTED_FILES_CREATED: "cline.ai_output.rejected.files_created.total",
REJECTED_FILES_DELETED: "cline.ai_output.rejected.files_deleted.total",
REJECTED_FILES_MOVED: "cline.ai_output.rejected.files_moved.total",
},
GRPC: {
RESPONSE_SIZE_BYTES: "cline.grpc.response.size_bytes",
},
MIGRATION: {
// Fires whenever Cline checks an old pre-SDK task and decides whether/how to migrate it.
LEGACY_TASK_ATTEMPTS_TOTAL: "cline.migration.legacy_task.attempts.total",
// Fires when the user opens an old task and Cline successfully copies it into SDK session storage.
LEGACY_TASK_SUCCESS_TOTAL: "cline.migration.legacy_task.success.total",
// Fires when Cline tried to migrate an old task but failed while building or writing the SDK session.
LEGACY_TASK_FAILURES_TOTAL: "cline.migration.legacy_task.failures.total",
// Fires when no migration happens because it is unnecessary or impossible, e.g. already migrated or missing old messages.
LEGACY_TASK_SKIPPED_TOTAL: "cline.migration.legacy_task.skipped.total",
// Fires for every migration decision; measures how long the check/migration took.
LEGACY_TASK_DURATION_SECONDS: "cline.migration.legacy_task.duration.seconds",
// Fires when Cline finds old conversation messages; records how many old messages were found.
LEGACY_TASK_LEGACY_MESSAGES_COUNT: "cline.migration.legacy_task.legacy_messages.count",
// Fires after conversion; records how many messages made it into SDK-compatible form.
LEGACY_TASK_CONVERTED_MESSAGES_COUNT: "cline.migration.legacy_task.converted_messages.count",
// Fires when history is listed; counts old pre-SDK tasks still waiting to be migrated.
LEGACY_TASK_PENDING_COUNT: "cline.migration.legacy_task.pending.count",
// Fires when history is listed; counts old tasks that already made it safely into SDK session storage.
LEGACY_TASK_MIGRATED_COUNT: "cline.migration.legacy_task.migrated.count",
},
}
// Event constants for tracking user interactions and system events
private static readonly EVENTS = {
// Task-related events for tracking conversation and execution flow
USER: {
OPT_OUT: "user.opt_out",
OPT_IN: "user.opt_in",
TELEMETRY_ENABLED: "user.telemetry_enabled",
EXTENSION_ACTIVATED: "user.extension_activated",
EXTENSION_STORAGE_ERROR: "user.extension_storage_error",
AUTH_STARTED: "user.auth_started",
AUTH_SUCCEEDED: "user.auth_succeeded",
AUTH_FAILED: "user.auth_failed",
AUTH_LOGGED_OUT: "user.auth_logged_out",
ONBOARDING_PROGRESS: "user.onboarding_progress",
},
// Workspace-related events for multi-root support
WORKSPACE: {
// Track workspace initialization
INITIALIZED: "workspace.initialized",
// Track initialization errors
INIT_ERROR: "workspace.init_error",
// Track VCS detection
VCS_DETECTED: "workspace.vcs_detected",
// Track multi-root checkpoint operations
MULTI_ROOT_CHECKPOINT: "workspace.multi_root_checkpoint",
// Track workspace resolution
PATH_RESOLVED: "workspace.path_resolved",
},
TASK: {
// Tracks when a new task/conversation is started
CREATED: "task.created",
// Tracks when a task is reopened
RESTARTED: "task.restarted",
// Tracks when a task is finished, with acceptance or rejection status
COMPLETED: "task.completed",
// Tracks user feedback on completed tasks
FEEDBACK: "task.feedback",
// Tracks when a message is sent in a conversation
CONVERSATION_TURN: "task.conversation_turn",
// Tracks token consumption for cost and usage analysis
TOKEN_USAGE: "task.tokens",
// Tracks switches between plan and act modes
MODE_SWITCH: "task.mode",
// Tracks when users select an option from AI-generated followup questions
OPTION_SELECTED: "task.option_selected",
// Tracks when users type a custom response instead of selecting an option from AI-generated followup questions
OPTIONS_IGNORED: "task.options_ignored",
// Tracks checkpoint lifecycle actions.
CHECKPOINT_USED: "task.checkpoint_used",
// Tracks when tools (like file operations, commands) are used
TOOL_USED: "task.tool_used",
// Tracks when MCP tools are used
MCP_TOOL_CALLED: "task.mcp_tool_called",
// Tracks when a historical task is loaded from storage
HISTORICAL_LOADED: "task.historical_loaded",
// Tracks legacy VS Code task history migration into SDK sessions
LEGACY_TASK_MIGRATION: "task.legacy_task_migration",
// Tracks when the retry button is clicked for failed operations
RETRY_CLICKED: "task.retry_clicked",
// Tracks when a diff edit (replace_in_file) operation fails
DIFF_EDIT_FAILED: "task.diff_edit_failed",
// Tracks when the browser tool is started
BROWSER_TOOL_START: "task.browser_tool_start",
// Tracks when the browser tool is completed
BROWSER_TOOL_END: "task.browser_tool_end",
// Tracks when browser errors occur
BROWSER_ERROR: "task.browser_error",
// Tracks Gemini API specific performance metrics
GEMINI_API_PERFORMANCE: "task.gemini_api_performance",
// Tracks when API providers return errors
PROVIDER_API_ERROR: "task.provider_api_error",
// Tracks when users enable the focus chain feature
FOCUS_CHAIN_ENABLED: "task.focus_chain_enabled",
// Tracks when users disable the focus chain feature
FOCUS_CHAIN_DISABLED: "task.focus_chain_disabled",
// Tracks when the first focus chain return is returned by the model
FOCUS_CHAIN_PROGRESS_FIRST: "task.focus_chain_progress_first",
// Tracks when subsequent focus chain list returns are returned
FOCUS_CHAIN_PROGRESS_UPDATE: "task.focus_chain_progress_update",
// Tracks the statusn of the focus chain list when the task reaches a task completion state
FOCUS_CHAIN_INCOMPLETE_ON_COMPLETION: "task.focus_chain_incomplete_on_completion",
// Tracks when users click to open the focus chain markdfown file
FOCUS_CHAIN_LIST_OPENED: "task.focus_chain_list_opened",
// Tracks when users save and write to the focus chain markdown file
FOCUS_CHAIN_LIST_WRITTEN: "task.focus_chain_list_written",
// Tracks when the context window is auto-condensed with the summarize_task tool call
AUTO_COMPACT: "task.summarize_task",
// Tracks when slash commands or workflows are activated
SLASH_COMMAND_USED: "task.slash_command_used",
// Tracks when a feature is toggled on/off
FEATURE_TOGGLED: "task.feature_toggled",
// Tracks when individual Cline rules are toggled on/off
RULE_TOGGLED: "task.rule_toggled",
// Tracks when auto condense setting is toggled on/off
AUTO_CONDENSE_TOGGLED: "task.auto_condense_toggled",
// Tracks when yolo mode setting is toggled on/off
YOLO_MODE_TOGGLED: "task.yolo_mode_toggled",
// Tracks task initialization timing
INITIALIZATION: "task.initialization",
// Terminal execution telemetry events
TERMINAL_EXECUTION: "task.terminal_execution",
TERMINAL_OUTPUT_FAILURE: "task.terminal_output_failure",
TERMINAL_USER_INTERVENTION: "task.terminal_user_intervention",
TERMINAL_HANG: "task.terminal_hang",
// Mention telemetry events
MENTION_USED: "task.mention_used",
MENTION_FAILED: "task.mention_failed",
MENTION_SEARCH_RESULTS: "task.mention_search_results",
// Multi-workspace search pattern tracking
WORKSPACE_SEARCH_PATTERN: "task.workspace_search_pattern",
// CLI Subagents telemetry events
SUBAGENT_ENABLED: "task.subagent_enabled",
SUBAGENT_DISABLED: "task.subagent_disabled",
SUBAGENT_STARTED: "task.subagent_started",
SUBAGENT_COMPLETED: "task.subagent_completed",
// Skills telemetry events
SKILL_USED: "task.skill_used",
},
// UI interaction events for tracking user engagement
UI: {
// Tracks when a different model is selected
MODEL_SELECTED: "ui.model_selected",
// Tracks when users use the "favorite" button in the model picker
MODEL_FAVORITE_TOGGLED: "ui.model_favorite_toggled",
// Tracks when a button is clicked
BUTTON_CLICKED: "ui.button_clicked",
// Tracks when the rules menu button is clicked
RULES_MENU_OPENED: "ui.rules_menu_opened",
},
// Hooks-related events for tracking hook execution
HOOKS: {
// Tracks when hooks feature is enabled
ENABLED: "hooks.enabled",
// Tracks when hooks feature is disabled
DISABLED: "hooks.disabled",
// Tracks when a hook requests task cancellation
CANCEL_REQUESTED: "hooks.cancel_requested",
// Tracks when a hook modifies context
CONTEXT_MODIFIED: "hooks.context_modified",
// Tracks when hook discovery completes
DISCOVERY_COMPLETED: "hooks.discovery_completed",
},
// Worktree-related events for tracking worktree feature usage
WORKTREE: {
// Tracks when user opens worktrees view from home page
VIEW_OPENED: "worktree.view_opened",
// Tracks when a worktree is created
CREATED: "worktree.created",
// Tracks when a worktree merge is attempted
MERGE_ATTEMPTED: "worktree.merge_attempted",
},
HOST: {
// Tracks events detected from the host environment
DETECTED: "host.detected",
},
}
public static async create(): Promise<TelemetryService> {
const providers = await TelemetryProviderFactory.createProviders()
const hostVersion = await HostProvider.env.getHostVersion({})
const metadata: TelemetryMetadata = {
extension_version: extensionVersion,
platform: hostVersion.platform || "unknown",
platform_version: hostVersion.version || "unknown",
cline_type: hostVersion.clineType || "unknown",
os_type: os.platform(),
os_version: os.version(),
// `remoteName` is normalized by the host bridge to `undefined` for local workspaces.
is_remote_workspace: !!hostVersion.remoteName,
is_dev: process.env.IS_DEV,
}
return new TelemetryService(providers, metadata)
}
/**
* Constructor that accepts multiple telemetry providers for dual tracking
* @param providers Array of telemetry providers for dual/multi tracking
*/
constructor(
private providers: ITelemetryProvider[],
private telemetryMetadata: TelemetryMetadata,
) {
this.capture({ event: TelemetryService.EVENTS.USER.TELEMETRY_ENABLED })
Logger.info(`[TelemetryService] Initialized with ${providers.length} telemetry provider(s)`)
}
public addProvider(provider: ITelemetryProvider) {
this.providers.push(provider)
}
public removeProvider(name: string) {
this.providers = this.providers.filter((p) => p.name !== name)
}
/**
* Updates the telemetry state based on user preferences and VSCode settings
* Only enables telemetry if both VSCode global telemetry is enabled and user has opted in
* @param didUserOptIn Whether the user has explicitly opted into telemetry
*/
public async updateTelemetryState(didUserOptIn: boolean): Promise<void> {
// First check global telemetry level - telemetry should only be enabled when level is "all"
// We only enable telemetry if global host telemetry is enabled
const hostSetting = await HostProvider.env.getTelemetrySettings({})
if (hostSetting.isEnabled === Setting.DISABLED) {
// Only show warning if user has opted in to Cline telemetry but host telemetry is disabled
if (didUserOptIn) {
void HostProvider.window
.showMessage({
type: ShowMessageType.WARNING,
message:
"Anonymous Cline error and usage reporting is enabled, but IDE telemetry is disabled. To enable error and usage reporting for this extension, enable telemetry in IDE settings.",
options: {
items: ["Open Settings"],
},
})
.then((response: { selectedOption?: string }) => {
if (response.selectedOption === "Open Settings") {
void HostProvider.window.openSettings({
query: "telemetry.telemetryLevel",
})
}
})
}
}
}
/**
* Captures when a user explicitly opts out of telemetry.
* Uses captureRequired to ensure the event is sent before telemetry is disabled.
* Should only be called on explicit user action, not on init/sync.
*/
public captureUserOptOut(): void {
this.captureRequired(TelemetryService.EVENTS.USER.OPT_OUT, {})
}
/**
* Captures when a user explicitly opts back into telemetry.
* Should only be called on explicit user action, not on init/sync.
*/
public captureUserOptIn(): void {
this.capture({ event: TelemetryService.EVENTS.USER.OPT_IN })
}
/**
* Captures a telemetry event if telemetry is enabled
* @param event The event to capture with its properties
*/
public capture(event: { event: string; properties?: TelemetryProperties }): void {
const propertiesWithMetadata: TelemetryProperties = {
...(event.properties || {}),
...this.telemetryMetadata,
}
this.captureToProviders(event.event, propertiesWithMetadata, false)
}
/**
* Captures a required telemetry event that bypasses user opt-out settings
* @param event The event name to capture
* @param properties Optional properties to attach to the event
*/
public captureRequired(event: string, properties?: TelemetryProperties): void {
const propertiesWithMetadata: TelemetryProperties = {
...(properties || {}),
...this.telemetryMetadata,
}
this.captureToProviders(event, propertiesWithMetadata, true)
}
/**
* Internal method to capture events to all providers with error isolation
* @param event The event name
* @param properties Event properties (must be JSON-serializable)
* @param required Whether this is a required event
*/
private captureToProviders(event: string, properties: TelemetryProperties, required: boolean): void {
this.providers.forEach((provider) => {
try {
if (required) {
provider.logRequired(event, properties)
} else {
provider.log(event, properties)
}
} catch (error) {
Logger.error(`[TelemetryService] Provider failed for event ${event}:`, error)
}
})
}
private getStandardAttributes(extra?: TelemetryProperties): TelemetryProperties {
return {
...this.telemetryMetadata,
...(this.userId ? { userId: this.userId } : {}),
...this.activeOrg,
...(extra ?? {}),
}
}
private recordCounter(
name: string,
value: number,
attributes?: TelemetryProperties,
description?: string,
required = false,
): void {
const attrs = this.getStandardAttributes(attributes)
this.providers.forEach((provider) => {
try {
provider.recordCounter(name, value, attrs, description, required)
} catch (error) {
Logger.error(`[TelemetryService] recordCounter failed: ${name}`, error)
}
})
}
private recordHistogram(
name: string,
value: number,
attributes?: TelemetryProperties,
description?: string,
required = false,
): void {
const attrs = this.getStandardAttributes(attributes)
this.providers.forEach((provider) => {
try {
provider.recordHistogram(name, value, attrs, description, required)
} catch (error) {
Logger.error(`[TelemetryService] recordHistogram failed: ${name}`, error)
}
})
}
/**
* Gauge values require explicit cleanup: callers must pass null with the same attribute set
* when the series identified by name+attributes ends to prevent stale metric entries.
*/
private recordGauge(
name: string,
value: number | null,
attributes?: TelemetryProperties,
description?: string,
required = false,
): void {
const attrs = this.getStandardAttributes(attributes)
this.providers.forEach((provider) => {
try {
provider.recordGauge(name, value, attrs, description, required)
} catch (error) {
Logger.error(`[TelemetryService] recordGauge failed: ${name}`, error)
}
})
}
private incrementTaskCounter(store: Map<string, number>, ulid: string): number {
const nextValue = (store.get(ulid) ?? 0) + 1
store.set(ulid, nextValue)
return nextValue
}
private resetTaskAggregates(ulid: string): void {
this.taskTurnCounts.delete(ulid)
this.taskToolCallCounts.delete(ulid)
this.taskErrorCounts.delete(ulid)
}
public captureExtensionActivated() {
this.capture({
event: TelemetryService.EVENTS.USER.EXTENSION_ACTIVATED,
})
}
public captureExtensionStorageError(errorMessage: string, eventName: string) {
// Truncate error message to prevent excessive data
this.capture({
event: TelemetryService.EVENTS.USER.EXTENSION_STORAGE_ERROR,
properties: {
error:
errorMessage.length > MAX_ERROR_MESSAGE_LENGTH
? errorMessage.substring(0, MAX_ERROR_MESSAGE_LENGTH) + "..."
: errorMessage,
eventName,
},
})
}
/**
* Records when authentication flow is started
* @param provider The authentication provider being used
*/
public captureAuthStarted(provider?: string) {
this.capture({
event: TelemetryService.EVENTS.USER.AUTH_STARTED,
properties: {
provider,
},
})
}
/**
* Records when authentication flow succeeds
* @param provider The authentication provider that was used
*/
public captureAuthSucceeded(provider?: string) {
this.capture({
event: TelemetryService.EVENTS.USER.AUTH_SUCCEEDED,
properties: {
provider,
},
})
}
/**
* Records when authentication flow fails
* @param provider The authentication provider that was used
*/
public captureAuthFailed(provider?: string) {
this.capture({
event: TelemetryService.EVENTS.USER.AUTH_FAILED,
properties: {
provider,
},
})
}
/**
* Records when user logs out of their account
* @param provider The authentication provider that was used
* @param reason The reason for logout (user action, cross-window sync, error, etc.)
*/
public captureAuthLoggedOut(provider?: string, reason?: string) {
this.capture({
event: TelemetryService.EVENTS.USER.AUTH_LOGGED_OUT,
properties: {
provider,
reason,
},
})
}
/**
* Identifies the accounts user
* @param userInfo The user's information
*/
public identifyAccount(userInfo: ClineAccountUserInfo) {
const propertiesWithMetadata: TelemetryProperties = {
...this.telemetryMetadata,
}
this.userId = userInfo.id
const activeOrg = userInfo.organizations?.find((org) => org.active)
if (activeOrg) {
this.activeOrg = {
organization_id: activeOrg.organizationId,
organization_name: activeOrg.name,
member_id: activeOrg.memberId,
}
} else {
this.activeOrg = null
}
// Update all providers with error isolation
this.providers.forEach((provider) => {
try {
provider.identifyUser(userInfo, propertiesWithMetadata)
} catch (error) {
Logger.error(`[TelemetryService] Provider failed for user identification:`, error)
}
})
if (userInfo.id) {
setDistinctId(userInfo.id)
}
}
// Task events
/**
* Records when a new task/conversation is started
* @param ulid Unique identifier for the new task
* @param apiProvider Optional API provider
* @param openAiCompatibleDomain Optional domain for OpenAI Compatible providers (e.g., "api.example.com")
*/
public captureTaskCreated(ulid: string, apiProvider?: string, openAiCompatibleDomain?: string) {
this.resetTaskAggregates(ulid)
this.capture({
event: TelemetryService.EVENTS.TASK.CREATED,
properties: { ulid, apiProvider, openAiCompatibleDomain },
})
}
/**
* Records when a task/conversation is restarted
* @param ulid Unique identifier for the new task
* @param apiProvider Optional API provider
* @param openAiCompatibleDomain Optional domain for OpenAI Compatible providers (e.g., "api.example.com")
*/
public captureTaskRestarted(ulid: string, apiProvider?: string, openAiCompatibleDomain?: string) {
this.resetTaskAggregates(ulid)
this.capture({
event: TelemetryService.EVENTS.TASK.RESTARTED,
properties: { ulid, apiProvider, openAiCompatibleDomain },
})
}
/**
* Records when cline calls the task completion_result tool signifying that cline is done with the task
* @param ulid Unique identifier for the task
*/
public captureTaskCompleted(
ulid: string,
args?: {
provider?: string
modelId?: string
apiFormat?: ApiFormat
timeToFirstTokenMs?: number
durationMs?: number
mode: Mode
},
) {
const apiFormatName = args?.apiFormat !== undefined ? apiFormatToJSON(args.apiFormat) : undefined
this.capture({
event: TelemetryService.EVENTS.TASK.COMPLETED,
properties: {
ulid,
provider: args?.provider,
modelId: args?.modelId,
apiFormat: args?.apiFormat,
apiFormatName,
timeToFirstTokenMs: args?.timeToFirstTokenMs,
durationMs: args?.durationMs,
mode: args?.mode,
},
})
if (Number.isFinite(args?.timeToFirstTokenMs)) {
this.recordHistogram(TelemetryService.METRICS.API.TTFT_SECONDS, (args?.timeToFirstTokenMs ?? 0) / 1000, {
ulid,
provider: args?.provider,
model: args?.modelId,
apiFormat: apiFormatName,
mode: args?.mode,
})
}
if (Number.isFinite(args?.durationMs)) {
this.recordHistogram(TelemetryService.METRICS.API.DURATION_SECONDS, (args?.durationMs ?? 0) / 1000, {
ulid,
provider: args?.provider,
model: args?.modelId,
apiFormat: apiFormatName,
scope: "task",
mode: args?.mode,
})
}
this.resetTaskAggregates(ulid)
}
/**
* Captures that a message was sent, and includes the API provider and model used
* @param ulid Unique identifier for the task
* @param provider The API provider (e.g., OpenAI, Anthropic)
* @param model The specific model used (e.g., GPT-4, Claude)
* @param source The source of the message ("user" | "model"). Used to track message patterns and identify when users need to correct the model's responses.
* @param mode The mode in which the conversation turn occurred ("plan" or "act")
* @param tokenUsage Optional token usage data
*/
public captureConversationTurnEvent(
ulid: string,
provider = "unknown",
model = "unknown",
source: "user" | "assistant",
mode: Mode,
tokenUsage: TokenUsage = {},
isNativeToolCall?: boolean,
) {
// Ensure required parameters are provided
if (!ulid || !provider || !model || !source) {
Logger.warn("TelemetryService: Missing required parameters for message capture")
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.CONVERSATION_TURN,
properties: {
ulid,
provider,
model,
source,
mode,
timestamp: new Date().toISOString(), // Add timestamp for message sequencing
...tokenUsage,
isNativeToolCall,
},
})
const turnCount = this.incrementTaskCounter(this.taskTurnCounts, ulid)
const turnAttributes = { ulid, provider, model, source, mode }
this.recordCounter(TelemetryService.METRICS.TASK.TURNS_TOTAL, 1, turnAttributes)
this.recordHistogram(TelemetryService.METRICS.TASK.TURNS_PER_TASK, turnCount, turnAttributes)
if (Number.isFinite(tokenUsage.cacheWriteTokens)) {
const cacheWriteTokens = tokenUsage.cacheWriteTokens ?? 0
this.recordCounter(TelemetryService.METRICS.CACHE.WRITE_TOTAL, cacheWriteTokens, {
ulid,
provider,
model,
mode,
})
this.recordHistogram(TelemetryService.METRICS.CACHE.WRITE_PER_EVENT, cacheWriteTokens, {
ulid,
provider,
model,
mode,
})
}
if (Number.isFinite(tokenUsage.cacheReadTokens)) {
const cacheReadTokens = tokenUsage.cacheReadTokens ?? 0
this.recordCounter(TelemetryService.METRICS.CACHE.READ_TOTAL, cacheReadTokens, {
ulid,
provider,
model,
mode,
})
this.recordHistogram(TelemetryService.METRICS.CACHE.READ_PER_EVENT, cacheReadTokens, {
ulid,
provider,
model,
mode,
})
}
if (Number.isFinite(tokenUsage.totalCost)) {
const totalCost = tokenUsage.totalCost ?? 0
const costAttributes = { ulid, provider, model, mode, currency: "USD" }
this.recordCounter(TelemetryService.METRICS.TASK.COST_TOTAL, totalCost, costAttributes)
this.recordHistogram(TelemetryService.METRICS.TASK.COST_PER_EVENT, totalCost, costAttributes)
}
}
/**
* Records token usage metrics for cost tracking and usage analysis
* @param ulid Unique identifier for the task
* @param tokensIn Number of input tokens consumed
* @param tokensOut Number of output tokens generated
* @param provider The API provider identifier (e.g. "anthropic", "openai", "cline")
* @param model The model used for token calculation
*/
public captureTokenUsage(
ulid: string,
tokensIn: number,
tokensOut: number,
provider: string,
model: string,
options?: TokenUsage,
) {
this.capture({
event: TelemetryService.EVENTS.TASK.TOKEN_USAGE,
properties: {
ulid,
tokensIn,
tokensOut,
provider,
model,
...options,
},
})
const attributes = { ulid, provider, model }
if (Number.isFinite(tokensIn)) {
const value = tokensIn ?? 0
this.recordCounter(TelemetryService.METRICS.TASK.TOKENS_INPUT_TOTAL, value, attributes)
this.recordHistogram(TelemetryService.METRICS.TASK.TOKENS_INPUT_PER_RESPONSE, value, attributes)
}
if (Number.isFinite(tokensOut)) {
const value = tokensOut ?? 0
this.recordCounter(TelemetryService.METRICS.TASK.TOKENS_OUTPUT_TOTAL, value, attributes)
this.recordHistogram(TelemetryService.METRICS.TASK.TOKENS_OUTPUT_PER_RESPONSE, value, attributes)
}
if (Number.isFinite(options?.cacheWriteTokens)) {
const cacheWriteTokens = options!.cacheWriteTokens ?? 0
this.recordCounter(TelemetryService.METRICS.CACHE.WRITE_TOTAL, cacheWriteTokens, attributes)
this.recordHistogram(TelemetryService.METRICS.CACHE.WRITE_PER_EVENT, cacheWriteTokens, attributes)
}
if (Number.isFinite(options?.cacheReadTokens)) {
const cacheReadTokens = options!.cacheReadTokens ?? 0
this.recordCounter(TelemetryService.METRICS.CACHE.READ_TOTAL, cacheReadTokens, attributes)
this.recordHistogram(TelemetryService.METRICS.CACHE.READ_PER_EVENT, cacheReadTokens, attributes)
}
if (Number.isFinite(options?.totalCost)) {
const totalCost = options!.totalCost ?? 0
const costAttributes = { ...attributes, currency: "USD" }
this.recordCounter(TelemetryService.METRICS.TASK.COST_TOTAL, totalCost, costAttributes)
this.recordHistogram(TelemetryService.METRICS.TASK.COST_PER_EVENT, totalCost, costAttributes)
}
}
/**
* Records when a task switches between plan and act modes
* @param ulid Unique identifier for the task
* @param mode The mode being switched to (plan or act)
*/
public captureModeSwitch(ulid: string, mode: Mode) {
this.capture({
event: TelemetryService.EVENTS.TASK.MODE_SWITCH,
properties: {
ulid,
mode,
},
})
}
/**
* Records when context summarization is triggered due to context window pressure
* @param ulid Unique identifier for the task
* @param modelId The model that triggered summarization
* @param provider The API provider being used
* @param currentTokens Total tokens in context window when summarization was triggered
* @param maxContextWindow Maximum context window size for the model
*/
public captureSummarizeTask(
ulid: string,
modelId: string,
provider: string,
currentTokens: number,
maxContextWindow: number,
) {
this.capture({
event: TelemetryService.EVENTS.TASK.AUTO_COMPACT,
properties: {
ulid,
modelId,
provider,
currentTokens,
maxContextWindow,
},
})
}
/**
* Records user feedback on completed tasks
* @param ulid Unique identifier for the task
* @param feedbackType The type of feedback ("thumbs_up" or "thumbs_down")
*/
public captureTaskFeedback(ulid: string, feedbackType: TaskFeedbackType) {
Logger.info("TelemetryService: Capturing task feedback", {
ulid,
feedbackType,
})
this.capture({
event: TelemetryService.EVENTS.TASK.FEEDBACK,
properties: {
ulid,
feedbackType,
},
})
this.resetTaskAggregates(ulid)
}
// Tool events
/**
* Records when a tool is used during task execution
* @param ulid Unique identifier for the task
* @param tool Name of the tool being used
* @param modelId The model ID being used
* @param provider The API provider being used
* @param autoApproved Whether the tool was auto-approved based on settings
* @param success Whether the tool execution was successful
* @param workspaceContext Optional workspace context for multi-root workspace tracking
*/
public captureToolUsage(
ulid: string,
tool: string,
modelId: string,
provider: string,
autoApproved: boolean,
success: boolean,
workspaceContext?: {
isMultiRootEnabled: boolean
usedWorkspaceHint: boolean
resolvedToNonPrimary: boolean
resolutionMethod: "hint" | "primary_fallback" | "path_detection"
},
isNativeToolCall = false,
) {
this.capture({
event: TelemetryService.EVENTS.TASK.TOOL_USED,
properties: {
ulid,
tool,
autoApproved,
success,
modelId,
provider,
// Workspace context (optional)
...(workspaceContext && {
workspace_multi_root_enabled: workspaceContext.isMultiRootEnabled,
workspace_hint_used: workspaceContext.usedWorkspaceHint,
workspace_resolved_non_primary: workspaceContext.resolvedToNonPrimary,
workspace_resolution_method: workspaceContext.resolutionMethod,
}),
isNativeToolCall,
},
})
const toolAttributes = {
ulid,
tool,
model: modelId,
success,
autoApproved,
}
const toolCallCount = this.incrementTaskCounter(this.taskToolCallCounts, ulid)
this.recordCounter(TelemetryService.METRICS.TOOLS.CALLS_TOTAL, 1, toolAttributes)
this.recordHistogram(TelemetryService.METRICS.TOOLS.CALLS_PER_TASK, toolCallCount, toolAttributes)
}
public captureSkillUsed(args: {
ulid: string
skillName: string
skillSource: "global" | "project"
skillsAvailableGlobal: number
skillsAvailableProject: number
provider?: string
modelId?: string
}): void {
if (!this.isCategoryEnabled("skills")) {
return
}
if (!args.ulid || !args.skillName) {
return
}
const skillsAvailableGlobal = Math.max(0, args.skillsAvailableGlobal)
const skillsAvailableProject = Math.max(0, args.skillsAvailableProject)
const properties = {
ulid: args.ulid,
skillName: args.skillName,
skillSource: args.skillSource,
skillsAvailableGlobal,
skillsAvailableProject,
provider: args.provider,
modelId: args.modelId,
}
this.capture({
event: TelemetryService.EVENTS.TASK.SKILL_USED,
properties,
})
}
/**
* Records when an MCP tool is called.
* This telemetry event is designed to monitor the usage and performance of MCP tools
* without compromising user privacy. It captures the tool's metadata (server, name, and arguments)
* but explicitly avoids logging the values of the arguments.
*
* @param ulid Unique identifier for the task.
* @param serverName The name of the MCP server.
* @param toolName The name of the tool being called.
* @param status The status of the tool call.
* @param errorMessage Optional error message if the call failed.
* @param argumentKeys Optional array of argument keys for the tool.
*/
public captureMcpToolCall(
ulid: string,
serverName: string,
toolName: string,
status: "started" | "success" | "error",
errorMessage?: string,
argumentKeys?: string[],
isNativeToolCall = false,
) {
this.capture({
event: TelemetryService.EVENTS.TASK.MCP_TOOL_CALLED,
properties: {
ulid,
serverName,
toolName,
status,
errorMessage,
argumentKeys,
isNativeToolCall,
},
})
}
/**
* Records checkpoint interactions.
* @param ulid Unique identifier for the task
* @param action The type of checkpoint action
* @param durationMs Optional duration of the operation in milliseconds
*/
public captureCheckpointUsage(ulid: string, action: "created" | "restored", durationMs?: number) {
if (!this.isCategoryEnabled("checkpoints")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.CHECKPOINT_USED,
properties: {
ulid,
action,
durationMs,
},
})
}
/**
* Records when a diff edit (replace_in_file) operation fails
* @param ulid Unique identifier for the task
* @param modelId The model ID being used
* @param provider The API provider being used
* @param errorType Type of error that occurred (e.g., "search_not_found", "invalid_format")
* @param isNativeToolCall Whether the diff edit was invoked by a native tool call
*/
public captureDiffEditFailure(ulid: string, modelId: string, provider: string, errorType?: string, isNativeToolCall = false) {
this.capture({
event: TelemetryService.EVENTS.TASK.DIFF_EDIT_FAILED,
properties: {
ulid,
errorType,
modelId,
provider,
isNativeToolCall,
},
})
}
/**
* Records when a different model is selected for use
* @param model Name of the selected model
* @param provider Provider of the selected model
* @param ulid Optional task identifier if model was selected during a task
*/
public captureModelSelected(model: string, provider: string, ulid?: string) {
this.capture({
event: TelemetryService.EVENTS.UI.MODEL_SELECTED,
properties: {
model,
provider,
ulid,
},
})
}
/**
* Records when the browser tool is started
* @param ulid Unique identifier for the task
* @param browserSettings The browser settings being used
*/
public captureBrowserToolStart(ulid: string, browserSettings: BrowserSettings) {
if (!this.isCategoryEnabled("browser")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.BROWSER_TOOL_START,
properties: {
ulid,
viewport: browserSettings.viewport,
isRemote: !!browserSettings.remoteBrowserEnabled,
remoteBrowserHost: browserSettings.remoteBrowserHost,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when the browser tool is completed
* @param ulid Unique identifier for the task
* @param stats Statistics about the browser session
*/
public captureBrowserToolEnd(
ulid: string,
stats: {
actionCount: number
duration: number
actions?: string[]
},
) {
if (!this.isCategoryEnabled("browser")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.BROWSER_TOOL_END,
properties: {
ulid,
actionCount: stats.actionCount,
duration: stats.duration,
actions: stats.actions,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when browser errors occur during a task
* @param ulid Unique identifier for the task
* @param errorType Type of error that occurred (e.g., "launch_error", "connection_error", "navigation_error")
* @param errorMessage The error message
* @param context Additional context about where the error occurred
*/
public captureBrowserError(
ulid: string,
errorType: string,
errorMessage: string,
context?: {
action?: string
url?: string
isRemote?: boolean
remoteBrowserHost?: string
endpoint?: string
},
) {
if (!this.isCategoryEnabled("browser")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.BROWSER_ERROR,
properties: {
ulid,
errorType,
errorMessage,
...(context && { context }),
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when a user selects an option from AI-generated followup questions
* @param ulid Unique identifier for the task
* @param qty The quantity of options that were presented
* @param mode The mode in which the option was selected ("plan" or "act")
*/
public captureOptionSelected(ulid: string, qty: number, mode: Mode) {
this.capture({
event: TelemetryService.EVENTS.TASK.OPTION_SELECTED,
properties: {
ulid,
qty,
mode,
},
})
}
/**
* Records when a user types a custom response instead of selecting one of the AI-generated followup questions
* @param ulid Unique identifier for the task
* @param qty The quantity of options that were presented
* @param mode The mode in which the custom response was provided ("plan" or "act")
*/
public captureOptionsIgnored(ulid: string, qty: number, mode: Mode) {
this.capture({
event: TelemetryService.EVENTS.TASK.OPTIONS_IGNORED,
properties: {
ulid,
qty,
mode,
},
})
}
/**
* Captures Gemini API performance metrics.
* @param ulid Unique identifier for the task
* @param modelId Specific Gemini model ID
* @param data Performance data including TTFT, durations, token counts, cache stats, and API success status
*/
public captureGeminiApiPerformance(
ulid: string,
modelId: string,
data: {
ttftSec?: number
totalDurationSec?: number
promptTokens: number
outputTokens: number
cacheReadTokens: number
cacheHit: boolean
cacheHitPercentage?: number
apiSuccess: boolean
apiError?: string
throughputTokensPerSec?: number
},
) {
this.capture({
event: TelemetryService.EVENTS.TASK.GEMINI_API_PERFORMANCE,
properties: {
ulid,
modelId,
...data,
},
})
if (typeof data.ttftSec === "number") {
this.recordHistogram(TelemetryService.METRICS.API.TTFT_SECONDS, data.ttftSec, {
ulid,
model: modelId,
provider: "gemini",
})
}
if (typeof data.totalDurationSec === "number") {
this.recordHistogram(TelemetryService.METRICS.API.DURATION_SECONDS, data.totalDurationSec, {
ulid,
model: modelId,
provider: "gemini",
})
}
if (typeof data.throughputTokensPerSec === "number") {
this.recordHistogram(TelemetryService.METRICS.API.THROUGHPUT_TOKENS_PER_SECOND, data.throughputTokensPerSec, {
ulid,
model: modelId,
provider: "gemini",
})
}
if (data.cacheHit) {
this.recordCounter(TelemetryService.METRICS.CACHE.HITS_TOTAL, 1, { ulid, model: modelId, provider: "gemini" })
}
}
/**
* Records when the user uses the model favorite button in the model picker
* @param model The name of the model the user has interacted with
* @param isFavorited Whether the model is being favorited (true) or unfavorited (false)
*/
public captureModelFavoritesUsage(model: string, isFavorited: boolean) {
this.capture({
event: TelemetryService.EVENTS.UI.MODEL_FAVORITE_TOGGLED,
properties: {
model,
isFavorited,
},
})
}
public captureButtonClick(button: string, ulid?: string) {
this.capture({
event: TelemetryService.EVENTS.UI.BUTTON_CLICKED,
properties: {
button,
ulid,
},
})
}
/**
* Records telemetry when an API provider returns an error
* @param ulid Unique identifier for the task
* @param model Identifier of the model used
* @param requestId Unique identifier for the specific API request
* @param errorMessage Detailed error message from the API provider
* @param errorStatus HTTP status code of the error response, if available
* @param collect Optional flag to determine if the event should be collected for batch sending
*/
public captureProviderApiError(args: {
ulid: string
model: string
errorMessage: string
provider?: string
errorStatus?: number | undefined
requestId?: string | undefined
isNativeToolCall?: boolean
}) {
this.capture({
event: TelemetryService.EVENTS.TASK.PROVIDER_API_ERROR,
properties: {
...args,
errorMessage: args.errorMessage.substring(0, MAX_ERROR_MESSAGE_LENGTH), // Truncate long error messages
timestamp: new Date().toISOString(),
},
})
this.recordCounter(TelemetryService.METRICS.ERRORS.TOTAL, 1, {
ulid: args.ulid,
model: args.model,
provider: args.provider,
error_status: args.errorStatus,
})
const errorAttributes = {
ulid: args.ulid,
model: args.model,
provider: args.provider,
error_status: args.errorStatus,
}
const errorCount = this.incrementTaskCounter(this.taskErrorCounts, args.ulid)
this.recordHistogram(TelemetryService.METRICS.ERRORS.PER_TASK, errorCount, errorAttributes)
}
/**
* Records when focus chain is enabled/disabled by the user
* @param enabled Whether focus chain was enabled (true) or disabled (false)
*/
public captureFocusChainToggle(enabled: boolean) {
if (!this.isCategoryEnabled("focus_chain")) {
return
}
this.capture({
event: enabled ? TelemetryService.EVENTS.TASK.FOCUS_CHAIN_ENABLED : TelemetryService.EVENTS.TASK.FOCUS_CHAIN_DISABLED,
properties: {
enabled,
},
})
}
/**
* Records when a task progress list is returned by the model for the first time in a task
* @param ulid Unique identifier for the task
* @param totalItems Number of items in the initial focus chain list
*/
public captureFocusChainProgressFirst(ulid: string, totalItems: number) {
if (!this.isCategoryEnabled("focus_chain")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.FOCUS_CHAIN_PROGRESS_FIRST,
properties: {
ulid,
totalItems,
},
})
}
/**
* Records when a task progress list is updated by the model mid-task
* @param ulid Unique identifier for the task
* @param totalItems Total number of items in the focus chain list
* @param completedItems Number of completed items in the focus chain list
*/
public captureFocusChainProgressUpdate(ulid: string, totalItems: number, completedItems: number) {
if (!this.isCategoryEnabled("focus_chain")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.FOCUS_CHAIN_PROGRESS_UPDATE,
properties: {
ulid,
totalItems,
completedItems,
completionPercentage: totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0,
},
})
}
/**
* Records when a task ends but the task progress list is not complete
* @param ulid Unique identifier for the task
* @param totalItems Total number of items in the focus chain list
* @param completedItems Number of completed items
* @param incompleteItems Number of incomplete items
* @param modelId The model ID being used
* @param provider The API provider being used
*/
public captureFocusChainIncompleteOnCompletion(
ulid: string,
totalItems: number,
completedItems: number,
incompleteItems: number,
modelId: string,
provider: string,
) {
if (!this.isCategoryEnabled("focus_chain")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.FOCUS_CHAIN_INCOMPLETE_ON_COMPLETION,
properties: {
ulid,
totalItems,
completedItems,
incompleteItems,
completionPercentage: totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0,
modelId,
provider,
},
})
}
/**
* Records when users click to open the focus chain markdown file
* @param ulid Unique identifier for the task
*/
public captureFocusChainListOpened(ulid: string) {
if (!this.isCategoryEnabled("focus_chain")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.FOCUS_CHAIN_LIST_OPENED,
properties: {
ulid,
},
})
}
/**
* Records when users save and write to the focus chain markdown file
* @param ulid Unique identifier for the task
*/
public captureFocusChainListWritten(ulid: string) {
if (!this.isCategoryEnabled("focus_chain")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.FOCUS_CHAIN_LIST_WRITTEN,
properties: {
ulid,
},
})
}
/**
* Records when slash commands or workflows are activated
* @param ulid Unique identifier for the task
* @param commandName The name of the command (e.g., "newtask", "reportbug", or custom workflow name)
* @param commandType Whether it's a built-in command, custom workflow, or MCP prompt
*/
public captureSlashCommandUsed(ulid: string, commandName: string, commandType: "builtin" | "workflow" | "mcp_prompt") {
this.capture({
event: TelemetryService.EVENTS.TASK.SLASH_COMMAND_USED,
properties: {
ulid,
commandName,
commandType,
},
})
}
/**
* Records when a feature is enabled/disabled by the user
* @param ulid Unique identifier for the task
* @param featureName The name of the feature being toggled
* @param enabled Whether the feature was enabled (true) or disabled (false)
* @param modelId The model ID being used when the toggle occurred
*/
public captureFeatureToggle(ulid: string, featureName: string, enabled: boolean, modelId: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.FEATURE_TOGGLED,
properties: {
ulid,
featureName,
enabled,
modelId,
},
})
}
/**
* Records when individual Cline rules are toggled on/off
* @param ulid Unique identifier for the task (to track rule changes within task context)
* @param ruleFileName The filename of the rule (sanitized to exclude full path)
* @param enabled Whether the rule is being enabled (true) or disabled (false)
* @param isGlobal Whether this is a global rule or workspace-specific rule
*/
public captureClineRuleToggled(ulid: string, ruleFileName: string, enabled: boolean, isGlobal: boolean) {
// Sanitize filename to remove any path information for privacy
const sanitizedFileName = ruleFileName.split("/").pop() || ruleFileName.split("\\").pop() || ruleFileName
this.capture({
event: TelemetryService.EVENTS.TASK.RULE_TOGGLED,
properties: {
ulid,
ruleFileName: sanitizedFileName,
enabled,
isGlobal,
},
})
}
/**
* Records when auto condense is enabled/disabled by the user
* @param ulid Unique identifier for the task
* @param enabled Whether auto condense was enabled (true) or disabled (false)
* @param modelId The model ID being used when the toggle occurred
*/
public captureAutoCondenseToggle(ulid: string, enabled: boolean, modelId: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.AUTO_CONDENSE_TOGGLED,
properties: {
ulid,
enabled,
modelId,
},
})
}
/**
* Records when yolo mode is enabled/disabled by the user
* @param ulid Unique identifier for the task
* @param enabled Whether yolo mode was enabled (true) or disabled (false)
*/
public captureYoloModeToggle(ulid: string, enabled: boolean) {
this.capture({
event: TelemetryService.EVENTS.TASK.YOLO_MODE_TOGGLED,
properties: {
ulid,
enabled,
},
})
}
/**
* Records task initialization timing and metadata
* @param ulid Unique identifier for the task
* @param taskId Task ID (timestamp in milliseconds when task was created)
* @param durationMs Duration of initialization in milliseconds
* @param hasCheckpoints Whether checkpoints are enabled for this task
*/
public captureTaskInitialization(ulid: string, taskId: string, durationMs: number, hasCheckpoints: boolean) {
this.capture({
event: TelemetryService.EVENTS.TASK.INITIALIZATION,
properties: {
ulid,
taskId,
durationMs,
hasCheckpoints,
},
})
}
/**
* Records when the rules menu button is clicked to open the rules/workflows modal
*/
public captureRulesMenuOpened() {
this.capture({
event: TelemetryService.EVENTS.UI.RULES_MENU_OPENED,
properties: {},
})
}
// Terminal telemetry methods
/**
* Records terminal command execution outcomes for VSCode terminal
* @param success Whether the command output was successfully captured
* @param terminalType The type of terminal ("vscode")
* @param method The VSCode-specific method used to capture output
*/
public captureTerminalExecution(success: boolean, terminalType: "vscode", method: VscodeOutputMethod): void
/**
* Records terminal command execution outcomes for standalone terminal
* @param success Whether the command output was successfully captured
* @param terminalType The type of terminal ("standalone")
* @param method The standalone-specific method used to capture output
* @param exitCode The process exit code (useful for diagnosing failure types: 1=error, 127=not found, 126=permission denied)
*/
public captureTerminalExecution(
success: boolean,
terminalType: "standalone",
method: StandaloneOutputMethod,
exitCode?: number | null,
): void
/**
* Implementation of captureTerminalExecution
*/
public captureTerminalExecution(
success: boolean,
terminalType: TerminalType,
method: TerminalOutputMethod,
exitCode?: number | null,
): void {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_EXECUTION,
properties: {
success,
terminalType,
method,
// Only include exitCode for standalone terminals when it's a meaningful value
...(terminalType === "standalone" && exitCode !== undefined && exitCode !== null && { exitCode }),
},
})
}
/**
* Records when terminal output capture fails
* @param reason The reason for failure
* @param terminalType The type of terminal (defaults to "vscode" for backward compatibility)
*/
public captureTerminalOutputFailure(reason: TerminalOutputFailureReason, terminalType: TerminalType = "vscode") {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_OUTPUT_FAILURE,
properties: {
reason,
terminalType,
},
})
}
/**
* Records when user has to intervene with terminal execution
* @param action The user action
* @param terminalType The type of terminal (defaults to "vscode" for backward compatibility)
*/
public captureTerminalUserIntervention(action: TerminalUserInterventionAction, terminalType: TerminalType = "vscode") {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_USER_INTERVENTION,
properties: {
action,
terminalType,
},
})
}
/**
* Records when terminal execution hangs or gets stuck
* @param stage Where the hang occurred
* @param terminalType The type of terminal (defaults to "vscode" for backward compatibility)
*/
public captureTerminalHang(stage: TerminalHangStage, terminalType: TerminalType = "vscode") {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_HANG,
properties: {
stage,
terminalType,
},
})
}
// Workspace telemetry methods
/**
* Records when workspace is initialized
* @param rootCount Number of workspace roots
* @param vcsTypes Array of VCS types detected
* @param initDurationMs Time taken to initialize in milliseconds
* @param featureFlagEnabled Whether multi-root feature flag is enabled
*/
public captureWorkspaceInitialized(
rootCount: number,
vcsTypes: string[],
initDurationMs?: number,
featureFlagEnabled?: boolean,
) {
this.capture({
event: TelemetryService.EVENTS.WORKSPACE.INITIALIZED,
properties: {
root_count: rootCount,
vcs_types: vcsTypes,
is_multi_root: rootCount > 1,
has_git: vcsTypes.includes("Git"),
has_mercurial: vcsTypes.includes("Mercurial"),
init_duration_ms: initDurationMs,
feature_flag_enabled: featureFlagEnabled,
},
})
const isMultiRoot = rootCount > 1
this.recordGauge("cline.workspace.active_roots", rootCount, {
is_multi_root: isMultiRoot,
})
// Retire the previous series to avoid leaking gauge entries when the flag flips.
this.recordGauge("cline.workspace.active_roots", null, {
is_multi_root: !isMultiRoot,
})
}
/**
* Records workspace initialization errors
* @param error The error that occurred
* @param fallbackMode Whether system fell back to single-root mode
* @param workspaceCount Number of workspace folders detected
*/
public captureWorkspaceInitError(error: Error, fallbackMode: boolean, workspaceCount?: number) {
this.capture({
event: TelemetryService.EVENTS.WORKSPACE.INIT_ERROR,
properties: {
error_type: error.constructor.name,
error_message: error.message.substring(0, MAX_ERROR_MESSAGE_LENGTH),
fallback_to_single_root: fallbackMode,
workspace_count: workspaceCount ?? 0,
},
})
}
/**
* Records multi-root checkpoint operations
* @param ulid Task identifier
* @param action Type of checkpoint action
* @param rootCount Number of roots being checkpointed
* @param successCount Number of successful checkpoints
* @param failureCount Number of failed checkpoints
* @param durationMs Total operation duration in milliseconds
*/
public captureMultiRootCheckpoint(
ulid: string,
action: "initialized" | "committed" | "restored",
rootCount: number,
successCount: number,
failureCount: number,
durationMs?: number,
) {
this.capture({
event: TelemetryService.EVENTS.WORKSPACE.MULTI_ROOT_CHECKPOINT,
properties: {
ulid,
action,
root_count: rootCount,
success_count: successCount,
failure_count: failureCount,
success_rate: rootCount > 0 ? successCount / rootCount : 0,
duration_ms: durationMs,
},
})
}
/**
* Records workspace path resolution events
* @param ulid Unique identifier for the task
* @param context The component/handler where resolution occurred
* @param resolutionType Type of resolution performed
* @param hintType Type of workspace hint provided (if any)
* @param resolutionSuccess Whether the resolution was successful
* @param targetWorkspaceIndex Index of the resolved workspace (0=primary, 1=secondary, etc.)
* @param isMultiRootEnabled Whether multi-root mode is enabled
*/
public captureWorkspacePathResolved(
ulid: string,
context: string,
resolutionType: "hint_provided" | "fallback_to_primary" | "cross_workspace_search",
hintType?: "workspace_name" | "workspace_path" | "invalid",
resolutionSuccess?: boolean,
targetWorkspaceIndex?: number,
isMultiRootEnabled?: boolean,
) {
this.capture({
event: TelemetryService.EVENTS.WORKSPACE.PATH_RESOLVED,
properties: {
ulid,
context,
resolution_type: resolutionType,
hint_type: hintType,
resolution_success: resolutionSuccess,
target_workspace_index: targetWorkspaceIndex,
is_multi_root_enabled: isMultiRootEnabled,
},
})
}
/**
* Records multi-workspace search patterns and performance
* @param ulid Unique identifier for the task
* @param searchType Type of search performed
* @param workspaceCount Number of workspaces searched
* @param hintProvided Whether a workspace hint was provided
* @param resultsFound Whether search results were found
* @param searchDurationMs Optional search duration in milliseconds
*/
public captureWorkspaceSearchPattern(
ulid: string,
searchType: "targeted" | "cross_workspace" | "primary_only",
workspaceCount: number,
hintProvided: boolean,
resultsFound: boolean,
searchDurationMs?: number,
) {
this.capture({
event: TelemetryService.EVENTS.TASK.WORKSPACE_SEARCH_PATTERN,
properties: {
ulid,
search_type: searchType,
workspace_count: workspaceCount,
hint_provided: hintProvided,
results_found: resultsFound,
search_duration_ms: searchDurationMs,
},
})
}
/**
* Records when user opens the worktrees view
* @param source Where the user opened the view from (home_page or menu_bar)
*/
public captureWorktreeViewOpened(source: "home_page" | "menu_bar") {
this.capture({
event: TelemetryService.EVENTS.WORKTREE.VIEW_OPENED,
properties: {
source,
},
})
}
/**
* Records when a worktree is created
* @param success Whether the creation was successful
* @param worktreeCount Total number of worktrees after creation (to track power users)
*/
public captureWorktreeCreated(success: boolean, worktreeCount?: number) {
this.capture({
event: TelemetryService.EVENTS.WORKTREE.CREATED,
properties: {
success,
worktree_count: worktreeCount,
},
})
}
/**
* Records when a worktree merge is attempted
* @param success Whether the merge was successful
* @param hasConflicts Whether merge conflicts were detected
* @param deleteAfterMerge Whether user chose to delete worktree after merge
*/
public captureWorktreeMergeAttempted(success: boolean, hasConflicts: boolean, deleteAfterMerge: boolean) {
this.capture({
event: TelemetryService.EVENTS.WORKTREE.MERGE_ATTEMPTED,
properties: {
success,
has_conflicts: hasConflicts,
delete_after_merge: deleteAfterMerge,
},
})
}
/**
* Checks if a specific telemetry category is enabled
* @param category The telemetry category to check
* @returns Boolean indicating whether the specified telemetry category is enabled
*/
public isCategoryEnabled(category: TelemetryCategory): boolean {
// Default to true if category has not been explicitly configured
return this.telemetryCategoryEnabled.get(category) ?? true
}
/**
* Get the telemetry provider instances
* @returns The array of telemetry providers
*/
public getProviders(): ITelemetryProvider[] {
return [...this.providers]
}
/**
* Check if telemetry is currently enabled
* @returns Boolean indicating whether any provider is enabled
*/
public isEnabled(): boolean {
return this.providers.some((provider) => provider.isEnabled())
}
/**
* Get current telemetry settings from the first provider
* @returns Current telemetry settings
*/
public getSettings() {
return this.providers.length > 0
? this.providers[0].getSettings()
: {
hostEnabled: false,
level: "off" as const,
}
}
/**
* Records when a mention is successfully used and content is retrieved
* @param mentionType Type of mention (file, folder, url, problems, terminal, git-changes, commit)
* @param contentLength Optional length of content retrieved (for size tracking)
*/
public captureMentionUsed(
mentionType: "file" | "folder" | "url" | "problems" | "terminal" | "git-changes" | "commit",
contentLength?: number,
) {
this.capture({
event: TelemetryService.EVENTS.TASK.MENTION_USED,
properties: {
mentionType,
contentLength,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when a mention fails to retrieve content or when the mention
* picker's file/folder search itself fails.
*
* `ripgrep_spawn_failed` and `workspace_unavailable` are picker-search
* failures, surfaced by the `searchFiles` controller; the others are
* mention-content retrieval failures.
*
* @param fsContext Optional filesystem info, emitted as `fs_class` and `fs_type`.
*/
public captureMentionFailed(
mentionType: "file" | "folder" | "url" | "problems" | "terminal" | "git-changes" | "commit",
errorType:
| "not_found"
| "permission_denied"
| "network_error"
| "parse_error"
| "ripgrep_spawn_failed"
| "workspace_unavailable"
| "unknown",
errorMessage?: string,
fsContext?: { fsClass?: "local" | "network" | "unknown"; fsType?: string },
) {
this.capture({
event: TelemetryService.EVENTS.TASK.MENTION_FAILED,
properties: {
mentionType,
errorType,
errorMessage: errorMessage?.substring(0, MAX_ERROR_MESSAGE_LENGTH),
...(fsContext?.fsClass ? { fs_class: fsContext.fsClass } : {}),
...(fsContext?.fsType ? { fs_type: fsContext.fsType } : {}),
timestamp: new Date().toISOString(),
},
})
}
/**
* Records search results when user searches for files/folders in mention dropdown
* @param query The search query entered by user
* @param resultCount Number of results returned
* @param searchType Type of search (file, folder, or all)
* @param isEmpty Whether the search returned no results
* @param fsContext Optional filesystem info, emitted as `fs_class` and `fs_type`.
* @param searchSource Which backend served the search: `host_index` (e.g.
* JetBrains FilenameIndex) or `ripgrep` (default everywhere). Emitted as
* the `search_source` property so we can tell, for a given fs_class, how
* often the host index actually picks up the load.
*/
public captureMentionSearchResults(
query: string,
resultCount: number,
searchType: "file" | "folder" | "all",
isEmpty: boolean,
fsContext?: { fsClass?: "local" | "network" | "unknown"; fsType?: string },
searchSource?: "host_index" | "ripgrep",
) {
this.capture({
event: TelemetryService.EVENTS.TASK.MENTION_SEARCH_RESULTS,
properties: {
queryLength: query.length,
resultCount,
searchType,
isEmpty,
...(fsContext?.fsClass ? { fs_class: fsContext.fsClass } : {}),
...(fsContext?.fsType ? { fs_type: fsContext.fsType } : {}),
...(searchSource ? { search_source: searchSource } : {}),
timestamp: new Date().toISOString(),
},
})
}
// CLI Subagents telemetry methods
/**
* Records when CLI subagents feature is enabled/disabled by the user
* @param enabled Whether subagents was enabled (true) or disabled (false)
*/
public captureSubagentToggle(enabled: boolean) {
if (!this.isCategoryEnabled("subagents")) {
return
}
this.capture({
event: enabled ? TelemetryService.EVENTS.TASK.SUBAGENT_ENABLED : TelemetryService.EVENTS.TASK.SUBAGENT_DISABLED,
properties: {
enabled,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when a CLI subagent is executed
* @param ulid Unique identifier for the task
* @param durationMs Duration of the subagent execution in milliseconds
* @param outputLines Number of lines of output produced by the subagent
* @param success Whether the subagent execution was successful
*/
public captureSubagentExecution(ulid: string, durationMs: number, outputLines: number, success: boolean) {
if (!this.isCategoryEnabled("subagents")) {
return
}
this.capture({
event: success ? TelemetryService.EVENTS.TASK.SUBAGENT_COMPLETED : TelemetryService.EVENTS.TASK.SUBAGENT_STARTED,
properties: {
ulid,
durationMs,
outputLines,
success,
timestamp: new Date().toISOString(),
},
})
}
public captureOnboardingProgress(args: {
step: number
action?: string
page?: string
pageVariant?: string
userType?: string
selectedModelId?: string
destinationStep?: number
destinationPage?: string
completed?: boolean
}) {
this.capture({
event: TelemetryService.EVENTS.USER.ONBOARDING_PROGRESS,
properties: {
...args,
},
})
}
// Hooks telemetry methods
/**
* Records hook discovery cache access (hit or miss)
* @param hookName The type of hook being accessed
* @param cacheHit Whether the cache had the result (true) or miss (false)
*/
public captureHookCacheAccess(hookName: string, cacheHit: boolean) {
if (!this.isCategoryEnabled("hooks")) {
return
}
// Record cache access counter with hit/miss attribute
// This allows deriving hit rate: hits / (hits + misses)
this.recordCounter(TelemetryService.METRICS.HOOKS.CACHE_ACCESSES_TOTAL, 1, {
hookName,
cacheHit: cacheHit.toString(),
})
}
// Simplified Hook Telemetry API (following MCP pattern)
/**
* Records hook execution events with a unified status-based approach.
* This is the simplified API that consolidates multiple hook execution methods.
*
* @param ulid Task identifier
* @param hookName Type of hook (PreToolUse, PostToolUse, etc.)
* @param status Current execution status
* @param metadata Optional execution metadata
*/
public captureHookExecution(
ulid: string,
hookName: string,
status: "started" | "completed" | "failed" | "cancelled",
metadata?: {
source?: "global" | "workspace"
toolName?: string
durationMs?: number
exitCode?: number
errorType?: "timeout" | "execution" | "validation"
errorMessage?: string
cancelRequested?: boolean
contextModified?: boolean
contextSize?: number
},
) {
if (!this.isCategoryEnabled("hooks")) {
return
}
const properties: TelemetryProperties = {
ulid,
hookName,
status,
timestamp: new Date().toISOString(),
...(metadata?.source && { source: metadata.source }),
...(metadata?.toolName && { toolName: metadata.toolName }),
...(metadata?.durationMs !== undefined && { durationMs: metadata.durationMs }),
...(metadata?.exitCode !== undefined && { exitCode: metadata.exitCode }),
...(metadata?.errorType && { errorType: metadata.errorType }),
...(metadata?.errorMessage && {
errorMessage: metadata.errorMessage.substring(0, MAX_ERROR_MESSAGE_LENGTH),
}),
...(metadata?.cancelRequested !== undefined && { cancelRequested: metadata.cancelRequested }),
...(metadata?.contextModified !== undefined && { contextModified: metadata.contextModified }),
...(metadata?.contextSize !== undefined && { contextSize: metadata.contextSize }),
}
// Single event for all statuses
this.capture({
event: "hooks.execution",
properties,
})
// Record metrics based on status
const hookAttributes = {
ulid,
hookName,
status,
...(metadata?.source && { source: metadata.source }),
...(metadata?.toolName && { toolName: metadata.toolName }),
}
if (status === "started") {
this.recordCounter(TelemetryService.METRICS.HOOKS.EXECUTIONS_TOTAL, 1, hookAttributes)
} else if (status === "completed") {
if (metadata?.durationMs !== undefined) {
this.recordHistogram(TelemetryService.METRICS.HOOKS.DURATION_SECONDS, metadata.durationMs / 1000, hookAttributes)
}
if (metadata?.cancelRequested) {
this.recordCounter(TelemetryService.METRICS.HOOKS.CANCELLATIONS_TOTAL, 1, hookAttributes)
}
if (metadata?.contextModified) {
this.recordCounter(TelemetryService.METRICS.HOOKS.CONTEXT_MODIFICATIONS_TOTAL, 1, hookAttributes)
}
} else if (status === "failed") {
this.recordCounter(TelemetryService.METRICS.HOOKS.FAILURES_TOTAL, 1, {
...hookAttributes,
errorType: metadata?.errorType || "unknown",
})
} else if (status === "cancelled") {
this.recordCounter(TelemetryService.METRICS.HOOKS.CANCELLATIONS_TOTAL, 1, hookAttributes)
}
}
/**
* Records hook discovery results (simplified version).
*
* @param hookName The type of hook being discovered
* @param globalCount Number of global hooks found
* @param workspaceCount Number of workspace-specific hooks found
*/
public captureHookDiscovery(hookName: string, globalCount: number, workspaceCount: number) {
if (!this.isCategoryEnabled("hooks")) {
return
}
this.capture({
event: TelemetryService.EVENTS.HOOKS.DISCOVERY_COMPLETED,
properties: {
hookName,
globalCount,
workspaceCount,
totalCount: globalCount + workspaceCount,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when a file edit (write_to_file, replace_in_file, apply_patch) is accepted by the user
* Tracks lines added, deleted, and changed for the accepted edit.
*
* @param args Properties for the accepted AI output event
*/
public captureAiOutputAccepted(args: {
ulid: string
tool: string
provider?: string
model?: string
source: "agent" | "human"
linesAdded: number
linesDeleted: number
linesChanged: number
filesCreated?: number
filesDeleted?: number
filesMoved?: number
}): void {
this.capture({
event: "task.ai_output.accepted",
properties: {
ulid: args.ulid,
tool: args.tool,
provider: args.provider,
model: args.model,
source: args.source,
linesAdded: args.linesAdded,
linesDeleted: args.linesDeleted,
linesChanged: args.linesChanged,
filesCreated: args.filesCreated ?? 0,
filesDeleted: args.filesDeleted ?? 0,
filesMoved: args.filesMoved ?? 0,
},
})
const attrs = { ulid: args.ulid, tool: args.tool, provider: args.provider, model: args.model, source: args.source }
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_LINES_ADDED, args.linesAdded, attrs)
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_LINES_DELETED, args.linesDeleted, attrs)
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_LINES_CHANGED, args.linesChanged, attrs)
if (args.filesCreated) {
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_FILES_CREATED, args.filesCreated, attrs)
}
if (args.filesDeleted) {
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_FILES_DELETED, args.filesDeleted, attrs)
}
if (args.filesMoved) {
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.ACCEPTED_FILES_MOVED, args.filesMoved, attrs)
}
}
/**
* Records when a file edit (write_to_file, replace_in_file, apply_patch) is rejected by the user
* Tracks lines that would have been added, deleted, and changed.
*
* @param args Properties for the rejected AI output event
*/
public captureAiOutputRejected(args: {
ulid: string
tool: string
provider?: string
model?: string
source: "agent" | "human"
linesAdded: number
linesDeleted: number
linesChanged: number
filesCreated?: number
filesDeleted?: number
filesMoved?: number
}): void {
this.capture({
event: "task.ai_output.rejected",
properties: {
ulid: args.ulid,
tool: args.tool,
provider: args.provider,
model: args.model,
source: args.source,
linesAdded: args.linesAdded,
linesDeleted: args.linesDeleted,
linesChanged: args.linesChanged,
filesCreated: args.filesCreated ?? 0,
filesDeleted: args.filesDeleted ?? 0,
filesMoved: args.filesMoved ?? 0,
},
})
const attrs = { ulid: args.ulid, tool: args.tool, provider: args.provider, model: args.model, source: args.source }
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_LINES_ADDED, args.linesAdded, attrs)
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_LINES_DELETED, args.linesDeleted, attrs)
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_LINES_CHANGED, args.linesChanged, attrs)
if (args.filesCreated) {
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_FILES_CREATED, args.filesCreated, attrs)
}
if (args.filesDeleted) {
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_FILES_DELETED, args.filesDeleted, attrs)
}
if (args.filesMoved) {
this.recordCounter(TelemetryService.METRICS.AI_OUTPUT.REJECTED_FILES_MOVED, args.filesMoved, attrs)
}
}
public captureHostEvent(name: string, content: string) {
this.capture({
event: TelemetryService.EVENTS.HOST.DETECTED,
properties: {
name,
content,
},
})
}
/**
* Records the size of a gRPC response message for observability.
*
* @param sizeUtf8Bytes Size in UTF-8 bytes (use `Buffer.byteLength`, not `string.length`)
* @param service The gRPC service name
* @param method The gRPC method name
* @param requestId Optional request ID for correlation
*/
public captureLegacyTaskMigration(args: {
taskId: string
outcome: "success" | "skipped" | "error"
reason: string
durationMs: number
legacyApiHistoryLength?: number
convertedMessageCount?: number
sdkLookupFailed?: boolean
hasFavorite?: boolean
hasCost?: boolean
hasTokenUsage?: boolean
hasCwd?: boolean
}): void {
const migrationType = "legacy_task_to_sdk_session"
const metricAttributes = {
migration_type: migrationType,
outcome: args.outcome,
reason: args.reason,
}
this.capture({
event: TelemetryService.EVENTS.TASK.LEGACY_TASK_MIGRATION,
properties: {
ulid: args.taskId,
migration_type: migrationType,
outcome: args.outcome,
reason: args.reason,
durationMs: args.durationMs,
legacyApiHistoryLength: args.legacyApiHistoryLength,
convertedMessageCount: args.convertedMessageCount,
sdkLookupFailed: args.sdkLookupFailed,
hasFavorite: args.hasFavorite,
hasCost: args.hasCost,
hasTokenUsage: args.hasTokenUsage,
hasCwd: args.hasCwd,
},
})
this.recordCounter(
TelemetryService.METRICS.MIGRATION.LEGACY_TASK_ATTEMPTS_TOTAL,
1,
metricAttributes,
"Legacy VS Code task migration decisions",
)
if (args.outcome === "success") {
this.recordCounter(
TelemetryService.METRICS.MIGRATION.LEGACY_TASK_SUCCESS_TOTAL,
1,
metricAttributes,
"Legacy VS Code tasks successfully copied into SDK session storage",
)
} else if (args.outcome === "error") {
this.recordCounter(
TelemetryService.METRICS.MIGRATION.LEGACY_TASK_FAILURES_TOTAL,
1,
metricAttributes,
"Legacy VS Code task migrations that failed while building or writing the SDK session",
)
} else {
this.recordCounter(
TelemetryService.METRICS.MIGRATION.LEGACY_TASK_SKIPPED_TOTAL,
1,
metricAttributes,
"Legacy VS Code task migration checks that did not need or could not perform a migration",
)
}
this.recordHistogram(
TelemetryService.METRICS.MIGRATION.LEGACY_TASK_DURATION_SECONDS,
args.durationMs / 1000,
metricAttributes,
"Time spent checking or migrating a legacy VS Code task into SDK session storage",
)
if (args.legacyApiHistoryLength !== undefined) {
this.recordHistogram(
TelemetryService.METRICS.MIGRATION.LEGACY_TASK_LEGACY_MESSAGES_COUNT,
args.legacyApiHistoryLength,
metricAttributes,
"Number of raw legacy API history messages found while migrating a legacy VS Code task",
)
}
if (args.convertedMessageCount !== undefined) {
this.recordHistogram(
TelemetryService.METRICS.MIGRATION.LEGACY_TASK_CONVERTED_MESSAGES_COUNT,
args.convertedMessageCount,
metricAttributes,
"Number of SDK-compatible messages produced from a legacy VS Code task migration",
)
}
}
public captureLegacyTaskMigrationBacklog(args: {
pendingLegacyTaskCount: number
migratedSdkTaskCount: number
visibleSdkTaskCount: number
visibleTaskCount: number
}): void {
const attributes = { migration_type: "legacy_task_to_sdk_session" }
this.recordGauge(
TelemetryService.METRICS.MIGRATION.LEGACY_TASK_PENDING_COUNT,
args.pendingLegacyTaskCount,
attributes,
"Legacy VS Code tasks visible in history but not yet migrated to SDK sessions",
)
this.recordGauge(
TelemetryService.METRICS.MIGRATION.LEGACY_TASK_MIGRATED_COUNT,
args.migratedSdkTaskCount,
attributes,
"SDK sessions marked as migrated from legacy VS Code task history",
)
this.capture({
event: TelemetryService.EVENTS.TASK.LEGACY_TASK_MIGRATION,
properties: {
migration_type: "legacy_task_to_sdk_session",
outcome: "backlog",
pendingLegacyTaskCount: args.pendingLegacyTaskCount,
migratedSdkTaskCount: args.migratedSdkTaskCount,
visibleSdkTaskCount: args.visibleSdkTaskCount,
visibleTaskCount: args.visibleTaskCount,
},
})
}
public captureGrpcResponseSize(sizeUtf8Bytes: number, service: string, method: string, requestId?: string): void {
this.recordHistogram(
TelemetryService.METRICS.GRPC.RESPONSE_SIZE_BYTES,
sizeUtf8Bytes,
{
service,
method,
...(requestId && { request_id: requestId }),
},
"Size of gRPC response messages in bytes",
)
if (sizeUtf8Bytes > 4 * 1024 * 1024) {
Logger.warn(
`[TelemetryService] Large gRPC response: ${service}.${method} ` +
`size=${(sizeUtf8Bytes / (1024 * 1024)).toFixed(1)}MB` +
(requestId ? ` request_id=${requestId}` : ""),
)
}
}
/**
* Safely executes a telemetry call with error protection.
*
* Use for critical execution paths where telemetry errors could break functionality:
* - Hook execution (during tool execution)
* - Browser automation (during active sessions)
* - Auth flows, task initialization
* - MCP server operations
*
* Not needed for non-critical, fire-and-forget events:
* - UI events (clicks, navigation)
* - Post-completion events
* - Background operations
*
* This wrapper protects against both pre-provider errors (parameter construction,
* property access, calculations) and provider-level errors (network, API failures).
*
* @param telemetryFn The telemetry function to execute
* @param context Optional context string for debugging (e.g., "HookFactory.exec")
*
* @example
* telemetryService.safeCapture(
* () => telemetryService.captureHookExecution(taskId, hookName, "started", {...}),
* 'HookFactory.exec.started'
* )
*/
public safeCapture(telemetryFn: () => void, context?: string): void {
try {
telemetryFn()
} catch (error) {
const contextStr = context ? ` [Context: ${context}]` : ""
Logger.error(`[Telemetry] Failed to capture telemetry${contextStr}:`, error)
}
}
/**
* Clean up resources when the service is disposed
*/
public async dispose(): Promise<void> {
const disposePromises = this.providers.map((provider) => provider.dispose())
await Promise.allSettled(disposePromises)
}
}