Skip to content

Commit a060887

Browse files
committed
feat(telemetry): aggregate task completion telemetry with delta installments
1 parent cd29243 commit a060887

9 files changed

Lines changed: 908 additions & 26 deletions

File tree

packages/telemetry/src/TelemetryService.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type TelemetryPropertiesProvider,
66
TelemetryEventName,
77
type TelemetrySetting,
8+
type ToolUsage,
89
} from "@roo-code/types"
910

1011
/**
@@ -86,8 +87,31 @@ export class TelemetryService {
8687
this.captureEvent(TelemetryEventName.TASK_RESTARTED, { taskId })
8788
}
8889

89-
public captureTaskCompleted(taskId: string): void {
90-
this.captureEvent(TelemetryEventName.TASK_COMPLETED, { taskId })
90+
/**
91+
* Captures task completion, summarizing per-task tool and message counts that
92+
* were previously reported as separate per-turn events to reduce event volume.
93+
*
94+
* A single task may emit this more than once (e.g. an "idle" or "shutdown"
95+
* installment followed by a final "attempt_completion" one). toolsUsed and
96+
* messageCount are always deltas since the previous emission for that task,
97+
* not running totals -- summing installments for a taskId reconstructs the
98+
* full-task counts without double-counting.
99+
*
100+
* Note: "attempt_completion" means the model called that tool, not that the
101+
* user accepted the result.
102+
*/
103+
public captureTaskCompleted(
104+
taskId: string,
105+
toolsUsed?: ToolUsage,
106+
messageCount?: { user: number; assistant: number },
107+
completionReason: "attempt_completion" | "idle" | "shutdown" = "attempt_completion",
108+
): void {
109+
this.captureEvent(TelemetryEventName.TASK_COMPLETED, {
110+
taskId,
111+
completionReason,
112+
...(toolsUsed !== undefined && { toolsUsed }),
113+
...(messageCount !== undefined && { messageCount }),
114+
})
91115
}
92116

93117
public captureConversationMessage(taskId: string, source: "user" | "assistant"): void {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// pnpm --filter @roo-code/telemetry test src/__tests__/TelemetryService.task-completed.test.ts
2+
3+
import { TelemetryEventName, type TelemetryClient } from "@roo-code/types"
4+
5+
import { TelemetryService } from "../TelemetryService"
6+
7+
describe("TelemetryService.captureTaskCompleted", () => {
8+
let mockClient: TelemetryClient
9+
10+
beforeEach(() => {
11+
mockClient = {
12+
setProvider: vi.fn(),
13+
capture: vi.fn().mockResolvedValue(undefined),
14+
captureException: vi.fn().mockResolvedValue(undefined),
15+
updateTelemetryState: vi.fn(),
16+
isTelemetryEnabled: vi.fn().mockReturnValue(true),
17+
shutdown: vi.fn().mockResolvedValue(undefined),
18+
}
19+
})
20+
21+
it("captures Task Completed with the taskId and a default 'attempt_completion' completionReason when no summary is provided", () => {
22+
const service = new TelemetryService([mockClient])
23+
24+
service.captureTaskCompleted("task_1")
25+
26+
expect(mockClient.capture).toHaveBeenCalledWith({
27+
event: TelemetryEventName.TASK_COMPLETED,
28+
properties: { taskId: "task_1", completionReason: "attempt_completion" },
29+
})
30+
})
31+
32+
it("includes toolsUsed and messageCount summaries when provided", () => {
33+
const service = new TelemetryService([mockClient])
34+
35+
service.captureTaskCompleted(
36+
"task_1",
37+
{ read_file: { attempts: 3, failures: 0 }, apply_diff: { attempts: 1, failures: 1 } },
38+
{ user: 4, assistant: 5 },
39+
)
40+
41+
expect(mockClient.capture).toHaveBeenCalledWith({
42+
event: TelemetryEventName.TASK_COMPLETED,
43+
properties: {
44+
taskId: "task_1",
45+
completionReason: "attempt_completion",
46+
toolsUsed: { read_file: { attempts: 3, failures: 0 }, apply_diff: { attempts: 1, failures: 1 } },
47+
messageCount: { user: 4, assistant: 5 },
48+
},
49+
})
50+
})
51+
52+
it("includes the given completionReason for idle/shutdown installments", () => {
53+
const service = new TelemetryService([mockClient])
54+
55+
service.captureTaskCompleted("task_1", { read_file: { attempts: 1, failures: 0 } }, undefined, "idle")
56+
57+
expect(mockClient.capture).toHaveBeenCalledWith({
58+
event: TelemetryEventName.TASK_COMPLETED,
59+
properties: {
60+
taskId: "task_1",
61+
completionReason: "idle",
62+
toolsUsed: { read_file: { attempts: 1, failures: 0 } },
63+
},
64+
})
65+
})
66+
})

src/core/task/Task.ts

Lines changed: 126 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ import { MessageManager } from "../message-manager"
135135
import { validateAndFixToolResultIds } from "./validateToolResultIds"
136136
import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages"
137137
import { prepareApiConversationMessage } from "./apiConversationHistory"
138+
import { shouldAddUserMessageToHistory } from "./messageCounting"
138139

139140
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
140141
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
@@ -322,6 +323,25 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
322323
consecutiveNoAssistantMessagesCount: number = 0
323324
toolUsage: ToolUsage = {}
324325

326+
// Conversation message counts, summarized once per Task Completed
327+
// installment instead of emitting a separate telemetry event per turn.
328+
messageCounts: { user: number; assistant: number } = { user: 0, assistant: 0 }
329+
330+
// Idle/shutdown telemetry flush: reports toolUsage/messageCounts for tasks that
331+
// go quiet or get torn down without the model ever calling attempt_completion
332+
// (or without the user accepting it), so long-running/abandoned tasks aren't
333+
// invisible to telemetry. Each flush reports only what changed since the previous
334+
// one, tracked via telemetryToolUsageBaseline/telemetryMessageCountsBaseline --
335+
// task.toolUsage/messageCounts themselves are never mutated by this, since they're
336+
// also read as running totals by the public TaskCompleted API event and the UI.
337+
// Checked on an interval rather than hooked into every say()/ask() call site.
338+
private static readonly IDLE_TELEMETRY_CHECK_INTERVAL_MS = 5 * 60 * 1000
339+
private static readonly IDLE_TELEMETRY_THRESHOLD_MS = 30 * 60 * 1000
340+
private idleTelemetryCheckInterval?: NodeJS.Timeout
341+
private lastTelemetryFlushAt: number = Date.now()
342+
private telemetryToolUsageBaseline: ToolUsage = {}
343+
private telemetryMessageCountsBaseline: { user: number; assistant: number } = { user: 0, assistant: 0 }
344+
325345
// Checkpoints
326346
enableCheckpoints: boolean
327347
checkpointTimeout: number
@@ -597,6 +617,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
597617
{ leading: true, trailing: true, maxWait: this.TOKEN_USAGE_EMIT_INTERVAL_MS },
598618
)
599619

620+
this.startIdleTelemetryCheck()
621+
600622
onCreated?.(this)
601623

602624
if (startTask) {
@@ -2270,6 +2292,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
22702292
public dispose(): void {
22712293
console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`)
22722294

2295+
// Stop the idle telemetry check and report any unflushed activity as a
2296+
// shutdown installment, so a task torn down mid-work (panel closed, task
2297+
// switched, extension deactivated) isn't invisible to telemetry.
2298+
try {
2299+
clearInterval(this.idleTelemetryCheckInterval)
2300+
this.idleTelemetryCheckInterval = undefined
2301+
this.flushTelemetryInstallment("shutdown")
2302+
} catch (error) {
2303+
console.error("Error flushing shutdown telemetry:", error)
2304+
}
2305+
22732306
// Cancel any in-progress HTTP request
22742307
try {
22752308
this.cancelCurrentRequest()
@@ -2629,18 +2662,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
26292662
// Add environment details as its own text block, separate from tool
26302663
// results.
26312664
const finalUserContent = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }]
2632-
// Only add user message to conversation history if:
2633-
// 1. This is the first attempt (retryAttempt === 0), AND
2634-
// 2. The original userContent was not empty (empty signals delegation resume where
2635-
// the user message with tool_result and env details is already in history), OR
2636-
// 3. The message was removed in a previous iteration (userMessageWasRemoved === true)
2637-
// This prevents consecutive user messages while allowing re-add when needed
2665+
// See shouldAddUserMessageToHistory for the full add/skip rules (retry/empty/removed).
26382666
const isEmptyUserContent = currentUserContent.length === 0
2639-
const shouldAddUserMessage =
2640-
((currentItem.retryAttempt ?? 0) === 0 && !isEmptyUserContent) || currentItem.userMessageWasRemoved
2667+
const shouldAddUserMessage = shouldAddUserMessageToHistory({
2668+
retryAttempt: currentItem.retryAttempt,
2669+
isEmptyUserContent,
2670+
userMessageWasRemoved: currentItem.userMessageWasRemoved,
2671+
})
26412672
if (shouldAddUserMessage) {
26422673
await this.addToApiConversationHistory({ role: "user", content: finalUserContent })
2643-
TelemetryService.instance.captureConversationMessage(this.taskId, "user")
2674+
this.messageCounts.user++
26442675
}
26452676

26462677
// Since we sent off a placeholder api_req_started message to update the
@@ -3557,7 +3588,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
35573588
)
35583589
this.assistantMessageSavedToHistory = true
35593590

3560-
TelemetryService.instance.captureConversationMessage(this.taskId, "assistant")
3591+
this.messageCounts.assistant++
35613592
}
35623593

35633594
// Present any partial blocks that were just completed.
@@ -3661,8 +3692,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
36613692
if (this.apiConversationHistory.length > 0) {
36623693
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
36633694
if (lastMessage.role === "user") {
3664-
// Remove the last user message that we added earlier
3695+
// Remove the last user message that we added earlier. Decrement
3696+
// messageCounts.user to match -- both retry branches below mark
3697+
// userMessageWasRemoved so the message (and its count) is restored
3698+
// exactly once when the retry succeeds, keeping the total symmetric
3699+
// regardless of how many empty-response cycles occur first.
36653700
this.apiConversationHistory.pop()
3701+
this.messageCounts.user--
36663702
}
36673703
}
36683704

@@ -3706,32 +3742,45 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
37063742
if (response === "yesButtonClicked") {
37073743
await this.say("api_req_retried")
37083744

3709-
// Push the same content back to retry
3745+
// Push the same content back to retry. Mark that user message was
3746+
// removed (same as the auto-retry path above) so it gets re-added --
3747+
// and messageCounts.user re-incremented -- on the retried attempt;
3748+
// otherwise shouldAddUserMessageToHistory sees retryAttempt > 0 with
3749+
// userMessageWasRemoved unset and skips re-adding it entirely.
37103750
stack.push({
37113751
userContent: currentUserContent,
37123752
includeFileDetails: false,
37133753
retryAttempt: (currentItem.retryAttempt ?? 0) + 1,
3754+
userMessageWasRemoved: true,
37143755
})
37153756

37163757
// Continue to retry the request
37173758
continue
37183759
} else {
37193760
// User declined to retry
3720-
// Re-add the user message we removed.
3761+
// Re-add the user message we removed (see messageCounts.user-- above)
3762+
// and increment messageCounts.user to match, same as the normal
3763+
// add-to-history path -- otherwise this abandoned-task path
3764+
// permanently undercounts by one.
37213765
await this.addToApiConversationHistory({
37223766
role: "user",
37233767
content: currentUserContent,
37243768
})
3769+
this.messageCounts.user++
37253770

37263771
await this.say(
37273772
"error",
37283773
"Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.",
37293774
)
37303775

3776+
// Synthetic assistant message recording the failure -- increment
3777+
// messageCounts.assistant to match, same as the normal
3778+
// assistant-message-saved path.
37313779
await this.addToApiConversationHistory({
37323780
role: "assistant",
37333781
content: [{ type: "text", text: "Failure: I did not provide a response." }],
37343782
})
3783+
this.messageCounts.assistant++
37353784
}
37363785
}
37373786
}
@@ -4679,6 +4728,70 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
46794728
}
46804729
}
46814730

4731+
/**
4732+
* Emits a Task Completed installment for whatever toolUsage/messageCounts have
4733+
* changed since the previous installment (from any reason), then advances the
4734+
* telemetry baseline so a later installment reports only its own delta. Does
4735+
* NOT touch task.toolUsage/messageCounts themselves -- those stay running totals
4736+
* for the public TaskCompleted API event and the UI. No-ops if nothing changed
4737+
* since the last installment, so idle/shutdown checks don't emit empty events
4738+
* for tasks that were already fully reported (e.g. right after attempt_completion).
4739+
*/
4740+
public flushTelemetryInstallment(reason: "attempt_completion" | "idle" | "shutdown"): void {
4741+
const toolUsageDelta: ToolUsage = {}
4742+
4743+
for (const [toolName, usage] of Object.entries(this.toolUsage) as [ToolName, ToolUsage[ToolName]][]) {
4744+
if (!usage) {
4745+
continue
4746+
}
4747+
4748+
const baseline = this.telemetryToolUsageBaseline[toolName]
4749+
const attempts = usage.attempts - (baseline?.attempts ?? 0)
4750+
const failures = usage.failures - (baseline?.failures ?? 0)
4751+
4752+
if (attempts > 0 || failures > 0) {
4753+
toolUsageDelta[toolName] = { attempts, failures }
4754+
}
4755+
}
4756+
4757+
const messageCountDelta = {
4758+
user: this.messageCounts.user - this.telemetryMessageCountsBaseline.user,
4759+
assistant: this.messageCounts.assistant - this.telemetryMessageCountsBaseline.assistant,
4760+
}
4761+
4762+
const hasToolUsageDelta = Object.keys(toolUsageDelta).length > 0
4763+
const hasMessageDelta = messageCountDelta.user > 0 || messageCountDelta.assistant > 0
4764+
4765+
if (!hasToolUsageDelta && !hasMessageDelta) {
4766+
return
4767+
}
4768+
4769+
this.emitFinalTokenUsageUpdate()
4770+
TelemetryService.instance.captureTaskCompleted(this.taskId, toolUsageDelta, messageCountDelta, reason)
4771+
4772+
this.telemetryToolUsageBaseline = JSON.parse(JSON.stringify(this.toolUsage))
4773+
this.telemetryMessageCountsBaseline = { ...this.messageCounts }
4774+
this.lastTelemetryFlushAt = Date.now()
4775+
}
4776+
4777+
private startIdleTelemetryCheck(): void {
4778+
this.idleTelemetryCheckInterval = setInterval(() => {
4779+
// lastMessageTs only moves forward on activity, so comparing it against the
4780+
// last flush tells us whether anything happened since that flush -- if the
4781+
// task has been quiet since well before the last flush, there's nothing new
4782+
// to report and flushTelemetryInstallment's own empty-check would no-op anyway,
4783+
// but skipping here avoids waking up to do that check needlessly.
4784+
const idleForMs = Date.now() - (this.lastMessageTs ?? this.lastTelemetryFlushAt)
4785+
4786+
if (idleForMs >= Task.IDLE_TELEMETRY_THRESHOLD_MS) {
4787+
this.flushTelemetryInstallment("idle")
4788+
}
4789+
}, Task.IDLE_TELEMETRY_CHECK_INTERVAL_MS)
4790+
4791+
// Don't hold the process open just for this timer.
4792+
this.idleTelemetryCheckInterval?.unref?.()
4793+
}
4794+
46824795
// Getters
46834796

46844797
public get taskStatus(): TaskStatus {

0 commit comments

Comments
 (0)