From 464c79e3fbdef1f54e356001ef75647c513cc3e5 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Wed, 25 Mar 2026 10:42:30 -0700 Subject: [PATCH 1/7] Add span links support and BaggageBuilder.invokeAgentServer method (#224) Add optional `spanLinks` parameter to all scope classes (OpenTelemetryScope, InvokeAgentScope, InferenceScope, ExecuteToolScope, OutputScope) to support OTel span link propagation. Add `invokeAgentServer()` to BaggageBuilder for setting server address/port baggage with proper cleanup of stale port entries. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 + .../agents-a365-observability/docs/design.md | 8 +- .../src/tracing/contracts.ts | 7 +- .../src/tracing/middleware/BaggageBuilder.ts | 16 ++ .../src/tracing/scopes/ExecuteToolScope.ts | 5 +- .../src/tracing/scopes/InferenceScope.ts | 5 +- .../src/tracing/scopes/InvokeAgentScope.ts | 5 +- .../src/tracing/scopes/OpenTelemetryScope.ts | 9 +- .../src/tracing/scopes/OutputScope.ts | 5 +- .../observability/core/BaggageBuilder.test.ts | 20 ++ tests/observability/core/span-links.test.ts | 180 ++++++++++++++++++ 11 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 tests/observability/core/span-links.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 529fd08b..b33f44d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 accept an optional `spanLinks?: Link[]` parameter 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). diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 76bb81b6..ffd3eaf1 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -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 `spanLinks?: Link[]` parameter 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(callback: () => Promise): Promise; + // Get span context for parent-child linking + getSpanContext(): SpanContext; + // Record an error recordError(error: Error): void; @@ -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 diff --git a/packages/agents-a365-observability/src/tracing/contracts.ts b/packages/agents-a365-observability/src/tracing/contracts.ts index 8b66dad5..4af809bc 100644 --- a/packages/agents-a365-observability/src/tracing/contracts.ts +++ b/packages/agents-a365-observability/src/tracing/contracts.ts @@ -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'; /** @@ -298,5 +298,10 @@ export interface SpanDetails { /** Optional span kind override. */ spanKind?: SpanKind; + + /** Optional span links to associate with this span. Links establish a causal relationship + * to other spans (e.g. a batch operation linking to individual trigger spans). Each link + * contains a SpanContext and optional attributes. */ + spanLinks?: Link[]; } diff --git a/packages/agents-a365-observability/src/tracing/middleware/BaggageBuilder.ts b/packages/agents-a365-observability/src/tracing/middleware/BaggageBuilder.ts index 80a401c5..12a2e745 100644 --- a/packages/agents-a365-observability/src/tracing/middleware/BaggageBuilder.ts +++ b/packages/agents-a365-observability/src/tracing/middleware/BaggageBuilder.ts @@ -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 diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts index e2337d45..f708b639 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts @@ -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, spanKind, spanLinks). * @returns A new ExecuteToolScope instance. */ public static start( @@ -56,7 +56,8 @@ export class ExecuteToolScope extends OpenTelemetryScope { spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, - userDetails + userDetails, + spanDetails?.spanLinks ); // Destructure the details object diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts index 0479fab8..51c853dc 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts @@ -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( @@ -56,7 +56,8 @@ export class InferenceScope extends OpenTelemetryScope { spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, - userDetails + userDetails, + spanDetails?.spanLinks ); // Set core inference information diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index 1e913f51..59afd488 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -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( @@ -61,7 +61,8 @@ export class InvokeAgentScope extends OpenTelemetryScope { spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, - callerDetails?.userDetails + callerDetails?.userDetails, + spanDetails?.spanLinks ); // Set provider name from agent details diff --git a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts index 070bc307..17487edf 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { trace, SpanKind, Span, SpanStatusCode, context, AttributeValue, SpanContext, TimeInput } from '@opentelemetry/api'; +import { trace, SpanKind, Span, SpanStatusCode, context, AttributeValue, SpanContext, TimeInput, Link } from '@opentelemetry/api'; import { OpenTelemetryConstants } from '../constants'; import { AgentDetails, UserDetails } from '../contracts'; import { createContextWithParentSpanRef } from '../context/parent-span-context'; @@ -37,6 +37,9 @@ export abstract class OpenTelemetryScope implements Disposable { * @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 userDetails Optional human caller identity details (id, upn, name, client ip). + * @param spanLinks Optional span links to associate with this span. Links establish a causal relationship + * to other spans (e.g. a batch operation linking to individual trigger spans). Each link contains + * a {@link SpanContext} and optional attributes. */ protected constructor( kind: SpanKind, @@ -46,7 +49,8 @@ export abstract class OpenTelemetryScope implements Disposable { parentContext?: ParentContext, startTime?: TimeInput, endTime?: TimeInput, - userDetails?: UserDetails + userDetails?: UserDetails, + spanLinks?: Link[] ) { // Determine the context to use for span creation let currentContext = context.active(); @@ -67,6 +71,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, }, diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index 34107f43..c39dc8ae 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -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( @@ -55,7 +55,8 @@ export class OutputScope extends OpenTelemetryScope { spanDetails?.parentContext, spanDetails?.startTime, spanDetails?.endTime, - userDetails + userDetails, + spanDetails?.spanLinks ); // Initialize accumulated messages list from the response diff --git a/tests/observability/core/BaggageBuilder.test.ts b/tests/observability/core/BaggageBuilder.test.ts index 1bdd751d..dd22fe2d 100644 --- a/tests/observability/core/BaggageBuilder.test.ts +++ b/tests/observability/core/BaggageBuilder.test.ts @@ -144,6 +144,26 @@ 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 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( diff --git a/tests/observability/core/span-links.test.ts b/tests/observability/core/span-links.test.ts new file mode 100644 index 00000000..fd1acbf4 --- /dev/null +++ b/tests/observability/core/span-links.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, jest } from '@jest/globals'; +import { trace, TraceFlags, Link } from '@opentelemetry/api'; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base'; + +import { + ExecuteToolScope, + InvokeAgentScope, + InferenceScope, + OutputScope, + AgentDetails, + ToolCallDetails, + InferenceDetails, + InferenceOperationType, + OutputResponse, + Request, + SpanDetails, +} from '@microsoft/agents-a365-observability'; + +// Mock console to avoid cluttering test output. +// Use beforeEach because jest config has restoreMocks: true which restores spies after each test. +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +describe('Span Links', () => { + const testAgentDetails: AgentDetails = { + agentId: 'test-agent', + agentName: 'Test Agent', + agentDescription: 'A test agent', + tenantId: 'test-tenant-456', + }; + + const testRequest: Request = { + conversationId: 'test-conv-123', + }; + + let exporter: InMemorySpanExporter; + let provider: BasicTracerProvider | undefined; + + beforeAll(() => { + exporter = new InMemorySpanExporter(); + const processor = new SimpleSpanProcessor(exporter); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const globalProvider: any = trace.getTracerProvider(); + if (globalProvider && typeof globalProvider.addSpanProcessor === 'function') { + globalProvider.addSpanProcessor(processor); + } else { + provider = new BasicTracerProvider({ + spanProcessors: [processor], + }); + trace.setGlobalTracerProvider(provider); + } + }); + + afterEach(() => { + exporter.reset(); + }); + + afterAll(async () => { + exporter.reset(); + await provider?.shutdown?.(); + }); + + /** Extract the last finished span from the in-memory exporter. */ + const getFinishedSpan = (): ReadableSpan => { + const spans = exporter.getFinishedSpans(); + expect(spans.length).toBeGreaterThanOrEqual(1); + return spans[spans.length - 1]; + }; + + const sampleLinks: Link[] = [ + { + context: { + traceId: '0aa4621e5ae09963a3de354f3d18aa65', + spanId: 'c1aaa519600b1bf0', + traceFlags: TraceFlags.SAMPLED, + }, + }, + { + context: { + traceId: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + spanId: 'aaaaaaaaaaaaaaaa', + traceFlags: TraceFlags.NONE, + }, + attributes: { 'link.reason': 'retry' }, + }, + ]; + + const spanDetailsWithLinks: SpanDetails = { spanLinks: sampleLinks }; + + it('should record span links with full context and attributes', () => { + const scope = ExecuteToolScope.start( + testRequest, + { toolName: 'my-tool' } as ToolCallDetails, + testAgentDetails, + undefined, + spanDetailsWithLinks + ); + scope.dispose(); + + const span = getFinishedSpan(); + expect(span.links).toHaveLength(2); + expect(span.links[0].context.traceId).toBe('0aa4621e5ae09963a3de354f3d18aa65'); + expect(span.links[0].context.spanId).toBe('c1aaa519600b1bf0'); + expect(span.links[0].context.traceFlags).toBe(TraceFlags.SAMPLED); + expect(span.links[1].context.traceId).toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + expect(span.links[1].context.spanId).toBe('aaaaaaaaaaaaaaaa'); + expect(span.links[1].attributes?.['link.reason']).toBe('retry'); + }); + + it('should have empty links when spanLinks is omitted', () => { + const scope = ExecuteToolScope.start( + testRequest, + { toolName: 'my-tool' } as ToolCallDetails, + testAgentDetails + ); + scope.dispose(); + + const span = getFinishedSpan(); + expect(span.links).toHaveLength(0); + }); + + it('should preserve typed link attributes', () => { + const linksWithAttrs: Link[] = [ + { + context: { + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb', + traceFlags: TraceFlags.SAMPLED, + }, + attributes: { 'link.type': 'causal', 'link.index': 0 }, + }, + ]; + + const scope = InvokeAgentScope.start( + testRequest, + {}, + { ...testAgentDetails, agentId: 'attr-agent' }, + undefined, + { spanLinks: linksWithAttrs } + ); + scope.dispose(); + + const span = getFinishedSpan(); + expect(span.links).toHaveLength(1); + expect(span.links[0].attributes?.['link.type']).toBe('causal'); + expect(span.links[0].attributes?.['link.index']).toBe(0); + }); + + it.each([ + ['InvokeAgentScope', () => InvokeAgentScope.start( + testRequest, {}, testAgentDetails, undefined, spanDetailsWithLinks)], + ['InferenceScope', () => InferenceScope.start( + testRequest, + { operationName: InferenceOperationType.CHAT, model: 'gpt-4', providerName: 'openai' } as InferenceDetails, + testAgentDetails, undefined, spanDetailsWithLinks)], + ['OutputScope', () => OutputScope.start( + testRequest, + { messages: ['hi'] } as OutputResponse, + testAgentDetails, undefined, spanDetailsWithLinks)], + ])('should forward span links on %s', (_name, createScope) => { + const scope = createScope(); + scope.dispose(); + + const span = getFinishedSpan(); + expect(span.links).toHaveLength(2); + expect(span.links[0].context.traceId).toBe('0aa4621e5ae09963a3de354f3d18aa65'); + expect(span.links[0].context.spanId).toBe('c1aaa519600b1bf0'); + expect(span.links[0].context.traceFlags).toBe(TraceFlags.SAMPLED); + expect(span.links[1].context.traceId).toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + expect(span.links[1].context.spanId).toBe('aaaaaaaaaaaaaaaa'); + expect(span.links[1].context.traceFlags).toBe(TraceFlags.NONE); + expect(span.links[1].attributes?.['link.reason']).toBe('retry'); + }); +}); From 1e81f01f6c39002d1f7cbc2fda8dcc2f4961fe6b Mon Sep 17 00:00:00 2001 From: jsl517 Date: Wed, 25 Mar 2026 11:02:23 -0700 Subject: [PATCH 2/7] Address PR review comments - Move jest.spyOn console mocks from beforeAll to beforeEach (restoreMocks compatibility) - Remove trace.disable() from afterAll to avoid resetting global provider for other test suites - Add test for clearing previously set non-443 port when invokeAgentServer is called again with port=443 Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/observability/core/BaggageBuilder.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/observability/core/BaggageBuilder.test.ts b/tests/observability/core/BaggageBuilder.test.ts index dd22fe2d..d156550d 100644 --- a/tests/observability/core/BaggageBuilder.test.ts +++ b/tests/observability/core/BaggageBuilder.test.ts @@ -158,6 +158,18 @@ describe('BaggageBuilder', () => { 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); From a77d296339e899cb31d49fd8d3a29afe3d1e61a9 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Thu, 26 Mar 2026 10:27:38 -0700 Subject: [PATCH 3/7] Refactor: encapsulate SpanDetails and UserDetails in base OpenTelemetryScope constructor Update the base OpenTelemetryScope constructor signature from 9 positional parameters to 6 by accepting SpanDetails and UserDetails as structured objects. This encapsulates span configuration in the base class so subclasses simply pass spanDetails through without destructuring. Adding new span options now only requires changes to SpanDetails and the base class. --- .../src/tracing/scopes/ExecuteToolScope.ts | 5 +--- .../src/tracing/scopes/InferenceScope.ts | 5 +--- .../src/tracing/scopes/InvokeAgentScope.ts | 5 +--- .../src/tracing/scopes/OpenTelemetryScope.ts | 29 +++++++------------ .../src/tracing/scopes/OutputScope.ts | 5 +--- 5 files changed, 15 insertions(+), 34 deletions(-) diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts index f708b639..4cfbd8ab 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts @@ -53,11 +53,8 @@ export class ExecuteToolScope extends OpenTelemetryScope { OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, `${OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME} ${details.toolName}`, agentDetails, - spanDetails?.parentContext, - spanDetails?.startTime, - spanDetails?.endTime, + spanDetails, userDetails, - spanDetails?.spanLinks ); // Destructure the details object diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts index 51c853dc..75482cfc 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts @@ -53,11 +53,8 @@ export class InferenceScope extends OpenTelemetryScope { details.operationName.toString(), `${details.operationName} ${details.model}`, agentDetails, - spanDetails?.parentContext, - spanDetails?.startTime, - spanDetails?.endTime, + spanDetails, userDetails, - spanDetails?.spanLinks ); // Set core inference information diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index 59afd488..9d688166 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -58,11 +58,8 @@ export class InvokeAgentScope extends OpenTelemetryScope { ? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${agentDetails.agentName}` : OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, agentDetails, - spanDetails?.parentContext, - spanDetails?.startTime, - spanDetails?.endTime, + spanDetails, callerDetails?.userDetails, - spanDetails?.spanLinks ); // Set provider name from agent details diff --git a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts index 17487edf..930615c6 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { trace, SpanKind, Span, SpanStatusCode, context, AttributeValue, SpanContext, TimeInput, Link } from '@opentelemetry/api'; +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'; /** @@ -28,30 +28,23 @@ export abstract class OpenTelemetryScope implements Disposable { * @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 override, and span links. * @param userDetails Optional human caller identity details (id, upn, name, client ip). - * @param spanLinks Optional span links to associate with this span. Links establish a causal relationship - * to other spans (e.g. a batch operation linking to individual trigger spans). Each link contains - * a {@link SpanContext} and optional attributes. */ protected constructor( kind: SpanKind, operationName: string, spanName: string, agentDetails?: AgentDetails, - parentContext?: ParentContext, - startTime?: TimeInput, - endTime?: TimeInput, + spanDetails?: SpanDetails, userDetails?: UserDetails, - spanLinks?: Link[] ) { + const parentContext = spanDetails?.parentContext; + const startTime = spanDetails?.startTime; + const endTime = spanDetails?.endTime; + const spanLinks = spanDetails?.spanLinks; + // Determine the context to use for span creation let currentContext = context.active(); if (parentContext) { diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index c39dc8ae..d2e5090c 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -52,11 +52,8 @@ export class OutputScope extends OpenTelemetryScope { ? `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentName}` : `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentId}`, agentDetails, - spanDetails?.parentContext, - spanDetails?.startTime, - spanDetails?.endTime, + spanDetails, userDetails, - spanDetails?.spanLinks ); // Initialize accumulated messages list from the response From 6ae6387dd3c74d105f472020a18b1cac0ec5955f Mon Sep 17 00:00:00 2001 From: jsl517 Date: Thu, 26 Mar 2026 11:52:58 -0700 Subject: [PATCH 4/7] Encapsulate spanKind in SpanDetails and fix JSDoc comments Move the `kind` parameter from a standalone constructor argument into the `SpanDetails` object in OpenTelemetryScope. Each subclass now builds a `resolvedSpanDetails` with its required span kind before calling super(). Simplify the `spanLinks` JSDoc in contracts.ts and clarify that the base class defaults spanKind to SpanKind.CLIENT. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agents-a365-observability/src/tracing/contracts.ts | 4 +--- .../src/tracing/scopes/ExecuteToolScope.ts | 6 ++++-- .../src/tracing/scopes/InferenceScope.ts | 6 ++++-- .../src/tracing/scopes/InvokeAgentScope.ts | 5 +++-- .../src/tracing/scopes/OpenTelemetryScope.ts | 6 +++--- .../src/tracing/scopes/OutputScope.ts | 6 ++++-- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/agents-a365-observability/src/tracing/contracts.ts b/packages/agents-a365-observability/src/tracing/contracts.ts index 4af809bc..e58b335e 100644 --- a/packages/agents-a365-observability/src/tracing/contracts.ts +++ b/packages/agents-a365-observability/src/tracing/contracts.ts @@ -299,9 +299,7 @@ export interface SpanDetails { /** Optional span kind override. */ spanKind?: SpanKind; - /** Optional span links to associate with this span. Links establish a causal relationship - * to other spans (e.g. a batch operation linking to individual trigger spans). Each link - * contains a SpanContext and optional attributes. */ + /** Optional span links to associate with this span. */ spanLinks?: Link[]; } diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts index 4cfbd8ab..76cbef60 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts @@ -48,12 +48,14 @@ 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, + resolvedSpanDetails, userDetails, ); diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts index 75482cfc..0f663bbf 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts @@ -48,12 +48,14 @@ 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, + resolvedSpanDetails, userDetails, ); diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index 9d688166..c1db9ac8 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts @@ -51,14 +51,15 @@ 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, + resolvedSpanDetails, callerDetails?.userDetails, ); diff --git a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts index 930615c6..82c765a3 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts @@ -24,16 +24,15 @@ 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 spanDetails Optional span configuration including parent context, start/end times, - * span kind override, and span links. + * 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, @@ -44,6 +43,7 @@ export abstract class OpenTelemetryScope implements Disposable { 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(); diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index d2e5090c..c60aaf7a 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts @@ -45,14 +45,16 @@ 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, + resolvedSpanDetails, userDetails, ); From 4311eeb796b46d5ffc929c40d7caf2085b7b95b8 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Thu, 26 Mar 2026 12:01:18 -0700 Subject: [PATCH 5/7] Fix ExecuteToolScope spanKind test to match INTERNAL-only behavior ExecuteToolScope always forces SpanKind.INTERNAL regardless of any spanKind override in SpanDetails. Update the test expectation to verify the override is correctly ignored. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/observability/core/scopes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/observability/core/scopes.test.ts b/tests/observability/core/scopes.test.ts index 156a40c3..09d7c7d4 100644 --- a/tests/observability/core/scopes.test.ts +++ b/tests/observability/core/scopes.test.ts @@ -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, From 8539582ff85eaa09e52a78689d41048080b86f63 Mon Sep 17 00:00:00 2001 From: jsl517 Date: Thu, 26 Mar 2026 12:37:59 -0700 Subject: [PATCH 6/7] comment update --- packages/agents-a365-observability/docs/design.md | 2 +- .../src/tracing/scopes/ExecuteToolScope.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index ffd3eaf1..19663470 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -104,7 +104,7 @@ builder.start(); Base class for all tracing scopes, implementing `Disposable`. -All scope constructors accept an optional `spanLinks?: Link[]` parameter to establish causal relationships to other spans (e.g. linking a batch operation to individual trigger spans): +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 { diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts index 76cbef60..032ede0c 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts @@ -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, spanLinks). + * @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( From ce37030e162818d07bbcb2aac573cd122946c74a Mon Sep 17 00:00:00 2001 From: jsl517 Date: Thu, 26 Mar 2026 12:39:04 -0700 Subject: [PATCH 7/7] update --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b33f44d2..56351c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ 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 accept an optional `spanLinks?: Link[]` parameter to establish causal relationships to other spans (e.g. linking a batch operation to individual trigger spans). +- **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.