From 184e51f5a61cb9a2bc023a2a088ca343194d7e49 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:18:00 -0700 Subject: [PATCH] Add A365 manual scopes, processors, baggage, context propagation, and migration guide --- MIGRATION_A365.md | 250 ++++ README.md | 11 +- samples/README.md | 3 +- samples/src/a365Export.ts | 6 +- samples/src/a365ManualScopes.ts | 301 ++++ src/a365/configuration/A365Configuration.ts | 55 +- src/a365/constants.ts | 114 ++ src/a365/context.ts | 137 ++ src/a365/context/tokenContext.ts | 79 ++ src/a365/contracts.ts | 339 +++++ src/a365/index.ts | 76 + src/a365/message-utils.ts | 129 ++ src/a365/middleware/BaggageBuilder.ts | 283 ++++ src/a365/middleware/index.ts | 4 + src/a365/processors/A365SpanProcessor.ts | 138 ++ .../processors/PerRequestSpanProcessor.ts | 334 +++++ src/a365/processors/index.ts | 7 + src/a365/processors/util.ts | 56 + src/a365/scopes/ExecuteToolScope.ts | 96 ++ src/a365/scopes/InferenceScope.ts | 116 ++ src/a365/scopes/InvokeAgentScope.ts | 142 ++ src/a365/scopes/OpenTelemetryScope.ts | 266 ++++ src/a365/scopes/OutputScope.ts | 111 ++ src/a365/scopes/index.ts | 8 + src/distro/distro.ts | 2 +- src/index.ts | 54 + .../unit/a365/a365Configuration.test.ts | 14 +- .../unit/a365/a365SpanProcessor.test.ts | 231 +++ .../internal/unit/a365/baggageBuilder.test.ts | 343 +++++ .../unit/a365/contextPropagation.test.ts | 576 ++++++++ test/internal/unit/a365/messageUtils.test.ts | 307 ++++ .../unit/a365/perRequestSpanProcessor.test.ts | 487 +++++++ test/internal/unit/a365/scopes.test.ts | 1251 +++++++++++++++++ 33 files changed, 6284 insertions(+), 42 deletions(-) create mode 100644 MIGRATION_A365.md create mode 100644 samples/src/a365ManualScopes.ts create mode 100644 src/a365/constants.ts create mode 100644 src/a365/context.ts create mode 100644 src/a365/context/tokenContext.ts create mode 100644 src/a365/contracts.ts create mode 100644 src/a365/message-utils.ts create mode 100644 src/a365/middleware/BaggageBuilder.ts create mode 100644 src/a365/middleware/index.ts create mode 100644 src/a365/processors/A365SpanProcessor.ts create mode 100644 src/a365/processors/PerRequestSpanProcessor.ts create mode 100644 src/a365/processors/index.ts create mode 100644 src/a365/processors/util.ts create mode 100644 src/a365/scopes/ExecuteToolScope.ts create mode 100644 src/a365/scopes/InferenceScope.ts create mode 100644 src/a365/scopes/InvokeAgentScope.ts create mode 100644 src/a365/scopes/OpenTelemetryScope.ts create mode 100644 src/a365/scopes/OutputScope.ts create mode 100644 src/a365/scopes/index.ts create mode 100644 test/internal/unit/a365/a365SpanProcessor.test.ts create mode 100644 test/internal/unit/a365/baggageBuilder.test.ts create mode 100644 test/internal/unit/a365/contextPropagation.test.ts create mode 100644 test/internal/unit/a365/messageUtils.test.ts create mode 100644 test/internal/unit/a365/perRequestSpanProcessor.test.ts create mode 100644 test/internal/unit/a365/scopes.test.ts diff --git a/MIGRATION_A365.md b/MIGRATION_A365.md new file mode 100644 index 00000000..4fdd421c --- /dev/null +++ b/MIGRATION_A365.md @@ -0,0 +1,250 @@ +# Migrating from `@microsoft/agents-a365-observability` to `@microsoft/opentelemetry` + +This guide covers migrating agent observability code from the standalone `@microsoft/agents-a365-observability` package (in [Agent365-nodejs](https://github.com/microsoft/Agent365-nodejs)) to the `@microsoft/opentelemetry` distribution. + +## Quick Start + +### Before (Agent365-nodejs) + +```typescript +import { Builder } from "@microsoft/agents-a365-observability"; + +const manager = new Builder({ + tokenResolver: async (agentId, tenantId) => getToken(agentId, tenantId), + clusterCategory: "prod", +}).build(); +``` + +### After (@microsoft/opentelemetry) + +```typescript +import { useMicrosoftOpenTelemetry } from "@microsoft/opentelemetry"; + +useMicrosoftOpenTelemetry({ + a365: { + enabled: true, + tokenResolver: async (agentId, tenantId) => getToken(agentId, tenantId), + clusterCategory: "prod", + }, +}); +``` + +## Package Changes + +| Before | After | +|---|---| +| `npm install @microsoft/agents-a365-observability` | `npm install @microsoft/opentelemetry` | +| `import { ... } from "@microsoft/agents-a365-observability"` | `import { ... } from "@microsoft/opentelemetry"` | + +## Import Mapping + +All public APIs are re-exported from the root `@microsoft/opentelemetry` package: + +| `@microsoft/agents-a365-observability` | `@microsoft/opentelemetry` | +|---|---| +| `OpenTelemetryConstants` | `OpenTelemetryConstants` | +| `OpenTelemetryScope` | `OpenTelemetryScope` | +| `InvokeAgentScope` | `InvokeAgentScope` | +| `ExecuteToolScope` | `ExecuteToolScope` | +| `InferenceScope` | `InferenceScope` | +| `OutputScope` | `OutputScope` | +| `BaggageBuilder` | `BaggageBuilder` | +| `BaggageScope` | `BaggageScope` | +| `runWithExportToken` | `runWithExportToken` | +| `updateExportToken` | `updateExportToken` | +| `getExportToken` | `getExportToken` | +| `runWithParentSpanRef` | `runWithParentSpanRef` | +| `createContextWithParentSpanRef` | `createContextWithParentSpanRef` | +| `injectContextToHeaders` | `injectContextToHeaders` | +| `extractContextFromHeaders` | `extractContextFromHeaders` | +| `runWithExtractedTraceContext` | `runWithExtractedTraceContext` | +| `MessageRole` | `MessageRole` | +| `FinishReason` | `FinishReason` | +| `InferenceOperationType` | `InferenceOperationType` | + +### Types + +| `@microsoft/agents-a365-observability` | `@microsoft/opentelemetry` | +|---|---| +| `Request` | `A365Request` (renamed to avoid collision with global `Request`) | +| `SpanDetails` | `A365SpanDetails` (renamed for clarity) | +| `AgentDetails` | `AgentDetails` | +| `UserDetails` | `UserDetails` | +| `CallerDetails` | `CallerDetails` | +| `Channel` | `Channel` | +| `ServiceEndpoint` | `ServiceEndpoint` | +| `InvokeAgentScopeDetails` | `InvokeAgentScopeDetails` | +| `ToolCallDetails` | `ToolCallDetails` | +| `InferenceDetails` | `InferenceDetails` | +| `InferenceResponse` | `InferenceResponse` | +| `OutputResponse` | `OutputResponse` | +| `ParentSpanRef` | `ParentSpanRef` | +| `ParentContext` | `ParentContext` | +| `ChatMessage` | `ChatMessage` | +| `HeadersCarrier` | `HeadersCarrier` | + +### Processor Classes + +| `@microsoft/agents-a365-observability` | `@microsoft/opentelemetry` | Notes | +|---|---|---| +| `SpanProcessor` (from `processors/`) | `A365SpanProcessor` | Renamed to avoid collision with OTel `SpanProcessor` | +| `PerRequestSpanProcessor` | `PerRequestSpanProcessor` | Same name | + +## Initialization + +### Before: `ObservabilityBuilder` + +The Agent365-nodejs package used `ObservabilityBuilder` / `ObservabilityManager`: + +```typescript +import { Builder } from "@microsoft/agents-a365-observability"; + +const manager = new Builder({ + tokenResolver: async (agentId, tenantId) => getToken(agentId, tenantId), + clusterCategory: "prod", + perRequestExport: true, +}).build(); +``` + +### After: `useMicrosoftOpenTelemetry` + +The new package uses a unified initialization call: + +```typescript +import { useMicrosoftOpenTelemetry } from "@microsoft/opentelemetry"; + +useMicrosoftOpenTelemetry({ + a365: { + enabled: true, + tokenResolver: async (agentId, tenantId) => getToken(agentId, tenantId), + clusterCategory: "prod", + perRequestExport: true, + }, + // Optional: also send to Azure Monitor + azureMonitor: { + azureMonitorExporterOptions: { + connectionString: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING, + }, + }, +}); +``` + +## Environment Variables + +Environment variable names are **unchanged** from Agent365-nodejs: + +| Environment Variable | Description | +|---|---| +| `ENABLE_A365_OBSERVABILITY_EXPORTER` | Enable/disable A365 exporter (`true`, `1`, `yes`, `on`) | +| `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` | Enable/disable per-request export mode | +| `A365_OBSERVABILITY_SCOPES_OVERRIDE` | Space-separated list of OAuth scopes | +| `A365_OBSERVABILITY_DOMAIN_OVERRIDE` | Override service domain | +| `CLUSTER_CATEGORY` | Cluster category (`prod`, `dev`, `test`, etc.) | +| `A365_OBSERVABILITY_LOG_LEVEL` | SDK log level (`none`, `info`, `warn`, `error`) | +| `A365_PER_REQUEST_MAX_TRACES` | Max buffered traces (default: `1000`) | +| `A365_PER_REQUEST_MAX_SPANS_PER_TRACE` | Max spans per trace (default: `5000`) | +| `A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS` | Max concurrent exports (default: `20`) | +| `A365_PER_REQUEST_FLUSH_GRACE_MS` | Grace period after root span ends (default: `250`) | +| `A365_PER_REQUEST_MAX_TRACE_AGE_MS` | Max trace age before forced flush (default: `1800000`) | + +## Scopes + +Scope usage is identical. Just update the import path: + +### Before + +```typescript +import { InvokeAgentScope } from "@microsoft/agents-a365-observability"; + +const scope = new InvokeAgentScope({ + agent: { id: "agent-123", name: "MyAgent" }, + request: { tenantId: "tenant-456" }, + invokeAgent: { targetAgentId: "target-789" }, +}); + +scope.start(); +try { + // ... agent work +} finally { + scope.end(); +} +``` + +### After + +```typescript +import { InvokeAgentScope } from "@microsoft/opentelemetry"; + +// Same API — just a different import path +const scope = new InvokeAgentScope({ + agent: { id: "agent-123", name: "MyAgent" }, + request: { tenantId: "tenant-456" }, + invokeAgent: { targetAgentId: "target-789" }, +}); + +scope.start(); +try { + // ... agent work +} finally { + scope.end(); +} +``` + +## BaggageBuilder + +The `BaggageBuilder` fluent API is identical: + +```typescript +import { BaggageBuilder } from "@microsoft/opentelemetry"; + +const scope = new BaggageBuilder() + .tenantId("tenant-123") + .agentId("agent-456") + .sessionId("session-789") + .build(); + +scope.run(() => { + // Baggage is active in this context + // A365SpanProcessor copies baggage to span attributes automatically +}); +``` + +## Token Context + +Per-request token management is identical: + +```typescript +import { runWithExportToken, updateExportToken } from "@microsoft/opentelemetry"; + +runWithExportToken(initialToken, async () => { + // Start spans... + + // Refresh token before long-running request completes + updateExportToken(refreshedToken); + + // End root span — export uses the refreshed token +}); +``` + +## What's Not Migrated + +The following Agent365-nodejs components are **not** included in `@microsoft/opentelemetry` because they are runtime/hosting concerns rather than observability: + +| Component | Reason | +|---|---| +| `ObservabilityManager` / `ObservabilityBuilder` | Replaced by `useMicrosoftOpenTelemetry()` | +| `@microsoft/agents-a365-runtime` | Runtime configuration framework — not needed | +| `@microsoft/agents-hosting` | HTTP hosting middleware — separate concern | +| `IConfigurationProvider` | Replaced by direct options + env vars | +| `AgenticTokenCache` | Token caching is the caller's responsibility | + +## Checklist + +- [ ] Replace `@microsoft/agents-a365-observability` dependency with `@microsoft/opentelemetry` +- [ ] Update all imports to use `@microsoft/opentelemetry` +- [ ] Replace `Builder().build()` with `useMicrosoftOpenTelemetry({ a365: { ... } })` +- [ ] Rename `Request` type references to `A365Request` +- [ ] Rename `SpanDetails` type references to `A365SpanDetails` +- [ ] Rename `SpanProcessor` references to `A365SpanProcessor` +- [ ] Verify environment variables work (names are unchanged) +- [ ] Remove `@microsoft/agents-a365-runtime` dependency if no longer needed diff --git a/README.md b/README.md index 874e9a01..1a0d4a90 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,12 @@ A365 options can also be set via environment variables (highest precedence): | Environment Variable | Description | |---|---| -| `MICROSOFT_OTEL_A365_EXPORTER_ENABLED` | `"true"` / `"false"` — override `enabled` | -| `MICROSOFT_OTEL_A365_PER_REQUEST_EXPORT` | `"true"` / `"false"` — override `perRequestExport` | -| `MICROSOFT_OTEL_A365_AUTH_SCOPES` | Comma-separated list of OAuth scopes | -| `MICROSOFT_OTEL_A365_DOMAIN` | Override service domain | -| `MICROSOFT_OTEL_A365_CLUSTER_CATEGORY` | Override cluster category | +| `ENABLE_A365_OBSERVABILITY_EXPORTER` | `"true"` / `"false"` — override `enabled` | +| `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` | `"true"` / `"false"` — override `perRequestExport` | +| `A365_OBSERVABILITY_SCOPES_OVERRIDE` | Space-separated list of OAuth scopes | +| `A365_OBSERVABILITY_DOMAIN_OVERRIDE` | Override service domain | +| `CLUSTER_CATEGORY` | Override cluster category | +| `A365_OBSERVABILITY_LOG_LEVEL` | SDK log level (`none`, `info`, `warn`, `error`) | ### Example diff --git a/samples/README.md b/samples/README.md index 6cc37ce9..a8dd60c9 100644 --- a/samples/README.md +++ b/samples/README.md @@ -25,6 +25,7 @@ These sample programs show how to use the `@microsoft/opentelemetry` distributio | [langchainInstrumentation.ts][langchaininstrumentation] | Demonstrates how to enable LangChain instrumentation to trace GenAI operations. | | [openaiInstrumentation.ts][openaiinstrumentation] | Demonstrates how to enable OpenAI Agents SDK instrumentation to trace GenAI operations. | | [a365Export.ts][a365export] | Demonstrates how to enable A365 observability export alongside Azure Monitor. | +| [a365ManualScopes.ts][a365manualscopes] | Demonstrates how to use A365 manual telemetry scopes (InvokeAgent, Inference, ExecuteTool, Output) and cross-service context propagation. | ## Prerequisites @@ -76,4 +77,4 @@ APPLICATIONINSIGHTS_CONNECTION_STRING="" node dist/basic [langchaininstrumentation]: https://github.com/Azure/opentelemetry-distro-javascript/blob/main/samples/src/langchainInstrumentation.ts [openaiinstrumentation]: https://github.com/Azure/opentelemetry-distro-javascript/blob/main/samples/src/openaiInstrumentation.ts [a365export]: https://github.com/Azure/opentelemetry-distro-javascript/blob/main/samples/src/a365Export.ts - +[a365manualscopes]: https://github.com/Azure/opentelemetry-distro-javascript/blob/main/samples/src/a365ManualScopes.ts diff --git a/samples/src/a365Export.ts b/samples/src/a365Export.ts index 0d0ed7a5..78e93200 100644 --- a/samples/src/a365Export.ts +++ b/samples/src/a365Export.ts @@ -8,9 +8,9 @@ * provide a `tokenResolver` that returns a bearer token for the given agent/tenant pair. * * Configuration can also be set via environment variables (highest precedence): - * - MICROSOFT_OTEL_A365_EXPORTER_ENABLED=true - * - MICROSOFT_OTEL_A365_CLUSTER_CATEGORY=dev - * - MICROSOFT_OTEL_A365_DOMAIN=https://custom.domain.com + * - ENABLE_A365_OBSERVABILITY_EXPORTER=true + * - CLUSTER_CATEGORY=dev + * - A365_OBSERVABILITY_DOMAIN_OVERRIDE=https://custom.domain.com */ import { useMicrosoftOpenTelemetry } from "@microsoft/opentelemetry"; diff --git a/samples/src/a365ManualScopes.ts b/samples/src/a365ManualScopes.ts new file mode 100644 index 00000000..98b2b526 --- /dev/null +++ b/samples/src/a365ManualScopes.ts @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @summary Demonstrates how to use the A365 manual telemetry scopes API. + * + * This sample shows how to trace a realistic AI agent flow: + * 1. Receive a user request (InvokeAgentScope) + * 2. Call an LLM for inference (InferenceScope) + * 3. Execute a tool the LLM requested (ExecuteToolScope) + * 4. Stream the final response (OutputScope) + * 5. Propagate trace context across service boundaries + * + * All scopes create structured OpenTelemetry spans with gen-ai semantic + * conventions and A365-specific attributes. + */ + +import { + useMicrosoftOpenTelemetry, + InvokeAgentScope, + InferenceScope, + ExecuteToolScope, + OutputScope, + InferenceOperationType, + MessageRole, + injectContextToHeaders, + runWithExtractedTraceContext, +} from "@microsoft/opentelemetry"; +import type { + AgentDetails, + A365Request, + InferenceDetails, + ToolCallDetails, +} from "@microsoft/opentelemetry"; +import "dotenv/config"; + +// ──────────────────────────────────────────────────────────────────────────── +// Setup +// ──────────────────────────────────────────────────────────────────────────── + +async function myTokenResolver(agentId: string, tenantId: string): Promise { + console.log(` [auth] Resolving token for agent=${agentId}, tenant=${tenantId}`); + return process.env.A365_BEARER_TOKEN || ""; +} + +// Shared agent identity used across all scopes +const agentDetails: AgentDetails = { + agentId: "weather-agent-001", + agentName: "WeatherBot", + agentDescription: "An agent that answers weather questions", + tenantId: "contoso-tenant-id", + providerName: "contoso", + agentVersion: "1.0.0", +}; + +// ──────────────────────────────────────────────────────────────────────────── +// Simulated agent logic +// ──────────────────────────────────────────────────────────────────────────── + +/** Simulate an LLM inference call that decides to use a tool. */ +async function callLLM( + request: A365Request, + parentScope: InvokeAgentScope, +): Promise<{ toolName: string; args: Record; callId: string }> { + const details: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: "gpt-4o", + providerName: "azure-openai", + endpoint: { host: "contoso.openai.azure.com", port: 443 }, + }; + + const scope = InferenceScope.start(request, details, agentDetails); + try { + // Record what we sent to the LLM + scope.recordInputMessages([ + "You are a helpful weather assistant.", + request.content as string, + ]); + + // Simulate LLM response latency + await new Promise((r) => setTimeout(r, 50)); + + // LLM decided to call a tool + scope.recordOutputMessages({ + version: "0.1.0", + messages: [ + { + role: MessageRole.ASSISTANT, + parts: [ + { type: "text", content: "I need to check the weather. Let me look that up." }, + { + type: "tool_call", + name: "getWeather", + id: "call_abc123", + arguments: { city: "Seattle" }, + }, + ], + }, + ], + }); + scope.recordInputTokens(85); + scope.recordOutputTokens(42); + scope.recordFinishReasons(["tool_call"]); + + return { toolName: "getWeather", args: { city: "Seattle" }, callId: "call_abc123" }; + } catch (err) { + scope.recordError(err as Error); + throw err; + } finally { + scope.dispose(); + } +} + +/** Simulate executing a tool that the LLM requested. */ +async function executeTool( + request: A365Request, + tool: { toolName: string; args: Record; callId: string }, +): Promise { + const toolDetails: ToolCallDetails = { + toolName: tool.toolName, + arguments: tool.args, + toolCallId: tool.callId, + description: "Returns current weather for a given city", + toolType: "function", + }; + + const scope = ExecuteToolScope.start(request, toolDetails, agentDetails); + try { + // Simulate tool execution + await new Promise((r) => setTimeout(r, 30)); + const result = { temperature: 62, condition: "Partly cloudy", unit: "F" }; + + scope.recordResponse(result); + return JSON.stringify(result); + } catch (err) { + scope.recordError(err as Error); + throw err; + } finally { + scope.dispose(); + } +} + +/** Simulate a final LLM call to format the tool result into a natural language response. */ +async function formatResponse( + request: A365Request, + toolResult: string, +): Promise { + const details: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: "gpt-4o", + providerName: "azure-openai", + }; + + const scope = InferenceScope.start(request, details, agentDetails); + try { + scope.recordInputMessages([ + `Tool result: ${toolResult}`, + "Please summarize the weather for the user.", + ]); + + await new Promise((r) => setTimeout(r, 40)); + + const answer = "It's currently 62°F and partly cloudy in Seattle."; + scope.recordOutputMessages([answer]); + scope.recordInputTokens(60); + scope.recordOutputTokens(18); + scope.recordFinishReasons(["stop"]); + + return answer; + } catch (err) { + scope.recordError(err as Error); + throw err; + } finally { + scope.dispose(); + } +} + +/** Record the final streamed output. */ +function recordOutput(request: A365Request, answer: string): void { + const scope = OutputScope.start( + request, + { messages: [answer] }, + agentDetails, + ); + scope.dispose(); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Trace context propagation demo +// ──────────────────────────────────────────────────────────────────────────── + +/** Shows how to propagate trace context across HTTP service boundaries. */ +function demonstrateContextPropagation(): void { + console.log("\n--- Context Propagation Demo ---"); + + // SERVICE A: inject the current trace context into outgoing HTTP headers + const outgoingHeaders: Record = {}; + injectContextToHeaders(outgoingHeaders); + console.log(" Injected headers:", outgoingHeaders); + + // SERVICE B: extract trace context from incoming headers and run in that context + runWithExtractedTraceContext(outgoingHeaders, () => { + // Any spans created here will be children of Service A's active span + const scope = InvokeAgentScope.start( + { conversationId: "cross-service-conv" }, + { endpoint: { host: "service-b.internal", port: 8080 } }, + { ...agentDetails, agentId: "downstream-agent", agentName: "DownstreamBot", tenantId: "contoso-tenant-id" }, + ); + console.log(" Created child span in Service B, traceId:", scope.getSpanContext().traceId); + scope.recordResponse("Handled by downstream agent"); + scope.dispose(); + }); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Main +// ──────────────────────────────────────────────────────────────────────────── + +async function main(): Promise { + // Initialize the distro with A365 export enabled + useMicrosoftOpenTelemetry({ + azureMonitor: { + azureMonitorExporterOptions: { + connectionString: + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING || "", + }, + }, + a365: { + enabled: true, + tokenResolver: myTokenResolver, + clusterCategory: "dev", + }, + }); + + // ── Simulate an incoming user request ────────────────────────────────── + const request: A365Request = { + conversationId: "conv-12345", + sessionId: "session-abc", + channel: { name: "Teams", description: "https://teams.microsoft.com" }, + content: "What's the weather in Seattle?", + }; + + console.log("=== A365 Manual Telemetry Scopes Demo ===\n"); + + // 1️⃣ InvokeAgentScope — wraps the entire agent invocation + const invokeScope = InvokeAgentScope.start( + request, + {}, + agentDetails, + { + userDetails: { + userId: "user-jane-doe", + userName: "Jane Doe", + userEmail: "jane@contoso.com", + tenantId: "contoso-tenant-id", + }, + }, + ); + + try { + console.log("1. InvokeAgentScope started"); + console.log(` traceId: ${invokeScope.getSpanContext().traceId}`); + + // 2️⃣ InferenceScope — first LLM call (decides to use a tool) + console.log("2. Calling LLM (InferenceScope)..."); + const toolCall = await callLLM(request, invokeScope); + console.log(` LLM wants to call tool: ${toolCall.toolName}(${JSON.stringify(toolCall.args)})`); + + // 3️⃣ ExecuteToolScope — run the tool + console.log("3. Executing tool (ExecuteToolScope)..."); + const toolResult = await executeTool(request, toolCall); + console.log(` Tool result: ${toolResult}`); + + // 4️⃣ InferenceScope — second LLM call (format the answer) + console.log("4. Formatting response (InferenceScope)..."); + const answer = await formatResponse(request, toolResult); + console.log(` Final answer: ${answer}`); + + // 5️⃣ OutputScope — record the streamed output + console.log("5. Recording output (OutputScope)..."); + recordOutput(request, answer); + + // Record the final response on the invoke scope + invokeScope.recordResponse(answer); + console.log("\nAll scopes completed successfully."); + + // 6️⃣ Context propagation across services + demonstrateContextPropagation(); + } catch (err) { + invokeScope.recordError(err as Error); + console.error("Agent invocation failed:", err); + } finally { + invokeScope.dispose(); + } + + // Give the batch processor time to flush + await new Promise((resolve) => setTimeout(resolve, 5000)); + console.log("\nDone. Check your telemetry backend for the trace."); +} + +main().catch(console.error); diff --git a/src/a365/configuration/A365Configuration.ts b/src/a365/configuration/A365Configuration.ts index 2bf64dc9..e7e33a90 100644 --- a/src/a365/configuration/A365Configuration.ts +++ b/src/a365/configuration/A365Configuration.ts @@ -7,20 +7,30 @@ import type { A365BaggageOptions, A365HostingOptions, } from "./A365ConfigurationOptions.js"; -import { getEnv } from "../utils/utils.js"; import { Logger } from "../../shared/logging/index.js"; import { JsonConfig } from "../../shared/jsonConfig.js"; +/** + * Parse an environment variable as a boolean. + * Recognizes 'true', '1', 'yes', 'on' (case-insensitive) as true; all other values as false. + * Matches the upstream Agent365-nodejs RuntimeConfiguration.parseEnvBoolean. + */ +function parseEnvBoolean(envValue: string | undefined): boolean { + if (!envValue) return false; + return ["true", "1", "yes", "on"].includes(envValue.toLowerCase()); +} + /** * Environment variable names for A365 configuration. - * These follow the MICROSOFT_OTEL_A365_* convention defined in PLANNING.md. + * These match the upstream Agent365-nodejs conventions. */ export const A365_ENV_VARS = { - EXPORTER_ENABLED: "MICROSOFT_OTEL_A365_EXPORTER_ENABLED", - PER_REQUEST_EXPORT: "MICROSOFT_OTEL_A365_PER_REQUEST_EXPORT", - AUTH_SCOPES: "MICROSOFT_OTEL_A365_AUTH_SCOPES", - DOMAIN: "MICROSOFT_OTEL_A365_DOMAIN", - CLUSTER_CATEGORY: "MICROSOFT_OTEL_A365_CLUSTER_CATEGORY", + EXPORTER_ENABLED: "ENABLE_A365_OBSERVABILITY_EXPORTER", + PER_REQUEST_EXPORT: "ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT", + AUTH_SCOPES: "A365_OBSERVABILITY_SCOPES_OVERRIDE", + DOMAIN: "A365_OBSERVABILITY_DOMAIN_OVERRIDE", + CLUSTER_CATEGORY: "CLUSTER_CATEGORY", + LOG_LEVEL: "A365_OBSERVABILITY_LOG_LEVEL", } as const; const DEFAULT_AUTH_SCOPE = "https://api.powerplatform.com/.default"; @@ -47,7 +57,7 @@ const VALID_CLUSTER_CATEGORIES: ReadonlySet = new Set([ * 1. Defaults * 2. Programmatic options (`A365Options`) * 3. JSON config (`applicationinsights.json` → `a365` key) - * 4. Environment variables (`MICROSOFT_OTEL_A365_*`) + * 4. Environment variables (see `A365_ENV_VARS`) */ export class A365Configuration { /** Whether A365 observability is enabled. */ @@ -102,32 +112,27 @@ export class A365Configuration { } // 4. Apply environment variable overrides (highest precedence) - if (getEnv(A365_ENV_VARS.EXPORTER_ENABLED) === "true") { - enabled = true; - } else if (getEnv(A365_ENV_VARS.EXPORTER_ENABLED) === "false") { - enabled = false; + const envEnabled = process.env[A365_ENV_VARS.EXPORTER_ENABLED]; + if (envEnabled !== undefined) { + enabled = parseEnvBoolean(envEnabled); } - if (getEnv(A365_ENV_VARS.PER_REQUEST_EXPORT) === "true") { - perRequestExport = true; - } else if (getEnv(A365_ENV_VARS.PER_REQUEST_EXPORT) === "false") { - perRequestExport = false; + const envPerRequest = process.env[A365_ENV_VARS.PER_REQUEST_EXPORT]; + if (envPerRequest !== undefined) { + perRequestExport = parseEnvBoolean(envPerRequest); } - const envScopes = getEnv(A365_ENV_VARS.AUTH_SCOPES); + const envScopes = process.env[A365_ENV_VARS.AUTH_SCOPES]?.trim(); if (envScopes) { - authScopes = envScopes - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + authScopes = envScopes.split(/\s+/).filter(Boolean); } - const envDomain = getEnv(A365_ENV_VARS.DOMAIN); + const envDomain = process.env[A365_ENV_VARS.DOMAIN]?.trim(); if (envDomain) { - domainOverride = envDomain; + domainOverride = envDomain.replace(/\/+$/, ""); } - const envCluster = getEnv(A365_ENV_VARS.CLUSTER_CATEGORY); + const envCluster = process.env[A365_ENV_VARS.CLUSTER_CATEGORY]?.toLowerCase(); if (envCluster && VALID_CLUSTER_CATEGORIES.has(envCluster)) { clusterCategory = envCluster as ClusterCategory; } @@ -168,7 +173,7 @@ export class A365Configuration { if (hasNonTrivialOptions) { Logger.getInstance().warn( "A365 configuration options are set but A365 is not enabled. " + - "Set `a365.enabled: true` or `MICROSOFT_OTEL_A365_EXPORTER_ENABLED=true` to enable.", + "Set `a365.enabled: true` or `ENABLE_A365_OBSERVABILITY_EXPORTER=true` to enable.", ); } } diff --git a/src/a365/constants.ts b/src/a365/constants.ts new file mode 100644 index 00000000..7c89fd51 --- /dev/null +++ b/src/a365/constants.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * OpenTelemetry constants for A365 observability. + * + * Attribute keys follow OTel gen-ai semantic conventions plus + * Microsoft-specific extensions under the `microsoft.*` namespace. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability/src/tracing/constants.ts + */ +export class OpenTelemetryConstants { + // ── Span operation names ────────────────────────────────────────── + public static readonly INVOKE_AGENT_OPERATION_NAME = "invoke_agent"; + public static readonly EXECUTE_TOOL_OPERATION_NAME = "execute_tool"; + public static readonly OUTPUT_MESSAGES_OPERATION_NAME = "output_messages"; + public static readonly CHAT_OPERATION_NAME = "chat"; + + // ── Standard OTel semantic conventions ──────────────────────────── + public static readonly ERROR_TYPE_KEY = "error.type"; + public static readonly ERROR_TYPE_CANCELLED = "TaskCanceledException"; + public static readonly ERROR_MESSAGE_KEY = "error.message"; + public static readonly AZ_NAMESPACE_KEY = "az.namespace"; + public static readonly SERVER_ADDRESS_KEY = "server.address"; + public static readonly SERVER_PORT_KEY = "server.port"; + + // ── Source / SDK identity ───────────────────────────────────────── + public static readonly SOURCE_NAME = "Agent365Sdk"; + + // ── GenAI core attributes ──────────────────────────────────────── + public static readonly GEN_AI_OPERATION_NAME_KEY = "gen_ai.operation.name"; + public static readonly GEN_AI_REQUEST_MODEL_KEY = "gen_ai.request.model"; + public static readonly GEN_AI_RESPONSE_MODEL_KEY = "gen_ai.response.model"; + public static readonly GEN_AI_RESPONSE_FINISH_REASONS_KEY = "gen_ai.response.finish_reasons"; + public static readonly GEN_AI_PROVIDER_NAME_KEY = "gen_ai.provider.name"; + + // ── GenAI usage ────────────────────────────────────────────────── + public static readonly GEN_AI_USAGE_INPUT_TOKENS_KEY = "gen_ai.usage.input_tokens"; + public static readonly GEN_AI_USAGE_OUTPUT_TOKENS_KEY = "gen_ai.usage.output_tokens"; + + // ── GenAI message attributes ───────────────────────────────────── + public static readonly GEN_AI_SYSTEM_INSTRUCTIONS_KEY = "gen_ai.system_instructions"; + public static readonly GEN_AI_INPUT_MESSAGES_KEY = "gen_ai.input.messages"; + public static readonly GEN_AI_OUTPUT_MESSAGES_KEY = "gen_ai.output.messages"; + public static readonly A365_MESSAGES_SCHEMA_VERSION_KEY = + "microsoft.a365.messages.schema_version"; + + // ── GenAI agent attributes ─────────────────────────────────────── + public static readonly GEN_AI_AGENT_ID_KEY = "gen_ai.agent.id"; + public static readonly GEN_AI_AGENT_NAME_KEY = "gen_ai.agent.name"; + public static readonly GEN_AI_AGENT_DESCRIPTION_KEY = "gen_ai.agent.description"; + public static readonly GEN_AI_AGENT_VERSION_KEY = "gen_ai.agent.version"; + public static readonly GEN_AI_AGENT_PLATFORM_ID_KEY = "microsoft.a365.agent.platform.id"; + public static readonly GEN_AI_AGENT_THOUGHT_PROCESS_KEY = "microsoft.a365.agent.thought.process"; + public static readonly GEN_AI_ICON_URI_KEY = "gen_ai.agent365.icon_uri"; + + // ── GenAI conversation / session ───────────────────────────────── + public static readonly GEN_AI_CONVERSATION_ID_KEY = "gen_ai.conversation.id"; + public static readonly GEN_AI_CONVERSATION_ITEM_LINK_KEY = "microsoft.conversation.item.link"; + public static readonly SESSION_ID_KEY = "microsoft.session.id"; + public static readonly SESSION_DESCRIPTION_KEY = "microsoft.session.description"; + + // ── GenAI tool attributes ──────────────────────────────────────── + public static readonly GEN_AI_TOOL_CALL_ID_KEY = "gen_ai.tool.call.id"; + public static readonly GEN_AI_TOOL_NAME_KEY = "gen_ai.tool.name"; + public static readonly GEN_AI_TOOL_DESCRIPTION_KEY = "gen_ai.tool.description"; + public static readonly GEN_AI_TOOL_ARGS_KEY = "gen_ai.tool.call.arguments"; + public static readonly GEN_AI_TOOL_CALL_RESULT_KEY = "gen_ai.tool.call.result"; + public static readonly GEN_AI_TOOL_TYPE_KEY = "gen_ai.tool.type"; + + // ── Tenant ─────────────────────────────────────────────────────── + public static readonly TENANT_ID_KEY = "microsoft.tenant.id"; + + // ── Human caller dimensions (OTel user.* namespace) ────────────── + public static readonly USER_ID_KEY = "user.id"; + public static readonly USER_NAME_KEY = "user.name"; + public static readonly USER_EMAIL_KEY = "user.email"; + public static readonly GEN_AI_CALLER_CLIENT_IP_KEY = "client.address"; + + // ── Agent-to-Agent caller dimensions ───────────────────────────── + public static readonly GEN_AI_CALLER_AGENT_USER_ID_KEY = "microsoft.a365.caller.agent.user.id"; + public static readonly GEN_AI_CALLER_AGENT_EMAIL_KEY = "microsoft.a365.caller.agent.user.email"; + public static readonly GEN_AI_CALLER_AGENT_NAME_KEY = "microsoft.a365.caller.agent.name"; + public static readonly GEN_AI_CALLER_AGENT_ID_KEY = "microsoft.a365.caller.agent.id"; + public static readonly GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY = + "microsoft.a365.caller.agent.blueprint.id"; + public static readonly GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY = + "microsoft.a365.caller.agent.platform.id"; + public static readonly GEN_AI_CALLER_AGENT_VERSION_KEY = "microsoft.a365.caller.agent.version"; + + // ── Baggage keys ───────────────────────────────────────────────── + public static readonly GEN_AI_AGENT_AUID_KEY = "microsoft.agent.user.id"; + public static readonly GEN_AI_AGENT_EMAIL_KEY = "microsoft.agent.user.email"; + public static readonly GEN_AI_AGENT_BLUEPRINT_ID_KEY = "microsoft.a365.agent.blueprint.id"; + + // ── Channel dimensions ─────────────────────────────────────────── + public static readonly CHANNEL_NAME_KEY = "microsoft.channel.name"; + public static readonly CHANNEL_LINK_KEY = "microsoft.channel.link"; + + // ── Custom parent / span name ──────────────────────────────────── + public static readonly CUSTOM_PARENT_SPAN_ID_KEY = "custom.parent.span.id"; + public static readonly CUSTOM_SPAN_NAME_KEY = "custom.span.name"; + + // ── Service attributes ─────────────────────────────────────────── + public static readonly SERVICE_NAME_KEY = "service.name"; + + // ── Telemetry SDK attributes ───────────────────────────────────── + public static readonly TELEMETRY_SDK_NAME_KEY = "telemetry.sdk.name"; + public static readonly TELEMETRY_SDK_LANGUAGE_KEY = "telemetry.sdk.language"; + public static readonly TELEMETRY_SDK_VERSION_KEY = "telemetry.sdk.version"; + public static readonly TELEMETRY_SDK_NAME_VALUE = "A365ObservabilitySDK"; + public static readonly TELEMETRY_SDK_LANGUAGE_VALUE = "nodejs"; + public static readonly TELEMETRY_SDK_VERSION_VALUE = "0.1.0"; +} diff --git a/src/a365/context.ts b/src/a365/context.ts new file mode 100644 index 00000000..303f0ed0 --- /dev/null +++ b/src/a365/context.ts @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Context propagation utilities for A365 observability. + * + * Provides: + * - `ParentSpanRef` for explicit parent-child linking across async boundaries + * - `createContextWithParentSpanRef` / `runWithParentSpanRef` helpers + * - `isParentSpanRef` type guard + * - W3C traceparent inject/extract helpers + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability/src/tracing/context/ + */ + +import { context, trace, propagation } from "@opentelemetry/api"; +import type { Context, SpanContext } from "@opentelemetry/api"; +import { TraceFlags } from "@opentelemetry/api"; +import type { ParentSpanRef, ParentContext } from "./contracts.js"; +import { Logger } from "../shared/logging/index.js"; + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +function isValidTraceId(traceId: string): boolean { + return /^[0-9a-f]{32}$/i.test(traceId) && traceId !== "00000000000000000000000000000000"; +} + +function isValidSpanId(spanId: string): boolean { + return /^[0-9a-f]{16}$/i.test(spanId) && spanId !== "0000000000000000"; +} + +// --------------------------------------------------------------------------- +// Type guard +// --------------------------------------------------------------------------- + +/** Type guard to distinguish a ParentSpanRef from an OTel Context. */ +export function isParentSpanRef(value: ParentContext): value is ParentSpanRef { + if (typeof value !== "object" || value === null) return false; + + const maybeCtx = value as Context; + if ( + typeof maybeCtx.getValue === "function" && + typeof maybeCtx.setValue === "function" && + typeof maybeCtx.deleteValue === "function" + ) { + return false; + } + + const maybeRef = value as ParentSpanRef; + return ( + "traceId" in maybeRef && + typeof maybeRef.traceId === "string" && + "spanId" in maybeRef && + typeof maybeRef.spanId === "string" + ); +} + +// --------------------------------------------------------------------------- +// Parent span context +// --------------------------------------------------------------------------- + +/** + * Creates a new Context with an explicit parent span reference. + * This allows child spans to be correctly parented even when async context is broken. + */ +export function createContextWithParentSpanRef(base: Context, parent: ParentSpanRef): Context { + const logger = Logger.getInstance(); + + if (!isValidTraceId(parent.traceId) || !isValidSpanId(parent.spanId)) { + logger.warn( + `[A365] Invalid parent span reference; returning base context. traceId=${parent.traceId}, spanId=${parent.spanId}`, + ); + return base; + } + + const activeCtx = trace.getSpan(base)?.spanContext(); + const traceFlags = + parent.traceFlags ?? + (activeCtx?.traceId === parent.traceId ? activeCtx.traceFlags : undefined) ?? + TraceFlags.SAMPLED; + + const parentSpanContext: SpanContext = { + traceId: parent.traceId, + spanId: parent.spanId, + traceFlags, + traceState: parent.traceState, + isRemote: parent.isRemote ?? true, + }; + + const parentSpan = trace.wrapSpanContext(parentSpanContext); + return trace.setSpan(base, parentSpan); +} + +/** + * Runs a callback within a context that has an explicit parent span reference. + */ +export function runWithParentSpanRef(parent: ParentSpanRef, callback: () => T): T { + const contextWithParent = createContextWithParentSpanRef(context.active(), parent); + return context.with(contextWithParent, callback); +} + +// --------------------------------------------------------------------------- +// W3C trace context propagation +// --------------------------------------------------------------------------- + +/** Carrier type for HTTP headers. */ +export type HeadersCarrier = Record; + +/** + * Injects the current trace context (`traceparent`/`tracestate` headers) into + * the provided headers object using the globally registered W3C propagator. + */ +export function injectContextToHeaders( + headers: Record, + ctx?: Context, +): Record { + propagation.inject(ctx ?? context.active(), headers); + return headers; +} + +/** + * Extracts trace context from incoming HTTP headers using the globally + * registered W3C propagator. + */ +export function extractContextFromHeaders(headers: HeadersCarrier, baseCtx?: Context): Context { + return propagation.extract(baseCtx ?? context.active(), headers); +} + +/** + * Extracts trace context from incoming HTTP headers and runs the callback + * within that context. + */ +export function runWithExtractedTraceContext(headers: HeadersCarrier, callback: () => T): T { + return context.with(extractContextFromHeaders(headers), callback); +} diff --git a/src/a365/context/tokenContext.ts b/src/a365/context/tokenContext.ts new file mode 100644 index 00000000..1115c552 --- /dev/null +++ b/src/a365/context/tokenContext.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Per-request export token context propagation. + * + * Uses OpenTelemetry Context (backed by AsyncLocalStorage) to carry and + * refresh a per-request bearer token that the PerRequestSpanProcessor + * restores at export time. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability/src/tracing/context/token-context.ts + */ + +import { context, createContextKey } from "@opentelemetry/api"; +import type { Context } from "@opentelemetry/api"; +import { Logger } from "../../shared/logging/index.js"; + +const EXPORT_TOKEN_KEY = createContextKey("a365_export_token"); + +/** + * Mutable holder stored in Context so the token can be refreshed + * after the context is created (OTel contexts are immutable, but + * the object reference stays the same). + */ +interface TokenHolder { + token: string; +} + +/** + * Run a function within a Context that carries the per-request export token. + * This keeps the token only in OTel Context (ALS), never in any registry. + * + * The token can be updated later via `updateExportToken()` before the trace + * is flushed — useful when the callback is long-running and the original + * token may expire before export. + */ +export function runWithExportToken(token: string, fn: () => T): T { + const holder: TokenHolder = { token }; + const ctxWithToken = context.active().setValue(EXPORT_TOKEN_KEY, holder); + Logger.getInstance().info("[TokenContext] Running function with export token in context."); + return context.with(ctxWithToken, fn); +} + +/** + * Update the export token in the active OTel Context. + * Call this to refresh the token before ending the root span when the + * original token may have expired during a long-running request. + * + * Must be called within the same async context created by `runWithExportToken`. + * @param token The fresh token to use for export. + * @returns true if the token was updated successfully, false if no token holder was found. + */ +export function updateExportToken(token: string): boolean { + const value = context.active().getValue(EXPORT_TOKEN_KEY); + if (value && typeof value === "object" && "token" in value) { + (value as TokenHolder).token = token; + Logger.getInstance().info("[TokenContext] Export token updated in context."); + return true; + } + Logger.getInstance().warn( + "[TokenContext] updateExportToken called but no token holder found in active context. Was runWithExportToken called?", + ); + return false; +} + +/** + * Retrieve the per-request export token from a given OTel Context (or the active one). + */ +export function getExportToken(ctx: Context = context.active()): string | undefined { + const value = ctx.getValue(EXPORT_TOKEN_KEY); + if (value && typeof value === "object" && "token" in value) { + return (value as TokenHolder).token; + } + // Backward compat: support raw string values from older callers + if (typeof value === "string") { + return value; + } + return undefined; +} diff --git a/src/a365/contracts.ts b/src/a365/contracts.ts new file mode 100644 index 00000000..5d37c828 --- /dev/null +++ b/src/a365/contracts.ts @@ -0,0 +1,339 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * A365 observability contracts — types for scopes, messages, and telemetry details. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability/src/tracing/contracts.ts + * following OTel gen-ai semantic conventions. + */ + +import type { SpanKind, TimeInput, Link, Context, TraceState } from "@opentelemetry/api"; + +// --------------------------------------------------------------------------- +// Message schema version +// --------------------------------------------------------------------------- + +export const A365_MESSAGE_SCHEMA_VERSION = "0.1.0" as const; + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +/** Role of a message participant per OTEL gen-ai semantic conventions. */ +export enum MessageRole { + SYSTEM = "system", + USER = "user", + ASSISTANT = "assistant", + TOOL = "tool", +} + +/** Reason a model stopped generating per OTEL gen-ai semantic conventions. */ +export enum FinishReason { + STOP = "stop", + LENGTH = "length", + CONTENT_FILTER = "content_filter", + TOOL_CALL = "tool_call", + ERROR = "error", +} + +/** Media modality for blob, file, and URI parts. */ +export enum Modality { + IMAGE = "image", + VIDEO = "video", + AUDIO = "audio", +} + +/** Represents different roles that can invoke an agent. */ +export enum InvocationRole { + Human = "Human", + Agent = "Agent", + Event = "Event", + Unknown = "Unknown", +} + +/** Represents different operation types for model inference. */ +export enum InferenceOperationType { + CHAT = "Chat", + TEXT_COMPLETION = "TextCompletion", + GENERATE_CONTENT = "GenerateContent", +} + +// --------------------------------------------------------------------------- +// Message parts (discriminated union on `type`) +// --------------------------------------------------------------------------- + +/** Plain text content. */ +export interface TextPart { + type: "text"; + content: string; +} + +/** A tool call requested by the model. */ +export interface ToolCallRequestPart { + type: "tool_call"; + name: string; + id?: string; + arguments?: Record | unknown[]; +} + +/** Result of a tool call. */ +export interface ToolCallResponsePart { + type: "tool_call_response"; + id?: string; + response?: unknown; +} + +/** Model reasoning / chain-of-thought content. */ +export interface ReasoningPart { + type: "reasoning"; + content: string; +} + +/** Inline binary data (base64-encoded). */ +export interface BlobPart { + type: "blob"; + modality: Modality | string; + mime_type?: string; + content: string; +} + +/** Reference to a pre-uploaded file. */ +export interface FilePart { + type: "file"; + modality: Modality | string; + mime_type?: string; + file_id: string; +} + +/** External URI reference. */ +export interface UriPart { + type: "uri"; + modality: Modality | string; + mime_type?: string; + uri: string; +} + +/** Extensible server tool call details. */ +export interface GenericServerToolCall { + type: string; + [key: string]: unknown; +} + +/** Extensible server tool call response. */ +export interface GenericServerToolCallResponse { + type: string; + [key: string]: unknown; +} + +/** Server-side tool invocation. */ +export interface ServerToolCallPart { + type: "server_tool_call"; + name: string; + id?: string; + server_tool_call: GenericServerToolCall; +} + +/** Server-side tool response. */ +export interface ServerToolCallResponsePart { + type: "server_tool_call_response"; + id?: string; + server_tool_call_response: GenericServerToolCallResponse; +} + +/** Extensible part for custom / future types. */ +export interface GenericPart { + type: string; + [key: string]: unknown; +} + +/** Union of all message part types per OTEL gen-ai semantic conventions. */ +export type MessagePart = + | TextPart + | ToolCallRequestPart + | ToolCallResponsePart + | ReasoningPart + | BlobPart + | FilePart + | UriPart + | ServerToolCallPart + | ServerToolCallResponsePart + | GenericPart; + +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +/** An input message sent to a model (OTEL gen-ai semantic conventions). */ +export interface ChatMessage { + role: MessageRole | string; + parts: MessagePart[]; + name?: string; +} + +/** Versioned wrapper for input messages. */ +export interface InputMessages { + version: typeof A365_MESSAGE_SCHEMA_VERSION; + messages: ChatMessage[]; +} + +/** An output message produced by a model (OTEL gen-ai semantic conventions). */ +export interface OutputMessage extends ChatMessage { + finish_reason?: FinishReason | string; +} + +/** Versioned wrapper for output messages. */ +export interface OutputMessages { + version: typeof A365_MESSAGE_SCHEMA_VERSION; + messages: OutputMessage[]; +} + +/** Accepted input for `recordInputMessages`. */ +export type InputMessagesParam = string | string[] | InputMessages; + +/** Accepted input for `recordOutputMessages`. */ +export type OutputMessagesParam = string | string[] | OutputMessages; + +/** Accepted input for `OutputResponse.messages`. */ +export type ResponseMessagesParam = OutputMessagesParam | Record; + +// --------------------------------------------------------------------------- +// Channel & Request +// --------------------------------------------------------------------------- + +/** Represents a channel for an invocation. */ +export interface Channel { + id?: string; + name?: string; + iconUri?: string; + role?: InvocationRole; + description?: string; +} + +/** Represents a request with telemetry context. */ +export interface Request { + content?: InputMessagesParam; + sessionId?: string; + channel?: Channel; + conversationId?: string; +} + +// --------------------------------------------------------------------------- +// Agent, User, Caller details +// --------------------------------------------------------------------------- + +/** Details about an AI agent. */ +export interface AgentDetails { + agentId: string; + agentName?: string; + agentDescription?: string; + iconUri?: string; + platformId?: string; + agentAUID?: string; + agentEmail?: string; + agentBlueprintId?: string; + tenantId?: string; + providerName?: string; + agentVersion?: string; +} + +/** Details about the human user caller. */ +export interface UserDetails { + userId?: string; + userEmail?: string; + userName?: string; + tenantId?: string; + callerClientIp?: string; +} + +/** + * Caller details for scope creation. + * Supports human callers, agent callers, or both (A2A with a human in the chain). + */ +export interface CallerDetails { + userDetails?: UserDetails; + callerAgentDetails?: AgentDetails; +} + +// --------------------------------------------------------------------------- +// Service endpoint +// --------------------------------------------------------------------------- + +/** Represents an endpoint for agent invocation. */ +export interface ServiceEndpoint { + host: string; + port?: number; + protocol?: string; +} + +// --------------------------------------------------------------------------- +// Scope detail types +// --------------------------------------------------------------------------- + +/** Details for invoking agent scope. */ +export interface InvokeAgentScopeDetails { + endpoint?: ServiceEndpoint; +} + +/** Details of a tool call made by an agent. */ +export interface ToolCallDetails { + toolName: string; + arguments?: Record | string; + toolCallId?: string; + description?: string; + toolType?: string; + endpoint?: ServiceEndpoint; +} + +/** Details for an inference call. */ +export interface InferenceDetails { + operationName: InferenceOperationType; + model: string; + providerName?: string; + inputTokens?: number; + outputTokens?: number; + finishReasons?: string[]; + thoughtProcess?: string; + endpoint?: ServiceEndpoint; +} + +/** Details for recording the response from an inference call. */ +export interface InferenceResponse { + content: string; + responseId?: string; + finishReason?: string; + inputTokens?: number; + outputTokens?: number; +} + +/** Represents a response containing output messages from an agent. */ +export interface OutputResponse { + messages: ResponseMessagesParam; +} + +// --------------------------------------------------------------------------- +// Span details +// --------------------------------------------------------------------------- + +/** Parent context — either an OTel Context or a manual ParentSpanRef. */ +export type ParentContext = Context | ParentSpanRef; + +/** Manual parent span reference for cross-async-boundary tracing. */ +export interface ParentSpanRef { + traceId: string; + spanId: string; + traceFlags?: number; + traceState?: TraceState; + isRemote?: boolean; +} + +/** + * Span configuration details for scope creation. + */ +export interface SpanDetails { + parentContext?: ParentContext; + startTime?: TimeInput; + endTime?: TimeInput; + spanKind?: SpanKind; + spanLinks?: Link[]; +} diff --git a/src/a365/index.ts b/src/a365/index.ts index 31543889..cf574dee 100644 --- a/src/a365/index.ts +++ b/src/a365/index.ts @@ -12,3 +12,79 @@ export type { export { Agent365Exporter } from "./exporter/index.js"; export type { Agent365ExporterOptions, TokenResolver } from "./exporter/index.js"; export { ResolvedExporterOptions } from "./exporter/index.js"; + +// ── Scopes (manual telemetry API) ─────────────────────────────────────────── +export { + OpenTelemetryScope, + InvokeAgentScope, + ExecuteToolScope, + InferenceScope, + OutputScope, +} from "./scopes/index.js"; + +// ── Constants ─────────────────────────────────────────────────────────────── +export { OpenTelemetryConstants } from "./constants.js"; + +// ── Contracts (types & enums) ─────────────────────────────────────────────── +export { + MessageRole, + FinishReason, + Modality, + InvocationRole, + InferenceOperationType, + A365_MESSAGE_SCHEMA_VERSION, +} from "./contracts.js"; +export type { + ChatMessage, + InputMessages, + OutputMessage, + OutputMessages, + InputMessagesParam, + OutputMessagesParam, + ResponseMessagesParam, + MessagePart, + TextPart, + ToolCallRequestPart, + ToolCallResponsePart, + ReasoningPart, + AgentDetails, + UserDetails, + CallerDetails, + Request, + Channel, + ServiceEndpoint, + InvokeAgentScopeDetails, + ToolCallDetails, + InferenceDetails, + InferenceResponse, + OutputResponse, + SpanDetails, + ParentSpanRef, + ParentContext, +} from "./contracts.js"; + +// ── Context propagation ───────────────────────────────────────────────────── +export { + isParentSpanRef, + createContextWithParentSpanRef, + runWithParentSpanRef, + injectContextToHeaders, + extractContextFromHeaders, + runWithExtractedTraceContext, +} from "./context.js"; +export type { HeadersCarrier } from "./context.js"; + +// ── Middleware (BaggageBuilder) ────────────────────────────────────────────── +export { BaggageBuilder, BaggageScope } from "./middleware/index.js"; + +// ── Processors ────────────────────────────────────────────────────────────── +export { + A365SpanProcessor, + PerRequestSpanProcessor, + GENERIC_ATTRIBUTES, + INVOKE_AGENT_ATTRIBUTES, +} from "./processors/index.js"; +export type { PerRequestSpanProcessorOptions } from "./processors/index.js"; + +// ── Token context ─────────────────────────────────────────────────────────── +export { runWithExportToken, updateExportToken, getExportToken } from "./context/tokenContext.js"; diff --git a/src/a365/message-utils.ts b/src/a365/message-utils.ts new file mode 100644 index 00000000..f59ae276 --- /dev/null +++ b/src/a365/message-utils.ts @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Utilities for normalizing and serializing gen-ai messages. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability/src/tracing/message-utils.ts + */ + +import type { + ChatMessage, + OutputMessage, + InputMessages, + OutputMessages, + InputMessagesParam, + OutputMessagesParam, +} from "./contracts.js"; +import { MessageRole, A365_MESSAGE_SCHEMA_VERSION } from "./contracts.js"; + +/** + * Type guard that returns `true` when the input is a versioned wrapper + * object (`InputMessages` or `OutputMessages`). + */ +export function isWrappedMessages( + input: InputMessagesParam | OutputMessagesParam, +): input is InputMessages | OutputMessages { + return ( + !Array.isArray(input) && + typeof input === "object" && + input !== null && + "version" in input && + "messages" in input + ); +} + +/** Converts plain input strings into OTEL input messages. */ +export function toInputMessages(messages: string[]): ChatMessage[] { + return messages.map((content) => ({ + role: MessageRole.USER, + parts: [{ type: "text" as const, content }], + })); +} + +/** Converts plain output strings into OTEL output messages. */ +export function toOutputMessages(messages: string[]): OutputMessage[] { + return messages.map((content) => ({ + role: MessageRole.ASSISTANT, + parts: [{ type: "text" as const, content }], + })); +} + +/** + * Normalizes an `InputMessagesParam` to a versioned `InputMessages` wrapper. + * - `string` / `string[]` → converted to `ChatMessage[]` and wrapped + * - `InputMessages` → returned as-is + */ +export function normalizeInputMessages(param: InputMessagesParam): InputMessages { + if (typeof param === "string" || Array.isArray(param)) { + const arr = typeof param === "string" ? [param] : param; + return { version: A365_MESSAGE_SCHEMA_VERSION, messages: toInputMessages(arr) }; + } + return param; +} + +/** + * Normalizes an `OutputMessagesParam` to a versioned `OutputMessages` wrapper. + * - `string` / `string[]` → converted to `OutputMessage[]` and wrapped + * - `OutputMessages` → returned as-is + */ +export function normalizeOutputMessages(param: OutputMessagesParam): OutputMessages { + if (typeof param === "string" || Array.isArray(param)) { + const arr = typeof param === "string" ? [param] : param; + return { version: A365_MESSAGE_SCHEMA_VERSION, messages: toOutputMessages(arr) }; + } + return param; +} + +/** + * Serializes a versioned message wrapper to JSON. + * + * The try/catch ensures telemetry recording is non-throwing even when + * message parts contain non-JSON-serializable values. + */ +export function serializeMessages(wrapper: InputMessages | OutputMessages): string { + try { + return JSON.stringify(wrapper); + } catch { + return JSON.stringify({ + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [ + { + role: MessageRole.SYSTEM, + parts: [ + { + type: "text", + content: `[serialization failed: ${wrapper.messages.length} ${wrapper.messages.length === 1 ? "message" : "messages"}]`, + }, + ], + }, + ], + }); + } +} + +/** + * Ensures the value is always a JSON-parseable string. + * - Objects are serialized via JSON.stringify. + * - Strings that are already valid JSON objects/arrays are passed through. + * - All other strings are wrapped: `{ [key]: value }`. + */ +export function safeSerializeToJson(value: Record | string, key: string): string { + if (typeof value === "object" && value !== null) { + try { + return JSON.stringify(value); + } catch { + return JSON.stringify({ error: "serialization failed" }); + } + } + const str = value as string; + try { + const parsed = JSON.parse(str) as unknown; + if (parsed !== null && typeof parsed === "object") { + return str; + } + } catch { + // not valid JSON — fall through to wrap + } + return JSON.stringify({ [key]: str }); +} diff --git a/src/a365/middleware/BaggageBuilder.ts b/src/a365/middleware/BaggageBuilder.ts new file mode 100644 index 00000000..72a6b5f5 --- /dev/null +++ b/src/a365/middleware/BaggageBuilder.ts @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Per-request baggage builder for OpenTelemetry context propagation. + * + * Provides a fluent API for setting baggage values that will be propagated + * in the OpenTelemetry context and stamped onto spans by the SpanProcessor. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability/src/tracing/middleware/BaggageBuilder.ts + */ + +import { propagation, context as otelContext } from "@opentelemetry/api"; +import type { Context } from "@opentelemetry/api"; +import { OpenTelemetryConstants } from "../constants.js"; + +/** + * Fluent builder for setting OpenTelemetry baggage values. + * + * @example + * ```typescript + * const scope = new BaggageBuilder() + * .tenantId("tenant-123") + * .agentId("agent-456") + * .build(); + * + * scope.run(() => { + * // Baggage is active in this context + * }); + * ``` + */ +export class BaggageBuilder { + private pairs: Map = new Map(); + + /** Set the operation source baggage value (e.g., ATG, ACF). */ + operationSource(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.SERVICE_NAME_KEY, value); + return this; + } + + /** Set the tenant ID baggage value. */ + tenantId(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.TENANT_ID_KEY, value); + return this; + } + + /** Set the agent ID baggage value. */ + agentId(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, value); + return this; + } + + /** Set the agent AUID baggage value. */ + agentAuid(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, value); + return this; + } + + /** Set the agent email baggage value. */ + agentEmail(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_AGENT_EMAIL_KEY, value); + return this; + } + + /** Set the agent blueprint ID baggage value. */ + agentBlueprintId(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, value); + return this; + } + + /** Set the session ID baggage value. */ + sessionId(value: string): BaggageBuilder { + this.set(OpenTelemetryConstants.SESSION_ID_KEY, value); + return this; + } + + /** Set the user ID baggage value. */ + userId(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.USER_ID_KEY, value); + return this; + } + + /** Set the agent name baggage value. */ + agentName(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, value); + return this; + } + + /** Set the agent description baggage value. */ + agentDescription(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, value); + return this; + } + + /** Set the agent platform ID baggage value. */ + agentPlatformId(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_AGENT_PLATFORM_ID_KEY, value); + return this; + } + + /** Set the agent version baggage value. */ + agentVersion(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY, value); + return this; + } + + /** Set the session description baggage value. */ + sessionDescription(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.SESSION_DESCRIPTION_KEY, value); + return this; + } + + /** Set the user name baggage value. */ + userName(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.USER_NAME_KEY, value); + return this; + } + + /** Set the user email baggage value. */ + userEmail(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.USER_EMAIL_KEY, value); + return this; + } + + /** Set the caller client IP baggage value. */ + callerClientIp(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, value); + return this; + } + + /** Set the caller agent platform ID baggage value. */ + callerAgentPlatformId(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, value); + return this; + } + + /** Set the conversation ID baggage value. */ + conversationId(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, value); + return this; + } + + /** Set the conversation item link baggage value. */ + conversationItemLink(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.GEN_AI_CONVERSATION_ITEM_LINK_KEY, value); + return this; + } + + /** Set the channel name (e.g., Teams, Slack). */ + channelName(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.CHANNEL_NAME_KEY, value); + return this; + } + + /** Set the channel link/URL. */ + channelLink(value: string | null | undefined): BaggageBuilder { + this.set(OpenTelemetryConstants.CHANNEL_LINK_KEY, value); + 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. + */ + 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 + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setPairs( + pairs: Record | Iterable<[string, any]> | null | undefined, + ): BaggageBuilder { + if (!pairs) { + return this; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let entries: Iterable<[string, any]>; + if (Symbol.iterator in Object(pairs)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + entries = pairs as Iterable<[string, any]>; + } else { + entries = Object.entries(pairs); + } + + for (const [key, value] of entries) { + if (value !== null && value !== undefined) { + this.set(key, String(value)); + } + } + + return this; + } + + /** + * Apply the collected baggage to the current context. + * @returns A BaggageScope that can run callbacks under the baggage context + */ + build(): BaggageScope { + return new BaggageScope(this.pairs); + } + + /** + * Add a baggage key/value if the value is not null or whitespace. + */ + private set(key: string, value: string | null | undefined): void { + if (value !== null && value !== undefined) { + const trimmed = value.trim(); + if (trimmed) { + this.pairs.set(key, trimmed); + } + } + } + + /** + * Convenience method to begin a request baggage scope with common fields. + * @param tenantId The tenant ID + * @param agentId The agent ID + * @returns A BaggageScope with tenant and agent ID set + */ + static setRequestContext(tenantId?: string | null, agentId?: string | null): BaggageScope { + return new BaggageBuilder().tenantId(tenantId).agentId(agentId).build(); + } +} + +/** + * Context manager for baggage scope. + * + * Manages the lifecycle of baggage values, setting them in the OTel context + * and restoring the previous context when the scope ends. + */ +export class BaggageScope implements Disposable { + /** @internal Exposed for testing. */ + readonly contextWithBaggage: Context; + + constructor(pairs: Map) { + // 1. Start from current active context + const currentCtx = otelContext.active(); + + // 2. Build merged baggage + let bag = propagation.getBaggage(currentCtx) ?? propagation.createBaggage({}); + for (const [key, value] of pairs.entries()) { + if (value && value.trim()) { + bag = bag.setEntry(key, { value }); + } + } + + // 3. Create a new context that carries that baggage + this.contextWithBaggage = propagation.setBaggage(currentCtx, bag); + } + + /** + * Execute a synchronous function under this baggage scope. + * Automatically restores previous context afterward. + */ + run(fn: () => T): T { + return otelContext.with(this.contextWithBaggage, fn); + } + + /** + * Dispose is a no-op because OpenTelemetry JS automatically restores + * the previous context after `context.with()` completes. + */ + [Symbol.dispose](): void { + // Nothing to detach manually; context restoration happens automatically. + } + + /** Manual cleanup alternative if caller isn't using `using`. */ + dispose(): void { + this[Symbol.dispose](); + } +} diff --git a/src/a365/middleware/index.ts b/src/a365/middleware/index.ts new file mode 100644 index 00000000..a4f94b45 --- /dev/null +++ b/src/a365/middleware/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { BaggageBuilder, BaggageScope } from "./BaggageBuilder.js"; diff --git a/src/a365/processors/A365SpanProcessor.ts b/src/a365/processors/A365SpanProcessor.ts new file mode 100644 index 00000000..abdd16c3 --- /dev/null +++ b/src/a365/processors/A365SpanProcessor.ts @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Span processor that propagates baggage key/value pairs to span attributes. + * + * This processor copies baggage entries onto spans based on the operation type. + * For `invoke_agent` operations, it applies both generic and invoke-agent-specific attributes. + * For other operations, it applies only generic attributes. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability/src/tracing/processors/SpanProcessor.ts + */ + +import type { Context, Span } from "@opentelemetry/api"; +import { propagation } from "@opentelemetry/api"; +import type { + SpanProcessor as BaseSpanProcessor, + ReadableSpan, +} from "@opentelemetry/sdk-trace-base"; +import { OpenTelemetryConstants } from "../constants.js"; +import { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from "./util.js"; + +/** + * Copies relevant baggage entries to span attributes on span start. + * + * This is the "automatic" counterpart to the manual scope API — it ensures + * every span in the pipeline gets agent identity attributes from baggage + * without explicitly creating scopes. + */ +export class A365SpanProcessor implements BaseSpanProcessor { + /** + * Called when a span is started. + * Copies relevant baggage entries to span attributes. + */ + onStart(span: Span, parentContext?: Context): void { + const ctx = parentContext; + if (!ctx) { + return; + } + + // Get existing span attributes + const existingAttrs = new Set(); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spanRecord = span as any; + if (spanRecord.attributes) { + Object.keys(spanRecord.attributes).forEach((key) => existingAttrs.add(key)); + } + } catch { + // Ignore errors accessing span attributes + } + + // Get all baggage entries + const baggage = propagation.getBaggage(ctx); + if (!baggage) { + return; + } + + const baggageMap = new Map(); + baggage.getAllEntries().forEach(([key, entry]) => { + if (entry.value) { + baggageMap.set(key, entry.value); + } + }); + + // Determine if this is an invoke_agent operation + const operationName = + baggageMap.get(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY) || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (span as any).attributes?.[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spanName = (span as any).name || ""; + const isInvokeAgent = + operationName === OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME || + spanName.startsWith(OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME); + + // Build target key set + const targetKeys = new Set(GENERIC_ATTRIBUTES); + if (isInvokeAgent) { + INVOKE_AGENT_ATTRIBUTES.forEach((key) => targetKeys.add(key)); + } + + // Set telemetry SDK attributes + if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY)) { + span.setAttribute( + OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY, + OpenTelemetryConstants.TELEMETRY_SDK_NAME_VALUE, + ); + } + if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY)) { + span.setAttribute( + OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY, + OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_VALUE, + ); + } + if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY)) { + span.setAttribute( + OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY, + OpenTelemetryConstants.TELEMETRY_SDK_VERSION_VALUE, + ); + } + + // Copy baggage to span attributes + for (const key of targetKeys) { + // Skip if attribute already exists + if (existingAttrs.has(key)) { + continue; + } + + const value = baggageMap.get(key); + if (!value) { + continue; + } + + try { + span.setAttribute(key, value); + } catch { + // Ignore errors setting attributes + } + } + } + + /** Called when a span is ended. */ + onEnd(_span: ReadableSpan): void { + // No-op for this processor + } + + /** Shutdown the processor. */ + async shutdown(): Promise { + // No-op for this processor + } + + /** Force flush the processor. */ + async forceFlush(): Promise { + // No-op for this processor + } +} diff --git a/src/a365/processors/PerRequestSpanProcessor.ts b/src/a365/processors/PerRequestSpanProcessor.ts new file mode 100644 index 00000000..075ec6ed --- /dev/null +++ b/src/a365/processors/PerRequestSpanProcessor.ts @@ -0,0 +1,334 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Buffers spans per trace and exports once the request completes. + * Token is not stored; we export under the saved request Context so that + * getExportToken() can read the token from the active OpenTelemetry Context at export time. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts + */ + +import { context } from "@opentelemetry/api"; +import type { Context } from "@opentelemetry/api"; +import type { ReadableSpan, SpanProcessor, SpanExporter } from "@opentelemetry/sdk-trace-base"; +import { Logger } from "../../shared/logging/index.js"; + +const logger = Logger.getInstance(); + +function isRootSpan(span: ReadableSpan): boolean { + return !span.parentSpanContext; +} + +type TraceBuffer = { + spans: ReadableSpan[]; + openCount: number; + rootEnded: boolean; + rootCtx?: Context; + startedAtMs: number; + rootEndedAtMs?: number; + droppedSpans: number; +}; + +type FlushReason = "trace_completed" | "root_ended_grace" | "max_trace_age" | "force_flush"; + +/** + * Configuration options for the PerRequestSpanProcessor. + */ +export interface PerRequestSpanProcessorOptions { + maxBufferedTraces?: number; + maxSpansPerTrace?: number; + maxConcurrentExports?: number; + flushGraceMs?: number; + maxTraceAgeMs?: number; +} + +// Default values +const DEFAULT_MAX_BUFFERED_TRACES = 1000; +const DEFAULT_MAX_SPANS_PER_TRACE = 5000; +const DEFAULT_MAX_CONCURRENT_EXPORTS = 20; +const DEFAULT_FLUSH_GRACE_MS = 250; +const DEFAULT_MAX_TRACE_AGE_MS = 1800000; // 30 minutes + +function parseEnvInt(envVar: string | undefined, defaultValue: number): number { + if (envVar === undefined || envVar === "") return defaultValue; + const parsed = parseInt(envVar, 10); + return Number.isNaN(parsed) ? defaultValue : parsed; +} + +/** + * Buffers spans per trace and exports them together once the trace completes. + * + * This processor supports per-request token resolution by exporting under + * the original request Context so that getExportToken() can read the token + * from the active OpenTelemetry Context at export time. + */ +export class PerRequestSpanProcessor implements SpanProcessor { + private traces = new Map(); + private sweepTimer?: ReturnType; + private isSweeping = false; + + private readonly maxBufferedTraces: number; + private readonly maxSpansPerTrace: number; + private readonly maxConcurrentExports: number; + private readonly flushGraceMs: number; + private readonly maxTraceAgeMs: number; + + private inFlightExports = 0; + private exportWaiters: Array<() => void> = []; + + constructor( + private readonly exporter: SpanExporter, + options?: PerRequestSpanProcessorOptions, + ) { + this.maxBufferedTraces = parseEnvInt( + process.env.A365_PER_REQUEST_MAX_TRACES, + options?.maxBufferedTraces ?? DEFAULT_MAX_BUFFERED_TRACES, + ); + this.maxSpansPerTrace = parseEnvInt( + process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE, + options?.maxSpansPerTrace ?? DEFAULT_MAX_SPANS_PER_TRACE, + ); + this.maxConcurrentExports = parseEnvInt( + process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS, + options?.maxConcurrentExports ?? DEFAULT_MAX_CONCURRENT_EXPORTS, + ); + this.flushGraceMs = parseEnvInt( + process.env.A365_PER_REQUEST_FLUSH_GRACE_MS, + options?.flushGraceMs ?? DEFAULT_FLUSH_GRACE_MS, + ); + this.maxTraceAgeMs = parseEnvInt( + process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS, + options?.maxTraceAgeMs ?? DEFAULT_MAX_TRACE_AGE_MS, + ); + } + + onStart(span: ReadableSpan, ctx: Context): void { + const traceId = span.spanContext().traceId; + let buf = this.traces.get(traceId); + if (!buf) { + if (this.traces.size >= this.maxBufferedTraces) { + logger.warn( + `[PerRequestSpanProcessor] Dropping new trace due to maxBufferedTraces=${this.maxBufferedTraces} traceId=${traceId}`, + ); + return; + } + + buf = { + spans: [], + openCount: 0, + rootEnded: false, + rootCtx: undefined, + startedAtMs: Date.now(), + droppedSpans: 0, + }; + this.traces.set(traceId, buf); + this.ensureSweepTimer(); + + logger.info( + `[PerRequestSpanProcessor] Trace started traceId=${traceId} maxTraceAgeMs=${this.maxTraceAgeMs}`, + ); + } + buf.openCount += 1; + + logger.info( + `[PerRequestSpanProcessor] Span start name=${span.name} traceId=${traceId} spanId=${span.spanContext().spanId}` + + ` root=${isRootSpan(span)} openCount=${buf.openCount}`, + ); + + // Capture a context to export under. + if (isRootSpan(span)) { + buf.rootCtx = ctx; + } else { + buf.rootCtx ??= ctx; + } + } + + onEnd(span: ReadableSpan): void { + const traceId = span.spanContext().traceId; + const buf = this.traces.get(traceId); + if (!buf) return; + + if (buf.spans.length >= this.maxSpansPerTrace) { + buf.droppedSpans += 1; + if (buf.droppedSpans === 1 || buf.droppedSpans % 100 === 0) { + logger.warn( + `[PerRequestSpanProcessor] Dropping ended span due to maxSpansPerTrace=${this.maxSpansPerTrace} ` + + `traceId=${traceId} droppedSpans=${buf.droppedSpans}`, + ); + } + } else { + buf.spans.push(span); + } + buf.openCount -= 1; + if (buf.openCount < 0) { + logger.warn( + `[PerRequestSpanProcessor] openCount underflow traceId=${traceId} spanId=${span.spanContext().spanId} resettingToZero`, + ); + buf.openCount = 0; + } + + logger.info( + `[PerRequestSpanProcessor] Span end name=${span.name} traceId=${traceId} spanId=${span.spanContext().spanId}` + + ` root=${isRootSpan(span)} openCount=${buf.openCount} rootEnded=${buf.rootEnded}`, + ); + + if (isRootSpan(span)) { + buf.rootEnded = true; + buf.rootEndedAtMs = Date.now(); + if (buf.openCount === 0) { + this.flushTrace(traceId, "trace_completed"); + } + } else if (buf.rootEnded && buf.openCount === 0) { + this.flushTrace(traceId, "trace_completed"); + } + } + + async forceFlush(): Promise { + await Promise.all([...this.traces.keys()].map((id) => this.flushTrace(id, "force_flush"))); + } + + async shutdown(): Promise { + await this.forceFlush(); + this.stopSweepTimerIfIdle(); + await this.exporter.shutdown?.(); + } + + private ensureSweepTimer(): void { + if (this.sweepTimer) return; + + const intervalMs = Math.max(10, Math.min(this.flushGraceMs, 250)); + this.sweepTimer = setInterval(() => { + void this.sweep(); + }, intervalMs); + + if (typeof this.sweepTimer === "object" && "unref" in this.sweepTimer) { + this.sweepTimer.unref(); + } + } + + private stopSweepTimerIfIdle(): void { + if (this.traces.size !== 0) return; + if (!this.sweepTimer) return; + clearInterval(this.sweepTimer); + this.sweepTimer = undefined; + } + + private async sweep(): Promise { + if (this.isSweeping) return; + this.isSweeping = true; + try { + if (this.traces.size === 0) { + this.stopSweepTimerIfIdle(); + return; + } + + const now = Date.now(); + const toFlush: Array<{ traceId: string; reason: FlushReason }> = []; + + for (const [traceId, trace] of this.traces.entries()) { + // 1) Max age safety flush + if (now - trace.startedAtMs >= this.maxTraceAgeMs) { + toFlush.push({ traceId, reason: "max_trace_age" }); + continue; + } + + // 2) Root ended grace window flush + if (trace.rootEnded && trace.openCount > 0 && trace.rootEndedAtMs) { + if (now - trace.rootEndedAtMs >= this.flushGraceMs) { + toFlush.push({ traceId, reason: "root_ended_grace" }); + } + } + } + + await Promise.all(toFlush.map((x) => this.flushTrace(x.traceId, x.reason))); + this.stopSweepTimerIfIdle(); + } finally { + this.isSweeping = false; + } + } + + private async flushTrace(traceId: string, reason: FlushReason): Promise { + const trace = this.traces.get(traceId); + if (!trace) return; + + this.traces.delete(traceId); + this.stopSweepTimerIfIdle(); + + const spans = trace.spans; + if (spans.length === 0) return; + + logger.info( + `[PerRequestSpanProcessor] Flushing trace traceId=${traceId} reason=${reason} spans=${spans.length} rootEnded=${trace.rootEnded}`, + ); + + if (!trace.rootCtx) { + logger.error( + `[PerRequestSpanProcessor] Missing rootCtx for trace ${traceId}, cannot export spans`, + ); + return; + } + + await this.acquireExportSlot(); + + try { + await new Promise((resolve) => { + try { + context.with(trace.rootCtx as Context, () => { + try { + this.exporter.export(spans, (result) => { + if (result.code !== 0) { + logger.error( + `[PerRequestSpanProcessor] Export failed traceId=${traceId} reason=${reason} code=${result.code}`, + result.error, + ); + } else { + logger.info( + `[PerRequestSpanProcessor] Export succeeded traceId=${traceId} reason=${reason} spans=${spans.length}`, + ); + } + resolve(); + }); + } catch (err) { + logger.error( + `[PerRequestSpanProcessor] Export threw traceId=${traceId} reason=${reason} spans=${spans.length}`, + err, + ); + resolve(); + } + }); + } catch (err) { + logger.error( + `[PerRequestSpanProcessor] context.with threw traceId=${traceId} reason=${reason}`, + err, + ); + resolve(); + } + }); + } finally { + this.releaseExportSlot(); + } + } + + private async acquireExportSlot(): Promise { + if (this.maxConcurrentExports <= 0) return; + if (this.inFlightExports < this.maxConcurrentExports) { + this.inFlightExports += 1; + return; + } + + await new Promise((resolve) => { + this.exportWaiters.push(() => { + this.inFlightExports += 1; + resolve(); + }); + }); + } + + private releaseExportSlot(): void { + if (this.maxConcurrentExports <= 0) return; + this.inFlightExports = Math.max(0, this.inFlightExports - 1); + const next = this.exportWaiters.shift(); + if (next) next(); + } +} diff --git a/src/a365/processors/index.ts b/src/a365/processors/index.ts new file mode 100644 index 00000000..361afd50 --- /dev/null +++ b/src/a365/processors/index.ts @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { A365SpanProcessor } from "./A365SpanProcessor.js"; +export { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from "./util.js"; +export { PerRequestSpanProcessor } from "./PerRequestSpanProcessor.js"; +export type { PerRequestSpanProcessorOptions } from "./PerRequestSpanProcessor.js"; diff --git a/src/a365/processors/util.ts b/src/a365/processors/util.ts new file mode 100644 index 00000000..60774737 --- /dev/null +++ b/src/a365/processors/util.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Attribute key sets used by the SpanProcessor to copy baggage entries to span attributes. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability/src/tracing/processors/util.ts + */ + +import { OpenTelemetryConstants as consts } from "../constants.js"; + +/** + * Generic / common tracing attributes applied to all spans. + */ +export const GENERIC_ATTRIBUTES: readonly string[] = [ + consts.TENANT_ID_KEY, + consts.CUSTOM_PARENT_SPAN_ID_KEY, + consts.CUSTOM_SPAN_NAME_KEY, + consts.SESSION_ID_KEY, + consts.GEN_AI_CONVERSATION_ID_KEY, + consts.GEN_AI_CONVERSATION_ITEM_LINK_KEY, + consts.GEN_AI_OPERATION_NAME_KEY, + consts.GEN_AI_AGENT_ID_KEY, + consts.GEN_AI_AGENT_NAME_KEY, + consts.GEN_AI_AGENT_DESCRIPTION_KEY, + consts.SESSION_DESCRIPTION_KEY, + consts.GEN_AI_AGENT_EMAIL_KEY, + consts.GEN_AI_AGENT_AUID_KEY, + consts.GEN_AI_AGENT_PLATFORM_ID_KEY, + consts.GEN_AI_AGENT_BLUEPRINT_ID_KEY, + consts.GEN_AI_AGENT_VERSION_KEY, + consts.SERVICE_NAME_KEY, + // Caller / Invoker attributes + consts.USER_ID_KEY, + consts.USER_NAME_KEY, + consts.USER_EMAIL_KEY, + consts.GEN_AI_CALLER_CLIENT_IP_KEY, + // Channel attributes + consts.CHANNEL_NAME_KEY, + consts.CHANNEL_LINK_KEY, +]; + +/** + * Invoke Agent-specific attributes. + * These are only applied to spans whose operation is `invoke_agent`. + */ +export const INVOKE_AGENT_ATTRIBUTES: readonly string[] = [ + // Caller Agent (A2A) attributes + consts.GEN_AI_CALLER_AGENT_ID_KEY, + consts.GEN_AI_CALLER_AGENT_NAME_KEY, + consts.GEN_AI_CALLER_AGENT_USER_ID_KEY, + consts.GEN_AI_CALLER_AGENT_EMAIL_KEY, + consts.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, + consts.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, + consts.GEN_AI_CALLER_AGENT_VERSION_KEY, +]; diff --git a/src/a365/scopes/ExecuteToolScope.ts b/src/a365/scopes/ExecuteToolScope.ts new file mode 100644 index 00000000..84b4b908 --- /dev/null +++ b/src/a365/scopes/ExecuteToolScope.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SpanKind } from "@opentelemetry/api"; +import { OpenTelemetryScope } from "./OpenTelemetryScope.js"; +import { OpenTelemetryConstants } from "../constants.js"; +import { safeSerializeToJson } from "../message-utils.js"; +import type { + ToolCallDetails, + AgentDetails, + UserDetails, + Request, + SpanDetails, +} from "../contracts.js"; + +/** + * Provides OpenTelemetry tracing scope for AI tool execution operations. + */ +export class ExecuteToolScope extends OpenTelemetryScope { + /** + * Creates and starts a new scope for tool execution tracing. + * + * @param request Request payload (channel, conversationId, content, sessionId). + * @param details The tool call details (name, type, args, call id, etc.). + * @param agentDetails The agent executing the tool. `tenantId` is required. + * @param userDetails Optional human caller identity. + * @param spanDetails Optional span configuration. Defaults to SpanKind.INTERNAL. + */ + public static start( + request: Request, + details: ToolCallDetails, + agentDetails: AgentDetails, + userDetails?: UserDetails, + spanDetails?: SpanDetails, + ): ExecuteToolScope { + return new ExecuteToolScope(request, details, agentDetails, userDetails, spanDetails); + } + + private constructor( + request: Request, + details: ToolCallDetails, + agentDetails: AgentDetails, + userDetails?: UserDetails, + spanDetails?: SpanDetails, + ) { + if (!agentDetails.tenantId) { + throw new Error("ExecuteToolScope: tenantId is required on agentDetails"); + } + + const resolvedSpanDetails: SpanDetails = { + spanKind: SpanKind.INTERNAL, + ...spanDetails, + }; + + super( + OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, + `${OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME} ${details.toolName}`, + agentDetails, + resolvedSpanDetails, + userDetails, + ); + + const { toolName, arguments: args, toolCallId, description, toolType, endpoint } = details; + + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_NAME_KEY, toolName); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY, + args != null ? safeSerializeToJson(args, "arguments") : undefined, + ); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY, toolType); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_CALL_ID_KEY, toolCallId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_DESCRIPTION_KEY, description); + + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); + + if (endpoint) { + this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, endpoint.host); + if (endpoint.port && endpoint.port !== 443) { + this.setTagMaybe(OpenTelemetryConstants.SERVER_PORT_KEY, endpoint.port); + } + } + } + + /** + * Records response information for telemetry tracking. + * Objects are serialized to JSON automatically. + */ + public recordResponse(response: Record | string): void { + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY, + safeSerializeToJson(response, "result"), + ); + } +} diff --git a/src/a365/scopes/InferenceScope.ts b/src/a365/scopes/InferenceScope.ts new file mode 100644 index 00000000..821ce5ca --- /dev/null +++ b/src/a365/scopes/InferenceScope.ts @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SpanKind } from "@opentelemetry/api"; +import { OpenTelemetryScope } from "./OpenTelemetryScope.js"; +import { OpenTelemetryConstants } from "../constants.js"; +import type { + InferenceDetails, + AgentDetails, + UserDetails, + Request, + SpanDetails, + InputMessagesParam, + OutputMessagesParam, +} from "../contracts.js"; + +/** + * Provides OpenTelemetry tracing scope for generative AI inference operations. + */ +export class InferenceScope extends OpenTelemetryScope { + /** + * Creates and starts a new scope for inference tracing. + * + * @param request Request payload (channel, conversationId, content, sessionId). + * @param details The inference call details (model, provider, tokens, etc.). + * @param agentDetails The agent performing the inference. `tenantId` is required. + * @param userDetails Optional human caller identity. + * @param spanDetails Optional span configuration. `spanKind` is always CLIENT. + */ + public static start( + request: Request, + details: InferenceDetails, + agentDetails: AgentDetails, + userDetails?: UserDetails, + spanDetails?: SpanDetails, + ): InferenceScope { + return new InferenceScope(request, details, agentDetails, userDetails, spanDetails); + } + + private constructor( + request: Request, + details: InferenceDetails, + agentDetails: AgentDetails, + userDetails?: UserDetails, + spanDetails?: SpanDetails, + ) { + if (!agentDetails.tenantId) { + throw new Error("InferenceScope: tenantId is required on agentDetails"); + } + + // spanKind for InferenceScope is always CLIENT + const resolvedSpanDetails: SpanDetails = { ...spanDetails, spanKind: SpanKind.CLIENT }; + + super( + details.operationName.toString(), + `${details.operationName} ${details.model}`, + agentDetails, + resolvedSpanDetails, + userDetails, + ); + + // Core inference information + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY, details.model); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, details.providerName); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY, details.inputTokens); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY, details.outputTokens); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_RESPONSE_FINISH_REASONS_KEY, + details.finishReasons, + ); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_AGENT_THOUGHT_PROCESS_KEY, + details.thoughtProcess, + ); + + // Conversation and channel + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); + + // Endpoint + if (details.endpoint) { + this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, details.endpoint.host); + if (details.endpoint.port && details.endpoint.port !== 443) { + this.setTagMaybe(OpenTelemetryConstants.SERVER_PORT_KEY, details.endpoint.port); + } + } + } + + /** Records the number of input tokens. */ + public recordInputTokens(inputTokens: number): void { + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY, inputTokens); + } + + /** Records the number of output tokens. */ + public recordOutputTokens(outputTokens: number): void { + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY, outputTokens); + } + + /** Records the finish reasons. */ + public recordFinishReasons(finishReasons: string[]): void { + if (finishReasons && finishReasons.length > 0) { + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_RESPONSE_FINISH_REASONS_KEY, finishReasons); + } + } + + /** Records the input messages for telemetry tracking. */ + public override recordInputMessages(messages: InputMessagesParam): void { + super.recordInputMessages(messages); + } + + /** Records the output messages for telemetry tracking. */ + public override recordOutputMessages(messages: OutputMessagesParam): void { + super.recordOutputMessages(messages); + } +} diff --git a/src/a365/scopes/InvokeAgentScope.ts b/src/a365/scopes/InvokeAgentScope.ts new file mode 100644 index 00000000..bcfda81b --- /dev/null +++ b/src/a365/scopes/InvokeAgentScope.ts @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SpanKind } from "@opentelemetry/api"; +import { OpenTelemetryScope } from "./OpenTelemetryScope.js"; +import { OpenTelemetryConstants } from "../constants.js"; +import type { + InvokeAgentScopeDetails, + CallerDetails, + Request, + SpanDetails, + AgentDetails, + InputMessagesParam, + OutputMessagesParam, +} from "../contracts.js"; + +/** + * Provides OpenTelemetry tracing scope for AI agent invocation operations. + */ +export class InvokeAgentScope extends OpenTelemetryScope { + /** + * Creates and starts a new scope for agent invocation tracing. + * + * @param request Request payload (channel, conversationId, content, sessionId). + * @param invokeScopeDetails Scope-level details (endpoint). + * @param agentDetails The agent identity. `tenantId` is required. + * @param callerDetails Optional caller information (human, agent, or both for A2A). + * @param spanDetails Optional span configuration. + */ + public static start( + request: Request, + invokeScopeDetails: InvokeAgentScopeDetails, + agentDetails: AgentDetails, + callerDetails?: CallerDetails, + spanDetails?: SpanDetails, + ): InvokeAgentScope { + return new InvokeAgentScope( + request, + invokeScopeDetails, + agentDetails, + callerDetails, + spanDetails, + ); + } + + private constructor( + request: Request, + invokeScopeDetails: InvokeAgentScopeDetails, + agentDetails: AgentDetails, + callerDetails?: CallerDetails, + spanDetails?: SpanDetails, + ) { + if (!agentDetails.tenantId) { + throw new Error("InvokeAgentScope: tenantId is required on agentDetails"); + } + + const resolvedSpanDetails: SpanDetails = { + ...spanDetails, + spanKind: spanDetails?.spanKind ?? SpanKind.CLIENT, + }; + + super( + OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, + agentDetails.agentName + ? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${agentDetails.agentName}` + : OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, + agentDetails, + resolvedSpanDetails, + callerDetails?.userDetails, + ); + + // Provider name + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, agentDetails.providerName); + + // Session ID + this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); + + // Endpoint + if (invokeScopeDetails.endpoint) { + this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, invokeScopeDetails.endpoint.host); + if (invokeScopeDetails.endpoint.port && invokeScopeDetails.endpoint.port !== 443) { + this.setTagMaybe(OpenTelemetryConstants.SERVER_PORT_KEY, invokeScopeDetails.endpoint.port); + } + } + + // Channel + if (request.channel) { + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel.name); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel.description); + } + + // Conversation ID + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); + + // Request content as input messages + if (request.content != null) { + this.recordInputMessages(request.content); + } + + // Caller agent details for A2A scenarios + const callerAgent = callerDetails?.callerAgentDetails; + if (callerAgent) { + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY, callerAgent.agentName); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, callerAgent.agentId); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, + callerAgent.agentBlueprintId, + ); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_USER_ID_KEY, + callerAgent.agentAUID, + ); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_EMAIL_KEY, + callerAgent.agentEmail, + ); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, + callerAgent.platformId, + ); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_VERSION_KEY, + callerAgent.agentVersion, + ); + } + } + + /** Records response information for telemetry tracking. */ + public recordResponse(response: string): void { + this.recordOutputMessages(response); + } + + /** Records the input messages for telemetry tracking. */ + public override recordInputMessages(messages: InputMessagesParam): void { + super.recordInputMessages(messages); + } + + /** Records the output messages for telemetry tracking. */ + public override recordOutputMessages(messages: OutputMessagesParam): void { + super.recordOutputMessages(messages); + } +} diff --git a/src/a365/scopes/OpenTelemetryScope.ts b/src/a365/scopes/OpenTelemetryScope.ts new file mode 100644 index 00000000..2b151538 --- /dev/null +++ b/src/a365/scopes/OpenTelemetryScope.ts @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Base class for OpenTelemetry tracing scopes. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts + */ + +import type { Span, SpanContext, AttributeValue, TimeInput } from "@opentelemetry/api"; +import { trace, SpanKind, SpanStatusCode, context } from "@opentelemetry/api"; +import { OpenTelemetryConstants } from "../constants.js"; +import type { + AgentDetails, + UserDetails, + SpanDetails, + InputMessagesParam, + OutputMessagesParam, +} from "../contracts.js"; +import { createContextWithParentSpanRef, isParentSpanRef } from "../context.js"; +import { + normalizeInputMessages, + normalizeOutputMessages, + serializeMessages, +} from "../message-utils.js"; +import { Logger } from "../../shared/logging/index.js"; + +/** + * Base class for OpenTelemetry tracing scopes. + * + * Subclasses: `InvokeAgentScope`, `ExecuteToolScope`, `InferenceScope`, `OutputScope`. + */ +export abstract class OpenTelemetryScope implements Disposable { + private static readonly tracer = trace.getTracer(OpenTelemetryConstants.SOURCE_NAME); + + protected readonly span: Span; + private readonly wallClockStartMs: number; + private customStartTime?: TimeInput; + private customEndTime?: TimeInput; + private errorType?: string; + private hasEnded = false; + private readonly logger = Logger.getInstance(); + + /** + * @param operationName The name of the operation being traced. + * @param spanName The display name of the span. + * @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, and span links. + * @param userDetails Optional human caller identity details. + */ + protected constructor( + operationName: string, + spanName: string, + agentDetails?: AgentDetails, + 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; + + let currentContext = context.active(); + if (parentContext) { + if (isParentSpanRef(parentContext)) { + currentContext = createContextWithParentSpanRef(currentContext, parentContext); + } else { + currentContext = parentContext; + } + } + + this.span = OpenTelemetryScope.tracer.startSpan( + spanName, + { + kind, + startTime, + links: spanLinks, + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: operationName, + }, + }, + currentContext, + ); + + this.wallClockStartMs = Date.now(); + if (startTime !== undefined) { + this.customStartTime = startTime; + } + this.customEndTime = endTime; + + // Set agent details + if (agentDetails) { + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, agentDetails.agentId); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, agentDetails.agentName); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, + agentDetails.agentDescription, + ); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_AGENT_PLATFORM_ID_KEY, + agentDetails.platformId, + ); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_ICON_URI_KEY, agentDetails.iconUri); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, agentDetails.agentAUID); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_EMAIL_KEY, agentDetails.agentEmail); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, + agentDetails.agentBlueprintId, + ); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY, agentDetails.agentVersion); + } + + // Set tenant ID + this.setTagMaybe(OpenTelemetryConstants.TENANT_ID_KEY, agentDetails?.tenantId); + + // Set caller details + if (userDetails) { + this.setTagMaybe(OpenTelemetryConstants.USER_ID_KEY, userDetails.userId); + this.setTagMaybe(OpenTelemetryConstants.USER_EMAIL_KEY, userDetails.userEmail); + this.setTagMaybe(OpenTelemetryConstants.USER_NAME_KEY, userDetails.userName); + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, + userDetails.callerClientIp, + ); + } + } + + /** Makes this span active for the duration of the async callback execution. */ + public withActiveSpanAsync(callback: () => Promise): Promise { + const newContext = trace.setSpan(context.active(), this.span); + return context.with(newContext, callback); + } + + /** Gets the span context for this scope. */ + public getSpanContext(): SpanContext { + return this.span.spanContext(); + } + + /** Records an error that occurred during the operation. */ + public recordError(error: Error): void { + if ("status" in error && typeof (error as Record).status === "number") { + this.errorType = String((error as Record).status); + } else { + this.errorType = error.constructor.name; + } + + this.span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + this.span.recordException(error); + } + + /** Records multiple attribute key/value pairs. */ + public recordAttributes( + attributes: + | Iterable<[string, AttributeValue]> + | Record + | null + | undefined, + ): void { + if (!attributes) return; + + if (Symbol.iterator in Object(attributes) && typeof attributes !== "string") { + for (const [key, value] of attributes as Iterable<[string, AttributeValue]>) { + if (key && typeof key === "string" && key.trim()) { + this.span.setAttribute(key, value); + } + } + } else if (typeof attributes === "object") { + for (const key of Object.keys(attributes as Record)) { + if (key && key.trim()) { + this.span.setAttribute(key, (attributes as Record)[key]); + } + } + } + } + + /** Records the input messages for telemetry tracking. */ + protected recordInputMessages(messages: InputMessagesParam): void { + const wrapper = normalizeInputMessages(messages); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, serializeMessages(wrapper)); + } + + /** Records the output messages for telemetry tracking. */ + protected recordOutputMessages(messages: OutputMessagesParam): void { + const wrapper = normalizeOutputMessages(messages); + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, serializeMessages(wrapper)); + } + + /** Sets a tag on the span if the value is not null or undefined. */ + protected setTagMaybe( + name: string, + value: T | null | undefined, + ): void { + if (value != null) { + this.span.setAttributes({ + [name]: value as string | number | boolean | string[] | number[], + }); + } + } + + /** + * Sets a custom end time for the scope. + * When set, `dispose()` will pass this value to `span.end()` instead of using wall-clock time. + */ + public setEndTime(endTime: TimeInput): void { + this.customEndTime = endTime; + } + + /** Records a cancellation event on the span. */ + public recordCancellation(reason?: string): void { + const message = reason ?? "Task was cancelled"; + this.span.setStatus({ code: SpanStatusCode.ERROR, message }); + this.errorType = OpenTelemetryConstants.ERROR_TYPE_CANCELLED; + } + + /** Converts a TimeInput value to milliseconds since epoch. */ + private static timeInputToMs(t: TimeInput): number { + if (typeof t === "number") return t; + if (t instanceof Date) return t.getTime(); + if (Array.isArray(t) && t.length === 2) return t[0] * 1000 + t[1] / 1_000_000; + return Date.now(); + } + + private end(): void { + if (this.hasEnded) return; + + const startMs = + this.customStartTime !== undefined + ? OpenTelemetryScope.timeInputToMs(this.customStartTime) + : this.wallClockStartMs; + const endMs = + this.customEndTime !== undefined + ? OpenTelemetryScope.timeInputToMs(this.customEndTime) + : Date.now(); + const durationMs = Math.max(0, endMs - startMs); + + if (this.errorType) { + this.span.setAttributes({ [OpenTelemetryConstants.ERROR_TYPE_KEY]: this.errorType }); + } + + this.hasEnded = true; + this.logger.info( + `[A365] Ending span[${this.span.spanContext().spanId}], duration: ${(durationMs / 1000).toFixed(3)}s`, + ); + } + + /** Disposes the scope and finalizes telemetry data collection. */ + public [Symbol.dispose](): void { + if (!this.hasEnded) { + this.end(); + if (this.customEndTime !== undefined) { + this.span.end(this.customEndTime); + } else { + this.span.end(); + } + } + } + + /** Legacy dispose method for compatibility. */ + public dispose(): void { + this[Symbol.dispose](); + } +} diff --git a/src/a365/scopes/OutputScope.ts b/src/a365/scopes/OutputScope.ts new file mode 100644 index 00000000..ca48f9ba --- /dev/null +++ b/src/a365/scopes/OutputScope.ts @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SpanKind } from "@opentelemetry/api"; +import { OpenTelemetryScope } from "./OpenTelemetryScope.js"; +import { OpenTelemetryConstants } from "../constants.js"; +import { normalizeOutputMessages, serializeMessages } from "../message-utils.js"; +import type { + AgentDetails, + UserDetails, + OutputResponse, + Request, + SpanDetails, + ResponseMessagesParam, +} from "../contracts.js"; +import { A365_MESSAGE_SCHEMA_VERSION } from "../contracts.js"; + +/** + * Provides OpenTelemetry tracing scope for output message tracing. + */ +export class OutputScope extends OpenTelemetryScope { + /** + * Creates and starts a new scope for output message tracing. + * + * @param request Request payload (channel, conversationId, content, sessionId). + * @param response The response containing initial output messages. + * @param agentDetails The agent producing the output. `tenantId` is required. + * @param userDetails Optional human caller identity details. + * @param spanDetails Optional span configuration. + */ + public static start( + request: Request, + response: OutputResponse, + agentDetails: AgentDetails, + userDetails?: UserDetails, + spanDetails?: SpanDetails, + ): OutputScope { + return new OutputScope(request, response, agentDetails, userDetails, spanDetails); + } + + private constructor( + request: Request, + response: OutputResponse, + agentDetails: AgentDetails, + userDetails?: UserDetails, + spanDetails?: SpanDetails, + ) { + if (!agentDetails.tenantId) { + throw new Error("OutputScope: tenantId is required on agentDetails"); + } + + // spanKind for OutputScope is always CLIENT + const resolvedSpanDetails: SpanDetails = { ...spanDetails, spanKind: SpanKind.CLIENT }; + + super( + OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME, + agentDetails.agentName + ? `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentName}` + : `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentId}`, + agentDetails, + resolvedSpanDetails, + userDetails, + ); + + // Set initial output messages + this._setOutput(response.messages); + + // Conversation and channel + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); + this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); + } + + /** + * Records the output messages for telemetry tracking. + * Overwrites any previously recorded output messages. + */ + public recordOutputMessages(messages: ResponseMessagesParam): void { + this._setOutput(messages); + } + + private _setOutput(messages: ResponseMessagesParam): void { + // Dict (Record) — treat as tool call result, serialize directly + if (this._isRawDict(messages)) { + try { + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, + JSON.stringify(messages), + ); + } catch { + this.setTagMaybe( + OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, + JSON.stringify({ error: "serialization failed" }), + ); + } + return; + } + const normalized = normalizeOutputMessages(messages); + const wrapper = { version: A365_MESSAGE_SCHEMA_VERSION, messages: normalized.messages }; + this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, serializeMessages(wrapper)); + } + + private _isRawDict(messages: ResponseMessagesParam): messages is Record { + return ( + typeof messages === "object" && + messages !== null && + !Array.isArray(messages) && + !("version" in messages && "messages" in messages) + ); + } +} diff --git a/src/a365/scopes/index.ts b/src/a365/scopes/index.ts new file mode 100644 index 00000000..d614a49c --- /dev/null +++ b/src/a365/scopes/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { OpenTelemetryScope } from "./OpenTelemetryScope.js"; +export { InvokeAgentScope } from "./InvokeAgentScope.js"; +export { ExecuteToolScope } from "./ExecuteToolScope.js"; +export { InferenceScope } from "./InferenceScope.js"; +export { OutputScope } from "./OutputScope.js"; diff --git a/src/distro/distro.ts b/src/distro/distro.ts index 2b957098..6350d812 100644 --- a/src/distro/distro.ts +++ b/src/distro/distro.ts @@ -39,7 +39,7 @@ let disposeAzureMonitor: (() => void) | undefined; * providers and instrumentations, then attaches the configured exporters: * - Azure Monitor (when `options.azureMonitor` is provided) * - OTLP HTTP (when `OTEL_EXPORTER_OTLP_ENDPOINT` is set) - * - A365 (when `options.a365.enabled` is true or `MICROSOFT_OTEL_A365_EXPORTER_ENABLED=true`) + * - A365 (when `options.a365.enabled` is true or `ENABLE_A365_OBSERVABILITY_EXPORTER=true`) * * @param options - Microsoft OpenTelemetry configuration options */ diff --git a/src/index.ts b/src/index.ts index 8d2be20f..8452e72b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,60 @@ export type { export { A365Configuration } from "./a365/index.js"; export type { ClusterCategory, A365BaggageOptions, A365HostingOptions } from "./a365/index.js"; +// ── Re-exports from A365 scopes (manual telemetry API) ────────────────────── +export { + OpenTelemetryScope, + InvokeAgentScope, + ExecuteToolScope, + InferenceScope, + OutputScope, + OpenTelemetryConstants, + MessageRole, + FinishReason, + InferenceOperationType, + isParentSpanRef, + createContextWithParentSpanRef, + runWithParentSpanRef, + injectContextToHeaders, + extractContextFromHeaders, + runWithExtractedTraceContext, + BaggageBuilder, + BaggageScope, + A365SpanProcessor, + PerRequestSpanProcessor, + GENERIC_ATTRIBUTES, + INVOKE_AGENT_ATTRIBUTES, + runWithExportToken, + updateExportToken, + getExportToken, +} from "./a365/index.js"; +export type { + AgentDetails, + UserDetails, + CallerDetails, + Request as A365Request, + Channel, + ServiceEndpoint, + InvokeAgentScopeDetails, + ToolCallDetails, + InferenceDetails, + InferenceResponse, + OutputResponse, + SpanDetails as A365SpanDetails, + ParentSpanRef, + ParentContext, + ChatMessage, + InputMessages, + OutputMessage, + OutputMessages, + InputMessagesParam, + OutputMessagesParam, + ResponseMessagesParam, + MessagePart, + HeadersCarrier, +} from "./a365/index.js"; +export type { PerRequestSpanProcessorOptions } from "./a365/index.js"; + // ── Re-exports from types ─────────────────────────────────────────────────── export type { OpenAIAgentsInstrumentationConfig, LangChainInstrumentationConfig } from "./types.js"; diff --git a/test/internal/unit/a365/a365Configuration.test.ts b/test/internal/unit/a365/a365Configuration.test.ts index f3060dc4..59a3a37d 100644 --- a/test/internal/unit/a365/a365Configuration.test.ts +++ b/test/internal/unit/a365/a365Configuration.test.ts @@ -111,8 +111,8 @@ describe("A365Configuration", () => { assert.strictEqual(config.perRequestExport, true); }); - it("should override auth scopes from env (comma-separated)", () => { - process.env[A365_ENV_VARS.AUTH_SCOPES] = "scope1, scope2, scope3"; + it("should override auth scopes from env (space-separated)", () => { + process.env[A365_ENV_VARS.AUTH_SCOPES] = "scope1 scope2 scope3"; const config = new A365Configuration(); assert.deepStrictEqual(config.authScopes, ["scope1", "scope2", "scope3"]); }); @@ -249,14 +249,14 @@ describe("A365Configuration", () => { describe("env var constants", () => { it("should have correct env var names", () => { - assert.strictEqual(A365_ENV_VARS.EXPORTER_ENABLED, "MICROSOFT_OTEL_A365_EXPORTER_ENABLED"); + assert.strictEqual(A365_ENV_VARS.EXPORTER_ENABLED, "ENABLE_A365_OBSERVABILITY_EXPORTER"); assert.strictEqual( A365_ENV_VARS.PER_REQUEST_EXPORT, - "MICROSOFT_OTEL_A365_PER_REQUEST_EXPORT", + "ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT", ); - assert.strictEqual(A365_ENV_VARS.AUTH_SCOPES, "MICROSOFT_OTEL_A365_AUTH_SCOPES"); - assert.strictEqual(A365_ENV_VARS.DOMAIN, "MICROSOFT_OTEL_A365_DOMAIN"); - assert.strictEqual(A365_ENV_VARS.CLUSTER_CATEGORY, "MICROSOFT_OTEL_A365_CLUSTER_CATEGORY"); + assert.strictEqual(A365_ENV_VARS.AUTH_SCOPES, "A365_OBSERVABILITY_SCOPES_OVERRIDE"); + assert.strictEqual(A365_ENV_VARS.DOMAIN, "A365_OBSERVABILITY_DOMAIN_OVERRIDE"); + assert.strictEqual(A365_ENV_VARS.CLUSTER_CATEGORY, "CLUSTER_CATEGORY"); }); }); }); diff --git a/test/internal/unit/a365/a365SpanProcessor.test.ts b/test/internal/unit/a365/a365SpanProcessor.test.ts new file mode 100644 index 00000000..a99c95a2 --- /dev/null +++ b/test/internal/unit/a365/a365SpanProcessor.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { context, propagation, SpanKind } from "@opentelemetry/api"; +import type { Span } from "@opentelemetry/api"; +import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; + +import { + A365SpanProcessor, + OpenTelemetryConstants, + GENERIC_ATTRIBUTES, + INVOKE_AGENT_ATTRIBUTES, +} from "../../../../src/a365/index.js"; + +describe("A365SpanProcessor", () => { + let provider: BasicTracerProvider; + let processor: A365SpanProcessor; + + beforeEach(() => { + processor = new A365SpanProcessor(); + provider = new BasicTracerProvider({ + spanProcessors: [processor], + }); + }); + + afterEach(async () => { + await provider.shutdown(); + }); + + describe("baggage to span attribute enrichment", () => { + it("should copy generic attributes from baggage to span", () => { + const baggageEntries = { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: "agent-789", + }; + + let baggage = propagation.createBaggage(); + for (const [key, value] of Object.entries(baggageEntries)) { + baggage = baggage.setEntry(key, { value }); + } + + const ctx = propagation.setBaggage(context.active(), baggage); + + const tracer = provider.getTracer("test"); + let testSpan: Span | undefined; + + context.with(ctx, () => { + testSpan = tracer.startSpan("test-span", { kind: SpanKind.CLIENT }); + if (testSpan) { + testSpan.end(); + } + }); + + expect(testSpan).toBeDefined(); + }); + + it("should copy sessionId from baggage to span", () => { + let baggage = propagation.createBaggage(); + baggage = baggage.setEntry(OpenTelemetryConstants.SESSION_ID_KEY, { + value: "session-abc", + }); + + const ctx = propagation.setBaggage(context.active(), baggage); + const tracer = provider.getTracer("test"); + const testSpan = tracer.startSpan("test-span", { kind: SpanKind.CLIENT }, ctx); + testSpan.end(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const attrs = (testSpan as any)._attributes ?? (testSpan as any).attributes ?? {}; + expect(attrs[OpenTelemetryConstants.SESSION_ID_KEY]).toBe("session-abc"); + }); + + it("should copy sessionDescription from baggage to span", () => { + let baggage = propagation.createBaggage(); + baggage = baggage.setEntry(OpenTelemetryConstants.SESSION_DESCRIPTION_KEY, { + value: "Test session description", + }); + + const ctx = propagation.setBaggage(context.active(), baggage); + const tracer = provider.getTracer("test"); + const testSpan = tracer.startSpan("test-span", { kind: SpanKind.CLIENT }, ctx); + testSpan.end(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const attrs = (testSpan as any)._attributes ?? (testSpan as any).attributes ?? {}; + expect(attrs[OpenTelemetryConstants.SESSION_DESCRIPTION_KEY]).toBe( + "Test session description", + ); + }); + + it("should copy invoke agent attributes for invoke_agent operations", () => { + const baggageEntries = { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: + OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.USER_ID_KEY]: "caller-456", + }; + + let baggage = propagation.createBaggage(); + for (const [key, value] of Object.entries(baggageEntries)) { + baggage = baggage.setEntry(key, { value }); + } + + const ctx = propagation.setBaggage(context.active(), baggage); + + const tracer = provider.getTracer("test"); + let testSpan: Span | undefined; + + context.with(ctx, () => { + testSpan = tracer.startSpan("invoke_agent test", { + kind: SpanKind.CLIENT, + }); + if (testSpan) { + testSpan.end(); + } + }); + + expect(testSpan).toBeDefined(); + }); + + it("should not overwrite existing span attributes", () => { + let baggage = propagation.createBaggage(); + baggage = baggage.setEntry(OpenTelemetryConstants.TENANT_ID_KEY, { + value: "tenant-from-baggage", + }); + + const ctx = propagation.setBaggage(context.active(), baggage); + + const tracer = provider.getTracer("test"); + let testSpan: Span | undefined; + + context.with(ctx, () => { + testSpan = tracer.startSpan("test-span", { + kind: SpanKind.CLIENT, + attributes: { + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-existing", + }, + }); + if (testSpan) { + testSpan.end(); + } + }); + + expect(testSpan).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const attrs = (testSpan as any)._attributes ?? (testSpan as any).attributes ?? {}; + expect(attrs[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-existing"); + }); + + it("should ignore empty baggage values", () => { + let baggage = propagation.createBaggage(); + baggage = baggage.setEntry(OpenTelemetryConstants.TENANT_ID_KEY, { value: "" }); + + const ctx = propagation.setBaggage(context.active(), baggage); + + const tracer = provider.getTracer("test"); + let testSpan: Span | undefined; + + context.with(ctx, () => { + testSpan = tracer.startSpan("test-span", { kind: SpanKind.CLIENT }); + if (testSpan) { + testSpan.end(); + } + }); + + expect(testSpan).toBeDefined(); + }); + + it("should set telemetry SDK attributes", () => { + const baggage = propagation.createBaggage(); + const ctx = propagation.setBaggage(context.active(), baggage); + const tracer = provider.getTracer("test"); + const testSpan = tracer.startSpan("test-span", { kind: SpanKind.CLIENT }, ctx); + testSpan.end(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const attrs = (testSpan as any)._attributes ?? (testSpan as any).attributes ?? {}; + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_NAME_VALUE, + ); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_VALUE, + ); + expect(attrs[OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY]).toBe( + OpenTelemetryConstants.TELEMETRY_SDK_VERSION_VALUE, + ); + }); + }); + + describe("attribute registry application", () => { + it("should apply all generic attributes", () => { + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.TENANT_ID_KEY); + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY); + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.SESSION_ID_KEY); + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.USER_ID_KEY); + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.USER_NAME_KEY); + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.USER_EMAIL_KEY); + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY); + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_AGENT_EMAIL_KEY); + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.CHANNEL_NAME_KEY); + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.CHANNEL_LINK_KEY); + expect(GENERIC_ATTRIBUTES).not.toContain("correlation.id"); + }); + + it("should apply invoke agent specific attributes", () => { + expect(INVOKE_AGENT_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY); + expect(INVOKE_AGENT_ATTRIBUTES).toContain( + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_EMAIL_KEY, + ); + expect(INVOKE_AGENT_ATTRIBUTES).toContain( + OpenTelemetryConstants.GEN_AI_CALLER_AGENT_VERSION_KEY, + ); + }); + + it("should include blueprint ID and agent version in generic attributes", () => { + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY); + expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY); + }); + }); + + describe("processor lifecycle", () => { + it("should shutdown gracefully", async () => { + await expect(processor.shutdown()).resolves.toBeUndefined(); + }); + + it("should force flush gracefully", async () => { + await expect(processor.forceFlush()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/test/internal/unit/a365/baggageBuilder.test.ts b/test/internal/unit/a365/baggageBuilder.test.ts new file mode 100644 index 00000000..53bb25da --- /dev/null +++ b/test/internal/unit/a365/baggageBuilder.test.ts @@ -0,0 +1,343 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { context, propagation } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { + BaggageBuilder, + BaggageScope, + OpenTelemetryConstants, +} from "../../../../src/a365/index.js"; + +describe("BaggageBuilder", () => { + let contextManager: AsyncLocalStorageContextManager; + + beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + context.setGlobalContextManager(contextManager); + }); + + afterAll(() => { + contextManager.disable(); + context.disable(); + }); + + describe("fluent setters", () => { + it("should set tenant ID", () => { + const builder = new BaggageBuilder(); + const result = builder.tenantId("tenant-123"); + expect(result).toBe(builder); // Fluent API + + const scope = builder.build(); + expect(scope).toBeInstanceOf(BaggageScope); + }); + + it("should set agent ID", () => { + const builder = new BaggageBuilder(); + builder.agentId("agent-456"); + + const scope = builder.build(); + expect(scope).toBeInstanceOf(BaggageScope); + }); + + it("should chain multiple setters", () => { + const builder = new BaggageBuilder() + .tenantId("tenant-123") + .agentId("agent-456") + .agentName("TestAgent") + .agentPlatformId("platform-xyz-123") + .conversationId("conv-001"); + + const scope = builder.build(); + expect(scope).toBeInstanceOf(BaggageScope); + }); + + it("should set agent platform ID", () => { + const builder = new BaggageBuilder(); + builder.agentPlatformId("platform-abc-456"); + + const scope = builder.build(); + expect(scope).toBeInstanceOf(BaggageScope); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bag = propagation.getBaggage((scope as any).contextWithBaggage); + expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_AGENT_PLATFORM_ID_KEY)?.value).toBe( + "platform-abc-456", + ); + }); + + it("should set caller agent platform ID via fluent API", () => { + const builder = new BaggageBuilder(); + builder.callerAgentPlatformId("caller-platform-xyz"); + + const scope = builder.build(); + expect(scope).toBeInstanceOf(BaggageScope); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bag = propagation.getBaggage((scope as any).contextWithBaggage); + expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY)?.value).toBe( + "caller-platform-xyz", + ); + }); + + it.each([["agentVersion", "1.0.0", OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY]] as const)( + "%s should set the correct baggage key", + (method, value, expectedKey) => { + const builder = new BaggageBuilder(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (builder as any)[method](value); + const scope = builder.build(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bag = propagation.getBaggage((scope as any).contextWithBaggage); + expect(bag?.getEntry(expectedKey)?.value).toBe(value); + }, + ); + + it.each([ + ["agentVersion", null], + ["agentVersion", " "], + ] as const)("%s should ignore %s", (method, value) => { + const builder = new BaggageBuilder(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (builder as any)[method](value); + const scope = builder.build(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bag = propagation.getBaggage((scope as any).contextWithBaggage); + expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY)).toBeUndefined(); + }); + }); + + describe("setPairs", () => { + it("should accept dictionary of pairs", () => { + const builder = new BaggageBuilder(); + builder.setPairs({ + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: "agent-456", + }); + + const scope = builder.build(); + expect(scope).toBeInstanceOf(BaggageScope); + }); + + it("should accept iterable of pairs", () => { + const builder = new BaggageBuilder(); + const pairs: Array<[string, string]> = [ + [OpenTelemetryConstants.TENANT_ID_KEY, "tenant-123"], + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, "agent-456"], + [OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, "10.0.0.5"], + ]; + builder.setPairs(pairs); + + const scope = builder.build(); + expect(scope).toBeInstanceOf(BaggageScope); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bag = propagation.getBaggage((scope as any).contextWithBaggage); + expect(bag?.getEntry(OpenTelemetryConstants.TENANT_ID_KEY)?.value).toBe("tenant-123"); + expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY)?.value).toBe("agent-456"); + expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY)?.value).toBe( + "10.0.0.5", + ); + }); + + it("should ignore null values", () => { + const builder = new BaggageBuilder(); + builder.setPairs({ + [OpenTelemetryConstants.TENANT_ID_KEY]: "tenant-123", + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: null, + }); + + const scope = builder.build(); + expect(scope).toBeInstanceOf(BaggageScope); + }); + }); + + describe("null and whitespace handling", () => { + it("should ignore null values", () => { + const builder = new BaggageBuilder(); + builder.tenantId(null); + builder.agentId(undefined); + + const scope = builder.build(); + expect(scope).toBeInstanceOf(BaggageScope); + }); + + it("should ignore whitespace-only values", () => { + const builder = new BaggageBuilder(); + builder.tenantId(" "); + builder.agentId("\t\n"); + + const scope = builder.build(); + expect(scope).toBeInstanceOf(BaggageScope); + }); + + it("should trim values", () => { + const builder = new BaggageBuilder(); + builder.tenantId(" tenant-123 "); + + const scope = builder.build(); + expect(scope).toBeInstanceOf(BaggageScope); + }); + }); + + describe("operationSource, channelName, and channelLink", () => { + it.each([ + ["operationSource", "ATG", OpenTelemetryConstants.SERVICE_NAME_KEY], + ["channelName", "teams", OpenTelemetryConstants.CHANNEL_NAME_KEY], + ["channelLink", "https://teams/channel", OpenTelemetryConstants.CHANNEL_LINK_KEY], + ] as const)("%s should set the correct baggage key", (method, value, expectedKey) => { + const builder = new BaggageBuilder(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (builder as any)[method](value); + const scope = builder.build(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bag = propagation.getBaggage((scope as any).contextWithBaggage); + expect(bag?.getEntry(expectedKey)?.value).toBe(value); + }); + }); + + 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(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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(); + builder.invokeAgentServer("api.example.com", 8080); + builder.invokeAgentServer("api.example.com", 443); + const scope = builder.build(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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("tenant-123", "agent-456"); + expect(scope).toBeInstanceOf(BaggageScope); + }); + + it("should handle null values", () => { + const scope = BaggageBuilder.setRequestContext(null, "agent-456"); + expect(scope).toBeInstanceOf(BaggageScope); + }); + }); + + describe("sessionId support", () => { + it("should set sessionId via fluent API", () => { + const scope = new BaggageBuilder() + .tenantId("tenant-123") + .agentId("agent-456") + .sessionId("session-0001") + .sessionDescription("My session desc") + .build(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bag = propagation.getBaggage((scope as any).contextWithBaggage); + expect(bag?.getEntry(OpenTelemetryConstants.SESSION_ID_KEY)?.value).toBe("session-0001"); + expect(bag?.getEntry(OpenTelemetryConstants.SESSION_DESCRIPTION_KEY)?.value).toBe( + "My session desc", + ); + }); + + it("should omit empty sessionId value", () => { + const scope = new BaggageBuilder().sessionId(" ").build(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bag = propagation.getBaggage((scope as any).contextWithBaggage); + expect(bag?.getEntry(OpenTelemetryConstants.SESSION_ID_KEY)).toBeUndefined(); + }); + + it("should omit null sessionDescription value", () => { + const scope = new BaggageBuilder().sessionDescription(null).build(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bag = propagation.getBaggage((scope as any).contextWithBaggage); + expect(bag?.getEntry(OpenTelemetryConstants.SESSION_DESCRIPTION_KEY)).toBeUndefined(); + }); + }); +}); + +describe("BaggageScope", () => { + let contextManager: AsyncLocalStorageContextManager; + + beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + context.setGlobalContextManager(contextManager); + }); + + afterAll(() => { + contextManager.disable(); + context.disable(); + }); + + describe("run method", () => { + it("should execute function with baggage context", () => { + const builder = new BaggageBuilder().tenantId("tenant-123").agentId("agent-456"); + + const scope = builder.build(); + let executed = false; + + const result = scope.run(() => { + executed = true; + return "test-result"; + }); + + expect(executed).toBe(true); + expect(result).toBe("test-result"); + }); + + it("should restore context after execution", () => { + const _originalContext = context.active(); + + const scope = new BaggageBuilder().tenantId("tenant-123").build(); + + scope.run(() => { + const currentContext = context.active(); + expect(currentContext).toBeDefined(); + }); + + const restoredContext = context.active(); + expect(restoredContext).toBeDefined(); + }); + }); + + describe("disposable pattern", () => { + it("should implement Symbol.dispose", () => { + const scope = new BaggageBuilder().tenantId("tenant-123").build(); + expect(typeof scope[Symbol.dispose]).toBe("function"); + expect(() => scope[Symbol.dispose]()).not.toThrow(); + }); + + it("should implement dispose method", () => { + const scope = new BaggageBuilder().tenantId("tenant-123").build(); + expect(typeof scope.dispose).toBe("function"); + expect(() => scope.dispose()).not.toThrow(); + }); + }); +}); diff --git a/test/internal/unit/a365/contextPropagation.test.ts b/test/internal/unit/a365/contextPropagation.test.ts new file mode 100644 index 00000000..1c2a87b2 --- /dev/null +++ b/test/internal/unit/a365/contextPropagation.test.ts @@ -0,0 +1,576 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { trace, context as otelContext, propagation, TraceFlags } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { W3CTraceContextPropagator } from "@opentelemetry/core"; + +import { + InvokeAgentScope, + InferenceScope, + ExecuteToolScope, +} from "../../../../src/a365/scopes/index.js"; +import { + injectContextToHeaders, + extractContextFromHeaders, + runWithExtractedTraceContext, + runWithParentSpanRef, + isParentSpanRef, +} from "../../../../src/a365/context.js"; +import { InferenceOperationType } from "../../../../src/a365/contracts.js"; +import type { + ParentSpanRef, + InferenceDetails, + ToolCallDetails, + AgentDetails, +} from "../../../../src/a365/contracts.js"; + +// File-level OTel setup — shared by all describe blocks. +let provider: BasicTracerProvider; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let flushProvider: any; +let exporter: InMemorySpanExporter; +let contextManager: AsyncLocalStorageContextManager; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); + propagation.setGlobalPropagator(new W3CTraceContextPropagator()); + + 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); + flushProvider = globalProvider; + } else { + provider = new BasicTracerProvider({ spanProcessors: [processor] }); + trace.setGlobalTracerProvider(provider); + flushProvider = provider; + } +}); + +beforeEach(() => exporter.reset()); + +afterAll(async () => { + exporter.reset(); + await provider?.shutdown?.(); + contextManager.disable(); + otelContext.disable(); +}); + +describe("Trace Context Propagation", () => { + describe("injectContextToHeaders", () => { + it("should inject traceparent header from active span", () => { + const tracer = trace.getTracer("test"); + const span = tracer.startSpan("sender"); + const { traceId, spanId, traceFlags } = span.spanContext(); + const ctx = trace.setSpan(otelContext.active(), span); + + otelContext.with(ctx, () => { + const headers: Record = {}; + const result = injectContextToHeaders(headers); + expect(result).toBe(headers); + + const traceparent = headers["traceparent"]; + expect(traceparent).toBeDefined(); + + // W3C traceparent format: {version}-{trace-id}-{parent-id}-{trace-flags} + const parts = traceparent.split("-"); + expect(parts).toHaveLength(4); + expect(parts[0]).toBe("00"); // version + expect(parts[1]).toBe(traceId); + expect(parts[2]).toBe(spanId); + expect(parts[3]).toBe(traceFlags.toString(16).padStart(2, "0")); + }); + + span.end(); + }); + + it("should be a no-op when no active span exists", () => { + const headers: Record = {}; + injectContextToHeaders(headers); + expect(headers["traceparent"]).toBeUndefined(); + }); + }); + + describe("extractContextFromHeaders", () => { + it("should extract valid traceparent into Context with correct traceId/spanId", () => { + const traceId = "0af7651916cd43dd8448eb211c80319c"; + const spanId = "b7ad6b7169203331"; + + const extracted = extractContextFromHeaders({ + traceparent: `00-${traceId}-${spanId}-01`, + }); + const span = trace.getSpan(extracted); + + expect(span).toBeDefined(); + expect(span!.spanContext().traceId).toBe(traceId); + expect(span!.spanContext().spanId).toBe(spanId); + expect(span!.spanContext().traceFlags).toBe(TraceFlags.SAMPLED); + }); + + it("should return base context for missing or malformed headers", () => { + expect(trace.getSpan(extractContextFromHeaders({}))).toBeUndefined(); + expect(trace.getSpan(extractContextFromHeaders({ traceparent: "invalid" }))).toBeUndefined(); + }); + }); + + describe("end-to-end inject → extract round-trip", () => { + it("should preserve trace and parent-child relationship across services", async () => { + const tracer = trace.getTracer("test"); + const senderSpan = tracer.startSpan("sender"); + const senderCtx = trace.setSpan(otelContext.active(), senderSpan); + + const headers: Record = {}; + otelContext.with(senderCtx, () => injectContextToHeaders(headers)); + + const result = runWithExtractedTraceContext(headers, () => { + const child = tracer.startSpan("receiver"); + expect(child.spanContext().traceId).toBe(senderSpan.spanContext().traceId); + child.end(); + return "ok"; + }); + expect(result).toBe("ok"); + + senderSpan.end(); + await flushProvider.forceFlush(); + + const spans = exporter.getFinishedSpans(); + const receiver = spans.find((s) => s.name === "receiver"); + expect(receiver!.spanContext().traceId).toBe(senderSpan.spanContext().traceId); + expect(receiver!.parentSpanContext?.spanId).toBe(senderSpan.spanContext().spanId); + }); + }); + + describe("scope with extracted Context as ParentContext", () => { + it("should create scope as child of extracted trace context", async () => { + const traceId = "0af7651916cd43dd8448eb211c80319c"; + const spanId = "b7ad6b7169203331"; + const extractedCtx = extractContextFromHeaders({ + traceparent: `00-${traceId}-${spanId}-01`, + }); + + const scope = InvokeAgentScope.start( + {}, + {}, + { agentId: "ctx-agent", tenantId: "test-tenant" }, + undefined, + { parentContext: extractedCtx }, + ); + expect(scope.getSpanContext().traceId).toBe(traceId); + scope.dispose(); + + await flushProvider.forceFlush(); + + const span = exporter + .getFinishedSpans() + .find((s) => s.name.toLowerCase().includes("invoke_agent")); + expect(span!.parentSpanContext?.spanId).toBe(spanId); + }); + }); + + describe("ParentSpanRef with isRemote", () => { + it("should propagate isRemote=true to child span parentSpanContext", async () => { + const parentRef: ParentSpanRef = { + traceId: "3af7651916cd43dd8448eb211c80319c", + spanId: "e7ad6b7169203331", + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + }; + + const scope = InvokeAgentScope.start( + {}, + {}, + { agentId: "remote-agent", tenantId: "test-tenant" }, + undefined, + { parentContext: parentRef }, + ); + scope.dispose(); + + await flushProvider.forceFlush(); + + const span = exporter + .getFinishedSpans() + .find((s) => s.spanContext().traceId === parentRef.traceId); + expect(span!.parentSpanContext?.spanId).toBe(parentRef.spanId); + expect(span!.parentSpanContext?.isRemote).toBe(true); + }); + }); +}); + +describe("ParentSpanRef - Explicit Parent Span Support", () => { + const testAgentDetails: AgentDetails = { + agentId: "test-agent", + agentName: "Test Agent", + agentDescription: "A test agent", + tenantId: "test-tenant-456", + }; + + const testRequest = { + conversationId: "test-conv-psr", + channel: { name: "PSRChannel", description: "https://psr.channel" }, + }; + + describe("runWithParentSpanRef", () => { + it("should execute callback with parent span context", () => { + const parentRef: ParentSpanRef = { + traceId: "0123456789abcdef0123456789abcdef", + spanId: "0123456789abcdef", + }; + + let executed = false; + const result = runWithParentSpanRef(parentRef, () => { + executed = true; + return "test-result"; + }); + + expect(executed).toBe(true); + expect(result).toBe("test-result"); + }); + }); + + it.each([ + [ + "InvokeAgentScope", + (parentRef: ParentSpanRef) => { + return InvokeAgentScope.start( + testRequest, + {}, + { agentId: "test-agent", agentName: "Test Agent", tenantId: "test-tenant-456" }, + undefined, + { parentContext: parentRef }, + ); + }, + (name: string) => + name.toLowerCase().includes("invokeagent") || name.toLowerCase().includes("invoke_agent"), + ], + [ + "InferenceScope", + (parentRef: ParentSpanRef) => { + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: "gpt-4", + providerName: "openai", + }; + return InferenceScope.start(testRequest, inferenceDetails, testAgentDetails, undefined, { + parentContext: parentRef, + }); + }, + (name: string) => name.toLowerCase().includes("chat"), + ], + [ + "ExecuteToolScope", + (parentRef: ParentSpanRef) => { + const toolDetails: ToolCallDetails = { + toolName: "test-tool", + arguments: '{"param": "value"}', + }; + return ExecuteToolScope.start(testRequest, toolDetails, testAgentDetails, undefined, { + parentContext: parentRef, + }); + }, + (name: string) => name.toLowerCase().includes("execute_tool"), + ], + ])( + "should create a child span with correct parent relationship (%s)", + async (_label, createScope, nameMatches) => { + const tracer = trace.getTracer("test"); + const rootSpan = tracer.startSpan("root-span"); + const parentSpanContext = rootSpan.spanContext(); + + const parentRef: ParentSpanRef = { + traceId: parentSpanContext.traceId, + spanId: parentSpanContext.spanId, + }; + + const baseCtx = trace.setSpan(otelContext.active(), rootSpan); + await otelContext.with(baseCtx, async () => { + const childScope = createScope(parentRef); + expect(childScope.getSpanContext().traceId).toBe(parentSpanContext.traceId); + childScope.dispose(); + }); + + rootSpan.end(); + + await flushProvider.forceFlush(); + + const spans = exporter.getFinishedSpans(); + const childSpan = spans.find((s) => nameMatches(s.name)); + expect(childSpan).toBeDefined(); + expect(childSpan!.spanContext().traceId).toBe(parentSpanContext.traceId); + expect(childSpan!.parentSpanContext?.spanId).toBe(parentSpanContext.spanId); + }, + ); + + describe("runWithParentSpanRef with nested scope creation", () => { + it("should correctly parent spans created inside runWithParentSpanRef", async () => { + const tracer = trace.getTracer("test"); + const rootSpan = tracer.startSpan("root-span"); + const parentSpanContext = rootSpan.spanContext(); + + const parentRef: ParentSpanRef = { + traceId: parentSpanContext.traceId, + spanId: parentSpanContext.spanId, + }; + + const baseCtx = trace.setSpan(otelContext.active(), rootSpan); + await otelContext.with(baseCtx, async () => { + runWithParentSpanRef(parentRef, () => { + const nestedScope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "nested-agent", + tenantId: "test-tenant-456", + }, + ); + + const nestedSpanContext = nestedScope.getSpanContext(); + expect(nestedSpanContext.traceId).toBe(parentSpanContext.traceId); + + nestedScope.dispose(); + }); + }); + + rootSpan.end(); + + await flushProvider.forceFlush(); + + const spans = exporter.getFinishedSpans(); + const nestedSpan = spans.find((s) => s.name.includes("invoke_agent")); + expect(nestedSpan).toBeDefined(); + expect(nestedSpan!.spanContext().traceId).toBe(parentSpanContext.traceId); + expect(nestedSpan!.parentSpanContext?.spanId).toBe(parentSpanContext.spanId); + }); + }); + + describe("getSpanContext method", () => { + it("should return the span context from a scope (and be usable as ParentSpanRef)", async () => { + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "test-agent", + tenantId: "test-tenant-456", + }, + ); + const spanContext = scope.getSpanContext(); + + expect(spanContext).toBeDefined(); + expect(spanContext.traceId).toBeDefined(); + expect(spanContext.spanId).toBeDefined(); + expect(spanContext.traceId.length).toBe(32); // 32 hex chars + expect(spanContext.spanId.length).toBe(16); // 16 hex chars + + const parentRef: ParentSpanRef = { + traceId: spanContext.traceId, + spanId: spanContext.spanId, + }; + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: "gpt-4", + }; + const activeParentSpan = trace.wrapSpanContext(spanContext); + const baseCtx = trace.setSpan(otelContext.active(), activeParentSpan); + const childScope = otelContext.with(baseCtx, () => + InferenceScope.start(testRequest, inferenceDetails, testAgentDetails, undefined, { + parentContext: parentRef, + }), + ); + expect(childScope.getSpanContext().traceId).toBe(spanContext.traceId); + + scope.dispose(); + childScope.dispose(); + + await flushProvider.forceFlush(); + + const spans = exporter.getFinishedSpans(); + const parentSpan = spans.find((s) => s.name.toLowerCase().includes("invoke_agent")); + const childSpan = spans.find((s) => s.name.toLowerCase().includes("chat")); + + expect(parentSpan).toBeDefined(); + expect(childSpan).toBeDefined(); + expect(childSpan!.spanContext().traceId).toBe(parentSpan!.spanContext().traceId); + expect(childSpan!.parentSpanContext?.spanId).toBe(parentSpan!.spanContext().spanId); + }); + }); + + describe("traceFlags propagation", () => { + it("should record child spans when parentRef.traceFlags is SAMPLED", async () => { + const parentRef: ParentSpanRef = { + traceId: "0123456789abcdef0123456789abcdef", + spanId: "0123456789abcdef", + traceFlags: TraceFlags.SAMPLED, + }; + + runWithParentSpanRef(parentRef, () => { + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "sampled-agent", + tenantId: "test-tenant-456", + }, + ); + scope.dispose(); + }); + + await flushProvider.forceFlush(); + + const spans = exporter.getFinishedSpans(); + const childSpan = spans.find( + (s) => + s.name.toLowerCase().includes("invokeagent") || + s.name.toLowerCase().includes("invoke_agent"), + ); + + expect(childSpan).toBeDefined(); + expect(childSpan!.spanContext().traceId).toBe(parentRef.traceId); + expect(childSpan!.parentSpanContext?.spanId).toBe(parentRef.spanId); + expect(childSpan!.spanContext().traceFlags).toBe(TraceFlags.SAMPLED); + }); + + it("should not record child spans when parentRef.traceFlags is NONE", async () => { + const parentRef: ParentSpanRef = { + traceId: "abcdef0123456789abcdef0123456789", + spanId: "abcdef0123456789", + traceFlags: TraceFlags.NONE, + }; + + runWithParentSpanRef(parentRef, () => { + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "unsampled-agent", + tenantId: "test-tenant-456", + }, + ); + scope.dispose(); + }); + + await flushProvider.forceFlush(); + + const spans = exporter.getFinishedSpans(); + const childSpan = spans.find( + (s) => + (s.name.toLowerCase().includes("invokeagent") || + s.name.toLowerCase().includes("invoke_agent")) && + s.spanContext().traceId === parentRef.traceId, + ); + + // The span should not be exported when traceFlags is NONE + expect(childSpan).toBeUndefined(); + }); + + it("should default to SAMPLED when parentRef.traceFlags is not provided and no active span matches", async () => { + const parentRef: ParentSpanRef = { + traceId: "fedcba9876543210fedcba9876543210", + spanId: "fedcba9876543210", + // traceFlags is not provided — should default to SAMPLED + }; + + runWithParentSpanRef(parentRef, () => { + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "default-sampled-agent", + tenantId: "test-tenant-456", + }, + ); + scope.dispose(); + }); + + await flushProvider.forceFlush(); + + const spans = exporter.getFinishedSpans(); + const childSpan = spans.find( + (s) => + (s.name.toLowerCase().includes("invokeagent") || + s.name.toLowerCase().includes("invoke_agent")) && + s.spanContext().traceId === parentRef.traceId, + ); + + // Should be recorded when traceFlags defaults to SAMPLED + expect(childSpan).toBeDefined(); + expect(childSpan!.spanContext().traceFlags).toBe(TraceFlags.SAMPLED); + }); + + it("should inherit traceFlags from active span when parentRef.traceFlags is not provided but traceId matches", async () => { + const tracer = trace.getTracer("test"); + const rootSpan = tracer.startSpan("active-root-span"); + const parentSpanContext = rootSpan.spanContext(); + + const parentRef: ParentSpanRef = { + traceId: parentSpanContext.traceId, + spanId: parentSpanContext.spanId, + // traceFlags is not provided + }; + + const baseCtx = trace.setSpan(otelContext.active(), rootSpan); + await otelContext.with(baseCtx, async () => { + runWithParentSpanRef(parentRef, () => { + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "inherited-flags-agent", + tenantId: "test-tenant-456", + }, + ); + scope.dispose(); + }); + }); + + rootSpan.end(); + + await flushProvider.forceFlush(); + + const spans = exporter.getFinishedSpans(); + const childSpan = spans.find( + (s) => + (s.name.toLowerCase().includes("invokeagent") || + s.name.toLowerCase().includes("invoke_agent")) && + s.spanContext().traceId === parentRef.traceId, + ); + + // Should be recorded with traceFlags inherited from active span + expect(childSpan).toBeDefined(); + expect(childSpan!.spanContext().traceFlags).toBe(parentSpanContext.traceFlags); + }); + }); +}); + +describe("isParentSpanRef type guard", () => { + it("should return true for ParentSpanRef objects", () => { + expect(isParentSpanRef({ traceId: "abc", spanId: "def" })).toBe(true); + }); + + it("should return false for OTel Context objects", () => { + const ctx = otelContext.active(); + expect(isParentSpanRef(ctx)).toBe(false); + }); + + it("should return false for null", () => { + expect(isParentSpanRef(null as any)).toBe(false); + }); + + it("should return false for objects without traceId", () => { + expect(isParentSpanRef({ spanId: "def" } as any)).toBe(false); + }); + + it("should return false for objects without spanId", () => { + expect(isParentSpanRef({ traceId: "abc" } as any)).toBe(false); + }); +}); diff --git a/test/internal/unit/a365/messageUtils.test.ts b/test/internal/unit/a365/messageUtils.test.ts new file mode 100644 index 00000000..018d8b5e --- /dev/null +++ b/test/internal/unit/a365/messageUtils.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from "vitest"; + +import { + MessageRole, + A365_MESSAGE_SCHEMA_VERSION, + Modality, +} from "../../../../src/a365/contracts.js"; +import type { InputMessages, OutputMessages } from "../../../../src/a365/contracts.js"; +import { + isWrappedMessages, + toInputMessages, + toOutputMessages, + normalizeInputMessages, + normalizeOutputMessages, + serializeMessages, +} from "../../../../src/a365/message-utils.js"; + +describe("isWrappedMessages", () => { + it("returns true for InputMessages wrapper", () => { + const wrapper: InputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [{ role: MessageRole.USER, parts: [{ type: "text", content: "hi" }] }], + }; + expect(isWrappedMessages(wrapper)).toBe(true); + }); + + it("returns true for OutputMessages wrapper", () => { + const wrapper: OutputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [{ role: MessageRole.ASSISTANT, parts: [{ type: "text", content: "hello" }] }], + }; + expect(isWrappedMessages(wrapper)).toBe(true); + }); + + it("returns false for string[]", () => { + expect(isWrappedMessages(["hello"])).toBe(false); + }); + + it("returns false for empty array", () => { + expect(isWrappedMessages([])).toBe(false); + }); + + it("returns false for null", () => { + expect(isWrappedMessages(null as unknown as any)).toBe(false); + }); + + it("returns false for object missing messages property", () => { + expect(isWrappedMessages({ version: "0.1.0" } as unknown as any)).toBe(false); + }); + + it("returns false for object missing version property", () => { + expect(isWrappedMessages({ messages: [] } as unknown as any)).toBe(false); + }); + + it("returns false for arbitrary non-matching object", () => { + expect(isWrappedMessages({ foo: "bar" } as unknown as any)).toBe(false); + }); +}); + +describe("toInputMessages", () => { + it("wraps strings as ChatMessage with role=user and TextPart", () => { + const result = toInputMessages(["hello", "world"]); + expect(result).toEqual([ + { role: "user", parts: [{ type: "text", content: "hello" }] }, + { role: "user", parts: [{ type: "text", content: "world" }] }, + ]); + }); + + it("handles empty array", () => { + expect(toInputMessages([])).toEqual([]); + }); + + it("preserves message content exactly", () => { + const content = " special chars: <>&\"' \n\ttabs "; + const result = toInputMessages([content]); + expect(result[0].parts[0]).toEqual({ type: "text", content }); + }); +}); + +describe("toOutputMessages", () => { + it("wraps strings as OutputMessage with role=assistant and TextPart", () => { + const result = toOutputMessages(["response 1", "response 2"]); + expect(result).toEqual([ + { role: "assistant", parts: [{ type: "text", content: "response 1" }] }, + { role: "assistant", parts: [{ type: "text", content: "response 2" }] }, + ]); + }); + + it("handles empty array", () => { + expect(toOutputMessages([])).toEqual([]); + }); +}); + +describe("normalizeInputMessages", () => { + it("wraps string[] into versioned InputMessages", () => { + const result = normalizeInputMessages(["hello"]); + expect(result).toEqual({ + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [{ role: "user", parts: [{ type: "text", content: "hello" }] }], + }); + }); + + it("returns InputMessages wrapper as-is", () => { + const wrapper: InputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [{ role: MessageRole.SYSTEM, parts: [{ type: "text", content: "system prompt" }] }], + }; + expect(normalizeInputMessages(wrapper)).toBe(wrapper); + }); + + it("wraps empty string[] into versioned wrapper with empty messages", () => { + const result = normalizeInputMessages([]); + expect(result).toEqual({ version: A365_MESSAGE_SCHEMA_VERSION, messages: [] }); + }); +}); + +describe("normalizeOutputMessages", () => { + it("wraps string[] into versioned OutputMessages", () => { + const result = normalizeOutputMessages(["response"]); + expect(result).toEqual({ + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [{ role: "assistant", parts: [{ type: "text", content: "response" }] }], + }); + }); + + it("returns OutputMessages wrapper as-is", () => { + const wrapper: OutputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [ + { + role: MessageRole.ASSISTANT, + parts: [{ type: "text", content: "done" }], + finish_reason: "stop", + }, + ], + }; + expect(normalizeOutputMessages(wrapper)).toBe(wrapper); + }); +}); + +describe("serializeMessages", () => { + it("returns JSON for versioned wrapper", () => { + const wrapper: InputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [{ role: MessageRole.USER, parts: [{ type: "text", content: "hello" }] }], + }; + const result = serializeMessages(wrapper); + const parsed = JSON.parse(result); + expect(parsed.version).toBe(A365_MESSAGE_SCHEMA_VERSION); + expect(parsed.messages).toEqual(wrapper.messages); + }); + + it("serializes empty messages wrapper", () => { + const wrapper: InputMessages = { version: A365_MESSAGE_SCHEMA_VERSION, messages: [] }; + const parsed = JSON.parse(serializeMessages(wrapper)); + expect(parsed.version).toBe(A365_MESSAGE_SCHEMA_VERSION); + expect(parsed.messages).toEqual([]); + }); + + it("serializes large messages without truncation", () => { + const largeContent = "x".repeat(100_000); + const wrapper: InputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [{ role: MessageRole.USER, parts: [{ type: "text", content: largeContent }] }], + }; + + const result = serializeMessages(wrapper); + const parsed = JSON.parse(result); + expect(parsed.messages[0].parts[0].content).toBe(largeContent); + }); + + it("does not mutate the original messages", () => { + const original = "z".repeat(50_000); + const wrapper: InputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [{ role: MessageRole.USER, parts: [{ type: "text", content: original }] }], + }; + + serializeMessages(wrapper); + + expect((wrapper.messages[0].parts[0] as { content: string }).content).toBe(original); + }); + + it("returns fallback sentinel when messages contain non-serializable values", () => { + const circular: Record = { a: 1 }; + circular.self = circular; + + const wrapper: InputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [ + { + role: MessageRole.TOOL, + parts: [{ type: "tool_call_response", id: "tc1", response: circular }], + }, + ], + }; + + const result = serializeMessages(wrapper); + const parsed = JSON.parse(result); + expect(parsed.version).toBe(A365_MESSAGE_SCHEMA_VERSION); + expect(parsed.messages[0].parts[0].content).toContain("serialization failed"); + expect(parsed.messages[0].parts[0].content).toContain("1 message"); + }); + + it("should serialize tool call request and response parts in wrapper", () => { + const messages: InputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [ + { + role: MessageRole.ASSISTANT, + parts: [ + { type: "text", content: "Let me search for that." }, + { type: "tool_call", name: "search", id: "call_123", arguments: { query: "test" } }, + ], + }, + { + role: MessageRole.TOOL, + parts: [{ type: "tool_call_response", id: "call_123", response: { results: ["item1"] } }], + }, + ], + }; + + const parsed = JSON.parse(serializeMessages(messages)); + + expect(parsed.version).toBe(A365_MESSAGE_SCHEMA_VERSION); + expect(parsed.messages[0].parts[1].type).toBe("tool_call"); + expect(parsed.messages[0].parts[1].arguments).toEqual({ query: "test" }); + expect(parsed.messages[1].parts[0].type).toBe("tool_call_response"); + expect(parsed.messages[1].parts[0].response).toEqual({ results: ["item1"] }); + }); + + it("should serialize blob, file, and URI parts", () => { + const wrapper: InputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [ + { + role: MessageRole.USER, + parts: [ + { + type: "blob", + modality: Modality.IMAGE, + mime_type: "image/png", + content: "iVBORw0KGgo=", + }, + { type: "file", modality: Modality.VIDEO, mime_type: "video/mp4", file_id: "file-123" }, + { + type: "uri", + modality: Modality.AUDIO, + mime_type: "audio/mp3", + uri: "https://example.com/audio.mp3", + }, + ], + }, + ], + }; + + const parsed = JSON.parse(serializeMessages(wrapper)); + + expect(parsed.version).toBe(A365_MESSAGE_SCHEMA_VERSION); + expect(parsed.messages[0].parts[0].modality).toBe("image"); + expect(parsed.messages[0].parts[1].file_id).toBe("file-123"); + expect(parsed.messages[0].parts[2].uri).toBe("https://example.com/audio.mp3"); + }); + + it("should serialize server tool call and generic parts", () => { + const wrapper: InputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [ + { + role: MessageRole.ASSISTANT, + parts: [ + { + type: "server_tool_call", + name: "mcp_tool", + id: "stc_1", + server_tool_call: { endpoint: "/api" }, + }, + ], + }, + { + role: MessageRole.TOOL, + parts: [ + { + type: "server_tool_call_response", + id: "stc_1", + server_tool_call_response: { status: "ok" }, + }, + ], + }, + { + role: MessageRole.USER, + parts: [{ type: "custom_annotation", timestamp: "00:01:23", note: "Important" }], + }, + ], + }; + + const parsed = JSON.parse(serializeMessages(wrapper)); + + expect(parsed.version).toBe(A365_MESSAGE_SCHEMA_VERSION); + expect(parsed.messages[0].parts[0].server_tool_call.endpoint).toBe("/api"); + expect(parsed.messages[1].parts[0].server_tool_call_response.status).toBe("ok"); + expect(parsed.messages[2].parts[0].type).toBe("custom_annotation"); + }); +}); diff --git a/test/internal/unit/a365/perRequestSpanProcessor.test.ts b/test/internal/unit/a365/perRequestSpanProcessor.test.ts new file mode 100644 index 00000000..24cf568a --- /dev/null +++ b/test/internal/unit/a365/perRequestSpanProcessor.test.ts @@ -0,0 +1,487 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; +import type { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base"; +import { ExportResultCode } from "@opentelemetry/core"; +import type { ExportResult } from "@opentelemetry/core"; +import { context, trace } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { PerRequestSpanProcessor } from "../../../../src/a365/processors/PerRequestSpanProcessor.js"; +import { + runWithExportToken, + updateExportToken, + getExportToken, +} from "../../../../src/a365/context/tokenContext.js"; + +describe("PerRequestSpanProcessor", () => { + let provider: BasicTracerProvider; + let processor: PerRequestSpanProcessor; + let exportedSpans: ReadableSpan[][] = []; + let mockExporter: SpanExporter; + let originalEnv: NodeJS.ProcessEnv; + + const getActiveTraceCount = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const traces: Map | undefined = (processor as any).traces; + return traces?.size ?? 0; + }; + + beforeEach(() => { + originalEnv = { ...process.env }; + exportedSpans = []; + mockExporter = { + export: (spans: ReadableSpan[], resultCallback: (result: ExportResult) => void) => { + exportedSpans.push([...spans]); + resultCallback({ code: ExportResultCode.SUCCESS }); + }, + shutdown: async () => { + // No-op + }, + }; + + processor = new PerRequestSpanProcessor(mockExporter); + provider = new BasicTracerProvider({ + spanProcessors: [processor], + }); + }); + + afterEach(async () => { + await provider.shutdown(); + process.env = originalEnv; + }); + + const recreateProvider = async (newProcessor: PerRequestSpanProcessor) => { + await provider.shutdown(); + processor = newProcessor; + provider = new BasicTracerProvider({ + spanProcessors: [processor], + }); + }; + + describe("per-request export with token context", () => { + it("should cap the number of buffered traces (maxBufferedTraces)", async () => { + process.env.A365_PER_REQUEST_MAX_TRACES = "2"; + await recreateProvider(new PerRequestSpanProcessor(mockExporter)); + + const tracer = provider.getTracer("test"); + + await new Promise((resolve) => { + runWithExportToken("token-1", () => { + const root1 = tracer.startSpan("trace-1", { root: true }); + const ctx1 = trace.setSpan(context.active(), root1); + const child1 = tracer.startSpan("trace-1-child", undefined, ctx1); + + root1.end(); + + runWithExportToken("token-2", () => { + const root2 = tracer.startSpan("trace-2"); + root2.end(); + }); + + setTimeout(() => { + child1.end(); + setTimeout(resolve, 50); + }, 10); + }); + }); + + const exportedNames = exportedSpans.flatMap((s) => s.map((sp) => sp.name)); + expect(exportedNames).toContain("trace-1"); + expect(exportedNames).toContain("trace-1-child"); + expect(exportedNames).toContain("trace-2"); + }); + + it("should drop additional traces beyond maxBufferedTraces (drop case)", async () => { + process.env.A365_PER_REQUEST_MAX_TRACES = "2"; + await recreateProvider(new PerRequestSpanProcessor(mockExporter)); + + const tracer = provider.getTracer("test"); + + await new Promise((resolve) => { + runWithExportToken("token-1", () => { + const root1 = tracer.startSpan("trace-1", { root: true }); + const ctx1 = trace.setSpan(context.active(), root1); + const child1 = tracer.startSpan("trace-1-child", undefined, ctx1); + root1.end(); + + runWithExportToken("token-2", () => { + const root2 = tracer.startSpan("trace-2", { root: true }); + const ctx2 = trace.setSpan(context.active(), root2); + const child2 = tracer.startSpan("trace-2-child", undefined, ctx2); + root2.end(); + + runWithExportToken("token-3", () => { + const root3 = tracer.startSpan("trace-3", { root: true }); + root3.end(); + }); + + setTimeout(() => { + child2.end(); + child1.end(); + setTimeout(resolve, 50); + }, 10); + }); + }); + }); + + const exportedNames = exportedSpans.flatMap((s) => s.map((sp) => sp.name)); + expect(exportedNames).toContain("trace-1"); + expect(exportedNames).toContain("trace-1-child"); + expect(exportedNames).toContain("trace-2"); + expect(exportedNames).toContain("trace-2-child"); + expect(exportedNames).not.toContain("trace-3"); + }); + + it("should cap the number of buffered spans per trace (maxSpansPerTrace)", async () => { + process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = "2"; + await recreateProvider(new PerRequestSpanProcessor(mockExporter)); + + const tracer = provider.getTracer("test"); + + await new Promise((resolve) => { + runWithExportToken("test-token", () => { + const rootSpan = tracer.startSpan("root", { root: true }); + const ctxWithRoot = trace.setSpan(context.active(), rootSpan); + + const child1 = tracer.startSpan("child-1", undefined, ctxWithRoot); + const child2 = tracer.startSpan("child-2", undefined, ctxWithRoot); + child1.end(); + child2.end(); + rootSpan.end(); + + setTimeout(resolve, 50); + }); + }); + + expect(exportedSpans.length).toBe(1); + expect(exportedSpans[0].length).toBe(2); + const exportedNames = exportedSpans[0].map((sp) => sp.name); + expect(exportedNames).toContain("child-1"); + expect(exportedNames).toContain("child-2"); + expect(exportedNames).not.toContain("root"); + }); + + it("should respect max concurrent exports (A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS)", async () => { + process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = "2"; + + let inFlight = 0; + let maxInFlight = 0; + + const exportHoldMs = 50; + + exportedSpans = []; + mockExporter = { + export: (spans: ReadableSpan[], resultCallback: (result: ExportResult) => void) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + exportedSpans.push([...spans]); + + setTimeout(() => { + inFlight -= 1; + resultCallback({ code: ExportResultCode.SUCCESS }); + }, exportHoldMs); + }, + shutdown: async () => { + // No-op + }, + }; + + await recreateProvider(new PerRequestSpanProcessor(mockExporter)); + + const tracer = provider.getTracer("test"); + + runWithExportToken("token-1", () => { + const span = tracer.startSpan("trace-1"); + span.end(); + }); + runWithExportToken("token-2", () => { + const span = tracer.startSpan("trace-2"); + span.end(); + }); + runWithExportToken("token-3", () => { + const span = tracer.startSpan("trace-3"); + span.end(); + }); + + await new Promise((resolve) => setTimeout(resolve, exportHoldMs * 6)); + + expect(maxInFlight).toBeLessThanOrEqual(2); + const exportedNames = exportedSpans.flatMap((s) => s.map((sp) => sp.name)); + expect(exportedNames).toContain("trace-1"); + expect(exportedNames).toContain("trace-2"); + expect(exportedNames).toContain("trace-3"); + }); + + it("should capture root span context and export under that context", async () => { + const tracer = provider.getTracer("test"); + + await new Promise((resolve) => { + runWithExportToken("test-token-123", () => { + const rootSpan = tracer.startSpan("root-span"); + rootSpan.end(); + + setTimeout(() => { + resolve(); + }, 100); + }); + }); + + expect(exportedSpans.length).toBeGreaterThan(0); + expect(exportedSpans[0][0].name).toBe("root-span"); + }); + + it("should export with refreshed token when updateExportToken is called before root span ends", async () => { + const contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + context.setGlobalContextManager(contextManager); + + try { + let authorizationHeader: string | undefined; + const tokenCapturingExporter: SpanExporter = { + export: (spans: ReadableSpan[], resultCallback: (result: ExportResult) => void) => { + const token = getExportToken() ?? null; + if (token) { + authorizationHeader = `Bearer ${token}`; + } + exportedSpans.push([...spans]); + resultCallback({ code: ExportResultCode.SUCCESS }); + }, + shutdown: async () => {}, + }; + await recreateProvider(new PerRequestSpanProcessor(tokenCapturingExporter)); + const tracer = provider.getTracer("test"); + + await new Promise((resolve) => { + runWithExportToken("initial-token", () => { + const rootSpan = tracer.startSpan("long-running-root"); + const child = tracer.startSpan("child-work"); + child.end(); + updateExportToken("refreshed-token"); + + rootSpan.end(); + + setTimeout(() => resolve(), 100); + }); + }); + + expect(exportedSpans.length).toBeGreaterThanOrEqual(1); + expect(authorizationHeader).toBe("Bearer refreshed-token"); + } finally { + contextManager.disable(); + context.disable(); + } + }); + + it("should collect multiple spans from a single trace", async () => { + const tracer = provider.getTracer("test"); + + await new Promise((resolve) => { + runWithExportToken("test-token", () => { + const rootSpan = tracer.startSpan("root-span"); + const child1 = tracer.startSpan("child-1"); + const child2 = tracer.startSpan("child-2"); + + child1.end(); + child2.end(); + rootSpan.end(); + + setTimeout(() => { + resolve(); + }, 100); + }); + }); + + expect(exportedSpans.length).toEqual(3); + const spanNames = exportedSpans.flatMap((s: ReadableSpan[]) => s.map((span) => span.name)); + expect(spanNames).toContain("root-span"); + expect(spanNames).toContain("child-1"); + expect(spanNames).toContain("child-2"); + }); + + it("should handle multiple independent traces", async () => { + const tracer = provider.getTracer("test"); + + await new Promise((resolve) => { + let completed = 0; + const checkDone = () => { + completed++; + if (completed === 3) { + setTimeout(() => { + resolve(); + }, 100); + } + }; + + runWithExportToken("token-1", () => { + const span1 = tracer.startSpan("trace-1-span"); + span1.end(); + checkDone(); + }); + + runWithExportToken("token-2", () => { + const span2 = tracer.startSpan("trace-2-span"); + span2.end(); + checkDone(); + }); + + runWithExportToken("token-3", () => { + const span3 = tracer.startSpan("trace-3-span"); + span3.end(); + checkDone(); + }); + }); + + expect(exportedSpans.length).toBeGreaterThanOrEqual(3); + const spanNames = exportedSpans.flatMap((spans) => spans.map((s) => s.name)); + expect(spanNames).toContain("trace-1-span"); + expect(spanNames).toContain("trace-2-span"); + expect(spanNames).toContain("trace-3-span"); + }); + + it("should correctly identify root spans", async () => { + const tracer = provider.getTracer("test"); + + await new Promise((resolve) => { + runWithExportToken("test-token", () => { + const rootSpan = tracer.startSpan("actual-root"); + const childSpan = tracer.startSpan("child-of-root"); + const grandchildSpan = tracer.startSpan("grandchild"); + + grandchildSpan.end(); + childSpan.end(); + rootSpan.end(); + + setTimeout(() => { + resolve(); + }, 100); + }); + }); + + expect(exportedSpans.length).toBe(3); + expect(exportedSpans[0][0].name).toBe("grandchild"); + expect(exportedSpans[1][0].name).toBe("child-of-root"); + expect(exportedSpans[2][0].name).toBe("actual-root"); + }); + + it("should handle forceFlush correctly", async () => { + const tracer = provider.getTracer("test"); + + runWithExportToken("test-token", () => { + const rootSpan = tracer.startSpan("root"); + tracer.startSpan("child"); + + rootSpan.end(); + }); + + await processor.forceFlush(); + + expect(exportedSpans.length).toBe(1); + }); + + it("should not retain trace buffers after trace completion", async () => { + const tracer = provider.getTracer("test"); + + await new Promise((resolve) => { + runWithExportToken("test-token", () => { + const rootSpan = tracer.startSpan("root"); + const childSpan = tracer.startSpan("child"); + + childSpan.end(); + rootSpan.end(); + + setTimeout(() => resolve(), 100); + }); + }); + + expect(getActiveTraceCount()).toBe(0); + }); + + it("should use default values when env vars are not set", async () => { + delete process.env.A365_PER_REQUEST_MAX_TRACES; + delete process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE; + delete process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS; + delete process.env.A365_PER_REQUEST_FLUSH_GRACE_MS; + delete process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS; + + await recreateProvider(new PerRequestSpanProcessor(mockExporter)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = processor as any; + expect(proc.maxBufferedTraces).toBe(1000); + expect(proc.maxSpansPerTrace).toBe(5000); + expect(proc.maxConcurrentExports).toBe(20); + expect(proc.flushGraceMs).toBe(250); + expect(proc.maxTraceAgeMs).toBe(1800000); + }); + + it("should fallback to defaults for invalid env var values", async () => { + process.env.A365_PER_REQUEST_MAX_TRACES = "not-a-number"; + process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = ""; + process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = "NaN"; + process.env.A365_PER_REQUEST_FLUSH_GRACE_MS = "abc"; + process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS = ""; + + await recreateProvider(new PerRequestSpanProcessor(mockExporter)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = processor as any; + expect(proc.maxBufferedTraces).toBe(1000); + expect(proc.maxSpansPerTrace).toBe(5000); + expect(proc.maxConcurrentExports).toBe(20); + expect(proc.flushGraceMs).toBe(250); + expect(proc.maxTraceAgeMs).toBe(1800000); + }); + + it("should parse valid numeric string env vars correctly", async () => { + process.env.A365_PER_REQUEST_MAX_TRACES = "50"; + process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = "100"; + process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = "5"; + process.env.A365_PER_REQUEST_FLUSH_GRACE_MS = "500"; + process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS = "60000"; + + await recreateProvider(new PerRequestSpanProcessor(mockExporter)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proc = processor as any; + expect(proc.maxBufferedTraces).toBe(50); + expect(proc.maxSpansPerTrace).toBe(100); + expect(proc.maxConcurrentExports).toBe(5); + expect(proc.flushGraceMs).toBe(500); + expect(proc.maxTraceAgeMs).toBe(60000); + }); + + it("should handle shutdown gracefully", async () => { + const tracer = provider.getTracer("test"); + + runWithExportToken("test-token", () => { + const span = tracer.startSpan("root"); + span.end(); + }); + + await expect(processor.shutdown()).resolves.not.toThrow(); + }); + + it("should handle onStart with null parentSpanContext as root span", async () => { + const tracer = provider.getTracer("test"); + + await new Promise((resolve) => { + runWithExportToken("test-token", () => { + const rootSpan = tracer.startSpan("root", { root: true }); + rootSpan.end(); + + setTimeout(() => resolve(), 50); + }); + }); + + expect(exportedSpans.length).toBe(1); + expect(exportedSpans[0][0].name).toBe("root"); + }); + + it("should handle empty traces array in forceFlush", async () => { + await expect(processor.forceFlush()).resolves.not.toThrow(); + }); + }); +}); diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts new file mode 100644 index 00000000..852c6e00 --- /dev/null +++ b/test/internal/unit/a365/scopes.test.ts @@ -0,0 +1,1251 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll, afterEach, vi, beforeEach } from "vitest"; +import { trace, SpanKind, context as otelContext } from "@opentelemetry/api"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { + ExecuteToolScope, + InvokeAgentScope, + InferenceScope, + OpenTelemetryScope, + OpenTelemetryConstants, +} from "../../../../src/a365/index.js"; +import type { + AgentDetails, + InvokeAgentScopeDetails, + ToolCallDetails, + InferenceDetails, + UserDetails, + InputMessages, +} from "../../../../src/a365/index.js"; +import { + InferenceOperationType, + MessageRole, + A365_MESSAGE_SCHEMA_VERSION, +} from "../../../../src/a365/index.js"; +import { safeSerializeToJson } from "../../../../src/a365/message-utils.js"; + +let sharedExporter: InMemorySpanExporter; +let contextManager: AsyncLocalStorageContextManager; + +const originalConsoleWarn = console.warn; +const originalConsoleError = console.error; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); + + sharedExporter = new InMemorySpanExporter(); + const processor = new SimpleSpanProcessor(sharedExporter); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const globalProvider: any = trace.getTracerProvider(); + if (globalProvider && typeof globalProvider.addSpanProcessor === "function") { + globalProvider.addSpanProcessor(processor); + } else { + const provider = new BasicTracerProvider({ + spanProcessors: [processor], + }); + trace.setGlobalTracerProvider(provider); + } + + console.warn = vi.fn(); + console.error = vi.fn(); +}); + +afterAll(() => { + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + contextManager.disable(); + otelContext.disable(); +}); + +describe("Scopes", () => { + const testAgentDetails: AgentDetails = { + agentId: "test-agent", + agentName: "Test Agent", + agentDescription: "A test agent", + tenantId: "test-tenant-456", + }; + + const testRequest = { + conversationId: "test-conv-req", + channel: { name: "TestChannel", description: "https://test.channel" }, + }; + + describe("InvokeAgentScope", () => { + it("should create scope with agent details", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + + const scope = InvokeAgentScope.start( + { + conversationId: "conv-req-1", + channel: { name: "Teams", description: "https://teams.link" }, + }, + {}, + { + agentId: "test-agent", + agentName: "Test Agent", + agentDescription: "A test agent", + tenantId: "test-tenant-456", + }, + ); + + expect(scope).toBeInstanceOf(InvokeAgentScope); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, + val: "conv-req-1", + }), + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: "Teams" }), + expect.objectContaining({ + key: OpenTelemetryConstants.CHANNEL_LINK_KEY, + val: "https://teams.link", + }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should create scope with agent ID only", () => { + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "simple-agent", + tenantId: "test-tenant-456", + }, + ); + + expect(scope).toBeInstanceOf(InvokeAgentScope); + scope?.dispose(); + }); + + it("should create scope with additional details", () => { + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "test-agent", + agentName: "Test Agent", + agentDescription: "A test agent", + iconUri: "https://example.com/icon.png", + tenantId: "test-tenant-456", + }, + ); + + expect(scope).toBeInstanceOf(InvokeAgentScope); + scope?.dispose(); + }); + + it("should create scope with platformId", () => { + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "test-agent", + agentName: "Test Agent", + platformId: "platform-xyz-123", + tenantId: "test-tenant-456", + }, + ); + + expect(scope).toBeInstanceOf(InvokeAgentScope); + scope?.dispose(); + }); + + it("should create scope with caller details", () => { + const callerDetails: UserDetails = { + userId: "user-123", + userName: "Test User", + userEmail: "test.user@contoso.com", + tenantId: "test-tenant", + }; + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "test-agent", + agentName: "Test Agent", + tenantId: "test-tenant-456", + }, + { userDetails: callerDetails }, + ); + + expect(scope).toBeInstanceOf(InvokeAgentScope); + scope?.dispose(); + }); + + it("should set sessionId from request", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = InvokeAgentScope.start( + { conversationId: "conv-1", sessionId: "session-abc-123" }, + {}, + { agentId: "test-agent", tenantId: "test-tenant-456" }, + ); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.SESSION_ID_KEY, + val: "session-abc-123", + }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should record error", () => { + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "test-agent", + tenantId: "test-tenant-456", + }, + ); + const error = new Error("Test error"); + + expect(() => scope?.recordError(error)).not.toThrow(); + scope?.dispose(); + }); + + it("should set conversationId from request", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = InvokeAgentScope.start( + { conversationId: "explicit-conv-id" }, + {}, + { agentId: "test-agent", tenantId: "test-tenant-456" }, + ); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, + val: "explicit-conv-id", + }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should set channel tags from request.channel", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = InvokeAgentScope.start( + { channel: { name: "Teams", description: "https://teams.link" } }, + {}, + { agentId: "test-agent", tenantId: "test-tenant-456" }, + ); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: "Teams" }), + expect.objectContaining({ + key: OpenTelemetryConstants.CHANNEL_LINK_KEY, + val: "https://teams.link", + }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should propagate platformId in span attributes", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "test-agent", + agentName: "Test Agent", + platformId: "test-platform-123", + tenantId: "test-tenant-456", + }, + ); + expect(scope).toBeInstanceOf(InvokeAgentScope); + + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_AGENT_PLATFORM_ID_KEY, + val: "test-platform-123", + }), + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + + it("should propagate caller agent platformId in span attributes", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const callerAgentDetails: AgentDetails = { + agentId: "caller-agent", + agentName: "Caller Agent", + agentDescription: "desc", + platformId: "caller-platform-xyz", + }; + + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "test-agent", + agentName: "Test Agent", + tenantId: "test-tenant-456", + }, + { callerAgentDetails }, + ); + expect(scope).toBeInstanceOf(InvokeAgentScope); + + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, + val: "caller-platform-xyz", + }), + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + + it("should propagate agent version and caller agent version in span attributes", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const callerAgentDetails: AgentDetails = { + agentId: "caller-agent", + agentName: "Caller Agent", + agentVersion: "2025-05-01", + }; + + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "test-agent", + agentName: "Test Agent", + tenantId: "test-tenant-456", + agentVersion: "1.2.3", + }, + { callerAgentDetails }, + ); + expect(scope).toBeInstanceOf(InvokeAgentScope); + + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY, + val: "1.2.3", + }), + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_VERSION_KEY, + val: "2025-05-01", + }), + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + + it("should set caller and caller-agent IP tags", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const agentDets = { + agentId: "test-agent", + agentName: "Test Agent", + tenantId: "test-tenant-456", + }; + const callerDetails: UserDetails = { + userId: "user-123", + tenantId: "test-tenant", + callerClientIp: "10.0.0.5", + }; + + const scope1 = InvokeAgentScope.start(testRequest, {}, agentDets, { + userDetails: callerDetails, + }); + expect(scope1).toBeInstanceOf(InvokeAgentScope); + + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, + val: "10.0.0.5", + }), + ]), + ); + + scope1?.dispose(); + spy.mockRestore(); + }); + + it("should throw when agentDetails.tenantId is missing", () => { + expect(() => InvokeAgentScope.start(testRequest, {}, { agentId: "a" } as any)).toThrow( + "InvokeAgentScope: tenantId is required on agentDetails", + ); + }); + + it("should set both userDetails and callerAgentDetails tags when both are provided", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "test-agent", + agentName: "Test Agent", + tenantId: "test-tenant-456", + }, + { + userDetails: { userId: "user-1", userName: "User One" }, + callerAgentDetails: { agentId: "caller-agent-1", agentName: "Caller Agent" } as any, + }, + ); + + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.USER_ID_KEY, val: "user-1" }), + expect.objectContaining({ key: OpenTelemetryConstants.USER_NAME_KEY, val: "User One" }), + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, + val: "caller-agent-1", + }), + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY, + val: "Caller Agent", + }), + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + + it("should set endpoint tags from typed InvokeAgentScopeDetails", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + + const details: InvokeAgentScopeDetails = { + endpoint: { host: "agent-api.contoso.com", port: 8443 }, + }; + const scope = InvokeAgentScope.start(testRequest, details, { + agentId: "typed-agent", + tenantId: "test-tenant-456", + }); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.SERVER_ADDRESS_KEY, + val: "agent-api.contoso.com", + }), + expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY, val: 8443 }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should omit endpoint tags when InvokeAgentScopeDetails is empty", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "test-agent", + tenantId: "test-tenant-456", + }, + ); + const keys = new Set(spy.mock.calls.map((args) => args[0])); + expect(keys).not.toContain(OpenTelemetryConstants.SERVER_ADDRESS_KEY); + expect(keys).not.toContain(OpenTelemetryConstants.SERVER_PORT_KEY); + scope?.dispose(); + spy.mockRestore(); + }); + }); + + describe("ExecuteToolScope", () => { + it("should create scope with tool details", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const callerDetails: UserDetails = { + userId: "caller-tool-1", + userEmail: "tool.user@contoso.com", + userName: "Tool User", + tenantId: "tool-tenant", + callerClientIp: "10.0.0.10", + }; + + const scope = ExecuteToolScope.start( + testRequest, + { + toolName: "test-tool", + arguments: '{"param": "value"}', + toolCallId: "call-123", + description: "A test tool", + toolType: "test", + }, + testAgentDetails, + callerDetails, + ); + + expect(scope).toBeInstanceOf(ExecuteToolScope); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.USER_ID_KEY, + val: "caller-tool-1", + }), + expect.objectContaining({ key: OpenTelemetryConstants.USER_NAME_KEY, val: "Tool User" }), + expect.objectContaining({ + key: OpenTelemetryConstants.USER_EMAIL_KEY, + val: "tool.user@contoso.com", + }), + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, + val: "10.0.0.10", + }), + ]), + ); + + // Validate raw attribute key strings for schema correctness + const keySet = new Set(calls.map((c) => c.key)); + expect(keySet).toContain("user.id"); + expect(keySet).toContain("user.name"); + expect(keySet).toContain("user.email"); + expect(keySet).toContain("client.address"); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should record response", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = ExecuteToolScope.start( + { + conversationId: "conv-tool-resp", + channel: { name: "Web", description: "https://web.link" }, + }, + { toolName: "test-tool" }, + testAgentDetails, + ); + + expect(() => scope?.recordResponse("Tool result")).not.toThrow(); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, + val: "conv-tool-resp", + }), + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: "Web" }), + expect.objectContaining({ + key: OpenTelemetryConstants.CHANNEL_LINK_KEY, + val: "https://web.link", + }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should set conversationId and channel tags when provided", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = ExecuteToolScope.start( + { + conversationId: "conv-tool-123", + channel: { name: "ChannelTool", description: "https://channel/tool" }, + }, + { toolName: "test-tool" }, + testAgentDetails, + ); + expect(scope).toBeInstanceOf(ExecuteToolScope); + + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, + val: "conv-tool-123", + }), + expect.objectContaining({ + key: OpenTelemetryConstants.CHANNEL_NAME_KEY, + val: "ChannelTool", + }), + expect.objectContaining({ + key: OpenTelemetryConstants.CHANNEL_LINK_KEY, + val: "https://channel/tool", + }), + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + }); + + describe("endpoint.port serialization", () => { + it("should record non-443 port as a number on ExecuteToolScope", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "test-tool", endpoint: { host: "tools.example.com", port: 8080 } }, + testAgentDetails, + ); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY, val: 8080 }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should omit port 443 on ExecuteToolScope", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "test-tool", endpoint: { host: "tools.example.com", port: 443 } }, + testAgentDetails, + ); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should record non-443 port as a number on InferenceScope", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = InferenceScope.start( + testRequest, + { + operationName: InferenceOperationType.CHAT, + model: "gpt-4", + endpoint: { host: "api.openai.com", port: 8443 }, + }, + testAgentDetails, + ); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY, val: 8443 }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should omit port 443 on InferenceScope", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = InferenceScope.start( + testRequest, + { + operationName: InferenceOperationType.CHAT, + model: "gpt-4", + endpoint: { host: "api.openai.com", port: 443 }, + }, + testAgentDetails, + ); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should record non-443 port as a number on InvokeAgentScope", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = InvokeAgentScope.start( + testRequest, + { endpoint: { host: "agent.example.com", port: 9090 } }, + { agentId: "test-agent", tenantId: "test-tenant-456" }, + ); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.SERVER_ADDRESS_KEY, + val: "agent.example.com", + }), + expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY, val: 9090 }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should omit port 443 on InvokeAgentScope", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const scope = InvokeAgentScope.start( + testRequest, + { endpoint: { host: "agent.example.com", port: 443 } }, + { agentId: "test-agent", tenantId: "test-tenant-456" }, + ); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + }); + + describe("InferenceScope", () => { + it("should create scope with inference details", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const callerDetails: UserDetails = { + userId: "caller-inf-1", + userEmail: "inf.user@contoso.com", + userName: "Inf User", + tenantId: "inf-tenant", + callerClientIp: "10.0.0.20", + }; + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: "gpt-4", + providerName: "openai", + inputTokens: 100, + outputTokens: 150, + finishReasons: ["stop"], + }; + + const scope = InferenceScope.start( + testRequest, + inferenceDetails, + testAgentDetails, + callerDetails, + ); + + expect(scope).toBeInstanceOf(InferenceScope); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: OpenTelemetryConstants.USER_ID_KEY, val: "caller-inf-1" }), + expect.objectContaining({ key: OpenTelemetryConstants.USER_NAME_KEY, val: "Inf User" }), + expect.objectContaining({ + key: OpenTelemetryConstants.USER_EMAIL_KEY, + val: "inf.user@contoso.com", + }), + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, + val: "10.0.0.20", + }), + ]), + ); + // Validate raw attribute key strings + const keySet = new Set(calls.map((c) => c.key)); + expect(keySet).toContain("user.id"); + expect(keySet).toContain("user.name"); + expect(keySet).toContain("user.email"); + expect(keySet).toContain("client.address"); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should create scope with minimal details", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.TEXT_COMPLETION, + model: "gpt-3.5-turbo", + }; + + const scope = InferenceScope.start( + { + conversationId: "conv-inf-min", + channel: { name: "Slack", description: "https://slack.link" }, + }, + inferenceDetails, + testAgentDetails, + ); + + expect(scope).toBeInstanceOf(InferenceScope); + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, + val: "conv-inf-min", + }), + expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: "Slack" }), + expect.objectContaining({ + key: OpenTelemetryConstants.CHANNEL_LINK_KEY, + val: "https://slack.link", + }), + ]), + ); + scope?.dispose(); + spy.mockRestore(); + }); + + it("should record granular telemetry", () => { + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: "gpt-4", + }; + + const scope = InferenceScope.start(testRequest, inferenceDetails, testAgentDetails); + + expect(() => scope?.recordInputMessages(["Input message"])).not.toThrow(); + expect(() => scope?.recordOutputMessages(["Generated response"])).not.toThrow(); + expect(() => scope?.recordInputTokens(50)).not.toThrow(); + expect(() => scope?.recordOutputTokens(100)).not.toThrow(); + expect(() => scope?.recordFinishReasons(["stop", "length"])).not.toThrow(); + scope?.dispose(); + }); + + it("should set conversationId and channel tags when provided", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: "gpt-4", + }; + + const scope = InferenceScope.start( + { + conversationId: "conv-inf-123", + channel: { name: "ChannelInf", description: "https://channel/inf" }, + }, + inferenceDetails, + testAgentDetails, + ); + expect(scope).toBeInstanceOf(InferenceScope); + + const calls = spy.mock.calls.map((args) => ({ key: args[0], val: args[1] })); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, + val: "conv-inf-123", + }), + expect.objectContaining({ + key: OpenTelemetryConstants.CHANNEL_NAME_KEY, + val: "ChannelInf", + }), + expect.objectContaining({ + key: OpenTelemetryConstants.CHANNEL_LINK_KEY, + val: "https://channel/inf", + }), + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + }); + + describe("Dispose pattern", () => { + it("should support manual dispose", () => { + const scope = InvokeAgentScope.start( + testRequest, + {}, + { + agentId: "test-agent", + tenantId: "test-tenant-456", + }, + ); + scope?.recordResponse("Manual dispose test"); + + expect(() => scope?.dispose()).not.toThrow(); + }); + + it("should support automatic disposal pattern", () => { + const toolDetails: ToolCallDetails = { toolName: "test-tool" }; + + expect(() => { + const scope = ExecuteToolScope.start(testRequest, toolDetails, testAgentDetails); + try { + scope?.recordResponse("Automatic disposal test"); + } finally { + scope?.dispose(); + } + }).not.toThrow(); + }); + }); + + describe("Custom start and end time", () => { + afterEach(() => { + sharedExporter.reset(); + }); + + /** Extract the last finished span from the in-memory exporter. */ + const getFinishedSpan = (): ReadableSpan => { + const spans = sharedExporter.getFinishedSpans(); + expect(spans.length).toBeGreaterThanOrEqual(1); + return spans[spans.length - 1]; + }; + + /** Convert an hrtime tuple to milliseconds. */ + const hrtimeToMs = (hr: [number, number]): number => hr[0] * 1000 + hr[1] / 1_000_000; + + it("should record constructor-provided start and end times on the span", () => { + const customStart = 1700000000000; // 2023-11-14T22:13:20Z + const customEnd = 1700000005000; // 5 seconds later + + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "my-tool" }, + testAgentDetails, + undefined, + { startTime: customStart, endTime: customEnd }, + ); + scope.dispose(); + + const span = getFinishedSpan(); + expect(hrtimeToMs(span.startTime as [number, number])).toBeCloseTo(customStart, -1); + expect(hrtimeToMs(span.endTime as [number, number])).toBeCloseTo(customEnd, -1); + }); + + it("setEndTime should override end time when called before dispose", () => { + const customStart = 1700000040000; + const laterEnd = 1700000048000; // 8 seconds later + + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "my-tool" }, + testAgentDetails, + undefined, + { startTime: customStart }, + ); + scope.setEndTime(laterEnd); + scope.dispose(); + + const span = getFinishedSpan(); + expect(hrtimeToMs(span.startTime as [number, number])).toBeCloseTo(customStart, -1); + expect(hrtimeToMs(span.endTime as [number, number])).toBeCloseTo(laterEnd, -1); + }); + + it("should support Date objects as start and end times", () => { + const customStart = new Date("2023-11-14T22:13:20.000Z"); + const customEnd = new Date("2023-11-14T22:13:25.000Z"); // 5 seconds later + + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "my-tool" }, + testAgentDetails, + undefined, + { startTime: customStart, endTime: customEnd }, + ); + scope.dispose(); + + const span = getFinishedSpan(); + expect(hrtimeToMs(span.startTime as [number, number])).toBeCloseTo(customStart.getTime(), -1); + expect(hrtimeToMs(span.endTime as [number, number])).toBeCloseTo(customEnd.getTime(), -1); + }); + + it("should support HrTime tuples as start and end times", () => { + const customStart: [number, number] = [1700000000, 0]; // 2023-11-14T22:13:20Z + const customEnd: [number, number] = [1700000005, 500000000]; // 5.5 seconds later + + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "my-tool" }, + testAgentDetails, + undefined, + { startTime: customStart, endTime: customEnd }, + ); + scope.dispose(); + + const span = getFinishedSpan(); + expect(hrtimeToMs(span.startTime as [number, number])).toBeCloseTo(1700000000000, -1); + expect(hrtimeToMs(span.endTime as [number, number])).toBeCloseTo(1700000005500, -1); + }); + + it("should use wall-clock time when no custom times are provided", () => { + const before = Date.now(); + const scope = ExecuteToolScope.start(testRequest, { toolName: "my-tool" }, testAgentDetails); + scope.dispose(); + const after = Date.now(); + + const span = getFinishedSpan(); + const spanStartMs = hrtimeToMs(span.startTime as [number, number]); + const spanEndMs = hrtimeToMs(span.endTime as [number, number]); + + expect(spanStartMs).toBeGreaterThanOrEqual(before - 1); + expect(spanEndMs).toBeLessThanOrEqual(after + 1); + }); + + it.each([ + ["CLIENT (default)", undefined, SpanKind.CLIENT], + ["SERVER", SpanKind.SERVER, SpanKind.SERVER], + ])("InvokeAgentScope spanKind: %s", (_label, input, expected) => { + const scope = InvokeAgentScope.start( + testRequest, + {}, + { agentId: "test-agent", tenantId: "test-tenant-456" }, + undefined, + input !== undefined ? { spanKind: input } : undefined, + ); + scope.dispose(); + expect(getFinishedSpan().kind).toBe(expected); + }); + + it.each([ + ["INTERNAL (default)", undefined, SpanKind.INTERNAL], + ["CLIENT (override)", SpanKind.CLIENT, SpanKind.CLIENT], + ])("ExecuteToolScope spanKind: %s", (_label, input, expected) => { + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "my-tool" }, + testAgentDetails, + undefined, + input !== undefined ? { spanKind: input } : undefined, + ); + scope.dispose(); + expect(getFinishedSpan().kind).toBe(expected); + }); + + it("recordCancellation should set error status and error.type attribute with default reason", () => { + const scope = ExecuteToolScope.start(testRequest, { toolName: "my-tool" }, testAgentDetails); + scope.recordCancellation(); + scope.dispose(); + + const span = getFinishedSpan(); + expect(span.status.code).toBe(2); // SpanStatusCode.ERROR + expect(span.status.message).toBe("Task was cancelled"); + expect(span.attributes[OpenTelemetryConstants.ERROR_TYPE_KEY]).toBe("TaskCanceledException"); + }); + + it("recordCancellation should use custom reason", () => { + const scope = ExecuteToolScope.start(testRequest, { toolName: "my-tool" }, testAgentDetails); + scope.recordCancellation("User aborted"); + scope.dispose(); + + const span = getFinishedSpan(); + expect(span.status.code).toBe(2); // SpanStatusCode.ERROR + expect(span.status.message).toBe("User aborted"); + expect(span.attributes[OpenTelemetryConstants.ERROR_TYPE_KEY]).toBe("TaskCanceledException"); + }); + }); +}); + +describe("Request content and message serialization (span attributes)", () => { + const testAgentDetails: AgentDetails = { + agentId: "test-agent", + agentName: "Test Agent", + tenantId: "test-tenant-456", + }; + const testRequest = { conversationId: "conv-1", channel: { name: "TestChannel" } }; + + beforeEach(() => { + sharedExporter.reset(); + }); + + const getLastSpan = (): ReadableSpan => { + const spans = sharedExporter.getFinishedSpans(); + expect(spans.length).toBeGreaterThanOrEqual(1); + return spans[spans.length - 1]; + }; + + describe("InvokeAgentScope – request.content as input messages", () => { + it("should record a single string as input message attribute", () => { + const scope = InvokeAgentScope.start( + { ...testRequest, content: "Hello agent" }, + {}, + testAgentDetails, + ); + scope.dispose(); + + const attributes = getLastSpan().attributes; + const parsed = JSON.parse( + attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string, + ); + expect(parsed.version).toBe("0.1.0"); + expect(parsed.messages).toHaveLength(1); + expect(parsed.messages[0].role).toBe("user"); + expect(parsed.messages[0].parts[0]).toEqual({ type: "text", content: "Hello agent" }); + }); + + it("should record a string array as input message attributes", () => { + const scope = InvokeAgentScope.start( + { ...testRequest, content: ["msg1", "msg2"] }, + {}, + testAgentDetails, + ); + scope.dispose(); + + const attributes = getLastSpan().attributes; + const parsed = JSON.parse( + attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string, + ); + expect(parsed.messages).toHaveLength(2); + expect(parsed.messages[0].parts[0].content).toBe("msg1"); + expect(parsed.messages[1].parts[0].content).toBe("msg2"); + }); + + it("should record a structured InputMessages wrapper as-is", () => { + const wrapper: InputMessages = { + version: A365_MESSAGE_SCHEMA_VERSION, + messages: [ + { role: MessageRole.SYSTEM, parts: [{ type: "text", content: "system prompt" }] }, + ], + }; + const scope = InvokeAgentScope.start( + { ...testRequest, content: wrapper }, + {}, + testAgentDetails, + ); + scope.dispose(); + + const attributes = getLastSpan().attributes; + const parsed = JSON.parse( + attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string, + ); + expect(parsed.version).toBe("0.1.0"); + expect(parsed.messages).toHaveLength(1); + expect(parsed.messages[0].role).toBe("system"); + }); + + it("should not set input messages when content is undefined", () => { + const scope = InvokeAgentScope.start(testRequest, {}, testAgentDetails); + scope.dispose(); + + const attributes = getLastSpan().attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY]).toBeUndefined(); + }); + }); + + describe("InvokeAgentScope – recordOutputMessages single string", () => { + it("should record a single string as output message attribute", () => { + const scope = InvokeAgentScope.start(testRequest, {}, testAgentDetails); + scope.recordOutputMessages("single output"); + scope.dispose(); + + const attributes = getLastSpan().attributes; + const parsed = JSON.parse( + attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string, + ); + expect(parsed.messages).toHaveLength(1); + expect(parsed.messages[0].role).toBe("assistant"); + expect(parsed.messages[0].parts[0].content).toBe("single output"); + }); + }); + + describe("ExecuteToolScope – tool args and response serialization", () => { + it("should serialize object arguments to span attribute", () => { + const objArgs = { query: "GDPR", maxResults: 5 }; + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "search", arguments: objArgs }, + testAgentDetails, + ); + scope.dispose(); + + const attributes = getLastSpan().attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY]).toBe(JSON.stringify(objArgs)); + }); + + it("should serialize object response to span attribute", () => { + const objResponse = { results: [{ title: "Doc A", relevance: 0.95 }] }; + const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); + scope.recordResponse(objResponse); + scope.dispose(); + + const attributes = getLastSpan().attributes; + expect(attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY]).toBe( + JSON.stringify(objResponse), + ); + }); + }); +}); + +// Validate attribute key constant values use the new schema namespace. +describe("Attribute key schema values", () => { + it("caller keys use user.* / client.* namespace", () => { + expect(OpenTelemetryConstants.USER_ID_KEY).toBe("user.id"); + expect(OpenTelemetryConstants.USER_NAME_KEY).toBe("user.name"); + expect(OpenTelemetryConstants.USER_EMAIL_KEY).toBe("user.email"); + expect(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY).toBe("client.address"); + }); + + it("agent baggage keys use microsoft.agent.* namespace", () => { + expect(OpenTelemetryConstants.GEN_AI_AGENT_EMAIL_KEY).toBe("microsoft.agent.user.email"); + expect(OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY).toBe("microsoft.agent.user.id"); + }); + + it("caller agent keys use microsoft.a365.* namespace", () => { + expect(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY).toBe( + "microsoft.a365.caller.agent.id", + ); + expect(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY).toBe( + "microsoft.a365.caller.agent.name", + ); + expect(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY).toBe( + "microsoft.a365.caller.agent.blueprint.id", + ); + expect(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_EMAIL_KEY).toBe( + "microsoft.a365.caller.agent.user.email", + ); + }); + + it("channel keys use microsoft.channel.* namespace", () => { + expect(OpenTelemetryConstants.CHANNEL_NAME_KEY).toBe("microsoft.channel.name"); + expect(OpenTelemetryConstants.CHANNEL_LINK_KEY).toBe("microsoft.channel.link"); + }); + + it("session and tenant keys use microsoft.* namespace", () => { + expect(OpenTelemetryConstants.SESSION_ID_KEY).toBe("microsoft.session.id"); + expect(OpenTelemetryConstants.SESSION_DESCRIPTION_KEY).toBe("microsoft.session.description"); + expect(OpenTelemetryConstants.TENANT_ID_KEY).toBe("microsoft.tenant.id"); + }); +}); + +describe("safeSerializeToJson", () => { + it("should serialize an object to JSON", () => { + const obj = { query: "test", count: 5 }; + expect(safeSerializeToJson(obj, "arguments")).toBe(JSON.stringify(obj)); + }); + + it("should return JSON error object for circular reference objects", () => { + const circular: Record = { a: 1 }; + circular.self = circular; + expect(safeSerializeToJson(circular, "arguments")).toBe( + JSON.stringify({ error: "serialization failed" }), + ); + }); + + it("should pass through a valid JSON object string as-is", () => { + expect(safeSerializeToJson('{"query":"test"}', "arguments")).toBe('{"query":"test"}'); + }); + + it("should pass through a valid JSON array string as-is", () => { + expect(safeSerializeToJson("[1,2,3]", "result")).toBe("[1,2,3]"); + }); + + it("should wrap a plain non-JSON string", () => { + expect(safeSerializeToJson("hello world", "arguments")).toBe('{"arguments":"hello world"}'); + }); + + it("should wrap bare JSON primitives instead of passing through", () => { + expect(safeSerializeToJson("42", "arguments")).toBe('{"arguments":"42"}'); + expect(safeSerializeToJson("true", "result")).toBe('{"result":"true"}'); + expect(safeSerializeToJson("null", "result")).toBe('{"result":"null"}'); + }); +});