diff --git a/extensions/copilot/assets/prompts/skills/troubleshoot/SKILL.md b/extensions/copilot/assets/prompts/skills/troubleshoot/SKILL.md index a2f4592970f..2e79390fca3 100644 --- a/extensions/copilot/assets/prompts/skills/troubleshoot/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/troubleshoot/SKILL.md @@ -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 diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index 461a9950617..9ccaefef64f 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -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 | diff --git a/extensions/copilot/docs/monitoring/agent_monitoring_arch.md b/extensions/copilot/docs/monitoring/agent_monitoring_arch.md index dd58b3fb5c7..565fb3c3642 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring_arch.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring_arch.md @@ -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 diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 3d178e241cb..e3158472e24 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -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": "", diff --git a/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts index b7b101b9194..0cbb496b2f7 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts @@ -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 = { 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 { diff --git a/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts index aa94687f520..c8fb3efb9e2 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts @@ -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 = { 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 { diff --git a/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts b/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts index 473311db1f2..25a6b059aaa 100644 --- a/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts +++ b/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts @@ -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 */ } } diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts index 5515ae0e252..dbbeafdb1b4 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts @@ -243,6 +243,7 @@ function logToolResult( requestLogger: IRequestLogger, otelToolSpans: Map, 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); diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts index 7677977e724..291333d4405 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts @@ -60,7 +60,7 @@ interface TestServices { function createTestServices(): TestServices { return { logService: new TestLogService(), - otelService: { startSpan: () => noopSpan } as Pick as IOTelService, + otelService: { startSpan: () => noopSpan, config: { maxAttributeSizeChars: 0 } } as unknown as IOTelService, toolsService: { invokeTool: vi.fn() } as Pick as IToolsService, requestLogger: { logToolCall: vi.fn(), captureInvocation: vi.fn() }, sessionStateService: { setPermissionModeForSession: vi.fn(), getCapturingTokenForSession: vi.fn().mockReturnValue(undefined) }, diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts index cdd20a2acfa..219deb73620 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts @@ -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(); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index ba58710ed8c..4e8926c5504 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -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( diff --git a/extensions/copilot/src/extension/extension/vscode-node/services.ts b/extensions/copilot/src/extension/extension/vscode-node/services.ts index 99bc9618fb6..dc10143085a 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/services.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/services.ts @@ -292,6 +292,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio settingExporterType: otelSettings.get<'otlp-grpc' | 'otlp-http' | 'console' | 'file'>('exporterType'), settingOtlpEndpoint: otelSettings.get('otlpEndpoint'), settingCaptureContent: otelSettings.get('captureContent'), + settingMaxAttributeSizeChars: otelSettings.get('maxAttributeSizeChars'), settingOutfile: otelSettings.get('outfile') || undefined, settingDbSpanExporter: otelSettings.get('dbSpanExporter.enabled'), extensionVersion: extensionContext.extension.packageJSON.version ?? '0.0.0', diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index 524b5503d46..6d1c60aa2ee 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -831,16 +831,17 @@ export abstract class ToolCallingLoop m.role === 'system'); @@ -297,14 +297,14 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { const capiMessages = (requestBody.messages ?? requestBody.input) as ReadonlyArray> | 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)); } } diff --git a/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts b/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts index ddccb2b47e6..b17be113c7f 100644 --- a/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts +++ b/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts @@ -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; diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 109a9a0a3f5..cf29ec86ee4 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -716,6 +716,7 @@ export namespace ConfigKey { export const OTelExporterType = defineSetting('chat.otel.exporterType', ConfigType.Simple, 'otlp-http'); export const OTelOtlpEndpoint = defineSetting('chat.otel.otlpEndpoint', ConfigType.Simple, 'http://localhost:4318'); export const OTelCaptureContent = defineSetting('chat.otel.captureContent', ConfigType.Simple, false); + export const OTelMaxAttributeSizeChars = defineSetting('chat.otel.maxAttributeSizeChars', ConfigType.Simple, 0); export const OTelOutfile = defineSetting('chat.otel.outfile', ConfigType.Simple, ''); export const OTelDbSpanExporter = defineSetting('chat.otel.dbSpanExporter.enabled', ConfigType.Simple, false); diff --git a/extensions/copilot/src/platform/otel/common/genAiEvents.ts b/extensions/copilot/src/platform/otel/common/genAiEvents.ts index 9c697921881..6f34878104d 100644 --- a/extensions/copilot/src/platform/otel/common/genAiEvents.ts +++ b/extensions/copilot/src/platform/otel/common/genAiEvents.ts @@ -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> : 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); } } diff --git a/extensions/copilot/src/platform/otel/common/messageFormatters.ts b/extensions/copilot/src/platform/otel/common/messageFormatters.ts index e3bcd9cba84..4163d6393be 100644 --- a/extensions/copilot/src/platform/otel/common/messageFormatters.ts +++ b/extensions/copilot/src/platform/otel/common/messageFormatters.ts @@ -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; } diff --git a/extensions/copilot/src/platform/otel/common/otelConfig.ts b/extensions/copilot/src/platform/otel/common/otelConfig.ts index 0c0f8592b3c..e2aaefe269a 100644 --- a/extensions/copilot/src/platform/otel/common/otelConfig.ts +++ b/extensions/copilot/src/platform/otel/common/otelConfig.ts @@ -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(['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; +} diff --git a/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts b/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts index 024a650169e..b401981d8dc 100644 --- a/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts @@ -16,6 +16,7 @@ function makeConfig(overrides: Partial = {}): OTelConfig { otlpEndpoint: 'http://localhost:4318', otlpProtocol: 'http', captureContent: false, + maxAttributeSizeChars: 0, dbSpanExporter: false, logLevel: 'info', httpInstrumentation: false, diff --git a/extensions/copilot/src/platform/otel/common/test/messageFormatters.spec.ts b/extensions/copilot/src/platform/otel/common/test/messageFormatters.spec.ts index ca76fd4ea81..efe1e1921ed 100644 --- a/extensions/copilot/src/platform/otel/common/test/messageFormatters.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/messageFormatters.spec.ts @@ -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'); + }); }); diff --git a/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts b/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts index 34197964f88..ff05f5c74b1 100644 --- a/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts @@ -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); + }); + }); });