diff --git a/CHANGELOG.md b/CHANGELOG.md index 529fd08b..56351c03 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 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). diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 76bb81b6..19663470 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 `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(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..e58b335e 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,8 @@ export interface SpanDetails { /** Optional span kind override. */ spanKind?: SpanKind; + + /** Optional span links to associate with this span. */ + 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..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). + * @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( @@ -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 diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts index 0479fab8..0f663bbf 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( @@ -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 diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts index 1e913f51..c1db9ac8 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( @@ -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 diff --git a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts index 070bc307..82c765a3 100644 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts @@ -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'; /** @@ -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) { @@ -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, }, diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts index 34107f43..c60aaf7a 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( @@ -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 diff --git a/tests/observability/core/BaggageBuilder.test.ts b/tests/observability/core/BaggageBuilder.test.ts index 1bdd751d..d156550d 100644 --- a/tests/observability/core/BaggageBuilder.test.ts +++ b/tests/observability/core/BaggageBuilder.test.ts @@ -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( 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, 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'); + }); +});