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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`ObservabilityConfiguration.isContentRecordingEnabled`** — Gates recording of sensitive content (prompts, completions, tool I/O) in telemetry spans. Defaults `false`; enable via `AZURE_TRACING_GEN_AI_CONTENT_RECORDING_ENABLED=true` or programmatic override in `ObservabilityConfigurationOptions`.
- **`OpenAIAgentsInstrumentationConfig.isContentRecordingEnabled`** — Optional `boolean` to enable content recording in OpenAI trace processor.
- **`LangChainTraceInstrumentor.instrument(module, options?)`** — New optional `{ isContentRecordingEnabled?: boolean }` parameter to enable content recording in LangChain tracer.
Comment thread
fpfp100 marked this conversation as resolved.
- **`truncateValue`** / **`MAX_ATTRIBUTE_LENGTH`** — Exported utilities for attribute value truncation (8192 char limit).

Comment thread
fpfp100 marked this conversation as resolved.
### Breaking Changes (`@microsoft/agents-a365-observability-hosting`)

- **`ScopeUtils.deriveAgentDetails(turnContext, authToken)`** — New required `authToken: string` parameter.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import { Run } from "@langchain/core/tracers/base";
import { Span } from "@opentelemetry/api";
import { OpenTelemetryConstants } from "@microsoft/agents-a365-observability";

const MAX_ATTRIBUTE_LENGTH = 8_192;

function truncateValue(value: string): string {
if (value.length > MAX_ATTRIBUTE_LENGTH) {
return value.substring(0, MAX_ATTRIBUTE_LENGTH) + '...[truncated]';
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
}
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
return value;
}

// Type guards
export function isString(value: unknown): value is string {
return typeof value === "string";
Expand Down Expand Up @@ -51,8 +60,8 @@ export function setToolAttributes(run: Run, span: Span) {
if (isString(run.name)) {
span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_NAME_KEY, run.name);
}
if (run.inputs) span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY, JSON.stringify(run.inputs?.input ?? run.inputs));
if (run.outputs?.output?.kwargs?.content) span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY, JSON.stringify(run.outputs?.output?.kwargs?.content));
if (run.inputs) span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY, truncateValue(JSON.stringify(run.inputs?.input ?? run.inputs)));
if (run.outputs?.output?.kwargs?.content) span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY, truncateValue(JSON.stringify(run.outputs?.output?.kwargs?.content)));
span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY, "extension");
if (run.outputs?.output?.tool_call_id) span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_CALL_ID_KEY, run.outputs?.output?.tool_call_id);
}
Expand All @@ -77,7 +86,7 @@ export function setInputMessagesAttribute(run: Run, span: Span) {
.filter(Boolean);

if (processed.length > 0) {
span.setAttribute(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, JSON.stringify(processed));
span.setAttribute(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, truncateValue(JSON.stringify(processed)));
}
}

Expand Down Expand Up @@ -214,7 +223,7 @@ export function setOutputMessagesAttribute(run: Run, span: Span) {
}

if (messages.length > 0) {
span.setAttribute(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, JSON.stringify(messages));
span.setAttribute(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, truncateValue(JSON.stringify(messages)));
}
}

Expand Down Expand Up @@ -263,15 +272,15 @@ export function setSystemInstructionsAttribute(run: Run, span: Span) {
}

const prompts = Array.isArray(inputs.prompts) ? inputs.prompts.map(p => String(p ?? "").trim()).filter(Boolean).join("\n") : "";
if (prompts) return span.setAttribute(OpenTelemetryConstants.GEN_AI_SYSTEM_INSTRUCTIONS_KEY, prompts);
if (prompts) return span.setAttribute(OpenTelemetryConstants.GEN_AI_SYSTEM_INSTRUCTIONS_KEY, truncateValue(prompts));

const messages = Array.isArray(inputs.messages) ? inputs.messages : [];
const systemText = messages
.filter((m: Record<string, unknown>) => m.lc_type === "system")
.map((m: Record<string, unknown>) => String((m.lc_kwargs as Record<string, unknown> | undefined)?.content ?? "").trim())
.filter(Boolean)
.join("\n");
if (systemText) span.setAttribute(OpenTelemetryConstants.GEN_AI_SYSTEM_INSTRUCTIONS_KEY, systemText);
if (systemText) span.setAttribute(OpenTelemetryConstants.GEN_AI_SYSTEM_INSTRUCTIONS_KEY, truncateValue(systemText));
}

// Tokens (input and output)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@
import { context, trace, Span, SpanKind, SpanStatusCode, Tracer } from "@opentelemetry/api";
import { BaseTracer, Run } from "@langchain/core/tracers/base";
import { isTracingSuppressed } from "@opentelemetry/core";
import { logger, OpenTelemetryConstants } from "@microsoft/agents-a365-observability";
import { logger, OpenTelemetryConstants, defaultObservabilityConfigurationProvider } from "@microsoft/agents-a365-observability";
import * as Utils from "./Utils";

type RunWithSpan = { run: Run; span: Span; startTime: number; lastAccessTime: number };

export class LangChainTracer extends BaseTracer {
private static readonly MAX_RUNS = 10_000;
private tracer: Tracer;
private runs: Record<string, RunWithSpan> = {};
private parentByRunId: Record<string, string | undefined> = {};
private runs = new Map<string, RunWithSpan>();
private parentByRunId = new Map<string, string | undefined>();


constructor(tracer: Tracer) {
Expand All @@ -27,7 +28,7 @@ export class LangChainTracer extends BaseTracer {
}

async onRunCreate(run: Run) {
this.parentByRunId[run.id] = run.parent_run_id;
this.parentByRunId.set(run.id, run.parent_run_id);
if (super.onRunCreate) await super.onRunCreate(run);
this.startTracing(run);
}
Comment thread
fpfp100 marked this conversation as resolved.
Expand Down Expand Up @@ -59,14 +60,19 @@ export class LangChainTracer extends BaseTracer {
spanName = `${operation} ${Utils.getModel(run) || run.name}`.trim();
}

if (this.runs.size >= LangChainTracer.MAX_RUNS) {
logger.warn(`[LangChainTracer] Max runs (${LangChainTracer.MAX_RUNS}) reached, skipping span`);
return;
}
Comment thread
fpfp100 marked this conversation as resolved.

const startTime = run.start_time ?? Date.now();
const span = this.tracer.startSpan(spanName, {
kind: SpanKind.INTERNAL,
startTime,
attributes: { [OpenTelemetryConstants.GEN_AI_SYSTEM_KEY]: "langchain" },
}, activeContext);

this.runs[run.id] = { run, span, startTime, lastAccessTime: startTime };
this.runs.set(run.id, { run, span, startTime, lastAccessTime: startTime });
}

protected async _endTrace(run: Run) {
Expand All @@ -77,11 +83,13 @@ export class LangChainTracer extends BaseTracer {
const operation = Utils.getOperationType(run);
Comment thread
fpfp100 marked this conversation as resolved.
if (run.tags?.includes("langsmith:hidden") || run.name?.startsWith("Branch") || operation === "unknown") {
logger.info(`Skipping internal run: ${run.name} (parent: ${run.parent_run_id})`);
this.parentByRunId.delete(run.id);
return;
}

const entry = this.runs[run.id];
const entry = this.runs.get(run.id);
if (!entry) {
this.parentByRunId.delete(run.id);
return;
}

Expand All @@ -91,7 +99,8 @@ export class LangChainTracer extends BaseTracer {

if (run.error) {
span.setStatus({ code: SpanStatusCode.ERROR });
span.setAttribute(OpenTelemetryConstants.ERROR_MESSAGE_KEY, String(run.error));
const errorMsg = String(run.error);
span.setAttribute(OpenTelemetryConstants.ERROR_MESSAGE_KEY, errorMsg.length > 8192 ? errorMsg.substring(0, 8178) + '...[truncated]' : errorMsg);
Comment thread
fpfp100 marked this conversation as resolved.
Outdated

Comment thread
fpfp100 marked this conversation as resolved.
} else {
span.setStatus({ code: SpanStatusCode.OK });
Expand All @@ -100,22 +109,27 @@ export class LangChainTracer extends BaseTracer {
// Set all attributes
Utils.setOperationTypeAttribute(operation, span);
Utils.setAgentAttributes(run, span);
Utils.setToolAttributes(run, span);
Utils.setInputMessagesAttribute(run, span);
Utils.setOutputMessagesAttribute(run, span);
Utils.setSystemInstructionsAttribute(run, span);
Utils.setModelAttribute(run, span);
Utils.setProviderNameAttribute(run, span);
Utils.setSessionIdAttribute(run, span);
Utils.setTokenAttributes(run, span);

// Content attributes gated by content recording setting
const contentRecording = defaultObservabilityConfigurationProvider.getConfiguration().isContentRecordingEnabled;
if (contentRecording) {
Utils.setToolAttributes(run, span);
Utils.setInputMessagesAttribute(run, span);
Utils.setOutputMessagesAttribute(run, span);
Utils.setSystemInstructionsAttribute(run, span);
}
Comment thread
fpfp100 marked this conversation as resolved.
Comment thread
fpfp100 marked this conversation as resolved.

} catch (error) {
logger.error(`[LangChainTracer] Error setting span attributes for run ${run.name}: ${error instanceof Error ? error.message : String(error)}`);
span.setStatus({ code: SpanStatusCode.ERROR });
} finally {
span.end(run.end_time ?? undefined);
delete this.runs[run.id];
delete this.parentByRunId[run.id];
this.runs.delete(run.id);
this.parentByRunId.delete(run.id);
await super._endTrace(run);
}
}
Expand All @@ -124,9 +138,9 @@ export class LangChainTracer extends BaseTracer {
let pid = run.parent_run_id;

while (pid) {
const entry = this.runs[pid];
const entry = this.runs.get(pid);
if (entry) return entry.span.spanContext();
pid = this.parentByRunId[pid];
pid = this.parentByRunId.get(pid);
}
return undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@
"@microsoft/agents-a365-runtime": "workspace:*",
"@openai/agents": "catalog:",
"@opentelemetry/api": "catalog:",
"@opentelemetry/instrumentation": "catalog:",
"hono": "catalog:"
"@opentelemetry/instrumentation": "catalog:"
},
"devDependencies": {
"@eslint/js": "catalog:",
Expand All @@ -66,7 +65,13 @@
"typescript-eslint": "catalog:"
},
"peerDependencies": {
"@openai/agents": "catalog:"
"@openai/agents": "catalog:",
"hono": "catalog:"
},
"peerDependenciesMeta": {
"hono": {
"optional": true
}
},
"engines": {
"node": ">=18.0.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { context, trace as OtelTrace, Span as OtelSpan, Tracer as OtelTracer } from '@opentelemetry/api';
import { OpenTelemetryConstants, InferenceOperationType } from '@microsoft/agents-a365-observability';
import { OpenTelemetryConstants, InferenceOperationType, defaultObservabilityConfigurationProvider, logger } from '@microsoft/agents-a365-observability';
import * as Constants from './Constants';
import * as Utils from './Utils';
import {
Expand All @@ -29,6 +29,7 @@ type ContextToken = unknown;
*/
export class OpenAIAgentsTraceProcessor implements TracingProcessor {
private static readonly MAX_HANDOFFS_IN_FLIGHT = 1000;
private static readonly MAX_SPANS_IN_FLIGHT = 10_000;

private readonly tracer: OtelTracer;
private readonly suppressInvokeAgentInput: boolean;
Expand All @@ -53,6 +54,19 @@ export class OpenAIAgentsTraceProcessor implements TracingProcessor {
this.suppressInvokeAgentInput = options?.suppressInvokeAgentInput ?? false;
}

private static readonly CONTENT_KEYS = new Set([
OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY,
OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY,
OpenTelemetryConstants.GEN_AI_EVENT_CONTENT,
OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY,
Constants.GEN_AI_REQUEST_CONTENT_KEY,
Constants.GEN_AI_RESPONSE_CONTENT_KEY,
]);

private static isContentKey(key: string): boolean {
return OpenAIAgentsTraceProcessor.CONTENT_KEYS.has(key);
}

private getNewKey(spanType: string, key: string): string | null {
return this.keyMappings.get(`${spanType}${key}`) ?? null;
}
Expand Down Expand Up @@ -96,6 +110,11 @@ export class OpenAIAgentsTraceProcessor implements TracingProcessor {
return;
}

if (this.otelSpans.size >= OpenAIAgentsTraceProcessor.MAX_SPANS_IN_FLIGHT) {
logger.warn(`[OpenAIAgentsTraceProcessor] Max spans in flight (${OpenAIAgentsTraceProcessor.MAX_SPANS_IN_FLIGHT}) reached, skipping span`);
return;
}

const startTime = new Date(startedAt).getTime();

// Find parent span
Expand Down Expand Up @@ -219,6 +238,7 @@ export class OpenAIAgentsTraceProcessor implements TracingProcessor {
* Process response span data
*/
private processResponseSpanData(otelSpan: OtelSpan, data: SpanData): void {
const contentRecording = defaultObservabilityConfigurationProvider.getConfiguration().isContentRecordingEnabled;
const responseData = data as Record<string, unknown>;
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
// Handle both formats: _response/_input (actual format) and response/input (legacy format)
const responseObj = responseData._response || responseData.response;
Expand All @@ -227,7 +247,7 @@ export class OpenAIAgentsTraceProcessor implements TracingProcessor {
const resp = responseObj as Record<string, unknown>;

// Store the output field for GEN_AI_RESPONSE_CONTENT_KEY
if (resp.output) {
if (resp.output && contentRecording) {
if (typeof resp.output === 'string') {
otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, resp.output);
} else {
Comment thread
fpfp100 marked this conversation as resolved.
Expand All @@ -251,7 +271,7 @@ export class OpenAIAgentsTraceProcessor implements TracingProcessor {
otelSpan.updateName(`${InferenceOperationType.CHAT} ${modelName}`);
}

if (inputObj && !this.suppressInvokeAgentInput) {
if (inputObj && !this.suppressInvokeAgentInput && contentRecording) {
if (typeof inputObj === 'string') {
try {
const parsed = JSON.parse(inputObj as string);
Expand Down Expand Up @@ -306,14 +326,18 @@ export class OpenAIAgentsTraceProcessor implements TracingProcessor {
* Process generation span data
*/
private processGenerationSpanData(otelSpan: OtelSpan, data: SpanData, traceId: string): void {
const contentRecording = defaultObservabilityConfigurationProvider.getConfiguration().isContentRecordingEnabled;
const attrs = Utils.getAttributesFromGenerationSpanData(data);
Object.entries(attrs).forEach(([key, value]) => {
const shouldExcludeKey = key === OpenTelemetryConstants.GEN_AI_EXECUTION_TYPE_KEY
|| key === Constants.GEN_AI_EXECUTION_PAYLOAD_KEY;
if (value !== null && value !== undefined && !shouldExcludeKey) {
const newKey = this.getNewKey(data.type, key);
if (newKey !== OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY || !this.suppressInvokeAgentInput) {
otelSpan.setAttribute(newKey || key, value as string | number | boolean);
const resolvedKey = newKey || key;
if (resolvedKey !== OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY || !this.suppressInvokeAgentInput) {
if (!OpenAIAgentsTraceProcessor.isContentKey(resolvedKey) || contentRecording) {
otelSpan.setAttribute(resolvedKey, value as string | number | boolean);
}
}
}
});
Expand All @@ -332,12 +356,16 @@ export class OpenAIAgentsTraceProcessor implements TracingProcessor {
* Process function/tool span data
*/
private processFunctionSpanData(otelSpan: OtelSpan, data: SpanData, traceId: string): void {
const contentRecording = defaultObservabilityConfigurationProvider.getConfiguration().isContentRecordingEnabled;
const functionData = data as Record<string, unknown>;
const attrs = Utils.getAttributesFromFunctionSpanData(data);
Object.entries(attrs).forEach(([key, value]) => {
if (value !== null && value !== undefined && key !== OpenTelemetryConstants.GEN_AI_EXECUTION_TYPE_KEY) {
const newKey = this.getNewKey(data.type, key);
otelSpan.setAttribute(newKey || key, value as string | number | boolean);
const resolvedKey = newKey || key;
if (!OpenAIAgentsTraceProcessor.isContentKey(resolvedKey) || contentRecording) {
otelSpan.setAttribute(resolvedKey, value as string | number | boolean);
Comment thread
fpfp100 marked this conversation as resolved.
}
}
otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY, 'function');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,18 @@ import { Span as AgentsSpan, SpanData } from '@openai/agents-core/dist/tracing/s
* @param obj - The object to stringify
* @returns JSON string representation or string conversion if JSON.stringify fails
*/
const MAX_ATTRIBUTE_LENGTH = 8_192;

function truncateValue(value: string): string {
if (value.length > MAX_ATTRIBUTE_LENGTH) {
return value.substring(0, MAX_ATTRIBUTE_LENGTH) + '...[truncated]';
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
}
return value;
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
}

export function safeJsonDumps(obj: unknown): string {
try {
return JSON.stringify(obj);
return truncateValue(JSON.stringify(obj));
} catch {
return String(obj);
}
Comment thread
fpfp100 marked this conversation as resolved.
Comment thread
fpfp100 marked this conversation as resolved.
Outdated
Comment thread
fpfp100 marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export class AgenticTokenCache {
private readonly _map = new Map<string, CacheEntry>();
private readonly _defaultRefreshSkewMs = 60_000;
private readonly _defaultMaxTokenAgeMs = 3_600_000;
private readonly _maxCacheSize = 10_000;
private readonly _maxExpSeconds = 86_400; // 24 hours
private readonly _keyLocks = new Map<string, Promise<unknown>>();
private readonly _configProvider: IConfigurationProvider<ObservabilityConfiguration>;

Expand Down Expand Up @@ -85,6 +87,12 @@ export class AgenticTokenCache {
return;
}
entry = { scopes: effectiveScopes };
if (this._map.size >= this._maxCacheSize) {
const oldest = this._map.keys().next().value;
if (oldest !== undefined) {
this._map.delete(oldest);
}
}
this._map.set(key, entry);
}
if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) {
Expand Down Expand Up @@ -157,7 +165,9 @@ export class AgenticTokenCache {
const payloadSegment = parts[1];
const padded = payloadSegment + '='.repeat((4 - (payloadSegment.length % 4)) % 4);
const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as { exp?: unknown };
return typeof json.exp === 'number' ? json.exp : undefined;
if (typeof json.exp !== 'number') return undefined;
const maxExp = Math.floor(Date.now() / 1000) + this._maxExpSeconds;
return Math.min(json.exp, maxExp);
} catch {
return undefined;
}
Expand Down
Loading