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

Filter by extension

Filter by extension

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

### Added

- **Span links support** — All scope classes (`InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope`, `OutputScope`) now support span links via `SpanDetails.spanLinks` (passed through the existing `spanDetails?` argument) to establish causal relationships to other spans (e.g. linking a batch operation to individual trigger spans).
- **`BaggageBuilder.invokeAgentServer(address, port?)`** — Fluent setter for server address and port baggage values. Port is only recorded when different from 443 (default HTTPS). Clears stale port entries when port is omitted or 443.
- **`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.
- **`truncateValue`** / **`MAX_ATTRIBUTE_LENGTH`** — Exported utilities for attribute value truncation (8192 char limit).
Expand Down
8 changes: 7 additions & 1 deletion packages/agents-a365-observability/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,18 @@ builder.start();

#### OpenTelemetryScope (Base Class) ([OpenTelemetryScope.ts](../src/tracing/scopes/OpenTelemetryScope.ts))

Base class for all tracing scopes, implementing `Disposable`:
Base class for all tracing scopes, implementing `Disposable`.

All scope constructors accept an optional `spanDetails?: SpanDetails` parameter, where span links can be provided via `spanDetails.spanLinks` to establish causal relationships to other spans (e.g. linking a batch operation to individual trigger spans):

```typescript
abstract class OpenTelemetryScope implements Disposable {
// Make span active for async callback
withActiveSpanAsync<T>(callback: () => Promise<T>): Promise<T>;

// Get span context for parent-child linking
getSpanContext(): SpanContext;

// Record an error
recordError(error: Error): void;

Expand Down Expand Up @@ -253,6 +258,7 @@ const scope2 = BaggageBuilder.setRequestContext(
| `operationSource(value)` | `service.name` |
| `channelName(value)` | `gen_ai.execution.source.name` |
| `channelLink(value)` | `gen_ai.execution.source.description` |
| `invokeAgentServer(address, port?)` | `server.address` / `server.port` |

## Data Interfaces

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { SpanKind, TimeInput } from '@opentelemetry/api';
import type { SpanKind, TimeInput, Link } from '@opentelemetry/api';
import type { ParentContext } from './context/trace-context-propagation';

/**
Expand Down Expand Up @@ -298,5 +298,8 @@ export interface SpanDetails {

/** Optional span kind override. */
spanKind?: SpanKind;

/** Optional span links to associate with this span. */
spanLinks?: Link[];
}

Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,22 @@ export class BaggageBuilder {
return this;
}

/**
* Sets the invoke agent server address and port baggage values.
* @param address The server address (hostname) of the target agent service.
* @param port Optional server port. Only recorded when different from 443.
* @returns The current builder instance for method chaining.
*/
invokeAgentServer(address: string | null | undefined, port?: number): BaggageBuilder {
this.set(OpenTelemetryConstants.SERVER_ADDRESS_KEY, address);
if (port !== undefined && port !== 443) {
this.set(OpenTelemetryConstants.SERVER_PORT_KEY, port.toString());
} else {
this.pairs.delete(OpenTelemetryConstants.SERVER_PORT_KEY);
}
return this;
}

/**
* Set multiple baggage pairs from a dictionary or iterable.
* @param pairs Dictionary or iterable of key-value pairs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class ExecuteToolScope extends OpenTelemetryScope {
* @param details The tool call details (name, type, args, call id, etc.).
* @param agentDetails The agent executing the tool. Tenant ID is derived from `agentDetails.tenantId`.
* @param userDetails Optional human caller identity.
* @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanKind).
* @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanLinks). Any provided spanKind is ignored; ExecuteToolScope always uses SpanKind.INTERNAL.
* @returns A new ExecuteToolScope instance.
*/
public static start(
Expand All @@ -48,15 +48,15 @@ export class ExecuteToolScope extends OpenTelemetryScope {
throw new Error('ExecuteToolScope: tenantId is required on agentDetails');
}

// spanKind for ExecuteToolScope is always INTERNAL
const resolvedSpanDetails: SpanDetails = { ...spanDetails, spanKind: SpanKind.INTERNAL };

super(
spanDetails?.spanKind ?? SpanKind.INTERNAL,
OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME,
`${OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME} ${details.toolName}`,
agentDetails,
spanDetails?.parentContext,
spanDetails?.startTime,
spanDetails?.endTime,
userDetails
resolvedSpanDetails,
userDetails,
);

// Destructure the details object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class InferenceScope extends OpenTelemetryScope {
* @param details The inference call details (model, provider, tokens, etc.).
* @param agentDetails The agent performing the inference. Tenant ID is derived from `agentDetails.tenantId`.
* @param userDetails Optional human caller identity.
* @param spanDetails Optional span configuration (parentContext, startTime, endTime). Note: `spanKind` is ignored; InferenceScope always uses `SpanKind.CLIENT`.
* @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanLinks). Note: `spanKind` is ignored; InferenceScope always uses `SpanKind.CLIENT`.
* @returns A new InferenceScope instance
*/
public static start(
Expand All @@ -48,15 +48,15 @@ export class InferenceScope extends OpenTelemetryScope {
throw new Error('InferenceScope: tenantId is required on agentDetails');
}

// spanKind for InferenceScope is always CLIENT
const resolvedSpanDetails: SpanDetails = { ...spanDetails, spanKind: SpanKind.CLIENT };

super(
SpanKind.CLIENT,
details.operationName.toString(),
`${details.operationName} ${details.model}`,
agentDetails,
spanDetails?.parentContext,
spanDetails?.startTime,
spanDetails?.endTime,
userDetails
resolvedSpanDetails,
userDetails,
);

// Set core inference information
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class InvokeAgentScope extends OpenTelemetryScope {
* - Human caller only: `{ userDetails: { userId, userName, ... } }`
* - Agent caller only: `{ callerAgentDetails: { agentId, agentName, ... } }`
* - Both (A2A with human in chain): `{ userDetails: { ... }, callerAgentDetails: { ... } }`
* @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanKind).
* @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanKind, spanLinks).
* @returns A new InvokeAgentScope instance.
*/
public static start(
Expand All @@ -51,17 +51,16 @@ export class InvokeAgentScope extends OpenTelemetryScope {
throw new Error('InvokeAgentScope: tenantId is required on agentDetails');
}

const resolvedSpanDetails: SpanDetails = { ...spanDetails, spanKind: spanDetails?.spanKind ?? SpanKind.CLIENT };

super(
spanDetails?.spanKind ?? SpanKind.CLIENT,
OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME,
agentDetails.agentName
? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${agentDetails.agentName}`
: OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME,
agentDetails,
spanDetails?.parentContext,
spanDetails?.startTime,
spanDetails?.endTime,
callerDetails?.userDetails
resolvedSpanDetails,
callerDetails?.userDetails,
);

// Set provider name from agent details
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

import { trace, SpanKind, Span, SpanStatusCode, context, AttributeValue, SpanContext, TimeInput } from '@opentelemetry/api';
import { OpenTelemetryConstants } from '../constants';
import { AgentDetails, UserDetails } from '../contracts';
import { AgentDetails, UserDetails, SpanDetails } from '../contracts';
import { createContextWithParentSpanRef } from '../context/parent-span-context';
import { ParentContext, isParentSpanRef } from '../context/trace-context-propagation';
import { isParentSpanRef } from '../context/trace-context-propagation';
import logger from '../../utils/logging';

/**
Expand All @@ -24,30 +24,27 @@ export abstract class OpenTelemetryScope implements Disposable {

/**
* Initializes a new instance of the OpenTelemetryScope class
* @param kind The kind of span (CLIENT, SERVER, INTERNAL, etc.)
* @param operationName The name of the operation being traced
* @param spanName The name of the span for display purposes
* @param agentDetails Optional agent details. Tenant ID is read from `agentDetails.tenantId`.
* @param parentContext Optional parent context for cross-async-boundary tracing.
* Accepts a {@link ParentSpanRef} (manual traceId/spanId) or an OTel {@link Context}
* (e.g. from {@link extractContextFromHeaders} for W3C header propagation).
* @param startTime Optional explicit start time (ms epoch, Date, or HrTime). When provided the span
* records this timestamp instead of "now", which is useful when recording an operation after it
* has already completed (e.g. a tool call whose start time was captured earlier).
* @param endTime Optional explicit end time (ms epoch, Date, or HrTime). When provided the span will
* use this timestamp when {@link dispose} is called instead of the current wall-clock time.
* @param spanDetails Optional span configuration including parent context, start/end times,
* span kind, and span links. Subclasses may override `spanDetails.spanKind` before
* calling this constructor; defaults to `SpanKind.CLIENT`.
* @param userDetails Optional human caller identity details (id, upn, name, client ip).
*/
protected constructor(
kind: SpanKind,
operationName: string,
spanName: string,
agentDetails?: AgentDetails,
parentContext?: ParentContext,
startTime?: TimeInput,
endTime?: TimeInput,
userDetails?: UserDetails
spanDetails?: SpanDetails,
userDetails?: UserDetails,
) {
const parentContext = spanDetails?.parentContext;
const startTime = spanDetails?.startTime;
const endTime = spanDetails?.endTime;
const spanLinks = spanDetails?.spanLinks;
const kind = spanDetails?.spanKind ?? SpanKind.CLIENT;

// Determine the context to use for span creation
let currentContext = context.active();
if (parentContext) {
Expand All @@ -67,6 +64,7 @@ export abstract class OpenTelemetryScope implements Disposable {
this.span = OpenTelemetryScope.tracer.startSpan(spanName, {
kind,
startTime,
links: spanLinks,
attributes: {
[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: operationName,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class OutputScope extends OpenTelemetryScope {
* @param response The response containing initial output messages.
* @param agentDetails The agent producing the output. Tenant ID is derived from `agentDetails.tenantId`.
* @param userDetails Optional human caller identity details.
* @param spanDetails Optional span configuration (parentContext, startTime, endTime).
* @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanLinks).
* @returns A new OutputScope instance.
*/
public static start(
Expand All @@ -45,17 +45,17 @@ export class OutputScope extends OpenTelemetryScope {
throw new Error('OutputScope: tenantId is required on agentDetails');
}

// spanKind for OutputScope is always CLIENT
const resolvedSpanDetails: SpanDetails = { ...spanDetails, spanKind: SpanKind.CLIENT };

super(
SpanKind.CLIENT,
OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME,
agentDetails.agentName
? `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentName}`
: `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentId}`,
agentDetails,
spanDetails?.parentContext,
spanDetails?.startTime,
spanDetails?.endTime,
userDetails
resolvedSpanDetails,
userDetails,
);

// Initialize accumulated messages list from the response
Expand Down
32 changes: 32 additions & 0 deletions tests/observability/core/BaggageBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,38 @@ describe('BaggageBuilder', () => {
});
});

describe('invokeAgentServer', () => {
it.each([
['api.example.com', 8080, 'api.example.com', '8080'],
['api.example.com', 443, 'api.example.com', undefined],
['api.example.com', undefined, 'api.example.com', undefined],
] as const)('address=%s port=%s should set address=%s portBaggage=%s', (address, port, expectedAddress, expectedPort) => {
const builder = new BaggageBuilder();
builder.invokeAgentServer(address, port as number | undefined);
const scope = builder.build();
const bag = propagation.getBaggage((scope as any).contextWithBaggage);
expect(bag?.getEntry(OpenTelemetryConstants.SERVER_ADDRESS_KEY)?.value).toBe(expectedAddress);
expect(bag?.getEntry(OpenTelemetryConstants.SERVER_PORT_KEY)?.value).toBe(expectedPort);
});

it('should clear previously set non-443 port when port is 443', () => {
const builder = new BaggageBuilder();
// First set a non-443 port
builder.invokeAgentServer('api.example.com', 8080);
// Then call again with port 443, which should clear the port baggage
builder.invokeAgentServer('api.example.com', 443);
const scope = builder.build();
const bag = propagation.getBaggage((scope as any).contextWithBaggage);
expect(bag?.getEntry(OpenTelemetryConstants.SERVER_ADDRESS_KEY)?.value).toBe('api.example.com');
expect(bag?.getEntry(OpenTelemetryConstants.SERVER_PORT_KEY)).toBeUndefined();
});

it('should return self for method chaining', () => {
const builder = new BaggageBuilder();
expect(builder.invokeAgentServer('api.example.com', 8080)).toBe(builder);
});
});

describe('setRequestContext static method', () => {
it('should create scope with common fields', () => {
const scope = BaggageBuilder.setRequestContext(
Expand Down
2 changes: 1 addition & 1 deletion tests/observability/core/scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,7 @@ describe('Scopes', () => {

it.each([
['INTERNAL (default)', undefined, SpanKind.INTERNAL],
['CLIENT', SpanKind.CLIENT, SpanKind.CLIENT],
['INTERNAL (override ignored)', SpanKind.CLIENT, SpanKind.INTERNAL],
])('ExecuteToolScope spanKind: %s', (_label, input, expected) => {
const scope = ExecuteToolScope.start(
testRequest, { toolName: 'my-tool' }, testAgentDetails,
Expand Down
Loading
Loading