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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions docs/design/tool-call-terminal-telemetry-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Tool Call Terminal Telemetry Contract

## Problem

Tool-call terminal events are produced by both the Core scheduler and ACP.
They already expose `status`, `success`, `error`, and `error_type`, but those
fields can disagree or be absent. In particular, a tool can return a soft
error without an error type, and ACP can call the telemetry logger without
constructing a `ToolCallEvent`.

This leaves logs, usage statistics, metrics, hooks, and chat recording with
different views of the same terminal result.

## PR1 scope

PR1 establishes a runtime contract at two boundaries:

1. The Core scheduler converts an unclassified `ToolResult.error` to
`ToolErrorType.UNKNOWN` before building a completed call.
2. `logToolCall` normalizes every event before sending it to any telemetry
consumer.

The terminal contract is:

| `status` | `success` | `error` | `error_type` |
| ----------- | --------- | --------- | --------------------------- |
| `success` | `true` | absent | absent |
| `error` | `false` | preserved | explicit value or `unknown` |
| `cancelled` | `false` | absent | absent |

`status` is authoritative. A blank `function_name` becomes `unknown_tool`.
Non-empty tool names and non-empty error types are preserved verbatim. The
normalizer returns a copy and is idempotent.

The Core boundary is intentionally private. Public tool implementations may
continue to omit `ToolResult.error.type`, and `ToolCallResponseInfo.errorType`
remains optional because successful and cancelled calls do not have an error
classification.

## Consumers

The normalized event is used by UI telemetry, the chat-recorded UI event,
QwenLogger, OpenTelemetry logs, and tool-call metrics. OpenTelemetry
`error.message` and `error.type` aliases are populated independently.

The tool-call counter adds the low-cardinality `status` attribute while
retaining `success`. The public `recordToolCallMetrics` input accepts an
optional status for source compatibility; callers that omit it are mapped from
the legacy success boolean. The latency histogram remains keyed only by
`function_name`, and `error_type` is not added to metrics.

QwenLogger receives `status` and `tool_type`. It does not receive
`mcp_server_name`, function arguments, results, or stack traces as part of this
change.

## Compatibility and follow-ups

This change is additive for logs and metrics, but it changes an unclassified
Core error from a missing value to `unknown` in PostToolBatch and Core chat
recording. Historical queries should coalesce missing error types to `unknown`;
no data backfill is required.

The following remain outside PR1:

- correcting ACP permission cancellation and other producer-side terminal
status bugs;
- normalizing ACP's separate raw `tool_result` recording;
- adding `error_type` to the PostToolUseFailure hook contract;
- adding error classification to primary tool spans;
- classifying individual built-in and MCP error sites;
- changing legacy UI `totalFail` semantics.

The new `status` metric must not become the stability SLO source until the ACP
terminal-status fixes land.

## Rollout checks

Comment thread
doudouOUC marked this conversation as resolved.
For the new service version, operators should verify that:

- error tool-call logs never have a blank `error_type`;
- tool-call logs never have a blank `function_name`;
- success and cancelled events do not carry error fields;
- explicitly classified errors retain their previous type;
- the tool-call counter total remains aligned with tool-call log volume; and
- the increase in `unknown` corresponds to the previous missing bucket.
6 changes: 3 additions & 3 deletions docs/developers/development/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -576,8 +576,8 @@ The following events are logged:

#### Tool Events

- `qwen-code.tool_call`: Each function/tool call.
- **Attributes**: `function_name` (string), `function_args` (object), `duration_ms` (int), `status` (string: "success", "error", or "cancelled"), `success` (boolean), `decision` (string: "accept", "reject", "auto_accept", or "modify", optional), `error` (string, optional), `error_type` (string, optional), `prompt_id` (string), `response_id` (string, optional), `content_length` (int, optional), `tool_type` (string: "native" or "mcp"), `mcp_server_name` (string, optional), `metadata` (object, optional — for file-writing tools contains `model_added_lines`, `model_removed_lines`, `user_added_lines`, `user_removed_lines`, `model_added_chars`, `model_removed_chars`, `user_added_chars`, `user_removed_chars`)
- `qwen-code.tool_call`: Each function/tool call. Terminal events are normalized so `status` is authoritative: success and cancelled events omit error fields, while error events always have a non-empty `error_type` (`unknown` when the producer did not classify the error). Blank tool names are emitted as `unknown_tool`.
- **Attributes**: `function_name` (string), `function_args` (object), `duration_ms` (int), `status` (string: "success", "error", or "cancelled"), `success` (boolean), `decision` (string: "accept", "reject", "auto_accept", or "modify", optional), `error` (string, optional), `error_type` (string, present for error events), `prompt_id` (string), `response_id` (string, optional), `content_length` (int, optional), `tool_type` (string: "native" or "mcp"), `mcp_server_name` (string, optional), `metadata` (object, optional — for file-writing tools contains `model_added_lines`, `model_removed_lines`, `user_added_lines`, `user_removed_lines`, `model_added_chars`, `model_removed_chars`, `user_added_chars`, `user_removed_chars`)

- `qwen-code.file_operation`: Each file operation.
- **Attributes**: `tool_name` (string), `operation` (string: "create", "read", "update"), `lines` (int, optional), `mimetype` (string, optional), `extension` (string, optional), `programming_language` (string, optional)
Expand Down Expand Up @@ -721,7 +721,7 @@ Metrics are numerical measurements of behavior over time. Metric names use the `
- `qwen-code.session.count` (Counter, Int): Incremented once per CLI startup.

- `qwen-code.tool.call.count` (Counter, Int): Counts tool calls.
- **Attributes**: `function_name`, `success` (boolean), `decision` ("accept"/"reject"/"auto_accept"/"modify", optional), `tool_type` ("mcp"/"native", optional)
- **Attributes**: `function_name`, `status` ("success"/"error"/"cancelled"), `success` (boolean, retained for compatibility), `decision` ("accept"/"reject"/"auto_accept"/"modify", optional), `tool_type` ("mcp"/"native", optional)

- `qwen-code.tool.call.latency` (Histogram, ms): Measures tool call latency.
- **Attributes**: `function_name` (string)
Expand Down
2 changes: 1 addition & 1 deletion docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@ To help us improve Qwen Code, we collect anonymized usage statistics. This data

**What we collect:**

- **Tool Calls:** We log the names of the tools that are called, whether they succeed or fail, and how long they take to execute. We do not collect the arguments passed to the tools or any data returned by them.
- **Tool Calls:** We log the names and categories (native or MCP) of the tools that are called, their terminal status (success, error, or cancelled), and how long they take to execute. We do not collect the arguments passed to the tools or any data returned by them.
- **API Requests:** We log the model used for each request, the duration of the request, and whether it was successful. We do not collect the content of the prompts or responses.
- **Session Information:** We collect information about the configuration of the CLI, such as the enabled tools and the approval mode.

Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/core/coreToolScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3982,6 +3982,11 @@ describe('CoreToolScheduler', () => {
returnDisplay: 'alpha output',
});
const executeB = vi.fn().mockRejectedValue(new Error('beta failed'));
const executeC = vi.fn().mockResolvedValue({
llmContent: 'gamma failed',
returnDisplay: 'gamma failed',
error: { message: 'gamma failed' },
});
const toolsByName = new Map<string, MockTool>([
[
'alpha',
Expand All @@ -3999,6 +4004,14 @@ describe('CoreToolScheduler', () => {
execute: executeB,
}),
],
[
'gamma',
new MockTool({
name: 'gamma',
kind: Kind.Read,
execute: executeC,
}),
],
]);
const messageBus = {
request: vi.fn().mockImplementation(
Expand All @@ -4013,11 +4026,15 @@ describe('CoreToolScheduler', () => {
),
};
const onAllToolCallsComplete = vi.fn();
const recordToolResult = vi.fn();
const { scheduler } = createSchedulerForLegacyToolTests({
toolsByName,
messageBus,
disableHooks: false,
onAllToolCallsComplete,
chatRecordingService: {
recordToolResult,
} as unknown as ChatRecordingService,
});

await scheduler.schedule(
Expand All @@ -4036,6 +4053,13 @@ describe('CoreToolScheduler', () => {
isClientInitiated: false,
prompt_id: 'prompt-batch-failure',
},
{
callId: 'call-gamma',
name: 'gamma',
args: { value: 'c' },
isClientInitiated: false,
prompt_id: 'prompt-batch-failure',
},
],
new AbortController().signal,
);
Expand Down Expand Up @@ -4068,10 +4092,36 @@ describe('CoreToolScheduler', () => {
error_type: ToolErrorType.UNHANDLED_EXCEPTION,
}),
}),
expect.objectContaining({
tool_name: 'gamma',
status: 'error',
tool_response: expect.objectContaining({
error: 'gamma failed',
error_type: ToolErrorType.UNKNOWN,
}),
}),
],
},
}),
);
const completedCalls = onAllToolCallsComplete.mock
.calls[0][0] as ToolCall[];
expect(
completedCalls.find((call) => call.request.callId === 'call-gamma'),
).toMatchObject({
status: 'error',
response: {
errorType: ToolErrorType.UNKNOWN,
},
});
expect(
recordToolResult.mock.calls.find(
([, metadata]) => metadata?.callId === 'call-gamma',
)?.[1],
).toMatchObject({
status: 'error',
errorType: ToolErrorType.UNKNOWN,
});
});

it('queues new tool calls while a PostToolBatch hook is still running', async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/core/coreToolScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,7 +910,7 @@ const TRUNCATION_RETRY_LOOP_DIRECTIVE =
const createErrorResponse = (
request: ToolCallRequestInfo,
error: Error,
errorType: ToolErrorType | undefined,
errorType: ToolErrorType,
artifacts?: ToolArtifact[],
resultDisplay?: ToolResultDisplay,
): ToolCallResponseInfo => ({
Expand Down Expand Up @@ -4982,7 +4982,7 @@ export class CoreToolScheduler {
let errorResponse = createErrorResponse(
scheduledCall.request,
error,
toolResult.error.type,
toolResult.error.type ?? ToolErrorType.UNKNOWN,
failureHookArtifacts,
typeof toolResult.returnDisplay === 'string'
? undefined
Expand Down
Loading
Loading