mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-10 16:49:36 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
+8
-4
@@ -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);
|
||||
|
||||
+1
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user