feat(otel): make attribute truncation configurable

Adds `github.copilot.chat.otel.maxAttributeSizeChars` setting and
`COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS` env var to control truncation
of free-form OTel content attributes (prompts, responses, tool
arguments/results, hook input/output).

Default is `0` (unlimited), matching the OTel spec's
`AttributeValueLengthLimit` default of `Infinity`. Users on backends
with per-attribute size limits can set a positive value to keep OTLP
batches under the backend cap.

Plumbs the resolved limit through every call site that previously hit a
hardcoded 64KB fallback. Drops the `DEFAULT_MAX_OTEL_ATTRIBUTE_LENGTH`
constant; `truncateForOTel`'s default arg is now `0` (unlimited).

Refs #299952
This commit is contained in:
Zhichao Li
2026-05-01 23:07:22 -07:00
parent c9ce567528
commit 6fef3dffff
22 changed files with 225 additions and 54 deletions
@@ -66,7 +66,7 @@ Key attrs: `args` (JSON string of tool input), `result` (tool output or error te
```jsonl
{"ts":1773200231010,"dur":3001,"sid":"62f52dec","type":"llm_request","name":"chat:gpt-4o","spanId":"000000000000000c","parentSpanId":"0000000000000003","status":"ok","attrs":{"model":"gpt-4o","inputTokens":15025,"outputTokens":126,"ttft":1987,"maxTokens":32000,"systemPromptFile":"system_prompt_0.json","userRequest":"echo hello","inputMessages":"[{...}]"}}
```
Key attrs: `model`, `inputTokens`, `outputTokens`, `ttft` (time to first token in ms), `maxTokens`, `temperature`, `topP`, `systemPromptFile` (references a system prompt file in the session directory), `toolsFile` (references a tools file in the session directory), `userRequest` (the full user message content, untruncated), `inputMessages` (full messages array as JSON, pre-truncated at 64KB), `error` (when failed).
Key attrs: `model`, `inputTokens`, `outputTokens`, `ttft` (time to first token in ms), `maxTokens`, `temperature`, `topP`, `systemPromptFile` (references a system prompt file in the session directory), `toolsFile` (references a tools file in the session directory), `userRequest` (the full user message content, untruncated), `inputMessages` (full messages array as JSON, truncated to the configured `maxAttributeSizeChars` — unlimited by default), `error` (when failed).
#### agent_response — model output (text + tool calls)
```jsonl
@@ -70,6 +70,7 @@ Open **Settings** (`Ctrl+,`) and search for `copilot otel`:
| `github.copilot.chat.otel.exporterType` | string | `"otlp-http"` | `otlp-http`, `otlp-grpc`, `console`, or `file` |
| `github.copilot.chat.otel.otlpEndpoint` | string | `"http://localhost:4318"` | OTLP collector endpoint |
| `github.copilot.chat.otel.captureContent` | boolean | `false` | Capture full prompt/response content |
| `github.copilot.chat.otel.maxAttributeSizeChars` | integer | `0` | Max characters per OTel content attribute (prompts, tool args/results, hook input/output). `0` (the default) disables truncation so backends with no per-attribute limit get full payloads. Set to a positive value to match your backend's per-attribute size limit — consult your backend's documentation. The value counts JavaScript string characters (UTF-16 code units); for non-ASCII content one character can be multiple UTF-8 bytes on the wire. |
| `github.copilot.chat.otel.outfile` | string | `""` | File path for JSON-lines output |
| `github.copilot.chat.otel.dbSpanExporter.enabled` | boolean | `false` | Persist OTel spans to a local SQLite database for the **Chat: Export Agent Traces DB** command. Implicitly enables OTel. |
@@ -87,6 +88,7 @@ Environment variables **always take precedence** over VS Code settings.
| `OTEL_SERVICE_NAME` | `copilot-chat` | Service name in resource attributes |
| `OTEL_RESOURCE_ATTRIBUTES` | — | Extra resource attributes (`key1=val1,key2=val2`) |
| `COPILOT_OTEL_CAPTURE_CONTENT` | `false` | Capture full prompt/response content |
| `COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS` | `0` | Override the max character size for OTel content attributes. `0` (default) disables truncation; set to a positive value when your backend has a per-attribute limit. Takes precedence over the `maxAttributeSizeChars` setting. |
| `COPILOT_OTEL_LOG_LEVEL` | `info` | Min log level: `trace`, `debug`, `info`, `warn`, `error` |
| `COPILOT_OTEL_FILE_EXPORTER_PATH` | — | Write all signals to this file (JSON-lines) |
| `COPILOT_OTEL_HTTP_INSTRUMENTATION` | `false` | Enable HTTP-level OTel instrumentation |
@@ -321,6 +321,19 @@ if (this._otelService.config.captureContent) {
}
```
### Attribute Truncation
All free-form content attributes (prompts, tool arguments/results, hook input/output, reasoning text) **must** be passed through `truncateForOTel(value, otel.config.maxAttributeSizeChars)` before being set on a span. The default `maxAttributeSizeChars` is `0` (no truncation), matching the [OTel spec default of `Infinity`](https://opentelemetry.io/docs/specs/otel/common/#attribute-limits) for `AttributeValueLengthLimit`. Users whose OTel backend caps per-attribute size should set `github.copilot.chat.otel.maxAttributeSizeChars` (or the `COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS` env var) to a positive value so OTLP batches stay under the backend cap — consult the backend's documentation for the appropriate value.
> **Chars vs. bytes.** The [OTel spec](https://opentelemetry.io/docs/specs/otel/common/#attribute-limits) defines string attribute limits as "counting any character in it as 1". `truncateForOTel` approximates this using `value.length`, which counts UTF-16 code units rather than Unicode code points — so astral-plane characters (e.g. some emoji) count as 2 against the limit. Backends typically apply a separate UTF-8 byte limit downstream, so the on-wire size for non-ASCII content can be larger than the configured character count.
```typescript
const maxLen = this._otelService.config.maxAttributeSizeChars; // 0 = unlimited
span.setAttribute(GenAiAttr.TOOL_CALL_ARGUMENTS, truncateForOTel(JSON.stringify(args), maxLen));
```
The two-arg form is preferred at every call site that has access to `IOTelService`. The default-arg form (`truncateForOTel(value)`) is unlimited (no truncation) and should only be used from helpers that do not receive an `IOTelService` (e.g. tests, fixtures).
---
## Adding Instrumentation
+9
View File
@@ -4909,6 +4909,15 @@
"advanced"
]
},
"github.copilot.chat.otel.maxAttributeSizeChars": {
"type": "integer",
"default": 0,
"minimum": 0,
"markdownDescription": "Maximum size **in characters** for free-form OTel content attributes (prompts, responses, tool arguments/results, hook input/output). `0` (the default) disables truncation so backends without per-attribute size limits receive full JSON payloads. Set to a positive value when your OTel backend caps attribute size — consult your backend's documentation for its per-attribute limit. Truncated values are suffixed with `...[truncated, original N chars]`. Env var `COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS` takes precedence. Requires window reload.",
"tags": [
"advanced"
]
},
"github.copilot.chat.otel.outfile": {
"type": "string",
"default": "",
@@ -342,7 +342,7 @@ export class AnthropicLMProvider extends AbstractLanguageModelChatProvider {
if (responseText) { parts.push({ type: 'text', content: responseText }); }
parts.push(...toolCalls);
if (parts.length > 0) {
otelSpan.setAttribute(GenAiAttr.OUTPUT_MESSAGES, truncateForOTel(JSON.stringify([{ role: 'assistant', parts }])));
otelSpan.setAttribute(GenAiAttr.OUTPUT_MESSAGES, truncateForOTel(JSON.stringify([{ role: 'assistant', parts }]), this._otelService.config.maxAttributeSizeChars));
}
}
}
@@ -485,7 +485,7 @@ export class AnthropicLMProvider extends AbstractLanguageModelChatProvider {
// per OTel GenAI semantic conventions (issue #300318).
const toolDefs = toToolDefinitions(options.tools);
if (toolDefs) {
otelSpan.setAttribute(GenAiAttr.TOOL_DEFINITIONS, truncateForOTel(JSON.stringify(toolDefs)));
otelSpan.setAttribute(GenAiAttr.TOOL_DEFINITIONS, truncateForOTel(JSON.stringify(toolDefs), this._otelService.config.maxAttributeSizeChars));
}
try {
const roleNames: Record<number, string> = { 1: 'user', 2: 'assistant', 3: 'system' };
@@ -510,7 +510,7 @@ export class AnthropicLMProvider extends AbstractLanguageModelChatProvider {
}
return { role, parts };
});
otelSpan.setAttribute(GenAiAttr.INPUT_MESSAGES, truncateForOTel(JSON.stringify(inputMsgs)));
otelSpan.setAttribute(GenAiAttr.INPUT_MESSAGES, truncateForOTel(JSON.stringify(inputMsgs), this._otelService.config.maxAttributeSizeChars));
} catch { /* swallow */ }
}
try {
@@ -217,7 +217,7 @@ export class GeminiNativeBYOKLMProvider extends AbstractLanguageModelChatProvide
if (responseText) { parts.push({ type: 'text', content: responseText }); }
parts.push(...toolCalls);
if (parts.length > 0) {
otelSpan.setAttribute(GenAiAttr.OUTPUT_MESSAGES, truncateForOTel(JSON.stringify([{ role: 'assistant', parts }])));
otelSpan.setAttribute(GenAiAttr.OUTPUT_MESSAGES, truncateForOTel(JSON.stringify([{ role: 'assistant', parts }]), this._otelService.config.maxAttributeSizeChars));
}
}
}
@@ -350,7 +350,7 @@ export class GeminiNativeBYOKLMProvider extends AbstractLanguageModelChatProvide
// per OTel GenAI semantic conventions (issue #300318).
const toolDefs = toToolDefinitions(options.tools);
if (toolDefs) {
otelSpan.setAttribute(GenAiAttr.TOOL_DEFINITIONS, truncateForOTel(JSON.stringify(toolDefs)));
otelSpan.setAttribute(GenAiAttr.TOOL_DEFINITIONS, truncateForOTel(JSON.stringify(toolDefs), this._otelService.config.maxAttributeSizeChars));
}
try {
const roleNames: Record<number, string> = { 1: 'user', 2: 'assistant', 3: 'system' };
@@ -372,7 +372,7 @@ export class GeminiNativeBYOKLMProvider extends AbstractLanguageModelChatProvide
}
return { role, parts };
});
otelSpan.setAttribute(GenAiAttr.INPUT_MESSAGES, truncateForOTel(JSON.stringify(inputMsgs)));
otelSpan.setAttribute(GenAiAttr.INPUT_MESSAGES, truncateForOTel(JSON.stringify(inputMsgs), this._otelService.config.maxAttributeSizeChars));
} catch { /* swallow */ }
}
try {
@@ -166,7 +166,7 @@ export class ChatHookService implements IChatHookService {
try {
// Capture hook input for debug panel resolve
try {
span.setAttribute(CopilotChatAttr.HOOK_INPUT, truncateForOTel(JSON.stringify(commandInput)));
span.setAttribute(CopilotChatAttr.HOOK_INPUT, truncateForOTel(JSON.stringify(commandInput), this._otelService.config.maxAttributeSizeChars));
} catch { /* swallow serialization errors */ }
const sw = StopWatch.create();
@@ -195,7 +195,7 @@ export class ChatHookService implements IChatHookService {
try {
const output = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result);
if (output) {
span.setAttribute(CopilotChatAttr.HOOK_OUTPUT, truncateForOTel(output));
span.setAttribute(CopilotChatAttr.HOOK_OUTPUT, truncateForOTel(output, this._otelService.config.maxAttributeSizeChars));
}
} catch { /* swallow serialization errors */ }
}
@@ -243,6 +243,7 @@ function logToolResult(
requestLogger: IRequestLogger,
otelToolSpans: Map<string, ISpanHandle>,
capturingToken: CapturingToken | undefined,
maxAttributeSizeChars: number,
): void {
// OTel span
const toolSpan = otelToolSpans.get(toolUseId);
@@ -250,13 +251,13 @@ function logToolResult(
if (toolResult.is_error) {
const errContent = typeof toolResult.content === 'string' ? toolResult.content : 'tool error';
toolSpan.setStatus(SpanStatusCode.ERROR, errContent);
toolSpan.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(`ERROR: ${errContent}`));
toolSpan.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(`ERROR: ${errContent}`, maxAttributeSizeChars));
} else {
toolSpan.setStatus(SpanStatusCode.OK);
if (toolResult.content !== undefined) {
try {
const result = typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content);
toolSpan.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(result));
toolSpan.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(result, maxAttributeSizeChars));
} catch (e) {
logService.warn(`[ClaudeMessageDispatch] Failed to serialize tool result: ${e}`);
}
@@ -293,6 +294,7 @@ function processToolResult(
const logService = accessor.get(ILogService);
const requestLogger = accessor.get(IRequestLogger);
const claudeSessionStateService = accessor.get(IClaudeSessionStateService);
const otelService = accessor.get(IOTelService);
const { stream } = request;
const { unprocessedToolCalls, otelToolSpans } = state;
@@ -313,7 +315,8 @@ function processToolResult(
logService,
requestLogger,
otelToolSpans,
claudeSessionStateService.getCapturingTokenForSession(sessionId)
claudeSessionStateService.getCapturingTokenForSession(sessionId),
otelService.config.maxAttributeSizeChars,
);
// Tool-specific handling
@@ -511,6 +514,7 @@ export function handleHookResponse(
state: MessageHandlerState,
): void {
const logService = accessor.get(ILogService);
const otelService = accessor.get(IOTelService);
// TODO: can we map these types better
const hookType = message.hook_event as ChatHookType;
@@ -528,7 +532,7 @@ export function handleHookResponse(
span.setAttribute('copilot_chat.hook_exit_code', message.exit_code);
}
if (message.output) {
span.setAttribute('copilot_chat.hook_output', truncateForOTel(message.output));
span.setAttribute('copilot_chat.hook_output', truncateForOTel(message.output, otelService.config.maxAttributeSizeChars));
}
span.end();
state.otelHookSpans.delete(message.hook_id);
@@ -60,7 +60,7 @@ interface TestServices {
function createTestServices(): TestServices {
return {
logService: new TestLogService(),
otelService: { startSpan: () => noopSpan } as Pick<IOTelService, 'startSpan'> as IOTelService,
otelService: { startSpan: () => noopSpan, config: { maxAttributeSizeChars: 0 } } as unknown as IOTelService,
toolsService: { invokeTool: vi.fn() } as Pick<IToolsService, 'invokeTool'> as IToolsService,
requestLogger: { logToolCall: vi.fn(), captureInvocation: vi.fn() },
sessionStateService: { setPermissionModeForSession: vi.fn(), getCapturingTokenForSession: vi.fn().mockReturnValue(undefined) },
@@ -86,7 +86,7 @@ export class ClaudeOTelTracker {
},
parentTraceContext: this._currentTraceContext,
});
const userContent = truncateForOTel(promptLabel);
const userContent = truncateForOTel(promptLabel, this._otelService.config.maxAttributeSizeChars);
userMsgSpan.setAttribute(CopilotChatAttr.USER_REQUEST, userContent);
userMsgSpan.addEvent('user_message', { content: userContent, [CopilotChatAttr.CHAT_SESSION_ID]: this._sessionId });
userMsgSpan.end();
@@ -966,13 +966,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
[CopilotChatAttr.SESSION_ID]: this.sessionId,
[CopilotChatAttr.CHAT_SESSION_ID]: this.sessionId,
...(modelId ? { [GenAiAttr.REQUEST_MODEL]: modelId } : {}),
[CopilotChatAttr.USER_REQUEST]: truncateForOTel(promptLabel),
[CopilotChatAttr.USER_REQUEST]: truncateForOTel(promptLabel, this._otelService.config.maxAttributeSizeChars),
...workspaceMetadataToOTelAttributes(resolveWorkspaceOTelMetadata(this._gitService)),
},
},
async span => {
// Emit user_message event so chronicle can extract turns and summary
span.addEvent('user_message', { content: truncateForOTel(promptLabel) });
span.addEvent('user_message', { content: truncateForOTel(promptLabel, this._otelService.config.maxAttributeSizeChars) });
// Register the trace context so the bridge processor can inject CHAT_SESSION_ID
const traceCtx = span.getSpanContext();
@@ -1439,7 +1439,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
this.logService.trace(`[CopilotCLISession] Hook ${event.data.hookType} started (${event.data.hookInvocationId})`);
let input: string | undefined;
try {
input = truncateForOTel(JSON.stringify(event.data.input));
input = truncateForOTel(JSON.stringify(event.data.input), this._otelService.config.maxAttributeSizeChars);
} catch { /* swallow serialization errors */ }
this._bridgeProcessor?.stashHookInput(event.data.hookInvocationId, event.data.hookType, input);
})));
@@ -1449,7 +1449,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
let output: string | undefined;
if (event.data.success) {
try {
output = truncateForOTel(JSON.stringify(event.data.output));
output = truncateForOTel(JSON.stringify(event.data.output), this._otelService.config.maxAttributeSizeChars);
} catch { /* swallow serialization errors */ }
}
this._bridgeProcessor?.stashHookEnd(
@@ -292,6 +292,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio
settingExporterType: otelSettings.get<'otlp-grpc' | 'otlp-http' | 'console' | 'file'>('exporterType'),
settingOtlpEndpoint: otelSettings.get<string>('otlpEndpoint'),
settingCaptureContent: otelSettings.get<boolean>('captureContent'),
settingMaxAttributeSizeChars: otelSettings.get<number>('maxAttributeSizeChars'),
settingOutfile: otelSettings.get<string>('outfile') || undefined,
settingDbSpanExporter: otelSettings.get<boolean>('dbSpanExporter.enabled'),
extensionVersion: extensionContext.extension.packageJSON.version ?? '0.0.0',
@@ -831,16 +831,17 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
// Always capture user input message for the debug panel
{
const userMessage = this.turn.request.message;
const maxLen = this._otelService.config.maxAttributeSizeChars;
span.setAttribute(GenAiAttr.INPUT_MESSAGES, truncateForOTel(JSON.stringify([
{ role: 'user', parts: [{ type: 'text', content: userMessage }] }
])));
]), maxLen));
// Set USER_REQUEST so event translator can emit user.message
if (userMessage) {
span.setAttribute(CopilotChatAttr.USER_REQUEST, truncateForOTel(userMessage));
span.setAttribute(CopilotChatAttr.USER_REQUEST, truncateForOTel(userMessage, maxLen));
}
// Emit user_message span event for real-time debug panel streaming
if (userMessage) {
span.addEvent('user_message', { content: userMessage, ...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}) });
span.addEvent('user_message', { content: truncateForOTel(userMessage, maxLen), ...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}) });
}
}
@@ -264,7 +264,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
const userContent = typeof lastUserMsg.content === 'string'
? lastUserMsg.content
: JSON.stringify(lastUserMsg.content);
otelInferenceSpan.setAttribute(CopilotChatAttr.USER_REQUEST, truncateForOTel(userContent));
otelInferenceSpan.setAttribute(CopilotChatAttr.USER_REQUEST, truncateForOTel(userContent, this._otelService.config.maxAttributeSizeChars));
}
// System instructions — check messages array, top-level system (Anthropic), or instructions (Responses API)
const systemMsg = capiMessages?.find(m => m.role === 'system');
@@ -297,14 +297,14 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
const capiMessages = (requestBody.messages ?? requestBody.input) as ReadonlyArray<Record<string, unknown>> | undefined;
if (capiMessages) {
// Normalize provider-specific content (Anthropic tool_use/tool_result, OpenAI tool messages) to OTel schema
otelInferenceSpan.setAttribute(GenAiAttr.INPUT_MESSAGES, truncateForOTel(JSON.stringify(normalizeProviderMessages(capiMessages))));
otelInferenceSpan.setAttribute(GenAiAttr.INPUT_MESSAGES, truncateForOTel(JSON.stringify(normalizeProviderMessages(capiMessages)), this._otelService.config.maxAttributeSizeChars));
}
// Tool definitions: emit on every chat span so trace viewers can render the
// tool catalog per LLM call (issue #299934). Includes `parameters` per
// OTel GenAI semantic conventions (issue #300318).
const toolDefs = toToolDefinitions(requestBody.tools);
if (toolDefs) {
otelInferenceSpan.setAttribute(GenAiAttr.TOOL_DEFINITIONS, truncateForOTel(JSON.stringify(toolDefs)));
otelInferenceSpan.setAttribute(GenAiAttr.TOOL_DEFINITIONS, truncateForOTel(JSON.stringify(toolDefs), this._otelService.config.maxAttributeSizeChars));
}
// Cache-relevant request options. Anything in this blob, when changed
// between two requests, will invalidate the prompt cache even when
@@ -313,7 +313,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
// signature diff.
const requestOptions = pickCacheRelevantRequestOptions(requestBody);
if (requestOptions) {
otelInferenceSpan.setAttribute(CopilotChatAttr.REQUEST_OPTIONS, truncateForOTel(JSON.stringify(requestOptions)));
otelInferenceSpan.setAttribute(CopilotChatAttr.REQUEST_OPTIONS, truncateForOTel(JSON.stringify(requestOptions), this._otelService.config.maxAttributeSizeChars));
}
}
tokenCount = await countTokens();
@@ -434,7 +434,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
}
parts.push(...toolCalls);
if (parts.length > 0) {
otelInferenceSpan.setAttribute(GenAiAttr.OUTPUT_MESSAGES, truncateForOTel(JSON.stringify([{ role: 'assistant', parts }])));
otelInferenceSpan.setAttribute(GenAiAttr.OUTPUT_MESSAGES, truncateForOTel(JSON.stringify([{ role: 'assistant', parts }]), this._otelService.config.maxAttributeSizeChars));
}
// Capture reasoning/thinking text if present
const hasThinking = streamRecorder.deltas.some(d => d.thinking);
@@ -447,7 +447,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
return Array.isArray(t.text) ? t.text.join('') : (t.text ?? '');
});
const reasoningText = thinkingTexts.join('');
otelInferenceSpan.setAttribute(CopilotChatAttr.REASONING_CONTENT, truncateForOTel(reasoningText || '[encrypted]'));
otelInferenceSpan.setAttribute(CopilotChatAttr.REASONING_CONTENT, truncateForOTel(reasoningText || '[encrypted]', this._otelService.config.maxAttributeSizeChars));
}
}
@@ -143,7 +143,7 @@ export class ToolsService extends BaseToolsService {
// Always capture tool call arguments for the debug panel
if (options.input !== undefined) {
try {
span.setAttribute(GenAiAttr.TOOL_CALL_ARGUMENTS, truncateForOTel(JSON.stringify(options.input)));
span.setAttribute(GenAiAttr.TOOL_CALL_ARGUMENTS, truncateForOTel(JSON.stringify(options.input), this._otelService.config.maxAttributeSizeChars));
} catch { /* swallow serialization errors */ }
}
@@ -210,7 +210,7 @@ export class ToolsService extends BaseToolsService {
}
}
if (parts.length > 0) {
span.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(parts.join('')));
span.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(parts.join(''), this._otelService.config.maxAttributeSizeChars));
}
} catch { /* swallow */ }
span.end();
@@ -223,7 +223,7 @@ export class ToolsService extends BaseToolsService {
err => {
span.setStatus(SpanStatusCode.ERROR, err instanceof Error ? err.message : String(err));
span.setAttribute(StdAttr.ERROR_TYPE, err instanceof Error ? err.constructor.name : 'Error');
span.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(`ERROR: ${err instanceof Error ? err.message : String(err)}`));
span.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(`ERROR: ${err instanceof Error ? err.message : String(err)}`, this._otelService.config.maxAttributeSizeChars));
span.recordException(err);
span.end();
const durationMs = Date.now() - startTime;
@@ -716,6 +716,7 @@ export namespace ConfigKey {
export const OTelExporterType = defineSetting<string>('chat.otel.exporterType', ConfigType.Simple, 'otlp-http');
export const OTelOtlpEndpoint = defineSetting<string>('chat.otel.otlpEndpoint', ConfigType.Simple, 'http://localhost:4318');
export const OTelCaptureContent = defineSetting<boolean>('chat.otel.captureContent', ConfigType.Simple, false);
export const OTelMaxAttributeSizeChars = defineSetting<number>('chat.otel.maxAttributeSizeChars', ConfigType.Simple, 0);
export const OTelOutfile = defineSetting<string>('chat.otel.outfile', ConfigType.Simple, '');
export const OTelDbSpanExporter = defineSetting<boolean>('chat.otel.dbSpanExporter.enabled', ConfigType.Simple, false);
@@ -51,14 +51,15 @@ export function emitInferenceDetailsEvent(
attributes[StdAttr.ERROR_TYPE] = error.type;
}
// Full content capture with truncation to prevent OTLP batch failures
// Normalize to OTel GenAI semantic convention format
// Full content capture (optionally truncated per OTelConfig.maxAttributeSizeChars).
// Normalize to OTel GenAI semantic convention format.
if (otel.config.captureContent) {
const maxLen = otel.config.maxAttributeSizeChars;
if (request.messages !== undefined) {
const msgs = Array.isArray(request.messages) ? request.messages as ReadonlyArray<Record<string, unknown>> : undefined;
attributes[GenAiAttr.INPUT_MESSAGES] = truncateForOTel(JSON.stringify(
msgs ? normalizeProviderMessages(msgs) : request.messages
));
), maxLen);
}
if (request.systemMessage !== undefined) {
const systemText = typeof request.systemMessage === 'string'
@@ -66,11 +67,11 @@ export function emitInferenceDetailsEvent(
: JSON.stringify(request.systemMessage);
const systemInstructions = toSystemInstructions(systemText);
if (systemInstructions !== undefined) {
attributes[GenAiAttr.SYSTEM_INSTRUCTIONS] = truncateForOTel(JSON.stringify(systemInstructions));
attributes[GenAiAttr.SYSTEM_INSTRUCTIONS] = truncateForOTel(JSON.stringify(systemInstructions), maxLen);
}
}
if (request.tools !== undefined) {
attributes[GenAiAttr.TOOL_DEFINITIONS] = truncateForOTel(JSON.stringify(request.tools));
attributes[GenAiAttr.TOOL_DEFINITIONS] = truncateForOTel(JSON.stringify(request.tools), maxLen);
}
}
@@ -9,22 +9,28 @@
* @see https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-output-messages.json
*/
/**
* Maximum size (in characters) for a single OTel span/log attribute value.
* Aligned with common backend limits (Jaeger 64KB, Tempo 100KB).
* Matches gemini-cli's approach of capping content to prevent OTLP batch failures.
*/
const MAX_OTEL_ATTRIBUTE_LENGTH = 64_000;
/**
* Truncate a string to fit within OTel attribute size limits.
* Returns the original string if within bounds, otherwise truncates with a suffix.
*
* @param value The string to truncate.
* @param maxLength The maximum length in characters. A value of `0` (the
* default) or any non-positive number disables truncation entirely, matching
* the OTel spec's `AttributeValueLengthLimit` default of `Infinity` for string
* attributes (see https://opentelemetry.io/docs/specs/otel/common/#attribute-limits).
* Production call sites should pass `OTelConfig.maxAttributeSizeChars` so
* users can configure truncation to match their backend's per-attribute limit.
*/
export function truncateForOTel(value: string, maxLength: number = MAX_OTEL_ATTRIBUTE_LENGTH): string {
if (value.length <= maxLength) {
export function truncateForOTel(value: string, maxLength: number = 0): string {
if (maxLength <= 0 || value.length <= maxLength) {
return value;
}
const suffix = `...[truncated, original ${value.length} chars]`;
// If maxLength is too small to fit the suffix, fall back to a hard cut so
// the result is always <= maxLength.
if (maxLength <= suffix.length) {
return value.substring(0, maxLength);
}
return value.substring(0, maxLength - suffix.length) + suffix;
}
@@ -20,6 +20,15 @@ export interface OTelConfig {
readonly otlpEndpoint: string;
readonly otlpProtocol: 'grpc' | 'http';
readonly captureContent: boolean;
/**
* Maximum size (in characters) for free-form content attributes (prompts,
* tool args, etc.). A value of `0` disables truncation entirely (the
* default), matching the OTel spec's `AttributeValueLengthLimit` default of
* `Infinity`. Set to a positive value when targeting backends that cap
* per-attribute size to keep OTLP batches under the backend limit; consult
* the backend's documentation for the appropriate value.
*/
readonly maxAttributeSizeChars: number;
readonly fileExporterPath?: string;
readonly dbSpanExporter: boolean;
readonly logLevel: 'trace' | 'debug' | 'info' | 'warn' | 'error';
@@ -75,6 +84,7 @@ export interface OTelConfigInput {
settingExporterType?: OTelExporterType;
settingOtlpEndpoint?: string;
settingCaptureContent?: boolean;
settingMaxAttributeSizeChars?: number;
settingOutfile?: string;
settingDbSpanExporter?: boolean;
extensionVersion: string;
@@ -157,6 +167,11 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig {
?? input.settingCaptureContent
?? false;
// Max attribute size in characters: env > setting > default(0 = unlimited).
const maxAttributeSizeChars = parseMaxAttributeSizeChars(env['COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS'])
?? input.settingMaxAttributeSizeChars
?? 0;
// Log level
const validLogLevels = new Set<OTelConfig['logLevel']>(['trace', 'debug', 'info', 'warn', 'error']);
const rawLogLevel = env['COPILOT_OTEL_LOG_LEVEL'];
@@ -181,6 +196,7 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig {
otlpEndpoint,
otlpProtocol: protocol,
captureContent,
maxAttributeSizeChars: maxAttributeSizeChars < 0 ? 0 : maxAttributeSizeChars,
fileExporterPath,
dbSpanExporter,
logLevel,
@@ -201,6 +217,7 @@ function createDisabledConfig(input: OTelConfigInput): OTelConfig {
otlpEndpoint: '',
otlpProtocol: 'http' as const,
captureContent: false,
maxAttributeSizeChars: 0,
dbSpanExporter: false,
logLevel: 'info' as const,
httpInstrumentation: false,
@@ -217,3 +234,20 @@ function envBool(val: string | undefined): boolean | undefined {
}
return val === 'true' || val === '1';
}
/**
* Parse a numeric env var representing the max attribute size in characters.
* Accepts non-negative safe integers; fractional, unsafe, or non-numeric input
* returns `undefined` so the caller can fall back to the next config layer.
* Negative values are clamped to `0` (no truncation) by the caller.
*/
function parseMaxAttributeSizeChars(val: string | undefined): number | undefined {
if (val === undefined || val === '') {
return undefined;
}
const n = Number(val);
if (!Number.isSafeInteger(n)) {
return undefined;
}
return n;
}
@@ -16,6 +16,7 @@ function makeConfig(overrides: Partial<OTelConfig> = {}): OTelConfig {
otlpEndpoint: 'http://localhost:4318',
otlpProtocol: 'http',
captureContent: false,
maxAttributeSizeChars: 0,
dbSpanExporter: false,
logLevel: 'info',
httpInstrumentation: false,
@@ -322,15 +322,25 @@ describe('truncateForOTel', () => {
expect(result).toContain('...[truncated, original 200 chars]');
});
it('uses default 64000 limit', () => {
const s = 'x'.repeat(64_001);
const result = truncateForOTel(s);
expect(result.length).toBeLessThanOrEqual(64_000);
expect(result).toContain('...[truncated');
});
it('does not truncate at exactly 64000', () => {
const s = 'x'.repeat(64_000);
it('default (no maxLength) is unlimited', () => {
const s = 'x'.repeat(200_000);
expect(truncateForOTel(s)).toBe(s);
});
it('returns string unchanged when maxLength is 0 (unlimited)', () => {
const s = 'a'.repeat(200_000);
expect(truncateForOTel(s, 0)).toBe(s);
});
it('returns string unchanged when maxLength is negative (unlimited)', () => {
const s = 'a'.repeat(200_000);
expect(truncateForOTel(s, -1)).toBe(s);
});
it('falls back to a hard cut when maxLength is too small to fit the suffix', () => {
const s = 'a'.repeat(200);
const result = truncateForOTel(s, 5);
expect(result.length).toBeLessThanOrEqual(5);
expect(result).toBe('aaaaa');
});
});
@@ -230,4 +230,92 @@ describe('resolveOTelConfig', () => {
expect(config.enabledVia).toBe('envVar');
});
});
describe('maxAttributeSizeChars', () => {
it('defaults to 0 (unlimited) when nothing is set', () => {
const config = resolveOTelConfig(makeInput({ settingEnabled: true }));
expect(config.maxAttributeSizeChars).toBe(0);
});
it('defaults to 0 (unlimited) even when OTel is disabled', () => {
const config = resolveOTelConfig(makeInput());
expect(config.maxAttributeSizeChars).toBe(0);
});
it('uses VS Code setting when env var is unset', () => {
const config = resolveOTelConfig(makeInput({
settingEnabled: true,
settingMaxAttributeSizeChars: 64_000,
}));
expect(config.maxAttributeSizeChars).toBe(64_000);
});
it('treats setting value 0 as unlimited (no truncation)', () => {
const config = resolveOTelConfig(makeInput({
settingEnabled: true,
settingMaxAttributeSizeChars: 0,
}));
expect(config.maxAttributeSizeChars).toBe(0);
});
it('env var overrides VS Code setting', () => {
const config = resolveOTelConfig(makeInput({
env: {
'COPILOT_OTEL_ENABLED': 'true',
'COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS': '128000',
},
settingMaxAttributeSizeChars: 32_000,
}));
expect(config.maxAttributeSizeChars).toBe(128_000);
});
it('env var value 0 disables truncation', () => {
const config = resolveOTelConfig(makeInput({
env: {
'COPILOT_OTEL_ENABLED': 'true',
'COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS': '0',
},
settingMaxAttributeSizeChars: 32_000,
}));
expect(config.maxAttributeSizeChars).toBe(0);
});
it('clamps negative values to 0 (unlimited)', () => {
const config = resolveOTelConfig(makeInput({
settingEnabled: true,
settingMaxAttributeSizeChars: -1,
}));
expect(config.maxAttributeSizeChars).toBe(0);
});
it('falls back to default (0) when env var is non-numeric', () => {
const config = resolveOTelConfig(makeInput({
env: {
'COPILOT_OTEL_ENABLED': 'true',
'COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS': 'not-a-number',
},
}));
expect(config.maxAttributeSizeChars).toBe(0);
});
it('falls back to default (0) when env var is fractional', () => {
const config = resolveOTelConfig(makeInput({
env: {
'COPILOT_OTEL_ENABLED': 'true',
'COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS': '1.5',
},
}));
expect(config.maxAttributeSizeChars).toBe(0);
});
it('falls back to default (0) when env var exceeds safe integer range', () => {
const config = resolveOTelConfig(makeInput({
env: {
'COPILOT_OTEL_ENABLED': 'true',
'COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS': '1e308',
},
}));
expect(config.maxAttributeSizeChars).toBe(0);
});
});
});