Compare commits

...
Author SHA1 Message Date
NightTrek a75e71a00f refactor(telemetry): extract event capture logic into dedicated event classes
Extract telemetry event capture logic from TelemetryService into separate event class modules (BrowserEvents, DictationEvents, TaskEvents, UIEvents, WorkspaceEvents). This improves code organization, maintainability, and separation of concerns by:

- Creating dedicated event classes with static capture methods
- Encapsulating event properties and capture logic per category
- Simplifying TelemetryService methods to delegate to event classes
- Reducing code duplication and improving testability

Each event category now has its own module with strongly-typed property interfaces and focused capture methods, making the codebase more modular and easier to maintain.
2025-10-01 01:10:28 -07:00
NightTrek e2a306ccff refactor: remove dotenv dependency and use launch.json envFile
- Remove dotenv import and config() call from esbuild.mjs
- Add envFile parameter to all launch.json configurations to load .env
- Remove dotenv from package.json devDependencies

Environment variables are now loaded via VSCode's envFile feature for local
development, while CI/production continues to inject via GitHub Actions.
This provides cleaner separation between build-time and runtime environment
handling.
2025-10-01 00:18:49 -07:00
NightTrek f8dd797a74 refactor(telemetry): remove unnecessary addProperties method and improve type safety
- Remove addProperties helper method that used 'any' types
- Replace with inline typed spread operations in capture(), captureRequired(), and identifyAccount()
- Fix type errors in captureConversationTurnEvent and captureBrowserError
- All telemetry properties now properly typed as TelemetryProperties
- Ensures OpenTelemetry compatibility through type system enforcement
2025-09-30 23:35:09 -07:00
NightTrek 2fd9e3209f fix: remove race condition in captureToProviders and reorganize PostHog providers
- Changed captureToProviders from async to synchronous method
- Removed unnecessary Promise.allSettled overhead since provider.log() and provider.logRequired() are synchronous
- Changed from .map() to .forEach() for better clarity
- Moved PostHog provider files into posthog/ subdirectory for better organization
- Updated all import paths to reflect new folder structure
2025-09-30 23:26:59 -07:00
NightTrek 1c6a58dff2 fix: update import paths after PostHogClientProvider relocation 2025-09-30 22:41:30 -07:00
NightTrek 098b312392 removed jitsu 2025-09-30 22:37:44 -07:00
NightTrek c8144055f2 chore: add changeset for Jitsu removal 2025-09-30 22:32:06 -07:00
NightTrek 43223234fe refactor: remove Jitsu telemetry provider
- Remove Jitsu provider implementation and config files
- Remove Jitsu environment variables from .env.example
- Remove Jitsu build configuration from esbuild.mjs
- Update TelemetryProviderFactory to only support PostHog
- Uninstall @jitsu/js dependency
- Add .env to .gitignore to prevent committing local env files
2025-09-30 22:27:10 -07:00
NightTrek b9dc6dec43 moved and organized the telemetry files and updated the example env file to be more descriptive 2025-09-29 09:22:33 -07:00
NightTrek b642b29ed4 fix(telemetry): Replace Record<string, unknown> with proper JSON-serializable types
- Add TelemetryPrimitive, TelemetryValue, TelemetryObject, and TelemetryProperties types to ITelemetryProvider
- Update JitsuTelemetryProvider to use TelemetryProperties instead of Record<string, unknown>
- Update PostHogTelemetryProvider to use TelemetryProperties instead of Record<string, unknown>
- Update TelemetryService to use TelemetryProperties for type-safe telemetry data
- Ensures all telemetry properties are JSON-serializable, preventing runtime errors
- Fixes TypeScript compatibility issue between Jitsu's JSONObject type and Record<string, unknown>
2025-09-27 17:50:36 -07:00
NightTrek 5e147522a5 fix(build): Load environment variables from .env file during development builds
- Add dotenv.config() to esbuild.mjs to load .env variables
- Include all telemetry-related environment variables in build injection:
  - TELEMETRY_SERVICE_API_KEY (PostHog)
  - ERROR_SERVICE_API_KEY (PostHog error tracking)
  - JITSU_WRITE_KEY (Jitsu telemetry)
  - JITSU_HOST (Jitsu host URL)
  - JITSU_ENABLED (Jitsu provider control)
  - POSTHOG_TELEMETRY_ENABLED (PostHog provider control)

This ensures telemetry services work correctly in development builds
by properly injecting API keys and configuration from .env file.

Also updates TelemetryService tests to support multi-provider architecture.
2025-09-27 16:43:45 -07:00
NightTrek ed3b5cdd9c feat: Modular telemetry architecture with Jitsu provider support
- Add dual-provider telemetry architecture supporting both Jitsu and PostHog
- Implement JitsuTelemetryProvider with full API compatibility
- Add required telemetry bypass for critical system health events
- Create modular event handler base class for future extensibility
- Add Jitsu configuration with environment variable controls
- Update TelemetryService to support multiple providers with error isolation
- Add .env.example template for development setup
- Maintain backward compatibility with existing PostHog integration
- Enable easy PostHog removal via POSTHOG_TELEMETRY_ENABLED=false
- Install dotenv for local development environment support

Key benefits:
- Dual tracking during transition period
- Error isolation between providers
- Memory efficient static method architecture
- Easy provider enable/disable via environment variables
- Wednesday deployment ready for Jitsu migration
2025-09-27 15:18:43 -07:00
24 changed files with 1587 additions and 469 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Refactored the Telemetry service to support multiple providers for a future where we support Otel
+48
View File
@@ -0,0 +1,48 @@
# Cline Development Environment Variables
# Copy this file to .env and fill in your actual values
# Values should be obtained from 1Password shared vault for development
# ============================================================================
# DEVELOPMENT FLAGS
# Recomend not changing these unless you know what you're doing they are set by the launch.json normally
# ============================================================================
# IS_DEV=true
# CLINE_ENVIRONMENT=local
# ============================================================================
# POSTHOG TELEMETRY (Existing)
# ============================================================================
# Get these values from 1Password shared vault
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
# ============================================================================
# TELEMETRY PROVIDER CONTROL
# ============================================================================
# Control which telemetry providers are active
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
# Set to false to disable Telemetry completely
# ============================================================================
# OPTIONAL DEVELOPMENT SETTINGS
# ============================================================================
# Uncomment and modify as needed for development
# Multi-root workspace debugging
# MULTI_ROOT_TRACE=true
# gRPC recorder for testing
# GRPC_RECORDER_ENABLED=true
# GRPC_RECORDER_FILE_NAME=test-recording
# Test mode
# E2E_TEST=true
# IS_TEST=true
# ============================================================================
# USAGE INSTRUCTIONS
# ============================================================================
# 1. Copy this file: cp .env.example .env
# 2. Get PostHog keys from 1Password shared vault
# 3. Update the values in .env
# 4. The .env file is gitignored for security
+2 -1
View File
@@ -26,6 +26,7 @@ coverage-unit
!.github/scripts/coverage/
*evals.env
.env
## Generated files ##
src/generated/
@@ -36,4 +37,4 @@ webview-ui/src/services/grpc-client.ts
test-results
## CLI pre-release ##
/cli
/cli
+6
View File
@@ -19,6 +19,7 @@
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "${defaultBuildTask}",
"envFile": "${workspaceFolder}/.env",
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
@@ -39,6 +40,7 @@
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "${defaultBuildTask}",
"envFile": "${workspaceFolder}/.env",
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
@@ -59,6 +61,7 @@
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "${defaultBuildTask}",
"envFile": "${workspaceFolder}/.env",
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
@@ -84,6 +87,7 @@
"preLaunchTask": "clean-tmp-user",
"internalConsoleOptions": "openOnSessionStart",
"postDebugTask": "stop",
"envFile": "${workspaceFolder}/.env",
"env": {
"IS_DEV": "true",
"TEMP_PROFILE": "true",
@@ -114,6 +118,7 @@
"tsx"
],
"program": "scripts/test-standalone-core-api-server.ts",
"envFile": "${workspaceFolder}/.env",
"env": {
"PROTOBUS_PORT": "26040",
"HOSTBRIDGE_PORT": "26041",
@@ -151,6 +156,7 @@
"--exit",
"${file}"
],
"envFile": "${workspaceFolder}/.env",
"env": {
"TS_NODE_PROJECT": "./tsconfig.unit-test.json",
"NODE_ENV": "test",
+4
View File
@@ -139,6 +139,10 @@ if (process.env.TELEMETRY_SERVICE_API_KEY) {
if (process.env.ERROR_SERVICE_API_KEY) {
buildEnvVars["process.env.ERROR_SERVICE_API_KEY"] = JSON.stringify(process.env.ERROR_SERVICE_API_KEY)
}
if (process.env.POSTHOG_TELEMETRY_ENABLED) {
buildEnvVars["process.env.POSTHOG_TELEMETRY_ENABLED"] = JSON.stringify(process.env.POSTHOG_TELEMETRY_ENABLED)
}
// Base configuration shared between extension and standalone builds
const baseConfig = {
bundle: true,
+14
View File
@@ -106,6 +106,7 @@
"c8": "^10.1.3",
"chai": "^4.3.10",
"chalk": "5.6.2",
"dotenv": "^17.2.2",
"esbuild": "^0.25.0",
"grpc-tools": "^1.13.0",
"husky": "^9.1.7",
@@ -7541,6 +7542,19 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "17.2.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dprint-node": {
"version": "1.0.8",
"dev": true,
+1 -1
View File
@@ -17,8 +17,8 @@ import { audioRecordingService } from "./services/dictation/AudioRecordingServic
import { ErrorService } from "./services/error"
import { featureFlagsService } from "./services/feature-flags"
import { initializeDistinctId } from "./services/logging/distinctId"
import { PostHogClientProvider } from "./services/posthog/PostHogClientProvider"
import { telemetryService } from "./services/telemetry"
import { PostHogClientProvider } from "./services/telemetry/providers/posthog/PostHogClientProvider"
import { ShowMessageType } from "./shared/proto/host/window"
import { getLatestAnnouncementId } from "./utils/announcements"
/**
@@ -2,7 +2,7 @@ import { PostHog } from "posthog-node"
import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { getDistinctId } from "@/services/logging/distinctId"
import { PostHogClientProvider } from "@/services/posthog/PostHogClientProvider"
import { PostHogClientProvider } from "@/services/telemetry/providers/posthog/PostHogClientProvider"
import { Setting } from "@/shared/proto/index.host"
import * as pkg from "../../../../package.json"
import { PostHogClientValidConfig } from "../../../shared/services/config/posthog-config"
@@ -1,6 +1,6 @@
import { isPostHogConfigValid, posthogConfig } from "@/shared/services/config/posthog-config"
import { Logger } from "../logging/Logger"
import { PostHogClientProvider } from "../posthog/PostHogClientProvider"
import { PostHogClientProvider } from "../telemetry/providers/posthog/PostHogClientProvider"
import type { IFeatureFlagsProvider } from "./providers/IFeatureFlagsProvider"
import { PostHogFeatureFlagsProvider } from "./providers/PostHogFeatureFlagsProvider"
@@ -1,8 +1,8 @@
import { isPostHogConfigValid, posthogConfig } from "@/shared/services/config/posthog-config"
import { Logger } from "../logging/Logger"
import { PostHogClientProvider } from "../posthog/PostHogClientProvider"
import type { ITelemetryProvider } from "./providers/ITelemetryProvider"
import { PostHogTelemetryProvider } from "./providers/PostHogTelemetryProvider"
import { PostHogClientProvider } from "./providers/posthog/PostHogClientProvider"
import { PostHogTelemetryProvider } from "./providers/posthog/PostHogTelemetryProvider"
/**
* Supported telemetry provider types
@@ -22,15 +22,45 @@ export interface TelemetryProviderConfig {
*/
export class TelemetryProviderFactory {
/**
* Creates a telemetry provider based on the provided configuration
* Creates multiple telemetry providers based on configuration
* Supports dual tracking during transition period
* @returns Array of ITelemetryProvider instances
*/
public static async createProviders(): Promise<ITelemetryProvider[]> {
const providers: ITelemetryProvider[] = []
// Add PostHog if enabled and configured
if (isPostHogConfigValid(posthogConfig)) {
try {
const sharedClient = PostHogClientProvider.getClient()
if (sharedClient) {
const posthogProvider = await new PostHogTelemetryProvider(sharedClient).initialize()
providers.push(posthogProvider)
Logger.info("TelemetryProviderFactory: PostHog provider initialized")
}
} catch (error) {
console.error("TelemetryProviderFactory: Failed to initialize PostHog provider:", error)
}
}
// Fallback to no-op if no providers available
if (providers.length === 0) {
providers.push(new NoOpTelemetryProvider())
Logger.info("TelemetryProviderFactory: Using NoOp provider (no valid configs)")
}
return providers
}
/**
* Creates a single telemetry provider based on the provided configuration
* @param config Configuration for the telemetry provider
* @returns ITelemetryProvider instance
* @deprecated Use createProviders() for multi-provider support
*/
public static async createProvider(config: TelemetryProviderConfig): Promise<ITelemetryProvider> {
// Get the shared PostHog client from PostHogClientProvider
switch (config.type) {
case "posthog": {
// Get the shared PostHog client from PostHogClientProvider
const sharedClient = PostHogClientProvider.getClient()
if (sharedClient) {
return await new PostHogTelemetryProvider(sharedClient).initialize()
@@ -45,13 +75,13 @@ export class TelemetryProviderFactory {
/**
* Gets the default telemetry provider configuration
* @returns Default configuration using PostHog
* @returns Default configuration using available providers
*/
public static getDefaultConfig(): TelemetryProviderConfig {
const hasValidConfig = isPostHogConfigValid(posthogConfig)
return {
type: hasValidConfig ? "posthog" : "no-op",
if (isPostHogConfigValid(posthogConfig)) {
return { type: "posthog" }
}
return { type: "no-op" }
}
}
@@ -66,6 +96,10 @@ export class NoOpTelemetryProvider implements ITelemetryProvider {
Logger.log(`[NoOpTelemetryProvider] ${event}: ${JSON.stringify(properties)}`)
}
public logRequired(event: string, properties?: Record<string, unknown>): void {
Logger.log(`[NoOpTelemetryProvider] REQUIRED ${event}: ${JSON.stringify(properties)}`)
}
public identifyUser(userInfo: any, properties?: Record<string, unknown>): void {
Logger.info(`[NoOpTelemetryProvider] identifyUser - ${JSON.stringify(userInfo)} - ${JSON.stringify(properties)}`)
}
@@ -1,7 +1,7 @@
/**
* Tests for the abstracted telemetry system
* This demonstrates how easy it is to switch between providers
* and validates the NoOpTelemetryProvider functionality
* Tests for the abstracted multi-provider telemetry system
* This demonstrates the multi-provider architecture that supports dual tracking,
* validates provider switching capabilities, and ensures NoOpTelemetryProvider functionality
*/
import * as assert from "assert"
@@ -48,7 +48,7 @@ describe("Telemetry system is abstracted and can easily switch between providers
const logSpy = sinon.spy(noOpProvider, "log")
const identifyUserSpy = sinon.spy(noOpProvider, "identifyUser")
const telemetryService = new TelemetryService(noOpProvider, MOCK_METADATA)
const telemetryService = new TelemetryService([noOpProvider], MOCK_METADATA)
// Reset the spy to ignore the initial telemetry event from constructor
logSpy.resetHistory()
@@ -88,6 +88,69 @@ describe("Telemetry system is abstracted and can easily switch between providers
await noOpProvider.dispose()
})
it("should support multi-provider telemetry for dual tracking", async () => {
// Create multiple providers for dual tracking scenario
const noOpProvider1 = await TelemetryProviderFactory.createProvider({
type: "no-op",
})
const noOpProvider2 = await TelemetryProviderFactory.createProvider({
type: "no-op",
})
// Spy on both providers to verify they both receive events
const logSpy1 = sinon.spy(noOpProvider1, "log")
const logSpy2 = sinon.spy(noOpProvider2, "log")
const identifyUserSpy1 = sinon.spy(noOpProvider1, "identifyUser")
const identifyUserSpy2 = sinon.spy(noOpProvider2, "identifyUser")
// Create TelemetryService with multiple providers
const telemetryService = new TelemetryService([noOpProvider1, noOpProvider2], MOCK_METADATA)
// Reset spies to ignore constructor events
logSpy1.resetHistory()
logSpy2.resetHistory()
// Test that events are sent to both providers
telemetryService.captureTaskCreated("multi-task-123", "anthropic")
// Verify both providers received the event
assert.ok(logSpy1.calledOnce, "First provider should receive the event")
assert.ok(logSpy2.calledOnce, "Second provider should receive the event")
// Verify event content is correct for both providers
const [eventName1, properties1] = logSpy1.firstCall.args
const [eventName2, properties2] = logSpy2.firstCall.args
assert.strictEqual(eventName1, "task.created", "First provider should receive correct event name")
assert.strictEqual(eventName2, "task.created", "Second provider should receive correct event name")
const expectedProperties = {
ulid: "multi-task-123",
apiProvider: "anthropic",
...MOCK_METADATA,
}
assert.deepStrictEqual(properties1, expectedProperties, "First provider should receive correct properties")
assert.deepStrictEqual(properties2, expectedProperties, "Second provider should receive correct properties")
// Test user identification with multiple providers
telemetryService.identifyAccount(MOCK_USER_INFO)
assert.ok(identifyUserSpy1.calledOnce, "First provider should receive user identification")
assert.ok(identifyUserSpy2.calledOnce, "Second provider should receive user identification")
// Verify provider count
const providers = telemetryService.getProviders()
assert.strictEqual(providers.length, 2, "Should have exactly 2 providers")
// Cleanup
logSpy1.restore()
logSpy2.restore()
identifyUserSpy1.restore()
identifyUserSpy2.restore()
await noOpProvider1.dispose()
await noOpProvider2.dispose()
})
})
describe("PostHog Provider", () => {
it("should create PostHog provider and track events", async () => {
@@ -96,7 +159,7 @@ describe("Telemetry system is abstracted and can easily switch between providers
type: "posthog",
})
const posthogTelemetryService = new TelemetryService(posthogProvider, MOCK_METADATA)
const posthogTelemetryService = new TelemetryService([posthogProvider], MOCK_METADATA)
// Test various telemetry methods
posthogTelemetryService.captureTaskCreated("task-123", "anthropic")
@@ -127,7 +190,7 @@ describe("Telemetry system is abstracted and can easily switch between providers
type: "no-op",
})
const noOpTelemetryService = new TelemetryService(noOpProvider, MOCK_METADATA)
const noOpTelemetryService = new TelemetryService([noOpProvider], MOCK_METADATA)
// Test various telemetry methods - should all be no-ops
noOpTelemetryService.captureTaskCreated("task-789", "google")
@@ -190,7 +253,7 @@ describe("Telemetry system is abstracted and can easily switch between providers
)
// Should handle all operations safely
const telemetryService = new TelemetryService(unsupportedProvider, MOCK_METADATA)
const telemetryService = new TelemetryService([unsupportedProvider], MOCK_METADATA)
telemetryService.captureTaskCreated("task-456", "test")
telemetryService.identifyAccount(MOCK_USER_INFO)
@@ -224,7 +287,7 @@ describe("Telemetry system is abstracted and can easily switch between providers
const posthogProvider = await TelemetryProviderFactory.createProvider({
type: "posthog",
})
let telemetryService = new TelemetryService(posthogProvider, MOCK_METADATA)
let telemetryService = new TelemetryService([posthogProvider], MOCK_METADATA)
telemetryService.captureTaskCreated("task-switch-1", "anthropic")
console.log("Captured event with PostHog provider")
@@ -235,7 +298,7 @@ describe("Telemetry system is abstracted and can easily switch between providers
const noOpProvider = await TelemetryProviderFactory.createProvider({
type: "no-op",
})
telemetryService = new TelemetryService(noOpProvider, MOCK_METADATA)
telemetryService = new TelemetryService([noOpProvider], MOCK_METADATA)
telemetryService.captureTaskCreated("task-switch-2", "openai")
console.log("Captured event with No-Op provider")
+133 -436
View File
@@ -8,7 +8,12 @@ import { Setting } from "@/shared/proto/index.host"
import { Mode } from "@/shared/storage/types"
import { version as extensionVersion } from "../../../package.json"
import { setDistinctId } from "../logging/distinctId"
import type { ITelemetryProvider } from "./providers/ITelemetryProvider"
import { BrowserEvents } from "./events/BrowserEvents"
import { DictationEvents } from "./events/DictationEvents"
import { TaskEvents } from "./events/TaskEvents"
import { UIEvents } from "./events/UIEvents"
import { WorkspaceEvents } from "./events/WorkspaceEvents"
import type { ITelemetryProvider, TelemetryProperties } from "./providers/ITelemetryProvider"
import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
/**
@@ -202,9 +207,7 @@ export class TelemetryService {
}
public static async create(): Promise<TelemetryService> {
const provider = await TelemetryProviderFactory.createProvider({
type: "posthog",
})
const providers = await TelemetryProviderFactory.createProviders()
const hostVersion = await HostProvider.env.getHostVersion({})
const metadata: TelemetryMetadata = {
extension_version: extensionVersion,
@@ -214,19 +217,19 @@ export class TelemetryService {
os_version: os.version(),
is_dev: process.env.IS_DEV,
}
return new TelemetryService(provider, metadata)
return new TelemetryService(providers, metadata)
}
/**
* Constructor that accepts a PostHogClientProvider instance
* @param provider PostHogClientProvider instance for sending analytics events
* Constructor that accepts multiple telemetry providers for dual tracking
* @param providers Array of telemetry providers for dual/multi tracking
*/
constructor(
private provider: ITelemetryProvider,
private providers: ITelemetryProvider[],
private telemetryMetadata: TelemetryMetadata,
) {
this.capture({ event: TelemetryService.EVENTS.USER.TELEMETRY_ENABLED })
console.info("[TelemetryService] Initialized with telemetry provider")
console.info(`[TelemetryService] Initialized with ${providers.length} telemetry provider(s)`)
}
/**
@@ -261,30 +264,59 @@ export class TelemetryService {
}
}
this.provider.setOptIn(didUserOptIn)
}
private addProperties(properties: any): any {
return {
...properties,
...this.telemetryMetadata,
}
// Update all providers
this.providers.forEach((provider) => {
provider.setOptIn(didUserOptIn)
})
}
/**
* Captures a telemetry event if telemetry is enabled
* @param event The event to capture with its properties
*/
public capture(event: { event: string; properties?: unknown }): void {
const propertiesWithVersion = this.addProperties(event.properties)
public capture(event: { event: string; properties?: TelemetryProperties }): void {
const propertiesWithMetadata: TelemetryProperties = {
...(event.properties || {}),
...this.telemetryMetadata,
}
this.captureToProviders(event.event, propertiesWithMetadata, false)
}
// Use the provider's log method
this.provider.log(event.event, propertiesWithVersion)
/**
* 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) {
console.error(`[TelemetryService] Provider failed for event ${event}:`, error)
}
})
}
public captureExtensionActivated() {
// Use provider's log method for the activation event
this.provider.log(TelemetryService.EVENTS.USER.EXTENSION_ACTIVATED)
this.captureToProviders(TelemetryService.EVENTS.USER.EXTENSION_ACTIVATED, {}, false)
}
/**
@@ -292,9 +324,19 @@ export class TelemetryService {
* @param userInfo The user's information
*/
public identifyAccount(userInfo: ClineAccountUserInfo) {
const propertiesWithVersion = this.addProperties({})
// Use the provider's log method instead of direct client capture
this.provider.identifyUser(userInfo, propertiesWithVersion)
const propertiesWithMetadata: TelemetryProperties = {
...this.telemetryMetadata,
}
// Update all providers with error isolation
this.providers.forEach((provider) => {
try {
provider.identifyUser(userInfo, propertiesWithMetadata)
} catch (error) {
console.error(`[TelemetryService] Provider failed for user identification:`, error)
}
})
if (userInfo.id) {
setDistinctId(userInfo.id)
}
@@ -309,15 +351,7 @@ export class TelemetryService {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.RECORDING_STARTED,
properties: {
taskId,
platform: platform ?? process.platform,
timestamp: new Date().toISOString(),
},
})
DictationEvents.captureVoiceRecordingStarted(this, taskId, platform)
}
/**
@@ -331,17 +365,7 @@ export class TelemetryService {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.RECORDING_STOPPED,
properties: {
taskId,
durationMs,
success,
platform: platform ?? process.platform,
timestamp: new Date().toISOString(),
},
})
DictationEvents.captureVoiceRecordingStopped(this, taskId, durationMs, success, platform)
}
/**
@@ -353,15 +377,7 @@ export class TelemetryService {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_STARTED,
properties: {
taskId,
language,
timestamp: new Date().toISOString(),
},
})
DictationEvents.captureVoiceTranscriptionStarted(this, taskId, language)
}
/**
@@ -382,18 +398,7 @@ export class TelemetryService {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_COMPLETED,
properties: {
taskId,
transcriptionLength,
durationMs,
language,
accountType: isOrgAccount ? "organization" : "personal",
timestamp: new Date().toISOString(),
},
})
DictationEvents.captureVoiceTranscriptionCompleted(this, taskId, transcriptionLength, durationMs, language, isOrgAccount)
}
/**
@@ -407,17 +412,7 @@ export class TelemetryService {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_ERROR,
properties: {
taskId,
errorType,
errorMessage,
durationMs,
timestamp: new Date().toISOString(),
},
})
DictationEvents.captureVoiceTranscriptionError(this, taskId, errorType, errorMessage, durationMs)
}
// Task events
/**
@@ -426,10 +421,7 @@ export class TelemetryService {
* @param apiProvider Optional API provider
*/
public captureTaskCreated(ulid: string, apiProvider?: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.CREATED,
properties: { ulid, apiProvider },
})
TaskEvents.captureTaskCreated(this, ulid, apiProvider)
}
/**
@@ -438,10 +430,7 @@ export class TelemetryService {
* @param apiProvider Optional API provider
*/
public captureTaskRestarted(ulid: string, apiProvider?: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.RESTARTED,
properties: { ulid, apiProvider },
})
TaskEvents.captureTaskRestarted(this, ulid, apiProvider)
}
/**
@@ -449,10 +438,7 @@ export class TelemetryService {
* @param ulid Unique identifier for the task
*/
public captureTaskCompleted(ulid: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.COMPLETED,
properties: { ulid },
})
TaskEvents.captureTaskCompleted(this, ulid)
}
/**
@@ -476,25 +462,7 @@ export class TelemetryService {
totalCost?: number
} = {},
) {
// Ensure required parameters are provided
if (!ulid || !provider || !model || !source) {
console.warn("TelemetryService: Missing required parameters for message capture")
return
}
const properties: Record<string, unknown> = {
ulid,
provider,
model,
source,
timestamp: new Date().toISOString(), // Add timestamp for message sequencing
...tokenUsage,
}
this.capture({
event: TelemetryService.EVENTS.TASK.CONVERSATION_TURN,
properties,
})
TaskEvents.captureConversationTurnEvent(this, ulid, provider, model, source, tokenUsage)
}
/**
@@ -505,15 +473,7 @@ export class TelemetryService {
* @param model The model used for token calculation
*/
public captureTokenUsage(ulid: string, tokensIn: number, tokensOut: number, model: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.TOKEN_USAGE,
properties: {
ulid,
tokensIn,
tokensOut,
model,
},
})
TaskEvents.captureTokenUsage(this, ulid, tokensIn, tokensOut, model)
}
/**
@@ -522,13 +482,7 @@ export class TelemetryService {
* @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,
},
})
TaskEvents.captureModeSwitch(this, ulid, mode)
}
/**
@@ -539,15 +493,7 @@ export class TelemetryService {
* @param maxContextWindow Maximum context window size for the model
*/
public captureSummarizeTask(ulid: string, modelId: string, currentTokens: number, maxContextWindow: number) {
this.capture({
event: TelemetryService.EVENTS.TASK.AUTO_COMPACT,
properties: {
ulid,
modelId,
currentTokens,
maxContextWindow,
},
})
TaskEvents.captureSummarizeTask(this, ulid, modelId, currentTokens, maxContextWindow)
}
/**
@@ -556,17 +502,7 @@ export class TelemetryService {
* @param feedbackType The type of feedback ("thumbs_up" or "thumbs_down")
*/
public captureTaskFeedback(ulid: string, feedbackType: TaskFeedbackType) {
console.info("TelemetryService: Capturing task feedback", {
ulid,
feedbackType,
})
this.capture({
event: TelemetryService.EVENTS.TASK.FEEDBACK,
properties: {
ulid,
feedbackType,
},
})
TaskEvents.captureTaskFeedback(this, ulid, feedbackType)
}
// Tool events
@@ -578,16 +514,7 @@ export class TelemetryService {
* @param success Whether the tool execution was successful
*/
public captureToolUsage(ulid: string, tool: string, modelId: string, autoApproved: boolean, success: boolean) {
this.capture({
event: TelemetryService.EVENTS.TASK.TOOL_USED,
properties: {
ulid,
tool,
autoApproved,
success,
modelId,
},
})
TaskEvents.captureToolUsage(this, ulid, tool, modelId, autoApproved, success)
}
/**
@@ -611,17 +538,7 @@ export class TelemetryService {
errorMessage?: string,
argumentKeys?: string[],
) {
this.capture({
event: TelemetryService.EVENTS.TASK.MCP_TOOL_CALLED,
properties: {
ulid,
serverName,
toolName,
status,
errorMessage,
argumentKeys,
},
})
TaskEvents.captureMcpToolCall(this, ulid, serverName, toolName, status, errorMessage, argumentKeys)
}
/**
@@ -638,15 +555,7 @@ export class TelemetryService {
if (!this.isCategoryEnabled("checkpoints")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.CHECKPOINT_USED,
properties: {
ulid,
action,
durationMs,
},
})
TaskEvents.captureCheckpointUsage(this, ulid, action, durationMs)
}
/**
@@ -655,14 +564,7 @@ export class TelemetryService {
* @param errorType Type of error that occurred (e.g., "search_not_found", "invalid_format")
*/
public captureDiffEditFailure(ulid: string, modelId: string, errorType?: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.DIFF_EDIT_FAILED,
properties: {
ulid,
errorType,
modelId,
},
})
TaskEvents.captureDiffEditFailure(this, ulid, modelId, errorType)
}
/**
@@ -672,14 +574,7 @@ export class TelemetryService {
* @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,
},
})
UIEvents.captureModelSelected(this, model, provider, ulid)
}
/**
@@ -691,17 +586,7 @@ export class TelemetryService {
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(),
},
})
BrowserEvents.captureBrowserToolStart(this, ulid, browserSettings)
}
/**
@@ -720,17 +605,7 @@ export class TelemetryService {
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(),
},
})
BrowserEvents.captureBrowserToolEnd(this, ulid, stats)
}
/**
@@ -748,23 +623,15 @@ export class TelemetryService {
action?: string
url?: string
isRemote?: boolean
[key: string]: unknown
remoteBrowserHost?: string
endpoint?: string
[key: string]: string | number | boolean | undefined
},
) {
if (!this.isCategoryEnabled("browser")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.BROWSER_ERROR,
properties: {
ulid,
errorType,
errorMessage,
context,
timestamp: new Date().toISOString(),
},
})
BrowserEvents.captureBrowserError(this, ulid, errorType, errorMessage, context)
}
/**
@@ -774,14 +641,7 @@ export class TelemetryService {
* @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,
},
})
TaskEvents.captureOptionSelected(this, ulid, qty, mode)
}
/**
@@ -791,14 +651,7 @@ export class TelemetryService {
* @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,
},
})
TaskEvents.captureOptionsIgnored(this, ulid, qty, mode)
}
/**
@@ -823,14 +676,7 @@ export class TelemetryService {
throughputTokensPerSec?: number
},
) {
this.capture({
event: TelemetryService.EVENTS.TASK.GEMINI_API_PERFORMANCE,
properties: {
ulid,
modelId,
...data,
},
})
TaskEvents.captureGeminiApiPerformance(this, ulid, modelId, data)
}
/**
@@ -839,23 +685,11 @@ export class TelemetryService {
* @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,
},
})
UIEvents.captureModelFavoritesUsage(this, model, isFavorited)
}
public captureButtonClick(button: string, ulid?: string) {
this.capture({
event: TelemetryService.EVENTS.UI.BUTTON_CLICKED,
properties: {
button,
ulid,
},
})
UIEvents.captureButtonClick(this, button, ulid)
}
/**
@@ -875,14 +709,7 @@ export class TelemetryService {
errorStatus?: number | undefined
requestId?: string | undefined
}) {
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(),
},
})
TaskEvents.captureProviderApiError(this, args)
}
/**
@@ -893,13 +720,7 @@ export class TelemetryService {
if (!this.isCategoryEnabled("focus_chain")) {
return
}
this.capture({
event: enabled ? TelemetryService.EVENTS.TASK.FOCUS_CHAIN_ENABLED : TelemetryService.EVENTS.TASK.FOCUS_CHAIN_DISABLED,
properties: {
enabled,
},
})
TaskEvents.captureFocusChainToggle(this, enabled)
}
/**
@@ -911,14 +732,7 @@ export class TelemetryService {
if (!this.isCategoryEnabled("focus_chain")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.FOCUS_CHAIN_PROGRESS_FIRST,
properties: {
ulid,
totalItems,
},
})
TaskEvents.captureFocusChainProgressFirst(this, ulid, totalItems)
}
/**
@@ -931,16 +745,7 @@ export class TelemetryService {
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,
},
})
TaskEvents.captureFocusChainProgressUpdate(this, ulid, totalItems, completedItems)
}
/**
@@ -959,17 +764,7 @@ export class TelemetryService {
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,
},
})
TaskEvents.captureFocusChainIncompleteOnCompletion(this, ulid, totalItems, completedItems, incompleteItems)
}
/**
@@ -980,13 +775,7 @@ export class TelemetryService {
if (!this.isCategoryEnabled("focus_chain")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.FOCUS_CHAIN_LIST_OPENED,
properties: {
ulid,
},
})
TaskEvents.captureFocusChainListOpened(this, ulid)
}
/**
@@ -997,13 +786,7 @@ export class TelemetryService {
if (!this.isCategoryEnabled("focus_chain")) {
return
}
this.capture({
event: TelemetryService.EVENTS.TASK.FOCUS_CHAIN_LIST_WRITTEN,
properties: {
ulid,
},
})
TaskEvents.captureFocusChainListWritten(this, ulid)
}
/**
@@ -1013,14 +796,7 @@ export class TelemetryService {
* @param commandType Whether it's a built-in command or custom workflow
*/
public captureSlashCommandUsed(ulid: string, commandName: string, commandType: "builtin" | "workflow") {
this.capture({
event: TelemetryService.EVENTS.TASK.SLASH_COMMAND_USED,
properties: {
ulid,
commandName,
commandType,
},
})
TaskEvents.captureSlashCommandUsed(this, ulid, commandName, commandType)
}
/**
@@ -1031,18 +807,7 @@ export class TelemetryService {
* @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,
},
})
TaskEvents.captureClineRuleToggled(this, ulid, ruleFileName, enabled, isGlobal)
}
/**
@@ -1052,14 +817,7 @@ export class TelemetryService {
* @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,
},
})
TaskEvents.captureAutoCondenseToggle(this, ulid, enabled, modelId)
}
/**
@@ -1068,13 +826,7 @@ export class TelemetryService {
* @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,
},
})
TaskEvents.captureYoloModeToggle(this, ulid, enabled)
}
/**
@@ -1085,25 +837,14 @@ export class TelemetryService {
* @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,
},
})
TaskEvents.captureTaskInitialization(this, 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: {},
})
UIEvents.captureRulesMenuOpened(this)
}
// Terminal telemetry methods
@@ -1114,13 +855,7 @@ export class TelemetryService {
* @param method The method used to capture output ("shell_integration" | "clipboard" | "none")
*/
public captureTerminalExecution(success: boolean, method: "shell_integration" | "clipboard" | "none") {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_EXECUTION,
properties: {
success,
method,
},
})
TaskEvents.captureTerminalExecution(this, success, method)
}
/**
@@ -1128,12 +863,7 @@ export class TelemetryService {
* @param reason The reason for failure
*/
public captureTerminalOutputFailure(reason: TerminalOutputFailureReason) {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_OUTPUT_FAILURE,
properties: {
reason,
},
})
TaskEvents.captureTerminalOutputFailure(this, reason)
}
/**
@@ -1141,12 +871,7 @@ export class TelemetryService {
* @param action The user action
*/
public captureTerminalUserIntervention(action: TerminalUserInterventionAction) {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_USER_INTERVENTION,
properties: {
action,
},
})
TaskEvents.captureTerminalUserIntervention(this, action)
}
/**
@@ -1154,12 +879,7 @@ export class TelemetryService {
* @param stage Where the hang occurred
*/
public captureTerminalHang(stage: TerminalHangStage) {
this.capture({
event: TelemetryService.EVENTS.TASK.TERMINAL_HANG,
properties: {
stage,
},
})
TaskEvents.captureTerminalHang(this, stage)
}
// Workspace telemetry methods
@@ -1177,18 +897,7 @@ export class TelemetryService {
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,
},
})
WorkspaceEvents.captureWorkspaceInitialized(this, rootCount, vcsTypes, initDurationMs, featureFlagEnabled)
}
/**
@@ -1198,15 +907,7 @@ export class TelemetryService {
* @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,
},
})
WorkspaceEvents.captureWorkspaceInitError(this, error, fallbackMode, workspaceCount)
}
/**
@@ -1226,18 +927,7 @@ export class TelemetryService {
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,
},
})
WorkspaceEvents.captureMultiRootCheckpoint(this, ulid, action, rootCount, successCount, failureCount, durationMs)
}
/**
@@ -1251,33 +941,40 @@ export class TelemetryService {
}
/**
* Get the telemetry provider instance
* @returns The current telemetry provider
* Get the telemetry provider instances
* @returns The array of telemetry providers
*/
public getProvider(): ITelemetryProvider {
return this.provider
public getProviders(): ITelemetryProvider[] {
return [...this.providers]
}
/**
* Check if telemetry is currently enabled
* @returns Boolean indicating whether telemetry is enabled
* @returns Boolean indicating whether any provider is enabled
*/
public isEnabled(): boolean {
return this.provider.isEnabled()
return this.providers.some((provider) => provider.isEnabled())
}
/**
* Get current telemetry settings
* Get current telemetry settings from the first provider
* @returns Current telemetry settings
*/
public getSettings() {
return this.provider.getSettings()
return this.providers.length > 0
? this.providers[0].getSettings()
: {
extensionEnabled: false,
hostEnabled: false,
level: "off" as const,
}
}
/**
* Clean up resources when the service is disposed
*/
public async dispose(): Promise<void> {
await this.provider.dispose()
const disposePromises = this.providers.map((provider) => provider.dispose())
await Promise.allSettled(disposePromises)
}
}
@@ -0,0 +1,110 @@
import type { BrowserSettings } from "@shared/BrowserSettings"
import type { TelemetryProperties } from "../providers/ITelemetryProvider"
import type { TelemetryService } from "../TelemetryService"
import { EventHandlerBase } from "./EventHandlerBase"
/**
* Property types for browser telemetry events
*/
export interface BrowserToolStartProperties extends TelemetryProperties {
ulid: string
viewport?: { width: number; height: number }
isRemote: boolean
remoteBrowserHost?: string
timestamp: string
}
export interface BrowserErrorProperties extends TelemetryProperties {
ulid: string
errorType: string
errorMessage: string
action?: string
url?: string
isRemote?: boolean
remoteBrowserHost?: string
endpoint?: string
timestamp: string
}
export interface BrowserToolEndProperties extends TelemetryProperties {
ulid: string
actionCount: number
duration: number
actions?: string[]
timestamp: string
}
/**
* Event handler for browser-related telemetry events
*/
export class BrowserEvents extends EventHandlerBase {
static override readonly prefix = "browser"
/**
* Records when the browser tool is started
* @param service The telemetry service instance
* @param ulid Unique identifier for the task
* @param browserSettings The browser settings being used
*/
static captureBrowserToolStart(service: TelemetryService, ulid: string, browserSettings: BrowserSettings): void {
const properties: BrowserToolStartProperties = {
ulid,
viewport: browserSettings.viewport,
isRemote: !!browserSettings.remoteBrowserEnabled,
remoteBrowserHost: browserSettings.remoteBrowserHost,
timestamp: new Date().toISOString(),
}
BrowserEvents.capture(service, "task.browser_tool_start", properties)
}
/**
* Records when browser errors occur during a task
* @param service The telemetry service instance
* @param ulid Unique identifier for the task
* @param errorType Type of error that occurred
* @param errorMessage The error message
* @param context Additional context about where the error occurred
*/
static captureBrowserError(
service: TelemetryService,
ulid: string,
errorType: string,
errorMessage: string,
context?: Partial<BrowserErrorProperties>,
): void {
const properties: BrowserErrorProperties = {
ulid,
errorType,
errorMessage,
timestamp: new Date().toISOString(),
...context,
}
BrowserEvents.capture(service, "task.browser_error", properties)
}
/**
* Records when the browser tool is completed
* @param service The telemetry service instance
* @param ulid Unique identifier for the task
* @param stats Statistics about the browser session
*/
static captureBrowserToolEnd(
service: TelemetryService,
ulid: string,
stats: {
actionCount: number
duration: number
actions?: string[]
},
): void {
const properties: BrowserToolEndProperties = {
ulid,
actionCount: stats.actionCount,
duration: stats.duration,
actions: stats.actions,
timestamp: new Date().toISOString(),
}
BrowserEvents.capture(service, "task.browser_tool_end", properties)
}
}
@@ -0,0 +1,159 @@
import type { TelemetryProperties } from "../providers/ITelemetryProvider"
import type { TelemetryService } from "../TelemetryService"
import { EventHandlerBase } from "./EventHandlerBase"
/**
* Property types for dictation/voice telemetry events
*/
export interface VoiceRecordingStartedProperties extends TelemetryProperties {
taskId?: string
platform: string
timestamp: string
}
export interface VoiceRecordingStoppedProperties extends TelemetryProperties {
taskId?: string
durationMs?: number
success?: boolean
platform: string
timestamp: string
}
export interface VoiceTranscriptionStartedProperties extends TelemetryProperties {
taskId?: string
language?: string
timestamp: string
}
export interface VoiceTranscriptionCompletedProperties extends TelemetryProperties {
taskId?: string
transcriptionLength?: number
durationMs?: number
language?: string
accountType: string
timestamp: string
}
export interface VoiceTranscriptionErrorProperties extends TelemetryProperties {
taskId?: string
errorType?: string
errorMessage?: string
durationMs?: number
timestamp: string
}
/**
* Event handler for dictation/voice-related telemetry events
*/
export class DictationEvents extends EventHandlerBase {
static override readonly prefix = "dictation"
/**
* Records when voice recording is started
* @param service The telemetry service instance
* @param taskId Optional task identifier if recording was started during a task
* @param platform The platform where recording is happening
*/
static captureVoiceRecordingStarted(service: TelemetryService, taskId?: string, platform?: string): void {
const properties: VoiceRecordingStartedProperties = {
taskId,
platform: platform ?? process.platform,
timestamp: new Date().toISOString(),
}
DictationEvents.capture(service, "voice.recording_started", properties)
}
/**
* Records when voice recording is stopped
* @param service The telemetry service instance
* @param taskId Optional task identifier
* @param durationMs Duration of the recording in milliseconds
* @param success Whether the recording was successful
* @param platform The platform where recording happened
*/
static captureVoiceRecordingStopped(
service: TelemetryService,
taskId?: string,
durationMs?: number,
success?: boolean,
platform?: string,
): void {
const properties: VoiceRecordingStoppedProperties = {
taskId,
durationMs,
success,
platform: platform ?? process.platform,
timestamp: new Date().toISOString(),
}
DictationEvents.capture(service, "voice.recording_stopped", properties)
}
/**
* Records when voice transcription is started
* @param service The telemetry service instance
* @param taskId Optional task identifier
* @param language Language hint provided for transcription
*/
static captureVoiceTranscriptionStarted(service: TelemetryService, taskId?: string, language?: string): void {
const properties: VoiceTranscriptionStartedProperties = {
taskId,
language,
timestamp: new Date().toISOString(),
}
DictationEvents.capture(service, "voice.transcription_started", properties)
}
/**
* Records when voice transcription is completed successfully
* @param service The telemetry service instance
* @param taskId Optional task identifier
* @param transcriptionLength Length of the transcribed text
* @param durationMs Time taken for transcription in milliseconds
* @param language Language used for transcription
* @param isOrgAccount Whether the transcription was done using an organization account
*/
static captureVoiceTranscriptionCompleted(
service: TelemetryService,
taskId?: string,
transcriptionLength?: number,
durationMs?: number,
language?: string,
isOrgAccount?: boolean,
): void {
const properties: VoiceTranscriptionCompletedProperties = {
taskId,
transcriptionLength,
durationMs,
language,
accountType: isOrgAccount ? "organization" : "personal",
timestamp: new Date().toISOString(),
}
DictationEvents.capture(service, "voice.transcription_completed", properties)
}
/**
* Records when voice transcription fails
* @param service The telemetry service instance
* @param taskId Optional task identifier
* @param errorType Type of error that occurred
* @param errorMessage The error message
* @param durationMs Time taken before failure in milliseconds
*/
static captureVoiceTranscriptionError(
service: TelemetryService,
taskId?: string,
errorType?: string,
errorMessage?: string,
durationMs?: number,
): void {
const properties: VoiceTranscriptionErrorProperties = {
taskId,
errorType,
errorMessage,
durationMs,
timestamp: new Date().toISOString(),
}
DictationEvents.capture(service, "voice.transcription_error", properties)
}
}
@@ -0,0 +1,99 @@
import type { TelemetryService } from "../TelemetryService"
/**
* Base class for telemetry event handlers
* Provides common functionality for event capture with metadata enrichment
*/
export abstract class EventHandlerBase {
/** Event prefix for this handler (e.g., "task", "ui", "dictation") */
static readonly prefix: string
/**
* Capture a regular telemetry event
* @param service The telemetry service instance
* @param event The full event name (e.g., "task.created")
* @param properties Event properties
* @param required Whether this is a required event that bypasses user preferences
*/
protected static capture(
service: TelemetryService,
event: string,
properties?: import("../providers/ITelemetryProvider").TelemetryProperties,
required: boolean = false,
): void {
if (required) {
service.captureRequired(event, properties)
} else {
service.capture({ event, properties })
}
}
/**
* Capture a required event that bypasses telemetry opt-out settings
* @param service The telemetry service instance
* @param event The full event name
* @param properties Event properties
*/
protected static captureRequired(
service: TelemetryService,
event: string,
properties?: import("../providers/ITelemetryProvider").TelemetryProperties,
): void {
service.captureRequired(event, properties)
}
/**
* Check if telemetry is enabled for regular events
* @param service The telemetry service instance
* @returns Whether telemetry is enabled
*/
protected static isEnabled(service: TelemetryService): boolean {
return service.isEnabled()
}
}
/**
* Registry for automatic event handler registration
*/
export class EventHandlerRegistry {
private static handlers: Map<string, typeof EventHandlerBase> = new Map()
private static registered = false
/**
* Register an event handler for a specific prefix
* @param prefix The event prefix (e.g., "task", "ui")
* @param handlerClass The handler class
*/
static register(prefix: string, handlerClass: typeof EventHandlerBase): void {
EventHandlerRegistry.handlers.set(prefix, handlerClass)
}
/**
* Get a registered event handler by prefix
* @param prefix The event prefix
* @returns The handler class or undefined
*/
static getHandler(prefix: string): typeof EventHandlerBase | undefined {
return EventHandlerRegistry.handlers.get(prefix)
}
/**
* Auto-register all event handlers
* This is called once during TelemetryService initialization
*/
static registerAll(): void {
if (EventHandlerRegistry.registered) return
// Import and register all handlers
// This will be populated as we create the specific handlers
EventHandlerRegistry.registered = true
}
/**
* Get all registered prefixes
* @returns Array of registered event prefixes
*/
static getRegisteredPrefixes(): string[] {
return Array.from(EventHandlerRegistry.handlers.keys())
}
}
+571
View File
@@ -0,0 +1,571 @@
import type { TaskFeedbackType } from "@shared/WebviewMessage"
import { Mode } from "@/shared/storage/types"
import type { TelemetryProperties } from "../providers/ITelemetryProvider"
import type { TelemetryService } from "../TelemetryService"
import { EventHandlerBase } from "./EventHandlerBase"
/**
* Property types for task telemetry events
*/
export interface TaskCreatedProperties extends TelemetryProperties {
ulid: string
apiProvider?: string
}
export interface TaskCompletedProperties extends TelemetryProperties {
ulid: string
}
export interface TaskFeedbackProperties extends TelemetryProperties {
ulid: string
feedbackType: TaskFeedbackType
}
export interface ConversationTurnProperties extends TelemetryProperties {
ulid: string
provider: string
model: string
source: "user" | "assistant"
timestamp: string
tokensIn?: number
tokensOut?: number
cacheWriteTokens?: number
cacheReadTokens?: number
totalCost?: number
}
export interface TokenUsageProperties extends TelemetryProperties {
ulid: string
tokensIn: number
tokensOut: number
model: string
}
export interface ModeSwitchProperties extends TelemetryProperties {
ulid: string
mode: Mode
}
export interface ToolUsageProperties extends TelemetryProperties {
ulid: string
tool: string
modelId: string
autoApproved: boolean
success: boolean
}
export interface McpToolCallProperties extends TelemetryProperties {
ulid: string
serverName: string
toolName: string
status: "started" | "success" | "error"
errorMessage?: string
argumentKeys?: string[]
}
export interface CheckpointUsageProperties extends TelemetryProperties {
ulid: string
action: "shadow_git_initialized" | "commit_created" | "restored" | "diff_generated"
durationMs?: number
}
export interface DiffEditFailureProperties extends TelemetryProperties {
ulid: string
modelId: string
errorType?: string
}
export interface OptionSelectedProperties extends TelemetryProperties {
ulid: string
qty: number
mode: Mode
}
export interface GeminiApiPerformanceProperties extends TelemetryProperties {
ulid: string
modelId: string
ttftSec?: number
totalDurationSec?: number
promptTokens: number
outputTokens: number
cacheReadTokens: number
cacheHit: boolean
cacheHitPercentage?: number
apiSuccess: boolean
apiError?: string
throughputTokensPerSec?: number
}
export interface ProviderApiErrorProperties extends TelemetryProperties {
ulid: string
model: string
errorMessage: string
provider?: string
errorStatus?: number
requestId?: string
timestamp: string
}
export interface SummarizeTaskProperties extends TelemetryProperties {
ulid: string
modelId: string
currentTokens: number
maxContextWindow: number
}
export interface SlashCommandUsedProperties extends TelemetryProperties {
ulid: string
commandName: string
commandType: "builtin" | "workflow"
}
export interface ClineRuleToggledProperties extends TelemetryProperties {
ulid: string
ruleFileName: string
enabled: boolean
isGlobal: boolean
}
export interface AutoCondenseToggleProperties extends TelemetryProperties {
ulid: string
enabled: boolean
modelId: string
}
export interface YoloModeToggleProperties extends TelemetryProperties {
ulid: string
enabled: boolean
}
export interface TaskInitializationProperties extends TelemetryProperties {
ulid: string
taskId: string
durationMs: number
hasCheckpoints: boolean
}
export interface TerminalExecutionProperties extends TelemetryProperties {
success: boolean
method: "shell_integration" | "clipboard" | "none"
}
export interface TerminalOutputFailureProperties extends TelemetryProperties {
reason: string
}
export interface TerminalUserInterventionProperties extends TelemetryProperties {
action: string
}
export interface TerminalHangProperties extends TelemetryProperties {
stage: string
}
export interface FocusChainToggleProperties extends TelemetryProperties {
enabled: boolean
}
export interface FocusChainProgressFirstProperties extends TelemetryProperties {
ulid: string
totalItems: number
}
export interface FocusChainProgressUpdateProperties extends TelemetryProperties {
ulid: string
totalItems: number
completedItems: number
completionPercentage: number
}
export interface FocusChainIncompleteOnCompletionProperties extends TelemetryProperties {
ulid: string
totalItems: number
completedItems: number
incompleteItems: number
completionPercentage: number
}
export interface FocusChainListOpenedProperties extends TelemetryProperties {
ulid: string
}
export interface FocusChainListWrittenProperties extends TelemetryProperties {
ulid: string
}
/**
* Event handler for task-related telemetry events
*/
export class TaskEvents extends EventHandlerBase {
static override readonly prefix = "task"
/**
* Records when a new task/conversation is started
*/
static captureTaskCreated(service: TelemetryService, ulid: string, apiProvider?: string): void {
const properties: TaskCreatedProperties = { ulid, apiProvider }
TaskEvents.capture(service, "task.created", properties)
}
/**
* Records when a task/conversation is restarted
*/
static captureTaskRestarted(service: TelemetryService, ulid: string, apiProvider?: string): void {
const properties: TaskCreatedProperties = { ulid, apiProvider }
TaskEvents.capture(service, "task.restarted", properties)
}
/**
* Records when cline calls the task completion_result tool
*/
static captureTaskCompleted(service: TelemetryService, ulid: string): void {
const properties: TaskCompletedProperties = { ulid }
TaskEvents.capture(service, "task.completed", properties)
}
/**
* Records user feedback on completed tasks
*/
static captureTaskFeedback(service: TelemetryService, ulid: string, feedbackType: TaskFeedbackType): void {
const properties: TaskFeedbackProperties = { ulid, feedbackType }
TaskEvents.capture(service, "task.feedback", properties)
}
/**
* Captures that a message was sent
*/
static captureConversationTurnEvent(
service: TelemetryService,
ulid: string,
provider: string,
model: string,
source: "user" | "assistant",
tokenUsage: {
tokensIn?: number
tokensOut?: number
cacheWriteTokens?: number
cacheReadTokens?: number
totalCost?: number
} = {},
): void {
const properties: ConversationTurnProperties = {
ulid,
provider,
model,
source,
timestamp: new Date().toISOString(),
...tokenUsage,
}
TaskEvents.capture(service, "task.conversation_turn", properties)
}
/**
* Records token usage metrics
*/
static captureTokenUsage(service: TelemetryService, ulid: string, tokensIn: number, tokensOut: number, model: string): void {
const properties: TokenUsageProperties = { ulid, tokensIn, tokensOut, model }
TaskEvents.capture(service, "task.tokens", properties)
}
/**
* Records when a task switches between plan and act modes
*/
static captureModeSwitch(service: TelemetryService, ulid: string, mode: Mode): void {
const properties: ModeSwitchProperties = { ulid, mode }
TaskEvents.capture(service, "task.mode", properties)
}
/**
* Records when a tool is used during task execution
*/
static captureToolUsage(
service: TelemetryService,
ulid: string,
tool: string,
modelId: string,
autoApproved: boolean,
success: boolean,
): void {
const properties: ToolUsageProperties = { ulid, tool, modelId, autoApproved, success }
TaskEvents.capture(service, "task.tool_used", properties)
}
/**
* Records when an MCP tool is called
*/
static captureMcpToolCall(
service: TelemetryService,
ulid: string,
serverName: string,
toolName: string,
status: "started" | "success" | "error",
errorMessage?: string,
argumentKeys?: string[],
): void {
const properties: McpToolCallProperties = { ulid, serverName, toolName, status, errorMessage, argumentKeys }
TaskEvents.capture(service, "task.mcp_tool_called", properties)
}
/**
* Records interactions with the git-based checkpoint system
*/
static captureCheckpointUsage(
service: TelemetryService,
ulid: string,
action: "shadow_git_initialized" | "commit_created" | "restored" | "diff_generated",
durationMs?: number,
): void {
const properties: CheckpointUsageProperties = { ulid, action, durationMs }
TaskEvents.capture(service, "task.checkpoint_used", properties)
}
/**
* Records when a diff edit operation fails
*/
static captureDiffEditFailure(service: TelemetryService, ulid: string, modelId: string, errorType?: string): void {
const properties: DiffEditFailureProperties = { ulid, modelId, errorType }
TaskEvents.capture(service, "task.diff_edit_failed", properties)
}
/**
* Records when a user selects an option from AI-generated followup questions
*/
static captureOptionSelected(service: TelemetryService, ulid: string, qty: number, mode: Mode): void {
const properties: OptionSelectedProperties = { ulid, qty, mode }
TaskEvents.capture(service, "task.option_selected", properties)
}
/**
* Records when a user types a custom response instead of selecting an option
*/
static captureOptionsIgnored(service: TelemetryService, ulid: string, qty: number, mode: Mode): void {
const properties: OptionSelectedProperties = { ulid, qty, mode }
TaskEvents.capture(service, "task.options_ignored", properties)
}
/**
* Captures Gemini API performance metrics
*/
static captureGeminiApiPerformance(
service: TelemetryService,
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
},
): void {
const properties: GeminiApiPerformanceProperties = { ulid, modelId, ...data }
TaskEvents.capture(service, "task.gemini_api_performance", properties)
}
/**
* Records when an API provider returns an error
*/
static captureProviderApiError(
service: TelemetryService,
args: {
ulid: string
model: string
errorMessage: string
provider?: string
errorStatus?: number
requestId?: string
},
): void {
const properties: ProviderApiErrorProperties = {
...args,
errorMessage: args.errorMessage.substring(0, 500),
timestamp: new Date().toISOString(),
}
TaskEvents.capture(service, "task.provider_api_error", properties)
}
/**
* Records when context summarization is triggered
*/
static captureSummarizeTask(
service: TelemetryService,
ulid: string,
modelId: string,
currentTokens: number,
maxContextWindow: number,
): void {
const properties: SummarizeTaskProperties = { ulid, modelId, currentTokens, maxContextWindow }
TaskEvents.capture(service, "task.summarize_task", properties)
}
/**
* Records when slash commands or workflows are activated
*/
static captureSlashCommandUsed(
service: TelemetryService,
ulid: string,
commandName: string,
commandType: "builtin" | "workflow",
): void {
const properties: SlashCommandUsedProperties = { ulid, commandName, commandType }
TaskEvents.capture(service, "task.slash_command_used", properties)
}
/**
* Records when individual Cline rules are toggled on/off
*/
static captureClineRuleToggled(
service: TelemetryService,
ulid: string,
ruleFileName: string,
enabled: boolean,
isGlobal: boolean,
): void {
const sanitizedFileName = ruleFileName.split("/").pop() || ruleFileName.split("\\").pop() || ruleFileName
const properties: ClineRuleToggledProperties = { ulid, ruleFileName: sanitizedFileName, enabled, isGlobal }
TaskEvents.capture(service, "task.rule_toggled", properties)
}
/**
* Records when auto condense is enabled/disabled
*/
static captureAutoCondenseToggle(service: TelemetryService, ulid: string, enabled: boolean, modelId: string): void {
const properties: AutoCondenseToggleProperties = { ulid, enabled, modelId }
TaskEvents.capture(service, "task.auto_condense_toggled", properties)
}
/**
* Records when yolo mode is enabled/disabled
*/
static captureYoloModeToggle(service: TelemetryService, ulid: string, enabled: boolean): void {
const properties: YoloModeToggleProperties = { ulid, enabled }
TaskEvents.capture(service, "task.yolo_mode_toggled", properties)
}
/**
* Records task initialization timing
*/
static captureTaskInitialization(
service: TelemetryService,
ulid: string,
taskId: string,
durationMs: number,
hasCheckpoints: boolean,
): void {
const properties: TaskInitializationProperties = { ulid, taskId, durationMs, hasCheckpoints }
TaskEvents.capture(service, "task.initialization", properties)
}
/**
* Records terminal command execution outcomes
*/
static captureTerminalExecution(
service: TelemetryService,
success: boolean,
method: "shell_integration" | "clipboard" | "none",
): void {
const properties: TerminalExecutionProperties = { success, method }
TaskEvents.capture(service, "task.terminal_execution", properties)
}
/**
* Records when terminal output capture fails
*/
static captureTerminalOutputFailure(service: TelemetryService, reason: string): void {
const properties: TerminalOutputFailureProperties = { reason }
TaskEvents.capture(service, "task.terminal_output_failure", properties)
}
/**
* Records when user has to intervene with terminal execution
*/
static captureTerminalUserIntervention(service: TelemetryService, action: string): void {
const properties: TerminalUserInterventionProperties = { action }
TaskEvents.capture(service, "task.terminal_user_intervention", properties)
}
/**
* Records when terminal execution hangs
*/
static captureTerminalHang(service: TelemetryService, stage: string): void {
const properties: TerminalHangProperties = { stage }
TaskEvents.capture(service, "task.terminal_hang", properties)
}
/**
* Records when focus chain is enabled/disabled
*/
static captureFocusChainToggle(service: TelemetryService, enabled: boolean): void {
const properties: FocusChainToggleProperties = { enabled }
const event = enabled ? "task.focus_chain_enabled" : "task.focus_chain_disabled"
TaskEvents.capture(service, event, properties)
}
/**
* Records when a task progress list is returned for the first time
*/
static captureFocusChainProgressFirst(service: TelemetryService, ulid: string, totalItems: number): void {
const properties: FocusChainProgressFirstProperties = { ulid, totalItems }
TaskEvents.capture(service, "task.focus_chain_progress_first", properties)
}
/**
* Records when a task progress list is updated
*/
static captureFocusChainProgressUpdate(
service: TelemetryService,
ulid: string,
totalItems: number,
completedItems: number,
): void {
const properties: FocusChainProgressUpdateProperties = {
ulid,
totalItems,
completedItems,
completionPercentage: totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0,
}
TaskEvents.capture(service, "task.focus_chain_progress_update", properties)
}
/**
* Records when a task ends but the task progress list is not complete
*/
static captureFocusChainIncompleteOnCompletion(
service: TelemetryService,
ulid: string,
totalItems: number,
completedItems: number,
incompleteItems: number,
): void {
const properties: FocusChainIncompleteOnCompletionProperties = {
ulid,
totalItems,
completedItems,
incompleteItems,
completionPercentage: totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0,
}
TaskEvents.capture(service, "task.focus_chain_incomplete_on_completion", properties)
}
/**
* Records when users click to open the focus chain markdown file
*/
static captureFocusChainListOpened(service: TelemetryService, ulid: string): void {
const properties: FocusChainListOpenedProperties = { ulid }
TaskEvents.capture(service, "task.focus_chain_list_opened", properties)
}
/**
* Records when users save and write to the focus chain markdown file
*/
static captureFocusChainListWritten(service: TelemetryService, ulid: string): void {
const properties: FocusChainListWrittenProperties = { ulid }
TaskEvents.capture(service, "task.focus_chain_list_written", properties)
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { TelemetryProperties } from "../providers/ITelemetryProvider"
import type { TelemetryService } from "../TelemetryService"
import { EventHandlerBase } from "./EventHandlerBase"
/**
* Property types for UI telemetry events
*/
export interface ModelSelectedProperties extends TelemetryProperties {
model: string
provider: string
ulid?: string
}
export interface ModelFavoriteToggledProperties extends TelemetryProperties {
model: string
isFavorited: boolean
}
export interface ButtonClickedProperties extends TelemetryProperties {
button: string
ulid?: string
}
/**
* Event handler for UI-related telemetry events
*/
export class UIEvents extends EventHandlerBase {
static override readonly prefix = "ui"
/**
* Records when a different model is selected for use
* @param service The telemetry service instance
* @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
*/
static captureModelSelected(service: TelemetryService, model: string, provider: string, ulid?: string): void {
const properties: ModelSelectedProperties = {
model,
provider,
ulid,
}
UIEvents.capture(service, "ui.model_selected", properties)
}
/**
* Records when the user uses the model favorite button in the model picker
* @param service The telemetry service instance
* @param model The name of the model the user has interacted with
* @param isFavorited Whether the model is being favorited (true) or unfavorited (false)
*/
static captureModelFavoritesUsage(service: TelemetryService, model: string, isFavorited: boolean): void {
const properties: ModelFavoriteToggledProperties = {
model,
isFavorited,
}
UIEvents.capture(service, "ui.model_favorite_toggled", properties)
}
/**
* Records when a button is clicked
* @param service The telemetry service instance
* @param button The button identifier
* @param ulid Optional task identifier
*/
static captureButtonClick(service: TelemetryService, button: string, ulid?: string): void {
const properties: ButtonClickedProperties = {
button,
ulid,
}
UIEvents.capture(service, "ui.button_clicked", properties)
}
/**
* Records when the rules menu button is clicked to open the rules/workflows modal
* @param service The telemetry service instance
*/
static captureRulesMenuOpened(service: TelemetryService): void {
UIEvents.capture(service, "ui.rules_menu_opened", {})
}
}
@@ -0,0 +1,47 @@
import type { TelemetryProperties } from "../providers/ITelemetryProvider"
import type { TelemetryService } from "../TelemetryService"
import { EventHandlerBase } from "./EventHandlerBase"
/**
* Property types for user telemetry events
*/
export interface UserOptOutProperties extends TelemetryProperties {}
export interface TelemetryEnabledProperties extends TelemetryProperties {}
export interface ExtensionActivatedProperties extends TelemetryProperties {}
/**
* Event handler for user-related telemetry events
*/
export class UserEvents extends EventHandlerBase {
static override readonly prefix = "user"
/**
* Records when a user opts out of telemetry
* @param service The telemetry service instance
*/
static captureUserOptOut(service: TelemetryService): void {
const properties: UserOptOutProperties = {}
UserEvents.captureRequired(service, "user.opt_out", properties)
}
/**
* Records when telemetry is enabled
* @param service The telemetry service instance
*/
static captureTelemetryEnabled(service: TelemetryService): void {
const properties: TelemetryEnabledProperties = {}
UserEvents.capture(service, "user.telemetry_enabled", properties)
}
/**
* Records when the extension is activated
* @param service The telemetry service instance
*/
static captureExtensionActivated(service: TelemetryService): void {
const properties: ExtensionActivatedProperties = {}
UserEvents.capture(service, "user.extension_activated", properties)
}
}
@@ -0,0 +1,121 @@
import type { TelemetryProperties } from "../providers/ITelemetryProvider"
import type { TelemetryService } from "../TelemetryService"
import { EventHandlerBase } from "./EventHandlerBase"
/**
* Property types for workspace telemetry events
*/
export interface WorkspaceInitializedProperties extends TelemetryProperties {
root_count: number
vcs_types: string[]
is_multi_root: boolean
has_git: boolean
has_mercurial: boolean
init_duration_ms?: number
feature_flag_enabled?: boolean
}
export interface WorkspaceInitErrorProperties extends TelemetryProperties {
error_type: string
error_message: string
fallback_to_single_root: boolean
workspace_count: number
}
export interface MultiRootCheckpointProperties extends TelemetryProperties {
ulid: string
action: "initialized" | "committed" | "restored"
root_count: number
success_count: number
failure_count: number
success_rate: number
duration_ms?: number
}
/**
* Event handler for workspace-related telemetry events
*/
export class WorkspaceEvents extends EventHandlerBase {
static override readonly prefix = "workspace"
/**
* Records when workspace is initialized
* @param service The telemetry service instance
* @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
*/
static captureWorkspaceInitialized(
service: TelemetryService,
rootCount: number,
vcsTypes: string[],
initDurationMs?: number,
featureFlagEnabled?: boolean,
): void {
const properties: WorkspaceInitializedProperties = {
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,
}
WorkspaceEvents.capture(service, "workspace.initialized", properties)
}
/**
* Records workspace initialization errors
* @param service The telemetry service instance
* @param error The error that occurred
* @param fallbackMode Whether system fell back to single-root mode
* @param workspaceCount Number of workspace folders detected
*/
static captureWorkspaceInitError(
service: TelemetryService,
error: Error,
fallbackMode: boolean,
workspaceCount?: number,
): void {
const properties: WorkspaceInitErrorProperties = {
error_type: error.constructor.name,
error_message: error.message.substring(0, 500), // Truncate long error messages
fallback_to_single_root: fallbackMode,
workspace_count: workspaceCount ?? 0,
}
WorkspaceEvents.capture(service, "workspace.init_error", properties)
}
/**
* Records multi-root checkpoint operations
* @param service The telemetry service instance
* @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
*/
static captureMultiRootCheckpoint(
service: TelemetryService,
ulid: string,
action: "initialized" | "committed" | "restored",
rootCount: number,
successCount: number,
failureCount: number,
durationMs?: number,
): void {
const properties: MultiRootCheckpointProperties = {
ulid,
action,
root_count: rootCount,
success_count: successCount,
failure_count: failureCount,
success_rate: rootCount > 0 ? successCount / rootCount : 0,
duration_ms: durationMs,
}
WorkspaceEvents.capture(service, "workspace.multi_root_checkpoint", properties)
}
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Barrel export for all telemetry event handlers
*/
export * from "./BrowserEvents"
export * from "./DictationEvents"
export * from "./EventHandlerBase"
export * from "./TaskEvents"
export * from "./UIEvents"
export * from "./UserEvents"
export * from "./WorkspaceEvents"
+1 -1
View File
@@ -2,7 +2,7 @@ export type {
ITelemetryProvider,
TelemetrySettings,
} from "./providers/ITelemetryProvider"
export { PostHogTelemetryProvider } from "./providers/PostHogTelemetryProvider"
export { PostHogTelemetryProvider } from "./providers/posthog/PostHogTelemetryProvider"
export {
type TelemetryProviderConfig,
TelemetryProviderFactory,
@@ -5,6 +5,32 @@
import type { ClineAccountUserInfo } from "../../auth/AuthService"
/**
* JSON-serializable primitive types for telemetry properties
*/
export type TelemetryPrimitive = string | number | boolean | null | undefined
/**
* JSON-serializable value types for telemetry properties
* Ensures all telemetry data can be properly serialized
*/
export type TelemetryValue = TelemetryPrimitive | TelemetryObject | TelemetryArray
/**
* JSON-serializable object for telemetry properties
*/
export type TelemetryObject = { [key: string]: TelemetryValue }
/**
* JSON-serializable array for telemetry properties
*/
export type TelemetryArray = Array<TelemetryValue>
/**
* Properties that can be safely passed to telemetry providers
*/
export type TelemetryProperties = TelemetryObject
/**
* Telemetry settings that control when and how telemetry is collected
*/
@@ -25,16 +51,24 @@ export interface ITelemetryProvider {
/**
* Log an event with optional properties
* @param event The event name to log
* @param properties Optional properties to attach to the event
* @param properties Optional JSON-serializable properties to attach to the event
*/
log(event: string, properties?: Record<string, unknown>): void
log(event: string, properties?: TelemetryProperties): void
/**
* Log a required event that bypasses telemetry opt-out settings
* Required events are critical for system health and error monitoring
* @param event The event name to log
* @param properties Optional JSON-serializable properties to attach to the event
*/
logRequired(event: string, properties?: TelemetryProperties): void
/**
* Identify a user for tracking
* @param userInfo The user's information
* @param properties Optional additional properties
* @param properties Optional additional JSON-serializable properties
*/
identifyUser(userInfo: ClineAccountUserInfo, properties?: Record<string, unknown>): void
identifyUser(userInfo: ClineAccountUserInfo, properties?: TelemetryProperties): void
/**
* Update telemetry opt-in/out status
@@ -1,5 +1,5 @@
import { EventMessage, PostHog } from "posthog-node"
import { posthogConfig } from "../../shared/services/config/posthog-config"
import { posthogConfig } from "@/shared/services/config/posthog-config"
export class PostHogClientProvider {
private static _instance: PostHogClientProvider | null = null
@@ -31,6 +31,7 @@ export class PostHogClientProvider {
/**
* Filters PostHog events before they are sent.
* For exceptions, we only capture those from the Cline extension.
* this is specifically to avoid capturing errors from anything other than Cline
*/
static eventFilter(event: EventMessage | null) {
if (!event || event?.event !== "$exception") {
@@ -3,9 +3,9 @@ import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { getDistinctId, setDistinctId } from "@/services/logging/distinctId"
import { Setting } from "@/shared/proto/index.host"
import { posthogConfig } from "../../../shared/services/config/posthog-config"
import type { ClineAccountUserInfo } from "../../auth/AuthService"
import type { ITelemetryProvider, TelemetrySettings } from "./ITelemetryProvider"
import { posthogConfig } from "../../../../shared/services/config/posthog-config"
import type { ClineAccountUserInfo } from "../../../auth/AuthService"
import type { ITelemetryProvider, TelemetryProperties, TelemetrySettings } from "../ITelemetryProvider"
/**
* PostHog implementation of the telemetry provider interface
* Handles PostHog-specific analytics tracking
@@ -60,7 +60,7 @@ export class PostHogTelemetryProvider implements ITelemetryProvider {
return this
}
public log(event: string, properties?: Record<string, unknown>): void {
public log(event: string, properties?: TelemetryProperties): void {
if (!this.isEnabled() || this.telemetrySettings.level === "off") {
return
}
@@ -79,7 +79,18 @@ export class PostHogTelemetryProvider implements ITelemetryProvider {
})
}
public identifyUser(userInfo: ClineAccountUserInfo, properties: Record<string, unknown> = {}): void {
public logRequired(event: string, properties?: TelemetryProperties): void {
this.client.capture({
distinctId: getDistinctId(),
event,
properties: {
...properties,
_required: true, // Mark as required event
},
})
}
public identifyUser(userInfo: ClineAccountUserInfo, properties: TelemetryProperties = {}): void {
const distinctId = getDistinctId()
// Only identify user if telemetry is enabled and user ID is different than the currently set distinct ID
if (this.isEnabled() && userInfo && userInfo?.id !== distinctId) {