From 182b9f240109553c183cc4d9699957f4c2bc1890 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:23:52 -0700 Subject: [PATCH 1/6] Revert "Replace vendored a365 with npm packages direct dependencies (#25)" This reverts commit 7f986ee7819a9582ae0ad86871ec72cc15f0fada. --- azure-monitor-opentelemetry/README.md | 20 ++++++++++++ src/a365/configuration/A365Configuration.ts | 32 ++++++------------- src/a365/exporter/utils.ts | 9 ++---- .../processors/PerRequestSpanProcessor.ts | 10 +++--- .../unit/a365/agent365Exporter.test.ts | 10 +++--- 5 files changed, 42 insertions(+), 39 deletions(-) create mode 100644 azure-monitor-opentelemetry/README.md diff --git a/azure-monitor-opentelemetry/README.md b/azure-monitor-opentelemetry/README.md new file mode 100644 index 00000000..cadfc2dd --- /dev/null +++ b/azure-monitor-opentelemetry/README.md @@ -0,0 +1,20 @@ +# azure-monitor-opentelemetry (Temporary) + +> **This is a temporary staging area for the `@azure/monitor-opentelemetry` package source.** + +This directory exists solely to validate the integration between the Microsoft OpenTelemetry distro and the Azure Monitor OpenTelemetry pipeline. It allows us to iterate on the distro configuration surface and understand the required changes before they are finalized upstream. + +## Important + +- **This is not the source of truth.** The canonical source for `@azure/monitor-opentelemetry` lives in the [Azure SDK for JavaScript](https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/monitor/monitor-opentelemetry) repository. +- **Do not make long-lived changes here.** Any changes required in `@azure/monitor-opentelemetry` should be developed in parallel in the actual Azure SDK repository. +- **This copy will be removed** once the upstream package exposes the necessary APIs and the distro can depend on a published release, or once the code is fully migrated into `src/`. + +## Migration Plan + +Per the distro planning document (see [PLANNING.md](../PLANNING.md) Phase 3), the Azure Monitor OpenTelemetry code will be migrated directly into this repository so it can be modified freely alongside the Microsoft distro code. Key goals: + +- Refactor for exporter-optional setup (skip Azure Monitor exporter attachment when not needed) +- Expose provider instances (`TracerProvider`, `MeterProvider`, `LoggerProvider`) after setup so the distro can layer additional exporters (OTLP, A365) +- Enable instrumentation activation to be driven by the distro configuration without requiring Azure Monitor-specific export +- Establish a synchronization process for backporting fixes between this repo and the standalone package during the dual-maintenance period diff --git a/src/a365/configuration/A365Configuration.ts b/src/a365/configuration/A365Configuration.ts index 1ce1909a..f29c50d0 100644 --- a/src/a365/configuration/A365Configuration.ts +++ b/src/a365/configuration/A365Configuration.ts @@ -12,22 +12,12 @@ import { JsonConfig } from "../../shared/jsonConfig.js"; /** * Parse an environment variable as a boolean. - * Recognizes 'true', '1', 'yes', 'on' (case-insensitive) as true and - * 'false', '0', 'no', 'off' as false. Unset, empty, or unrecognized values - * are ignored and return undefined so they do not override an existing - * configuration value. + * 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 | undefined { - if (!envValue) return undefined; - const normalized = envValue.trim().toLowerCase(); - if (!normalized) return undefined; - if (["true", "1", "yes", "on"].includes(normalized)) { - return true; - } - if (["false", "0", "no", "off"].includes(normalized)) { - return false; - } - return undefined; +function parseEnvBoolean(envValue: string | undefined): boolean { + if (!envValue) return false; + return ["true", "1", "yes", "on"].includes(envValue.trim().toLowerCase()); } /** @@ -121,14 +111,14 @@ export class A365Configuration { } // 4. Apply environment variable overrides (highest precedence) - const envEnabled = parseEnvBoolean(process.env[A365_ENV_VARS.EXPORTER_ENABLED]); + const envEnabled = process.env[A365_ENV_VARS.EXPORTER_ENABLED]; if (envEnabled !== undefined) { - enabled = envEnabled; + enabled = parseEnvBoolean(envEnabled); } - const envPerRequest = parseEnvBoolean(process.env[A365_ENV_VARS.PER_REQUEST_EXPORT]); + const envPerRequest = process.env[A365_ENV_VARS.PER_REQUEST_EXPORT]; if (envPerRequest !== undefined) { - perRequestExport = envPerRequest; + perRequestExport = parseEnvBoolean(envPerRequest); } const envScopes = process.env[A365_ENV_VARS.AUTH_SCOPES]?.trim(); @@ -144,10 +134,6 @@ export class A365Configuration { const envCluster = process.env[A365_ENV_VARS.CLUSTER_CATEGORY]?.toLowerCase(); if (envCluster && VALID_CLUSTER_CATEGORIES.has(envCluster)) { clusterCategory = envCluster as ClusterCategory; - } else if (envCluster) { - Logger.getInstance().warn( - `Invalid ${A365_ENV_VARS.CLUSTER_CATEGORY} value '${envCluster}'. Using default cluster category.`, - ); } // Assign resolved values diff --git a/src/a365/exporter/utils.ts b/src/a365/exporter/utils.ts index 5f38f718..2eb0234d 100644 --- a/src/a365/exporter/utils.ts +++ b/src/a365/exporter/utils.ts @@ -12,7 +12,6 @@ import { ATTR_GEN_AI_OUTPUT_MESSAGES, ATTR_MICROSOFT_TENANT_ID, } from "../../genai/semconv.js"; -import { A365_MESSAGE_SCHEMA_VERSION } from "../contracts.js"; // Message attribute keys that receive special truncation handling const MESSAGE_ATTR_KEYS: Set = new Set([ @@ -20,6 +19,8 @@ const MESSAGE_ATTR_KEYS: Set = new Set([ ATTR_GEN_AI_OUTPUT_MESSAGES, ]); +// A365 message schema version used in overflow sentinels +const A365_MESSAGE_SCHEMA_VERSION = "1.0"; const MESSAGE_ROLE_SYSTEM = "system"; /** @@ -125,12 +126,8 @@ export function statusName(code: SpanStatusCode): string { export function resolveAgent365Endpoint(clusterCategory: ClusterCategory): string { switch (clusterCategory) { case "prod": - return "https://agent365.svc.cloud.microsoft"; default: - throw new Error( - `Unsupported Agent365 cluster category "${clusterCategory}". ` + - "Configure an explicit domain override or add a mapped endpoint for this cluster category.", - ); + return "https://agent365.svc.cloud.microsoft"; } } diff --git a/src/a365/processors/PerRequestSpanProcessor.ts b/src/a365/processors/PerRequestSpanProcessor.ts index bdee6eef..aa6332ca 100644 --- a/src/a365/processors/PerRequestSpanProcessor.ts +++ b/src/a365/processors/PerRequestSpanProcessor.ts @@ -135,14 +135,14 @@ export class PerRequestSpanProcessor implements SpanProcessor { this.traces.set(traceId, buf); this.ensureSweepTimer(); - logger.debug( + logger.info( `[PerRequestSpanProcessor] Trace started traceId=${traceId} maxTraceAgeMs=${this.maxTraceAgeMs}`, ); } buf.openCount += 1; const root = isRootSpanFromContext(ctx); - logger.debug( + logger.info( `[PerRequestSpanProcessor] Span start traceId=${traceId} spanId=${span.spanContext().spanId}` + ` root=${root} openCount=${buf.openCount}`, ); @@ -179,7 +179,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { buf.openCount = 0; } - logger.debug( + logger.info( `[PerRequestSpanProcessor] Span end name=${span.name} traceId=${traceId} spanId=${span.spanContext().spanId}` + ` root=${isRootSpan(span)} openCount=${buf.openCount} rootEnded=${buf.rootEnded}`, ); @@ -269,7 +269,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { const spans = trace.spans; if (spans.length === 0) return; - logger.debug( + logger.info( `[PerRequestSpanProcessor] Flushing trace traceId=${traceId} reason=${reason} spans=${spans.length} rootEnded=${trace.rootEnded}`, ); @@ -294,7 +294,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { result.error, ); } else { - logger.debug( + logger.info( `[PerRequestSpanProcessor] Export succeeded traceId=${traceId} reason=${reason} spans=${spans.length}`, ); } diff --git a/test/internal/unit/a365/agent365Exporter.test.ts b/test/internal/unit/a365/agent365Exporter.test.ts index 7457d4ce..bfb04f68 100644 --- a/test/internal/unit/a365/agent365Exporter.test.ts +++ b/test/internal/unit/a365/agent365Exporter.test.ts @@ -743,10 +743,10 @@ describe("Exporter utils", () => { assert.strictEqual(resolveAgent365Endpoint("prod"), "https://agent365.svc.cloud.microsoft"); }); - it("should throw for unsupported cluster categories", () => { - assert.throws( - () => resolveAgent365Endpoint("preprod"), - /Unsupported Agent365 cluster category "preprod"/, + it("should default to prod endpoint for unknown categories", () => { + assert.strictEqual( + resolveAgent365Endpoint("preprod"), + "https://agent365.svc.cloud.microsoft", ); }); }); @@ -1302,7 +1302,7 @@ describe("Exporter utils", () => { const result = truncateSpan(span); const sentinelValue = result.attributes!["gen_ai.input.messages"] as string; const parsed = JSON.parse(sentinelValue); - assert.strictEqual(parsed.version, "0.1.0"); + assert.strictEqual(parsed.version, "1.0"); assert.strictEqual(parsed.messages.length, 1); assert.strictEqual(parsed.messages[0].role, "system"); assert.strictEqual(parsed.messages[0].parts[0].type, "text"); From fca54e0e2d5931eba66409a40b8afe3c50196b61 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:34:11 -0700 Subject: [PATCH 2/6] Update --- azure-monitor-opentelemetry/README.md | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 azure-monitor-opentelemetry/README.md diff --git a/azure-monitor-opentelemetry/README.md b/azure-monitor-opentelemetry/README.md deleted file mode 100644 index cadfc2dd..00000000 --- a/azure-monitor-opentelemetry/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# azure-monitor-opentelemetry (Temporary) - -> **This is a temporary staging area for the `@azure/monitor-opentelemetry` package source.** - -This directory exists solely to validate the integration between the Microsoft OpenTelemetry distro and the Azure Monitor OpenTelemetry pipeline. It allows us to iterate on the distro configuration surface and understand the required changes before they are finalized upstream. - -## Important - -- **This is not the source of truth.** The canonical source for `@azure/monitor-opentelemetry` lives in the [Azure SDK for JavaScript](https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/monitor/monitor-opentelemetry) repository. -- **Do not make long-lived changes here.** Any changes required in `@azure/monitor-opentelemetry` should be developed in parallel in the actual Azure SDK repository. -- **This copy will be removed** once the upstream package exposes the necessary APIs and the distro can depend on a published release, or once the code is fully migrated into `src/`. - -## Migration Plan - -Per the distro planning document (see [PLANNING.md](../PLANNING.md) Phase 3), the Azure Monitor OpenTelemetry code will be migrated directly into this repository so it can be modified freely alongside the Microsoft distro code. Key goals: - -- Refactor for exporter-optional setup (skip Azure Monitor exporter attachment when not needed) -- Expose provider instances (`TracerProvider`, `MeterProvider`, `LoggerProvider`) after setup so the distro can layer additional exporters (OTLP, A365) -- Enable instrumentation activation to be driven by the distro configuration without requiring Azure Monitor-specific export -- Establish a synchronization process for backporting fixes between this repo and the standalone package during the dual-maintenance period From 7727bae5652da1a01db6f7076abd4d2883bd24c6 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:44:42 -0700 Subject: [PATCH 3/6] Update --- src/a365/configuration/A365Configuration.ts | 32 +++++++++++++------ src/a365/exporter/utils.ts | 9 ++++-- .../processors/PerRequestSpanProcessor.ts | 10 +++--- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/a365/configuration/A365Configuration.ts b/src/a365/configuration/A365Configuration.ts index f29c50d0..1ce1909a 100644 --- a/src/a365/configuration/A365Configuration.ts +++ b/src/a365/configuration/A365Configuration.ts @@ -12,12 +12,22 @@ 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. + * Recognizes 'true', '1', 'yes', 'on' (case-insensitive) as true and + * 'false', '0', 'no', 'off' as false. Unset, empty, or unrecognized values + * are ignored and return undefined so they do not override an existing + * configuration value. */ -function parseEnvBoolean(envValue: string | undefined): boolean { - if (!envValue) return false; - return ["true", "1", "yes", "on"].includes(envValue.trim().toLowerCase()); +function parseEnvBoolean(envValue: string | undefined): boolean | undefined { + if (!envValue) return undefined; + const normalized = envValue.trim().toLowerCase(); + if (!normalized) return undefined; + if (["true", "1", "yes", "on"].includes(normalized)) { + return true; + } + if (["false", "0", "no", "off"].includes(normalized)) { + return false; + } + return undefined; } /** @@ -111,14 +121,14 @@ export class A365Configuration { } // 4. Apply environment variable overrides (highest precedence) - const envEnabled = process.env[A365_ENV_VARS.EXPORTER_ENABLED]; + const envEnabled = parseEnvBoolean(process.env[A365_ENV_VARS.EXPORTER_ENABLED]); if (envEnabled !== undefined) { - enabled = parseEnvBoolean(envEnabled); + enabled = envEnabled; } - const envPerRequest = process.env[A365_ENV_VARS.PER_REQUEST_EXPORT]; + const envPerRequest = parseEnvBoolean(process.env[A365_ENV_VARS.PER_REQUEST_EXPORT]); if (envPerRequest !== undefined) { - perRequestExport = parseEnvBoolean(envPerRequest); + perRequestExport = envPerRequest; } const envScopes = process.env[A365_ENV_VARS.AUTH_SCOPES]?.trim(); @@ -134,6 +144,10 @@ export class A365Configuration { const envCluster = process.env[A365_ENV_VARS.CLUSTER_CATEGORY]?.toLowerCase(); if (envCluster && VALID_CLUSTER_CATEGORIES.has(envCluster)) { clusterCategory = envCluster as ClusterCategory; + } else if (envCluster) { + Logger.getInstance().warn( + `Invalid ${A365_ENV_VARS.CLUSTER_CATEGORY} value '${envCluster}'. Using default cluster category.`, + ); } // Assign resolved values diff --git a/src/a365/exporter/utils.ts b/src/a365/exporter/utils.ts index 2eb0234d..5f38f718 100644 --- a/src/a365/exporter/utils.ts +++ b/src/a365/exporter/utils.ts @@ -12,6 +12,7 @@ import { ATTR_GEN_AI_OUTPUT_MESSAGES, ATTR_MICROSOFT_TENANT_ID, } from "../../genai/semconv.js"; +import { A365_MESSAGE_SCHEMA_VERSION } from "../contracts.js"; // Message attribute keys that receive special truncation handling const MESSAGE_ATTR_KEYS: Set = new Set([ @@ -19,8 +20,6 @@ const MESSAGE_ATTR_KEYS: Set = new Set([ ATTR_GEN_AI_OUTPUT_MESSAGES, ]); -// A365 message schema version used in overflow sentinels -const A365_MESSAGE_SCHEMA_VERSION = "1.0"; const MESSAGE_ROLE_SYSTEM = "system"; /** @@ -126,8 +125,12 @@ export function statusName(code: SpanStatusCode): string { export function resolveAgent365Endpoint(clusterCategory: ClusterCategory): string { switch (clusterCategory) { case "prod": - default: return "https://agent365.svc.cloud.microsoft"; + default: + throw new Error( + `Unsupported Agent365 cluster category "${clusterCategory}". ` + + "Configure an explicit domain override or add a mapped endpoint for this cluster category.", + ); } } diff --git a/src/a365/processors/PerRequestSpanProcessor.ts b/src/a365/processors/PerRequestSpanProcessor.ts index aa6332ca..bdee6eef 100644 --- a/src/a365/processors/PerRequestSpanProcessor.ts +++ b/src/a365/processors/PerRequestSpanProcessor.ts @@ -135,14 +135,14 @@ export class PerRequestSpanProcessor implements SpanProcessor { this.traces.set(traceId, buf); this.ensureSweepTimer(); - logger.info( + logger.debug( `[PerRequestSpanProcessor] Trace started traceId=${traceId} maxTraceAgeMs=${this.maxTraceAgeMs}`, ); } buf.openCount += 1; const root = isRootSpanFromContext(ctx); - logger.info( + logger.debug( `[PerRequestSpanProcessor] Span start traceId=${traceId} spanId=${span.spanContext().spanId}` + ` root=${root} openCount=${buf.openCount}`, ); @@ -179,7 +179,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { buf.openCount = 0; } - logger.info( + logger.debug( `[PerRequestSpanProcessor] Span end name=${span.name} traceId=${traceId} spanId=${span.spanContext().spanId}` + ` root=${isRootSpan(span)} openCount=${buf.openCount} rootEnded=${buf.rootEnded}`, ); @@ -269,7 +269,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { const spans = trace.spans; if (spans.length === 0) return; - logger.info( + logger.debug( `[PerRequestSpanProcessor] Flushing trace traceId=${traceId} reason=${reason} spans=${spans.length} rootEnded=${trace.rootEnded}`, ); @@ -294,7 +294,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { result.error, ); } else { - logger.info( + logger.debug( `[PerRequestSpanProcessor] Export succeeded traceId=${traceId} reason=${reason} spans=${spans.length}`, ); } From 30c676be8e19d329b6e0a75f783b463e9b97a064 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:49:26 -0700 Subject: [PATCH 4/6] Update test --- test/internal/unit/a365/agent365Exporter.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/internal/unit/a365/agent365Exporter.test.ts b/test/internal/unit/a365/agent365Exporter.test.ts index bfb04f68..7457d4ce 100644 --- a/test/internal/unit/a365/agent365Exporter.test.ts +++ b/test/internal/unit/a365/agent365Exporter.test.ts @@ -743,10 +743,10 @@ describe("Exporter utils", () => { assert.strictEqual(resolveAgent365Endpoint("prod"), "https://agent365.svc.cloud.microsoft"); }); - it("should default to prod endpoint for unknown categories", () => { - assert.strictEqual( - resolveAgent365Endpoint("preprod"), - "https://agent365.svc.cloud.microsoft", + it("should throw for unsupported cluster categories", () => { + assert.throws( + () => resolveAgent365Endpoint("preprod"), + /Unsupported Agent365 cluster category "preprod"/, ); }); }); @@ -1302,7 +1302,7 @@ describe("Exporter utils", () => { const result = truncateSpan(span); const sentinelValue = result.attributes!["gen_ai.input.messages"] as string; const parsed = JSON.parse(sentinelValue); - assert.strictEqual(parsed.version, "1.0"); + assert.strictEqual(parsed.version, "0.1.0"); assert.strictEqual(parsed.messages.length, 1); assert.strictEqual(parsed.messages[0].role, "system"); assert.strictEqual(parsed.messages[0].parts[0].type, "text"); From c6b3d2c59de109e68cc84e54f005615a0d0be811 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:43:40 -0700 Subject: [PATCH 5/6] feat(a365): add hosting observability middleware and utilities Add hosting-layer middleware adapted from Agent365-nodejs that enables automatic OpenTelemetry instrumentation for agents-hosting compatible adapters using structural typing (no direct dependency on @microsoft/agents-hosting). Hosting middleware: - BaggageMiddleware: propagates OTel baggage (tenant, agent, caller, channel, conversation) from TurnContext; skips ContinueConversation - OutputLoggingMiddleware: creates OutputScope spans for outgoing messages with parent span linking - ObservabilityHostingManager: one-call middleware registration Hosting utilities: - TurnContextUtils: extract baggage pairs from TurnContext activity - ScopeUtils: create InferenceScope/InvokeAgentScope/ExecuteToolScope pre-populated from TurnContext - BaggageBuilderUtils: populate BaggageBuilder from TurnContext Bug fixes (vendored A365 code vs upstream): - Fix S2S exporter endpoint path (add /otlp/ segment) - Fix SDK version constant to read from package version - Add ~15 missing OpenTelemetryConstants from upstream Also includes: - Comprehensive unit tests (6 test files, 62 new tests) - Hosting middleware sample app (a365HostingMiddleware.ts) - Updated samples README --- samples/README.md | 2 + samples/src/a365HostingMiddleware.ts | 358 +++++++++++++++ src/a365/constants.ts | 30 +- src/a365/exporter/Agent365Exporter.ts | 5 +- src/a365/hosting/baggageBuilderUtils.ts | 69 +++ src/a365/hosting/baggageMiddleware.ts | 47 ++ src/a365/hosting/index.ts | 27 ++ .../hosting/observabilityHostingManager.ts | 72 +++ src/a365/hosting/outputLoggingMiddleware.ts | 126 ++++++ src/a365/hosting/scopeUtils.ts | 249 +++++++++++ src/a365/hosting/turnContextUtils.ts | 154 +++++++ src/a365/hosting/types.ts | 62 +++ src/a365/index.ts | 24 + src/a365/middleware/BaggageBuilder.ts | 2 +- src/index.ts | 24 + .../unit/a365/agent365Exporter.test.ts | 6 +- .../a365/hosting/baggageBuilderUtils.test.ts | 128 ++++++ .../a365/hosting/baggageMiddleware.test.ts | 169 +++++++ .../observabilityHostingManager.test.ts | 68 +++ .../hosting/outputLoggingMiddleware.test.ts | 321 ++++++++++++++ .../unit/a365/hosting/scopeUtils.test.ts | 411 ++++++++++++++++++ .../a365/hosting/turnContextUtils.test.ts | 222 ++++++++++ 22 files changed, 2569 insertions(+), 7 deletions(-) create mode 100644 samples/src/a365HostingMiddleware.ts create mode 100644 src/a365/hosting/baggageBuilderUtils.ts create mode 100644 src/a365/hosting/baggageMiddleware.ts create mode 100644 src/a365/hosting/index.ts create mode 100644 src/a365/hosting/observabilityHostingManager.ts create mode 100644 src/a365/hosting/outputLoggingMiddleware.ts create mode 100644 src/a365/hosting/scopeUtils.ts create mode 100644 src/a365/hosting/turnContextUtils.ts create mode 100644 src/a365/hosting/types.ts create mode 100644 test/internal/unit/a365/hosting/baggageBuilderUtils.test.ts create mode 100644 test/internal/unit/a365/hosting/baggageMiddleware.test.ts create mode 100644 test/internal/unit/a365/hosting/observabilityHostingManager.test.ts create mode 100644 test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts create mode 100644 test/internal/unit/a365/hosting/scopeUtils.test.ts create mode 100644 test/internal/unit/a365/hosting/turnContextUtils.test.ts diff --git a/samples/README.md b/samples/README.md index a8dd60c9..5b9b3f52 100644 --- a/samples/README.md +++ b/samples/README.md @@ -26,6 +26,7 @@ These sample programs show how to use the `@microsoft/opentelemetry` distributio | [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. | +| [a365HostingMiddleware.ts][a365hostingmiddleware] | Demonstrates A365 hosting middleware (BaggageMiddleware, OutputLoggingMiddleware, ObservabilityHostingManager, ScopeUtils). | ## Prerequisites @@ -78,3 +79,4 @@ APPLICATIONINSIGHTS_CONNECTION_STRING="" node dist/basic [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 +[a365hostingmiddleware]: https://github.com/Azure/opentelemetry-distro-javascript/blob/main/samples/src/a365HostingMiddleware.ts diff --git a/samples/src/a365HostingMiddleware.ts b/samples/src/a365HostingMiddleware.ts new file mode 100644 index 00000000..b1e5ca06 --- /dev/null +++ b/samples/src/a365HostingMiddleware.ts @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @summary Demonstrates how to use A365 hosting observability middleware. + * + * This sample shows how the hosting package works with an `@microsoft/agents-hosting` + * compatible adapter. The middleware automatically: + * - Extracts baggage (tenant, agent, caller, channel, conversation) from TurnContext + * - Creates OutputScope spans for outgoing messages + * - Links output spans to a parent InvokeAgentScope + * + * Since this sample does not require a running agents-hosting server, it uses + * mock objects that satisfy the TurnContextLike/MiddlewareLike interfaces. + */ + +import { + useMicrosoftOpenTelemetry, + shutdownMicrosoftOpenTelemetry, + + // Hosting middleware + BaggageMiddleware, + OutputLoggingMiddleware, + ObservabilityHostingManager, + A365_PARENT_SPAN_KEY, + A365_AUTH_TOKEN_KEY, + + // Hosting utilities + BaggageBuilderUtils, + ScopeUtils, + BaggageBuilder, + + // Scopes — used to create the parent InvokeAgentScope + InvokeAgentScope, + InferenceScope, + InferenceOperationType, +} from "@microsoft/opentelemetry"; +import type { + TurnContextLike, + ActivityLike, + MiddlewareLike, + AgentDetails, + InferenceDetails, +} from "@microsoft/opentelemetry"; +import "dotenv/config"; + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────────────── + +async function myTokenResolver(agentId: string, tenantId: string): Promise { + console.log(` [auth] Resolving token for agent=${agentId}, tenant=${tenantId}`); + return process.env.A365_BEARER_TOKEN || ""; +} + +/** + * Creates a mock TurnContext that looks like an incoming agents-hosting request. + * In production, this object is created by the adapter from an incoming Activity. + */ +function createMockTurnContext(): TurnContextLike { + const activity: ActivityLike = { + type: "message", + text: "What's the weather in Seattle?", + channelId: "msteams", + channelIdSubChannel: "general", + serviceUrl: "https://smba.trafficmanager.net/teams/", + from: { + aadObjectId: "user-aad-object-id-123", + name: "Jane Doe", + agenticUserId: "jane@contoso.com", + agenticAppBlueprintId: "caller-blueprint-001", + }, + recipient: { + aadObjectId: "agent-aad-object-id-456", + name: "WeatherBot", + role: "An agent that answers weather questions", + }, + conversation: { + id: "conv-12345", + }, + isAgenticRequest: () => true, + getAgenticInstanceId: () => "weather-agent-001", + getAgenticTenantId: () => "contoso-tenant-id", + getAgenticUser: () => "weatherbot@contoso.com", + }; + + const turnState = new Map(); + const sendHandlers: Array<(ctx: TurnContextLike, activities: ActivityLike[], next: () => Promise) => Promise> = []; + + return { + activity, + turnState, + onSendActivities(handler) { + sendHandlers.push(handler); + }, + // Simulated sendActivity — runs through registered handlers + async sendActivity(text: string): Promise { + const outgoing: ActivityLike[] = [{ type: "message", text }]; + const sendNext = async () => { + console.log(` [adapter] Sending message: "${text}"`); + return [{ id: "msg-001" }]; + }; + // Run through handlers (last registered first, like real adapter) + let chain = sendNext; + for (const h of [...sendHandlers].reverse()) { + const prev = chain; + chain = () => h(this as unknown as TurnContextLike, outgoing, prev as () => Promise); + } + await chain(); + }, + } as TurnContextLike & { sendActivity(text: string): Promise }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Demo 1: ObservabilityHostingManager (simplest setup) +// ──────────────────────────────────────────────────────────────────────────── + +function demoHostingManager(): void { + console.log("\n=== Demo 1: ObservabilityHostingManager ===\n"); + + // In production this would be your real adapter instance + const registeredMiddleware: MiddlewareLike[] = []; + const mockAdapter = { + use(...mws: MiddlewareLike[]) { + registeredMiddleware.push(...mws); + console.log(` [adapter] Registered ${mws.length} middleware(s)`); + }, + }; + + const manager = new ObservabilityHostingManager(); + manager.configure(mockAdapter, { + enableBaggage: true, + enableOutputLogging: true, + }); + + console.log(` Registered middleware count: ${registeredMiddleware.length}`); + console.log(` Types: ${registeredMiddleware.map((m) => m.constructor.name).join(", ")}`); + + // Calling configure again is a no-op + manager.configure(mockAdapter, { enableBaggage: true }); + console.log(" Second configure() call was ignored (as expected)."); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Demo 2: BaggageMiddleware — automatic baggage propagation +// ──────────────────────────────────────────────────────────────────────────── + +async function demoBaggageMiddleware(): Promise { + console.log("\n=== Demo 2: BaggageMiddleware ===\n"); + + const middleware = new BaggageMiddleware(); + const ctx = createMockTurnContext(); + + await middleware.onTurn(ctx, async () => { + // Inside here, baggage is active in the OTel context. + // Any spans created will be enriched by A365SpanProcessor. + console.log(" Inside middleware — baggage is active"); + console.log(" Creating a span that will inherit baggage attributes..."); + + // The BaggageBuilder can also be used manually for additional entries + const builder = new BaggageBuilder().sessionId("session-xyz"); + BaggageBuilderUtils.fromTurnContext(builder, ctx); + + const scope = builder.build(); + await scope.run(async () => { + console.log(" Inside BaggageScope — additional baggage applied"); + }); + }); + + console.log(" Middleware turn completed."); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Demo 3: OutputLoggingMiddleware — auto OutputScope on outgoing messages +// ──────────────────────────────────────────────────────────────────────────── + +async function demoOutputLoggingMiddleware(): Promise { + console.log("\n=== Demo 3: OutputLoggingMiddleware ===\n"); + + const middleware = new OutputLoggingMiddleware(); + const ctx = createMockTurnContext() as TurnContextLike & { sendActivity(text: string): Promise }; + + // Set the auth token so the middleware can derive agent details + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + // Create an InvokeAgentScope and store its ref in turnState + const invokeScope = InvokeAgentScope.start( + { conversationId: "conv-12345" }, + {}, + { + agentId: "weather-agent-001", + agentName: "WeatherBot", + tenantId: "contoso-tenant-id", + }, + ); + ctx.turnState.set(A365_PARENT_SPAN_KEY, invokeScope.getParentSpanRef()); + console.log(` InvokeAgentScope started (traceId: ${invokeScope.getSpanContext().traceId})`); + + await middleware.onTurn(ctx, async () => { + // Simulate agent processing and sending a response + console.log(" Agent processing request..."); + await ctx.sendActivity("It's 62°F and partly cloudy in Seattle."); + console.log(" OutputLoggingMiddleware created an OutputScope span for the outgoing message."); + }); + + invokeScope.dispose(); + console.log(" InvokeAgentScope ended."); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Demo 4: ScopeUtils — create scopes from TurnContext +// ──────────────────────────────────────────────────────────────────────────── + +async function demoScopeUtils(): Promise { + console.log("\n=== Demo 4: ScopeUtils — scopes from TurnContext ===\n"); + + const ctx = createMockTurnContext(); + const authToken = ""; + + // Derive agent/caller details + const agent = ScopeUtils.deriveAgentDetails(ctx, authToken); + console.log(` Derived agent: ${agent?.agentName} (${agent?.agentId})`); + + const caller = ScopeUtils.deriveCallerDetails(ctx); + console.log(` Derived caller: ${caller?.userName} (${caller?.userId})`); + + const conversationId = ScopeUtils.deriveConversationId(ctx); + console.log(` Derived conversationId: ${conversationId}`); + + const channel = ScopeUtils.deriveChannelObject(ctx); + console.log(` Derived channel: ${channel.name} / ${channel.description}`); + + // Create an InferenceScope directly from TurnContext + const inferenceDetails: InferenceDetails = { + operationName: InferenceOperationType.CHAT, + model: "gpt-4o", + providerName: "azure-openai", + }; + + const inferenceScope = ScopeUtils.populateInferenceScopeFromTurnContext( + inferenceDetails, + ctx, + authToken, + ); + console.log(` InferenceScope created from TurnContext (traceId: ${inferenceScope.getSpanContext().traceId})`); + console.log(" Input messages from activity.text were auto-recorded."); + + // Simulate response + inferenceScope.recordOutputMessages(["It's 62°F and partly cloudy in Seattle."]); + inferenceScope.recordInputTokens(45); + inferenceScope.recordOutputTokens(12); + inferenceScope.recordFinishReasons(["stop"]); + inferenceScope.dispose(); + + console.log(" InferenceScope completed."); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Demo 5: Full agent turn with middleware +// ──────────────────────────────────────────────────────────────────────────── + +async function demoFullAgentTurn(): Promise { + console.log("\n=== Demo 5: Full Agent Turn with Middleware ===\n"); + + const ctx = createMockTurnContext() as TurnContextLike & { sendActivity(text: string): Promise }; + const authToken = ""; + + // Register middleware + const baggageMiddleware = new BaggageMiddleware(); + const outputMiddleware = new OutputLoggingMiddleware(); + + // Simulate the adapter pipeline: baggage → output → agent logic + await baggageMiddleware.onTurn(ctx, async () => { + console.log(" [baggage] Baggage context active"); + + ctx.turnState.set(A365_AUTH_TOKEN_KEY, authToken); + + await outputMiddleware.onTurn(ctx, async () => { + console.log(" [output] Output logging middleware active"); + + // Agent logic + const agentDetails: AgentDetails = ScopeUtils.buildInvokeAgentDetails( + { agentName: "WeatherBot" } as AgentDetails, + ctx, + authToken, + ); + + const invokeScope = InvokeAgentScope.start( + { conversationId: ctx.activity.conversation?.id }, + {}, + agentDetails, + ); + ctx.turnState.set(A365_PARENT_SPAN_KEY, invokeScope.getParentSpanRef()); + + try { + console.log(` [agent] Processing: "${ctx.activity.text}"`); + + // LLM inference + const inference = ScopeUtils.populateInferenceScopeFromTurnContext( + { operationName: InferenceOperationType.CHAT, model: "gpt-4o", providerName: "azure-openai" }, + ctx, + authToken, + ); + inference.recordOutputMessages(["It's 62°F and partly cloudy."]); + inference.recordFinishReasons(["stop"]); + inference.dispose(); + + // Send response — OutputLoggingMiddleware will create an OutputScope span + await ctx.sendActivity("It's 62°F and partly cloudy in Seattle."); + + invokeScope.recordResponse("It's 62°F and partly cloudy in Seattle."); + } catch (err) { + invokeScope.recordError(err as Error); + throw err; + } finally { + invokeScope.dispose(); + } + }); + }); + + console.log(" Full turn completed."); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Main +// ──────────────────────────────────────────────────────────────────────────── + +async function main(): Promise { + useMicrosoftOpenTelemetry({ + azureMonitor: { + azureMonitorExporterOptions: { + connectionString: + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING || "", + }, + }, + a365: { + enabled: true, + tokenResolver: myTokenResolver, + clusterCategory: "dev", + }, + }); + + console.log("=== A365 Hosting Middleware Demo ==="); + + demoHostingManager(); + await demoBaggageMiddleware(); + await demoOutputLoggingMiddleware(); + await demoScopeUtils(); + await demoFullAgentTurn(); + + console.log("\n=== All demos completed ==="); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + await shutdownMicrosoftOpenTelemetry(); + console.log("Done. Check your telemetry backend for traces."); +} + +main().catch(console.error); diff --git a/src/a365/constants.ts b/src/a365/constants.ts index 7c89fd51..a7cf587e 100644 --- a/src/a365/constants.ts +++ b/src/a365/constants.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { MICROSOFT_OPENTELEMETRY_VERSION } from "../types.js"; + /** * OpenTelemetry constants for A365 observability. * @@ -23,16 +25,37 @@ export class OpenTelemetryConstants { public static readonly AZ_NAMESPACE_KEY = "az.namespace"; public static readonly SERVER_ADDRESS_KEY = "server.address"; public static readonly SERVER_PORT_KEY = "server.port"; + public static readonly AZURE_RP_NAMESPACE_VALUE = "Microsoft.CognitiveServices"; // ── Source / SDK identity ───────────────────────────────────────── public static readonly SOURCE_NAME = "Agent365Sdk"; + // ── Feature flags / env var names ───────────────────────────────── + public static readonly ENABLE_OPENTELEMETRY_SWITCH = "Azure.Experimental.EnableActivitySource"; + public static readonly TRACE_CONTENTS_SWITCH = "Azure.Experimental.TraceGenAIMessageContent"; + public static readonly TRACE_CONTENTS_ENVIRONMENT_VARIABLE = + "AZURE_TRACING_GEN_AI_CONTENT_RECORDING_ENABLED"; + public static readonly ENABLE_OBSERVABILITY = "ENABLE_OBSERVABILITY"; + public static readonly ENABLE_A365_OBSERVABILITY_EXPORTER = "ENABLE_A365_OBSERVABILITY_EXPORTER"; + public static readonly ENABLE_A365_OBSERVABILITY = "ENABLE_A365_OBSERVABILITY"; + public static readonly ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = + "ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT"; + // ── 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"; + public static readonly GEN_AI_REQUEST_MAX_TOKENS_KEY = "gen_ai.request.max_tokens"; + public static readonly GEN_AI_REQUEST_TEMPERATURE_KEY = "gen_ai.request.temperature"; + public static readonly GEN_AI_REQUEST_TOP_P_KEY = "gen_ai.request.top_p"; + public static readonly GEN_AI_CHOICE = "gen_ai.choice"; + + // ── GenAI metrics ──────────────────────────────────────────────── + public static readonly GEN_AI_CLIENT_OPERATION_DURATION_METRIC_NAME = + "gen_ai.client.operation.duration"; + public static readonly GEN_AI_CLIENT_TOKEN_USAGE_METRIC_NAME = "gen_ai.client.token.usage"; // ── GenAI usage ────────────────────────────────────────────────── public static readonly GEN_AI_USAGE_INPUT_TOKENS_KEY = "gen_ai.usage.input_tokens"; @@ -53,6 +76,7 @@ export class OpenTelemetryConstants { 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"; + public static readonly AGENT_ID_KEY = "gen_ai.agent.id"; // ── GenAI conversation / session ───────────────────────────────── public static readonly GEN_AI_CONVERSATION_ID_KEY = "gen_ai.conversation.id"; @@ -93,6 +117,10 @@ export class OpenTelemetryConstants { 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"; + // ── Execution context ──────────────────────────────────────────── + public static readonly GEN_AI_TASK_ID_KEY = "gen_ai.task.id"; + public static readonly GEN_AI_EXECUTION_PAYLOAD_KEY = "gen_ai.execution.payload"; + // ── Channel dimensions ─────────────────────────────────────────── public static readonly CHANNEL_NAME_KEY = "microsoft.channel.name"; public static readonly CHANNEL_LINK_KEY = "microsoft.channel.link"; @@ -110,5 +138,5 @@ export class OpenTelemetryConstants { 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"; + public static readonly TELEMETRY_SDK_VERSION_VALUE = MICROSOFT_OPENTELEMETRY_VERSION; } diff --git a/src/a365/exporter/Agent365Exporter.ts b/src/a365/exporter/Agent365Exporter.ts index e1f4c7ca..f0b65c12 100644 --- a/src/a365/exporter/Agent365Exporter.ts +++ b/src/a365/exporter/Agent365Exporter.ts @@ -129,9 +129,8 @@ export class Agent365Exporter implements SpanExporter { const payload = this.buildExportRequest(spans); const body = JSON.stringify(payload); - const endpointPath = this.options.useS2SEndpoint - ? `/observabilityService/tenants/${encodeURIComponent(tenantId)}/agents/${encodeURIComponent(agentId)}/traces` - : `/observability/tenants/${encodeURIComponent(tenantId)}/agents/${encodeURIComponent(agentId)}/traces`; + const servicePrefix = this.options.useS2SEndpoint ? "/observabilityService" : "/observability"; + const endpointPath = `${servicePrefix}/tenants/${encodeURIComponent(tenantId)}/otlp/agents/${encodeURIComponent(agentId)}/traces`; const baseUrl = this.options.domainOverride ?? resolveAgent365Endpoint(this.options.clusterCategory); diff --git a/src/a365/hosting/baggageBuilderUtils.ts b/src/a365/hosting/baggageBuilderUtils.ts new file mode 100644 index 00000000..cb92fcd5 --- /dev/null +++ b/src/a365/hosting/baggageBuilderUtils.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Utilities to populate BaggageBuilder from a TurnContext. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/utils/BaggageBuilderUtils.ts + */ + +import { BaggageBuilder } from "../middleware/BaggageBuilder.js"; +import type { TurnContextLike } from "./types.js"; +import { + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, +} from "./turnContextUtils.js"; + +/** + * Utilities to populate BaggageBuilder from a TurnContext. + */ +export class BaggageBuilderUtils { + /** + * Populate all supported baggage pairs from the provided TurnContext. + */ + static fromTurnContext(builder: BaggageBuilder, turnContext: TurnContextLike): BaggageBuilder { + if (!turnContext) { + throw new Error("turnContext is required"); + } + this.setCallerBaggage(builder, turnContext); + this.setTargetAgentBaggage(builder, turnContext); + this.setTenantIdBaggage(builder, turnContext); + this.setChannelBaggage(builder, turnContext); + this.setConversationIdBaggage(builder, turnContext); + return builder; + } + + static setCallerBaggage(builder: BaggageBuilder, turnContext: TurnContextLike): BaggageBuilder { + builder.setPairs(getCallerBaggagePairs(turnContext)); + return builder; + } + + static setTargetAgentBaggage( + builder: BaggageBuilder, + turnContext: TurnContextLike, + ): BaggageBuilder { + builder.setPairs(getTargetAgentBaggagePairs(turnContext)); + return builder; + } + + static setTenantIdBaggage(builder: BaggageBuilder, turnContext: TurnContextLike): BaggageBuilder { + builder.setPairs(getTenantIdPair(turnContext)); + return builder; + } + + static setChannelBaggage(builder: BaggageBuilder, turnContext: TurnContextLike): BaggageBuilder { + builder.setPairs(getChannelBaggagePairs(turnContext)); + return builder; + } + + static setConversationIdBaggage( + builder: BaggageBuilder, + turnContext: TurnContextLike, + ): BaggageBuilder { + builder.setPairs(getConversationIdAndItemLinkPairs(turnContext)); + return builder; + } +} diff --git a/src/a365/hosting/baggageMiddleware.ts b/src/a365/hosting/baggageMiddleware.ts new file mode 100644 index 00000000..960890ea --- /dev/null +++ b/src/a365/hosting/baggageMiddleware.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Middleware that propagates OpenTelemetry baggage context derived from TurnContext. + * Async replies (ContinueConversation events) are passed through without baggage setup. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/middleware/BaggageMiddleware.ts + */ + +import { BaggageBuilder } from "../middleware/BaggageBuilder.js"; +import type { TurnContextLike, MiddlewareLike } from "./types.js"; +import { + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, +} from "./turnContextUtils.js"; + +/** + * Middleware that propagates OpenTelemetry baggage context derived from TurnContext. + * Async replies (ContinueConversation) are passed through without baggage setup. + */ +export class BaggageMiddleware implements MiddlewareLike { + async onTurn(context: TurnContextLike, next: () => Promise): Promise { + const isAsyncReply = + context.activity?.type === "event" && context.activity?.name === "ContinueConversation"; + + if (isAsyncReply) { + await next(); + return; + } + + const baggageScope = new BaggageBuilder() + .setPairs(getCallerBaggagePairs(context)) + .setPairs(getTargetAgentBaggagePairs(context)) + .setPairs(getTenantIdPair(context)) + .setPairs(getChannelBaggagePairs(context)) + .setPairs(getConversationIdAndItemLinkPairs(context)) + .build(); + + await baggageScope.run(async () => { + await next(); + }); + } +} diff --git a/src/a365/hosting/index.ts b/src/a365/hosting/index.ts new file mode 100644 index 00000000..8522539c --- /dev/null +++ b/src/a365/hosting/index.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { BaggageBuilderUtils } from "./baggageBuilderUtils.js"; +export { ScopeUtils } from "./scopeUtils.js"; +export { + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, + resolveEmbodiedAgentIds, +} from "./turnContextUtils.js"; +export { BaggageMiddleware } from "./baggageMiddleware.js"; +export { + OutputLoggingMiddleware, + A365_PARENT_SPAN_KEY, + A365_AUTH_TOKEN_KEY, +} from "./outputLoggingMiddleware.js"; +export { ObservabilityHostingManager } from "./observabilityHostingManager.js"; +export type { ObservabilityHostingOptions } from "./observabilityHostingManager.js"; +export type { + TurnContextLike, + ActivityLike, + MiddlewareLike, + SendActivitiesHandler, +} from "./types.js"; diff --git a/src/a365/hosting/observabilityHostingManager.ts b/src/a365/hosting/observabilityHostingManager.ts new file mode 100644 index 00000000..aaeab639 --- /dev/null +++ b/src/a365/hosting/observabilityHostingManager.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Manager for configuring hosting-layer observability middleware. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts + */ + +import { Logger } from "../../shared/logging/index.js"; +import { BaggageMiddleware } from "./baggageMiddleware.js"; +import { OutputLoggingMiddleware } from "./outputLoggingMiddleware.js"; +import type { MiddlewareLike } from "./types.js"; + +/** + * Configuration options for the hosting observability layer. + */ +export interface ObservabilityHostingOptions { + /** Enable baggage propagation middleware. Defaults to false. */ + enableBaggage?: boolean; + + /** Enable output logging middleware for tracing outgoing messages. Defaults to false. */ + enableOutputLogging?: boolean; +} + +/** + * Manager for configuring hosting-layer observability middleware. + * + * @example + * ```typescript + * const manager = new ObservabilityHostingManager(); + * manager.configure(adapter, { enableOutputLogging: true }); + * ``` + */ +export class ObservabilityHostingManager { + private _configured = false; + + /** + * Registers observability middleware on the adapter. + * Subsequent calls are ignored. + */ + configure( + adapter: { use(...middlewares: Array): void }, + options: ObservabilityHostingOptions, + ): void { + if (this._configured) { + Logger.getInstance().warn( + "[ObservabilityHostingManager] Already configured. Subsequent configure() calls are ignored.", + ); + return; + } + + const enableBaggage = options.enableBaggage === true; + const enableOutputLogging = options.enableOutputLogging === true; + + if (enableBaggage) { + adapter.use(new BaggageMiddleware()); + Logger.getInstance().info("[ObservabilityHostingManager] BaggageMiddleware registered."); + } + if (enableOutputLogging) { + adapter.use(new OutputLoggingMiddleware()); + Logger.getInstance().info( + "[ObservabilityHostingManager] OutputLoggingMiddleware registered.", + ); + } + + Logger.getInstance().info( + `[ObservabilityHostingManager] Configured. Baggage: ${enableBaggage}, OutputLogging: ${enableOutputLogging}.`, + ); + this._configured = true; + } +} diff --git a/src/a365/hosting/outputLoggingMiddleware.ts b/src/a365/hosting/outputLoggingMiddleware.ts new file mode 100644 index 00000000..a36dcd6e --- /dev/null +++ b/src/a365/hosting/outputLoggingMiddleware.ts @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Middleware that creates OutputScope spans for outgoing messages. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts + */ + +import { OutputScope } from "../scopes/index.js"; +import { ScopeUtils } from "./scopeUtils.js"; +import { Logger } from "../../shared/logging/index.js"; +import type { + AgentDetails, + UserDetails, + Request, + SpanDetails, + ParentSpanRef, +} from "../contracts.js"; +import type { + TurnContextLike, + MiddlewareLike, + SendActivitiesHandler, + ActivityLike, +} from "./types.js"; + +/** + * TurnState key for the parent span reference. + * Set this in `turnState` to link OutputScope spans as children of an InvokeAgentScope. + */ +export const A365_PARENT_SPAN_KEY = "A365ParentSpanId"; + +/** + * TurnState key for the auth token. + * Set this in `turnState` so middleware can resolve the agent blueprint ID + * from token claims (used for embodied/agentic requests). + */ +export const A365_AUTH_TOKEN_KEY = "A365AuthToken"; + +/** + * Middleware that creates {@link OutputScope} spans for outgoing messages. + * Links to a parent span when {@link A365_PARENT_SPAN_KEY} is set in turnState. + * + * **Privacy note:** Outgoing message content is captured verbatim + * as span attributes and exported to the configured telemetry backend. + */ +export class OutputLoggingMiddleware implements MiddlewareLike { + async onTurn(context: TurnContextLike, next: () => Promise): Promise { + const authToken = (context.turnState.get(A365_AUTH_TOKEN_KEY) as string) ?? ""; + const agentDetails = ScopeUtils.deriveAgentDetails(context, authToken); + + if (!agentDetails || !agentDetails.tenantId) { + await next(); + return; + } + + const userDetails = ScopeUtils.deriveCallerDetails(context); + const conversationId = ScopeUtils.deriveConversationId(context); + const channel = ScopeUtils.deriveChannelObject(context); + + const request: Request = { + conversationId, + channel, + }; + + if (context.onSendActivities) { + context.onSendActivities( + this._createSendHandler(context, agentDetails, userDetails, request), + ); + } + + await next(); + } + + private _createSendHandler( + turnContext: TurnContextLike, + agentDetails: AgentDetails, + userDetails?: UserDetails, + request?: Request, + ): SendActivitiesHandler { + return async ( + _ctx: TurnContextLike, + activities: ActivityLike[], + sendNext: () => Promise, + ) => { + const messages = activities.filter((a) => a.type === "message" && a.text).map((a) => a.text!); + + if (messages.length === 0) { + return await sendNext(); + } + + const parentSpanRef = turnContext.turnState.get(A365_PARENT_SPAN_KEY) as + | ParentSpanRef + | undefined; + if (!parentSpanRef) { + Logger.getInstance().warn( + `[OutputLoggingMiddleware] No parent span ref in turnState under '${A365_PARENT_SPAN_KEY}'. OutputScope will not be linked to a parent.`, + ); + } + + const spanDetails: SpanDetails | undefined = parentSpanRef + ? { parentContext: parentSpanRef } + : undefined; + + const outputScope = OutputScope.start( + request ?? {}, + { messages }, + agentDetails, + userDetails, + spanDetails, + ); + try { + return await sendNext(); + } catch (error) { + outputScope.recordError( + error instanceof Error + ? error + : new Error(typeof error === "string" ? error : JSON.stringify(error)), + ); + throw error; + } finally { + outputScope.dispose(); + } + }; + } +} diff --git a/src/a365/hosting/scopeUtils.ts b/src/a365/hosting/scopeUtils.ts new file mode 100644 index 00000000..f73bf2ed --- /dev/null +++ b/src/a365/hosting/scopeUtils.ts @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Utilities to populate scope parameters from a TurnContext. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/utils/ScopeUtils.ts + */ + +import type { SpanKind, TimeInput } from "@opentelemetry/api"; +import type { TurnContextLike } from "./types.js"; +import { resolveEmbodiedAgentIds } from "./turnContextUtils.js"; +import { InvokeAgentScope, InferenceScope, ExecuteToolScope } from "../scopes/index.js"; +import type { + AgentDetails, + UserDetails, + CallerDetails, + Request, + SpanDetails, + InvokeAgentScopeDetails, + ToolCallDetails, + InferenceDetails, +} from "../contracts.js"; + +/** + * Unified utilities to populate scope tags from a TurnContext. + */ +export class ScopeUtils { + // ── Context-derived helpers ───────────────────────────────────── + + /** + * Derive target agent details from the activity recipient. + */ + public static deriveAgentDetails( + turnContext: TurnContextLike, + authToken: string, + ): AgentDetails | undefined { + const recipient = turnContext?.activity?.recipient; + if (!recipient) return undefined; + const { agentId, agentBlueprintId } = resolveEmbodiedAgentIds(turnContext, authToken); + return { + agentId, + agentName: recipient.name, + agentAUID: recipient.aadObjectId, + agentBlueprintId, + agentEmail: turnContext?.activity?.getAgenticUser?.(), + agentDescription: recipient.role, + tenantId: turnContext?.activity?.getAgenticTenantId?.(), + } as AgentDetails; + } + + /** + * Derive caller agent details from the activity from. + */ + public static deriveCallerAgent(turnContext: TurnContextLike): AgentDetails | undefined { + const from = turnContext?.activity?.from; + if (!from) return undefined; + return { + agentBlueprintId: from.agenticAppBlueprintId, + agentName: from.name, + agentAUID: from.aadObjectId, + agentDescription: from.role, + tenantId: from.tenantId, + agentId: from.agenticAppId, + agentEmail: from.agenticUserId, + } as AgentDetails; + } + + /** + * Derive caller identity details (id, email, name, tenant) from the activity from. + */ + public static deriveCallerDetails(turnContext: TurnContextLike): UserDetails | undefined { + const from = turnContext?.activity?.from; + if (!from) return undefined; + return { + userId: from.aadObjectId, + userEmail: from.agenticUserId, + userName: from.name, + tenantId: from.tenantId, + } as UserDetails; + } + + /** + * Derive conversation id from the TurnContext. + */ + public static deriveConversationId(turnContext: TurnContextLike): string | undefined { + return turnContext?.activity?.conversation?.id; + } + + /** + * Derive channel (name and description) from the TurnContext. + */ + public static deriveChannelObject(turnContext: TurnContextLike): { + name?: string; + description?: string; + } { + return { + name: turnContext?.activity?.channelId, + description: turnContext?.activity?.channelIdSubChannel as string | undefined, + }; + } + + // ── Scope factory helpers ─────────────────────────────────────── + + private static setInputMessageTags( + scope: InvokeAgentScope | InferenceScope, + turnContext: TurnContextLike, + ): void { + if (turnContext?.activity?.text) { + scope.recordInputMessages([turnContext.activity.text]); + } + } + + /** + * Create an `InferenceScope` enriched with values from the TurnContext. + */ + static populateInferenceScopeFromTurnContext( + details: InferenceDetails, + turnContext: TurnContextLike, + authToken: string, + startTime?: TimeInput, + endTime?: TimeInput, + ): InferenceScope { + const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken); + const caller = ScopeUtils.deriveCallerDetails(turnContext); + const conversationId = ScopeUtils.deriveConversationId(turnContext); + const channel = ScopeUtils.deriveChannelObject(turnContext); + + if (!agent) { + throw new Error( + "populateInferenceScopeFromTurnContext: Missing agent details on TurnContext (recipient)", + ); + } + + const hasChannel = channel.name !== undefined || channel.description !== undefined; + const request: Request = { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + }; + + const spanDetails: SpanDetails | undefined = + startTime || endTime ? { startTime, endTime } : undefined; + + const scope = InferenceScope.start(request, details, agent, caller, spanDetails); + this.setInputMessageTags(scope, turnContext); + return scope; + } + + /** + * Create an `InvokeAgentScope` enriched with values from the TurnContext. + */ + static populateInvokeAgentScopeFromTurnContext( + details: AgentDetails, + scopeDetails: InvokeAgentScopeDetails, + turnContext: TurnContextLike, + authToken: string, + startTime?: TimeInput, + endTime?: TimeInput, + spanKind?: SpanKind, + ): InvokeAgentScope { + const callerAgent = ScopeUtils.deriveCallerAgent(turnContext); + const caller = ScopeUtils.deriveCallerDetails(turnContext); + const conversationId = ScopeUtils.deriveConversationId(turnContext); + const channel = ScopeUtils.deriveChannelObject(turnContext); + + const agentDetails = ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); + + const hasChannel = channel.name !== undefined || channel.description !== undefined; + const request: Request = { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + }; + + const callerDetails: CallerDetails = { + userDetails: caller, + callerAgentDetails: callerAgent, + }; + + const spanDetailsObj: SpanDetails | undefined = + startTime || endTime || spanKind ? { startTime, endTime, spanKind } : undefined; + + const scope = InvokeAgentScope.start( + request, + scopeDetails, + agentDetails, + callerDetails, + spanDetailsObj, + ); + this.setInputMessageTags(scope, turnContext); + return scope; + } + + /** + * Build agent details by merging provided details with TurnContext. + */ + public static buildInvokeAgentDetails( + details: AgentDetails, + turnContext: TurnContextLike, + authToken: string, + ): AgentDetails { + return ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); + } + + private static buildInvokeAgentDetailsCore( + details: AgentDetails, + turnContext: TurnContextLike, + authToken: string, + ): AgentDetails { + const derivedAgentDetails = ScopeUtils.deriveAgentDetails(turnContext, authToken); + return { + ...details, + ...(derivedAgentDetails ?? {}), + }; + } + + /** + * Create an `ExecuteToolScope` enriched with values from the TurnContext. + */ + static populateExecuteToolScopeFromTurnContext( + details: ToolCallDetails, + turnContext: TurnContextLike, + authToken: string, + startTime?: TimeInput, + endTime?: TimeInput, + spanKind?: SpanKind, + ): ExecuteToolScope { + const agent = ScopeUtils.deriveAgentDetails(turnContext, authToken); + const caller = ScopeUtils.deriveCallerDetails(turnContext); + const conversationId = ScopeUtils.deriveConversationId(turnContext); + const channel = ScopeUtils.deriveChannelObject(turnContext); + + if (!agent) { + throw new Error( + "populateExecuteToolScopeFromTurnContext: Missing agent details on TurnContext (recipient)", + ); + } + + const hasChannel = channel.name !== undefined || channel.description !== undefined; + const request: Request = { + conversationId, + ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), + }; + + const spanDetailsObj: SpanDetails | undefined = + startTime || endTime || spanKind ? { startTime, endTime, spanKind } : undefined; + + return ExecuteToolScope.start(request, details, agent, caller, spanDetailsObj); + } +} diff --git a/src/a365/hosting/turnContextUtils.ts b/src/a365/hosting/turnContextUtils.ts new file mode 100644 index 00000000..d9c4ae1d --- /dev/null +++ b/src/a365/hosting/turnContextUtils.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * TurnContext utility methods for extracting OpenTelemetry baggage pairs. + * + * Adapted from microsoft/Agent365-nodejs agents-a365-observability-hosting/src/utils/TurnContextUtils.ts + */ + +import { OpenTelemetryConstants } from "../constants.js"; +import type { TurnContextLike } from "./types.js"; + +function normalizePairs(pairs: Array<[string, string | undefined]>): Array<[string, string]> { + return pairs + .filter(([, v]) => v != null && String(v).trim() !== "") + .map(([k, v]) => [k, String(v)]); +} + +/** + * Extracts caller-related OpenTelemetry baggage pairs from the TurnContext. + */ +export function getCallerBaggagePairs(turnContext: TurnContextLike): Array<[string, string]> { + if (!turnContext?.activity?.from) { + return []; + } + const from = turnContext.activity.from; + const pairs: Array<[string, string | undefined]> = [ + [OpenTelemetryConstants.USER_ID_KEY, from.aadObjectId], + [OpenTelemetryConstants.USER_NAME_KEY, from.name], + [OpenTelemetryConstants.USER_EMAIL_KEY, from.agenticUserId], + [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, from.agenticAppBlueprintId], + ]; + return normalizePairs(pairs); +} + +/** + * Resolves the agent instance ID and blueprint ID for embodied (agentic) agents. + * For non-embodied agents, both fields are undefined. + * @param turnContext Activity context + * @param authToken Auth token for resolving blueprint ID from token claims. + */ +export function resolveEmbodiedAgentIds( + turnContext: TurnContextLike, + authToken: string, +): { agentId: string | undefined; agentBlueprintId: string | undefined } { + const isAgentic = turnContext?.activity?.isAgenticRequest?.(); + const rawAgentId = isAgentic ? turnContext.activity.getAgenticInstanceId?.() : undefined; + const rawBlueprintId = isAgentic ? getAgentIdFromToken(authToken) : undefined; + return { + agentId: rawAgentId || undefined, + agentBlueprintId: rawBlueprintId || undefined, + }; +} + +/** + * Extracts agent/recipient-related OpenTelemetry baggage pairs from the TurnContext. + */ +export function getTargetAgentBaggagePairs( + turnContext: TurnContextLike, + authToken?: string, +): Array<[string, string]> { + if (!turnContext?.activity?.recipient) { + return []; + } + const recipient = turnContext.activity.recipient; + const { agentId } = authToken + ? resolveEmbodiedAgentIds(turnContext, authToken) + : { + agentId: turnContext.activity?.isAgenticRequest?.() + ? turnContext.activity.getAgenticInstanceId?.() + : undefined, + }; + const pairs: Array<[string, string | undefined]> = [ + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, agentId], + [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, recipient.name], + [OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, recipient.role], + [OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, recipient.aadObjectId], + ]; + return normalizePairs(pairs); +} + +/** + * Extracts the tenant ID baggage key-value pair. + */ +export function getTenantIdPair(turnContext: TurnContextLike): Array<[string, string]> { + const tenantId = turnContext.activity?.getAgenticTenantId?.(); + return tenantId ? [[OpenTelemetryConstants.TENANT_ID_KEY, tenantId]] : []; +} + +/** + * Extracts channel baggage pairs from the TurnContext. + */ +export function getChannelBaggagePairs(turnContext: TurnContextLike): Array<[string, string]> { + if (!turnContext) { + return []; + } + const pairs: Array<[string, string | undefined]> = [ + [OpenTelemetryConstants.CHANNEL_NAME_KEY, turnContext.activity?.channelId], + [ + OpenTelemetryConstants.CHANNEL_LINK_KEY, + turnContext.activity?.channelIdSubChannel as string | undefined, + ], + ]; + return normalizePairs(pairs); +} + +/** + * Extracts conversation ID and item link baggage pairs. + */ +export function getConversationIdAndItemLinkPairs( + turnContext: TurnContextLike, +): Array<[string, string]> { + if (!turnContext) { + return []; + } + const conversationId = turnContext.activity?.conversation?.id; + const itemLink = turnContext.activity?.serviceUrl; + const pairs: Array<[string, string | undefined]> = [ + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, conversationId], + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ITEM_LINK_KEY, itemLink], + ]; + return normalizePairs(pairs); +} + +// --------------------------------------------------------------------------- +// Inline token decode (replaces @microsoft/agents-a365-runtime Utility) +// --------------------------------------------------------------------------- + +/** + * Decode the JWT and return the best available agent identifier. + * Priority: xms_par_app_azp > appid > azp. + * + * WARNING: NO SIGNATURE VERIFICATION — suitable only for logging/diagnostics. + */ +function getAgentIdFromToken(token: string): string { + if (!token || token.trim() === "") return ""; + try { + const parts = token.split("."); + if (parts.length < 2) return ""; + const padded = parts[1] + "=".repeat((4 - (parts[1].length % 4)) % 4); + const decoded = JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as Record< + string, + unknown + >; + return ( + (decoded["xms_par_app_azp"] as string) || + (decoded["appid"] as string) || + (decoded["azp"] as string) || + "" + ); + } catch { + return ""; + } +} diff --git a/src/a365/hosting/types.ts b/src/a365/hosting/types.ts new file mode 100644 index 00000000..848767f4 --- /dev/null +++ b/src/a365/hosting/types.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Minimal interfaces for hosting-layer observability middleware. + * + * These types mirror the shapes from `@microsoft/agents-hosting` and + * `@microsoft/agents-activity` so the hosting utilities can be used + * without taking a dependency on those packages directly. Consumers + * using agents-hosting can pass their TurnContext/Middleware instances + * directly since they satisfy these interfaces structurally. + */ + +/** Minimal activity shape required by hosting observability utilities. */ +export interface ActivityLike { + type?: string; + name?: string; + text?: string; + channelId?: string; + channelIdSubChannel?: string | unknown; + serviceUrl?: string; + from?: { + aadObjectId?: string; + name?: string; + role?: string; + tenantId?: string; + agenticUserId?: string; + agenticAppBlueprintId?: string; + agenticAppId?: string; + }; + recipient?: { + aadObjectId?: string; + name?: string; + role?: string; + }; + conversation?: { + id?: string; + }; + isAgenticRequest?: () => boolean; + getAgenticInstanceId?: () => string; + getAgenticTenantId?: () => string; + getAgenticUser?: () => string; +} + +/** Minimal turn context shape required by hosting observability utilities. */ +export interface TurnContextLike { + activity: ActivityLike; + turnState: Map; + onSendActivities?(handler: SendActivitiesHandler): void; +} + +/** Handler for intercepting outgoing activities. */ +export type SendActivitiesHandler = ( + ctx: TurnContextLike, + activities: ActivityLike[], + sendNext: () => Promise, +) => Promise; + +/** Middleware interface compatible with agents-hosting adapters. */ +export interface MiddlewareLike { + onTurn(context: TurnContextLike, next: () => Promise): Promise; +} diff --git a/src/a365/index.ts b/src/a365/index.ts index cf574dee..1c86f7dd 100644 --- a/src/a365/index.ts +++ b/src/a365/index.ts @@ -88,3 +88,27 @@ export type { PerRequestSpanProcessorOptions } from "./processors/index.js"; // ── Token context ─────────────────────────────────────────────────────────── export { runWithExportToken, updateExportToken, getExportToken } from "./context/tokenContext.js"; + +// ── Hosting utilities ─────────────────────────────────────────────────────── +export { + BaggageBuilderUtils, + ScopeUtils, + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, + resolveEmbodiedAgentIds, + BaggageMiddleware, + OutputLoggingMiddleware, + A365_PARENT_SPAN_KEY, + A365_AUTH_TOKEN_KEY, + ObservabilityHostingManager, +} from "./hosting/index.js"; +export type { + ObservabilityHostingOptions, + TurnContextLike, + ActivityLike, + MiddlewareLike, + SendActivitiesHandler, +} from "./hosting/index.js"; diff --git a/src/a365/middleware/BaggageBuilder.ts b/src/a365/middleware/BaggageBuilder.ts index dcc2d166..cf62ed61 100644 --- a/src/a365/middleware/BaggageBuilder.ts +++ b/src/a365/middleware/BaggageBuilder.ts @@ -177,7 +177,7 @@ export class BaggageBuilder { * 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 { diff --git a/src/index.ts b/src/index.ts index 8452e72b..dbca6a83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,6 +76,30 @@ export type { } from "./a365/index.js"; export type { PerRequestSpanProcessorOptions } from "./a365/index.js"; +// ── Re-exports from A365 hosting utilities ────────────────────────────────── +export { + BaggageBuilderUtils, + ScopeUtils, + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, + resolveEmbodiedAgentIds, + BaggageMiddleware, + OutputLoggingMiddleware, + A365_PARENT_SPAN_KEY, + A365_AUTH_TOKEN_KEY, + ObservabilityHostingManager, +} from "./a365/index.js"; +export type { + ObservabilityHostingOptions, + TurnContextLike, + ActivityLike, + MiddlewareLike, + SendActivitiesHandler, +} from "./a365/index.js"; + // ── Re-exports from types ─────────────────────────────────────────────────── export type { OpenAIAgentsInstrumentationConfig, LangChainInstrumentationConfig } from "./types.js"; diff --git a/test/internal/unit/a365/agent365Exporter.test.ts b/test/internal/unit/a365/agent365Exporter.test.ts index 7457d4ce..d6706d21 100644 --- a/test/internal/unit/a365/agent365Exporter.test.ts +++ b/test/internal/unit/a365/agent365Exporter.test.ts @@ -124,7 +124,7 @@ describe("Agent365Exporter", () => { assert.strictEqual(fetchSpy.mock.calls.length, 1); const [url, options] = fetchSpy.mock.calls[0]; - assert.ok(url.includes(`/observability/tenants/${TENANT_ID}/agents/${AGENT_ID}/traces`)); + assert.ok(url.includes(`/observability/tenants/${TENANT_ID}/otlp/agents/${AGENT_ID}/traces`)); assert.strictEqual(options.method, "POST"); assert.strictEqual(options.headers["authorization"], "Bearer test-token"); assert.strictEqual(options.headers["x-ms-tenant-id"], TENANT_ID); @@ -186,6 +186,7 @@ describe("Agent365Exporter", () => { const [url] = fetchSpy.mock.calls[0]; assert.ok(url.startsWith("https://agent365.svc.cloud.microsoft/observability/tenants/")); + assert.ok(url.includes("/otlp/agents/")); }); it("should use S2S endpoint when configured", async () => { @@ -200,7 +201,7 @@ describe("Agent365Exporter", () => { const [url] = fetchSpy.mock.calls[0]; assert.ok(url.includes("/observabilityService/tenants/")); - assert.ok(url.includes(`/agents/${AGENT_ID}/traces`)); + assert.ok(url.includes(`/otlp/agents/${AGENT_ID}/traces`)); }); it("should use S2S endpoint with domain override", async () => { @@ -216,6 +217,7 @@ describe("Agent365Exporter", () => { const [url, options] = fetchSpy.mock.calls[0]; assert.ok(url.startsWith("https://custom.domain/observabilityService/tenants/")); + assert.ok(url.includes("/otlp/agents/")); assert.strictEqual(options.headers["authorization"], "Bearer tok-s2s-custom"); assert.strictEqual(options.headers["x-ms-tenant-id"], TENANT_ID); }); diff --git a/test/internal/unit/a365/hosting/baggageBuilderUtils.test.ts b/test/internal/unit/a365/hosting/baggageBuilderUtils.test.ts new file mode 100644 index 00000000..662c2ae3 --- /dev/null +++ b/test/internal/unit/a365/hosting/baggageBuilderUtils.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { context as otelContext } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { + BaggageBuilderUtils, + BaggageBuilder, + OpenTelemetryConstants, +} from "../../../../../src/a365/index.js"; +import type { TurnContextLike } from "../../../../../src/a365/index.js"; + +let contextManager: AsyncLocalStorageContextManager; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); +}); + +afterAll(() => { + contextManager.disable(); + otelContext.disable(); +}); + +function makeMockTurnContext(): TurnContextLike { + return { + activity: { + from: { + aadObjectId: "user-oid", + name: "User One", + agenticUserId: "user@contoso.com", + tenantId: "tenant1", + role: "user", + }, + recipient: { + aadObjectId: "agent-oid", + name: "Agent One", + role: "agent", + }, + conversation: { id: "conv-1" }, + channelId: "web", + serviceUrl: "https://example.com", + isAgenticRequest: () => true, + getAgenticInstanceId: () => "agent-app-1", + getAgenticTenantId: () => "tenant1", + getAgenticUser: () => "agent@contoso.com", + }, + turnState: new Map(), + }; +} + +describe("BaggageBuilderUtils", () => { + it("should populate all baggage pairs from TurnContext", () => { + const capturedPairs: Array<[string, string]> = []; + + // Subclass BaggageBuilder to capture setPairs calls + class CapturingBaggageBuilder extends BaggageBuilder { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setPairs(pairs: Record | Iterable<[string, any]> | null | undefined): this { + if (pairs) { + // 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 [k, v] of entries) { + if (v !== null && v !== undefined) { + capturedPairs.push([k, String(v)]); + } + } + } + return this; + } + } + + const builder = new CapturingBaggageBuilder(); + const ctx = makeMockTurnContext(); + const result = BaggageBuilderUtils.fromTurnContext(builder, ctx); + + expect(result).toBe(builder); + + const asObj = Object.fromEntries(capturedPairs); + + // Caller pairs + expect(asObj[OpenTelemetryConstants.USER_ID_KEY]).toBe("user-oid"); + expect(asObj[OpenTelemetryConstants.USER_NAME_KEY]).toBe("User One"); + expect(asObj[OpenTelemetryConstants.USER_EMAIL_KEY]).toBe("user@contoso.com"); + + // Agent pairs + expect(asObj[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe("agent-app-1"); + expect(asObj[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe("Agent One"); + expect(asObj[OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY]).toBe("agent"); + + // Tenant + expect(asObj[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant1"); + + // Channel + expect(asObj[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("web"); + + // Conversation + expect(asObj[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("conv-1"); + }); + + it("should throw if turnContext is missing", () => { + const builder = new BaggageBuilder(); + expect(() => + BaggageBuilderUtils.fromTurnContext(builder, undefined as unknown as TurnContextLike), + ).toThrow("turnContext is required"); + }); + + it("should call individual set helpers", () => { + const builder = new BaggageBuilder(); + const ctx = makeMockTurnContext(); + + // Each helper should return the same builder (fluent) + expect(BaggageBuilderUtils.setCallerBaggage(builder, ctx)).toBe(builder); + expect(BaggageBuilderUtils.setTargetAgentBaggage(builder, ctx)).toBe(builder); + expect(BaggageBuilderUtils.setTenantIdBaggage(builder, ctx)).toBe(builder); + expect(BaggageBuilderUtils.setChannelBaggage(builder, ctx)).toBe(builder); + expect(BaggageBuilderUtils.setConversationIdBaggage(builder, ctx)).toBe(builder); + }); +}); diff --git a/test/internal/unit/a365/hosting/baggageMiddleware.test.ts b/test/internal/unit/a365/hosting/baggageMiddleware.test.ts new file mode 100644 index 00000000..232e5ed2 --- /dev/null +++ b/test/internal/unit/a365/hosting/baggageMiddleware.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { trace, context as otelContext, propagation } from "@opentelemetry/api"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { BaggageMiddleware, OpenTelemetryConstants } from "../../../../../src/a365/index.js"; +import type { TurnContextLike } from "../../../../../src/a365/index.js"; + +let exporter: InMemorySpanExporter; +let contextManager: AsyncLocalStorageContextManager; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); + + exporter = new InMemorySpanExporter(); + const processor = new SimpleSpanProcessor(exporter); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const globalProvider: any = trace.getTracerProvider(); + if (globalProvider && typeof globalProvider.addSpanProcessor === "function") { + globalProvider.addSpanProcessor(processor); + } else { + const provider = new BasicTracerProvider({ + spanProcessors: [processor], + }); + trace.setGlobalTracerProvider(provider); + } +}); + +beforeEach(() => { + exporter.reset(); +}); + +afterAll(() => { + contextManager.disable(); + otelContext.disable(); +}); + +function makeMockTurnContext(options?: { + activityType?: string; + activityName?: string; +}): TurnContextLike { + return { + activity: { + type: options?.activityType, + name: options?.activityName, + text: "Hello agent", + channelId: "web", + conversation: { id: "conv-001" }, + serviceUrl: "https://example.com", + from: { + aadObjectId: "user-oid", + name: "Test User", + agenticUserId: "user@contoso.com", + tenantId: "from-tenant", + }, + recipient: { + aadObjectId: "agent-oid", + name: "Agent One", + role: "assistant", + }, + isAgenticRequest: () => false, + getAgenticInstanceId: () => "agent-1", + getAgenticTenantId: () => "tenant-123", + getAgenticUser: () => "agent@contoso.com", + }, + turnState: new Map(), + }; +} + +describe("BaggageMiddleware", () => { + it("should propagate baggage context during turn", async () => { + const middleware = new BaggageMiddleware(); + const ctx = makeMockTurnContext(); + const capturedBaggage: Record = {}; + + await middleware.onTurn(ctx, async () => { + const bag = propagation.getBaggage(otelContext.active()); + if (bag) { + for (const [key, entry] of bag.getAllEntries()) { + capturedBaggage[key] = entry.value; + } + } + }); + + expect(capturedBaggage[OpenTelemetryConstants.USER_ID_KEY]).toBe("user-oid"); + expect(capturedBaggage[OpenTelemetryConstants.TENANT_ID_KEY]).toBe("tenant-123"); + expect(capturedBaggage[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("web"); + expect(capturedBaggage[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("conv-001"); + }); + + it("should skip baggage setup for async replies (ContinueConversation)", async () => { + const middleware = new BaggageMiddleware(); + const ctx = makeMockTurnContext({ + activityType: "event", + activityName: "ContinueConversation", + }); + const capturedBaggage: Record = {}; + + await middleware.onTurn(ctx, async () => { + const bag = propagation.getBaggage(otelContext.active()); + if (bag) { + for (const [key, entry] of bag.getAllEntries()) { + capturedBaggage[key] = entry.value; + } + } + }); + + // No baggage set for async replies + expect(Object.keys(capturedBaggage).length).toBe(0); + }); + + it("should call next() even when baggage setup is skipped", async () => { + const middleware = new BaggageMiddleware(); + const ctx = makeMockTurnContext({ + activityType: "event", + activityName: "ContinueConversation", + }); + let nextCalled = false; + + await middleware.onTurn(ctx, async () => { + nextCalled = true; + }); + + expect(nextCalled).toBe(true); + }); + + it("should always call next() for normal message activities", async () => { + const middleware = new BaggageMiddleware(); + const ctx = makeMockTurnContext(); + let nextCalled = false; + + await middleware.onTurn(ctx, async () => { + nextCalled = true; + }); + + expect(nextCalled).toBe(true); + }); + + it("should not treat non-ContinueConversation events as async replies", async () => { + const middleware = new BaggageMiddleware(); + const ctx = makeMockTurnContext({ + activityType: "event", + activityName: "SomeOtherEvent", + }); + const capturedBaggage: Record = {}; + + await middleware.onTurn(ctx, async () => { + const bag = propagation.getBaggage(otelContext.active()); + if (bag) { + for (const [key, entry] of bag.getAllEntries()) { + capturedBaggage[key] = entry.value; + } + } + }); + + // Should still set baggage for non-ContinueConversation events + expect(Object.keys(capturedBaggage).length).toBeGreaterThan(0); + }); +}); diff --git a/test/internal/unit/a365/hosting/observabilityHostingManager.test.ts b/test/internal/unit/a365/hosting/observabilityHostingManager.test.ts new file mode 100644 index 00000000..555d4bd8 --- /dev/null +++ b/test/internal/unit/a365/hosting/observabilityHostingManager.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, vi } from "vitest"; + +import { ObservabilityHostingManager } from "../../../../../src/a365/hosting/observabilityHostingManager.js"; +import { BaggageMiddleware } from "../../../../../src/a365/hosting/baggageMiddleware.js"; +import { OutputLoggingMiddleware } from "../../../../../src/a365/hosting/outputLoggingMiddleware.js"; +import type { MiddlewareLike } from "../../../../../src/a365/hosting/types.js"; + +describe("ObservabilityHostingManager", () => { + it("should not register any middleware by default", () => { + const manager = new ObservabilityHostingManager(); + const registered: MiddlewareLike[] = []; + const adapter = { use: (...mws: MiddlewareLike[]) => registered.push(...mws) }; + + manager.configure(adapter, {}); + + expect(registered.length).toBe(0); + }); + + it("should register BaggageMiddleware when enableBaggage is true", () => { + const manager = new ObservabilityHostingManager(); + const registered: MiddlewareLike[] = []; + const adapter = { use: (...mws: MiddlewareLike[]) => registered.push(...mws) }; + + manager.configure(adapter, { enableBaggage: true }); + + expect(registered.length).toBe(1); + expect(registered[0]).toBeInstanceOf(BaggageMiddleware); + }); + + it("should register OutputLoggingMiddleware when enableOutputLogging is true", () => { + const manager = new ObservabilityHostingManager(); + const registered: MiddlewareLike[] = []; + const adapter = { use: (...mws: MiddlewareLike[]) => registered.push(...mws) }; + + manager.configure(adapter, { enableOutputLogging: true }); + + expect(registered.length).toBe(1); + expect(registered[0]).toBeInstanceOf(OutputLoggingMiddleware); + }); + + it("should register both middleware when both flags are true", () => { + const manager = new ObservabilityHostingManager(); + const registered: MiddlewareLike[] = []; + const adapter = { use: (...mws: MiddlewareLike[]) => registered.push(...mws) }; + + manager.configure(adapter, { enableBaggage: true, enableOutputLogging: true }); + + expect(registered.length).toBe(2); + expect(registered[0]).toBeInstanceOf(BaggageMiddleware); + expect(registered[1]).toBeInstanceOf(OutputLoggingMiddleware); + }); + + it("should ignore subsequent configure() calls", () => { + const manager = new ObservabilityHostingManager(); + const registered: MiddlewareLike[] = []; + const adapter = { use: (...mws: MiddlewareLike[]) => registered.push(...mws) }; + + manager.configure(adapter, { enableBaggage: true }); + expect(registered.length).toBe(1); + + // Second call should be a no-op + manager.configure(adapter, { enableBaggage: true, enableOutputLogging: true }); + expect(registered.length).toBe(1); + }); +}); diff --git a/test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts b/test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts new file mode 100644 index 00000000..1bf1c7c5 --- /dev/null +++ b/test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import { trace, context as otelContext } from "@opentelemetry/api"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { + OutputLoggingMiddleware, + A365_PARENT_SPAN_KEY, + A365_AUTH_TOKEN_KEY, + OpenTelemetryConstants, + InvokeAgentScope, +} from "../../../../../src/a365/index.js"; +import type { + TurnContextLike, + ActivityLike, + SendActivitiesHandler, +} from "../../../../../src/a365/index.js"; + +let exporter: InMemorySpanExporter; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let flushProvider: any; +let contextManager: AsyncLocalStorageContextManager; + +const originalConsoleWarn = console.warn; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); + + 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 { + const provider = new BasicTracerProvider({ + spanProcessors: [processor], + }); + trace.setGlobalTracerProvider(provider); + flushProvider = provider; + } + + console.warn = vi.fn(); +}); + +beforeEach(() => { + exporter.reset(); +}); + +afterAll(() => { + console.warn = originalConsoleWarn; + contextManager.disable(); + otelContext.disable(); +}); + +interface MockTurnContext extends TurnContextLike { + _sendHandlers: SendActivitiesHandler[]; + simulateSend(activities: Array<{ type?: string; text?: string }>): Promise; +} + +function makeMockTurnContext(options?: { + text?: string; + recipientName?: string; + recipientTenantId?: string; + channelId?: string; + conversationId?: string; + activityType?: string; + activityName?: string; +}): MockTurnContext { + const sendHandlers: SendActivitiesHandler[] = []; + const recipientTenantId = options?.recipientTenantId ?? "tenant-123"; + + const ctx: MockTurnContext = { + activity: { + type: options?.activityType, + name: options?.activityName, + text: options?.text ?? "Hello agent", + channelId: options?.channelId ?? "web", + conversation: { id: options?.conversationId ?? "conv-001" }, + serviceUrl: "https://example.com", + from: { + aadObjectId: "user-oid", + name: "Test User", + agenticUserId: "user@contoso.com", + tenantId: "from-tenant", + }, + recipient: { + aadObjectId: "agent-oid", + name: options?.recipientName ?? "Agent One", + role: "assistant", + }, + getAgenticTenantId: () => recipientTenantId, + getAgenticUser: () => "agent@contoso.com", + getAgenticInstanceId: () => "agent-1", + isAgenticRequest: () => false, + }, + turnState: new Map(), + _sendHandlers: sendHandlers, + onSendActivities(handler: SendActivitiesHandler) { + sendHandlers.push(handler); + }, + async simulateSend(activities: Array<{ type?: string; text?: string }>) { + const finalSend = async () => activities.map(() => ({ id: "resp-1" })); + let current = finalSend; + for (let i = sendHandlers.length - 1; i >= 0; i--) { + const handler = sendHandlers[i]; + const prev = current; + current = () => + handler( + ctx as TurnContextLike, + activities as ActivityLike[], + prev as () => Promise, + ); + } + return await current(); + }, + }; + + return ctx; +} + +describe("OutputLoggingMiddleware", () => { + it("should create OutputScope for outgoing messages", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + await middleware.onTurn(ctx, async () => { + ctx.turnState.set(A365_PARENT_SPAN_KEY, { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: 1, + }); + await ctx.simulateSend([{ type: "message", text: "Hi there!" }]); + }); + + await flushProvider.forceFlush(); + const spans = exporter.getFinishedSpans(); + const outputSpan = spans.find((s) => s.name.includes("output_messages")); + + expect(outputSpan).toBeDefined(); + const messages = outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY]; + expect(messages).toBeDefined(); + expect(typeof messages).toBe("string"); + expect(JSON.parse(messages as string).messages[0].parts[0].content).toBe("Hi there!"); + }); + + it("should skip non-message activities", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + await middleware.onTurn(ctx, async () => { + await ctx.simulateSend([{ type: "typing" }, { type: "event", text: "some event" }]); + }); + + await flushProvider.forceFlush(); + const outputSpan = exporter.getFinishedSpans().find((s) => s.name.includes("output_messages")); + expect(outputSpan).toBeUndefined(); + }); + + it("should pass through without tracing when agent details are missing", async () => { + const middleware = new OutputLoggingMiddleware(); + + // No recipient → no agent details → should pass through + const ctx: TurnContextLike = { + activity: { + text: "Hello", + isAgenticRequest: () => false, + getAgenticTenantId: () => undefined as unknown as string, + getAgenticUser: () => undefined as unknown as string, + }, + turnState: new Map(), + }; + + let nextCalled = false; + await middleware.onTurn(ctx, async () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + }); + + it("should pass through when tenantId is missing", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx: TurnContextLike = { + activity: { + text: "Hello", + recipient: { name: "Agent" }, + isAgenticRequest: () => false, + getAgenticInstanceId: () => "aid", + getAgenticTenantId: () => undefined as unknown as string, + getAgenticUser: () => undefined as unknown as string, + }, + turnState: new Map(), + }; + + let nextCalled = false; + await middleware.onTurn(ctx, async () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + }); + + it("should set caller details on OutputScope span", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello", channelId: "teams" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + await middleware.onTurn(ctx, async () => { + ctx.turnState.set(A365_PARENT_SPAN_KEY, { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: 1, + }); + await ctx.simulateSend([{ type: "message", text: "Reply" }]); + }); + + await flushProvider.forceFlush(); + const outputSpan = exporter.getFinishedSpans().find((s) => s.name.includes("output_messages")); + expect(outputSpan).toBeDefined(); + expect(outputSpan!.attributes[OpenTelemetryConstants.USER_ID_KEY]).toBe("user-oid"); + expect(outputSpan!.attributes[OpenTelemetryConstants.USER_NAME_KEY]).toBe("Test User"); + expect(outputSpan!.attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("teams"); + }); + + it("should link OutputScope to parent when parentSpanRef is set", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + const parentSpanRef = { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: 1, + }; + + await middleware.onTurn(ctx, async () => { + ctx.turnState.set(A365_PARENT_SPAN_KEY, parentSpanRef); + await ctx.simulateSend([{ type: "message", text: "Reply" }]); + }); + + await flushProvider.forceFlush(); + const outputSpan = exporter.getFinishedSpans().find((s) => s.name.includes("output_messages")); + expect(outputSpan).toBeDefined(); + const parentCtx = outputSpan!.parentSpanContext; + expect(parentCtx?.traceId).toBe(parentSpanRef.traceId); + expect(parentCtx?.spanId).toBe(parentSpanRef.spanId); + }); + + it("should not create spans when no messages are sent", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + + await middleware.onTurn(ctx, async () => { + // next() without sending any messages + }); + + await flushProvider.forceFlush(); + expect( + exporter.getFinishedSpans().find((s) => s.name.includes("output_messages")), + ).toBeUndefined(); + }); + + it("should re-throw errors from sendNext after recording on OutputScope", async () => { + const middleware = new OutputLoggingMiddleware(); + const ctx = makeMockTurnContext({ text: "Hello" }); + ctx.turnState.set(A365_AUTH_TOKEN_KEY, ""); + const sendError = new Error("send pipeline failed"); + + // Override simulateSend to make the final send throw + const origSimulate = ctx.simulateSend.bind(ctx); + ctx.simulateSend = async (activities) => { + // Register the handler first via onTurn, then trigger a throwing send + const finalSend = async () => { + throw sendError; + }; + let current = finalSend; + for (let i = ctx._sendHandlers.length - 1; i >= 0; i--) { + const handler = ctx._sendHandlers[i]; + const prev = current; + current = () => + handler( + ctx as TurnContextLike, + activities as ActivityLike[], + prev as () => Promise, + ); + } + return await current(); + }; + + await middleware.onTurn(ctx, async () => { + await expect(ctx.simulateSend([{ type: "message", text: "Will fail" }])).rejects.toThrow( + "send pipeline failed", + ); + }); + + await flushProvider.forceFlush(); + const outputSpan = exporter.getFinishedSpans().find((s) => s.name.includes("output_messages")); + expect(outputSpan).toBeDefined(); + // The span should have an error event recorded + const errorEvents = outputSpan!.events.filter((e) => e.name === "exception"); + expect(errorEvents.length).toBeGreaterThan(0); + }); + + it("should export A365_PARENT_SPAN_KEY and A365_AUTH_TOKEN_KEY constants", () => { + expect(A365_PARENT_SPAN_KEY).toBe("A365ParentSpanId"); + expect(A365_AUTH_TOKEN_KEY).toBe("A365AuthToken"); + }); +}); diff --git a/test/internal/unit/a365/hosting/scopeUtils.test.ts b/test/internal/unit/a365/hosting/scopeUtils.test.ts new file mode 100644 index 00000000..10541ed2 --- /dev/null +++ b/test/internal/unit/a365/hosting/scopeUtils.test.ts @@ -0,0 +1,411 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from "vitest"; +import { trace, context as otelContext, SpanKind } from "@opentelemetry/api"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { + ScopeUtils, + OpenTelemetryConstants, + OpenTelemetryScope, + InvokeAgentScope, + InferenceScope, + ExecuteToolScope, +} from "../../../../../src/a365/index.js"; +import type { TurnContextLike } from "../../../../../src/a365/index.js"; + +let contextManager: AsyncLocalStorageContextManager; + +const testAuthToken = "mock-auth-token"; + +const originalConsoleWarn = console.warn; +const originalConsoleError = console.error; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); + + const exporter = new InMemorySpanExporter(); + const processor = new SimpleSpanProcessor(exporter); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const globalProvider: any = trace.getTracerProvider(); + if (globalProvider && typeof globalProvider.addSpanProcessor === "function") { + globalProvider.addSpanProcessor(processor); + } else { + 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(); +}); + +function makeTurnContext( + text?: string, + channelName?: string, + channelLink?: string, + conversationId?: string, +): TurnContextLike { + return { + activity: { + text: text ?? "hello world", + channelId: channelName ?? "web", + channelIdSubChannel: channelLink ?? "https://example/channel", + conversation: { id: conversationId ?? "conv-001" }, + serviceUrl: "https://example.com", + from: { + aadObjectId: "user-oid", + name: "Test User", + agenticUserId: "user@contoso.com", + tenantId: "tenant-xyz", + agenticAppBlueprintId: "caller-agentBlueprintId", + agenticAppId: "callerAgent-1", + role: "user", + }, + recipient: { + aadObjectId: "agent-oid", + name: "Agent One", + role: "assistant", + }, + isAgenticRequest: () => true, + getAgenticInstanceId: () => "agent-1", + getAgenticUser: () => "agent-upn@contoso.com", + getAgenticTenantId: () => "tenant-123", + }, + turnState: new Map(), + }; +} + +function makeCtx(partial: Partial): TurnContextLike { + return partial as unknown as TurnContextLike; +} + +describe("ScopeUtils", () => { + describe("deriveAgentDetails", () => { + it("should map recipient fields to AgentDetails", () => { + const ctx = makeCtx({ + activity: { + recipient: { name: "A", aadObjectId: "auid", role: "bot" }, + isAgenticRequest: () => false, + getAgenticInstanceId: () => "aid", + getAgenticUser: () => "upn1", + getAgenticTenantId: () => "t1", + }, + }); + const result = ScopeUtils.deriveAgentDetails(ctx, testAuthToken); + expect(result).toEqual({ + agentId: undefined, + agentName: "A", + agentAUID: "auid", + agentBlueprintId: undefined, + agentEmail: "upn1", + agentDescription: "bot", + tenantId: "t1", + }); + }); + + it("should return undefined without recipient", () => { + const ctx = makeCtx({ activity: {} }); + expect(ScopeUtils.deriveAgentDetails(ctx, testAuthToken)).toBeUndefined(); + }); + }); + + describe("deriveCallerAgent", () => { + it("should map from fields to caller AgentDetails", () => { + const ctx = makeCtx({ + activity: { + from: { + agenticAppBlueprintId: "bp", + name: "Caller", + aadObjectId: "uid", + agenticUserId: "caller-upn", + role: "agent", + tenantId: "t2", + agenticAppId: "agent-caller", + }, + }, + }); + expect(ScopeUtils.deriveCallerAgent(ctx)).toEqual({ + agentBlueprintId: "bp", + agentName: "Caller", + agentAUID: "uid", + agentEmail: "caller-upn", + agentDescription: "agent", + tenantId: "t2", + agentId: "agent-caller", + }); + }); + + it("should return undefined without from", () => { + const ctx = makeCtx({ activity: {} }); + expect(ScopeUtils.deriveCallerAgent(ctx)).toBeUndefined(); + }); + }); + + describe("deriveCallerDetails", () => { + it("should map from to UserDetails", () => { + const ctx = makeCtx({ + activity: { + from: { + aadObjectId: "uid", + agenticUserId: "upn", + name: "User", + tenantId: "t3", + }, + }, + }); + expect(ScopeUtils.deriveCallerDetails(ctx)).toEqual({ + userId: "uid", + userEmail: "upn", + userName: "User", + tenantId: "t3", + }); + }); + + it("should return undefined without from", () => { + const ctx = makeCtx({ activity: {} }); + expect(ScopeUtils.deriveCallerDetails(ctx)).toBeUndefined(); + }); + }); + + describe("deriveConversationId", () => { + it("should return id when present", () => { + const ctx = makeCtx({ activity: { conversation: { id: "conv-1" } } }); + expect(ScopeUtils.deriveConversationId(ctx)).toBe("conv-1"); + }); + + it("should return undefined when missing", () => { + const ctx = makeCtx({ activity: {} }); + expect(ScopeUtils.deriveConversationId(ctx)).toBeUndefined(); + }); + }); + + describe("deriveChannelObject", () => { + it("should map channel name/description", () => { + const ctx = makeCtx({ + activity: { channelId: "teams", channelIdSubChannel: "chat" }, + }); + expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ + name: "teams", + description: "chat", + }); + }); + + it("should return undefined fields when missing", () => { + const ctx = makeCtx({ activity: {} }); + expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ + name: undefined, + description: undefined, + }); + }); + }); + + describe("populateInferenceScopeFromTurnContext", () => { + it("should build InferenceScope based on turn context", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const details = { + operationName: "inference", + model: "gpt-4o", + providerName: "openai", + } as any; + const ctx = makeTurnContext("input text", "web", "https://web", "conv-A"); + const scope = ScopeUtils.populateInferenceScopeFromTurnContext(details, ctx, testAuthToken); + + expect(scope).toBeInstanceOf(InferenceScope); + + const calls = spy.mock.calls.map((args) => [args[0], args[1]]); + expect(calls).toEqual( + expect.arrayContaining([ + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, "conv-A"], + [OpenTelemetryConstants.CHANNEL_NAME_KEY, "web"], + [OpenTelemetryConstants.CHANNEL_LINK_KEY, "https://web"], + [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, "Agent One"], + [OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, "agent-oid"], + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, "agent-1"], + [OpenTelemetryConstants.TENANT_ID_KEY, "tenant-123"], + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + + it("should throw when agent details are missing (no recipient)", () => { + const details = { operationName: "inference", model: "m", providerName: "prov" } as any; + const ctx = makeCtx({ + activity: { getAgenticTenantId: () => "t1" }, + }); + expect(() => + ScopeUtils.populateInferenceScopeFromTurnContext(details, ctx, testAuthToken), + ).toThrow("Missing agent details on TurnContext (recipient)"); + }); + }); + + describe("populateInvokeAgentScopeFromTurnContext", () => { + it("should build InvokeAgentScope based on turn context", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const ctx = makeTurnContext("invoke message", "teams", "https://teams", "conv-B"); + const scope = ScopeUtils.populateInvokeAgentScopeFromTurnContext( + { agentId: "invoke-agent", providerName: "internal" } as any, + {}, + ctx, + testAuthToken, + ); + + expect(scope).toBeInstanceOf(InvokeAgentScope); + + const calls = spy.mock.calls.map((args) => [args[0], args[1]]); + expect(calls).toEqual( + expect.arrayContaining([ + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, "conv-B"], + [OpenTelemetryConstants.CHANNEL_NAME_KEY, "teams"], + [OpenTelemetryConstants.CHANNEL_LINK_KEY, "https://teams"], + [OpenTelemetryConstants.USER_ID_KEY, "user-oid"], + [OpenTelemetryConstants.USER_NAME_KEY, "Test User"], + [OpenTelemetryConstants.USER_EMAIL_KEY, "user@contoso.com"], + [OpenTelemetryConstants.TENANT_ID_KEY, "tenant-123"], + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + + it("should forward spanKind", () => { + const spy = vi.spyOn(InvokeAgentScope, "start"); + const ctx = makeTurnContext("hello", "web", "https://web", "conv-span"); + const scope = ScopeUtils.populateInvokeAgentScopeFromTurnContext( + { agentId: "test-agent" } as any, + {}, + ctx, + testAuthToken, + undefined, + undefined, + SpanKind.SERVER, + ); + + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ spanKind: SpanKind.SERVER }), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + }); + + describe("populateExecuteToolScopeFromTurnContext", () => { + it("should build ExecuteToolScope based on turn context", () => { + const spy = vi.spyOn(OpenTelemetryScope.prototype as any, "setTagMaybe"); + const details = { toolName: "search", arguments: "{}" } as any; + const ctx = makeTurnContext(undefined, "cli", "https://cli", "conv-C"); + const scope = ScopeUtils.populateExecuteToolScopeFromTurnContext(details, ctx, testAuthToken); + + expect(scope).toBeInstanceOf(ExecuteToolScope); + + const calls = spy.mock.calls.map((args) => [args[0], args[1]]); + expect(calls).toEqual( + expect.arrayContaining([ + [OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, "conv-C"], + [OpenTelemetryConstants.CHANNEL_NAME_KEY, "cli"], + [OpenTelemetryConstants.CHANNEL_LINK_KEY, "https://cli"], + [OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, "agent-oid"], + [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, "Agent One"], + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, "agent-1"], + [OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, "assistant"], + [OpenTelemetryConstants.TENANT_ID_KEY, "tenant-123"], + ]), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + + it("should throw when agent details are missing (no recipient)", () => { + const details = { toolName: "tool" } as any; + const ctx = makeCtx({ + activity: { getAgenticTenantId: () => "t1" }, + }); + expect(() => + ScopeUtils.populateExecuteToolScopeFromTurnContext(details, ctx, testAuthToken), + ).toThrow("Missing agent details on TurnContext (recipient)"); + }); + + it("should forward spanKind", () => { + const spy = vi.spyOn(ExecuteToolScope, "start"); + const ctx = makeTurnContext(); + const scope = ScopeUtils.populateExecuteToolScopeFromTurnContext( + { toolName: "tool" } as any, + ctx, + testAuthToken, + undefined, + undefined, + SpanKind.CLIENT, + ); + + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ spanKind: SpanKind.CLIENT }), + ); + + scope?.dispose(); + spy.mockRestore(); + }); + }); + + describe("buildInvokeAgentDetails", () => { + it("should merge agent (recipient) into provided details", () => { + const ctx = makeCtx({ + activity: { + recipient: { name: "Rec", role: "bot" }, + conversation: { id: "c-2" }, + channelId: "web", + isAgenticRequest: () => false, + getAgenticInstanceId: () => "rec-agent", + getAgenticUser: () => undefined as unknown as string, + getAgenticTenantId: () => "tX", + }, + }); + + const result = ScopeUtils.buildInvokeAgentDetails( + { agentId: "provided" } as any, + ctx, + testAuthToken, + ); + expect(result.agentName).toBe("Rec"); + }); + + it("should keep base details when TurnContext has no overrides", () => { + const ctx = makeCtx({ activity: {} }); + const result = ScopeUtils.buildInvokeAgentDetails( + { agentId: "base-agent" } as any, + ctx, + testAuthToken, + ); + expect(result.agentId).toBe("base-agent"); + }); + }); +}); diff --git a/test/internal/unit/a365/hosting/turnContextUtils.test.ts b/test/internal/unit/a365/hosting/turnContextUtils.test.ts new file mode 100644 index 00000000..59c7a27a --- /dev/null +++ b/test/internal/unit/a365/hosting/turnContextUtils.test.ts @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { context as otelContext } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { + getCallerBaggagePairs, + getTargetAgentBaggagePairs, + getTenantIdPair, + getChannelBaggagePairs, + getConversationIdAndItemLinkPairs, + resolveEmbodiedAgentIds, + OpenTelemetryConstants, +} from "../../../../../src/a365/index.js"; +import type { TurnContextLike } from "../../../../../src/a365/index.js"; + +let contextManager: AsyncLocalStorageContextManager; + +beforeAll(() => { + contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + otelContext.setGlobalContextManager(contextManager); +}); + +afterAll(() => { + contextManager.disable(); + otelContext.disable(); +}); + +function makeMockTurnContext(): TurnContextLike { + return { + activity: { + from: { + aadObjectId: "user-oid", + name: "User One", + agenticUserId: "user@contoso.com", + tenantId: "tenant1", + role: "user", + agenticAppBlueprintId: "caller-blueprint-001", + }, + recipient: { + aadObjectId: "agent-oid", + name: "Agent One", + role: "assistant", + }, + conversation: { id: "conv-1" }, + channelId: "web", + channelIdSubChannel: "general", + serviceUrl: "https://smba.trafficmanager.net/teams/", + text: "Hello world", + isAgenticRequest: () => true, + getAgenticInstanceId: () => "agent-instance-1", + getAgenticTenantId: () => "tenant1", + getAgenticUser: () => "agent@contoso.com", + }, + turnState: new Map(), + }; +} + +describe("TurnContextUtils", () => { + describe("getCallerBaggagePairs", () => { + it("should return caller baggage pairs from activity.from", () => { + const ctx = makeMockTurnContext(); + const pairs = getCallerBaggagePairs(ctx); + expect(Array.isArray(pairs)).toBe(true); + expect(pairs.length).toBeGreaterThan(0); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.USER_ID_KEY]).toBe("user-oid"); + expect(obj[OpenTelemetryConstants.USER_NAME_KEY]).toBe("User One"); + expect(obj[OpenTelemetryConstants.USER_EMAIL_KEY]).toBe("user@contoso.com"); + expect(obj[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY]).toBe( + "caller-blueprint-001", + ); + }); + + it("should return empty array when from is undefined", () => { + const ctx: TurnContextLike = { activity: {}, turnState: new Map() }; + const pairs = getCallerBaggagePairs(ctx); + expect(pairs).toEqual([]); + }); + + it("should filter out undefined/empty values", () => { + const ctx: TurnContextLike = { + activity: { from: { name: "User", aadObjectId: "" } }, + turnState: new Map(), + }; + const pairs = getCallerBaggagePairs(ctx); + const keys = pairs.map(([k]) => k); + // aadObjectId is empty string → filtered out + expect(keys).not.toContain(OpenTelemetryConstants.USER_ID_KEY); + expect(keys).toContain(OpenTelemetryConstants.USER_NAME_KEY); + }); + }); + + describe("getTargetAgentBaggagePairs", () => { + it("should return target agent baggage pairs from activity.recipient", () => { + const ctx = makeMockTurnContext(); + const pairs = getTargetAgentBaggagePairs(ctx); + expect(Array.isArray(pairs)).toBe(true); + expect(pairs.length).toBeGreaterThan(0); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe("agent-instance-1"); + expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe("Agent One"); + expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY]).toBe("assistant"); + expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY]).toBe("agent-oid"); + }); + + it("should return empty array when recipient is undefined", () => { + const ctx: TurnContextLike = { activity: {}, turnState: new Map() }; + const pairs = getTargetAgentBaggagePairs(ctx); + expect(pairs).toEqual([]); + }); + + it("should include auth-resolved agent ID when authToken is provided", () => { + const ctx = makeMockTurnContext(); + // Create a fake JWT with appid claim + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64"); + const payload = Buffer.from(JSON.stringify({ appid: "token-agent-id" })).toString("base64"); + const fakeToken = `${header}.${payload}.sig`; + const pairs = getTargetAgentBaggagePairs(ctx, fakeToken); + const obj = Object.fromEntries(pairs); + // When isAgenticRequest returns true and token provided, blueprint from token is used + expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe("agent-instance-1"); + }); + }); + + describe("resolveEmbodiedAgentIds", () => { + it("should return agent IDs for agentic requests", () => { + const ctx = makeMockTurnContext(); + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64"); + const payload = Buffer.from(JSON.stringify({ appid: "bp-from-token" })).toString("base64"); + const token = `${header}.${payload}.sig`; + const result = resolveEmbodiedAgentIds(ctx, token); + expect(result.agentId).toBe("agent-instance-1"); + expect(result.agentBlueprintId).toBe("bp-from-token"); + }); + + it("should return undefined for non-agentic requests", () => { + const ctx: TurnContextLike = { + activity: { + isAgenticRequest: () => false, + recipient: { name: "Agent" }, + }, + turnState: new Map(), + }; + const result = resolveEmbodiedAgentIds(ctx, "some-token"); + expect(result.agentId).toBeUndefined(); + expect(result.agentBlueprintId).toBeUndefined(); + }); + + it("should handle empty token", () => { + const ctx = makeMockTurnContext(); + const result = resolveEmbodiedAgentIds(ctx, ""); + expect(result.agentId).toBe("agent-instance-1"); + // Empty token → getAgentIdFromToken returns "" → undefined + expect(result.agentBlueprintId).toBeUndefined(); + }); + + it("should use xms_par_app_azp over appid over azp from JWT", () => { + const ctx = makeMockTurnContext(); + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64"); + const payload = Buffer.from( + JSON.stringify({ xms_par_app_azp: "xms-id", appid: "appid-id", azp: "azp-id" }), + ).toString("base64"); + const token = `${header}.${payload}.sig`; + const result = resolveEmbodiedAgentIds(ctx, token); + expect(result.agentBlueprintId).toBe("xms-id"); + }); + }); + + describe("getTenantIdPair", () => { + it("should return tenant ID pair when available", () => { + const ctx = makeMockTurnContext(); + const pairs = getTenantIdPair(ctx); + expect(pairs).toEqual([[OpenTelemetryConstants.TENANT_ID_KEY, "tenant1"]]); + }); + + it("should return empty array when tenantId is not available", () => { + const ctx: TurnContextLike = { + activity: { getAgenticTenantId: () => undefined as unknown as string }, + turnState: new Map(), + }; + const pairs = getTenantIdPair(ctx); + expect(pairs).toEqual([]); + }); + }); + + describe("getChannelBaggagePairs", () => { + it("should return channel baggage pairs", () => { + const ctx = makeMockTurnContext(); + const pairs = getChannelBaggagePairs(ctx); + expect(Array.isArray(pairs)).toBe(true); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe("web"); + expect(obj[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe("general"); + }); + + it("should return empty array when context is null", () => { + const pairs = getChannelBaggagePairs(null as unknown as TurnContextLike); + expect(pairs).toEqual([]); + }); + }); + + describe("getConversationIdAndItemLinkPairs", () => { + it("should return conversation ID and item link", () => { + const ctx = makeMockTurnContext(); + const pairs = getConversationIdAndItemLinkPairs(ctx); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe("conv-1"); + expect(obj[OpenTelemetryConstants.GEN_AI_CONVERSATION_ITEM_LINK_KEY]).toBe( + "https://smba.trafficmanager.net/teams/", + ); + }); + + it("should return empty array when context is null", () => { + const pairs = getConversationIdAndItemLinkPairs(null as unknown as TurnContextLike); + expect(pairs).toEqual([]); + }); + }); +}); From ce77e82309538f5a4271fa5502fcf1ce32dde02e Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:04:09 -0700 Subject: [PATCH 6/6] Fix ci errors --- .../unit/a365/hosting/observabilityHostingManager.test.ts | 2 +- test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts | 2 -- test/internal/unit/a365/hosting/scopeUtils.test.ts | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/test/internal/unit/a365/hosting/observabilityHostingManager.test.ts b/test/internal/unit/a365/hosting/observabilityHostingManager.test.ts index 555d4bd8..d60aa31f 100644 --- a/test/internal/unit/a365/hosting/observabilityHostingManager.test.ts +++ b/test/internal/unit/a365/hosting/observabilityHostingManager.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import { ObservabilityHostingManager } from "../../../../../src/a365/hosting/observabilityHostingManager.js"; import { BaggageMiddleware } from "../../../../../src/a365/hosting/baggageMiddleware.js"; diff --git a/test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts b/test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts index 1bf1c7c5..5e63b2b1 100644 --- a/test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts +++ b/test/internal/unit/a365/hosting/outputLoggingMiddleware.test.ts @@ -15,7 +15,6 @@ import { A365_PARENT_SPAN_KEY, A365_AUTH_TOKEN_KEY, OpenTelemetryConstants, - InvokeAgentScope, } from "../../../../../src/a365/index.js"; import type { TurnContextLike, @@ -280,7 +279,6 @@ describe("OutputLoggingMiddleware", () => { const sendError = new Error("send pipeline failed"); // Override simulateSend to make the final send throw - const origSimulate = ctx.simulateSend.bind(ctx); ctx.simulateSend = async (activities) => { // Register the handler first via onTurn, then trigger a throwing send const finalSend = async () => { diff --git a/test/internal/unit/a365/hosting/scopeUtils.test.ts b/test/internal/unit/a365/hosting/scopeUtils.test.ts index 10541ed2..ab1952fb 100644 --- a/test/internal/unit/a365/hosting/scopeUtils.test.ts +++ b/test/internal/unit/a365/hosting/scopeUtils.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from "vitest"; +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; import { trace, context as otelContext, SpanKind } from "@opentelemetry/api"; import { BasicTracerProvider,