Skip to content
28 changes: 26 additions & 2 deletions packages/telemetry/src/TelemetryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type TelemetryPropertiesProvider,
TelemetryEventName,
type TelemetrySetting,
type ToolUsage,
} from "@roo-code/types"

/**
Expand Down Expand Up @@ -86,8 +87,31 @@ export class TelemetryService {
this.captureEvent(TelemetryEventName.TASK_RESTARTED, { taskId })
}

public captureTaskCompleted(taskId: string): void {
this.captureEvent(TelemetryEventName.TASK_COMPLETED, { taskId })
/**
* Captures task completion, summarizing per-task tool and message counts that
* were previously reported as separate per-turn events to reduce event volume.
*
* A single task may emit this more than once (e.g. an "idle" or "shutdown"
* installment followed by a final "attempt_completion" one). toolsUsed and
* messageCount are always deltas since the previous emission for that task,
* not running totals -- summing installments for a taskId reconstructs the
* full-task counts without double-counting.
*
* Note: "attempt_completion" means the model called that tool, not that the
* user accepted the result.
*/
public captureTaskCompleted(
taskId: string,
toolsUsed?: ToolUsage,
messageCount?: { user: number; assistant: number },
completionReason: "attempt_completion" | "idle" | "shutdown" = "attempt_completion",
): void {
this.captureEvent(TelemetryEventName.TASK_COMPLETED, {
taskId,
completionReason,
...(toolsUsed !== undefined && { toolsUsed }),
...(messageCount !== undefined && { messageCount }),
})
}

public captureConversationMessage(taskId: string, source: "user" | "assistant"): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// pnpm --filter @roo-code/telemetry test src/__tests__/TelemetryService.task-completed.test.ts

import { TelemetryEventName, type TelemetryClient } from "@roo-code/types"

import { TelemetryService } from "../TelemetryService"

describe("TelemetryService.captureTaskCompleted", () => {
let mockClient: TelemetryClient

beforeEach(() => {
mockClient = {
setProvider: vi.fn(),
capture: vi.fn().mockResolvedValue(undefined),
captureException: vi.fn().mockResolvedValue(undefined),
updateTelemetryState: vi.fn(),
isTelemetryEnabled: vi.fn().mockReturnValue(true),
shutdown: vi.fn().mockResolvedValue(undefined),
}
})

it("captures Task Completed with the taskId and a default 'attempt_completion' completionReason when no summary is provided", () => {
const service = new TelemetryService([mockClient])

service.captureTaskCompleted("task_1")

expect(mockClient.capture).toHaveBeenCalledWith({
event: TelemetryEventName.TASK_COMPLETED,
properties: { taskId: "task_1", completionReason: "attempt_completion" },
})
})

it("includes toolsUsed and messageCount summaries when provided", () => {
const service = new TelemetryService([mockClient])

service.captureTaskCompleted(
"task_1",
{ read_file: { attempts: 3, failures: 0 }, apply_diff: { attempts: 1, failures: 1 } },
{ user: 4, assistant: 5 },
)

expect(mockClient.capture).toHaveBeenCalledWith({
event: TelemetryEventName.TASK_COMPLETED,
properties: {
taskId: "task_1",
completionReason: "attempt_completion",
toolsUsed: { read_file: { attempts: 3, failures: 0 }, apply_diff: { attempts: 1, failures: 1 } },
messageCount: { user: 4, assistant: 5 },
},
})
})

it("includes the given completionReason for idle/shutdown installments", () => {
const service = new TelemetryService([mockClient])

service.captureTaskCompleted("task_1", { read_file: { attempts: 1, failures: 0 } }, undefined, "idle")

expect(mockClient.capture).toHaveBeenCalledWith({
event: TelemetryEventName.TASK_COMPLETED,
properties: {
taskId: "task_1",
completionReason: "idle",
toolsUsed: { read_file: { attempts: 1, failures: 0 } },
},
})
})
})
3 changes: 2 additions & 1 deletion src/__tests__/history-resume-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ vi.mock("vscode", () => {
return { window, workspace, env, Uri, commands, ExtensionMode, version }
})

// Mock TelemetryService (needed by attemptCompletionTool's emitTaskCompleted)
// Mock TelemetryService (needed by attemptCompletionTool's emitPublicTaskCompleted)
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
Expand Down Expand Up @@ -1255,6 +1255,7 @@ describe("History resume delegation - parent metadata transitions", () => {
userMessageContent: [],
consecutiveMistakeCount: 0,
emitFinalTokenUsageUpdate: vi.fn(),
flushTelemetryInstallment: vi.fn(),
} as unknown as import("../core/task/Task").Task

const block = {
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/nested-delegation-resume.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ describe("Nested delegation resume (A → B → C)", () => {
userMessageContent: [],
consecutiveMistakeCount: 0,
emitFinalTokenUsageUpdate: vi.fn(),
flushTelemetryInstallment: vi.fn(),
} as unknown as Task

const blockC = {
Expand Down Expand Up @@ -250,6 +251,7 @@ describe("Nested delegation resume (A → B → C)", () => {
userMessageContent: [],
consecutiveMistakeCount: 0,
emitFinalTokenUsageUpdate: vi.fn(),
flushTelemetryInstallment: vi.fn(),
} as unknown as Task

const blockB = {
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/task-run-dispatch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Runnable = {
metadata: { task?: string | null; images?: string[] | null }
resumeTaskFromHistory: () => Promise<void>
startTask: (task?: string, images?: string[]) => Promise<void>
startIdleTelemetryCheck: () => void
}

function makeRunnable(overrides: Partial<Runnable> = {}): Runnable & { run(): Promise<void> } {
Expand All @@ -27,6 +28,7 @@ function makeRunnable(overrides: Partial<Runnable> = {}): Runnable & { run(): Pr
metadata: { task: undefined, images: undefined },
resumeTaskFromHistory: vi.fn().mockResolvedValue(undefined),
startTask: vi.fn().mockResolvedValue(undefined),
startIdleTelemetryCheck: vi.fn(),
...overrides,
}
// Bind the real run() implementation from Task.prototype to our stand-in.
Expand Down
Loading
Loading