From 900fecf8a92133096cb350e402e4183f28eb249e Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 28 Oct 2026 16:17:09 -0600 Subject: [PATCH 1/8] Retire Observability packages and document OpenTelemetry migration Remove the four retired Observability packages and dedicated tests. Preserve the six maintained packages, add consumer migration and owner retirement guidance, and guard fresh release artifacts against retired content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9339e0c8-5126-4e42-b471-9dddfb80d4a8 --- .claude/agents/architecture-reviewer.md | 8 +- .claude/agents/code-review-manager.md | 8 +- .claude/agents/code-reviewer.md | 26 +- .claude/agents/pr-comment-resolver.md | 4 +- .claude/agents/prd-task-generator.md | 21 +- .claude/agents/prd-writer.md | 18 +- .claude/agents/task-implementer.md | 8 +- .claude/agents/test-coverage-reviewer.md | 3 - .github/CODEOWNERS | 3 +- .github/workflows/ci.yml | 85 +- CLAUDE.md | 81 +- DEPENDENCIES.md | 12 - HOW_TO_BUILD.md | 238 +--- HOW_TO_RELEASE.md | 42 +- README.md | 17 +- VERSIONING_QUICKREF.md | 2 +- VERSION_MANAGEMENT.md | 20 +- docs/design.md | 210 +-- docs/observability-migration.md | 206 +++ docs/prd-configuration-provider.md | 2 + eslint.config.mjs | 3 +- generate-package-dependencies.js | 7 - jest.integration.config.cjs | 31 - packPackages.js | 132 ++ package.json | 29 +- .../agents-a365-notifications/package.json | 2 +- .../README.md | 34 - .../jest.config.json | 29 - .../package.json | 73 - .../src/LangChainTraceInstrumentor.ts | 207 --- .../src/Utils.ts | 527 ------- .../src/index.ts | 5 - .../src/tracer.ts | 175 --- .../tsconfig.cjs.json | 7 - .../tsconfig.esm.json | 7 - .../tsconfig.json | 15 - .../README.md | 33 - .../docs/design.md | 207 --- .../jest.config.json | 23 - .../package.json | 79 -- .../src/Constants.ts | 35 - .../src/OpenAIAgentsTraceInstrumentor.ts | 146 -- .../src/OpenAIAgentsTraceProcessor.ts | 474 ------- .../src/Utils.ts | 481 ------- .../OpenAIObservabilityConfiguration.ts | 61 - ...OpenAIObservabilityConfigurationOptions.ts | 12 - .../src/configuration/index.ts | 14 - .../src/index.ts | 15 - .../tsconfig.cjs.json | 7 - .../tsconfig.esm.json | 7 - .../tsconfig.json | 28 - .../docs/design.md | 244 ---- .../package.json | 66 - .../src/caching/AgenticTokenCache.ts | 271 ---- .../src/index.ts | 14 - .../src/middleware/BaggageMiddleware.ts | 43 - .../middleware/ObservabilityHostingManager.ts | 60 - .../src/middleware/OutputLoggingMiddleware.ts | 131 -- .../src/utils/BaggageBuilderUtils.ts | 98 -- .../src/utils/ScopeUtils.ts | 285 ---- .../src/utils/TurnContextUtils.ts | 151 -- .../tsconfig.cjs.json | 7 - .../tsconfig.esm.json | 7 - .../tsconfig.json | 21 - packages/agents-a365-observability/.gitignore | 2 - packages/agents-a365-observability/README.md | 95 -- .../agents-a365-observability/docs/design.md | 632 --------- .../agents-a365-observability/jest.config.js | 13 - .../agents-a365-observability/package.json | 73 - .../src/ObservabilityBuilder.ts | 308 ---- .../src/ObservabilityManager.ts | 81 -- .../ObservabilityConfiguration.ts | 65 - .../ObservabilityConfigurationOptions.ts | 64 - .../PerRequestSpanProcessorConfiguration.ts | 78 - ...equestSpanProcessorConfigurationOptions.ts | 79 -- .../src/configuration/index.ts | 23 - .../agents-a365-observability/src/index.ts | 99 -- .../PerRequestProcessorInternalOverrides.ts | 15 - .../src/tracing/PerRequestSpanProcessor.ts | 301 ---- .../src/tracing/constants.ts | 162 --- .../tracing/context/parent-span-context.ts | 105 -- .../src/tracing/context/token-context.ts | 69 - .../context/trace-context-propagation.ts | 108 -- .../src/tracing/contracts.ts | 610 -------- .../src/tracing/exporter/Agent365Exporter.ts | 473 ------- .../exporter/Agent365ExporterOptions.ts | 60 - .../tracing/exporter/ExporterEventNames.ts | 24 - .../src/tracing/exporter/utils.ts | 729 ---------- .../src/tracing/message-utils.ts | 95 -- .../src/tracing/middleware/BaggageBuilder.ts | 375 ----- .../src/tracing/processors/SpanProcessor.ts | 122 -- .../src/tracing/processors/util.ts | 49 - .../src/tracing/scopes/ApplyGuardrailScope.ts | 173 --- .../src/tracing/scopes/ExecuteToolScope.ts | 99 -- .../src/tracing/scopes/InferenceScope.ts | 176 --- .../src/tracing/scopes/InvokeAgentScope.ts | 134 -- .../src/tracing/scopes/OpenTelemetryScope.ts | 311 ---- .../src/tracing/scopes/OutputScope.ts | 111 -- .../src/tracing/util.ts | 53 - .../src/utils/logging.ts | 220 --- .../tsconfig.cjs.json | 7 - .../tsconfig.esm.json | 7 - .../agents-a365-observability/tsconfig.json | 27 - packages/agents-a365-runtime/docs/design.md | 5 +- packages/agents-a365-runtime/package.json | 2 +- .../src/environment-utils.ts | 29 - .../package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- packages/agents-a365-tooling/package.json | 2 +- pnpm-lock.yaml | 924 +----------- pnpm-workspace.yaml | 22 +- setVersion.js | 15 +- tests/README.md | 2 +- tests/RUNNING_TESTS.md | 3 +- tests/TEST_PLAN.md | 19 +- tests/jest.config.cjs | 10 +- .../OpenAIObservabilityConfiguration.test.ts | 158 --- .../ObservabilityConfiguration.test.ts | 205 --- ...rRequestSpanProcessorConfiguration.test.ts | 340 ----- .../observability/core/BaggageBuilder.test.ts | 315 ----- .../core/PerRequestSpanProcessor.test.ts | 636 --------- .../observability/core/SpanProcessor.test.ts | 197 --- .../core/agent365-exporter.test.ts | 1253 ----------------- .../core/apply-guardrail-scope.test.ts | 305 ---- .../observability/core/custom-logger.test.ts | 258 ---- ...bservabilityBuilder-configProvider.test.ts | 111 -- .../core/observabilityBuilder-options.test.ts | 112 -- ...ervabilityBuilder-per-request-auth.test.ts | 138 -- ...ervabilityManager-exporter-options.test.ts | 92 -- .../core/observabilityManager.test.ts | 143 -- tests/observability/core/output-scope.test.ts | 260 ---- .../core/parent-span-ref.test.ts | 349 ----- .../core/payload-chunking.test.ts | 145 -- .../observability/core/scope-messages.test.ts | 298 ---- tests/observability/core/scopes.test.ts | 1102 --------------- tests/observability/core/span-links.test.ts | 180 --- .../observability/core/token-context.test.ts | 59 - .../core/trace-context-propagation.test.ts | 183 --- .../helpers/message-schema-validator.ts | 68 - .../hosting/BaggageBuilderUtils.test.ts | 64 - .../hosting/TurnContextUtils.test.ts | 61 - .../hosting/agentic-token-cache.test.ts | 241 ---- .../hosting/baggage-middleware.test.ts | 229 --- .../observability-hosting-manager.test.ts | 50 - .../hosting/output-logging-middleware.test.ts | 370 ----- .../extension/hosting/scope-utils.test.ts | 319 ----- .../LangChainMessageContract.test.ts | 119 -- .../LangChainObservabilityAttributes.test.ts | 587 -------- .../LangChainTraceInstrumentor.test.ts | 109 -- .../OpenAIAgentsTraceInstrumentor.test.ts | 207 --- .../openai/OpenAIAgentsTraceProcessor.test.ts | 876 ------------ .../openai/OpenAIMessageContract.test.ts | 238 ---- tests/observability/integration/.env.example | 14 - tests/observability/integration/conftest.ts | 80 -- .../integration/helpers/span-validators.ts | 144 -- .../integration/jest.polyfills.js | 19 - .../langchain-agent-instrument.test.ts | 534 ------- .../openai-agent-instrument.test.ts | 635 --------- tests/observability/integration/setup.ts | 37 - .../PerRequestProcessorOverrides.test.ts | 123 -- .../tracing/exporter-utils.test.ts | 500 ------- .../tracing/message-utils.test.ts | 197 --- tests/observability/utils/logging.test.ts | 693 --------- tests/package.json | 17 +- tests/release-packages.test.ts | 102 ++ tests/runtime/environment-utils.test.ts | 23 - 167 files changed, 708 insertions(+), 25370 deletions(-) create mode 100644 docs/observability-migration.md delete mode 100644 jest.integration.config.cjs create mode 100644 packPackages.js delete mode 100644 packages/agents-a365-observability-extensions-langchain/README.md delete mode 100644 packages/agents-a365-observability-extensions-langchain/jest.config.json delete mode 100644 packages/agents-a365-observability-extensions-langchain/package.json delete mode 100644 packages/agents-a365-observability-extensions-langchain/src/LangChainTraceInstrumentor.ts delete mode 100644 packages/agents-a365-observability-extensions-langchain/src/Utils.ts delete mode 100644 packages/agents-a365-observability-extensions-langchain/src/index.ts delete mode 100644 packages/agents-a365-observability-extensions-langchain/src/tracer.ts delete mode 100644 packages/agents-a365-observability-extensions-langchain/tsconfig.cjs.json delete mode 100644 packages/agents-a365-observability-extensions-langchain/tsconfig.esm.json delete mode 100644 packages/agents-a365-observability-extensions-langchain/tsconfig.json delete mode 100644 packages/agents-a365-observability-extensions-openai/README.md delete mode 100644 packages/agents-a365-observability-extensions-openai/docs/design.md delete mode 100644 packages/agents-a365-observability-extensions-openai/jest.config.json delete mode 100644 packages/agents-a365-observability-extensions-openai/package.json delete mode 100644 packages/agents-a365-observability-extensions-openai/src/Constants.ts delete mode 100644 packages/agents-a365-observability-extensions-openai/src/OpenAIAgentsTraceInstrumentor.ts delete mode 100644 packages/agents-a365-observability-extensions-openai/src/OpenAIAgentsTraceProcessor.ts delete mode 100644 packages/agents-a365-observability-extensions-openai/src/Utils.ts delete mode 100644 packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfiguration.ts delete mode 100644 packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfigurationOptions.ts delete mode 100644 packages/agents-a365-observability-extensions-openai/src/configuration/index.ts delete mode 100644 packages/agents-a365-observability-extensions-openai/src/index.ts delete mode 100644 packages/agents-a365-observability-extensions-openai/tsconfig.cjs.json delete mode 100644 packages/agents-a365-observability-extensions-openai/tsconfig.esm.json delete mode 100644 packages/agents-a365-observability-extensions-openai/tsconfig.json delete mode 100644 packages/agents-a365-observability-hosting/docs/design.md delete mode 100644 packages/agents-a365-observability-hosting/package.json delete mode 100644 packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts delete mode 100644 packages/agents-a365-observability-hosting/src/index.ts delete mode 100644 packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware.ts delete mode 100644 packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts delete mode 100644 packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts delete mode 100644 packages/agents-a365-observability-hosting/src/utils/BaggageBuilderUtils.ts delete mode 100644 packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts delete mode 100644 packages/agents-a365-observability-hosting/src/utils/TurnContextUtils.ts delete mode 100644 packages/agents-a365-observability-hosting/tsconfig.cjs.json delete mode 100644 packages/agents-a365-observability-hosting/tsconfig.esm.json delete mode 100644 packages/agents-a365-observability-hosting/tsconfig.json delete mode 100644 packages/agents-a365-observability/.gitignore delete mode 100644 packages/agents-a365-observability/README.md delete mode 100644 packages/agents-a365-observability/docs/design.md delete mode 100644 packages/agents-a365-observability/jest.config.js delete mode 100644 packages/agents-a365-observability/package.json delete mode 100644 packages/agents-a365-observability/src/ObservabilityBuilder.ts delete mode 100644 packages/agents-a365-observability/src/ObservabilityManager.ts delete mode 100644 packages/agents-a365-observability/src/configuration/ObservabilityConfiguration.ts delete mode 100644 packages/agents-a365-observability/src/configuration/ObservabilityConfigurationOptions.ts delete mode 100644 packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfiguration.ts delete mode 100644 packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfigurationOptions.ts delete mode 100644 packages/agents-a365-observability/src/configuration/index.ts delete mode 100644 packages/agents-a365-observability/src/index.ts delete mode 100644 packages/agents-a365-observability/src/internal/PerRequestProcessorInternalOverrides.ts delete mode 100644 packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts delete mode 100644 packages/agents-a365-observability/src/tracing/constants.ts delete mode 100644 packages/agents-a365-observability/src/tracing/context/parent-span-context.ts delete mode 100644 packages/agents-a365-observability/src/tracing/context/token-context.ts delete mode 100644 packages/agents-a365-observability/src/tracing/context/trace-context-propagation.ts delete mode 100644 packages/agents-a365-observability/src/tracing/contracts.ts delete mode 100644 packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts delete mode 100644 packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts delete mode 100644 packages/agents-a365-observability/src/tracing/exporter/ExporterEventNames.ts delete mode 100644 packages/agents-a365-observability/src/tracing/exporter/utils.ts delete mode 100644 packages/agents-a365-observability/src/tracing/message-utils.ts delete mode 100644 packages/agents-a365-observability/src/tracing/middleware/BaggageBuilder.ts delete mode 100644 packages/agents-a365-observability/src/tracing/processors/SpanProcessor.ts delete mode 100644 packages/agents-a365-observability/src/tracing/processors/util.ts delete mode 100644 packages/agents-a365-observability/src/tracing/scopes/ApplyGuardrailScope.ts delete mode 100644 packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts delete mode 100644 packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts delete mode 100644 packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts delete mode 100644 packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts delete mode 100644 packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts delete mode 100644 packages/agents-a365-observability/src/tracing/util.ts delete mode 100644 packages/agents-a365-observability/src/utils/logging.ts delete mode 100644 packages/agents-a365-observability/tsconfig.cjs.json delete mode 100644 packages/agents-a365-observability/tsconfig.esm.json delete mode 100644 packages/agents-a365-observability/tsconfig.json delete mode 100644 tests/observability-extensions-openai/configuration/OpenAIObservabilityConfiguration.test.ts delete mode 100644 tests/observability/configuration/ObservabilityConfiguration.test.ts delete mode 100644 tests/observability/configuration/PerRequestSpanProcessorConfiguration.test.ts delete mode 100644 tests/observability/core/BaggageBuilder.test.ts delete mode 100644 tests/observability/core/PerRequestSpanProcessor.test.ts delete mode 100644 tests/observability/core/SpanProcessor.test.ts delete mode 100644 tests/observability/core/agent365-exporter.test.ts delete mode 100644 tests/observability/core/apply-guardrail-scope.test.ts delete mode 100644 tests/observability/core/custom-logger.test.ts delete mode 100644 tests/observability/core/observabilityBuilder-configProvider.test.ts delete mode 100644 tests/observability/core/observabilityBuilder-options.test.ts delete mode 100644 tests/observability/core/observabilityBuilder-per-request-auth.test.ts delete mode 100644 tests/observability/core/observabilityManager-exporter-options.test.ts delete mode 100644 tests/observability/core/observabilityManager.test.ts delete mode 100644 tests/observability/core/output-scope.test.ts delete mode 100644 tests/observability/core/parent-span-ref.test.ts delete mode 100644 tests/observability/core/payload-chunking.test.ts delete mode 100644 tests/observability/core/scope-messages.test.ts delete mode 100644 tests/observability/core/scopes.test.ts delete mode 100644 tests/observability/core/span-links.test.ts delete mode 100644 tests/observability/core/token-context.test.ts delete mode 100644 tests/observability/core/trace-context-propagation.test.ts delete mode 100644 tests/observability/extension/helpers/message-schema-validator.ts delete mode 100644 tests/observability/extension/hosting/BaggageBuilderUtils.test.ts delete mode 100644 tests/observability/extension/hosting/TurnContextUtils.test.ts delete mode 100644 tests/observability/extension/hosting/agentic-token-cache.test.ts delete mode 100644 tests/observability/extension/hosting/baggage-middleware.test.ts delete mode 100644 tests/observability/extension/hosting/observability-hosting-manager.test.ts delete mode 100644 tests/observability/extension/hosting/output-logging-middleware.test.ts delete mode 100644 tests/observability/extension/hosting/scope-utils.test.ts delete mode 100644 tests/observability/extension/langchain/LangChainMessageContract.test.ts delete mode 100644 tests/observability/extension/langchain/LangChainObservabilityAttributes.test.ts delete mode 100644 tests/observability/extension/langchain/LangChainTraceInstrumentor.test.ts delete mode 100644 tests/observability/extension/openai/OpenAIAgentsTraceInstrumentor.test.ts delete mode 100644 tests/observability/extension/openai/OpenAIAgentsTraceProcessor.test.ts delete mode 100644 tests/observability/extension/openai/OpenAIMessageContract.test.ts delete mode 100644 tests/observability/integration/.env.example delete mode 100644 tests/observability/integration/conftest.ts delete mode 100644 tests/observability/integration/helpers/span-validators.ts delete mode 100644 tests/observability/integration/jest.polyfills.js delete mode 100644 tests/observability/integration/langchain-agent-instrument.test.ts delete mode 100644 tests/observability/integration/openai-agent-instrument.test.ts delete mode 100644 tests/observability/integration/setup.ts delete mode 100644 tests/observability/internal/PerRequestProcessorOverrides.test.ts delete mode 100644 tests/observability/tracing/exporter-utils.test.ts delete mode 100644 tests/observability/tracing/message-utils.test.ts delete mode 100644 tests/observability/utils/logging.test.ts create mode 100644 tests/release-packages.test.ts diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/architecture-reviewer.md index a4d9d8f5..ac2fa88d 100644 --- a/.claude/agents/architecture-reviewer.md +++ b/.claude/agents/architecture-reviewer.md @@ -231,7 +231,6 @@ Escalate to team discussion when: ### Dependency Flow ``` -runtime → observability → observability-hosting → observability-extensions-* runtime → tooling → tooling-extensions-* runtime → notifications ``` @@ -240,7 +239,6 @@ runtime → notifications | Package | Responsibility | |---------|---------------| | runtime | Foundation utilities, no external SDK deps | -| observability | OpenTelemetry tracing infrastructure | | tooling | MCP server configuration and discovery | | notifications | Agent lifecycle event handling | | *-extensions-* | Framework-specific adapters | @@ -248,8 +246,8 @@ runtime → notifications ### Design Patterns | Pattern | Usage | |---------|-------| -| Singleton | ObservabilityManager (single tracer provider) | -| Disposable | Scope classes (automatic span lifecycle) | -| Builder | BaggageBuilder, ObservabilityBuilder (fluent config) | +| Configuration Provider | Runtime and Tooling dynamic configuration | | Strategy | McpToolServerConfigurationService (dev vs prod) | | Extension Methods | Notifications extending AgentApplication | + +Observability packages are retired. Application telemetry belongs to `@microsoft/opentelemetry`; consult `docs/observability-migration.md`, not deleted SDK APIs. diff --git a/.claude/agents/code-review-manager.md b/.claude/agents/code-review-manager.md index 7d49a2dc..eb981104 100644 --- a/.claude/agents/code-review-manager.md +++ b/.claude/agents/code-review-manager.md @@ -271,12 +271,12 @@ When the task-implementer completes work: I've completed the implementation for [feature]. Please review: Files created: -- packages/agents-a365-observability/src/customProcessor.ts -- tests/observability/customProcessor.test.ts +- packages/agents-a365-tooling/src/discoveryRetry.ts +- tests/tooling/discoveryRetry.test.ts Files modified: -- packages/agents-a365-observability/src/index.ts -- packages/agents-a365-observability/src/ObservabilityBuilder.ts +- packages/agents-a365-tooling/src/index.ts +- packages/agents-a365-tooling/src/McpToolServerConfigurationService.ts Test results: 24 passed, 0 failed Lint: No errors diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index 95374b13..1aa6dcc0 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -311,28 +311,12 @@ try { ## SDK-Specific Patterns -### Disposable Pattern +### Configuration Providers ```typescript -// ✅ Correct usage with 'using' keyword -using scope = InvokeAgentScope.start(details, tenantDetails); -// Span automatically ends when scope is disposed -``` +import { defaultRuntimeConfigurationProvider } from '@microsoft/agents-a365-runtime'; -### Builder Pattern -```typescript -// ✅ Fluent API usage -const baggage = new BaggageBuilder() - .tenantId(tenantId) - .agentId(agentId) - .correlationId(correlationId) - .build(); +const config = defaultRuntimeConfigurationProvider.getConfiguration(); +const cluster = config.clusterCategory; ``` -### Singleton Access -```typescript -// ✅ Proper singleton usage -const manager = ObservabilityManager.getInstance(); -if (manager === null) { - throw new Error('ObservabilityManager not initialized'); -} -``` +Evaluate dynamic getter values at their point of use; overrides may vary per request. Observability is retired from this SDK; application integrations use the distro described in `docs/observability-migration.md`. diff --git a/.claude/agents/pr-comment-resolver.md b/.claude/agents/pr-comment-resolver.md index 965da177..37a94522 100644 --- a/.claude/agents/pr-comment-resolver.md +++ b/.claude/agents/pr-comment-resolver.md @@ -70,11 +70,11 @@ Types: `fix`, `refactor`, `style`, `docs`, `test` Example: ``` -fix(observability): add null check for tenant ID +fix(runtime): add null check for tenant ID Addresses review comment [CRM-003] - Added explicit null check before accessing tenantId -- Prevents potential runtime error when baggage is missing +- Prevents potential runtime error when request identity is missing ``` ### Phase 3: Verification diff --git a/.claude/agents/prd-task-generator.md b/.claude/agents/prd-task-generator.md index 63c004c9..be75d7d5 100644 --- a/.claude/agents/prd-task-generator.md +++ b/.claude/agents/prd-task-generator.md @@ -79,7 +79,6 @@ When generating tasks, consider: ### Package Placement - **Runtime**: Shared utilities, no external SDK dependencies -- **Observability**: OpenTelemetry integration, tracing scopes - **Tooling**: MCP server configuration and discovery - **Notifications**: Agent lifecycle event handling - **Extensions**: Framework-specific adapters (OpenAI, Claude, LangChain) @@ -88,7 +87,6 @@ When generating tasks, consider: - Use workspace protocol: `"@microsoft/agents-a365-runtime": "workspace:*"` - Understand the dependency flow: ``` - runtime → observability → observability-hosting → observability-extensions-* runtime → tooling → tooling-extensions-* runtime → notifications ``` @@ -99,9 +97,8 @@ When generating tasks, consider: - Build output: `dist/cjs/`, `dist/esm/` ### Observability Integration -- New features should emit appropriate spans -- Use existing scope classes: `InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope` -- Propagate baggage context for correlation +- Application telemetry is owned by the external `@microsoft/opentelemetry` distro. +- Follow `docs/observability-migration.md`; do not recreate retired scope implementations or add the distro to unaffected SDK packages. ## Deliverable Format @@ -167,25 +164,25 @@ Before finalizing, verify: ## Example Output -**Input**: PRD for adding custom span processors to observability +**Input**: PRD for adding configurable MCP discovery retries to Tooling **Output**: ```markdown -# Implementation Plan: Custom Span Processors +# Implementation Plan: MCP Discovery Retries ## Executive Summary -This plan breaks down the custom span processor feature into 6 tasks across 3 phases. The implementation adds a plugin architecture to ObservabilityBuilder while maintaining backward compatibility. +This plan breaks down configurable discovery retries into 6 tasks across 3 phases, preserving existing defaults and request-specific configuration. ## Architecture Impact -- **Primary Package**: `@microsoft/agents-a365-observability` +- **Primary Package**: `@microsoft/agents-a365-tooling` - **Dependencies**: None new - **Breaking Changes**: None (additive API) ## Phase 1: Foundation (2 tasks) -### Task 1.1: Define SpanProcessor Interface -**Package**: `@microsoft/agents-a365-observability` -**Description**: Create the interface contract for custom span processors... +### Task 1.1: Define Retry Configuration +**Package**: `@microsoft/agents-a365-tooling` +**Description**: Define bounded retries and backoff with existing configuration-provider patterns... ... ## Phase 2: Core Implementation (2 tasks) diff --git a/.claude/agents/prd-writer.md b/.claude/agents/prd-writer.md index b5db3501..b56ef0c4 100644 --- a/.claude/agents/prd-writer.md +++ b/.claude/agents/prd-writer.md @@ -32,15 +32,14 @@ Systematically extract: ### 2. Contextual Awareness Maintain alignment with the SDK's architecture: -- **Monorepo structure**: 9 interdependent packages in `packages/` +- **Monorepo structure**: Six maintained packages in `packages/` - **Core + Extensions pattern**: Framework-agnostic core with framework-specific extensions - **Package categories**: - Runtime: Foundation utilities - - Observability: OpenTelemetry tracing - Tooling: MCP server configuration - Notifications: Agent lifecycle events - **TypeScript standards**: Strict typing, interfaces over types where appropriate -- **Design patterns**: Singleton, Disposable, Builder, Strategy, Extension Methods +- **Design patterns**: Configuration Provider, Strategy, Extension Methods - **Async/await conventions**: Promise-based APIs for I/O operations - **Dual module format**: Both CJS and ESM builds required @@ -49,7 +48,7 @@ Maintain alignment with the SDK's architecture: Before drafting, ask targeted questions: 1. "What problem does this solve for developers?" -2. "Which packages are affected?" (runtime, observability, tooling, notifications, extensions) +2. "Which packages are affected?" (runtime, tooling, notifications, tooling extensions) 3. "Does this extend core functionality or require a new extension package?" 4. "What are the measurable success metrics?" 5. "Are there security or compliance considerations?" @@ -97,6 +96,7 @@ Generate documents with these sections: - Usage examples ### 8. Observability +- Application-owned telemetry uses the external Microsoft OpenTelemetry distro; see `docs/observability-migration.md`. No telemetry package is maintained here. - Tracing integration requirements - Span attributes and events - Metrics to capture @@ -173,15 +173,15 @@ PRDs must be: ## Example Interaction -**User**: "I want to add support for custom span processors in the observability package" +**User**: "I want to add configurable MCP discovery retries in the tooling package" **PRD Writer Response**: "Before I draft the PRD, I have a few clarifying questions: -1. Should custom processors run before or after the built-in Agent365Exporter? -2. Will this require changes to the ObservabilityBuilder API? -3. Should processors have access to the baggage context (tenant ID, agent ID)? -4. Are there specific use cases driving this? (e.g., filtering, sampling, custom export) +1. Which transient discovery failures should be retried? +2. Will this require changes to ToolingConfiguration? +3. Should retry settings vary per tenant/request? +4. What latency and attempt limits are required? 5. Should this be backward-compatible with existing configurations? Once I understand these aspects, I'll create a comprehensive PRD covering the API design, integration points, and testing strategy." diff --git a/.claude/agents/task-implementer.md b/.claude/agents/task-implementer.md index 5332a0c2..83577787 100644 --- a/.claude/agents/task-implementer.md +++ b/.claude/agents/task-implementer.md @@ -39,9 +39,7 @@ Before writing code: Determine: - **Package placement**: Core package or extension? - **Pattern alignment**: Which design patterns apply? - - Singleton (ObservabilityManager) - - Disposable (Scope classes with `using` keyword) - - Builder (BaggageBuilder, ObservabilityBuilder) + - Configuration Provider (Runtime and Tooling dynamic overrides) - Strategy (environment-based configuration) - Extension Methods (TypeScript declaration merging) - **Cross-package impacts**: Dependencies, shared types @@ -201,10 +199,10 @@ Before requesting code review, verify: - **Node.js versions**: >=18.0.0 - **TypeScript**: Strict mode enabled -- **Structure**: pnpm workspace monorepo with 9 packages +- **Structure**: pnpm workspace monorepo with six maintained packages - **Package naming**: `@microsoft/agents-a365-` - **Build output**: Dual format (CJS and ESM) -- **Observability**: OpenTelemetry for traces, spans, metrics +- **Application telemetry**: External `@microsoft/opentelemetry` distro; see `docs/observability-migration.md`. Do not reintroduce retired SDK packages. - **Tool integration**: MCP (Model Context Protocol) - **Testing**: Jest with ts-jest preset diff --git a/.claude/agents/test-coverage-reviewer.md b/.claude/agents/test-coverage-reviewer.md index 15aa07a2..04cf3877 100644 --- a/.claude/agents/test-coverage-reviewer.md +++ b/.claude/agents/test-coverage-reviewer.md @@ -61,9 +61,6 @@ Provide specific recommendations: ### Test Directory Structure ``` tests/ -├── observability/ -│ ├── BaggageBuilder.test.ts -│ └── InvokeAgentScope.test.ts ├── runtime/ │ └── Utility.test.ts ├── tooling/ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b92fc815..c96398f8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,8 +1,7 @@ # This will eventually be replaced by security group * @microsoft/agent365-approvers /.github/ @microsoft/agent365-approvers -/packages/agents-a365-observability*/ @microsoft/agent365-observability-approvers -/tests/observability/ @microsoft/agent365-observability-approvers +/docs/observability-migration.md @microsoft/agent365-observability-approvers /packages/agents-a365-notifications/ @microsoft/agent365-tooling-approvers /packages/agents-a365-tooling*/ @microsoft/agent365-tooling-approvers /tests/tooling*/ @microsoft/agent365-tooling-approvers diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09a42af5..ad3150b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,93 +80,32 @@ jobs: version: 10.20.0 - name: Install dependencies - run: pnpm i + run: pnpm install --frozen-lockfile - name: Lint code - run: npm run lint + run: pnpm lint - name: Set version with Nerdbank.GitVersioning run: node setVersion.js - name: Build package - run: npm run build + run: pnpm build + + - name: Type check remaining packages + run: pnpm typecheck - name: Run tests - run: npm test + run: pnpm test - name: Pack packages - run: npm run pack + run: pnpm run pack artifacts/release-${{ matrix.node-version }} - # Copy packages to drop folder - - name: Copy packages to drop folder - run: | - mkdir -p ~/drop/nodejs-${{ matrix.node-version }} && cp -v packages/*.tgz $_ + - name: Verify release artifacts independently + run: node packPackages.js --verify artifacts/release-${{ matrix.node-version }} - name: Upload NPM packages as artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: nodejs-${{ matrix.node-version }} - path: ~/drop/nodejs-${{ matrix.node-version }}/*.tgz - - # - name: Publish to NPM (commented for now) - # if: github.event_name == 'push' && github.ref == 'refs/heads/main' - # run: npm publish - # env: - # NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - integration-tests: - name: Integration tests - needs: [nodejs-sdk] - # Pull request workflows can be edited by the PR author, so the Azure OpenAI key lives in the - # integration-tests environment, which only allows deployments from main. - if: github.event_name == 'push' - runs-on: ubuntu-latest - environment: integration-tests - permissions: - contents: read - - strategy: - matrix: - node-version: ['18', '20'] - - steps: - - name: Checkout repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - fetch-depth: 0 - - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Install pnpm - with: - run_install: false - - - name: Set up Node.js ${{ matrix.node-version }} - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: ${{ matrix.node-version }} - cache: 'pnpm' - cache-dependency-path: '**/pnpm-lock.yaml' - - - name: Setup pnpm - uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 - with: - version: 10.20.0 - - - name: Install dependencies - run: pnpm i - - - name: Set version with Nerdbank.GitVersioning - run: node setVersion.js - - - name: Build package - run: npm run build - - - name: Run integration tests - env: - AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} - AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }} - AZURE_OPENAI_DEPLOYMENT: ${{ vars.AZURE_OPENAI_DEPLOYMENT }} - AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION }} - ENABLE_OBSERVABILITY: ${{ vars.ENABLE_OBSERVABILITY }} - run: | - npm run test:integration + path: artifacts/release-${{ matrix.node-version }}/ + if-no-files-found: error diff --git a/CLAUDE.md b/CLAUDE.md index 0ba2b924..c5a252a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -The Microsoft Agent 365 SDK extends the Microsoft 365 Agents SDK with enterprise-grade capabilities for building sophisticated AI agents. This Node.js/TypeScript monorepo provides comprehensive tooling across four core areas: +The Microsoft Agent 365 SDK extends the Microsoft 365 Agents SDK with enterprise-grade capabilities for building sophisticated AI agents. This Node.js/TypeScript monorepo provides comprehensive tooling across three core areas: -- **Observability**: OpenTelemetry-based tracing and monitoring for agent applications - **Notifications**: Agent notification services and lifecycle event handling - **Runtime**: Core utilities for agent operations and Power Platform integration - **Tooling**: MCP (Model Context Protocol) server configuration and tool discovery +Observability packages are retired here. Applications migrate to `@microsoft/opentelemetry`; see [the migration guide](docs/observability-migration.md). Do not restore retired implementations or add the distro to otherwise unaffected SDK packages. + ## Essential Commands ### Package Management @@ -33,7 +34,7 @@ pnpm clean ### Testing ```bash -# Run all unit tests (excludes integration tests) +# Run all SDK tests pnpm test # Run tests with coverage @@ -42,11 +43,8 @@ pnpm test:coverage # Run tests in watch mode pnpm test:watch -# Run integration tests -pnpm test:integration - -# Run tests for a specific package -cd packages/agents-a365-observability && pnpm test +# Run Runtime tests through the centralized runner +pnpm --filter @microsoft/agents-a365-tests test --runInBand --testPathPatterns=runtime ``` ### Code Quality @@ -62,25 +60,24 @@ pnpm lint:fix ### Package Building and Distribution ```bash -# Build and create .tgz files for distribution -pnpm build -cd packages && pnpm pack --workspaces - -# The .tgz files will be created in the root directory +# Clean, build, pack and inspect exactly six maintained packages +pnpm run pack +# Output: a fresh artifacts/packages- directory plus packages.json receipt +# Reinspect a specific output independently: +node packPackages.js --verify ``` +Never publish cached/wildcard-discovered tarballs. Release only receipt-listed artifacts from the approved commit; external publishers and old release branches need their own allowlist enforcement. See HOW_TO_RELEASE.md. + ## Architecture ### Monorepo Structure -This is a pnpm workspace monorepo with 9 packages in `packages/`: +This is a pnpm workspace monorepo with six packages in `packages/`: ``` packages/ ├── agents-a365-runtime/ # Core utilities (no external deps) -├── agents-a365-observability/ # OpenTelemetry tracing (depends on runtime) -├── agents-a365-observability-hosting/ # Hosting-specific observability -├── agents-a365-observability-extensions-openai/ # OpenAI instrumentation ├── agents-a365-notifications/ # Agent notification services ├── agents-a365-tooling/ # MCP server configuration ├── agents-a365-tooling-extensions-claude/ # Claude/Anthropic integration @@ -90,9 +87,7 @@ packages/ **Dependency Flow:** ``` -runtime ──► observability ──► observability-hosting ──► observability-extensions-openai - │ - └─────────► tooling ──► tooling-extensions-* (claude, langchain, openai) +runtime ─────► tooling ──► tooling-extensions-* (claude, langchain, openai) │ └─────────► notifications ``` @@ -123,12 +118,9 @@ packages/agents-a365-/ ### Key Design Patterns -1. **Singleton Pattern**: `ObservabilityManager` ensures single tracer provider per application -2. **Disposable Pattern**: Scope classes (`InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope`) implement `Disposable` for automatic span lifecycle with `using` keyword -3. **Builder Pattern**: `ObservabilityBuilder` and `BaggageBuilder` provide fluent APIs -4. **Strategy Pattern**: `McpToolServerConfigurationService` switches between manifest (dev) and gateway (prod) based on `NODE_ENV` -5. **Extension Methods**: `notifications` package extends `AgentApplication` via TypeScript declaration merging -6. **Configuration Provider Pattern**: Hierarchical configuration with function-based overrides for multi-tenant support. Each package has its own configuration class (`RuntimeConfiguration`, `ToolingConfiguration`, `ObservabilityConfiguration`) with default singleton providers (`defaultRuntimeConfigurationProvider`, etc.) +1. **Strategy Pattern**: `McpToolServerConfigurationService` switches between manifest (dev) and gateway (prod) based on `NODE_ENV` +2. **Extension Methods**: `notifications` package extends `AgentApplication` via TypeScript declaration merging +3. **Configuration Provider Pattern**: Hierarchical configuration with function-based overrides for multi-tenant support. `RuntimeConfiguration` and `ToolingConfiguration` use default singleton providers (`defaultRuntimeConfigurationProvider`, etc.). Overrides can return different values per request; do not cache getter values as process-static. ## Core Package Functionality @@ -141,30 +133,6 @@ Foundation package with no SDK dependencies. Provides: - **`PowerPlatformApiDiscovery`**: Endpoint discovery for different clouds (prod, gov, dod, mooncake, etc.) - **Environment utilities** _(deprecated)_: `getClusterCategory()`, `isDevelopmentEnvironment()`, etc. - use `RuntimeConfiguration` instead -### Observability (`@microsoft/agents-a365-observability`) -OpenTelemetry-based distributed tracing: -- **`ObservabilityConfiguration`**: Configuration with `observabilityAuthenticationScopes`, `isObservabilityExporterEnabled`, `observabilityLogLevel`, etc. -- **`PerRequestSpanProcessorConfiguration`**: Extends `ObservabilityConfiguration` with per-request processor settings (`isPerRequestExportEnabled`, `perRequestMaxTraces`, `perRequestMaxSpansPerTrace`, `perRequestMaxConcurrentExports`). Separated from `ObservabilityConfiguration` because these settings are only relevant when using `PerRequestSpanProcessor`. -- **`defaultObservabilityConfigurationProvider`**: Singleton configuration provider for observability settings -- **`defaultPerRequestSpanProcessorConfigurationProvider`**: Singleton configuration provider for per-request span processor settings -- **`ObservabilityManager`**: Main entry point (singleton) -- **`ObservabilityBuilder`**: Fluent configuration API -- **Scope classes**: - - `InvokeAgentScope`: Trace agent invocations (root span) - - `InferenceScope`: Trace LLM/AI inference calls - - `ExecuteToolScope`: Trace tool execution -- **`BaggageBuilder`**: Context propagation across async boundaries (tenant ID, agent ID, correlation ID) -- **Data contracts**: `InvokeAgentDetails`, `AgentDetails`, `TenantDetails`, `InferenceDetails`, `ToolCallDetails` - -**Tracing Flow:** -``` -BaggageBuilder.build().run() - └─► InvokeAgentScope.start() [Root span - agent request] - ├─► InferenceScope.start() [Child span - LLM call] - ├─► ExecuteToolScope.start() [Child span - tool execution] - └─► Agent365Exporter.export() [Batched HTTP export] -``` - ### Tooling (`@microsoft/agents-a365-tooling`) MCP tool server discovery and configuration: - **`ToolingConfiguration`**: Configuration with `mcpPlatformEndpoint`, `mcpPlatformAuthenticationScope`, `useToolingManifest` @@ -210,14 +178,6 @@ The keyword "Kairo" is legacy and should not appear in any code. Flag and remove | `CLUSTER_CATEGORY` | Environment classification | `local`, `dev`, `test`, `preprod`, `prod`, `gov`, `high`, `dod`, `mooncake`, `ex`, `rx` | | `MCP_PLATFORM_ENDPOINT` | MCP platform base URL | URL string | | `MCP_PLATFORM_AUTHENTICATION_SCOPE` | MCP platform auth scope | Scope string | -| `A365_OBSERVABILITY_SCOPES_OVERRIDE` | Override observability auth scopes | Space-separated scope strings | -| `ENABLE_A365_OBSERVABILITY_EXPORTER` | Enable Agent365 exporter | `true`, `false` (default) | -| `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` | Enable per-request export mode | `true`, `false` (default) | -| `A365_OBSERVABILITY_DOMAIN_OVERRIDE` | Custom domain URL override | URL string | -| `A365_OBSERVABILITY_LOG_LEVEL` | Internal logging level | `none` (default), `error`, `warn`, `info`, `debug` | -| `A365_PER_REQUEST_MAX_TRACES` | Max buffered traces per request (`PerRequestSpanProcessorConfiguration`) | Number (default: 1000) | -| `A365_PER_REQUEST_MAX_SPANS_PER_TRACE` | Max spans per trace (`PerRequestSpanProcessorConfiguration`) | Number (default: 5000) | -| `A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS` | Max concurrent exports (`PerRequestSpanProcessorConfiguration`) | Number (default: 20) | ## Testing @@ -231,7 +191,7 @@ The keyword "Kairo" is legacy and should not appear in any code. Flag and remove The Jest config includes module name mappers to resolve workspace packages: ```javascript '@microsoft/agents-a365-runtime$': '/packages/agents-a365-runtime/src' -'@microsoft/agents-a365-observability$': '/packages/agents-a365-observability/src' +'@microsoft/agents-a365-tooling$': '/packages/agents-a365-tooling/src' // etc. ``` @@ -258,9 +218,6 @@ npm run version:check - **Overall Architecture**: [docs/design.md](docs/design.md) - Main SDK architecture and design - **Runtime Package**: [packages/agents-a365-runtime/docs/design.md](packages/agents-a365-runtime/docs/design.md) -- **Observability Package**: [packages/agents-a365-observability/docs/design.md](packages/agents-a365-observability/docs/design.md) -- **Observability Hosting**: [packages/agents-a365-observability-hosting/docs/design.md](packages/agents-a365-observability-hosting/docs/design.md) -- **Observability Extensions (OpenAI)**: [packages/agents-a365-observability-extensions-openai/docs/design.md](packages/agents-a365-observability-extensions-openai/docs/design.md) - **Notifications Package**: [packages/agents-a365-notifications/docs/design.md](packages/agents-a365-notifications/docs/design.md) - **Tooling Package**: [packages/agents-a365-tooling/docs/design.md](packages/agents-a365-tooling/docs/design.md) - **Tooling Extensions (Claude)**: [packages/agents-a365-tooling-extensions-claude/docs/design.md](packages/agents-a365-tooling-extensions-claude/docs/design.md) diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 30391c94..f923e8e8 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -5,9 +5,6 @@ This diagram shows the internal dependencies between Microsoft Agent 365 SDK Nod ```mermaid graph LR agents_a365_notifications[agents-a365-notifications] - agents_a365_observability[agents-a365-observability] - agents_a365_observability_extensions_openai[agents-a365-observability-extensions-openai] - agents_a365_observability_tokencache[agents-a365-observability-tokencache] agents_a365_runtime[agents-a365-runtime] agents_a365_tooling[agents-a365-tooling] agents_a365_tooling_extensions_claude[agents-a365-tooling-extensions-claude] @@ -15,10 +12,6 @@ graph LR agents_a365_tooling_extensions_openai[agents-a365-tooling-extensions-openai] agents_a365_notifications --> agents_a365_runtime - agents_a365_observability --> agents_a365_runtime - agents_a365_observability_extensions_openai --> agents_a365_observability - agents_a365_observability_tokencache --> agents_a365_observability - agents_a365_observability_tokencache --> agents_a365_runtime agents_a365_tooling --> agents_a365_runtime agents_a365_tooling_extensions_claude --> agents_a365_runtime agents_a365_tooling_extensions_claude --> agents_a365_tooling @@ -28,9 +21,6 @@ graph LR agents_a365_tooling_extensions_openai --> agents_a365_tooling style agents_a365_notifications fill:#ffcdd2,stroke:#c62828,color:#280505 - style agents_a365_observability fill:#c8e6c9,stroke:#2e7d32,color:#142a14 - style agents_a365_observability_extensions_openai fill:#e8f5e9,stroke:#66bb6a,color:#1f3d1f - style agents_a365_observability_tokencache fill:#e8f5e9,stroke:#66bb6a,color:#1f3d1f style agents_a365_runtime fill:#bbdefb,stroke:#1565c0,color:#0d1a26 style agents_a365_tooling fill:#ffe0b2,stroke:#e65100,color:#331a00 style agents_a365_tooling_extensions_claude fill:#fff3e0,stroke:#fb8c00,color:#4d2600 @@ -40,8 +30,6 @@ graph LR ## Package Types - **Notifications** (Red): Notification and messaging extensions -- **Observability** (Green): Telemetry and monitoring core -- **Observability Extensions** (Light Green): Framework-specific observability integrations - **Runtime** (Blue): Core runtime components - **Tooling** (Orange): Agent tooling SDK core - **Tooling Extensions** (Light Orange): Framework-specific tooling integrations diff --git a/HOW_TO_BUILD.md b/HOW_TO_BUILD.md index a8a41f5e..97620d2d 100644 --- a/HOW_TO_BUILD.md +++ b/HOW_TO_BUILD.md @@ -1,221 +1,73 @@ # How to Build Microsoft Agent 365 Node.js SDK -This guide provides instructions for building and developing the Microsoft Agent 365 Node.js SDK packages. +Use Node.js >=18 and **pnpm 10.20.0** (declared by `packageManager`). TypeScript is a workspace dependency; no global installation is needed. -## Prerequisites +## Install and develop -- **Node.js**: Version 18.0.0 or higher -- **npm**: Latest version recommended -- **TypeScript**: Installed globally or via workspace dependencies +Run from the repository root: -## Project Structure - -The Microsoft Agent 365 Node.js SDK is organized as a monorepo with the following structure: - -``` -nodejs/ -├── packages/ # Main SDK packages -│ ├── agents-a365-runtime/ # @microsoft/agents-a365-runtime -│ ├── agents-a365-notifications/ # @microsoft/agents-a365-notifications -│ ├── agents-a365-observability/ # @microsoft/agents-a365-observability -│ ├── agents-a365-tooling/ # @microsoft/agents-a365-tooling -│ ├── agents-a365-tooling-extensions-claude/ # @microsoft/agents-a365-tooling-extensions-claude -│ ├── agents-a365-tooling-extensions-langchain/ # @microsoft/agents-a365-tooling-extensions-langchain -│ └── agents-a365-tooling-extensions-openai/ # @microsoft/agents-a365-tooling-extensions-openai -├── samples/ # Sample applications -├── tests/ # Test suites -└── *.tgz # Built package files (generated) -``` - -## Getting Started - -### 1. Install Dependencies - -Navigate to the `nodejs` directory and install all workspace dependencies: - -```bash -cd nodejs -npm install -``` - -This command will: -- Install dependencies for all workspace packages -- Generate or update `package-lock.json` files -- Set up workspace linking between packages - -**Alternative - Production Install:** -```bash -npm ci -``` -Use `npm ci` for faster, deterministic installs in CI/CD environments or when you want to install exactly what's in the lock file. - -### 2. Build All Packages - -To build all SDK packages: - -```bash -npm run build -``` - -To generate distributable `.tgz` files: - -```bash -cd packages -npm run pack -``` - -This workflow will: -- First command: Compile TypeScript source code for all packages and generate JavaScript output in `dist/` folders -- Second command: Create `.tgz` package files in the `nodejs/` directory -- Make packages ready for distribution and installation in samples - -**Generated Package Files:** -After building and packing, you'll find these `.tgz` files in the `nodejs/` directory: -- `microsoft-agents-a365-runtime-{version}.tgz` -- `microsoft-agents-a365-notifications-{version}.tgz` -- `microsoft-agents-a365-observability-{version}.tgz` -- `microsoft-agents-a365-tooling-{version}.tgz` -- `microsoft-agents-a365-tooling-extensions-claude-{version}.tgz` -- `microsoft-agents-a365-tooling-extensions-langchain-{version}.tgz` -- `microsoft-agents-a365-tooling-extensions-openai-{version}.tgz` - -### 3. Clean Build Artifacts - -To remove build artifacts and start fresh: - -```bash -npm run clean -``` - -This command will: -- Remove all `dist/` folders from workspace packages - -## Development Workflow - -### Building Individual Packages - -To build a specific package, navigate to its directory and run: - -```bash -cd packages/agents-a365-observability -npm run build +```shell +pnpm install --frozen-lockfile +pnpm build +pnpm test +pnpm lint +pnpm typecheck ``` -### Watch Mode Development - -For active development with automatic rebuilding: +`pnpm build` builds CJS and ESM output for six maintained packages: -```bash -cd packages/agents-a365-observability -npm run build:watch -``` +- `@microsoft/agents-a365-runtime` +- `@microsoft/agents-a365-notifications` +- `@microsoft/agents-a365-tooling` +- `@microsoft/agents-a365-tooling-extensions-claude` +- `@microsoft/agents-a365-tooling-extensions-langchain` +- `@microsoft/agents-a365-tooling-extensions-openai` -### Installing Local Packages in Samples +Observability implementations, dedicated tests, and package builds are retired. Application telemetry belongs to the [Microsoft OpenTelemetry distro](docs/observability-migration.md); it is not installed into the remaining SDK packages. -After building and packing packages with `npm run build` and `npm pack --workspaces`, you can install them in sample applications: +For targeted development: -```bash -cd samples/claude-code-sdk -npm install +```shell +pnpm --filter @microsoft/agents-a365-runtime build +pnpm build:watch +pnpm test:watch +pnpm test:coverage +pnpm lint:fix +pnpm clean ``` -The sample `package.json` files reference the local `.tgz` files from the `packages/` directory, so they'll use your locally built versions. +`pnpm clean` removes workspace build output, not dependencies. Tests live centrally in `tests`; see [running tests](tests/RUNNING_TESTS.md). Dependency versions are maintained in pnpm catalogs, and `pnpm-lock.yaml` is the lockfile. Do not generate npm lockfiles. -## Testing +## Pack verified artifacts -### Run All Tests +Use the **root script**, not the built-in `pnpm pack` command or wildcard workspace packing: -```bash -cd tests -npm install -npm test +```shell +pnpm run pack ``` -### Run Tests in Watch Mode - -```bash -cd tests -npm run test:watch -``` - -## Linting and Code Quality - -### Lint All Packages - -```bash -npm run lint -``` +This creates a fresh `artifacts/packages-` directory, cleans and rebuilds maintained packages, copies license/changelog files, packs all six explicit workspaces, inspects their archives, and writes a SHA-256 `packages.json` receipt. It rejects retired directories/dependencies/code, duplicate or missing packages, and missing entry points. -### Fix Linting Issues +For a named output and independent inspection: -```bash -npm run lint:fix +```shell +pnpm run pack artifacts/release-candidate +node packPackages.js --verify artifacts/release-candidate ``` -## Common Build Scripts - -The following npm scripts are available at the workspace root (`nodejs/`): - -| Script | Description | -|--------|-------------| -| `npm run build` | Build all workspace packages | -| `npm run build:watch` | Build all packages in watch mode | -| `npm run clean` | Remove all build artifacts from packages | -| `npm run test` | Run tests for all packages | -| `npm run test:watch` | Run tests in watch mode | -| `npm run lint` | Run ESLint on all packages | -| `npm run lint:fix` | Fix ESLint issues in all packages | -| `npm run ci` | Run clean install on all packages | - -## Troubleshooting - -### Build Failures - -If you encounter build errors: - -1. **Clean and reinstall:** - ```bash - npm install - npm run clean - npm run build - ``` - -2. **Check Node.js version:** - ```bash - node --version # Should be 18.0.0 or higher - ``` - -3. **Clear npm cache:** - ```bash - npm cache clean --force - ``` - -### Package Resolution Issues - -If samples can't find local packages: - -1. Ensure packages are built: `npm run build` -2. Generate package files: `cd packages && npm pack --workspaces` -3. Check that `.tgz` files exist in `packages/` directory -4. Verify sample `package.json` references correct package versions - -### TypeScript Compilation Errors +On Windows, use `artifacts\release-candidate`. The destination must not already exist. Reviewed stale generated output under retired package directories must be removed first. Never collect old `packages/*.tgz` or reuse cached tarballs as a release. -- Ensure all workspace dependencies are installed -- Check that TypeScript configuration is consistent across packages -- Verify that cross-package imports use correct module names +Samples are maintained in [Agent365-Samples](https://github.com/microsoft/Agent365-Samples), not this workspace. Install the exact verified tarball(s) needed by your sample; published workspace dependencies may also need corresponding local overrides in that application. -## Publishing (For Maintainers) +## Troubleshooting and releasing -When ready to publish packages: +For missing dependencies, restore with `pnpm install --frozen-lockfile`; for stale compilation output, run `pnpm clean` then `pnpm build`. Check Node and pnpm versions before changing configuration. Do not clear shared caches as a routine repair. -1. Update version numbers in all `package.json` files -2. Run full build and test suite: `npm run build && npm test` -3. Generate packages: `cd packages && npm pack --workspaces` -4. Publish individual packages to npm registry +See [release instructions](HOW_TO_RELEASE.md) and the [owner-operated retirement checklist](docs/observability-migration.md#owner-operated-npm-retirement-checklist). Only authorized owners publish receipt-listed artifacts. Repository checks cannot stop external pipelines or old branches from releasing retired packages. -## Additional Resources +## Resources -- [Microsoft Agent 365 Documentation](../../README.md) -- [Contributing Guidelines](../../CONTRIBUTING.md) -- [Security Policy](../../SECURITY.md) +- [README](README.md) +- [Architecture](docs/design.md) +- [Contributing](CONTRIBUTING.md) +- [Security](SECURITY.md) diff --git a/HOW_TO_RELEASE.md b/HOW_TO_RELEASE.md index 66fb2446..3957ad4e 100644 --- a/HOW_TO_RELEASE.md +++ b/HOW_TO_RELEASE.md @@ -208,16 +208,15 @@ All packages in this release: - @microsoft/agents-a365-notifications@1.1.0 - @microsoft/agents-a365-runtime@1.1.0 - @microsoft/agents-a365-tooling@1.1.0 -- @microsoft/agents-a365-observability@1.1.0 - @microsoft/agents-a365-tooling-extensions-claude@1.1.0 - @microsoft/agents-a365-tooling-extensions-langchain@1.1.0 - @microsoft/agents-a365-tooling-extensions-openai@1.1.0 ## 📚 Documentation -- [Getting Started](../README.md) -- [Changelog](../CHANGELOG.md) -- [Migration Guide](../VERSIONING_MIGRATION.md) +- [Getting Started](https://github.com/microsoft/Agent365-nodejs/blob/main/README.md) +- [Changelog](https://github.com/microsoft/Agent365-nodejs/blob/main/CHANGELOG.md) +- [Observability migration](https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md) ## Installation @@ -230,29 +229,28 @@ npm install @microsoft/agents-a365-runtime@1.1.0 ### Step 8: Publish to NPM (Optional) -If publishing to NPM registry: +Observability is retired. First complete the [migration and owner-operated npm retirement checklist](docs/observability-migration.md), including external publisher/old-branch restrictions. Never publish every discovered directory or `*.tgz` file. -```bash -# Ensure you're logged in -npm login - -# Publish all packages (from release branch/tag) -npm publish --workspaces --access public +From the approved release commit, create a **new** output directory through the guarded root script: -# Or publish individually -cd packages/agents-a365-runtime -npm publish --access public +```shell +pnpm install --frozen-lockfile +pnpm build +pnpm test +pnpm lint +pnpm typecheck +pnpm run pack artifacts/release-candidate +node packPackages.js --verify artifacts/release-candidate ``` -**Note**: Update the CI workflow to enable automatic NPM publishing: -```yaml -- name: Publish to NPM - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - run: npm publish --workspaces --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} +Use backslashes for Windows paths. Verify that `packages.json` lists exactly the six maintained packages and compare its SHA-256 hashes immediately before publishing. An authorized maintainer may publish **each exact verified tarball**, for example: + +```shell +pnpm publish artifacts/release-candidate/microsoft-agents-a365-runtime-.tgz --access public ``` +Repeat only for receipt-listed maintained packages; do not use wildcard publishing or cached drops. CI uploads only its fresh inspected directory and does not publish. External publishers must independently enforce the six-package allowlist and approved commit/receipt; these changes cannot protect old branches, revoke registry credentials, or invalidate already cached artifacts. + --- ## Post-Release Tasks @@ -524,5 +522,3 @@ git push origin main **Document Version**: 1.0 **Last Updated**: November 2, 2025 **Maintained By**: DevOps Team - - diff --git a/README.md b/README.md index 9335a82d..0629e643 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Microsoft Agent 365 SDK - Node.js/TypeScript -[![npm](https://img.shields.io/npm/v/@microsoft/agents-a365-observability?label=npm&logo=npm)](https://www.npmjs.com/search?q=%40microsoft%2Fagents-a365) -[![npm Downloads](https://img.shields.io/npm/dm/@microsoft/agents-a365-observability?label=Downloads&logo=npm)](https://www.npmjs.com/search?q=%40microsoft%2Fagents-a365) +[![npm](https://img.shields.io/npm/v/@microsoft/agents-a365-runtime?label=npm&logo=npm)](https://www.npmjs.com/search?q=%40microsoft%2Fagents-a365) +[![npm Downloads](https://img.shields.io/npm/dm/@microsoft/agents-a365-runtime?label=Downloads&logo=npm)](https://www.npmjs.com/search?q=%40microsoft%2Fagents-a365) [![Build Status](https://img.shields.io/github/actions/workflow/status/microsoft/Agent365-nodejs/.github/workflows/ci.yml?branch=main&label=Build&logo=github)](https://github.com/microsoft/Agent365-nodejs/actions) [![License](https://img.shields.io/github/license/microsoft/Agent365-nodejs?label=License)](LICENSE.md) [![Node.js Version](https://img.shields.io/badge/Node.js-18%2B-339933?logo=node.js)](https://nodejs.org/) @@ -10,11 +10,12 @@ > #### Note: > Use the information in this README to contribute to this open-source project. To learn about using this SDK in your projects, refer to the [Microsoft Agent 365 developer documentation](https://learn.microsoft.com/microsoft-agent-365/developer/). -The Microsoft Agent 365 SDK extends the Microsoft 365 Agents SDK with enterprise-grade capabilities for building sophisticated agents. This SDK provides comprehensive tooling for observability, notifications, runtime utilities, and development tools that help developers create production-ready agents for platforms including M365, Teams, Copilot Studio, and Webchat. +> **Observability has moved.** The four Agent365 Observability packages are retired from this repository. Use [`@microsoft/opentelemetry`](https://www.npmjs.com/package/@microsoft/opentelemetry) from the [Microsoft OpenTelemetry distro](https://github.com/microsoft/opentelemetry-distro-javascript). See the **[migration guide and package-owner retirement checklist](docs/observability-migration.md)** before upgrading. -The Microsoft Agent 365 SDK focuses on four core areas: +The Microsoft Agent 365 SDK extends the Microsoft 365 Agents SDK with enterprise-grade notifications, runtime utilities, and development tools for platforms including M365, Teams, Copilot Studio, and Webchat. + +The Microsoft Agent 365 SDK focuses on three core areas: -- **Observability**: Comprehensive tracing, caching, and monitoring capabilities for agent applications - **Notifications**: Agent notification services and models for handling user notifications - **Runtime**: Core utilities and extensions for agent runtime operations - **Tooling**: Developer tools and utilities for building sophisticated agent applications @@ -72,15 +73,13 @@ For more detailed build instructions, see the [HOW_TO_BUILD.md](HOW_TO_BUILD.md) ## Project Structure - **packages/agents-a365-notifications**: Microsoft Agent 365 Notifications SDK - Agent notification services and models -- **packages/agents-a365-observability**: Microsoft Agent 365 Observability Core - Core observability functionality -- **packages/agents-a365-observability-extensions-openai**: OpenAI observability extensions - **packages/agents-a365-runtime**: Microsoft Agent 365 Runtime - Core runtime utilities and extensions - **packages/agents-a365-tooling**: Microsoft Agent 365 Tooling SDK - Agent tooling and MCP integration - **packages/agents-a365-tooling-extensions-claude**: Claude/Anthropic tooling extensions - **packages/agents-a365-tooling-extensions-langchain**: LangChain tooling extensions - **packages/agents-a365-tooling-extensions-openai**: OpenAI tooling extensions - For sample applications, see the [Microsoft Agent 365 SDK Samples repository](https://github.com/microsoft/Agent365-Samples) -- **tests/**: Unit and integration tests +- **tests/**: SDK unit tests and release artifact safeguards ## Support @@ -111,7 +110,7 @@ The core SDK for building conversational AI agents for Microsoft 365 platforms. ### Microsoft Agent 365 SDK -Enterprise-grade extensions for observability, notifications, runtime utilities, and developer tools. +Enterprise-grade extensions for notifications, runtime utilities, and developer tools. Application telemetry is provided separately by the Microsoft OpenTelemetry distro. - [Microsoft Agent 365 SDK - C# /.NET repository](https://github.com/microsoft/Agent365-dotnet) - [Microsoft Agent 365 SDK - Python repository](https://github.com/microsoft/Agent365-python) diff --git a/VERSIONING_QUICKREF.md b/VERSIONING_QUICKREF.md index 7defa9ee..b8e63df0 100644 --- a/VERSIONING_QUICKREF.md +++ b/VERSIONING_QUICKREF.md @@ -124,7 +124,7 @@ git push origin release/v1.0 --tags ↓ ┌─────────────────────────────────────────────────┐ │ 5. Test & Package │ -│ npm test && npm pack │ +│ pnpm test && pnpm run pack │ └─────────────────────────────────────────────────┘ ``` diff --git a/VERSION_MANAGEMENT.md b/VERSION_MANAGEMENT.md index a050b7ac..b1c30db5 100644 --- a/VERSION_MANAGEMENT.md +++ b/VERSION_MANAGEMENT.md @@ -84,7 +84,7 @@ npm run build npm run version # Create tarballs -npm pack --workspaces +pnpm run pack # Now you have versioned packages to test! # But remember to reset before committing: @@ -105,7 +105,7 @@ node resetVersion.js run: npm test - name: Package - run: npm pack --workspaces + run: pnpm run pack ``` ## Understanding Version Sources @@ -242,12 +242,10 @@ npm run build # Set version (will modify files temporarily) node setVersion.js -# Create package -cd packages/agents-a365-runtime -npm pack +# Create all maintained packages in a fresh inspected output directory +pnpm run pack # Reset versions before committing -cd ../.. node resetVersion.js ``` @@ -262,7 +260,7 @@ node resetVersion.js fetch-depth: 0 # Required for git versioning - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Build run: npm run build @@ -274,23 +272,25 @@ node resetVersion.js run: npm test - name: Package - run: npm pack --workspaces + run: pnpm run pack ``` ### Azure DevOps ```yaml - script: | - npm ci + pnpm install --frozen-lockfile npm run build node setVersion.js # No flags for CI npm test - npm pack --workspaces + pnpm run pack displayName: 'Build and Package' ``` ## Summary +Release only the six maintained packages listed in the guarded pack output's `packages.json` receipt. Never discover/publish cached tarballs by wildcard. Observability packages are retired; see the [migration and owner checklist](docs/observability-migration.md). External/old-branch pipelines need independent allowlist enforcement. + | Aspect | Local Development | CI/CD Build | |--------|-------------------|-------------| | **Command** | `--dry-run` / `--local` | No flags | diff --git a/docs/design.md b/docs/design.md index d036fd7c..64e1c171 100644 --- a/docs/design.md +++ b/docs/design.md @@ -6,29 +6,26 @@ This document describes the architecture and design of the Microsoft Agent 365 S The Microsoft Agent 365 SDK extends the [Microsoft 365 Agents SDK](https://github.com/Microsoft/Agents-for-js) with enterprise-grade capabilities for building sophisticated AI agents. It provides comprehensive tooling for: -- **Observability**: OpenTelemetry-based tracing, monitoring, and context propagation - **Notifications**: Agent notification services and activity routing for Microsoft 365 workloads - **Runtime**: Core utilities for agent operations and Power Platform integration - **Tooling**: MCP (Model Context Protocol) server configuration and tool discovery The SDK supports production deployment across Microsoft 365, Teams, Copilot Studio, and Webchat platforms. +Observability is no longer implemented or released here. Applications use the Microsoft OpenTelemetry distro; see the [migration guide](observability-migration.md) for APIs, authentication, and package-owner retirement actions. + ## Repository Structure ``` Agent365-nodejs/ -├── packages/ # Core packages (9 total) +├── packages/ # Six maintained packages │ ├── agents-a365-runtime/ │ ├── agents-a365-tooling/ -│ ├── agents-a365-observability/ │ ├── agents-a365-notifications/ -│ ├── agents-a365-observability-hosting/ -│ ├── agents-a365-observability-extensions-openai/ │ ├── agents-a365-tooling-extensions-claude/ │ ├── agents-a365-tooling-extensions-langchain/ │ └── agents-a365-tooling-extensions-openai/ ├── tests/ # Test suite -├── tests-agent/ # Sample agent applications ├── docs/ # Documentation └── package.json # Workspace configuration ``` @@ -74,7 +71,6 @@ Core utilities shared across the SDK. | `clusterCategory` | Environment classification (prod, dev, local) | | `isDevelopmentEnvironment` | Check if running in development mode | | `mcpPlatformAuthenticationScope` | MCP platform authentication scope | -| `observabilityAuthenticationScopes` | Auth scopes for observability service | **Usage Example:** @@ -104,84 +100,7 @@ const endpoint = discovery.getTenantIslandClusterEndpoint(tenantId); const userAgent = Utility.GetUserAgentHeader('MyOrchestrator'); ``` -### 2. Observability (`@microsoft/agents-a365-observability`) - -> **Detailed documentation**: [packages/agents-a365-observability/docs/design.md](../packages/agents-a365-observability/docs/design.md) - -The foundation for distributed tracing in agent applications. Built on OpenTelemetry. - -**Key Classes:** - -| Class | Purpose | -|-------|---------| -| `ObservabilityManager` | Main entry point, singleton pattern for telemetry configuration | -| `ObservabilityBuilder` | Fluent API for configuring telemetry | -| `InvokeAgentScope` | Trace agent invocation lifecycle (entry point for agent requests) | -| `InferenceScope` | Trace LLM/AI model inference calls | -| `ExecuteToolScope` | Trace tool execution operations | -| `BaggageBuilder` | Fluent API for context propagation across async boundaries | - -**Data Classes:** - -| Interface | Purpose | -|-----------|---------| -| `Request` | Request payload (channel, conversationId, content, sessionId) | -| `AgentDetails` | Agent identification and metadata (includes tenantId) | -| `InvokeAgentScopeDetails` | Details for invoking agent scope | -| `InferenceDetails` | Model name, tokens, provider information | -| `ToolCallDetails` | Tool name, arguments, endpoint | -| `CallerDetails` | Wrapper for caller identity: `userDetails` (human) and/or `callerAgentDetails` (A2A) | -| `UserDetails` | Human caller identification (userId, userEmail, userName, callerClientIp) | -| `SpanDetails` | Optional span configuration (parentContext, startTime, endTime, spanKind) | - -**Usage Example:** - -```typescript -import { - ObservabilityManager, - InvokeAgentScope, - BaggageBuilder, - ExecutionType, -} from '@microsoft/agents-a365-observability'; - -// Initialize telemetry -ObservabilityManager.start({ - serviceName: 'my-agent', - tokenResolver: (agentId, tenantId) => getAuthToken(), - clusterCategory: 'prod' -}); - -// Set context for child spans -const scope = new BaggageBuilder() - .tenantId(tenantId) - .agentId(agentId) - .correlationId(correlationId) - .build(); - -scope.run(() => { - // Trace agent invocation - using agentScope = InvokeAgentScope.start( - { conversationId, channel: { name: 'Teams' } }, // Request - { endpoint: { host: 'api.example.com' } }, // InvokeAgentScopeDetails - { agentId, agentName, tenantId }, // AgentDetails - { userDetails: { userId, userName } } // CallerDetails (optional) - ); - - // Agent logic here - agentScope.recordResponse('result'); -}); -``` - -### 3. Observability Extensions - -Framework-specific instrumentations that integrate with the observability core: - -| Package | Purpose | Design Doc | -|---------|---------|------------| -| `observability-extensions-openai` | Instrument OpenAI Agents SDK | [design.md](../packages/agents-a365-observability-extensions-openai/docs/design.md) | -| `observability-hosting` | Hosting-specific observability utilities | [design.md](../packages/agents-a365-observability-hosting/docs/design.md) | - -### 4. Tooling (`@microsoft/agents-a365-tooling`) +### 2. Tooling (`@microsoft/agents-a365-tooling`) > **Detailed documentation**: [packages/agents-a365-tooling/docs/design.md](../packages/agents-a365-tooling/docs/design.md) @@ -238,7 +157,7 @@ for (const server of servers) { } ``` -### 5. Tooling Extensions +### 3. Tooling Extensions Framework-specific adapters for MCP tool integration: @@ -248,7 +167,7 @@ Framework-specific adapters for MCP tool integration: | `tooling-extensions-langchain` | LangChain integration | [design.md](../packages/agents-a365-tooling-extensions-langchain/docs/design.md) | | `tooling-extensions-openai` | OpenAI Agents SDK integration | [design.md](../packages/agents-a365-tooling-extensions-openai/docs/design.md) | -### 6. Notifications (`@microsoft/agents-a365-notifications`) +### 4. Notifications (`@microsoft/agents-a365-notifications`) > **Detailed documentation**: [packages/agents-a365-notifications/docs/design.md](../packages/agents-a365-notifications/docs/design.md) @@ -290,52 +209,7 @@ app.onLifecycleNotification(async (turnContext, turnState, notification) => { ## Design Patterns -### 1. Singleton Pattern - -`ObservabilityManager` uses singleton pattern to ensure a single tracer provider per application: - -```typescript -export class ObservabilityManager { - private static instance?: ObservabilityBuilder; - - public static start(options?: BuilderOptions): ObservabilityBuilder { - const builder = new ObservabilityBuilder(); - // Configure builder... - builder.start(); - ObservabilityManager.instance = builder; - return builder; - } - - public static getInstance(): ObservabilityBuilder | null { - return ObservabilityManager.instance || null; - } -} -``` - -### 2. Disposable Pattern - -All scope classes implement the `Disposable` interface for automatic span lifecycle management: - -```typescript -using scope = InvokeAgentScope.start(request, scopeDetails, agentDetails); -// Span is active -scope.recordResponse('result'); -// Span automatically ends when scope is disposed -``` - -### 3. Builder Pattern - -`BaggageBuilder` and `ObservabilityBuilder` provide fluent APIs for configuration: - -```typescript -const scope = new BaggageBuilder() - .tenantId(tenantId) - .agentId(agentId) - .correlationId(correlationId) - .build(); -``` - -### 4. Strategy Pattern +### 1. Strategy Pattern `McpToolServerConfigurationService` selects between manifest-based (dev) and gateway-based (prod) configuration loading based on environment: @@ -347,7 +221,7 @@ async listToolServers(agenticAppId: string, authToken: string): Promise=22.0.0**, root `import` and `require` exports with declarations, and the public `@microsoft/opentelemetry/loader` subpath. The repository's `main` currently declares stricter engines (`^22.23.4 || ^24.22.1 || >=26.12.1`); some Learn prerequisites still describe older runtimes. Choose a supported Node release satisfying the **selected published version's** engines and deployment policy, and recheck before upgrading: + +```shell +npm view @microsoft/opentelemetry version engines exports --json +``` + +The remaining Agent365 SDK packages retain their own Node >=18 requirement; that is not a compatibility guarantee for the distro. + +## What was removed + +| Retired npm package | Removed source | Dedicated tests removed | +|---|---|---| +| `@microsoft/agents-a365-observability` | `packages/agents-a365-observability` | Core, exporter, scope, configuration, tracing, and integration tests under `tests/observability` | +| `@microsoft/agents-a365-observability-hosting` | `packages/agents-a365-observability-hosting` | Hosting/middleware tests under `tests/observability` | +| `@microsoft/agents-a365-observability-extensions-openai` | `packages/agents-a365-observability-extensions-openai` | OpenAI tests under `tests/observability` and `tests/observability-extensions-openai` | +| `@microsoft/agents-a365-observability-extensions-langchain` | `packages/agents-a365-observability-extensions-langchain` | LangChain tests under `tests/observability` | + +Their package-root public exports, dual CJS/ESM builds, release/versioning participation, and dedicated Azure OpenAI integration workflow are removed. A historical `observability-tokencache` diagram/mapping was stale, not a fifth current package. The Runtime-only `getObservabilityAuthenticationScope()` and `PROD_OBSERVABILITY_SCOPE` exports were also removed: resolve telemetry authentication with the distro instead of continuing to use a hardcoded legacy scope. + +Notifications, Runtime, Tooling, and Tooling framework adapters remain maintained. **Do not uninstall Runtime if your application still uses its authentication, identity, configuration, or discovery APIs.** No maintained implementation depended on the removed packages. The deprecated Runtime constant `PROD_OBSERVABILITY_CLUSTER_CATEGORY` remains a general cluster-default compatibility alias, not a telemetry implementation. + +## Update dependencies and imports + +Remove only retired packages your application actually installed; the following pnpm commands show all four: + +```shell +pnpm remove @microsoft/agents-a365-observability @microsoft/agents-a365-observability-hosting @microsoft/agents-a365-observability-extensions-openai @microsoft/agents-a365-observability-extensions-langchain +pnpm add @microsoft/opentelemetry +``` + +Use your application's existing package manager, update its lockfile, and remove direct OpenTelemetry dependencies only if no remaining application code needs them. Search source, tests, preload commands, bundler configuration, and deployment settings for old imports, including deep imports. + +| Old surface | Application migration | +|---|---| +| `ObservabilityManager`, `ObservabilityBuilder` / `Builder` | Replace startup/configuration with `useMicrosoftOpenTelemetry({ a365: ... })`; no compatible manager/builder root export | +| `ObservabilityConfiguration`, options, default providers | Review `A365Configuration`, `A365Options`, and `MicrosoftOpenTelemetryOptions`; old provider overrides are not mechanically portable | +| `Request`, `SpanDetails` | Public distro names are `A365Request`, `A365SpanDetails` | +| `InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope`, contracts | Available from the distro root, but review current argument/response types and signatures | +| `BaggageBuilder`, trace/context and export-token helpers | Available distro APIs; review propagation, identity, and token semantics | +| Hosting manager, middleware, token cache | Prefer distro `configureA365Hosting`; explicitly configure authentication and content capture | +| OpenAI instrumentor/trace processor; `LangChainTraceInstrumentor`, `LangChainTracer` | Replace explicit registration with distro `instrumentationOptions`; remove old callbacks/instrumentors | +| `PerRequestSpanProcessor`, its configuration/providers and buffer limits | No matching public replacement contract verified; redesign and test delivery/latency requirements rather than copying options | + +Names such as `Agent365Exporter`, `ObservabilityHostingManager`, and `A365_OBSERVABILITY_*` can legitimately remain when imported/configured through the **replacement**. A name match alone is not a retired dependency. Do not copy deleted implementations into the remaining packages. + +## Initialize before application modules load + +Initialize once per process before loading instrumented frameworks. Both patterns below use **JavaScript runtime files**, not uncompiled TypeScript. `acquireTelemetryToken` is an application-owned authentication function; it must return a valid token or reject explicitly and must not load instrumented frameworks before startup. + +### CommonJS + +Create `telemetry.cjs`: + +```javascript +const { useMicrosoftOpenTelemetry } = require('@microsoft/opentelemetry'); +const { acquireTelemetryToken } = require('./telemetry-auth.cjs'); + +useMicrosoftOpenTelemetry({ + a365: { + enabled: true, + enableObservabilityExporter: true, + tokenResolver: (agentId, tenantId, authScopes) => + acquireTelemetryToken(agentId, tenantId, authScopes), + }, + instrumentationOptions: { + openaiAgents: { enabled: true }, + langchain: { enabled: true }, + }, +}); +``` + +Preload it before the entry point: + +```shell +node --require ./telemetry.cjs ./app.cjs +``` + +### ESM + +Create `telemetry.mjs` using the distro's supported loader registration: + +```javascript +import '@microsoft/opentelemetry/loader'; +import { useMicrosoftOpenTelemetry } from '@microsoft/opentelemetry'; +import { acquireTelemetryToken } from './telemetry-auth.mjs'; + +useMicrosoftOpenTelemetry({ + a365: { + enabled: true, + enableObservabilityExporter: true, + tokenResolver: (agentId, tenantId, authScopes) => + acquireTelemetryToken(agentId, tenantId, authScopes), + }, + instrumentationOptions: { + openaiAgents: { enabled: true }, + langchain: { enabled: true }, + }, +}); +``` + +```shell +node --import ./telemetry.mjs ./app.mjs +``` + +**Calling initialization textually above static imports in `app.mjs` is insufficient**: ESM dependencies load/evaluate before the importing module's body. Register the loader and initialize in the preload before loading the application. The loader-only command `node --import @microsoft/opentelemetry/loader ...` registers hooks; it does not substitute for your configured initialization. Keep preload dependencies lightweight, including their transitive imports. Validate framework instrumentation in the actual deployment/bundler/serverless environment; module rewriting or bundling can change hook behavior. + +Set service identity with the distro's OpenTelemetry resource options or `OTEL_RESOURCE_ATTRIBUTES` (`service.name`, `service.version`), not old builder methods. Any direct OTel resource imports need compatible application-owned dependencies. + +## Authentication and Agent365 delivery + +`a365.enabled: true` enables Agent365 enrichment; **also set `enableObservabilityExporter: true`** for Agent365 HTTP export. Merely configuring generic OTLP or Azure Monitor does not preserve Agent365 routing, token acquisition, payload filtering, identity, or delivery semantics. + +Supply a token resolver for the chosen identity/endpoint. The simple resolver receives `(agentId, tenantId, authScopes)` and returns a string or `Promise`. The contextual resolver accepts `TokenResolverContext`, supports `identity.agenticUserId`, and takes precedence when supplied. A cache must be populated/refreshed through your authenticated flow; do not return empty strings as successful credentials. + +The retired exporter used `/observabilityService` with an **app-only resolver**, including per-request export. Request-context tokens were not fallback credentials. The distro's `useS2SEndpoint` defaults to `false`: explicitly choose and validate the endpoint and identity flow rather than assuming old behavior. Check current scopes, audience, tenant consent, and `Agent365.Observability.OtelWrite` permissions in the official migration/authentication documentation. Do not reuse the removed Runtime hardcoded scope or confuse MCP Tooling tokens with telemetry tokens. + +The distro documents a changed default scope (`api://9b975845-388f-4429-889e-eab1ef63949c/.default`) and distinct OBO/S2S requirements. Treat these as identity-flow-specific configuration, not interchangeable strings. Verify acquisition, expiry/refresh, denied consent, and 401/403 handling against your tenant before rollout. + +Some environment names remain supported upstream: `ENABLE_A365_OBSERVABILITY_EXPORTER`, `A365_OBSERVABILITY_SCOPES_OVERRIDE`, `A365_OBSERVABILITY_DOMAIN_OVERRIDE`, `A365_OBSERVABILITY_LOG_LEVEL`, and `CLUSTER_CATEGORY`. They do not make every old setting compatible. Revalidate precedence against the selected distro version. Old `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` and `A365_PER_REQUEST_MAX_*` settings have no verified equivalent; remove or redesign them explicitly. + +## Context, manual spans, hosting, and frameworks + +Use distro `BaggageBuilder` to scope tenant/agent/conversation/session metadata around asynchronous work: + +```javascript +import { BaggageBuilder } from '@microsoft/opentelemetry'; + +await new BaggageBuilder() + .tenantId(tenantId) + .agentId(agentId) + .conversationId(conversationId) + .sessionId(sessionId) + .build() + .run(async () => { + await handleRequest(); + }); +``` + +This is an application-body ESM fragment after preloading; CommonJS obtains the same API with `require()`. Use `injectContextToHeaders`, `extractContextFromHeaders`, or `runWithExtractedTraceContext` for process boundaries, following upstream signatures. Validate/sanitize untrusted incoming baggage and identity; never place tokens, secrets, or unnecessary personal content in baggage. + +Manual scopes remain available, but typed tool argument payloads, response recording, and scope details have changed. Follow the [current Agent365 scope examples](https://github.com/microsoft/opentelemetry-distro-javascript/blob/main/A365_DOCUMENTATION.md), await asynchronous `run()` work, and dispose scopes in `finally` so spans end after completion/errors. Recheck parent/child relationships, token usage, tool arguments, exceptions, and output events rather than replacing import strings alone. + +For hosting, use `configureA365Hosting(adapter, { enableBaggage: true, enableOutputLogging: false })` after initialization and before requests. Enable output logging only after a privacy review. It does not configure token acquisition for you. + +OpenAI Agents and LangChain instrumentation are bundled and enabled by default for supported framework versions. `instrumentationOptions.openaiAgents` and `.langchain` control them. Remove old `.enable()` calls and registered legacy tracers/processors to avoid duplicate spans. In A365-only mode, non-GenAI instrumentation (for example HTTP/databases) is disabled by default; enable required categories explicitly. Consult upstream for supported framework/version combinations. Content capture requires deliberate sensitive-data settings (including framework-specific options); do not assume old capture policy carries forward. + +## Shutdown, flushing, and unsupported assumptions + +During an application-owned graceful shutdown, first stop accepting requests and await in-flight work, then: + +```javascript +// ESM; CommonJS: const { shutdownMicrosoftOpenTelemetry } = require('...'); +import { shutdownMicrosoftOpenTelemetry } from '@microsoft/opentelemetry'; + +await shutdownMicrosoftOpenTelemetry(); +``` + +Await this in your asynchronous signal/lifecycle handler; a synchronous `exit` handler or immediate `process.exit()` cannot flush reliably. Handle rejected shutdown explicitly through application logging/error policy. No generic root-level `forceFlush()` API was verified. Applications explicitly owning an `Agent365Exporter` can use its documented `forceFlush()` contract; do not reach into private distro internals. + +The current A365 HTTP exporter requests durable delivery by default. Review its platform requirements: on Unix, provision an existing absolute private durable-storage root; persisted telemetry is plaintext and delivery is at least once. `durableDelivery: { enabled: false }` chooses network-only delivery, with different loss guarantees. Shutdown is bounded and does not drain an existing spool; exporter force-flush replay is also bounded. See upstream operational guidance for permissions, storage, replay, timeouts, and recovery. + +Unsupported migration assumptions include old per-request buffering/flush semantics, custom provider factories and builder hooks, identical trace schemas, automatic token fallback, identical log/content policies, and identical instrumentation coverage. Build application regression tests for these requirements. Do not retain both SDKs to mask gaps or assume OTLP success proves Agent365 ingestion. Validate actual backend receipt, identity separation, retries, shutdown, and failures. + +## Repository release safeguards + +Root and pnpm workspace lists explicitly contain six maintained packages; version tooling uses that list. CI uses the root guarded packaging path: + +```shell +pnpm run pack artifacts/release-candidate +node packPackages.js --verify artifacts/release-candidate +``` + +Use an output directory that does **not** already exist (on Windows, use backslashes). The guard rejects physical retired package directories, cleans/rebuilds maintained packages, packs only explicit allowed manifests, and independently inspects all six archives. It checks package identity, dependency sections, entry points, retired paths/imports/known implementation signatures, and duplicate/missing packages, then emits a SHA-256 `packages.json` receipt. Historical changelog entries are preserved and are not executable implementation. + +Reviewed stale `dist` trees from deleted packages must be removed before packing. Do not copy cached `packages/*.tgz`, reuse old output directories, publish by wildcard discovery, or treat a successful individual `pnpm pack` as removal verification. Package owners must publish only verified receipt-listed artifacts from the approved commit. These repository safeguards do **not** revoke already uploaded artifacts, old-branch pipelines, registry permissions, or external release jobs. + +## Owner-operated npm retirement checklist + +No registry operation is performed by this change. An authorized package owner/maintainer must: + +1. Publish this guide at the canonical link below and announce the breaking removal, including Runtime scope-helper removal. Confirm migration destinations, ownership, and support routing. +2. Audit npm owners/tokens/trusted publishers and every external pipeline, release branch/tag trigger, scheduled job, artifact feed, and manual release runbook. Disable retired-package publishing or backport the allowlist/artifact checks to every branch still allowed to release. Enforce the package-name allowlist independently in the external publisher. +3. Quarantine/delete specifically identified retired cached tarballs and artifacts in external drops/caches. Rebuild from the approved commit into fresh directories; compare receipt hashes immediately before publishing. Do not replay an old successful build. +4. Review all versions/dist-tags of the four exact packages, including prereleases, and confirm authorization. Run the following **only after the migration link is live**, using your normal authenticated npm environment. +5. Check registry deprecation metadata for representative stable/prerelease versions and confirm installation displays the warning. Monitor consumer migration and stop new retired-package releases. Deprecation **warns**; it neither replaces dependencies nor prevents installation. **Do not unpublish.** + +[npm's current deprecation syntax](https://docs.npmjs.com/cli/v11/commands/npm-deprecate) is `npm deprecate `. Quoted `@*` targets all versions, including prereleases. This command is workspace-unaware; run once for each exact package: + +```shell +npm deprecate "@microsoft/agents-a365-observability@*" "Retired: migrate your application to @microsoft/opentelemetry using useMicrosoftOpenTelemetry(). Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" +npm deprecate "@microsoft/agents-a365-observability-hosting@*" "Retired: migrate hosting telemetry to @microsoft/opentelemetry. Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" +npm deprecate "@microsoft/agents-a365-observability-extensions-openai@*" "Retired: use OpenAI Agents instrumentation in @microsoft/opentelemetry. Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" +npm deprecate "@microsoft/agents-a365-observability-extensions-langchain@*" "Retired: use LangChain instrumentation in @microsoft/opentelemetry. Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" +``` + +These are ready-to-paste owner actions, not commands for CI or this implementation session. Preserve historical changelogs and released package availability. diff --git a/docs/prd-configuration-provider.md b/docs/prd-configuration-provider.md index 9c3854d4..188ec55f 100644 --- a/docs/prd-configuration-provider.md +++ b/docs/prd-configuration-provider.md @@ -1,5 +1,7 @@ # PRD: Configuration Provider for Agent365 SDK +> **Historical design draft, not the current API contract.** Observability portions below describe retired packages and are retained only as design history. Current maintained architecture is in [design.md](design.md); applications should follow the [Observability migration guide](observability-migration.md). Do not implement the retired package/provider proposals. + ## Document Information | Field | Value | diff --git a/eslint.config.mjs b/eslint.config.mjs index f45f9876..89cd5251 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -38,7 +38,7 @@ export default defineConfig( { "object": "process", "property": "env", - "message": "Use configuration classes instead of direct process.env access. See RuntimeConfiguration, ToolingConfiguration, or ObservabilityConfiguration." + "message": "Use configuration classes instead of direct process.env access. See RuntimeConfiguration or ToolingConfiguration." } ] } @@ -66,4 +66,3 @@ export default defineConfig( } } ); - diff --git a/generate-package-dependencies.js b/generate-package-dependencies.js index faab369b..a8edd9b7 100644 --- a/generate-package-dependencies.js +++ b/generate-package-dependencies.js @@ -11,8 +11,6 @@ const __dirname = path.dirname(__filename); // Color definitions for package types const packageTypeColors = { 'Notifications': { fill: '#ffcdd2', stroke: '#c62828', color: '#280505' }, - 'Observability': { fill: '#c8e6c9', stroke: '#2e7d32', color: '#142a14' }, - 'Observability Extensions': { fill: '#e8f5e9', stroke: '#66bb6a', color: '#1f3d1f' }, 'Runtime': { fill: '#bbdefb', stroke: '#1565c0', color: '#0d1a26' }, 'Tooling': { fill: '#ffe0b2', stroke: '#e65100', color: '#331a00' }, 'Tooling Extensions': { fill: '#fff3e0', stroke: '#fb8c00', color: '#4d2600' } @@ -21,9 +19,6 @@ const packageTypeColors = { // Package to type mapping const packageToType = { 'agents-a365-notifications': 'Notifications', - 'agents-a365-observability': 'Observability', - 'agents-a365-observability-extensions-openai': 'Observability Extensions', - 'agents-a365-observability-tokencache': 'Observability Extensions', 'agents-a365-runtime': 'Runtime', 'agents-a365-tooling': 'Tooling', 'agents-a365-tooling-extensions-claude': 'Tooling Extensions', @@ -163,8 +158,6 @@ function main() { markdown += diagram; markdown += '## Package Types\n\n'; markdown += '- **Notifications** (Red): Notification and messaging extensions\n'; - markdown += '- **Observability** (Green): Telemetry and monitoring core\n'; - markdown += '- **Observability Extensions** (Light Green): Framework-specific observability integrations\n'; markdown += '- **Runtime** (Blue): Core runtime components\n'; markdown += '- **Tooling** (Orange): Agent tooling SDK core\n'; markdown += '- **Tooling Extensions** (Light Orange): Framework-specific tooling integrations\n\n'; diff --git a/jest.integration.config.cjs b/jest.integration.config.cjs deleted file mode 100644 index 7c95245f..00000000 --- a/jest.integration.config.cjs +++ /dev/null @@ -1,31 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - rootDir: '.', - testMatch: ['**/tests/observability/integration/**/*.test.ts'], - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], - collectCoverageFrom: [ - 'tests/observability/integration/**/*.ts', - '!tests/observability/integration/**/*.d.ts', - '!tests/observability/integration/**/*.test.ts', - ], - coverageDirectory: 'coverage/integration', - // Polyfill `crypto` global for Node.js 18 before any test modules are loaded. - // @langchain/core uses `crypto` directly; Node.js 18 only exposes it as - // `globalThis.crypto` in CommonJS context, not as a bare global. - setupFiles: ['/tests/observability/integration/jest.polyfills.js'], - setupFilesAfterEnv: ['/tests/observability/integration/setup.ts'], - testTimeout: 120000, - globals: { - 'ts-jest': { - tsconfig: { - module: 'commonjs', - esModuleInterop: true, - }, - }, - }, -}; diff --git a/packPackages.js b/packPackages.js new file mode 100644 index 00000000..b3f720d7 --- /dev/null +++ b/packPackages.js @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFileSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const rootManifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const workspaces = rootManifest.workspaces.filter(dir => dir.startsWith('packages/')); +const allowedNames = new Set([ + '@microsoft/agents-a365-notifications', + '@microsoft/agents-a365-runtime', + '@microsoft/agents-a365-tooling', + '@microsoft/agents-a365-tooling-extensions-claude', + '@microsoft/agents-a365-tooling-extensions-langchain', + '@microsoft/agents-a365-tooling-extensions-openai', +]); +const retiredDependency = /^@microsoft\/agents-a365-observability(?:$|-)/; +const retiredCode = /@microsoft\/agents-a365-observability|ObservabilityManager|ObservabilityBuilder|PerRequestSpanProcessor|LangChainTraceInstrumentor|OpenAIAgentsTraceInstrumentor|OpenAIAgentsTraceProcessor|Agent365Exporter|BaggageMiddleware|OutputLoggingMiddleware/; + +function assertManifest(manifest) { + if (!allowedNames.has(manifest.name)) { + throw new Error(`Unexpected package: ${manifest.name}`); + } + for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + for (const name of Object.keys(manifest[section] ?? {})) { + if (retiredDependency.test(name)) { + throw new Error(`Retired dependency: ${manifest.name} -> ${name} (${section})`); + } + } + } +} + +function verify(directory) { + const files = fs.readdirSync(directory).filter(file => file.endsWith('.tgz')).sort(); + const packages = []; + const seen = new Set(); + for (const file of files) { + const tarball = path.join(directory, file); + const entries = execFileSync('tar', ['-tzf', tarball], { encoding: 'utf8' }).trim().split(/\r?\n/); + if (!entries.includes('package/package.json')) { + throw new Error(`Missing package manifest: ${file}`); + } + const readEntry = entry => execFileSync('tar', ['-xOzf', tarball, entry], { + encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, + }); + const manifest = JSON.parse(readEntry('package/package.json')); + assertManifest(manifest); + if (seen.has(manifest.name)) { + throw new Error(`Duplicate package: ${manifest.name}`); + } + seen.add(manifest.name); + for (const entry of entries) { + if (/observability/i.test(entry)) { + throw new Error(`Retired implementation path: ${file}: ${entry}`); + } + if (/\.(?:[cm]?js|tsx?|json|map)$/.test(entry) && retiredCode.test(readEntry(entry))) { + throw new Error(`Retired implementation: ${file}: ${entry}`); + } + } + for (const entry of [manifest.main, manifest.module, manifest.types]) { + if (!entry || !entries.includes(`package/${entry.replace(/^\.\//, '')}`)) { + throw new Error(`Missing entry point in ${file}: ${entry}`); + } + } + packages.push({ + name: manifest.name, + version: manifest.version, + tarball: file, + sha256: createHash('sha256').update(fs.readFileSync(tarball)).digest('hex'), + }); + } + if (seen.size !== allowedNames.size) { + throw new Error(`Expected exactly ${allowedNames.size} maintained packages; found ${seen.size}`); + } + return packages; +} + +// Use pnpm's JS entry point rather than shell execution (also works on Windows). +function pnpm(args, cwd = root) { + if (!process.env.npm_execpath) { + throw new Error('Run packaging through pnpm run pack, not node directly.'); + } + execFileSync(process.execPath, [process.env.npm_execpath, ...args], { cwd, stdio: 'inherit' }); +} + +function pack(destination) { + if (workspaces.length !== allowedNames.size || workspaces.some(dir => dir.includes('*'))) { + throw new Error('Release workspaces must list exactly the six maintained package directories.'); + } + const names = new Set(); + for (const dir of workspaces) { + const manifest = JSON.parse(fs.readFileSync(path.join(root, dir, 'package.json'), 'utf8')); + assertManifest(manifest); + names.add(manifest.name); + } + if (names.size !== allowedNames.size) { + throw new Error('Release workspaces contain duplicate or missing packages.'); + } + for (const dir of fs.readdirSync(path.join(root, 'packages'))) { + if (dir.startsWith('agents-a365-observability')) { + throw new Error(`Retired package directory still exists: packages/${dir}. Remove reviewed stale artifacts first.`); + } + } + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.mkdirSync(destination); // Fail on reuse: cached tarballs must never enter a new release. + pnpm(['run', 'clean']); + pnpm(['run', 'build']); + for (const dir of workspaces) { + const cwd = path.join(root, dir); + execFileSync(process.execPath, [path.join(root, 'copyFiles.js'), cwd], { cwd, stdio: 'inherit' }); + pnpm(['pack', '--pack-destination', destination], cwd); + } + const packages = verify(destination); + fs.writeFileSync(path.join(destination, 'packages.json'), JSON.stringify(packages, null, 2) + '\n'); + console.log(`Verified ${packages.length} release packages in ${destination}`); +} + +try { + if (process.argv[2] === '--verify') { + const packages = verify(path.resolve(process.argv[3])); + console.log(JSON.stringify(packages, null, 2)); + } else { + pack(path.resolve(process.argv[2] ?? path.join(root, 'artifacts', `packages-${randomUUID()}`))); + } +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/package.json b/package.json index 7ed65987..094b2b8b 100644 --- a/package.json +++ b/package.json @@ -6,24 +6,27 @@ "type": "module", "scripts": { "build": "pnpm -r build", - "build:watch": "npm run build:watch --workspaces --if-present", - "clean": "npm run clean --workspaces --if-present && rimraf node_modules", - "test": "npm run test --workspaces --if-present -- --testPathIgnorePatterns=/integration/", - "test:coverage": "node --experimental-vm-modules ./node_modules/.bin/jest --config=tests/jest.config.cjs --coverage", - "test:watch": "npm run test:watch --workspaces --if-present", - "test:integration": "node --experimental-vm-modules ./node_modules/.bin/jest --config jest.integration.config.cjs", - "test:integration:watch": "node --experimental-vm-modules ./node_modules/.bin/jest --config jest.integration.config.cjs --watch", - "lint": "npm run lint --workspaces --if-present", - "lint:fix": "npm run lint:fix --workspaces --if-present", - "ci": "npm run ci --workspaces --if-present", - "pack": "npm run pack --workspaces --if-present", + "build:watch": "pnpm -r --if-present run build:watch", + "clean": "pnpm -r --if-present run clean", + "test": "pnpm --filter @microsoft/agents-a365-tests test", + "test:coverage": "pnpm --filter @microsoft/agents-a365-tests test:coverage", + "test:watch": "pnpm --filter @microsoft/agents-a365-tests test:watch", + "lint": "pnpm -r --if-present run lint", + "lint:fix": "pnpm -r --if-present run lint:fix", + "typecheck": "pnpm -r --fail-if-no-match --filter \"./packages/**\" exec tsc --project tsconfig.cjs.json --noEmit && pnpm -r --fail-if-no-match --filter \"./packages/**\" exec tsc --project tsconfig.esm.json --noEmit", + "ci": "pnpm install --frozen-lockfile", + "pack": "node packPackages.js", "version": "node setVersion.js", "version:local": "node setVersion.js --local", "version:check": "node setVersion.js --dry-run" }, "workspaces": [ - "packages/agents-a365-*", - "tests-agent/*", + "packages/agents-a365-notifications", + "packages/agents-a365-runtime", + "packages/agents-a365-tooling", + "packages/agents-a365-tooling-extensions-claude", + "packages/agents-a365-tooling-extensions-langchain", + "packages/agents-a365-tooling-extensions-openai", "tests" ], "keywords": [ diff --git a/packages/agents-a365-notifications/package.json b/packages/agents-a365-notifications/package.json index fccbe16a..97013740 100644 --- a/packages/agents-a365-notifications/package.json +++ b/packages/agents-a365-notifications/package.json @@ -17,7 +17,7 @@ "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", "ci": "npm ci", - "pack": "node ../../copyFiles.js . && pnpm pack --pack-destination=../" + "pack": "pnpm --dir ../.. run pack" }, "keywords": [ "ai", diff --git a/packages/agents-a365-observability-extensions-langchain/README.md b/packages/agents-a365-observability-extensions-langchain/README.md deleted file mode 100644 index 6ef4c54a..00000000 --- a/packages/agents-a365-observability-extensions-langchain/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Agent 365 Observability Extensions for LangChain (Node.js) - -This package provides automatic instrumentation for LangChain.js `Runnable` invocations, emitting OpenTelemetry spans aligned with Agent 365 observability conventions. - -## Installation - -```bash -pnpm add @microsoft/agents-a365-observability-extensions-langchain -``` - -Ensure you have configured the Agent 365 `ObservabilityManager` and an OpenTelemetry tracer provider. - -## Usage - -Enable the instrumentation early in your application startup: - -```ts -import { LangChainTraceInstrumentor } from '@microsoft/agents-a365-observability-extensions-langchain'; -import * as LangChainCallbacks from "@langchain/core/callbacks/manager"; -import { ObservabilityManager } from '@microsoft/agents-a365-observability'; - -// 1) Configure the tracing SDK -ObservabilityManager.start({ - serviceName: 'Your Service', - serviceVersion: '1.0.0' - // your exporter/provider configuration -}); - -// 2) set up langchain auto instrument -LangChainTraceInstrumentor.instrument(LangChainCallbacks as any); - -// 3) Use LangChain; spans are created automatically for all Runnable operations -const result = await chain.invoke(input); -``` diff --git a/packages/agents-a365-observability-extensions-langchain/jest.config.json b/packages/agents-a365-observability-extensions-langchain/jest.config.json deleted file mode 100644 index efb403e4..00000000 --- a/packages/agents-a365-observability-extensions-langchain/jest.config.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "preset": "ts-jest", - "testEnvironment": "node", - "roots": ["/src", "/../../tests/observability/extension/langchain"], - "testMatch": [ - "**/__tests__/**/*.ts", - "**/?(*.)+(spec|test).ts" - ], - "transform": { - "^.+\\.ts$": ["ts-jest", { - "tsconfig": { - "skipLibCheck": true, - "esModuleInterop": true, - "module": "commonjs" - } - }] - }, - "collectCoverageFrom": [ - "src/**/*.ts", - "!src/**/*.d.ts", - "!src/**/index.ts" - ], - "coverageDirectory": "coverage", - "coverageReporters": [ - "text", - "lcov", - "html" - ] -} \ No newline at end of file diff --git a/packages/agents-a365-observability-extensions-langchain/package.json b/packages/agents-a365-observability-extensions-langchain/package.json deleted file mode 100644 index 16186f01..00000000 --- a/packages/agents-a365-observability-extensions-langchain/package.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "name": "@microsoft/agents-a365-observability-extensions-langchain", - "version": "0.0.0-placeholder", - "description": "Microsoft Agent 365 SDK LangChain instrumentation extensions for telemetry and observability", - "keywords": [ - "agent365", - "langchain", - "agents", - "telemetry", - "observability", - "opentelemetry", - "instrumentation" - ], - "homepage": "https://github.com/microsoft/Agent365-nodejs", - "bugs": { - "url": "https://github.com/microsoft/Agent365-nodejs/issues" - }, - "repository": { - "type": "git", - "url": "https://github.com/microsoft/Agent365-nodejs.git", - "directory": "packages/agents-a365-observability-extensions-langchain" - }, - "license": "MIT", - "author": "Microsoft", - "main": "./dist/cjs/index.js", - "module": "./dist/esm/index.js", - "types": "./dist/cjs/index.d.ts", - "files": [ - "dist", - "README.md", - "CHANGELOG.md", - "LICENSE.md" - ], - "scripts": { - "build": "npm run build:cjs && npm run build:esm", - "build:cjs": "npx tsc --project tsconfig.cjs.json", - "build:esm": "npx tsc --project tsconfig.esm.json", - "build:watch": "npx tsc --watch", - "clean": "npx rimraf dist", - "lint": "eslint src/**/*.ts", - "lint:fix": "eslint src/**/*.ts --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "pack": "node ../../copyFiles.js . && pnpm pack --pack-destination=../" - }, - "dependencies": { - "@microsoft/agents-a365-observability": "workspace:*", - "@opentelemetry/api": "catalog:", - "@opentelemetry/instrumentation": "catalog:", - "@opentelemetry/core": "catalog:" - }, - "devDependencies": { - "@eslint/js": "catalog:", - "@types/jest": "catalog:", - "@types/node": "catalog:", - "@typescript-eslint/eslint-plugin": "catalog:", - "@typescript-eslint/parser": "catalog:", - "eslint": "catalog:", - "jest": "catalog:", - "rimraf": "catalog:", - "ts-jest": "catalog:", - "typescript": "catalog:", - "typescript-eslint": "catalog:", - "@langchain/core": "catalog:" - }, - "peerDependencies": { - "@langchain/core": "catalog:" - }, - "engines": { - "node": ">=18.0.0" - } -} diff --git a/packages/agents-a365-observability-extensions-langchain/src/LangChainTraceInstrumentor.ts b/packages/agents-a365-observability-extensions-langchain/src/LangChainTraceInstrumentor.ts deleted file mode 100644 index b0f840cf..00000000 --- a/packages/agents-a365-observability-extensions-langchain/src/LangChainTraceInstrumentor.ts +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type * as CallbackManagerModule from "@langchain/core/callbacks/manager"; -import { trace, Tracer } from "@opentelemetry/api"; -import { - InstrumentationBase, - InstrumentationConfig, - InstrumentationModuleDefinition, - isWrapped -} from "@opentelemetry/instrumentation"; -import { logger, ObservabilityManager } from "@microsoft/agents-a365-observability"; -import { LangChainTracer } from "./tracer"; - - -type CallbackManagerModuleType = typeof CallbackManagerModule; - -class LangChainTraceInstrumentorImpl extends InstrumentationBase { - private static _instance: LangChainTraceInstrumentorImpl | null = null; - private _hasBeenEnabled = false; - private _isPatched = false; - protected otelTracer: Tracer; - - private constructor() { - if (LangChainTraceInstrumentorImpl._instance !== null) { - throw new Error("LangChainTraceInstrumentor can only be instantiated once."); - } - - if (!ObservabilityManager.getInstance()) { - throw new Error( - "ObservabilityManager is not configured yet. " - + "Please configure ObservabilityManager before initializing this instrumentor." - ); - } - - super("agent365-langchain-instrumentor", "1.0.0", { - enabled: true - }); - - this.otelTracer = trace.getTracer( - "agent365-langchain", - "1.0.0" - ); - - LangChainTraceInstrumentorImpl._instance = this; - logger.info("[LangChainTraceInstrumentor] Initialized and automatically enabled"); - } - - static getInstance(): LangChainTraceInstrumentorImpl { - if (!LangChainTraceInstrumentorImpl._instance) { - LangChainTraceInstrumentorImpl._instance = new LangChainTraceInstrumentorImpl(); - } - return LangChainTraceInstrumentorImpl._instance; - } - - static hasInstance(): boolean { - return LangChainTraceInstrumentorImpl._instance !== null; - } - - static resetInstance(): void { - if (LangChainTraceInstrumentorImpl._instance) { - LangChainTraceInstrumentorImpl._instance._isPatched = false; - } - LangChainTraceInstrumentorImpl._instance = null; - } - - protected init(): InstrumentationModuleDefinition { - return { - name: "@langchain/core/callbacks/manager", - supportedVersions: [">=0.2.0"], - files: [], - patch: this.patch.bind(this), - unpatch: this.unpatch.bind(this) - }; - } - - private unpatch(moduleExports: Record): void { - const CallbackManager = moduleExports?.CallbackManager as typeof CallbackManagerModule.CallbackManager; - if (!CallbackManager || !isWrapped(CallbackManager._configureSync)) { - return; - } - - this._unwrap(CallbackManager, "_configureSync"); - this._isPatched = false; - logger.info("[LangChainTraceInstrumentor] Unpatched OTEL LangChain instrumentation"); - } - - patch(module: CallbackManagerModuleType): CallbackManagerModuleType { - if (this._isPatched) { - return module; - } - - const { CallbackManager } = module as CallbackManagerModuleType; - if (!CallbackManager || !("_configureSync" in CallbackManager)) { - return module; - } - - // eslint-disable-next-line @typescript-eslint/no-this-alias - const instrumentor = this; - this._wrap(CallbackManager, "_configureSync", (original) => { - return function ( - this: CallbackManagerModuleType, - ...args: Parameters - ) { - args[0] = addTracerToHandlers(instrumentor.otelTracer, args[0]); - logger.info("[LangChainTraceInstrumentor] _configureSync wrapped to add LangChainTracer"); - return original.apply(this, args); - }; - }); - - logger.info("[LangChainTraceInstrumentor] Patched OTEL LangChain instrumentation"); - this._isPatched = true; - return module; - } - - manuallyInstrumentImpl(module: CallbackManagerModuleType): void { - logger.info("Manually instrumenting CallbackManagerModule"); - this.patch(module); - } - - public instrumentationDependencies(): readonly string[] { - return ["@langchain/core >= 0.2.0"] as const; - } - - public override enable(): void { - if (this._hasBeenEnabled) { - return; - } - this._hasBeenEnabled = true; - logger.info("[LangChainTraceInstrumentor] Enabled LangChain instrumentation"); - super.enable(); - } - - public override disable(): void { - this._hasBeenEnabled = false; - logger.info("[LangChainTraceInstrumentor] Disabled LangChain instrumentation"); - super.disable(); - } -} - -/** - * Static wrapper for LangChain tracing instrumentation - */ -export class LangChainTraceInstrumentor { - private static throwNotInitialized(): never { - throw new Error( - "LangChainTraceInstrumentor must be initialized first. " - + "Call LangChainTraceInstrumentor.instrument() before using enable/disable." - ); - } - - /** - * Initialize and auto-instrument for LangChain - * @param module The CallbackManager module to instrument - */ - static instrument(module: CallbackManagerModuleType): void { - LangChainTraceInstrumentorImpl.getInstance().manuallyInstrumentImpl(module); - } - - /** - * Enable LangChain instrumentation - */ - static enable(): void { - if (!LangChainTraceInstrumentorImpl.hasInstance()) { - this.throwNotInitialized(); - } - LangChainTraceInstrumentorImpl.getInstance().enable(); - } - - /** - * Disable LangChain instrumentation - */ - static disable(): void { - if (!LangChainTraceInstrumentorImpl.hasInstance()) { - this.throwNotInitialized(); - } - LangChainTraceInstrumentorImpl.getInstance().disable(); - } - - /** - * Reset the instrumentor instance (for testing) - */ - static resetInstance(): void { - LangChainTraceInstrumentorImpl.resetInstance(); - } -} - -export function addTracerToHandlers( - tracer: Tracer, - handlers: CallbackManagerModule.Callbacks | undefined, -): CallbackManagerModule.Callbacks { - if (handlers == null) { - return [new LangChainTracer(tracer)]; - } - - if (Array.isArray(handlers)) { - if (!handlers.some((h) => h instanceof LangChainTracer)) { - handlers.push(new LangChainTracer(tracer)); - } - return handlers; - } - - if (!handlers.inheritableHandlers.some((h) => h instanceof LangChainTracer)) { - handlers.addHandler(new LangChainTracer(tracer), true); - } - return handlers; -} diff --git a/packages/agents-a365-observability-extensions-langchain/src/Utils.ts b/packages/agents-a365-observability-extensions-langchain/src/Utils.ts deleted file mode 100644 index 94c635ac..00000000 --- a/packages/agents-a365-observability-extensions-langchain/src/Utils.ts +++ /dev/null @@ -1,527 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { Run } from "@langchain/core/tracers/base"; -import { Span } from "@opentelemetry/api"; -import { - OpenTelemetryConstants, - serializeMessages, - safeSerializeToJson, - MessageRole, -} from "@microsoft/agents-a365-observability"; -import type { - ChatMessage, - OutputMessage, - InputMessages, - OutputMessages, - MessagePart, -} from "@microsoft/agents-a365-observability"; - -// Type guards -export function isString(value: unknown): value is string { - return typeof value === "string"; -} - -// Operation type mapping -export function getOperationType(run: Run): string { - let operation = "unknown"; - - if (run.run_type === "chain" && isLangGraphAgentInvoke(run)) { - operation = OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME; - } else if (run.run_type === "tool") { - operation = OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME; - } else if (run.run_type === "llm") { - operation = OpenTelemetryConstants.CHAT_OPERATION_NAME; - } - return operation; -} - -// Operation type mapping -export function setOperationTypeAttribute(operation: string, span: Span) { - span.setAttribute(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, operation); -} - -// Agent attributes -export function setAgentAttributes(run: Run, span: Span) { - if (isLangGraphAgentInvoke(run)) { - const agentName = run.name; - if (isString(agentName)) { - span.setAttribute(OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, agentName); - } - } -} - -// Tool attributes -export function setToolAttributes(run: Run, span: Span) { - if (run.run_type !== "tool") { - return; - } - if (!run.serialized || typeof run.serialized !== "object" || Array.isArray(run.serialized)) { - return; - } - - if (isString(run.name)) { - span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_NAME_KEY, run.name); - } - if (run.inputs) { - const argsValue = run.inputs?.input ?? run.inputs; - span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY, safeSerializeToJson( - typeof argsValue === 'object' ? argsValue as Record : String(argsValue), 'arguments' - )); - } - - // Tool result: v0 uses output.kwargs.content, v1 returns output as a plain string or has content directly - const toolResult = - run.outputs?.output?.kwargs?.content ?? - (isString(run.outputs?.output) ? run.outputs.output : null) ?? - run.outputs?.output?.content; - if (toolResult != null) { - span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY, safeSerializeToJson( - typeof toolResult === 'object' ? toolResult as Record : String(toolResult), 'result' - )); - } - - span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY, "extension"); - - // Tool call ID: v0 uses output.tool_call_id, v1 may have it on inputs - const toolCallId = run.outputs?.output?.tool_call_id ?? run.inputs?.tool_call_id; - if (toolCallId) span.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_CALL_ID_KEY, toolCallId); -} - -export function setInputMessagesAttribute(run: Run, span: Span) { - const messages = run.inputs?.messages; - if (!Array.isArray(messages)) { - return; - } - - // LangChain may provide messages as a direct array or as a single nested array. - // Normalize both shapes so agent/inference inputs are consistently processed. - const preprocess = getScopeType(run) !== "unknown" && messages.length > 0 && Array.isArray(messages[0]) - ? messages[0] as unknown[] - : messages; - const chatMessages: ChatMessage[] = []; - - for (const msg of preprocess) { - if (!msg || typeof msg !== "object") continue; - const msgObj = msg as Record; - const parts = buildPartsFromMessage(msgObj); - if (parts.length === 0) continue; - - const msgType = getMessageType(msgObj); - const role = mapLangChainRole(msgType); - chatMessages.push({ role, parts }); - } - - if (chatMessages.length > 0) { - const wrapper: InputMessages = { messages: chatMessages }; - span.setAttribute(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, serializeMessages(wrapper)); - } -} - -// Helper: Extract string content from a message (used for fallback text extraction and system instructions) -function extractStringContent(msg: Record): string | null { - const raw = extractRawContent(msg); - return isString(raw) ? raw : null; -} - -// Helper: Extract raw content (string or content block array) from various message formats -function extractRawContent(msg: Record): string | unknown[] | null { - // Simple format: {role: "user", content: string | array} - if (msg.content !== undefined && msg.content !== null) { - if (isString(msg.content)) return msg.content; - if (Array.isArray(msg.content)) return msg.content; - } - - // LangChain format: {lc_type: "human", lc_kwargs: {content}} - if (msg.lc_kwargs && typeof msg.lc_kwargs === "object" && !Array.isArray(msg.lc_kwargs)) { - const kwargs = msg.lc_kwargs as Record; - if (isString(kwargs.content)) return kwargs.content; - if (Array.isArray(kwargs.content)) return kwargs.content; - } - - // LangChain v1 serialized class instance format: { lc: 1, type: "constructor", kwargs: {...} } - // `lc: 1` is the LangChain serialization version marker indicating a v1 schema. - // `type: "constructor"` means the object was serialized as a class instance (e.g. HumanMessage, AIMessage) - // that can be reconstructed via its constructor arguments stored in `kwargs`. - if (msg.lc === 1 && msg.type === "constructor" && msg.kwargs && typeof msg.kwargs === "object" && !Array.isArray(msg.kwargs)) { - const kwargs = msg.kwargs as Record; - if (isString(kwargs.content)) return kwargs.content; - if (Array.isArray(kwargs.content)) return kwargs.content; - } - return null; -} - -// Helper: Map LangChain message type to MessageRole -function mapLangChainRole(msgType: string): MessageRole | string { - switch (msgType) { - case "user": - case "human": - return MessageRole.USER; - case "assistant": - case "ai": - return MessageRole.ASSISTANT; - case "system": - return MessageRole.SYSTEM; - case "tool": - return MessageRole.TOOL; - default: - return msgType; - } -} - -// Helper: Build MessagePart[] from a LangChain message -function buildPartsFromMessage(msg: Record): MessagePart[] { - const parts: MessagePart[] = []; - const rawContent = extractRawContent(msg); - - const addUnknownBlockPart = (blockType: string, block: Record) => { - try { - parts.push({ type: blockType, content: JSON.stringify(block) } as MessagePart); - } catch { - parts.push({ type: blockType, content: "[unserializable]" } as MessagePart); - } - }; - - const addPartFromContentBlock = (block: unknown) => { - if (!block || typeof block !== "object") return; - - const contentBlock = block as Record; - const blockType = contentBlock.type as string | undefined; - if (!blockType) return; - - if (blockType === "text" && isString(contentBlock.text)) { - parts.push({ type: "text", content: contentBlock.text }); - return; - } - - if (blockType === "reasoning" && isString(contentBlock.reasoning)) { - parts.push({ type: "reasoning", content: contentBlock.reasoning }); - return; - } - - if (blockType === "tool_call") { - parts.push({ - type: "tool_call", - name: String(contentBlock.name ?? ""), - id: contentBlock.id != null ? String(contentBlock.id) : undefined, - arguments: contentBlock.args && typeof contentBlock.args === "object" ? contentBlock.args as Record : undefined, - }); - return; - } - - addUnknownBlockPart(blockType, contentBlock); - }; - - if (isString(rawContent)) { - parts.push({ type: "text", content: rawContent }); - } else if (Array.isArray(rawContent)) { - for (const block of rawContent) { - addPartFromContentBlock(block); - } - } - - // Extract tool_calls from the message (AI messages may have a separate tool_calls array) - // Deduplicate by ID to avoid duplicates when tool_calls appear in both content blocks and tool_calls array - const seenToolCallIds = new Set(); - for (const part of parts) { - if (part.type !== "tool_call") continue; - const partId = (part as Record).id; - if (isString(partId)) { - seenToolCallIds.add(partId); - } - } - - for (const toolCall of extractToolCalls(msg)) { - const toolCallId = (toolCall as Record).id; - if (isString(toolCallId) && seenToolCallIds.has(toolCallId)) { - continue; - } - if (isString(toolCallId)) { - seenToolCallIds.add(toolCallId); - } - parts.push(toolCall); - } - - // Fallback: if no parts were built, use text extraction - if (parts.length === 0) { - const textContent = extractStringContent(msg); - if (textContent) { - parts.push({ type: "text", content: textContent }); - } - } - - return parts; -} - -// Helper: Extract tool_calls from a LangChain message -function extractToolCalls(msg: Record): MessagePart[] { - const parts: MessagePart[] = []; - - // Standard format: message.tool_calls[] — check direct, lc_kwargs, and kwargs paths - const directToolCalls = - getNestedValue(msg, "tool_calls") ?? - getNestedValue(msg, "lc_kwargs", "tool_calls") ?? - getNestedValue(msg, "kwargs", "tool_calls"); - if (Array.isArray(directToolCalls)) { - for (const tc of directToolCalls) { - if (!tc || typeof tc !== "object") continue; - const call = tc as Record; - parts.push({ - type: "tool_call", - name: String(call.name ?? ""), - id: call.id != null ? String(call.id) : undefined, - arguments: call.args && typeof call.args === "object" ? call.args as Record : undefined, - }); - } - } - - return parts; -} - -// Helper: Safely get a nested value from a message object -function getNestedValue(obj: Record, ...keys: string[]): unknown { - let current: unknown = obj; - for (const key of keys) { - if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; - current = (current as Record)[key]; - } - return current; -} - -// Helper: Determine message type -function getMessageType(msg: Record): string { - // Simple format - if (isString(msg.role)) return msg.role; - // LangChain old format - if (isString(msg.lc_type)) return msg.lc_type; - // Skip v1 constructor type marker — fall through to id array check - if (isString(msg.type) && msg.type !== "constructor") return msg.type; - // LangChain v1 format - check id array for message type (e.g., ["langchain_core", "messages", "HumanMessage"]) - if (Array.isArray(msg.id)) { - const lastId = msg.id[msg.id.length - 1]; - if (isString(lastId)) { - if (lastId.includes("Human")) return "human"; - if (lastId.includes("AI")) return "ai"; - if (lastId.includes("System")) return "system"; - if (lastId.includes("Tool")) return "tool"; - } - } - return "unknown"; -} - -// Helper: Determine scope type from run -function getScopeType(run: Run): "agent" | "tool" | "inference" | "unknown" { - if (run.run_type === "chain" && isLangGraphAgentInvoke(run)) { - return "agent"; - } else if (run.run_type === "tool") { - return "tool"; - } else if (run.run_type === "llm") { - return "inference"; - } - return "unknown"; -} - -// Helper: Check if output message should be included based on scope and message type -function shouldIncludeOutputMessage(scopeType: string, msgType: string): boolean { - if (scopeType === "agent" || scopeType === "inference") { - // Agent and Inference scopes want assistant/AI messages only - return msgType === "ai" || msgType === "assistant"; - } else if (scopeType === "tool") { - // Tool scope wants all output messages - return true; - } - // Default: all messages - return true; -} - -export function setOutputMessagesAttribute(run: Run, span: Span) { - const outputs = run.outputs; - if (!outputs) { - return; - } - - const scopeType = getScopeType(run); - const outputMessages: OutputMessage[] = []; - - // Helper: process a single message object into an OutputMessage - const processMessage = (msg: Record) => { - const msgType = getMessageType(msg); - if (!shouldIncludeOutputMessage(scopeType, msgType)) return; - - const parts = buildPartsFromMessage(msg); - if (parts.length === 0) return; - - const role = mapLangChainRole(msgType); - outputMessages.push({ role, parts }); - }; - - // Direct messages array (used in agent/chain outputs) - if (Array.isArray(outputs.messages)) { - for (const msg of outputs.messages as Record[]) { - processMessage(msg); - } - } - - // LangChain generations format (used in LLM/inference outputs) - if (Array.isArray(outputs.generations)) { - outputs.generations.forEach((gen: unknown) => { - if (Array.isArray(gen)) { - gen.forEach((item: Record) => { - // Try message property - if (item.message && typeof item.message === "object" && !Array.isArray(item.message)) { - processMessage(item.message as Record); - } - // Try direct text property (for generation items) - else if (isString(item.text) && scopeType === "inference") { - outputMessages.push({ - role: MessageRole.ASSISTANT, - parts: [{ type: "text", content: item.text }], - }); - } - }); - } - }); - } - - // Check for direct message object (some models return this) - if (outputs.message && typeof outputs.message === "object" && !Array.isArray(outputs.message)) { - processMessage(outputs.message as Record); - } - - if (outputMessages.length > 0) { - const wrapper: OutputMessages = { messages: outputMessages }; - span.setAttribute(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, serializeMessages(wrapper)); - } -} - -// Model - Helper to extract model name from run -export function getRequestModel(run: Run): string | undefined { - return [ - run.extra?.metadata?.ls_model_name, - run.extra?.invocation_params?.model, - run.extra?.invocation_params?.model_name, - ] - .map((v) => (v != null ? String(v).trim() : "")) - .find((v) => v.length > 0); -} - -// Model - Set model attribute on span -export function setModelAttribute(run: Run, span: Span) { - const requestModel = getRequestModel(run); - if (requestModel) span.setAttribute(OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY, requestModel); - - const responseModel = [ - run.outputs?.llmOutput?.model_name, - run.outputs?.llmOutput?.model, - run.outputs?.generations?.[0]?.[0]?.message?.response_metadata?.model_name, - run.outputs?.generations?.[0]?.[0]?.message?.kwargs?.response_metadata?.model_name, - ] - .map((v) => (v != null ? String(v).trim() : "")) - .find((v) => v.length > 0); - if (responseModel) span.setAttribute(OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY, responseModel); -} - -// Provider -export function setProviderNameAttribute(run: Run, span: Span) { - const provider = (run.extra?.metadata as Record | undefined)?.ls_provider; - if (isString(provider)) span.setAttribute(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, provider.toLowerCase()); -} - - -export function setSessionIdAttribute(run: Run, span: Span): void { - const metadata = run.extra?.metadata as Record | undefined; - if (!metadata) return; - - const sessionId = metadata.session_id ?? metadata.thread_id; - if (isString(sessionId) && sessionId.length > 0) { - span.setAttribute(OpenTelemetryConstants.SESSION_ID_KEY, sessionId); - } - - const conversationId = metadata.conversation_id; - if (isString(conversationId) && conversationId.length > 0) { - span.setAttribute(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, conversationId); - } -} - -// System instructions -export function setSystemInstructionsAttribute(run: Run, span: Span) { - const inputs = run.inputs as Record | undefined; - if (!inputs) { - return; - } - - const prompts = Array.isArray(inputs.prompts) ? inputs.prompts.map(p => String(p ?? "").trim()).filter(Boolean).join("\n") : ""; - if (prompts) return span.setAttribute(OpenTelemetryConstants.GEN_AI_SYSTEM_INSTRUCTIONS_KEY, prompts); - - // Check both flat and nested message arrays - const rawMessages = Array.isArray(inputs.messages) ? inputs.messages : []; - const flatMessages = rawMessages.length > 0 && Array.isArray(rawMessages[0]) ? rawMessages[0] as unknown[] : rawMessages; - const systemText = flatMessages - .filter((m: unknown) => { - if (!m || typeof m !== "object") return false; - const msgType = getMessageType(m as Record); - return msgType === "system"; - }) - .map((m: unknown) => extractStringContent(m as Record) ?? "") - .map((s: string) => s.trim()) - .filter(Boolean) - .join("\n"); - if (systemText) span.setAttribute(OpenTelemetryConstants.GEN_AI_SYSTEM_INSTRUCTIONS_KEY, systemText); -} - -// Tokens (input and output) -export function setTokenAttributes(run: Run, span: Span) { - // Try multiple paths to find usage metadata (LLM direct/kwargs/response_metadata, agent calls, and chain/model_request outputs) - // v1: usage_metadata is often on the last AI message in outputs.messages - const lastMsg = Array.isArray(run.outputs?.messages) ? run.outputs.messages[run.outputs.messages.length - 1] : undefined; - const usage = - run.outputs?.generations?.[0]?.[0]?.message?.usage_metadata || - run.outputs?.generations?.[0]?.[0]?.message?.kwargs?.usage_metadata || - run.outputs?.generations?.[0]?.[0]?.message?.response_metadata?.tokenUsage || - run.outputs?.generations?.[0]?.[0]?.message?.kwargs?.response_metadata?.tokenUsage || - lastMsg?.usage_metadata || - run.outputs?.message?.response_metadata?.usage || - run.outputs?.message?.response_metadata?.tokenUsage || - run.outputs?.messages - ?.map((msg: Record) => (msg.response_metadata as Record | undefined)?.tokenUsage) - .filter(Boolean)[0]; - - if (!usage || typeof usage !== "object") { - return; - } - - const usageObj = usage as Record; - // Support both usage_metadata shape (input_tokens/output_tokens) and - // tokenUsage shape (promptTokens/completionTokens) from LangChain OpenAI provider - const inputTokens = usageObj.input_tokens ?? usageObj.promptTokens; - const outputTokens = usageObj.output_tokens ?? usageObj.completionTokens; - const inputTokenDetails = usageObj.input_token_details as Record | undefined; - const cacheReadInputTokens = inputTokenDetails?.cache_read ?? inputTokenDetails?.cached_tokens; - const cacheWriteInputTokens = inputTokenDetails?.cache_write ?? inputTokenDetails?.cache_creation; - if (typeof inputTokens === "number") { - span.setAttribute(OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY, inputTokens); - } - if (typeof outputTokens === "number") { - span.setAttribute(OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY, outputTokens); - } - if (typeof cacheReadInputTokens === "number") { - span.setAttribute(OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, cacheReadInputTokens); - } - if (typeof cacheWriteInputTokens === "number") { - span.setAttribute(OpenTelemetryConstants.GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, cacheWriteInputTokens); - } -} - - -// LangGraph agent check -function isLangGraphAgentInvoke(run: Run): boolean { - if (run.run_type !== "chain") { - return false; - } - if (!run.serialized || typeof run.serialized !== "object" || Array.isArray(run.serialized)) { - return false; - } - const serialized = run.serialized as Record; - const id = serialized.id; - return Array.isArray(id) && id.includes("langgraph") && id.includes("CompiledStateGraph"); -} diff --git a/packages/agents-a365-observability-extensions-langchain/src/index.ts b/packages/agents-a365-observability-extensions-langchain/src/index.ts deleted file mode 100644 index c9028d06..00000000 --- a/packages/agents-a365-observability-extensions-langchain/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -export { LangChainTraceInstrumentor } from './LangChainTraceInstrumentor'; -export { LangChainTracer } from './tracer'; diff --git a/packages/agents-a365-observability-extensions-langchain/src/tracer.ts b/packages/agents-a365-observability-extensions-langchain/src/tracer.ts deleted file mode 100644 index 8f313e51..00000000 --- a/packages/agents-a365-observability-extensions-langchain/src/tracer.ts +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { context, trace, Span, SpanKind, SpanStatusCode, Tracer } from "@opentelemetry/api"; -import { BaseTracer, Run } from "@langchain/core/tracers/base"; -import { isTracingSuppressed } from "@opentelemetry/core"; -import { logger, OpenTelemetryConstants } from "@microsoft/agents-a365-observability"; -import * as Utils from "./Utils"; - -type RunWithSpan = { run: Run; span: Span; startTime: number; lastAccessTime: number }; - -export class LangChainTracer extends BaseTracer { - private static readonly MAX_RUNS = 10_000; - private tracer: Tracer; - private runs = new Map(); - private parentByRunId = new Map(); - - - constructor(tracer: Tracer) { - super(); - this.tracer = tracer; - } - - name = "OpenTelemetryLangChainTracer"; - - protected persistRun(_run: Run): Promise { - return Promise.resolve(); - } - - async onRunCreate(run: Run) { - this.parentByRunId.set(run.id, run.parent_run_id); - if (super.onRunCreate) await super.onRunCreate(run); - this.startTracing(run); - } - - protected startTracing(run: Run) { - if (isTracingSuppressed(context.active())) { - return; - } - - const operation = Utils.getOperationType(run); - - // Skip internal runs - if (run.tags?.includes("langsmith:hidden") || run.name?.startsWith("Branch") || operation === "unknown") { - logger.info(`Skipping internal run: ${run.name} (parent: ${run.parent_run_id})`); - return; - } - - const parentCtx = this.getNearestParentSpanContext(run); - const activeContext = parentCtx - ? trace.setSpanContext(context.active(), parentCtx) - : context.active(); - - let spanName = run.name; - let kind: SpanKind = SpanKind.INTERNAL; - if (operation === "invoke_agent") { - spanName = `${operation} ${run.name}`; - kind = SpanKind.SERVER; - } else if (operation === "execute_tool") { - spanName = `${operation} ${run.name}`; - kind = SpanKind.CLIENT; - } else if (operation === "chat") { - spanName = `${operation} ${Utils.getRequestModel(run) || run.name}`.trim(); - kind = SpanKind.CLIENT; - } - - if (this.runs.size >= LangChainTracer.MAX_RUNS) { - logger.warn(`[LangChainTracer] Max runs (${LangChainTracer.MAX_RUNS}) reached, skipping span`); - this.parentByRunId.delete(run.id); - return; - } - - const startTime = run.start_time ?? Date.now(); - const span = this.tracer.startSpan(spanName, { - kind, - startTime, - attributes: { [OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY]: "langchain" }, - }, activeContext); - - this.runs.set(run.id, { run, span, startTime, lastAccessTime: startTime }); - } - - protected async _endTrace(run: Run) { - if (isTracingSuppressed(context.active())) { - // Even when suppressed, end any span that was started before suppression kicked in - // to avoid abandoned spans that are never exported or closed. - const suppressedEntry = this.runs.get(run.id); - if (suppressedEntry) { - suppressedEntry.span.end(run.end_time ?? undefined); - } - this.parentByRunId.delete(run.id); - this.runs.delete(run.id); - return; - } - // Skip internal runs - const operation = Utils.getOperationType(run); - if (run.tags?.includes("langsmith:hidden") || run.name?.startsWith("Branch") || operation === "unknown") { - logger.info(`Skipping internal run: ${run.name} (parent: ${run.parent_run_id})`); - return; - } - - const entry = this.runs.get(run.id); - if (!entry) { - return; - } - - const { span } = entry; - try { - entry.lastAccessTime = Date.now(); - - if (run.error) { - span.setStatus({ code: SpanStatusCode.ERROR }); - span.setAttribute(OpenTelemetryConstants.ERROR_MESSAGE_KEY, String(run.error)); - const errorType = (run.error as { name?: string })?.name ?? (run.error as { constructor?: { name?: string } })?.constructor?.name; - if (typeof errorType === "string" && errorType.length > 0) { - span.setAttribute(OpenTelemetryConstants.ERROR_TYPE_KEY, errorType); - } - } else { - span.setStatus({ code: SpanStatusCode.OK }); - } - - // Set all attributes - Utils.setOperationTypeAttribute(operation, span); - Utils.setAgentAttributes(run, span); - if (operation === "invoke_agent") { - const callerName = this.findCallerAgentName(run); - if (callerName) { - span.setAttribute(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY, callerName); - } - } - Utils.setModelAttribute(run, span); - Utils.setProviderNameAttribute(run, span); - Utils.setSessionIdAttribute(run, span); - Utils.setTokenAttributes(run, span); - - // Content attributes — always recorded (aligned with Python/.NET SDKs) - Utils.setToolAttributes(run, span); - Utils.setInputMessagesAttribute(run, span); - Utils.setOutputMessagesAttribute(run, span); - Utils.setSystemInstructionsAttribute(run, span); - - } catch (error) { - logger.error(`[LangChainTracer] Error setting span attributes for run ${run.name}: ${error instanceof Error ? error.message : String(error)}`); - span.setStatus({ code: SpanStatusCode.ERROR }); - } finally { - span.end(run.end_time ?? undefined); - this.runs.delete(run.id); - this.parentByRunId.delete(run.id); - await super._endTrace(run); - } - } - - private getNearestParentSpanContext(run: Run) { - let pid = run.parent_run_id; - - while (pid) { - const entry = this.runs.get(pid); - if (entry) return entry.span.spanContext(); - pid = this.parentByRunId.get(pid); - } - return undefined; - } - - private findCallerAgentName(run: Run): string | undefined { - let pid = run.parent_run_id; - while (pid) { - const entry = this.runs.get(pid); - if (entry && Utils.getOperationType(entry.run) === "invoke_agent") { - return entry.run.name; - } - pid = this.parentByRunId.get(pid); - } - return undefined; - } -} diff --git a/packages/agents-a365-observability-extensions-langchain/tsconfig.cjs.json b/packages/agents-a365-observability-extensions-langchain/tsconfig.cjs.json deleted file mode 100644 index ba6d450d..00000000 --- a/packages/agents-a365-observability-extensions-langchain/tsconfig.cjs.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./dist/cjs", - "module": "CommonJS" - } -} \ No newline at end of file diff --git a/packages/agents-a365-observability-extensions-langchain/tsconfig.esm.json b/packages/agents-a365-observability-extensions-langchain/tsconfig.esm.json deleted file mode 100644 index 815f5d3c..00000000 --- a/packages/agents-a365-observability-extensions-langchain/tsconfig.esm.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./dist/esm", - "module": "ESNext" - } -} \ No newline at end of file diff --git a/packages/agents-a365-observability-extensions-langchain/tsconfig.json b/packages/agents-a365-observability-extensions-langchain/tsconfig.json deleted file mode 100644 index a96e5891..00000000 --- a/packages/agents-a365-observability-extensions-langchain/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist/cjs", - "target": "ES2020", - "module": "CommonJS", - "moduleResolution": "Node", - "declaration": true, - "sourceMap": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "exclude": ["node_modules", "dist"] -} \ No newline at end of file diff --git a/packages/agents-a365-observability-extensions-openai/README.md b/packages/agents-a365-observability-extensions-openai/README.md deleted file mode 100644 index 77ce91be..00000000 --- a/packages/agents-a365-observability-extensions-openai/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# @microsoft/agents-a365-observability-extensions-openai - -[![npm](https://img.shields.io/npm/v/@microsoft/agents-a365-observability-extensions-openai?label=npm&logo=npm)](https://www.npmjs.com/package/@microsoft/agents-a365-observability-extensions-openai) -[![npm Downloads](https://img.shields.io/npm/dm/@microsoft/agents-a365-observability-extensions-openai?label=Downloads&logo=npm)](https://www.npmjs.com/package/@microsoft/agents-a365-observability-extensions-openai) - -OpenAI Agents SDK instrumentation for the Microsoft Agent 365 Observability SDK. This package provides automatic OpenTelemetry tracing and telemetry collection for OpenAI Agents SDK operations, including agent execution, function calls, and AI model interactions. - -## Installation - -```bash -npm install @microsoft/agents-a365-observability-extensions-openai -``` - -## Usage - -For detailed usage examples and implementation guidance, see the [Microsoft Agent 365 Observability Documentation](https://learn.microsoft.com/microsoft-agent-365/developer/observability?tabs=nodejs). - -## Support - -For issues, questions, or feedback: - -- File issues in the [GitHub Issues](https://github.com/microsoft/Agent365-nodejs/issues) section -- See the [main documentation](../../README.md) for more information - -## Trademarks - -*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* - -## License - -Copyright (c) Microsoft Corporation. All rights reserved. - -Licensed under the MIT License - see the [LICENSE](../../LICENSE.md) file for details diff --git a/packages/agents-a365-observability-extensions-openai/docs/design.md b/packages/agents-a365-observability-extensions-openai/docs/design.md deleted file mode 100644 index 2a48e907..00000000 --- a/packages/agents-a365-observability-extensions-openai/docs/design.md +++ /dev/null @@ -1,207 +0,0 @@ -# Observability Extensions - OpenAI - Design Document - -This document describes the architecture and design of the `@microsoft/agents-a365-observability-extensions-openai` package. - -## Overview - -The OpenAI observability extensions package provides automatic instrumentation for the OpenAI Agents SDK. It wraps the OpenAI Agents SDK tracer to integrate with Agent 365 Observability, converting OpenAI trace events to OpenTelemetry spans. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ OpenAI Agents SDK │ -│ (@openai/agents) │ -└─────────────────────────────────────────────────────────────────┘ - │ - (trace events) - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ OpenAIAgentsTraceInstrumentor │ -│ (InstrumentationBase implementation) │ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ OpenAIAgentsTraceProcessor ││ -│ │ ││ -│ │ Converts OpenAI trace events → OpenTelemetry spans ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ OpenTelemetry SDK │ -│ (via ObservabilityManager) │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## Key Components - -### OpenAIAgentsTraceInstrumentor ([OpenAIAgentsTraceInstrumentor.ts](../src/OpenAIAgentsTraceInstrumentor.ts)) - -Extends OpenTelemetry's `InstrumentationBase` to provide automatic tracing for OpenAI Agents: - -```typescript -import { ObservabilityManager } from '@microsoft/agents-a365-observability'; -import { OpenAIAgentsTraceInstrumentor } from '@microsoft/agents-a365-observability-extensions-openai'; - -// First, configure observability -ObservabilityManager.start({ - serviceName: 'my-openai-agent', - tokenResolver: async (agentId, tenantId) => getToken(), - clusterCategory: 'prod' -}); - -// Then create and enable the instrumentor -const instrumentor = new OpenAIAgentsTraceInstrumentor({ - enabled: true, - tracerName: 'my-openai-agent-tracer', - tracerVersion: '1.0.0', - suppressInvokeAgentInput: false // Set true to hide LLM inputs -}); - -// Enable instrumentation -instrumentor.enable(); - -// ... run your OpenAI agent ... - -// Disable when done -instrumentor.disable(); -``` - -**Configuration Options:** - -```typescript -interface OpenAIAgentsInstrumentationConfig extends InstrumentationConfig { - enabled?: boolean; // Enable/disable instrumentation - tracerName?: string; // Custom tracer name - tracerVersion?: string; // Custom tracer version - suppressInvokeAgentInput?: boolean; // Hide LLM input messages -} -``` - -**Methods:** - -| Method | Purpose | -|--------|---------| -| `enable()` | Enable tracing for OpenAI Agents SDK | -| `disable()` | Disable instrumentation and cleanup | -| `getProcessor()` | Access the trace processor instance | - -### OpenAIAgentsTraceProcessor ([OpenAIAgentsTraceProcessor.ts](../src/OpenAIAgentsTraceProcessor.ts)) - -Custom trace processor that converts OpenAI Agents trace events to OpenTelemetry spans: - -```typescript -import { OpenAIAgentsTraceProcessor } from '@microsoft/agents-a365-observability-extensions-openai'; - -const processor = new OpenAIAgentsTraceProcessor(tracer, { - suppressInvokeAgentInput: false -}); - -// The processor is automatically registered with the OpenAI Agents SDK -// when using OpenAIAgentsTraceInstrumentor -``` - -The processor handles various OpenAI Agents trace events and converts them to appropriate OpenTelemetry span types: - -- Agent runs → `InvokeAgentScope` -- LLM calls → `InferenceScope` -- Tool executions → `ExecuteToolScope` - -## Prerequisites - -The instrumentor requires `ObservabilityManager` to be configured before initialization: - -```typescript -// This will throw an error -const instrumentor = new OpenAIAgentsTraceInstrumentor(); -// Error: ObservabilityManager is not configured yet - -// Correct usage -ObservabilityManager.start({ serviceName: 'my-agent' }); -const instrumentor = new OpenAIAgentsTraceInstrumentor(); -``` - -## Integration with OpenAI Agents SDK - -The instrumentor integrates with the OpenAI Agents SDK's tracing system: - -```typescript -import { setTraceProcessors, setTracingDisabled } from '@openai/agents'; - -// In enable(): -setTracingDisabled(false); // Enable tracing in OpenAI SDK -setTraceProcessors([this.processor]); // Register our processor - -// In disable(): -setTraceProcessors([]); // Clear processors -``` - -## Usage Example - -Complete example with an OpenAI agent: - -```typescript -import { Agent, run } from '@openai/agents'; -import { ObservabilityManager } from '@microsoft/agents-a365-observability'; -import { OpenAIAgentsTraceInstrumentor } from '@microsoft/agents-a365-observability-extensions-openai'; - -// 1. Configure observability -ObservabilityManager.start({ - serviceName: 'my-openai-agent', - tokenResolver: async () => getObservabilityToken(), - clusterCategory: 'prod' -}); - -// 2. Enable instrumentation -const instrumentor = new OpenAIAgentsTraceInstrumentor({ - enabled: true, - suppressInvokeAgentInput: false -}); -instrumentor.enable(); - -// 3. Create and run your agent -const agent = new Agent({ - name: 'MyAgent', - model: 'gpt-4o', - instructions: 'You are a helpful assistant.' -}); - -const result = await run(agent, 'Hello, world!'); -console.log(result.finalOutput); - -// 4. Cleanup -instrumentor.disable(); -await ObservabilityManager.shutdown(); -``` - -## File Structure - -``` -src/ -├── index.ts # Public API exports -├── OpenAIAgentsTraceInstrumentor.ts # Main instrumentor class -└── OpenAIAgentsTraceProcessor.ts # Trace event processor -``` - -## Dependencies - -- `@microsoft/agents-a365-observability` - ObservabilityManager, scope classes -- `@openai/agents` - OpenAI Agents SDK, trace types -- `@opentelemetry/api` - OpenTelemetry API -- `@opentelemetry/instrumentation` - InstrumentationBase class - -## Supported OpenAI Agents SDK Versions - -The instrumentor supports OpenAI Agents SDK versions `>=0.1.5`. - -```typescript -protected init(): InstrumentationModuleDefinition { - return { - name: '@openai/agents', - supportedVersions: ['>=0.1.5'], - files: [], - }; -} -``` diff --git a/packages/agents-a365-observability-extensions-openai/jest.config.json b/packages/agents-a365-observability-extensions-openai/jest.config.json deleted file mode 100644 index a1c08051..00000000 --- a/packages/agents-a365-observability-extensions-openai/jest.config.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "preset": "ts-jest", - "testEnvironment": "node", - "roots": ["/src", "/../../tests/observability/extension/openai"], - "testMatch": [ - "**/__tests__/**/*.ts", - "**/?(*.)+(spec|test).ts" - ], - "transform": { - "^.+\\.ts$": "ts-jest" - }, - "collectCoverageFrom": [ - "src/**/*.ts", - "!src/**/*.d.ts", - "!src/**/index.ts" - ], - "coverageDirectory": "coverage", - "coverageReporters": [ - "text", - "lcov", - "html" - ] -} diff --git a/packages/agents-a365-observability-extensions-openai/package.json b/packages/agents-a365-observability-extensions-openai/package.json deleted file mode 100644 index c72aee19..00000000 --- a/packages/agents-a365-observability-extensions-openai/package.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "name": "@microsoft/agents-a365-observability-extensions-openai", - "version": "0.0.0-placeholder", - "description": "Microsoft Agent 365 SDK OpenAI Agents instrumentation extensions for telemetry and observability", - "keywords": [ - "agent365", - "openai", - "agents", - "telemetry", - "observability", - "opentelemetry", - "instrumentation" - ], - "homepage": "https://github.com/microsoft/Agent365-nodejs", - "bugs": { - "url": "https://github.com/microsoft/Agent365-nodejs/issues" - }, - "repository": { - "type": "git", - "url": "https://github.com/microsoft/Agent365-nodejs.git", - "directory": "packages/agents-a365-observability-extensions-openai" - }, - "license": "MIT", - "author": "Microsoft", - "main": "./dist/cjs/index.js", - "module": "./dist/esm/index.js", - "types": "./dist/cjs/index.d.ts", - "files": [ - "dist", - "README.md", - "CHANGELOG.md", - "LICENSE.md" - ], - "scripts": { - "build": "npm run build:cjs && npm run build:esm", - "build:cjs": "npx tsc --project tsconfig.cjs.json", - "build:esm": "npx tsc --project tsconfig.esm.json", - "build:watch": "npx tsc --watch", - "clean": "npx rimraf dist", - "lint": "eslint src", - "lint:fix": "eslint src --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "pack": "node ../../copyFiles.js . && pnpm pack --pack-destination=../" - }, - "dependencies": { - "@microsoft/agents-a365-observability": "workspace:*", - "@microsoft/agents-a365-runtime": "workspace:*", - "@openai/agents": "catalog:", - "@opentelemetry/api": "catalog:", - "@opentelemetry/instrumentation": "catalog:" - }, - "devDependencies": { - "@eslint/js": "catalog:", - "@types/jest": "catalog:", - "@types/node": "catalog:", - "@typescript-eslint/eslint-plugin": "catalog:", - "@typescript-eslint/parser": "catalog:", - "eslint": "catalog:", - "jest": "catalog:", - "rimraf": "catalog:", - "ts-jest": "catalog:", - "typescript": "catalog:", - "typescript-eslint": "catalog:" - }, - "peerDependencies": { - "@openai/agents": "catalog:", - "hono": "catalog:" - }, - "peerDependenciesMeta": { - "hono": { - "optional": true - } - }, - "engines": { - "node": ">=18.0.0" - } -} diff --git a/packages/agents-a365-observability-extensions-openai/src/Constants.ts b/packages/agents-a365-observability-extensions-openai/src/Constants.ts deleted file mode 100644 index 5fb3ad4a..00000000 --- a/packages/agents-a365-observability-extensions-openai/src/Constants.ts +++ /dev/null @@ -1,35 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -/** - * Constants for OpenAI Agents SDK wrapper - */ - -// Span Attribute Types -export const GEN_AI_SPAN_KIND_AGENT_KEY = 'agent'; -export const GEN_AI_SPAN_KIND_TOOL_KEY = 'tool'; -export const GEN_AI_SPAN_KIND_CHAIN_KEY = 'chain'; -export const GEN_AI_SPAN_KIND_LLM_KEY = 'chat'; -export const GEN_AI_SPAN_KIND_RETRIEVER_KEY = 'retriever'; -export const GEN_AI_SPAN_KIND_EMBEDDING_KEY = 'embedding'; -export const GEN_AI_SPAN_KIND_RERANKER_KEY = 'reranker'; -export const GEN_AI_SPAN_KIND_GUARDRAIL_KEY = 'guardrail'; -export const GEN_AI_SPAN_KIND_EVALUATOR_KEY = 'evaluator'; -export const GEN_AI_SPAN_KIND_UNKNOWN_KEY = 'unknown'; - -// Message Prefixes -export const GEN_AI_MESSAGE_ROLE = 'message_role'; -export const GEN_AI_MESSAGE_CONTENT = 'message_content'; -export const GEN_AI_MESSAGE_CONTENTS = 'message_contents'; -export const GEN_AI_MESSAGE_CONTENT_TYPE = 'content_type'; -export const GEN_AI_MESSAGE_TOOL_CALLS = 'message_tool_calls'; -export const GEN_AI_MESSAGE_TOOL_CALL_ID = 'message_tool_id'; -export const GEN_AI_MESSAGE_TOOL_CALL_NAME = 'message_tool_name'; -export const GEN_AI_TOOL_JSON_SCHEMA = 'tool_json_schema'; -export const GEN_AI_LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHED_READ = 'llm_token_count_prompt_details_cached_read'; -export const GEN_AI_LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING = 'llm_token_count_completion_details_reasoning'; - -export const GEN_AI_REQUEST_CONTENT_KEY = 'gen_ai.request.content'; -export const GEN_AI_RESPONSE_CONTENT_KEY = 'gen_ai.response.content'; -export const GEN_AI_EXECUTION_PAYLOAD_KEY = 'gen_ai.execution.payload'; diff --git a/packages/agents-a365-observability-extensions-openai/src/OpenAIAgentsTraceInstrumentor.ts b/packages/agents-a365-observability-extensions-openai/src/OpenAIAgentsTraceInstrumentor.ts deleted file mode 100644 index 968a34af..00000000 --- a/packages/agents-a365-observability-extensions-openai/src/OpenAIAgentsTraceInstrumentor.ts +++ /dev/null @@ -1,146 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -/** - * Instrumentor for OpenAI Agents SDK - * Extends OpenTelemetry's InstrumentationBase to provide automatic tracing - */ - -import { trace } from '@opentelemetry/api'; -import { InstrumentationBase, InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; -import { ObservabilityManager } from '@microsoft/agents-a365-observability'; -import { OpenAIAgentsTraceProcessor } from './OpenAIAgentsTraceProcessor'; -import { setTraceProcessors, setTracingDisabled, TracingProcessor } from '@openai/agents'; - -/** - * Configuration options for the OpenAI Agents instrument - */ -export interface OpenAIAgentsInstrumentationConfig extends InstrumentationConfig { - // Override default behavior from parent - enabled?: boolean; - tracerName?: string; - tracerVersion?: string; - /** - * When true, the gen_ai.input.messages attribute containing LLM input messages - * will be suppressed and not attached to spans in InvokeAgent scopes. - * Defaults to false. - */ - suppressInvokeAgentInput?: boolean; -} - -/** - * Instrumentor for OpenAI Agents SDK - * Automatically instruments the @openai/agents package to send traces to OpenTelemetry - */ -export class OpenAIAgentsTraceInstrumentor extends InstrumentationBase { - private processor?: OpenAIAgentsTraceProcessor; - private _hasBeenEnabled = false; - - constructor(config: OpenAIAgentsInstrumentationConfig = {}) { - if (!ObservabilityManager.getInstance()) { - throw new Error( - 'ObservabilityManager is not configured yet. Please configure ObservabilityManager before initializing this instrumentor.' - ); - } - - const configWithDefaults = { - enabled: false, - ...config - }; - - super( - 'agent365-openai-agents-instrumentor', - '1.0.0', - configWithDefaults - ); - } - - /** - * Initialize the instrumentation modules - * This defines which npm packages to instrument - */ - protected init(): InstrumentationModuleDefinition { - return { - name: '@openai/agents', - supportedVersions: ['>=0.1.5'], - files: [], - }; - } - - /** - * Returns the packages this instrumentation depends on - */ - public instrumentationDependencies(): readonly string[] { - return ['@openai/agents >= 0.1.5'] as const; - } - - /** - * Enable instrumentation - * Sets up the trace processor and registers it with the OpenAI Agents SDK - */ - public override enable(): void { - // Prevent double-enabling - if (this._hasBeenEnabled) { - return; - } - this._hasBeenEnabled = true; - - // Enable tracing in the OpenAI Agents SDK (it's disabled by default) - setTracingDisabled(false); - - // Get tracer name and version from config - const tracerName = this._config.tracerName; - const tracerVersion = this._config.tracerVersion; - - // Get the configured tracer using OpenTelemetry API - const agent365Tracer = trace.getTracer(tracerName ?? 'agent365-openai-agents', tracerVersion); - - // Get tracer provider - trace.getTracerProvider(); - - this.processor = new OpenAIAgentsTraceProcessor(agent365Tracer, { - suppressInvokeAgentInput: this._config.suppressInvokeAgentInput ?? false, - }); - - // Register the processor directly using the imported setTraceProcessors function - // This bypasses the OpenTelemetry instrumentation patching mechanism - try { - setTraceProcessors([this.processor as TracingProcessor]); - } catch (_error) { - // Silent failure - processor registration failed - } - - super.enable(); - } - - /** - * Disable instrumentation - * Cleans up the trace processor and unregisters it - */ - public override disable(): void { - if (this.processor) { - this.processor.shutdown(); - this.processor = undefined; - } - - // Reset flag to allow re-enabling - this._hasBeenEnabled = false; - - // Reset trace processors using direct import - try { - setTraceProcessors([]); - } catch (_error) { - // Silent failure - processor cleanup failed - } - - super.disable(); - } - - /** - * Get the trace processor instance - */ - public getProcessor(): OpenAIAgentsTraceProcessor | undefined { - return this.processor; - } -} diff --git a/packages/agents-a365-observability-extensions-openai/src/OpenAIAgentsTraceProcessor.ts b/packages/agents-a365-observability-extensions-openai/src/OpenAIAgentsTraceProcessor.ts deleted file mode 100644 index 8b6b3aa7..00000000 --- a/packages/agents-a365-observability-extensions-openai/src/OpenAIAgentsTraceProcessor.ts +++ /dev/null @@ -1,474 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -/** - * Trace processor for OpenAI Agents SDK - * Converts OpenAI Agents SDK spans to OpenTelemetry spans - */ - -import { context, trace as OtelTrace, Span as OtelSpan, Tracer as OtelTracer, SpanKind } from '@opentelemetry/api'; -import { OpenTelemetryConstants, InferenceOperationType, logger, serializeMessages } from '@microsoft/agents-a365-observability'; -import * as Constants from './Constants'; -import * as Utils from './Utils'; -import { buildStructuredInputMessages, buildStructuredOutputMessages, wrapRawContentAsInputMessages, wrapRawContentAsOutputMessages } from './Utils'; -import { - Span as AgentsSpan, - SpanData, - MCPListToolsSpanData -} from '@openai/agents-core/dist/tracing/spans'; -import { Trace as AgentTrace } from '@openai/agents-core/dist/tracing/traces'; -import { TracingProcessor } from '@openai/agents-core/dist/tracing/processor'; - -/** - * Context token for span context management - */ -type ContextToken = unknown; - -/** - * Processor for OpenAI Agents SDK traces - */ -export class OpenAIAgentsTraceProcessor implements TracingProcessor { - private static readonly MAX_HANDOFFS_IN_FLIGHT = 1000; - private static readonly MAX_SPANS_IN_FLIGHT = 10_000; - private static readonly SERVER_SPAN_TYPES = new Set(['agent']); - private static readonly CLIENT_SPAN_TYPES = new Set(['handoff', 'response', 'generation', 'function', 'mcp_tools']); - - private readonly tracer: OtelTracer; - private readonly suppressInvokeAgentInput: boolean; - private readonly rootSpans: Map = new Map(); - private readonly otelSpans: Map = new Map(); - private readonly tokens: Map = new Map(); - private readonly reverseHandoffsDict: Map = new Map(); - // Track span names for later access (since OTel Span doesn't expose name) - private readonly spanNames: Map = new Map(); - - private readonly keyMappings: Map = new Map([ - ['mcp_tools' + Constants.GEN_AI_RESPONSE_CONTENT_KEY, OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY], - ['mcp_tools' + Constants.GEN_AI_REQUEST_CONTENT_KEY, OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY], - ['function' + Constants.GEN_AI_RESPONSE_CONTENT_KEY, OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY], - ['function' + Constants.GEN_AI_REQUEST_CONTENT_KEY, OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY], - ['generation' + Constants.GEN_AI_RESPONSE_CONTENT_KEY, OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY], - ['generation' + Constants.GEN_AI_REQUEST_CONTENT_KEY, OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY], - ]); - - constructor(tracer: OtelTracer, options?: { suppressInvokeAgentInput?: boolean }) { - this.tracer = tracer; - this.suppressInvokeAgentInput = options?.suppressInvokeAgentInput ?? false; - } - - private getNewKey(spanType: string, key: string): string | null { - return this.keyMappings.get(`${spanType}${key}`) ?? null; - } - - /** - * Called to initialize the processor - */ - public async start(): Promise { - // Initialization logic if needed - } - - /** - * Called when a trace starts - */ - public async onTraceStart(_trace: AgentTrace): Promise { - // Trace start - no root span creation needed - } - - /** - * Called when a trace ends - */ - public async onTraceEnd(trace: AgentTrace): Promise { - const rootSpan = this.rootSpans.get(trace.traceId); - if (rootSpan) { - this.rootSpans.delete(trace.traceId); - rootSpan.end(); - } - } - - /** - * Called when a span starts - */ - public async onSpanStart(span: AgentsSpan): Promise { - const spanId = span.spanId; - const parentId = span.parentId; - const traceId = span.traceId; - const startedAt = span.startedAt; - const spanData = span.spanData; - - if (!startedAt || !spanId || !traceId) { - return; - } - - // Handoff spans are emitted as CLIENT-kind InvokeAgent spans (see processHandoffSpanData). - const spanType = spanData?.type as string | undefined; - - // Skip span types we don't map to schema-defined operations. - if (!spanType || spanType === 'custom' || spanType === 'guardrail') { - return; - } - - if (this.otelSpans.size >= OpenAIAgentsTraceProcessor.MAX_SPANS_IN_FLIGHT) { - logger.warn(`[OpenAIAgentsTraceProcessor] Max spans in flight (${OpenAIAgentsTraceProcessor.MAX_SPANS_IN_FLIGHT}) reached, skipping span`); - return; - } - - const startTime = new Date(startedAt).getTime(); - - // Find parent span - const parentSpan = parentId - ? this.otelSpans.get(parentId) - : this.rootSpans.get(traceId); - - // Create context with parent - const parentContext = parentSpan - ? OtelTrace.setSpan(context.active(), parentSpan) - : context.active(); - - const spanName = Utils.getSpanName(span); - - // SpanKind per OTel client/server semantics + A365 schema: - const kind = OpenAIAgentsTraceProcessor.SERVER_SPAN_TYPES.has(spanType) - ? SpanKind.SERVER - : OpenAIAgentsTraceProcessor.CLIENT_SPAN_TYPES.has(spanType) - ? SpanKind.CLIENT - : undefined; - - // Start OpenTelemetry span - const otelSpan = this.tracer.startSpan( - spanName, - { - kind, - startTime, - attributes: { - [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: Utils.getSpanKind(spanData), - [OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY]: 'openai', - }, - }, - parentContext - ); - - if (!parentSpan) { - this.rootSpans.set(traceId, otelSpan); - } - - // Store span and activate context - this.otelSpans.set(spanId, otelSpan); - this.spanNames.set(otelSpan, spanName); - const newContext = OtelTrace.setSpan(context.active(), otelSpan); - const token = context.with(newContext, () => context.active()); - this.tokens.set(spanId, token); - } - - /** - * Called when a span ends - */ - public async onSpanEnd(span: AgentsSpan): Promise { - const spanId = span.spanId; - const traceId = span.traceId; - const endedAt = span.endedAt; - const spanData = span.spanData; - - if (!spanId || !traceId) { - return; - } - - // Cleanup context token - const token = this.tokens.get(spanId); - if (token) { - this.tokens.delete(spanId); - // Context cleanup is automatic in JS - } - - // Retrieve OpenTelemetry span - const otelSpan = this.otelSpans.get(spanId); - if (!otelSpan) { - return; - } - this.otelSpans.delete(spanId); - this.spanNames.delete(otelSpan); - - // Update span name - otelSpan.updateName(Utils.getSpanName(span)); - - // Process based on span data type - if (spanData) { - this.processSpanData(otelSpan, spanData, traceId); - } - - // Set end time and status - const endTime = endedAt ? new Date(endedAt).getTime() : undefined; - const status = Utils.getSpanStatus(span); - otelSpan.setStatus(status); - if (span.error) { - const errData = (span.error as { data?: Record; name?: string }).data; - const errorType = - (typeof errData?.type === 'string' && errData.type) || - (span.error as { name?: string }).name || - 'error'; - otelSpan.setAttribute(OpenTelemetryConstants.ERROR_TYPE_KEY, errorType); - } - if (endTime) { - otelSpan.end(endTime); - } else { - otelSpan.end(); - } - } - - /** - * Process span data based on type - */ - private processSpanData(otelSpan: OtelSpan, data: SpanData, traceId: string): void { - const type = data.type; - - switch (type) { - case 'response': - this.processResponseSpanData(otelSpan, data); - break; - - case 'generation': - this.processGenerationSpanData(otelSpan, data, traceId); - break; - - case 'function': - this.processFunctionSpanData(otelSpan, data, traceId); - break; - - case 'mcp_tools': - this.processMCPListToolsSpanData(otelSpan, data); - break; - - case 'handoff': - this.processHandoffSpanData(otelSpan, data, traceId); - break; - - case 'agent': - this.processAgentSpanData(otelSpan, data, traceId); - break; - } - } - - /** - * Process response span data - */ - private processResponseSpanData(otelSpan: OtelSpan, data: SpanData): void { - const responseData = data as Record; - // Handle both formats: _response/_input (actual format) and response/input (legacy format) - const responseObj = responseData._response || responseData.response; - const inputObj = responseData._input || responseData.input; - if (responseObj) { - const resp = responseObj as Record; - - // Store the output field as structured OutputMessages (always use versioned envelope) - if (resp.output != null) { - if (Array.isArray(resp.output)) { - const structured = buildStructuredOutputMessages(resp.output as Array>); - otelSpan.setAttribute( - OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, - serializeMessages(structured) - ); - } else { - // String or non-array object — wrap as raw content - const structured = wrapRawContentAsOutputMessages(resp.output); - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, serializeMessages(structured)); - } - } - - // Get attributes but filter out unwanted ones - const attrs = Utils.getAttributesFromResponse(responseObj); - Object.entries(attrs).forEach(([key, value]) => { - if (value !== null && value !== undefined && - key !== Constants.GEN_AI_RESPONSE_CONTENT_KEY) { - otelSpan.setAttribute(key, value as string | number | boolean); - } - }); - - const modelName = attrs[OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY] ?? ''; - otelSpan.updateName(`${InferenceOperationType.CHAT} ${modelName}`); - } - - if (inputObj != null && !this.suppressInvokeAgentInput) { - if (typeof inputObj === 'string') { - try { - const parsed = JSON.parse(inputObj as string); - if (Array.isArray(parsed)) { - const structured = buildStructuredInputMessages(parsed); - otelSpan.setAttribute( - OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, - serializeMessages(structured) - ); - return; - } - } catch { - // If parsing fails, wrap raw string in versioned envelope - } - const wrappedInput = wrapRawContentAsInputMessages(inputObj); - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, serializeMessages(wrappedInput)); - } else if (Array.isArray(inputObj)) { - const structured = buildStructuredInputMessages(inputObj as Array<{ role: string; content: string | unknown[] | unknown }>); - otelSpan.setAttribute( - OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, - serializeMessages(structured) - ); - } - } - } - - /** - * Process generation span data - */ - private processGenerationSpanData(otelSpan: OtelSpan, data: SpanData, traceId: string): void { - const attrs = Utils.getAttributesFromGenerationSpanData(data); - Object.entries(attrs).forEach(([key, value]) => { - const shouldExcludeKey = key === Constants.GEN_AI_EXECUTION_PAYLOAD_KEY; - if (value !== null && value !== undefined && !shouldExcludeKey) { - const newKey = this.getNewKey(data.type, key); - const resolvedKey = newKey || key; - if (resolvedKey !== OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY || !this.suppressInvokeAgentInput) { - otelSpan.setAttribute(resolvedKey, value as string | number | boolean); - } - } - }); - - this.stampCustomParent(otelSpan, traceId); - - // Update span name with model - const modelName = attrs[OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY]; - if (typeof modelName === 'string' && modelName.length > 0) { - otelSpan.updateName(`${InferenceOperationType.CHAT} ${modelName}`); - } - } - - /** - * Process function/tool span data - */ - private processFunctionSpanData(otelSpan: OtelSpan, data: SpanData, traceId: string): void { - const functionData = data as Record; - const attrs = Utils.getAttributesFromFunctionSpanData(data); - Object.entries(attrs).forEach(([key, value]) => { - if (value !== null && value !== undefined) { - const newKey = this.getNewKey(data.type, key); - const resolvedKey = newKey || key; - otelSpan.setAttribute(resolvedKey, value as string | number | boolean); - } - }); - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY, 'function'); - - this.stampCustomParent(otelSpan, traceId); - // Use function name from data instead of span name - otelSpan.updateName(`${OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME} ${functionData.name ?? ''}`); - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME); - } - - /** - * Process MCP list tools span data - */ - private processMCPListToolsSpanData(otelSpan: OtelSpan, data: SpanData): void { - const attrs = Utils.getAttributesFromMCPListToolsSpanData(data); - Object.entries(attrs).forEach(([key, value]) => { - if (value !== null && value !== undefined) { - const newKey = this.getNewKey(data.type, key); - otelSpan.setAttribute(newKey || key, value as string | number | boolean); - } - }); - - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME); - const serverName = (data as MCPListToolsSpanData).server ?? 'unknown'; - const newSpanName = `${OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME} ${serverName}`; - otelSpan.updateName(newSpanName); - if (serverName) { - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_NAME_KEY, serverName); - } - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY, 'extension'); - } - - /** - * Process handoff span data. The handoff span is emitted as a CLIENT-kind - * Invoke Agent span representing the caller invoking the target agent. - * The from→to mapping is also recorded so the downstream agent (SERVER) - * span can back-reference the caller. - */ - private processHandoffSpanData(otelSpan: OtelSpan, data: SpanData, traceId: string): void { - const handoffData = data as Record; - const fromAgent = handoffData.from_agent as string | undefined; - const toAgent = handoffData.to_agent as string | undefined; - - if (toAgent && fromAgent) { - const key = `${toAgent}:${traceId}`; - this.reverseHandoffsDict.set(key, fromAgent); - - // Cap the size - while (this.reverseHandoffsDict.size > OpenAIAgentsTraceProcessor.MAX_HANDOFFS_IN_FLIGHT) { - const firstKey = this.reverseHandoffsDict.keys().next().value; - if (firstKey) { - this.reverseHandoffsDict.delete(firstKey); - } - } - } - - otelSpan.setAttribute( - OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, - OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME - ); - if (toAgent) { - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, toAgent); - otelSpan.updateName(`${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${toAgent}`); - } - if (fromAgent) { - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY, fromAgent); - } - } - - /** - * Process agent span data - */ - private processAgentSpanData(otelSpan: OtelSpan, data: SpanData, traceId: string): void { - const agentData = data as Record; - if (agentData.name) { - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, agentData.name as string); - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME); - - // Link back to the agent that handed off to this one (A2A caller semantics) - const key = `${agentData.name}:${traceId}`; - const parentNode = this.reverseHandoffsDict.get(key); - if (parentNode) { - this.reverseHandoffsDict.delete(key); - otelSpan.setAttribute(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY, parentNode); - } - - // Update span name for agent - otelSpan.updateName(`${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${agentData.name}`); - } - } - - /** - * Stamp custom parent reference - */ - private stampCustomParent(otelSpan: OtelSpan, traceId: string): void { - const root = this.rootSpans.get(traceId); - if (!root) { - return; - } - - const spanContext = root.spanContext(); - const pidHex = `0x${spanContext.spanId}`; - otelSpan.setAttribute('custom.parent.span.id', pidHex); - } - - /** - * Force flush - */ - public async forceFlush(): Promise { - // Implementation depends on tracer provider - } - - /** - * Shutdown - */ - public async shutdown(_timeout?: number): Promise { - this.rootSpans.clear(); - this.otelSpans.clear(); - this.tokens.clear(); - this.reverseHandoffsDict.clear(); - } -} diff --git a/packages/agents-a365-observability-extensions-openai/src/Utils.ts b/packages/agents-a365-observability-extensions-openai/src/Utils.ts deleted file mode 100644 index d90be25b..00000000 --- a/packages/agents-a365-observability-extensions-openai/src/Utils.ts +++ /dev/null @@ -1,481 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -import { SpanStatusCode } from '@opentelemetry/api'; -import { - OpenTelemetryConstants, - serializeMessages, - safeSerializeToJson, - DEFAULT_FINISH_REASON, - MessageRole, - InputMessages, - OutputMessages, - MAX_SPAN_SIZE_BYTES, -} from '@microsoft/agents-a365-observability'; -import type { ChatMessage, OutputMessage, MessagePart } from '@microsoft/agents-a365-observability'; -import * as Constants from './Constants'; -import { Span as AgentsSpan, SpanData } from '@openai/agents-core/dist/tracing/spans'; - -/** - * Locate and normalize usage counts across OpenAI API shapes: - * - Responses API: { input_tokens, output_tokens } - * - Chat Completions: { prompt_tokens, completion_tokens } - * Usage may live directly on the span data, on `.output`, or inside `.output[0]`. - */ -export function extractUsageTokens(data: Record): { - inputTokens?: number; - outputTokens?: number; - cacheReadInputTokens?: number; -} { - const candidates: Array | undefined> = []; - const direct = data.usage as Record | undefined; - candidates.push(direct); - const output = data.output as unknown; - if (output && typeof output === 'object') { - if (Array.isArray(output)) { - const first = output[0]; - if (first && typeof first === 'object') { - candidates.push((first as Record).usage as Record | undefined); - } - } else { - candidates.push((output as Record).usage as Record | undefined); - } - } - for (const usage of candidates) { - if (!usage) continue; - const inputTokens = usage.input_tokens ?? usage.prompt_tokens; - const outputTokens = usage.output_tokens ?? usage.completion_tokens; - const inputTokenDetails = - (usage.input_tokens_details as Record | undefined) ?? - (usage.prompt_tokens_details as Record | undefined); - const cacheReadInputTokens = inputTokenDetails?.cached_tokens; - if (typeof inputTokens === 'number' || typeof outputTokens === 'number' || - typeof cacheReadInputTokens === 'number') { - return { - inputTokens: typeof inputTokens === 'number' ? inputTokens : undefined, - outputTokens: typeof outputTokens === 'number' ? outputTokens : undefined, - cacheReadInputTokens: typeof cacheReadInputTokens === 'number' ? cacheReadInputTokens : undefined, - }; - } - } - return {}; -} - -/** - * Locate the served/returned model identifier on a Chat Completions-style output object: - * - Chat Completions: the completion object itself (or `output[0]`) carries its own `model`, - * which can differ from the requested alias/deployment name on `genData.model`. - * Looks in `.output` or `.output[0]`, mirroring `extractUsageTokens`. - */ -export function extractServedModel(data: Record): string | undefined { - const output = data.output as unknown; - if (output && typeof output === 'object') { - if (Array.isArray(output)) { - const first = output[0]; - if (first && typeof first === 'object' && typeof (first as Record).model === 'string') { - return (first as Record).model as string; - } - } else if (typeof (output as Record).model === 'string') { - return (output as Record).model as string; - } - } - return undefined; -} - -/** - * Safely stringify an object to JSON - * @param obj - The object to stringify - * @returns JSON string representation or string conversion if JSON.stringify fails - */ -export function safeJsonDumps(obj: unknown): string { - try { - return JSON.stringify(obj); - } catch { - return String(obj); - } -} - -/** - * Get span name from OpenAI Agents SDK span - */ -export function getSpanName(span: AgentsSpan): string { - const data = span.spanData; - - // Check if data has a 'name' property (for agent, function, custom spans) - const dataWithName = data as { name?: string }; - if (dataWithName?.name && typeof dataWithName.name === 'string') { - return dataWithName.name; - } - - if (data?.type === 'handoff') { - const handoffData = data as Record; - if (handoffData.to_agent) { - return `handoff to ${handoffData.to_agent}`; - } - } - - return data?.type || 'unknown'; -} - -/** - * Get span kind based on span data type - */ -export function getSpanKind(spanData: SpanData | undefined): string { - if (!spanData?.type) { - return Constants.GEN_AI_SPAN_KIND_CHAIN_KEY; - } - - switch (spanData.type) { - case 'agent': - return Constants.GEN_AI_SPAN_KIND_AGENT_KEY; - case 'function': - return Constants.GEN_AI_SPAN_KIND_TOOL_KEY; - case 'generation': - case 'response': - return Constants.GEN_AI_SPAN_KIND_LLM_KEY; - case 'handoff': - return Constants.GEN_AI_SPAN_KIND_CHAIN_KEY; - case 'custom': - case 'guardrail': - return Constants.GEN_AI_SPAN_KIND_CHAIN_KEY; - default: - return Constants.GEN_AI_SPAN_KIND_CHAIN_KEY; - } -} - -/** - * Get attributes from generation span data - */ -export function getAttributesFromGenerationSpanData(data: SpanData): Record { - const attributes: Record = { - [OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY]: 'openai', - }; - - const genData = data as Record; - - if (typeof genData.model === 'string') { - attributes[OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY] = genData.model; - } - - const servedModel = extractServedModel(genData); - if (servedModel !== undefined) { - attributes[OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY] = servedModel; - } - - if (genData.model_config || genData.modelConfig) { - const config = genData.model_config || genData.modelConfig; - attributes[OpenTelemetryConstants.GEN_AI_EXECUTION_PAYLOAD_KEY] = safeJsonDumps(config); - } - - if (genData.input) { - attributes[Constants.GEN_AI_REQUEST_CONTENT_KEY] = serializeMessages(wrapRawContentAsInputMessages(genData.input)); - } - - if (genData.output) { - attributes[Constants.GEN_AI_RESPONSE_CONTENT_KEY] = serializeMessages(wrapRawContentAsOutputMessages(genData.output)); - } - - const genUsage = extractUsageTokens(genData); - if (genUsage.inputTokens !== undefined) { - attributes[OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY] = genUsage.inputTokens; - } - if (genUsage.outputTokens !== undefined) { - attributes[OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY] = genUsage.outputTokens; - } - if (genUsage.cacheReadInputTokens !== undefined) { - attributes[OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY] = genUsage.cacheReadInputTokens; - } - - return attributes; -} - -/** - * Get attributes from function span data - */ -export function getAttributesFromFunctionSpanData(data: SpanData): Record { - const attributes: Record = {}; - - const funcData = data as Record; - - if (funcData.name) { - attributes['gen_ai.tool.name'] = funcData.name; - } - - if (funcData.input != null) { - attributes[Constants.GEN_AI_REQUEST_CONTENT_KEY] = safeSerializeToJson( - typeof funcData.input === 'object' ? funcData.input as Record : String(funcData.input), - 'arguments' - ); - } - - if (funcData.output !== undefined && funcData.output !== null) { - attributes[Constants.GEN_AI_RESPONSE_CONTENT_KEY] = safeSerializeToJson( - typeof funcData.output === 'object' ? funcData.output as Record : String(funcData.output), - 'result' - ); - } - - return attributes; -} - -/** - * Get attributes from MCP list tools span data - */ -export function getAttributesFromMCPListToolsSpanData(data: SpanData): Record { - const attributes: Record = {}; - - const mcpData = data as Record; - - if (mcpData.result) { - attributes[Constants.GEN_AI_RESPONSE_CONTENT_KEY] = safeJsonDumps(mcpData.result); - } - return attributes; -} - -/** - * Get attributes from response span data - */ -export function getAttributesFromResponse(response: unknown): Record { - const attributes: Record = {}; - - const resp = response as Record; - - if (resp.model) { - attributes[OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY] = resp.model; - } - - const respUsage = extractUsageTokens(resp); - if (respUsage.inputTokens !== undefined) { - attributes[OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY] = respUsage.inputTokens; - } - if (respUsage.outputTokens !== undefined) { - attributes[OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY] = respUsage.outputTokens; - } - if (respUsage.cacheReadInputTokens !== undefined) { - attributes[OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY] = respUsage.cacheReadInputTokens; - } - - return attributes; -} - -/** - * Get span status from OpenAI Agents SDK span - */ -export function getSpanStatus(span: AgentsSpan): { code: SpanStatusCode; message?: string } { - if (span.error) { - const message = span.error.message || span.error.data || 'Unknown error'; - return { - code: SpanStatusCode.ERROR, - message: String(message), - }; - } - - return { code: SpanStatusCode.OK }; -} - -// --------------------------------------------------------------------------- -// Structured message builders (OTEL gen-ai message format) -// --------------------------------------------------------------------------- - -type OpenAIInputMessage = { role: string; content: string | unknown[] | unknown }; -type OpenAIOutputItem = { role?: string; content?: unknown[]; type?: string; text?: string; [key: string]: unknown }; - -/** - * Map an OpenAI role string to a MessageRole value. - */ -function mapOpenAIRole(role: string): MessageRole | string { - switch (role) { - case 'user': - return MessageRole.USER; - case 'assistant': - return MessageRole.ASSISTANT; - case 'system': - return MessageRole.SYSTEM; - case 'tool': - return MessageRole.TOOL; - default: - return role; - } -} - -function getModalityFromMimeType(mimeType: unknown): string { - return String(mimeType ?? 'file').split('/')[0] || 'file'; -} - -function mapGenericBlock(blockType: string | undefined, block: Record): MessagePart { - return { type: blockType ?? 'unknown', content: safeJsonDumps(block) } as MessagePart; -} - -function parseToolCallArguments(args: unknown): Record | undefined { - if (typeof args === 'string') { - try { - return JSON.parse(args) as Record; - } catch { - return { raw: args }; - } - } - - if (args && typeof args === 'object') { - return args as Record; - } - - return undefined; -} - -function getToolCallId(block: Record): string | undefined { - if (block.call_id != null) return String(block.call_id); - if (block.id != null) return String(block.id); - return undefined; -} - -function wrapRawContentAsMessages(raw: unknown, role: MessageRole): InputMessages | OutputMessages { - const content = typeof raw === 'string' ? raw : safeJsonDumps(raw); - return { - messages: [{ role, parts: [{ type: 'text', content }], finish_reason: role === MessageRole.ASSISTANT ? DEFAULT_FINISH_REASON : undefined }], - }; -} - -/** - * Map an OpenAI input content block to a MessagePart. - */ -function mapInputContentBlock(block: Record): MessagePart { - const blockType = block.type as string | undefined; - switch (blockType) { - case 'input_text': - return { type: 'text', content: String(block.text ?? '') }; - case 'input_image': - return { type: 'blob', modality: 'image', ...stripBinaryFields(block) } as MessagePart; - case 'input_file': - return { - type: 'file' as string, - modality: getModalityFromMimeType(block.mime_type), - ...stripBinaryFields(block), - } as MessagePart; - default: - return mapGenericBlock(blockType, block); - } -} - -/** - * Strip large binary fields from a content block for telemetry. - */ -function stripBinaryFields(block: Record): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(block)) { - if (key === 'type') continue; - if (typeof value === 'string' && value.length > MAX_SPAN_SIZE_BYTES) { - result[key] = '[truncated]'; - } else { - result[key] = value; - } - } - return result; -} - -/** - * Map an OpenAI output content block to a MessagePart. - */ -function mapOutputContentBlock(block: Record): MessagePart { - const blockType = block.type as string | undefined; - switch (blockType) { - case 'output_text': - return { type: 'text', content: String(block.text ?? '') }; - case 'refusal': - return { type: 'text', content: String(block.refusal ?? '') }; - case 'tool_call': - case 'function_call': { - const parsedArgs = parseToolCallArguments(block.arguments ?? block.args); - return { - type: 'tool_call', - name: String(block.name ?? block.function ?? ''), - id: getToolCallId(block), - arguments: parsedArgs, - }; - } - case 'reasoning': - return { type: 'reasoning', content: String(block.text ?? block.content ?? '') }; - default: - return mapGenericBlock(blockType, block); - } -} - -/** - * Build structured InputMessages from an OpenAI _input message array. - * Includes all roles (system, user, assistant, tool). - */ -export function buildStructuredInputMessages( - arr: OpenAIInputMessage[] -): InputMessages { - const messages: ChatMessage[] = []; - - for (const msg of arr) { - if (!msg || typeof msg !== 'object') continue; - - const role = mapOpenAIRole(msg.role ?? 'user'); - let parts: MessagePart[]; - - if (typeof msg.content === 'string') { - parts = [{ type: 'text', content: msg.content }]; - } else if (Array.isArray(msg.content)) { - parts = (msg.content as Record[]).map(mapInputContentBlock); - } else { - parts = [{ type: 'text', content: safeJsonDumps(msg.content) }]; - } - - messages.push({ role, parts }); - } - - return { messages }; -} - -/** - * Build structured OutputMessages from an OpenAI response.output array. - */ -export function buildStructuredOutputMessages( - arr: OpenAIOutputItem[] -): OutputMessages { - const messages: OutputMessage[] = []; - - for (const item of arr) { - if (!item || typeof item !== 'object') continue; - - const role = mapOpenAIRole(item.role ?? 'assistant'); - - // Items with a content array (standard response format) - if (Array.isArray(item.content)) { - const parts = (item.content as Record[]).map(mapOutputContentBlock); - messages.push({ role, parts }); - continue; - } - - // Items that are themselves content blocks (e.g., type: 'message' with text) - if (item.type && typeof item.type === 'string') { - const parts = [mapOutputContentBlock(item as Record)]; - messages.push({ role, parts }); - continue; - } - - // Fallback: stringify the item - messages.push({ - role, - parts: [{ type: 'text', content: safeJsonDumps(item) }], - }); - } - - return { messages }; -} - -/** - * Wrap opaque raw content as InputMessages (for generation span data). - */ -export function wrapRawContentAsInputMessages(raw: unknown): InputMessages { - return wrapRawContentAsMessages(raw, MessageRole.USER) as InputMessages; -} - -/** - * Wrap opaque raw content as OutputMessages (for generation span data). - */ -export function wrapRawContentAsOutputMessages(raw: unknown): OutputMessages { - return wrapRawContentAsMessages(raw, MessageRole.ASSISTANT) as OutputMessages; -} diff --git a/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfiguration.ts b/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfiguration.ts deleted file mode 100644 index b59cdb71..00000000 --- a/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfiguration.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { ObservabilityConfiguration } from '@microsoft/agents-a365-observability'; -import { OpenAIObservabilityConfigurationOptions } from './OpenAIObservabilityConfigurationOptions'; - -/** - * Configuration for OpenAI observability extension package. - * Inherits all observability and runtime settings. - * - * ## Why This Class Exists - * - * Although this class currently adds no new settings beyond what ObservabilityConfiguration - * provides, it exists for several important reasons: - * - * 1. **Type Safety**: Allows OpenAI-specific services to declare their dependency on - * `IConfigurationProvider`, making the configuration - * contract explicit and enabling compile-time checking. - * - * 2. **Extension Point**: Provides a clear place to add OpenAI-specific observability settings - * (e.g., trace sampling rates, span attribute limits, custom exporter options) without - * breaking existing code when those needs arise. - * - * 3. **Consistent Pattern**: Maintains symmetry with other extension packages - * (Claude, LangChain tooling extensions), making the SDK easier to understand and navigate. - * - * 4. **Dependency Injection**: Services can be designed to accept this specific - * configuration type, enabling proper IoC patterns and testability. - * - * ## Relationship to OpenAIAgentsInstrumentationConfig - * - * This class is separate from and complementary to `OpenAIAgentsInstrumentationConfig`: - * - * - **OpenAIObservabilityConfiguration**: SDK-wide configuration following the - * configuration provider pattern, supporting function-based overrides and - * environment variable fallbacks. - * - * - **OpenAIAgentsInstrumentationConfig**: OpenTelemetry instrumentation-specific - * configuration passed to `OpenAIAgentsTraceInstrumentor`, following OTel conventions. - * - * @example - * ```typescript - * // Service declares explicit dependency on OpenAI observability configuration - * class OpenAITracingService { - * constructor(private configProvider: IConfigurationProvider) {} - * } - * - * // Future: Add OpenAI-specific settings without breaking changes - * class OpenAIObservabilityConfiguration extends ObservabilityConfiguration { - * get traceSamplingRate(): number { ... } - * } - * ``` - */ -export class OpenAIObservabilityConfiguration extends ObservabilityConfiguration { - constructor(overrides?: OpenAIObservabilityConfigurationOptions) { - super(overrides); - } - - // Inherited: clusterCategory, isDevelopmentEnvironment, observabilityAuthenticationScopes, - // isObservabilityExporterEnabled, observabilityDomainOverride, observabilityLogLevel -} diff --git a/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfigurationOptions.ts b/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfigurationOptions.ts deleted file mode 100644 index 791ddad8..00000000 --- a/packages/agents-a365-observability-extensions-openai/src/configuration/OpenAIObservabilityConfigurationOptions.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { ObservabilityConfigurationOptions } from '@microsoft/agents-a365-observability'; - -/** - * OpenAI observability configuration options - extends observability options. - * All overrides are functions called on each property access. - * - * Currently no additional settings; this type exists for future extensibility. - */ -export type OpenAIObservabilityConfigurationOptions = ObservabilityConfigurationOptions; diff --git a/packages/agents-a365-observability-extensions-openai/src/configuration/index.ts b/packages/agents-a365-observability-extensions-openai/src/configuration/index.ts deleted file mode 100644 index f9bb7105..00000000 --- a/packages/agents-a365-observability-extensions-openai/src/configuration/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; -import { OpenAIObservabilityConfiguration } from './OpenAIObservabilityConfiguration'; - -export * from './OpenAIObservabilityConfigurationOptions'; -export * from './OpenAIObservabilityConfiguration'; - -/** - * Shared default provider for OpenAIObservabilityConfiguration. - */ -export const defaultOpenAIObservabilityConfigurationProvider = - new DefaultConfigurationProvider(() => new OpenAIObservabilityConfiguration()); diff --git a/packages/agents-a365-observability-extensions-openai/src/index.ts b/packages/agents-a365-observability-extensions-openai/src/index.ts deleted file mode 100644 index 77d787ab..00000000 --- a/packages/agents-a365-observability-extensions-openai/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -/** - * Wraps the OpenAI Agents SDK tracer to integrate with Agent 365 Observability. - */ - -export { OpenAIAgentsTraceInstrumentor, OpenAIAgentsInstrumentationConfig } from './OpenAIAgentsTraceInstrumentor'; -export { OpenAIAgentsTraceProcessor } from './OpenAIAgentsTraceProcessor'; -export { - OpenAIObservabilityConfiguration, - OpenAIObservabilityConfigurationOptions, - defaultOpenAIObservabilityConfigurationProvider -} from './configuration'; diff --git a/packages/agents-a365-observability-extensions-openai/tsconfig.cjs.json b/packages/agents-a365-observability-extensions-openai/tsconfig.cjs.json deleted file mode 100644 index a4c9f50b..00000000 --- a/packages/agents-a365-observability-extensions-openai/tsconfig.cjs.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./dist/cjs", - "module": "CommonJS" - } -} diff --git a/packages/agents-a365-observability-extensions-openai/tsconfig.esm.json b/packages/agents-a365-observability-extensions-openai/tsconfig.esm.json deleted file mode 100644 index 2532362d..00000000 --- a/packages/agents-a365-observability-extensions-openai/tsconfig.esm.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./dist/esm", - "module": "ESNext" - } -} diff --git a/packages/agents-a365-observability-extensions-openai/tsconfig.json b/packages/agents-a365-observability-extensions-openai/tsconfig.json deleted file mode 100644 index 1b6fa34b..00000000 --- a/packages/agents-a365-observability-extensions-openai/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2023", "DOM"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "resolveJsonModule": true, - "moduleResolution": "node", - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "types": ["node", "jest"] - }, - "include": ["src/**/*"], - "exclude": [ - "node_modules", - "dist", - "**/*.test.ts", - "**/*.spec.ts" - ] -} diff --git a/packages/agents-a365-observability-hosting/docs/design.md b/packages/agents-a365-observability-hosting/docs/design.md deleted file mode 100644 index d6001dda..00000000 --- a/packages/agents-a365-observability-hosting/docs/design.md +++ /dev/null @@ -1,244 +0,0 @@ -# Observability Hosting - Design Document - -This document describes the architecture and design of the `@microsoft/agents-a365-observability-hosting` package. - -## Overview - -The observability hosting package provides hosting-specific utilities for integrating observability with the Microsoft Agents Hosting SDK. It bridges the gap between `TurnContext` and OpenTelemetry baggage/scope creation. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ TurnContext │ -│ (@microsoft/agents-hosting) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Utility Classes │ -│ │ -│ ┌─────────────────────┐ ┌─────────────────────┐ │ -│ │ TurnContextUtils │ │ BaggageBuilderUtils │ │ -│ │ │ │ │ │ -│ │ Extract baggage │───▶│ Populate builder │ │ -│ │ pairs from context │ │ from context │ │ -│ └─────────────────────┘ └─────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ BaggageBuilder │ -│ (@microsoft/agents-a365-observability) │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## Key Components - -### BaggageBuilderUtils ([BaggageBuilderUtils.ts](../src/utils/BaggageBuilderUtils.ts)) - -Utilities to populate `BaggageBuilder` from a `TurnContext`: - -```typescript -import { BaggageBuilderUtils } from '@microsoft/agents-a365-observability-hosting'; -import { BaggageBuilder } from '@microsoft/agents-a365-observability'; - -const builder = new BaggageBuilder(); - -// Populate all baggage from turn context -BaggageBuilderUtils.fromTurnContext(builder, turnContext); - -// Or populate specific baggage categories -BaggageBuilderUtils.setCallerBaggage(builder, turnContext); -BaggageBuilderUtils.setTargetAgentBaggage(builder, turnContext); -BaggageBuilderUtils.setTenantIdBaggage(builder, turnContext); -BaggageBuilderUtils.setChannelBaggage(builder, turnContext); -BaggageBuilderUtils.setConversationIdBaggage(builder, turnContext); - -// Build and use the scope -const scope = builder.build(); -scope.run(() => { - // All child spans inherit the baggage -}); -``` - -**Methods:** - -| Method | Purpose | -|--------|---------| -| `fromTurnContext(builder, turnContext)` | Populate all supported baggage pairs | -| `setCallerBaggage(builder, turnContext)` | Set caller ID, name, UPN, tenant | -| `setTargetAgentBaggage(builder, turnContext)` | Set target agent ID, name, description | -| `setTenantIdBaggage(builder, turnContext)` | Set tenant ID from recipient or channel data | -| `setChannelBaggage(builder, turnContext)` | Set channel name and subchannel | -| `setConversationIdBaggage(builder, turnContext)` | Set conversation ID and item link | - -### TurnContextUtils ([TurnContextUtils.ts](../src/utils/TurnContextUtils.ts)) - -Low-level utilities to extract OpenTelemetry baggage pairs from `TurnContext`: - -```typescript -import { - getCallerBaggagePairs, - getTargetAgentBaggagePairs, - getTenantIdPair, - getChannelBaggagePairs, - getConversationIdAndItemLinkPairs -} from '@microsoft/agents-a365-observability-hosting'; - -// Extract caller information -const callerPairs = getCallerBaggagePairs(turnContext); -// => [['gen_ai.caller.id', 'aad-object-id'], ['gen_ai.caller.name', 'John'], ...] - -// Extract target agent information -const agentPairs = getTargetAgentBaggagePairs(turnContext); -// => [['gen_ai.agent.id', 'agent-123'], ['gen_ai.agent.name', 'MyAgent'], ...] -``` - -**Functions:** - -| Function | Extracted Keys | -|----------|----------------| -| `getCallerBaggagePairs()` | `gen_ai.caller.id`, `gen_ai.caller.name`, `gen_ai.caller.upn`, `gen_ai.caller.tenant_id`, `gen_ai.agent.blueprint_id` | -| `getTargetAgentBaggagePairs()` | `gen_ai.agent.id`, `gen_ai.agent.name`, `gen_ai.agent.description`, `gen_ai.agent.auid` | -| `getTenantIdPair()` | `tenant_id` | -| `getChannelBaggagePairs()` | `gen_ai.execution.source.name`, `gen_ai.execution.source.description` | -| `getConversationIdAndItemLinkPairs()` | `gen_ai.conversation.id`, `gen_ai.conversation.item_link` | - -### AgenticTokenCacheInstance ([AgenticTokenCache.ts](../src/caching/AgenticTokenCache.ts)) - -Cache app-only OBS tokens independently of the workload's AI Teammate or OBO -authorization. The `TurnContext`/`Authorization` overload was removed in 2.0.0: -TypeScript callers get a compile error, and untyped callers get a one-time error log -and no token instead of a delegated token that S2S rejects. `RefreshObservabilityToken` -throws when acquisition fails; call it from the exporter's `tokenResolver` or wrap it in -try/catch on the request path. - -```typescript -import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-hosting'; - -// acquireAppOnlyObsToken is your app-only token acquisition callback. -// It receives (agentId, tenantId, scopes) and returns the final OBS access token. -await AgenticTokenCacheInstance.RefreshObservabilityToken( - agentId, tenantId, acquireAppOnlyObsToken -); - -const token = AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId); -``` - -For a blueprint-backed agent, acquire a blueprint exchange assertion with -`fmi_path=agentId`, then use it as `client_assertion` in an instance -`client_credentials` request for the OBS `/.default` scope. Do not send the -intermediate assertion, a blueprint token, or a `user_fic`/OBO token to OBS. -The final token's application identity must match `agentId`, its tenant must -match `tenantId`, and its audience must be OBS. An eligible Agent 365-registered -instance can use a roleless app token when service policy permits; an -`Agent365.Observability.OtelWrite` grant is not a universal prerequisite. Entra -identity creation alone does not establish instance registration or service access. -The resolver must validate app-only identity: accept `idtyp=app`, or, when `idtyp` is absent, -either a non-empty `roles` array or a non-empty `oid` equal to `sub`; reject any other `idtyp` -value. It must also reject delegated `scp` tokens, and check audience and lifetime before -returning a token. The cache does not perform token authentication or authorization. -Acquisition failures propagate to the caller and never trigger delegated authentication. - -When migrating, replace only the OBS refresh call, not workload MCP/Graph/OBO -authorization. Configure an OBS resolver in both batch and per-request modes. -It should refresh the app-only cache at export time before returning its token, -so long-running requests do not depend on a token acquired at turn start: - -```typescript -builder.withTokenResolver(async (agentId, tenantId) => { - await AgenticTokenCacheInstance.RefreshObservabilityToken( - agentId, tenantId, acquireAppOnlyObsToken - ); - return AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId); -}); -``` - -`Agent365Exporter` ignores tokens in `runWithExportToken`; those context helpers -remain available for custom exporters, not as an OBS authentication fallback. -An enabled exporter without an explicit resolver fails configuration. -Both modes use the S2S OTLP -route even if the deprecated `useS2SEndpoint` option is false. Missing tokens and -failed acquisition report export failure without sending a request; HTTP -401/403/404 never select an OBO fallback. Check instance registration and service -policy rather than adding OBS permissions automatically. - -## Tenant ID Resolution - -The package extracts tenant ID from multiple sources: - -```typescript -function getTenantIdPair(turnContext: TurnContext): Array<[string, string]> { - // Try recipient first - let tenantId = turnContext.activity?.recipient?.tenantId; - - // Fallback to channelData - if (!tenantId && turnContext.activity?.channelData) { - const channelData = typeof turnContext.activity.channelData === 'string' - ? JSON.parse(turnContext.activity.channelData) - : turnContext.activity.channelData; - - tenantId = channelData?.tenant?.id; - } - - return tenantId ? [[OpenTelemetryConstants.TENANT_ID_KEY, tenantId]] : []; -} -``` - -## Usage Example - -Complete example of setting up observability from a turn context: - -```typescript -import { BaggageBuilderUtils } from '@microsoft/agents-a365-observability-hosting'; -import { BaggageBuilder, InvokeAgentScope } from '@microsoft/agents-a365-observability'; - -// In your agent's message handler -async function onMessage(turnContext: TurnContext, turnState: TurnState) { - // Build baggage from turn context - const builder = new BaggageBuilder(); - BaggageBuilderUtils.fromTurnContext(builder, turnContext); - const baggageScope = builder.build(); - - // Execute agent logic within baggage context - return baggageScope.run(async () => { - // Create agent invocation scope - using scope = InvokeAgentScope.start( - { conversationId: turnContext.activity.conversation?.id, sessionId: turnContext.activity.conversation?.id }, - {}, // InvokeAgentScopeDetails - { - agentId: turnContext.activity.recipient?.agenticAppId, - agentName: turnContext.activity.recipient?.name, - tenantId: turnContext.activity.recipient?.tenantId - } - ); - - // Agent processing... - const response = await processMessage(turnContext.activity.text); - scope.recordResponse(response); - - await turnContext.sendActivity(response); - }); -} -``` - -## File Structure - -``` -src/ -├── index.ts # Public API exports -├── utils/ -│ ├── BaggageBuilderUtils.ts # BaggageBuilder population utilities -│ ├── TurnContextUtils.ts # Low-level extraction functions -│ └── ScopeUtils.ts # Scope creation utilities -└── caching/ - └── AgenticTokenCache.ts # Token caching -``` - -## Dependencies - -- `@microsoft/agents-a365-observability` - BaggageBuilder, OpenTelemetryConstants -- `@microsoft/agents-a365-runtime` - Runtime utilities -- `@microsoft/agents-hosting` - TurnContext type -- `@microsoft/agents-activity` - RoleTypes enum diff --git a/packages/agents-a365-observability-hosting/package.json b/packages/agents-a365-observability-hosting/package.json deleted file mode 100644 index 238e2dc7..00000000 --- a/packages/agents-a365-observability-hosting/package.json +++ /dev/null @@ -1,66 +0,0 @@ -{ -"name": "@microsoft/agents-a365-observability-hosting", - "version": "0.0.0-placeholder", - "description": "Hosting & Observability utilities for Agent365 (Node.js): token caching, baggage builder, hosting registration.", - "keywords": [ - "agent365", - "observability", - "telemetry", - "token", - "cache" - ], - "homepage": "https://github.com/microsoft/Agent365-nodejs", - "bugs": { - "url": "https://github.com/microsoft/Agent365-nodejs/issues" - }, - "repository": { - "type": "git", - "url": "https://github.com/microsoft/Agent365-nodejs.git", - "directory": "packages/agents-a365-observability-hosting" - }, - "license": "MIT", - "author": "Microsoft", - "main": "./dist/cjs/index.js", - "module": "./dist/esm/index.js", - "types": "./dist/cjs/index.d.ts", - "files": [ - "dist", - "README.md", - "CHANGELOG.md" - ], - "scripts": { - "build": "npm run build:cjs && npm run build:esm", - "build:cjs": "npx tsc --project tsconfig.cjs.json", - "build:esm": "npx tsc --project tsconfig.esm.json", - "build:watch": "npx tsc --watch", - "clean": "npx rimraf dist", - "lint": "eslint src", - "lint:fix": "eslint src --fix", - "test": "jest --passWithNoTests", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "pack": "node ../../copyFiles.js . && pnpm pack --pack-destination=../" - }, - "dependencies": { - "@microsoft/agents-a365-observability": "workspace:*", - "@microsoft/agents-a365-runtime": "workspace:*", - "@microsoft/agents-hosting": "catalog:", - "@opentelemetry/api": "catalog:" - }, - "devDependencies": { - "@eslint/js": "catalog:", - "@types/jest": "catalog:", - "@types/node": "catalog:", - "@typescript-eslint/eslint-plugin": "catalog:", - "@typescript-eslint/parser": "catalog:", - "eslint": "catalog:", - "jest": "catalog:", - "rimraf": "catalog:", - "ts-jest": "catalog:", - "typescript": "catalog:", - "typescript-eslint": "catalog:" - }, - "engines": { - "node": ">=18.0.0" - } -} diff --git a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts b/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts deleted file mode 100644 index 5ae1befa..00000000 --- a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts +++ /dev/null @@ -1,271 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { - logger, formatError, defaultObservabilityConfigurationProvider, - type ObservabilityConfiguration, type TokenResolver, -} from '@microsoft/agents-a365-observability'; -import type { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; - -/** Acquires an app-only OBS token; must not perform user_fic or OBO authentication. */ -export type ObservabilityTokenResolver = ( - agentId: string, - tenantId: string, - scopes: readonly string[] -) => ReturnType; - -interface CacheEntry { - scopes: string[]; - token?: string; - expiresOn?: number; - acquiredOn?: number; -} - -/** - * Cache for agentic authentication tokens used by observability services. - * - * For custom configuration, create a new instance with your own configuration provider: - * ```typescript - * const customCache = new AgenticTokenCache(myConfigProvider); - * ``` - * - * For default configuration using environment variables, use the exported - * `AgenticTokenCacheInstance` singleton. - */ -export class AgenticTokenCache { - private readonly _map = new Map(); - private readonly _defaultRefreshSkewMs = 60_000; - private readonly _defaultMaxTokenAgeMs = 3_600_000; - private readonly _maxCacheSize = 10_000; - private readonly _maxExpSeconds = 86_400; // 24 hours - private readonly _keyLocks = new Map>(); - private _removedOverloadLogged = false; - private readonly _configProvider: IConfigurationProvider; - - /** - * Construct an AgenticTokenCache. - * @param configProvider Optional configuration provider. Defaults to defaultObservabilityConfigurationProvider if not specified. - */ - constructor(configProvider?: IConfigurationProvider) { - this._configProvider = configProvider ?? defaultObservabilityConfigurationProvider; - } - - public static makeKey(agentId: string, tenantId: string): string { - return `${agentId}:${tenantId}`; - } - - public getObservabilityToken(agentId: string, tenantId: string): string | null { - const key = AgenticTokenCache.makeKey(agentId, tenantId); - const entry = this._map.get(key); - if (!entry) { - logger.error(`[AgenticTokenCache] No cache entry for ${key}`); - return null; - } - if (!entry.token) { - logger.error(`[AgenticTokenCache] No token cached for ${key}`); - return null; - } - if (this.isExpired(entry)) { - logger.error(`[AgenticTokenCache] Token expired for ${key}`); - return null; - } - return entry.token; - } - - /** - * Refreshes an app-only OBS token independently of the current user's authorization. - * The resolver receives the configured OBS scopes and must acquire a token for - * the exporting agent identity, not its blueprint or the workload's user. - * - * @throws When the resolver fails or returns no token. Call this from the exporter's - * `tokenResolver`, where a failure fails that export, or wrap it in try/catch on the - * request path. - */ - public async RefreshObservabilityToken( - agentId: string, - tenantId: string, - tokenResolver: ObservabilityTokenResolver - ): Promise { - if (typeof tokenResolver !== 'function') { - // Untyped callers can still pass the TurnContext/Authorization overload removed in 2.0.0. - this.logRemovedOverloadOnce(); - return; - } - if (!agentId?.trim() || !tenantId?.trim()) { - throw new Error('[AgenticTokenCache] Agent and tenant IDs are required'); - } - const key = AgenticTokenCache.makeKey(agentId, tenantId); - return this.withKeyLock(key, async () => { - let entry = this._map.get(key); - if (!entry) { - const effectiveScopes = [...this._configProvider.getConfiguration().observabilityAuthenticationScopes]; - if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) { - throw new Error('[AgenticTokenCache] No valid scopes'); - } - entry = { scopes: effectiveScopes }; - if (this._map.size >= this._maxCacheSize) { - const oldest = this._map.keys().next().value; - if (oldest !== undefined) { - this._map.delete(oldest); - } - } - this._map.set(key, entry); - } - if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) { - throw new Error('[AgenticTokenCache] Entry has invalid scopes'); - } - - if (entry.token && !this.isExpired(entry)) { - return; - } - - const maxRetries = 2; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - logger.info(`[AgenticTokenCache] Acquiring app-only token attempt ${attempt + 1}/${maxRetries + 1}`); - try { - const token = await tokenResolver(agentId, tenantId, [...entry.scopes]); - if (!token?.trim()) { - throw new Error('[AgenticTokenCache] App-only token resolver returned no token'); - } - entry.token = token; - entry.acquiredOn = Date.now(); - const exp = this.decodeExp(token); - if (exp) { - entry.expiresOn = exp * 1000; - } else { - entry.expiresOn = undefined; - logger.warn('[AgenticTokenCache] No exp claim, fallback TTL'); - } - logger.info('[AgenticTokenCache] Token cached'); - return; - } catch (e) { - const retriable = this.isRetriableError(e); - if (retriable && attempt < maxRetries) { - logger.warn(`[AgenticTokenCache] Retriable failure attempt ${attempt + 1}`, formatError(e)); - await this.sleep(200 * (attempt + 1)); - continue; - } - logger.error('[AgenticTokenCache] Non-retriable failure', formatError(e)); - entry.token = undefined; - entry.expiresOn = undefined; - entry.acquiredOn = undefined; - throw e; - } - } - }); - } - - public invalidateToken(agentId: string, tenantId: string): void { - const entry = this._map.get(AgenticTokenCache.makeKey(agentId, tenantId)); - if (entry) { - entry.token = undefined; - entry.expiresOn = undefined; - } - } - - public invalidateAll(): void { - this._map.clear(); - } - - private decodeExp(jwt: string): number | undefined { - try { - if (!jwt) { - return undefined; - } - const parts = jwt.split('.'); - if (parts.length < 2) { - return undefined; - } - const payloadSegment = parts[1]; - const padded = payloadSegment + '='.repeat((4 - (payloadSegment.length % 4)) % 4); - const json = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as { exp?: unknown }; - if (typeof json.exp !== 'number') return undefined; - const maxExp = Math.floor(Date.now() / 1000) + this._maxExpSeconds; - return Math.min(json.exp, maxExp); - } catch { - return undefined; - } - } - - private isExpired(entry: CacheEntry): boolean { - const now = Date.now(); - if (entry.expiresOn) { - return now >= (entry.expiresOn - this._defaultRefreshSkewMs); - } - if (entry.acquiredOn) { - return now >= (entry.acquiredOn + this._defaultMaxTokenAgeMs); - } - return true; - } - - private isRetriableError(err: unknown): boolean { - const e = err as { code?: string; status?: number; message?: string } | undefined; - if (!e) { - return false; - } - const msg = (e.message || '').toLowerCase(); - if (msg.includes('timeout') || msg.includes('econnreset') || msg.includes('network')) { - return true; - } - if (typeof e.status === 'number') { - if (e.status === 408 || e.status === 429) { - return true; - } - if (e.status >= 500 && e.status < 600) { - return true; - } - } - return false; - } - - private logRemovedOverloadOnce(): void { - if (this._removedOverloadLogged) { - return; - } - this._removedOverloadLogged = true; - logger.error('[AgenticTokenCache] RefreshObservabilityToken(agentId, tenantId, turnContext, authorization, ...) was removed in 2.0.0 and does nothing; S2S OBS needs an app-only token. Call RefreshObservabilityToken(agentId, tenantId, tokenResolver) instead.'); - } - - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - private async withKeyLock(key: string, fn: () => Promise): Promise { - const previous = this._keyLocks.get(key); - if (previous) { - try { - await previous; - } catch (err) { - logger.warn(`[AgenticTokenCache] previous promise for ${key} rejected:`, formatError(err)); - } - } - const currentPromise: Promise = fn().finally(() => { - if (this._keyLocks.get(key) === currentPromise) { - this._keyLocks.delete(key); - } - }); - this._keyLocks.set(key, currentPromise); - return currentPromise; - } -} - -/** - * Default singleton instance of AgenticTokenCache using the default configuration provider. - * - * This instance uses `defaultObservabilityConfigurationProvider` which reads from - * environment variables. It is suitable for: - * - Single-tenant deployments - * - Multi-tenant deployments using dynamic override functions in the configuration - * - * **For custom configuration:** Create a new `AgenticTokenCache` instance with your - * own `IConfigurationProvider`: - * ```typescript - * import { AgenticTokenCache } from '@microsoft/agents-a365-observability-hosting'; - * - * const customCache = new AgenticTokenCache(myCustomConfigProvider); - * ``` - */ -export const AgenticTokenCacheInstance = new AgenticTokenCache(); -export default AgenticTokenCacheInstance; diff --git a/packages/agents-a365-observability-hosting/src/index.ts b/packages/agents-a365-observability-hosting/src/index.ts deleted file mode 100644 index 76943828..00000000 --- a/packages/agents-a365-observability-hosting/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -export * from './utils/BaggageBuilderUtils'; -export * from './utils/ScopeUtils'; -export * from './utils/TurnContextUtils'; -export { AgenticTokenCache, AgenticTokenCacheInstance } from './caching/AgenticTokenCache'; -export type { ObservabilityTokenResolver } from './caching/AgenticTokenCache'; -export { BaggageMiddleware } from './middleware/BaggageMiddleware'; -export { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY, A365_AUTH_TOKEN_KEY } from './middleware/OutputLoggingMiddleware'; -export { ObservabilityHostingManager } from './middleware/ObservabilityHostingManager'; -export type { ObservabilityHostingOptions } from './middleware/ObservabilityHostingManager'; diff --git a/packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware.ts deleted file mode 100644 index 053427f1..00000000 --- a/packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { TurnContext, Middleware } from '@microsoft/agents-hosting'; -import { ActivityTypes, ActivityEventNames } from '@microsoft/agents-activity'; -import { BaggageBuilder } from '@microsoft/agents-a365-observability'; -import { - getCallerBaggagePairs, - getTargetAgentBaggagePairs, - getTenantIdPair, - getChannelBaggagePairs, - getConversationIdAndItemLinkPairs, -} from '../utils/TurnContextUtils'; - -/** - * Middleware that propagates OpenTelemetry baggage context derived from TurnContext. - * Async replies (ContinueConversation) are passed through without baggage setup. - */ -export class BaggageMiddleware implements Middleware { - - async onTurn(context: TurnContext, next: () => Promise): Promise { - const isAsyncReply = - context.activity?.type === ActivityTypes.Event && - context.activity?.name === ActivityEventNames.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/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts b/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts deleted file mode 100644 index 5d9eeb32..00000000 --- a/packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { Middleware } from '@microsoft/agents-hosting'; -import { logger } from '@microsoft/agents-a365-observability'; -import { BaggageMiddleware } from './BaggageMiddleware'; -import { OutputLoggingMiddleware } from './OutputLoggingMiddleware'; - -/** - * 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.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.info('[ObservabilityHostingManager] BaggageMiddleware registered.'); - } - if (enableOutputLogging) { - adapter.use(new OutputLoggingMiddleware()); - logger.info('[ObservabilityHostingManager] OutputLoggingMiddleware registered.'); - } - - logger.info(`[ObservabilityHostingManager] Configured. Baggage: ${enableBaggage}, OutputLogging: ${enableOutputLogging}.`); - this._configured = true; - } -} diff --git a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts b/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts deleted file mode 100644 index 2b0a525d..00000000 --- a/packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { TurnContext, Middleware, SendActivitiesHandler } from '@microsoft/agents-hosting'; -import { - OutputScope, - AgentDetails, - UserDetails, - ParentSpanRef, - Request, - SpanDetails, - logger, - isPerRequestExportEnabled, -} from '@microsoft/agents-a365-observability'; -import { ScopeUtils } from '../utils/ScopeUtils'; -import { AgenticTokenCacheInstance } from '../caching/AgenticTokenCache'; - -/** - * 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 Middleware { - - async onTurn(context: TurnContext, next: () => Promise): Promise { - const authToken = this.resolveAuthToken(context); - 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, - }; - - context.onSendActivities( - this._createSendHandler(context, agentDetails, userDetails, request) - ); - - await next(); - } - - /** - * Resolve the auth token for agent identity resolution. - * When per-request export is enabled, reads from turnState. - * Otherwise, reads from the cached observability token. - */ - private resolveAuthToken(context: TurnContext): string { - if (isPerRequestExportEnabled()) { - return context.turnState.get(A365_AUTH_TOKEN_KEY) as string ?? ''; - } - const agentId = context.activity?.getAgenticInstanceId?.() ?? ''; - const tenantId = context.activity?.getAgenticTenantId?.() ?? ''; - if (agentId && tenantId) { - return AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId) ?? ''; - } - return ''; - } - - /** - * Creates a send handler that wraps outgoing messages in OutputScope spans. - * Reads parent span ref lazily so the agent handler can set it during `next()`. - */ - private _createSendHandler( - turnContext: TurnContext, - agentDetails: AgentDetails, - userDetails?: UserDetails, - request?: Request, - ): SendActivitiesHandler { - return async (_ctx, activities, sendNext) => { - const messages = activities - .filter((a) => a.type === 'message' && a.text) - .map((a) => a.text!); - - if (messages.length === 0) { - return await sendNext(); - } - - const parentSpanRef: ParentSpanRef | undefined = turnContext.turnState.get(A365_PARENT_SPAN_KEY); - if (!parentSpanRef) { - logger.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/packages/agents-a365-observability-hosting/src/utils/BaggageBuilderUtils.ts b/packages/agents-a365-observability-hosting/src/utils/BaggageBuilderUtils.ts deleted file mode 100644 index ec65e082..00000000 --- a/packages/agents-a365-observability-hosting/src/utils/BaggageBuilderUtils.ts +++ /dev/null @@ -1,98 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { TurnContext } from '@microsoft/agents-hosting'; -import { - getCallerBaggagePairs, - getTargetAgentBaggagePairs, - getTenantIdPair, - getChannelBaggagePairs, - getConversationIdAndItemLinkPairs -} from './TurnContextUtils'; -import { BaggageBuilder } from '@microsoft/agents-a365-observability'; - - - -/** - * Utilities to populate BaggageBuilder from a TurnContext. - */ -export class BaggageBuilderUtils { - /** - * Populate all supported baggage pairs from the provided TurnContext. - * @param builder The BaggageBuilder instance to populate. - * @param turnContext The TurnContext containing activity information. - * @returns The updated BaggageBuilder instance. - */ - static fromTurnContext(builder: BaggageBuilder, turnContext: TurnContext): 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; - } - - /** - * Sets the caller-related baggage values from the TurnContext. - * @param builder The BaggageBuilder instance. - * @param turnContext The TurnContext containing activity information. - * @returns The updated BaggageBuilder instance. - */ - static setCallerBaggage(builder: BaggageBuilder, turnContext: TurnContext): BaggageBuilder { - builder.setPairs(getCallerBaggagePairs(turnContext)); - return builder; - } - - - /** - * Sets the target agent-related baggage values from the TurnContext. - * @param builder The BaggageBuilder instance. - * @param turnContext The TurnContext containing activity information. - * @returns The updated BaggageBuilder instance. - */ - static setTargetAgentBaggage(builder: BaggageBuilder, turnContext: TurnContext): BaggageBuilder { - builder.setPairs(getTargetAgentBaggagePairs(turnContext)); - return builder; - } - - - /** - * Sets the tenant ID baggage value, extracting from ChannelData if necessary. - * @param builder The BaggageBuilder instance. - * @param turnContext The TurnContext containing activity information. - * @returns The updated BaggageBuilder instance. - */ - static setTenantIdBaggage(builder: BaggageBuilder, turnContext: TurnContext): BaggageBuilder { - builder.setPairs(getTenantIdPair(turnContext)); - return builder; - } - - - /** - * Sets the channel baggage values from the TurnContext. - * @param builder The BaggageBuilder instance. - * @param turnContext The TurnContext containing activity information. - * @returns The updated BaggageBuilder instance. - */ - static setChannelBaggage(builder: BaggageBuilder, turnContext: TurnContext): BaggageBuilder { - builder.setPairs(getChannelBaggagePairs(turnContext)); - return builder; - } - - - /** - * Sets the conversation ID and item link baggage values from the TurnContext. - * @param builder The BaggageBuilder instance. - * @param turnContext The TurnContext containing activity information. - * @returns The updated BaggageBuilder instance. - */ - static setConversationIdBaggage(builder: BaggageBuilder, turnContext: TurnContext): BaggageBuilder { - builder.setPairs(getConversationIdAndItemLinkPairs(turnContext)); - return builder; - } -} diff --git a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts b/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts deleted file mode 100644 index faa3068d..00000000 --- a/packages/agents-a365-observability-hosting/src/utils/ScopeUtils.ts +++ /dev/null @@ -1,285 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { TurnContext } from '@microsoft/agents-hosting'; -import { SpanKind, TimeInput } from '@opentelemetry/api'; -import { - InvokeAgentScope, - InferenceScope, - ExecuteToolScope, - AgentDetails, - UserDetails, - CallerDetails, - InferenceDetails, - ToolCallDetails, - Request, - SpanDetails, - InvokeAgentScopeDetails, -} from '@microsoft/agents-a365-observability'; -import { resolveEmbodiedAgentIds } from './TurnContextUtils'; - -/** - * Unified utilities to populate scope tags from a TurnContext. - * Provides common tag population and scope-specific helpers. - */ -export class ScopeUtils { - - - private static setInputMessageTags( - scope: InvokeAgentScope | InferenceScope, - turnContext: TurnContext, - ): InvokeAgentScope | InferenceScope { - if (turnContext?.activity?.text) { - scope.recordInputMessages([turnContext.activity.text]); - } - return scope; - } - - // ---------------------- - // Context-derived helpers - // ---------------------- - /** - * Derive target agent details from the activity recipient. - * Uses {@link resolveEmbodiedAgentIds} to resolve the agent ID and blueprint ID, which are only - * set for embodied (agentic) agents — see that function for the rationale. - * @param turnContext Activity context - * @param authToken Auth token for resolving agent identity from token claims. - * @returns Agent details built from recipient properties; otherwise undefined. - */ - public static deriveAgentDetails(turnContext: TurnContext, 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. - * @param turnContext Activity context - * @returns Agent details built from caller (from) properties; otherwise undefined. - */ - public static deriveCallerAgent(turnContext: TurnContext): 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. - * @param turnContext Activity context - * @returns User details when available; otherwise undefined. - */ - public static deriveCallerDetails(turnContext: TurnContext): 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. - * @param turnContext Activity context - * @returns Conversation id when present; otherwise undefined. - */ - public static deriveConversationId(turnContext: TurnContext): string | undefined { - return turnContext?.activity?.conversation?.id; - } - - /** - * Derive channel (name and description) from the TurnContext. - * @param turnContext Activity context - * @returns Object with optional name and description fields. - */ - public static deriveChannelObject(turnContext: TurnContext): { name?: string; description?: string } { - return { - name: turnContext?.activity?.channelId, - description: turnContext?.activity?.channelIdSubChannel as string | undefined - }; - } - - /** - * Create an `InferenceScope` using `details` and values derived from the provided `TurnContext`. - * Derives `conversationId` and `channel` (name/description) from context. - * Also records input messages from the context if present. - * @param details The inference call details (model, provider, tokens, etc.). - * @param turnContext The current activity context to derive scope parameters from. - * @param authToken Auth token for resolving agent identity from token claims. - * @param startTime Optional explicit start time (ms epoch, Date, or HrTime). - * @param endTime Optional explicit end time (ms epoch, Date, or HrTime). - * @returns A started `InferenceScope` enriched with context-derived parameters. - */ - static populateInferenceScopeFromTurnContext( - details: InferenceDetails, - turnContext: TurnContext, - 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` using `details` and values derived from the provided `TurnContext`. - * Builds a separate `Request` with `conversationId` and `channel` from context. - * Merges agent identity from context into `details` via `buildInvokeAgentDetailsCore`. - * Derives `callerAgentDetails` (from caller) and `userDetails` (human caller). - * Also records input messages from the context if present. - * @param details The agent details to be augmented with context-derived identity. - * @param turnContext The current activity context to derive scope parameters from. - * @param authToken Auth token for resolving agent identity from token claims. - * @param startTime Optional explicit start time (ms epoch, Date, or HrTime). - * @param endTime Optional explicit end time (ms epoch, Date, or HrTime). - * @param spanKind Optional span kind override. Defaults to `SpanKind.CLIENT`. - * @returns A started `InvokeAgentScope` enriched with context-derived parameters. - */ - static populateInvokeAgentScopeFromTurnContext( - details: AgentDetails, - scopeDetails: InvokeAgentScopeDetails, - turnContext: TurnContext, - 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); - - // Merge agent identity from TurnContext into details.details - const agentDetails = ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); - - // Build the request with channel and conversationId from context - const hasChannel = channel.name !== undefined || channel.description !== undefined; - const request: Request = { - conversationId, - ...(hasChannel ? { channel: { name: channel.name, description: channel.description } } : {}), - }; - - // Build caller info with both human caller and caller agent details - 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 agent info from the TurnContext. - * @param details Base agent details to augment - * @param turnContext Activity context - * @param authToken Auth token for resolving agent identity from token claims. - * @returns Merged AgentDetails with context-derived identity. - */ - public static buildInvokeAgentDetails(details: AgentDetails, turnContext: TurnContext, authToken: string): AgentDetails { - return ScopeUtils.buildInvokeAgentDetailsCore(details, turnContext, authToken); - } - - private static buildInvokeAgentDetailsCore(details: AgentDetails, turnContext: TurnContext, authToken: string): AgentDetails { - const derivedAgentDetails = ScopeUtils.deriveAgentDetails(turnContext, authToken); - - // Merge derived agent identity into details - const mergedAgent: AgentDetails = { - ...details, - ...(derivedAgentDetails ?? {}), - }; - - return mergedAgent; - } - - /** - * Create an `ExecuteToolScope` using `details` and values derived from the provided `TurnContext`. - * Derives `conversationId` and `channel` (name/link) from context. - * @param details The tool call details (name, type, args, call id, etc.). - * @param turnContext The current activity context to derive scope parameters from. - * @param authToken Auth token for resolving agent identity from token claims. - * @param startTime Optional explicit start time (ms epoch, Date, or HrTime). Useful when recording a - * tool call after execution has already completed. - * @param endTime Optional explicit end time (ms epoch, Date, or HrTime). - * @param spanKind Optional span kind override. Defaults to `SpanKind.INTERNAL`. - * @returns A started `ExecuteToolScope` enriched with context-derived parameters. - */ - static populateExecuteToolScopeFromTurnContext( - details: ToolCallDetails, - turnContext: TurnContext, - 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; - - const scope = ExecuteToolScope.start(request, details, agent, caller, spanDetailsObj); - return scope; - } - -} diff --git a/packages/agents-a365-observability-hosting/src/utils/TurnContextUtils.ts b/packages/agents-a365-observability-hosting/src/utils/TurnContextUtils.ts deleted file mode 100644 index 0710034d..00000000 --- a/packages/agents-a365-observability-hosting/src/utils/TurnContextUtils.ts +++ /dev/null @@ -1,151 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { TurnContext } from '@microsoft/agents-hosting'; -import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability'; -import { Utility as RuntimeUtility } from '@microsoft/agents-a365-runtime'; - -/** - * TurnContext utility methods. - */ - -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. - * @param turnContext The current TurnContext (activity context) - * @returns Array of [key, value] pairs for caller identity - */ -export function getCallerBaggagePairs(turnContext: TurnContext): Array<[string, string]> { - if (!turnContext|| !turnContext.activity?.from) { - return []; - } - const from = turnContext.activity.from; - - const upn = from.agenticUserId; - const pairs: Array<[string, string | undefined]> = [ - [OpenTelemetryConstants.USER_ID_KEY, from.aadObjectId], - [OpenTelemetryConstants.USER_NAME_KEY, from.name], - [OpenTelemetryConstants.USER_EMAIL_KEY, upn], - [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 only. - * For the non-embodied agent case, we cannot reliably determine whether the token contains an app ID, - * or whether the app ID present in the token claims actually corresponds to this agent. Therefore, - * we only set agentId and agentBlueprintId for embodied (agentic) agents. - * @param turnContext Activity context - * @param authToken Auth token for resolving blueprint ID from token claims. - * @returns Object with agentId and agentBlueprintId, both undefined for non-embodied agents. - */ -export function resolveEmbodiedAgentIds(turnContext: TurnContext, authToken: string): { agentId: string | undefined; agentBlueprintId: string | undefined } { - const isAgentic = turnContext?.activity?.isAgenticRequest?.(); - const rawAgentId = isAgentic ? turnContext.activity.getAgenticInstanceId?.() : undefined; - const rawBlueprintId = isAgentic ? RuntimeUtility.getAgentIdFromToken(authToken) : undefined; - return { - agentId: rawAgentId || undefined, - agentBlueprintId: rawBlueprintId || undefined, - }; -} - -/** - * Extracts agent/recipient-related OpenTelemetry baggage pairs from the TurnContext. - * @param turnContext The current TurnContext (activity context) - * @param authToken Optional auth token for resolving agent blueprint ID from token claims. - * @returns Array of [key, value] pairs for agent identity and description - */ -export function getTargetAgentBaggagePairs(turnContext: TurnContext, authToken?: string): Array<[string, string]> { - if (!turnContext || !turnContext.activity?.recipient) { - return []; - } - const recipient = turnContext.activity.recipient; - const { agentId } = authToken ? resolveEmbodiedAgentIds(turnContext, authToken) : { agentId: turnContext.activity?.isAgenticRequest?.() ? turnContext.activity.getAgenticInstanceId?.() : undefined }; - const agentName = recipient.name; - const aadObjectId = recipient.aadObjectId; - const agentDescription = recipient.role; - const pairs: Array<[string, string | undefined]> = [ - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, agentId], - [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, agentName], - [OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, agentDescription], - [OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, aadObjectId], - ]; - return normalizePairs(pairs); -} - -/** - * Extracts the tenant ID baggage key-value pair using the Activity's getAgenticTenantId() helper. - * @param turnContext The current TurnContext (activity context) - * @returns Array of [key, value] for tenant ID - */ -export function getTenantIdPair(turnContext: TurnContext): Array<[string, string]> { - const tenantId = turnContext.activity?.getAgenticTenantId?.(); - return tenantId ? [[OpenTelemetryConstants.TENANT_ID_KEY, tenantId]] : []; -} - -/** - * Extracts channel baggage pairs from the TurnContext. - * @param turnContext The current TurnContext (activity context) - * @returns Array of [key, value] pairs for channel name and subchannel description - */ -export function getChannelBaggagePairs(turnContext: TurnContext): Array<[string, string]> { - if (!turnContext) { - return []; - } - - let subChannel = turnContext.activity?.channelIdSubChannel as string | undefined; - - // Try to get subChannel from productContext in channelData if subChannel is not set or empty - if ((!subChannel || subChannel.trim() === '') && turnContext.activity?.channelData) { - try { - const channelData = turnContext.activity.channelData; - let channelDataObj: Record | undefined; - - // Convert channelData to object if it's a string - if (typeof channelData === 'string') { - channelDataObj = JSON.parse(channelData); - } else if (typeof channelData === 'object') { - channelDataObj = channelData as Record; - } - - // Extract productContext if available - if (channelDataObj && typeof channelDataObj.productContext === 'string') { - subChannel = channelDataObj.productContext; - } - } catch { - // Silently ignore any parsing errors - } - } - - const pairs: Array<[string, string | undefined]> = [ - [OpenTelemetryConstants.CHANNEL_NAME_KEY, turnContext.activity?.channelId], - [OpenTelemetryConstants.CHANNEL_LINK_KEY, subChannel] - ]; - return normalizePairs(pairs); -} - -/** - * Extracts conversation ID and item link baggage key-value pairs from the provided turn context. - * @param turnContext The current TurnContext (activity context) - * @returns Array of [key, value] pairs for conversation ID and item link - */ -export function getConversationIdAndItemLinkPairs(turnContext: TurnContext): 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); -} diff --git a/packages/agents-a365-observability-hosting/tsconfig.cjs.json b/packages/agents-a365-observability-hosting/tsconfig.cjs.json deleted file mode 100644 index 44b90283..00000000 --- a/packages/agents-a365-observability-hosting/tsconfig.cjs.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "commonjs", - "outDir": "./dist/cjs" - } -} diff --git a/packages/agents-a365-observability-hosting/tsconfig.esm.json b/packages/agents-a365-observability-hosting/tsconfig.esm.json deleted file mode 100644 index 0ebcdc1a..00000000 --- a/packages/agents-a365-observability-hosting/tsconfig.esm.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "esnext", - "outDir": "./dist/esm" - } -} diff --git a/packages/agents-a365-observability-hosting/tsconfig.json b/packages/agents-a365-observability-hosting/tsconfig.json deleted file mode 100644 index 1584f54a..00000000 --- a/packages/agents-a365-observability-hosting/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2023", "DOM"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "resolveJsonModule": true, - "moduleResolution": "node", - "types": ["node", "jest"] - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.spec.ts"] -} diff --git a/packages/agents-a365-observability/.gitignore b/packages/agents-a365-observability/.gitignore deleted file mode 100644 index 5d015ab0..00000000 --- a/packages/agents-a365-observability/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# The file "src/version.ts" is generated by a prebuild script and should not be committed. -src/version.ts diff --git a/packages/agents-a365-observability/README.md b/packages/agents-a365-observability/README.md deleted file mode 100644 index c6a9af5f..00000000 --- a/packages/agents-a365-observability/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# @microsoft/agents-a365-observability - -[![npm](https://img.shields.io/npm/v/@microsoft/agents-a365-observability?label=npm&logo=npm)](https://www.npmjs.com/package/@microsoft/agents-a365-observability) -[![npm Downloads](https://img.shields.io/npm/dm/@microsoft/agents-a365-observability?label=Downloads&logo=npm)](https://www.npmjs.com/package/@microsoft/agents-a365-observability) - -OpenTelemetry-based observability and tracing for Microsoft Agents A365 applications. This package provides comprehensive monitoring capabilities for agent invocations, tool executions, and AI model inference calls with seamless Azure Monitor integration. - -## Installation - -```bash -npm install @microsoft/agents-a365-observability -``` - -## Usage - -For detailed usage examples and implementation guidance, see the [Microsoft Agent 365 Observability Documentation](https://learn.microsoft.com/microsoft-agent-365/developer/observability?tabs=nodejs). - -### OBS endpoint - -All exports use `/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1`, -including batch and per-request exports from AI Teammate and OBO workloads. The exporter -never falls back to `/observability`. The `useS2SEndpoint` option is deprecated and ignored, -including when set to `false`; domain overrides change the host, not this route. - -Endpoint selection does not acquire or convert tokens. Supply an **app-only** OBS -resolver for the exporting tenant and agent identity. Eligible Agent 365-registered -instances can use roleless app tokens when service policy permits; an -`Agent365.Observability.OtelWrite` grant is not a universal prerequisite. -The S2S service rejects delegated (`scp`) tokens, including -AI Teammate user tokens. Keep workload authentication -(such as OBO for MCP or Microsoft Graph) separate from OBS authentication. An authorization -failure is not a reason to retry telemetry on the OBO route. - -When using the hosting token cache, call -`RefreshObservabilityToken(agentId, tenantId, appOnlyTokenResolver)`. It throws when the -resolver fails or returns no token, so call it from your exporter `tokenResolver` or wrap -it in try/catch on the request path. The `TurnContext`/`Authorization` overload was removed -in 2.0.0; JavaScript callers that still pass it get a one-time error log and no token. -S2S ingestion may remove unverified user attribution; routing a workload through S2S -does not establish that its caller identity is trusted. - -### Migrating per-request authentication - -Batch and per-request exports both call the configured `tokenResolver` with the -exporting agent and tenant IDs. Configure it with `withTokenResolver(...)` or -`exporterOptions.tokenResolver`; both also work through `ObservabilityManager.start(options)`, -and `withTokenResolver`/`tokenResolver` takes precedence. -The callback must acquire or refresh an app-only OBS token independently of -workload authentication. - -```typescript -import { ObservabilityManager, type TokenResolver } from '@microsoft/agents-a365-observability'; - -function startObservability(resolveAppOnlyObsToken: TokenResolver): void { - ObservabilityManager.configure(builder => { - builder.withService('my-agent').withTokenResolver(resolveAppOnlyObsToken); - }).start(); -} -``` - -Enabling `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` changes span buffering, -not credential selection. `runWithExportToken`, `updateExportToken`, and -`getExportToken` remain available for custom export integrations, but -`Agent365Exporter` never uses their context token, even when it looks app-only. -Existing callers must provide the OBS resolver instead of relying on a workload -token in context. - -**Resolvers must cache.** The exporter invokes the resolver on every export -batch, and once per identity group when spans partition across tenants or -agents. In per-request mode that is roughly one call per request. Resolvers -should cache the acquired app-only token and refresh only as it approaches -expiry. `AgenticTokenCache` in `@microsoft/agents-a365-observability-hosting` -implements this pattern; the sample `observability-token-service.ts` files show -a minimal single-identity variant. - -An enabled Agent 365 exporter without a resolver fails configuration. A resolver -failure or empty token fails export without an HTTP request or delegated fallback. -Console-only configuration does not require an OBS resolver. - -## Support - -For issues, questions, or feedback: - -- File issues in the [GitHub Issues](https://github.com/microsoft/Agent365-nodejs/issues) section -- See the [main documentation](../../README.md) for more information - -## Trademarks - -*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* - -## License - -Copyright (c) Microsoft Corporation. All rights reserved. - -Licensed under the MIT License - see the [LICENSE](../../LICENSE.md) file for details diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md deleted file mode 100644 index 7e4a6c04..00000000 --- a/packages/agents-a365-observability/docs/design.md +++ /dev/null @@ -1,632 +0,0 @@ -# Observability - Design Document - -This document describes the architecture and design of the `@microsoft/agents-a365-observability` package. - -## Overview - -The observability package provides OpenTelemetry-based distributed tracing infrastructure for AI agent applications. It enables comprehensive observability by tracing agent invocations, LLM inference calls, and tool executions. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Public API │ -│ ObservabilityManager | Builder | Scopes | BaggageBuilder │ -│ ObservabilityConfiguration | PerRequestSpanProcessorConfiguration│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ ObservabilityBuilder │ -│ (Fluent configuration API) │ -│ - Service name/version │ -│ - Token resolver │ -│ - Cluster category │ -│ - Exporter options │ -└─────────────────────────────────────────────────────────────────┘ - │ - ┌──────────────────┼──────────────────┐ - ▼ ▼ ▼ -┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ -│ SpanProcessor │ │ BatchSpanProcessor│ │ Agent365Exporter │ -│ (Baggage to │ │ (OTEL SDK) │ │ (HTTP export) │ -│ attributes) │ │ │ │ │ -└──────────────────┘ └──────────────────┘ └──────────────────┘ -``` - -## Key Components - -### ObservabilityManager ([ObservabilityManager.ts](../src/ObservabilityManager.ts)) - -Main entry point using singleton pattern: - -```typescript -import { ObservabilityManager } from '@microsoft/agents-a365-observability'; - -// Option 1: Simple start with options -ObservabilityManager.start({ - serviceName: 'my-agent', - serviceVersion: '1.0.0', - tokenResolver: async (agentId, tenantId) => getAppOnlyObsToken(agentId, tenantId), - clusterCategory: 'prod' -}); - -// Option 2: Configure with callback -ObservabilityManager.configure((builder) => { - builder - .withService('my-agent', '1.0.0') - .withTokenResolver(tokenResolver) - .withClusterCategory('prod'); -}).start(); - -// Get current instance -const instance = ObservabilityManager.getInstance(); - -// Shutdown -await ObservabilityManager.shutdown(); -``` - -The configured app-only OBS resolver is required in both batch and per-request -modes. The builder merges resolver options consistently, with `withTokenResolver` -taking precedence over `exporterOptions.tokenResolver`. `ObservabilityManager.start(options)` -forwards both `tokenResolver` and `exporterOptions` to the builder. Request context is retained -for tracing, but its token is not consumed by `Agent365Exporter`. See the -[per-request migration guide](../README.md#migrating-per-request-authentication). - -### ObservabilityBuilder ([ObservabilityBuilder.ts](../src/ObservabilityBuilder.ts)) - -Fluent API for configuring telemetry: - -```typescript -import { Builder } from '@microsoft/agents-a365-observability'; - -const builder = new Builder() - .withService('my-agent', '1.0.0') - .withTokenResolver(async (agentId, tenantId) => getToken()) - .withClusterCategory('prod') - .withExporterOptions({ - maxQueueSize: 2048, - scheduledDelayMilliseconds: 5000 - }); - -builder.build(); -builder.start(); -``` - -**Builder Methods:** - -| Method | Purpose | -|--------|---------| -| `withService(name, version?)` | Set service name and version | -| `withTokenResolver(resolver)` | Set token resolver for exporter auth | -| `withClusterCategory(category)` | Set environment cluster | -| `withExporterOptions(options)` | Configure exporter options | -| `build()` | Initialize OpenTelemetry SDK | -| `start()` | Start the SDK | -| `shutdown()` | Graceful shutdown | - -### Scope Classes - -#### OpenTelemetryScope (Base Class) ([OpenTelemetryScope.ts](../src/tracing/scopes/OpenTelemetryScope.ts)) - -Base class for all tracing scopes, implementing `Disposable`. - -All scope constructors accept an optional `spanDetails?: SpanDetails` parameter, where span links can be provided via `spanDetails.spanLinks` to establish causal relationships to other spans (e.g. linking a batch operation to individual trigger spans): - -```typescript -abstract class OpenTelemetryScope implements Disposable { - // Make span active for async callback - withActiveSpanAsync(callback: () => Promise): Promise; - - // Get span context for parent-child linking - getSpanContext(): SpanContext; - - // Record an error - recordError(error: Error): void; - - // Record multiple attributes - recordAttributes(attributes: Record): void; - - // Set attribute if value is not null - protected setTagMaybe(name: string, value: T | null | undefined): void; - - // Dispose and end span - [Symbol.dispose](): void; - dispose(): void; -} -``` - -#### InvokeAgentScope ([InvokeAgentScope.ts](../src/tracing/scopes/InvokeAgentScope.ts)) - -Traces agent invocation operations: - -```typescript -import { InvokeAgentScope, InvokeAgentScopeDetails, AgentDetails, Request, CallerDetails } from '@microsoft/agents-a365-observability'; - -const request: Request = { content: 'Hello', channel: { name: 'Teams' }, sessionId: 'session-456' }; -const scopeDetails: InvokeAgentScopeDetails = { - endpoint: { host: 'api.example.com', port: 443 } -}; -const agentDetails: AgentDetails = { agentId: 'agent-123', agentName: 'MyAgent', tenantId: 'tenant-789' }; -const callerInfo: CallerDetails = { - userDetails: { userId: 'user-1', userName: 'User' }, - callerAgentDetails: callerAgent // Optional, for A2A scenarios -}; - -using scope = InvokeAgentScope.start(request, scopeDetails, agentDetails, callerInfo); - -scope.recordInputMessages(['Hello']); -// ... agent processing ... -scope.recordResponse('Hi there!'); -scope.recordOutputMessages(['Hi there!']); -``` - -**Span attributes recorded:** -- Server address and port -- Session ID -- Channel name and link -- Input/output messages -- User details (ID, UPN, name, tenant, client IP) -- Caller agent details (if agent-to-agent) - -#### InferenceScope ([InferenceScope.ts](../src/tracing/scopes/InferenceScope.ts)) - -Traces LLM/AI model inference calls: - -```typescript -import { InferenceScope, InferenceDetails, InferenceOperationType } from '@microsoft/agents-a365-observability'; - -using scope = InferenceScope.start( - { conversationId: 'conv-123' }, // Request (required) - { - operationName: InferenceOperationType.CHAT, - model: 'gpt-4', - providerName: 'openai', - responseModel: 'gpt-4-0613', - cacheReadInputTokens: 25, - cacheWriteInputTokens: 10, - endpoint: { host: 'api.openai.com', port: 443 } - }, - agentDetails // Must include tenantId -); - -scope.recordInputMessages(['User message']); -// ... LLM call ... -scope.recordOutputMessages(['Assistant response']); -scope.recordInputTokens(100); -scope.recordOutputTokens(50); -scope.recordCacheReadInputTokens(25); -scope.recordCacheWriteInputTokens(10); -scope.recordFinishReasons(['stop']); -``` - -Inference spans use the model-observation schema: `gen_ai.operation.name` is the -lowercase semconv value (`chat`, `text_completion`, or `generate_content`); `gen_ai.provider.name` and at least one of -`gen_ai.request.model` or `gen_ai.response.model` identify the provider and -model. Attributable agents include `gen_ai.agent.id` and may include -`gen_ai.agent.type`. Supplied endpoints emit `server.address` and a numeric -`server.port`. Token usage uses `gen_ai.usage.input_tokens`, -`gen_ai.usage.output_tokens`, `gen_ai.usage.cache_read.input_tokens`, and -`gen_ai.usage.cache_write.input_tokens`. Optional attributes are omitted when -their values are unavailable. - -#### ExecuteToolScope ([ExecuteToolScope.ts](../src/tracing/scopes/ExecuteToolScope.ts)) - -Traces tool execution operations: - -```typescript -import { ExecuteToolScope, ToolCallDetails } from '@microsoft/agents-a365-observability'; - -using scope = ExecuteToolScope.start( - {}, // Request (required) - { - toolName: 'search', - arguments: JSON.stringify({ query: 'weather' }), - toolCallId: 'call-123', - toolType: 'mcp', - endpoint: { host: 'tools.example.com' } - }, - agentDetails // Must include tenantId -); - -// ... tool execution ... -scope.recordResponse('Tool result'); -``` - -#### OutputScope ([OutputScope.ts](../src/tracing/scopes/OutputScope.ts)) - -Traces outgoing agent output messages: - -```typescript -import { OutputScope, OutputResponse } from '@microsoft/agents-a365-observability'; - -const response: OutputResponse = { messages: ['Hello!', 'How can I help?'] }; - -using scope = OutputScope.start( - { conversationId: 'conv-123', channel: { name: 'Teams' } }, - response, - agentDetails // Must include tenantId -); - -scope.recordOutputMessages(['Additional response']); -// Messages are flushed to the span attribute on dispose -``` - -### Message Format (OTEL Gen-AI Semantic Conventions) - -The SDK uses [OpenTelemetry Gen-AI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) for message tracing. All `recordInputMessages`/`recordOutputMessages` methods accept both plain strings and structured OTEL message objects. - -#### Message Types ([contracts.ts](../src/tracing/contracts.ts)) - -| Type | Description | -|------|-------------| -| `ChatMessage` | Input message with `role`, `parts[]`, and optional `name` | -| `OutputMessage` | Output message extending `ChatMessage` with `finish_reason` | -| `InputMessages` | Versioned wrapper: `{ version, messages: ChatMessage[] }` | -| `OutputMessages` | Versioned wrapper: `{ version, messages: OutputMessage[] }` | -| `InputMessagesParam` | Union: `string[] \| InputMessages` | -| `OutputMessagesParam` | Union: `string[] \| OutputMessages` | -| `MessageRole` | Enum: `system`, `user`, `assistant`, `tool` | -| `FinishReason` | Enum: `stop`, `length`, `content_filter`, `tool_call`, `error` | -| `MessagePart` | Discriminated union of all content part types | - -#### Message Part Types - -| Part Type | `type` Discriminator | Purpose | -|-----------|---------------------|---------| -| `TextPart` | `text` | Plain text content | -| `ToolCallRequestPart` | `tool_call` | Tool invocation by the model | -| `ToolCallResponsePart` | `tool_call_response` | Tool execution result | -| `ReasoningPart` | `reasoning` | Chain-of-thought / reasoning content | -| `BlobPart` | `blob` | Inline base64 binary data (image, audio, video) | -| `FilePart` | `file` | Reference to a pre-uploaded file | -| `UriPart` | `uri` | External URI reference | -| `ServerToolCallPart` | `server_tool_call` | Server-side tool invocation | -| `ServerToolCallResponsePart` | `server_tool_call_response` | Server-side tool response | -| `GenericPart` | *(custom)* | Extensible part for future types | - -> **Forward compatibility note:** `GenericPart` uses `type: string` rather than a fixed literal, which means it acts as a catch-all for any part type not covered by the other discriminated union members. As a consequence, an exhaustive `switch`/`case` on `part.type` will **not** produce compile-time errors for unhandled cases. Consumers should always include a `default` case in their switch statements to handle unknown or future part types gracefully. - -#### Auto-Wrapping Behavior - -Plain `string[]` input is automatically wrapped to OTEL format: -- Input strings become `ChatMessage` with `role: 'user'` and a single `TextPart` -- Output strings become `OutputMessage` with `role: 'assistant'` and a single `TextPart` - -#### Structured Message Example - -```typescript -import { InputMessages, OutputMessages, MessageRole, FinishReason, A365_MESSAGE_SCHEMA_VERSION } from '@microsoft/agents-a365-observability'; - -// Option 1: Plain string array (auto-wrapped to OTEL format) -scope.recordInputMessages(['What is the weather?']); - -// Option 2: Versioned wrapper with structured messages -const input: InputMessages = { - version: A365_MESSAGE_SCHEMA_VERSION, - messages: [ - { role: MessageRole.SYSTEM, parts: [{ type: 'text', content: 'You are a helpful assistant.' }] }, - { role: MessageRole.USER, parts: [{ type: 'text', content: 'What is the weather?' }] } - ] -}; -scope.recordInputMessages(input); - -// Structured output with tool call and finish reason (versioned wrapper) -const output: OutputMessages = { - version: A365_MESSAGE_SCHEMA_VERSION, - messages: [{ - role: MessageRole.ASSISTANT, - parts: [ - { type: 'text', content: 'Let me check that for you.' }, - { type: 'tool_call', name: 'get_weather', id: 'call_1', arguments: { city: 'Seattle' } } - ], - finish_reason: FinishReason.TOOL_CALL - }] -}; -scope.recordOutputMessages(output); -``` - -#### Message Serialization ([message-utils.ts](../src/tracing/message-utils.ts)) - -Messages are serialized to JSON via `JSON.stringify` and stored as span attributes (`gen_ai.input.messages`, `gen_ai.output.messages`). No per-attribute size limit is enforced at the SDK level. If serialization fails (e.g., non-JSON-serializable values such as `BigInt` or circular references), a fallback sentinel is returned so that telemetry recording never throws. - -The schema version is embedded inside the serialized wrapper JSON (e.g., `{"version":"0.1.0","messages":[...]}`) rather than set as a separate span attribute. This enables cross-SDK schema evolution while keeping message data self-contained. The current version is `0.1.0` (`A365_MESSAGE_SCHEMA_VERSION`). - -#### Span-Level Size Enforcement - -Span size is enforced at export time in the `Agent365Exporter`. When a serialized OTLP span exceeds `MAX_SPAN_SIZE_BYTES` (250 KB), the exporter's `truncateSpan()` method reduces the payload until the span fits, operating on the mapped OTLP span object without mutating the original `ReadableSpan`. - -Truncation is not implemented as a single `"TRUNCATED"` replacement for every oversized value. Depending on the attribute type and content, the exporter may: - -- replace oversized text values with a truncated form ending in `"… [truncated]"`; -- replace structured message payload fields with sentinel markers such as `"[truncated]"`; -- replace oversized inline blob content with `"[blob truncated]"`; -- iteratively remeasure the serialized OTLP span after each shrink step until the span fits or no additional shrink actions remain. - -Consumers of exported spans should therefore treat these sentinel strings as part of the documented export format for oversized spans rather than expecting a single uniform replacement value. - -#### Scope Visibility - -`recordInputMessages`/`recordOutputMessages` are `protected` on the base `OpenTelemetryScope` class and exposed as `public` only on scopes where they are semantically appropriate: - -| Scope | `recordInputMessages` | `recordOutputMessages` | -|-------|----------------------|----------------------| -| `InvokeAgentScope` | public | public | -| `InferenceScope` | public | public | -| `OutputScope` | — | public (accumulating) | -| `ExecuteToolScope` | — | — | - -`ExecuteToolScope` records tool input/output via `ToolCallDetails.arguments` and `recordResponse()` instead. - -### BaggageBuilder ([BaggageBuilder.ts](../src/tracing/middleware/BaggageBuilder.ts)) - -Fluent API for setting OpenTelemetry baggage: - -```typescript -import { BaggageBuilder } from '@microsoft/agents-a365-observability'; - -// Full builder pattern -const scope = new BaggageBuilder() - .tenantId('tenant-123') - .agentId('agent-456') - .correlationId('corr-789') - .userId('user-abc') - .sessionId('session-xyz') - .userEmail('user@example.com') - .conversationId('conv-123') - .build(); - -// Execute code within baggage context -scope.run(() => { - // All child spans inherit this baggage -}); - -// Convenience method -const scope2 = BaggageBuilder.setRequestContext( - 'tenant-123', - 'agent-456', - 'corr-789' -); -``` - -**Available baggage setters:** - -| Method | Baggage Key | -|--------|-------------| -| `tenantId(value)` | `tenant_id` | -| `agentId(value)` | `gen_ai.agent.id` | -| `agentAuid(value)` | `gen_ai.agent.auid` | -| `agentEmail(value)` | `microsoft.agent.user.email` | -| `correlationId(value)` | `correlation_id` | -| `userId(value)` | `user.id` | -| `sessionId(value)` | `session_id` | -| `conversationId(value)` | `gen_ai.conversation.id` | -| `userEmail(value)` | `user.email` | -| `operationSource(value)` | `service.name` | -| `channelName(value)` | `gen_ai.execution.source.name` | -| `channelLink(value)` | `gen_ai.execution.source.description` | -| `invokeAgentServer(address, port?)` | `server.address` / `server.port` | - -## Data Interfaces - -### InvokeAgentScopeDetails - -```typescript -interface InvokeAgentScopeDetails { - endpoint?: ServiceEndpoint; -} -``` - -### AgentDetails - -```typescript -interface AgentDetails { - agentId: string; - conversationId?: string; - agentName?: string; - agentType?: string; - agentDescription?: string; - iconUri?: string; - platformId?: string; - agentAUID?: string; - agentUPN?: string; - agentBlueprintId?: string; - tenantId?: string; - agentClientIP?: string; -} -``` - -### InferenceDetails - -```typescript -interface InferenceDetails { - operationName: InferenceOperationType; - model?: string; - providerName: string; - responseModel?: string; - inputTokens?: number; - outputTokens?: number; - cacheReadInputTokens?: number; - cacheWriteInputTokens?: number; - finishReasons?: string[]; - responseId?: string; - endpoint?: ServiceEndpoint; -} -``` - -### ToolCallDetails - -```typescript -interface ToolCallDetails { - toolName: string; - arguments?: string; - toolCallId?: string; - description?: string; - toolType?: string; - endpoint?: ServiceEndpoint; -} -``` - -### Enums - -```typescript -enum InvocationRole { - Human = 'Human', - Agent = 'Agent', - Event = 'Event', - Unknown = 'Unknown' -} - -enum InferenceOperationType { - CHAT = 'Chat', - TEXT_COMPLETION = 'TextCompletion', - GENERATE_CONTENT = 'GenerateContent' -} -``` - -## Design Patterns - -### Singleton Pattern - -`ObservabilityManager` ensures a single telemetry configuration per application: - -```typescript -export class ObservabilityManager { - private static instance?: ObservabilityBuilder; - - public static getInstance(): ObservabilityBuilder | null { - return ObservabilityManager.instance || null; - } -} -``` - -### Disposable Pattern - -All scope classes implement `Disposable` for automatic span lifecycle: - -```typescript -using scope = InvokeAgentScope.start(...); -// Span is active -scope.recordResponse('result'); -// Span automatically ends when scope is disposed -``` - -### Builder Pattern - -`ObservabilityBuilder` and `BaggageBuilder` use method chaining: - -```typescript -const builder = new ObservabilityBuilder() - .withService('my-agent') - .withTokenResolver(resolver) - .withClusterCategory('prod'); -``` - -## File Structure - -``` -src/ -├── index.ts # Public API exports -├── ObservabilityManager.ts # Main entry point -├── ObservabilityBuilder.ts # Configuration builder -├── tracing/ -│ ├── constants.ts # OpenTelemetry attribute keys -│ ├── contracts.ts # Data interfaces, enums, OTEL message types -│ ├── message-utils.ts # Message conversion and serialization -│ ├── scopes/ -│ │ ├── OpenTelemetryScope.ts # Base scope class -│ │ ├── InvokeAgentScope.ts # Agent invocation tracing -│ │ ├── InferenceScope.ts # LLM inference tracing -│ │ ├── ExecuteToolScope.ts # Tool execution tracing -│ │ └── OutputScope.ts # Output message tracing -│ ├── middleware/ -│ │ └── BaggageBuilder.ts # Baggage context builder -│ ├── processors/ -│ │ └── SpanProcessor.ts # Baggage-to-attribute processor -│ └── exporter/ -│ ├── Agent365Exporter.ts # Custom HTTP exporter -│ ├── Agent365ExporterOptions.ts # Exporter configuration -│ └── utils.ts # Exporter utilities -└── utils/ - └── logging.ts # Internal logging -``` - -## Dependencies - -- `@opentelemetry/api` - OpenTelemetry API interfaces -- `@opentelemetry/sdk-node` - OpenTelemetry SDK -- `@opentelemetry/sdk-trace-base` - Span processors and exporters -- `@opentelemetry/resources` - Resource configuration -- `@opentelemetry/semantic-conventions` - Semantic attribute keys -- `@microsoft/agents-a365-runtime` - Cluster category type - -## Configuration - -The observability package provides configuration via `ObservabilityConfiguration`, which extends `RuntimeConfiguration`: - -```typescript -import { - ObservabilityConfiguration, - defaultObservabilityConfigurationProvider -} from '@microsoft/agents-a365-observability'; - -// Using the default provider (reads from env vars) -const config = defaultObservabilityConfigurationProvider.getConfiguration(); -console.log(config.isObservabilityExporterEnabled); // Enable/disable exporter -console.log(config.observabilityAuthenticationScopes); // Auth scopes for token exchange -// Custom configuration with overrides -const customConfig = new ObservabilityConfiguration({ - isObservabilityExporterEnabled: () => true, - observabilityAuthenticationScopes: () => ['custom-scope/.default'], - observabilityDomainOverride: () => 'https://custom.domain' -}); -``` - -**ObservabilityConfiguration Properties:** - -| Property | Env Variable | Default | Description | -|----------|--------------|---------|-------------| -| `observabilityAuthenticationScopes` | `A365_OBSERVABILITY_SCOPES_OVERRIDE` | `['https://api.powerplatform.com/.default']` | OAuth scopes for observability auth | -| `isObservabilityExporterEnabled` | `ENABLE_A365_OBSERVABILITY_EXPORTER` | `false` | Enable Agent365 exporter | -| `observabilityDomainOverride` | `A365_OBSERVABILITY_DOMAIN_OVERRIDE` | `null` | Custom domain URL override | -| `observabilityLogLevel` | `A365_OBSERVABILITY_LOG_LEVEL` | `none` | Internal logging level | -| `clusterCategory` | `CLUSTER_CATEGORY` | `prod` | (Inherited) Environment cluster | -| `isDevelopmentEnvironment` | - | Derived | (Inherited) true if cluster is 'local' or 'dev' | -| `isNodeEnvDevelopment` | `NODE_ENV` | `false` | (Inherited) true if NODE_ENV='development' | - -### PerRequestSpanProcessorConfiguration - -`PerRequestSpanProcessorConfiguration` extends `ObservabilityConfiguration` and adds guardrail settings specific to the `PerRequestSpanProcessor`. These settings are separated from the common `ObservabilityConfiguration` because the per-request span processor is only used in specific scenarios. - -```typescript -import { - PerRequestSpanProcessorConfiguration, - defaultPerRequestSpanProcessorConfigurationProvider -} from '@microsoft/agents-a365-observability'; - -const config = defaultPerRequestSpanProcessorConfigurationProvider.getConfiguration(); -console.log(config.isPerRequestExportEnabled); // Per-request export mode (default: false) -console.log(config.perRequestMaxTraces); // Max buffered traces (default: 1000) -``` - -**PerRequestSpanProcessorConfiguration Properties (in addition to all ObservabilityConfiguration properties):** - -| Property | Env Variable | Default | Description | -|----------|--------------|---------|-------------| -| `isPerRequestExportEnabled` | `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` | `false` | Enable per-request export mode | -| `perRequestMaxTraces` | `A365_PER_REQUEST_MAX_TRACES` | `1000` | Max buffered traces per request | -| `perRequestMaxSpansPerTrace` | `A365_PER_REQUEST_MAX_SPANS_PER_TRACE` | `5000` | Max spans per trace | -| `perRequestMaxConcurrentExports` | `A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS` | `20` | Max concurrent export operations | - -## Environment Variables - -| Variable | Purpose | Default | -|----------|---------|---------| -| `ENABLE_A365_OBSERVABILITY_EXPORTER` | Enable/disable Agent365 exporter | `false` | -| `A365_OBSERVABILITY_SCOPES_OVERRIDE` | Override auth scopes (space-separated) | Production scope | -| `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` | Enable per-request export mode | `false` | -| `A365_OBSERVABILITY_DOMAIN_OVERRIDE` | Custom domain URL | - | -| `A365_OBSERVABILITY_LOG_LEVEL` | Internal log level | `none` | -| `A365_PER_REQUEST_MAX_TRACES` | Max buffered traces (`PerRequestSpanProcessorConfiguration`) | `1000` | -| `A365_PER_REQUEST_MAX_SPANS_PER_TRACE` | Max spans per trace (`PerRequestSpanProcessorConfiguration`) | `5000` | -| `A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS` | Max concurrent exports (`PerRequestSpanProcessorConfiguration`) | `20` | diff --git a/packages/agents-a365-observability/jest.config.js b/packages/agents-a365-observability/jest.config.js deleted file mode 100644 index 85385500..00000000 --- a/packages/agents-a365-observability/jest.config.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/__tests__/**/*.test.ts'], - moduleFileExtensions: ['ts', 'js', 'json'], - collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.d.ts', - '!src/**/__tests__/**' - ], - coverageDirectory: 'coverage', - coverageReporters: ['text', 'lcov', 'html'] -}; diff --git a/packages/agents-a365-observability/package.json b/packages/agents-a365-observability/package.json deleted file mode 100644 index 5230c650..00000000 --- a/packages/agents-a365-observability/package.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "name": "@microsoft/agents-a365-observability", - "version": "0.0.0-placeholder", - "description": "OpenTelemetry tracing and monitoring SDK for AI agents built with TypeScript/Node.js", - "main": "dist/cjs/index.js", - "module": "dist/esm/index.js", - "types": "dist/esm/index.d.ts", - "scripts": { - "prebuild": "node -p \"'export const LIB_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts", - "build:cjs": "npx tsc --project tsconfig.cjs.json", - "build:esm": "npx tsc --project tsconfig.esm.json", - "build": "npm run build:cjs && npm run build:esm", - "build:watch": "npx tsc --watch", - "clean": "npx rimraf dist", - "test": "jest --passWithNoTests", - "test:watch": "jest --watch", - "lint": "eslint src", - "lint:fix": "eslint src --fix", - "prepublishOnly": "npm run clean && npm run build", - "ci": "npm ci", - "pack": "node ../../copyFiles.js . && pnpm pack --pack-destination=../" - }, - "keywords": [ - "Agent365", - "opentelemetry", - "tracing", - "monitoring", - "ai", - "agents", - "azure", - "typescript" - ], - "author": "Microsoft Corporation", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/microsoft/Agent365-nodejs.git", - "directory": "packages/agents-a365-observability" - }, - "dependencies": { - "@microsoft/agents-a365-runtime": "workspace:*", - "@opentelemetry/api": "catalog:", - "@opentelemetry/core": "catalog:", - "@opentelemetry/exporter-trace-otlp-http": "catalog:", - "@opentelemetry/instrumentation": "catalog:", - "@opentelemetry/resources": "catalog:", - "@opentelemetry/sdk-node": "catalog:", - "@opentelemetry/sdk-trace-base": "catalog:", - "@opentelemetry/semantic-conventions": "catalog:" - }, - "devDependencies": { - "@eslint/js": "catalog:", - "@types/jest": "catalog:", - "@types/node": "catalog:", - "@typescript-eslint/eslint-plugin": "catalog:", - "@typescript-eslint/parser": "catalog:", - "eslint": "catalog:", - "jest": "catalog:", - "rimraf": "catalog:", - "ts-jest": "catalog:", - "typescript": "catalog:", - "typescript-eslint": "catalog:" - }, - "engines": { - "node": ">=18.0.0" - }, - "files": [ - "dist/**/*", - "README.md", - "CHANGELOG.md", - "LICENSE.md" - ] -} diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts deleted file mode 100644 index 9150b338..00000000 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ /dev/null @@ -1,308 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { NodeSDK } from '@opentelemetry/sdk-node'; -import { ConsoleSpanExporter, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { SpanProcessor } from './tracing/processors/SpanProcessor'; -import { isAgent365ExporterEnabled, isPerRequestExportEnabled } from './tracing/exporter/utils'; -import { Agent365Exporter } from './tracing/exporter/Agent365Exporter'; -import type { TokenResolver } from './tracing/exporter/Agent365ExporterOptions'; -import { Agent365ExporterOptions } from './tracing/exporter/Agent365ExporterOptions'; -import { PerRequestSpanProcessor } from './tracing/PerRequestSpanProcessor'; -import { resourceFromAttributes, envDetector, processDetector } from '@opentelemetry/resources'; -import { ATTR_SERVICE_NAME, ATTR_SERVICE_NAMESPACE } from '@opentelemetry/semantic-conventions'; -import { trace } from '@opentelemetry/api'; -import { ClusterCategory, IConfigurationProvider } from '@microsoft/agents-a365-runtime'; -import logger, { setLogger, DefaultLogger, type ILogger } from './utils/logging'; -import type { ObservabilityConfiguration } from './configuration'; -/** - * Configuration options for Agent 365 Observability Builder - */ -export interface BuilderOptions { - /** Custom service name for telemetry */ - serviceName?: string; - - /** Custom service version for telemetry */ - serviceVersion?: string; - - /** Optional service namespace for the OTel resource (service.namespace attribute) */ - serviceNamespace?: string; - - tokenResolver?: TokenResolver; - /** Environment / cluster category (e.g., "preprod", "prod"). */ - clusterCategory?: ClusterCategory; - /** - * Optional partial set of exporter options allowing agent developers to customize. - * Any values omitted will fall back to the defaults defined in Agent365ExporterOptions. - * Values provided here will be overridden by explicitly configured tokenResolver or clusterCategory - * from dedicated builder methods. - */ - exporterOptions?: Partial; - - /** - * Optional custom logger implementation for the observability SDK. - * If not provided, the SDK uses the default console logger. - * Implement ILogger to integrate with other logging services - */ - customLogger?: ILogger; - - /** - * Optional configuration provider for ObservabilityConfiguration. - * When provided, this is used by the builder and its internal components - * (exporter, span processors, logger) - */ - configProvider?: IConfigurationProvider; -} - -/** - * Builder for configuring Agent 365 with OpenTelemetry tracing - */ -export class ObservabilityBuilder { - private options: BuilderOptions = {}; - private isBuilt = false; - private sdk?: NodeSDK; - - /** - * Configures the service name and version for telemetry - * @param serviceName The service name - * @param serviceVersion The service version - * @returns The builder instance for method chaining - */ - public withService(serviceName: string, serviceVersion?: string): ObservabilityBuilder { - this.options.serviceName = serviceName; - this.options.serviceVersion = serviceVersion; - return this; - } - - /** - * Configures the service namespace for telemetry (service.namespace resource attribute) - * @param serviceNamespace The service namespace - * @returns The builder instance for method chaining - */ - public withServiceNamespace(serviceNamespace: string): ObservabilityBuilder { - this.options.serviceNamespace = serviceNamespace; - return this; - } - - /** - * Configures the token resolver for Agent 365 exporter - * @param tokenResolver Function to resolve authentication tokens - * @returns The builder instance for method chaining - */ - public withTokenResolver(tokenResolver: TokenResolver): ObservabilityBuilder { - this.options.tokenResolver = tokenResolver; - return this; - } - - /** - * Configures the cluster category for Agent 365 exporter - * @param clusterCategory The cluster category (e.g., "preprod", "prod") - * @returns The builder instance for method chaining - */ - public withClusterCategory(clusterCategory: ClusterCategory): ObservabilityBuilder { - this.options.clusterCategory = clusterCategory; - return this; - } - - /** - * Provide a partial set of Agent365ExporterOptions. These will be merged with - * defaults and any explicitly configured clusterCategory/tokenResolver. - * @param exporterOptions Partial exporter options - * @returns The builder instance for chaining - */ - public withExporterOptions(exporterOptions: Partial): ObservabilityBuilder { - this.options.exporterOptions = { - ...(this.options.exporterOptions || {}), - ...exporterOptions - }; - return this; - } - - /** - * Configures the configuration provider for ObservabilityConfiguration. - * When set, this provider is used by the builder and its internal components - * instead of the default provider that reads from environment variables. - * @param configProvider The configuration provider - * @returns The builder instance for method chaining - */ - public withConfigurationProvider(configProvider: IConfigurationProvider): ObservabilityBuilder { - this.options.configProvider = configProvider; - return this; - } - - /** - * Sets a custom logger implementation for the observability SDK - * @param customLogger The custom logger implementation (must implement ILogger interface) - * @returns The builder instance for method chaining - * - * Example with Winston: - * ```typescript - * const winstonLogger = winston.createLogger({...}); - * builder.withCustomLogger({ - * info: (msg, ...args) => winstonLogger.info(msg, ...args), - * warn: (msg, ...args) => winstonLogger.warn(msg, ...args), - * error: (msg, ...args) => winstonLogger.error(msg, ...args) - * }); - * ``` - */ - public withCustomLogger(customLogger: ILogger): ObservabilityBuilder { - this.options.customLogger = customLogger; - return this; - } - - private createExporterOptions(): Agent365ExporterOptions { - const opts = new Agent365ExporterOptions(); - if (this.options.exporterOptions) { - Object.assign(opts, this.options.exporterOptions); - } - opts.clusterCategory = this.options.clusterCategory || opts.clusterCategory || ClusterCategory.prod; - if (this.options.tokenResolver) { - opts.tokenResolver = this.options.tokenResolver; - } - return opts; - } - - private createBatchProcessor(): BatchSpanProcessor { - if (!isAgent365ExporterEnabled(this.options.configProvider)) { - logger.info('[ObservabilityBuilder] Agent 365 exporter not enabled. Using ConsoleSpanExporter for BatchSpanProcessor.'); - return new BatchSpanProcessor(new ConsoleSpanExporter()); - } - - const opts = this.createExporterOptions(); - return new BatchSpanProcessor(new Agent365Exporter(opts, this.options.configProvider), { - maxQueueSize: opts.maxQueueSize, - scheduledDelayMillis: opts.scheduledDelayMilliseconds, - exportTimeoutMillis: opts.exporterTimeoutMilliseconds, - maxExportBatchSize: opts.maxExportBatchSize - }); - } - - private createPerRequestProcessor(): PerRequestSpanProcessor { - if (!isAgent365ExporterEnabled(this.options.configProvider)) { - logger.info('[Agent365Exporter] Per-request export enabled but Agent 365 exporter is disabled. Using ConsoleSpanExporter.'); - return new PerRequestSpanProcessor(new ConsoleSpanExporter()); - } - - const opts = this.createExporterOptions(); - return new PerRequestSpanProcessor(new Agent365Exporter(opts, this.options.configProvider)); - } - - private createExportProcessor(): BatchSpanProcessor | PerRequestSpanProcessor { - if (isPerRequestExportEnabled()) { - return this.createPerRequestProcessor(); - } - - return this.createBatchProcessor(); - } - - private createResource() { - const serviceName = this.options.serviceVersion - ? `${this.options.serviceName}-${this.options.serviceVersion}` - : this.options.serviceName ?? 'Agent365-TypeScript'; - - const attrs: Record = { - [ATTR_SERVICE_NAME]: serviceName, - }; - - if (this.options.serviceNamespace) { - attrs[ATTR_SERVICE_NAMESPACE] = this.options.serviceNamespace; - } - - return resourceFromAttributes(attrs); - } - - /** - * Builds and initializes the Agent 365 configuration - * @returns The configured NodeSDK instance - */ - public build(): boolean { - if (this.isBuilt) { - return this.isBuilt; - } - - // Apply custom logger if provided, or configure logger with custom config provider - if (this.options.customLogger) { - setLogger(this.options.customLogger); - } else if (this.options.configProvider) { - setLogger(new DefaultLogger(this.options.configProvider)); - } - - // Create processors in the desired order: - // 1. baggage enricher (copies baggage -> span attributes) - const spanProcessor = new SpanProcessor(); - - // 2. export processor (batch or per-request based on environment variable) - const exportProcessor = this.createExportProcessor(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const globalProvider: any = trace.getTracerProvider(); - - const canAddProcessors = - typeof globalProvider?.addSpanProcessor === 'function' && - typeof globalProvider?.resource !== 'undefined'; - - if (canAddProcessors) { - // Someone else already created a provider (maybe their own NodeSDK). - // We DO NOT create a new NodeSDK. - // We just add our baggage enricher + export processor to their provider, - // but only if they aren't already there. - - this.attachProcessorIfMissing(globalProvider, spanProcessor); - this.attachProcessorIfMissing(globalProvider, exportProcessor); - - this.isBuilt = true; - this.sdk = undefined; // we didn't create/own one - return this.isBuilt; - } - - - // Create & configure the NodeSDK manually so we can inject processors + resource. - this.sdk = new NodeSDK({ - resource: this.createResource(), - resourceDetectors: [envDetector, processDetector], - spanProcessors: [ - spanProcessor, - exportProcessor, - ], - }); - - this.isBuilt = true; - return this.isBuilt; - } - - /** - * Starts the OpenTelemetry SDK - */ - public start(): void { - if (!this.isBuilt) { - this.build(); - } - if (this.sdk) { - this.sdk.start(); - } - } - - /** - * Shuts down the OpenTelemetry SDK - */ - public async shutdown(): Promise { - if (this.sdk) { - await this.sdk.shutdown(); - } - } - - /** - * Helper to avoid double-registering same processor type. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private attachProcessorIfMissing(provider: any, processor: any) { - const active = provider._activeSpanProcessor?._spanProcessors; - const alreadyAdded = Array.isArray(active) && - // eslint-disable-next-line @typescript-eslint/no-explicit-any - active.some((p: any) => p?.constructor?.name === processor.constructor.name); - - if (!alreadyAdded) { - provider.addSpanProcessor(processor); - } - } -} diff --git a/packages/agents-a365-observability/src/ObservabilityManager.ts b/packages/agents-a365-observability/src/ObservabilityManager.ts deleted file mode 100644 index 225a29f6..00000000 --- a/packages/agents-a365-observability/src/ObservabilityManager.ts +++ /dev/null @@ -1,81 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -import { ObservabilityBuilder, BuilderOptions } from './ObservabilityBuilder'; - -/** - * Main entry point for Agent 365 providing OpenTelemetry tracing for AI agents and tools - */ -export class ObservabilityManager { - private static instance?: ObservabilityBuilder; - - - /** - * Configures Agent 365 with OpenTelemetry tracing for AI agents and tools - * @param configure Optional configuration callback for the Builder - * @returns The configured Builder instance - */ - public static configure( - configure?: (builder: ObservabilityBuilder) => void - ): ObservabilityBuilder { - const builder = new ObservabilityBuilder(); - - configure?.(builder); - - ObservabilityManager.instance = builder; - return builder; - } - - /** - * Configures and starts Agent 365 with simplified options - * @param options Configuration options - * @returns The configured and started Builder instance - */ - public static start(options?: BuilderOptions): ObservabilityBuilder { - const builder = new ObservabilityBuilder(); - - if (options?.serviceName) { - builder.withService(options.serviceName, options.serviceVersion); - } - - if (options?.tokenResolver) { - builder.withTokenResolver(options.tokenResolver); - } - - if (options?.exporterOptions) { - builder.withExporterOptions(options.exporterOptions); - } - - if (options?.clusterCategory) { - builder.withClusterCategory(options.clusterCategory); - } - - if (options?.configProvider) { - builder.withConfigurationProvider(options.configProvider); - } - - builder.start(); - - ObservabilityManager.instance = builder; - return builder; - } - - /** - * Gets the current Agent 365 instance - * @returns The current instance or null if not configured - */ - public static getInstance(): ObservabilityBuilder | null { - return ObservabilityManager.instance || null; - } - - /** - * Shuts down Agent 365 - */ - public static async shutdown(): Promise { - if (ObservabilityManager.instance) { - await ObservabilityManager.instance.shutdown(); - ObservabilityManager.instance = undefined; - } - } -} diff --git a/packages/agents-a365-observability/src/configuration/ObservabilityConfiguration.ts b/packages/agents-a365-observability/src/configuration/ObservabilityConfiguration.ts deleted file mode 100644 index 1f95ee56..00000000 --- a/packages/agents-a365-observability/src/configuration/ObservabilityConfiguration.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { RuntimeConfiguration } from '@microsoft/agents-a365-runtime'; -import { ObservabilityConfigurationOptions } from './ObservabilityConfigurationOptions'; - -// Default constants -const PROD_OBSERVABILITY_SCOPE = 'api://9b975845-388f-4429-889e-eab1ef63949c/.default'; - -/** - * Configuration for observability package. - * Inherits runtime settings and adds observability-specific settings. - */ -export class ObservabilityConfiguration extends RuntimeConfiguration { - protected get observabilityOverrides(): ObservabilityConfigurationOptions { - return this.overrides as ObservabilityConfigurationOptions; - } - - constructor(overrides?: ObservabilityConfigurationOptions) { - super(overrides); - } - - // Inherited: clusterCategory, isDevelopmentEnvironment, isNodeEnvDevelopment - - /** - * Gets the observability authentication scopes. - * Used by AgenticTokenCache for observability service authentication. - */ - get observabilityAuthenticationScopes(): readonly string[] { - const result = this.observabilityOverrides.observabilityAuthenticationScopes?.(); - if (result !== undefined) { - return result; - } - const override = process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE; - if (override?.trim()) { - return override.trim().split(/\s+/); - } - return [PROD_OBSERVABILITY_SCOPE]; - } - - get isObservabilityExporterEnabled(): boolean { - const result = this.observabilityOverrides.isObservabilityExporterEnabled?.(); - if (result !== undefined) return result; - return RuntimeConfiguration.parseEnvBoolean(process.env.ENABLE_A365_OBSERVABILITY_EXPORTER); - } - - get observabilityDomainOverride(): string | null { - const result = this.observabilityOverrides.observabilityDomainOverride?.(); - if (result !== undefined) { - return result; - } - const override = process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; - if (override?.trim()) { - return override.trim().replace(/\/+$/, ''); - } - return null; - } - - get observabilityLogLevel(): string { - return this.observabilityOverrides.observabilityLogLevel?.() - ?? process.env.A365_OBSERVABILITY_LOG_LEVEL - ?? 'none'; - } - -} diff --git a/packages/agents-a365-observability/src/configuration/ObservabilityConfigurationOptions.ts b/packages/agents-a365-observability/src/configuration/ObservabilityConfigurationOptions.ts deleted file mode 100644 index d25a57b3..00000000 --- a/packages/agents-a365-observability/src/configuration/ObservabilityConfigurationOptions.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { RuntimeConfigurationOptions } from '@microsoft/agents-a365-runtime'; - -/** - * Observability configuration options - extends runtime options. - * All overrides are functions called on each property access. - * - * Inherited from RuntimeConfigurationOptions: - * - clusterCategory - * - isNodeEnvDevelopment - * - * Note: `isDevelopmentEnvironment` is a derived getter on the configuration class - * (based on clusterCategory), not an overridable option. - */ -export type ObservabilityConfigurationOptions = RuntimeConfigurationOptions & { - /** - * Override for observability authentication scopes. - * Used by AgenticTokenCache for observability service authentication. - * - * @returns Array of OAuth scopes for observability service authentication. - * @envvar A365_OBSERVABILITY_SCOPES_OVERRIDE - Space-separated list of scopes. - * @default ['api://9b975845-388f-4429-889e-eab1ef63949c/.default'] - */ - observabilityAuthenticationScopes?: () => string[]; - - /** - * Override to enable/disable the Agent365 observability span exporter. - * When enabled, spans are exported to the Agent365 observability service. - * - * @returns `true` to enable the exporter, `false` to disable. - * @envvar ENABLE_A365_OBSERVABILITY_EXPORTER - 'true', '1', 'yes', 'on' to enable. - * @default false - */ - isObservabilityExporterEnabled?: () => boolean; - - /** - * Override for the custom observability domain/endpoint. - * Trailing slashes are automatically removed. - * - * @returns Custom domain URL string, or `null` for no override. - * @envvar A365_OBSERVABILITY_DOMAIN_OVERRIDE - Full URL of custom endpoint. - * @default null - */ - observabilityDomainOverride?: () => string | null; - - /** - * Override for the internal SDK log level. - * Controls which log messages are output by the observability SDK's internal logger. - * - * Supported values (pipe-separated for multiple): - * - 'none' - No logging (default) - * - 'info' - Information messages - * - 'warn' - Warning messages - * - 'error' - Error messages - * - 'info|warn|error' - All messages - * - * @returns Log level string. - * @envvar A365_OBSERVABILITY_LOG_LEVEL - * @default 'none' - */ - observabilityLogLevel?: () => string; -}; diff --git a/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfiguration.ts b/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfiguration.ts deleted file mode 100644 index 6c843b7f..00000000 --- a/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfiguration.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { RuntimeConfiguration } from '@microsoft/agents-a365-runtime'; -import { PerRequestSpanProcessorConfigurationOptions } from './PerRequestSpanProcessorConfigurationOptions'; -import { getPerRequestProcessorInternalOverrides } from '../internal/PerRequestProcessorInternalOverrides'; - -/** Guardrails to prevent unbounded memory growth / export bursts. Used for PerRequestSpanProcessor only. */ -const DEFAULT_MAX_BUFFERED_TRACES = 1000; -const DEFAULT_MAX_SPANS_PER_TRACE = 5000; -const DEFAULT_MAX_CONCURRENT_EXPORTS = 20; -const DEFAULT_FLUSH_GRACE_MS = 250; -const DEFAULT_MAX_TRACE_AGE_MS = 30 * 60 * 1000; // 30 minutes - -/** - * Configuration for PerRequestSpanProcessor. - * Inherits runtime settings (clusterCategory, isNodeEnvDevelopment) and adds - * per-request processor guardrails. - * - * This is separated from ObservabilityConfiguration because PerRequestSpanProcessor - * is used only in specific scenarios and these settings should not be exposed - * in the common ObservabilityConfiguration. - */ -export class PerRequestSpanProcessorConfiguration extends RuntimeConfiguration { - protected get perRequestOverrides(): PerRequestSpanProcessorConfigurationOptions { - const internal = getPerRequestProcessorInternalOverrides(); - const instanceOverrides = this.overrides as PerRequestSpanProcessorConfigurationOptions | undefined; - return { - ...(instanceOverrides ?? {}), - ...(internal?.isPerRequestExportEnabled !== undefined && { isPerRequestExportEnabled: internal.isPerRequestExportEnabled }), - ...(internal?.perRequestMaxTraces !== undefined && { perRequestMaxTraces: internal.perRequestMaxTraces }), - ...(internal?.perRequestMaxSpansPerTrace !== undefined && { perRequestMaxSpansPerTrace: internal.perRequestMaxSpansPerTrace }), - ...(internal?.perRequestMaxConcurrentExports !== undefined && { perRequestMaxConcurrentExports: internal.perRequestMaxConcurrentExports }), - ...(internal?.perRequestFlushGraceMs !== undefined && { perRequestFlushGraceMs: internal.perRequestFlushGraceMs }), - ...(internal?.perRequestMaxTraceAgeMs !== undefined && { perRequestMaxTraceAgeMs: internal.perRequestMaxTraceAgeMs }), - }; - } - - constructor(overrides?: PerRequestSpanProcessorConfigurationOptions) { - super(overrides); - } - - get isPerRequestExportEnabled(): boolean { - const result = this.perRequestOverrides.isPerRequestExportEnabled?.(); - if (result !== undefined) return result; - return RuntimeConfiguration.parseEnvBoolean(process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT); - } - - get perRequestMaxTraces(): number { - const value = this.perRequestOverrides.perRequestMaxTraces?.() - ?? RuntimeConfiguration.parseEnvInt(process.env.A365_PER_REQUEST_MAX_TRACES, DEFAULT_MAX_BUFFERED_TRACES); - return value > 0 ? value : DEFAULT_MAX_BUFFERED_TRACES; - } - - get perRequestMaxSpansPerTrace(): number { - const value = this.perRequestOverrides.perRequestMaxSpansPerTrace?.() - ?? RuntimeConfiguration.parseEnvInt(process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE, DEFAULT_MAX_SPANS_PER_TRACE); - return value > 0 ? value : DEFAULT_MAX_SPANS_PER_TRACE; - } - - get perRequestMaxConcurrentExports(): number { - const value = this.perRequestOverrides.perRequestMaxConcurrentExports?.() - ?? RuntimeConfiguration.parseEnvInt(process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS, DEFAULT_MAX_CONCURRENT_EXPORTS); - return value > 0 ? value : DEFAULT_MAX_CONCURRENT_EXPORTS; - } - - get perRequestFlushGraceMs(): number { - const value = this.perRequestOverrides.perRequestFlushGraceMs?.() - ?? RuntimeConfiguration.parseEnvInt(process.env.A365_PER_REQUEST_FLUSH_GRACE_MS, DEFAULT_FLUSH_GRACE_MS); - return value > 0 ? value : DEFAULT_FLUSH_GRACE_MS; - } - - get perRequestMaxTraceAgeMs(): number { - const value = this.perRequestOverrides.perRequestMaxTraceAgeMs?.() - ?? RuntimeConfiguration.parseEnvInt(process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS, DEFAULT_MAX_TRACE_AGE_MS); - return value > 0 ? value : DEFAULT_MAX_TRACE_AGE_MS; - } -} diff --git a/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfigurationOptions.ts b/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfigurationOptions.ts deleted file mode 100644 index fc379e4a..00000000 --- a/packages/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfigurationOptions.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { RuntimeConfigurationOptions } from '@microsoft/agents-a365-runtime'; - -/** - * Configuration options for PerRequestSpanProcessor - extends runtime options. - * All overrides are functions called on each property access. - * - * Inherited from RuntimeConfigurationOptions: - * - clusterCategory, isNodeEnvDevelopment - */ -export type PerRequestSpanProcessorConfigurationOptions = RuntimeConfigurationOptions & { - /** - * Override to enable/disable per-request export mode. - * When enabled, spans are buffered per-request and exported when the request completes, - * rather than using a batch processor. - * - * @returns `true` to enable per-request export, `false` for batch export. - * @envvar ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT - 'true', '1', 'yes', 'on' to enable. - * @default false - */ - isPerRequestExportEnabled?: () => boolean; - - /** - * Override for maximum number of traces to buffer in per-request export mode. - * When this limit is reached, oldest traces are dropped. - * Values <= 0 are ignored and the default is used. - * - * @returns Maximum number of buffered traces. - * @envvar A365_PER_REQUEST_MAX_TRACES - * @default 1000 - */ - perRequestMaxTraces?: () => number; - - /** - * Override for maximum number of spans per trace in per-request export mode. - * Traces with more spans than this limit will have excess spans dropped. - * Values <= 0 are ignored and the default is used. - * - * @returns Maximum spans per trace. - * @envvar A365_PER_REQUEST_MAX_SPANS_PER_TRACE - * @default 5000 - */ - perRequestMaxSpansPerTrace?: () => number; - - /** - * Override for maximum concurrent export operations in per-request export mode. - * Limits the number of parallel HTTP requests to the observability service. - * Values <= 0 are ignored and the default is used. - * - * @returns Maximum concurrent exports. - * @envvar A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS - * @default 20 - */ - perRequestMaxConcurrentExports?: () => number; - - /** - * Override for grace period (ms) to wait for child spans after root span ends. - * After the root span ends, the processor waits this long for remaining children - * before flushing the trace. Values <= 0 are ignored and the default is used. - * - * @returns Flush grace period in milliseconds. - * @envvar A365_PER_REQUEST_FLUSH_GRACE_MS - * @default 250 - */ - perRequestFlushGraceMs?: () => number; - - /** - * Override for maximum age (ms) for a trace before forcing flush. - * Traces older than this are flushed regardless of whether all spans have ended. - * Values <= 0 are ignored and the default is used. - * - * @returns Maximum trace age in milliseconds. - * @envvar A365_PER_REQUEST_MAX_TRACE_AGE_MS - * @default 1800000 (30 minutes) - */ - perRequestMaxTraceAgeMs?: () => number; -}; diff --git a/packages/agents-a365-observability/src/configuration/index.ts b/packages/agents-a365-observability/src/configuration/index.ts deleted file mode 100644 index 00f35ffe..00000000 --- a/packages/agents-a365-observability/src/configuration/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; -import { ObservabilityConfiguration } from './ObservabilityConfiguration'; -import { PerRequestSpanProcessorConfiguration } from './PerRequestSpanProcessorConfiguration'; - -export * from './ObservabilityConfigurationOptions'; -export * from './ObservabilityConfiguration'; -export * from './PerRequestSpanProcessorConfigurationOptions'; -export * from './PerRequestSpanProcessorConfiguration'; - -/** - * Shared default provider for ObservabilityConfiguration. - */ -export const defaultObservabilityConfigurationProvider = - new DefaultConfigurationProvider(() => new ObservabilityConfiguration()); - -/** - * Shared default provider for PerRequestSpanProcessorConfiguration. - */ -export const defaultPerRequestSpanProcessorConfigurationProvider = - new DefaultConfigurationProvider(() => new PerRequestSpanProcessorConfiguration()); diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts deleted file mode 100644 index ad00731d..00000000 --- a/packages/agents-a365-observability/src/index.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Main SDK classes -export { ObservabilityManager } from './ObservabilityManager'; -export { ObservabilityBuilder as Builder, BuilderOptions } from './ObservabilityBuilder'; -export { Agent365ExporterOptions } from './tracing/exporter/Agent365ExporterOptions'; -export type { TokenResolver } from './tracing/exporter/Agent365ExporterOptions'; -// Tracing constants -export { OpenTelemetryConstants } from './tracing/constants'; -export { ExporterEventNames } from './tracing/exporter/ExporterEventNames'; - -// Baggage builder -export { BaggageBuilder, BaggageScope } from './tracing/middleware/BaggageBuilder'; - -// Per-request export utilities -export { runWithExportToken, updateExportToken, getExportToken } from './tracing/context/token-context'; - -// Parent span context utilities -export { ParentSpanRef, runWithParentSpanRef, createContextWithParentSpanRef } from './tracing/context/parent-span-context'; - -// Trace context propagation utilities (W3C traceparent/tracestate) -export { - HeadersCarrier, - ParentContext, - injectContextToHeaders, - extractContextFromHeaders, - runWithExtractedTraceContext -} from './tracing/context/trace-context-propagation'; - -// Contracts and interfaces -export { - InvocationRole, - Channel, - Request, - AgentDetails, - ToolCallDetails, - InvokeAgentScopeDetails, - UserDetails, - CallerDetails, - // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional re-export for backward compatibility - EnhancedAgentDetails, - ServiceEndpoint, - InferenceDetails, - InferenceOperationType, - InferenceResponse, - OutputResponse, - SpanDetails, - // OTEL gen-ai message format types - MessageRole, - FinishReason, - Modality, - TextPart, - ToolCallRequestPart, - ToolCallResponsePart, - ReasoningPart, - BlobPart, - FilePart, - UriPart, - ServerToolCallPart, - ServerToolCallResponsePart, - GenericServerToolCall, - GenericServerToolCallResponse, - GenericPart, - MessagePart, - ChatMessage, - OutputMessage, - InputMessages, - OutputMessages, - InputMessagesParam, - OutputMessagesParam, - ResponseMessagesParam, - DEFAULT_FINISH_REASON, - GuardrailDecisionType, - GuardrailDetails, - GuardrailFinding, - GuardrailRiskSeverity, - GuardrailTargetType, -} from './tracing/contracts'; - -// Scopes -export { OpenTelemetryScope } from './tracing/scopes/OpenTelemetryScope'; -export { ExecuteToolScope } from './tracing/scopes/ExecuteToolScope'; -export { InvokeAgentScope } from './tracing/scopes/InvokeAgentScope'; -export { InferenceScope } from './tracing/scopes/InferenceScope'; -export { OutputScope } from './tracing/scopes/OutputScope'; -export { ApplyGuardrailScope } from './tracing/scopes/ApplyGuardrailScope'; -export { logger, setLogger, getLogger, resetLogger, formatError } from './utils/logging'; -export type { ILogger } from './utils/logging'; -export { safeSerializeToJson } from './tracing/util'; - -// Message utilities -export { serializeMessages, normalizeInputMessages, normalizeOutputMessages } from './tracing/message-utils'; - -// Exporter utilities -export { isPerRequestExportEnabled, MAX_SPAN_SIZE_BYTES } from './tracing/exporter/utils'; - -// Configuration -export * from './configuration'; diff --git a/packages/agents-a365-observability/src/internal/PerRequestProcessorInternalOverrides.ts b/packages/agents-a365-observability/src/internal/PerRequestProcessorInternalOverrides.ts deleted file mode 100644 index 7af19089..00000000 --- a/packages/agents-a365-observability/src/internal/PerRequestProcessorInternalOverrides.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { PerRequestSpanProcessorConfigurationOptions } from '../configuration/PerRequestSpanProcessorConfigurationOptions'; - -let overrides: PerRequestSpanProcessorConfigurationOptions | undefined; - -// Only for tests / internal usage -export function setPerRequestProcessorInternalOverrides(value?: PerRequestSpanProcessorConfigurationOptions) { - overrides = value; -} - -export function getPerRequestProcessorInternalOverrides(): PerRequestSpanProcessorConfigurationOptions | undefined { - return overrides; -} diff --git a/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts b/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts deleted file mode 100644 index 2ef12777..00000000 --- a/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts +++ /dev/null @@ -1,301 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { context, type Context } from '@opentelemetry/api'; -import type { ReadableSpan, SpanProcessor, SpanExporter } from '@opentelemetry/sdk-trace-base'; -import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; -import logger from '../utils/logging'; -import { PerRequestSpanProcessorConfiguration, defaultPerRequestSpanProcessorConfigurationProvider } from '../configuration'; - - - -function isRootSpan(span: ReadableSpan): boolean { - return !span.parentSpanContext; -} - -type TraceBuffer = { - spans: ReadableSpan[]; - openCount: number; - rootEnded: boolean; - rootCtx?: Context; // preserves request-local baggage and custom exporter state - startedAtMs: number; - rootEndedAtMs?: number; - droppedSpans: number; -}; - -type FlushReason = 'trace_completed' | 'root_ended_grace' | 'max_trace_age' | 'force_flush'; - -/** - * Buffers spans per trace and exports once the request completes. - * Exports under the saved request Context to preserve request-local state. - * Agent365Exporter acquires credentials through its own app-only resolver, not this Context. - */ -export class PerRequestSpanProcessor implements SpanProcessor { - private traces = new Map(); - private sweepTimer?: NodeJS.Timeout; - private isSweeping = false; - - private readonly maxBufferedTraces: number; - private readonly maxSpansPerTrace: number; - private readonly maxConcurrentExports: number; - private readonly flushGraceMs: number; - private readonly maxTraceAgeMs: number; - - private inFlightExports = 0; - private exportWaiters: Array<() => void> = []; - - /** - * Construct a PerRequestSpanProcessor. - * @param exporter The span exporter to use. - * @param configProvider Optional configuration provider. Defaults to defaultPerRequestSpanProcessorConfigurationProvider if not specified. - */ - constructor( - private readonly exporter: SpanExporter, - configProvider?: IConfigurationProvider - ) { - const effectiveConfigProvider = configProvider ?? defaultPerRequestSpanProcessorConfigurationProvider; - const config = effectiveConfigProvider.getConfiguration(); - this.maxBufferedTraces = config.perRequestMaxTraces; - this.maxSpansPerTrace = config.perRequestMaxSpansPerTrace; - this.maxConcurrentExports = config.perRequestMaxConcurrentExports; - this.flushGraceMs = config.perRequestFlushGraceMs; - this.maxTraceAgeMs = config.perRequestMaxTraceAgeMs; - } - - onStart(span: ReadableSpan, ctx: Context): void { - const traceId = span.spanContext().traceId; - let buf = this.traces.get(traceId); - if (!buf) { - if (this.traces.size >= this.maxBufferedTraces) { - logger.warn( - `[PerRequestSpanProcessor] Dropping new trace due to maxBufferedTraces=${this.maxBufferedTraces} traceId=${traceId}` - ); - return; - } - - buf = { - spans: [], - openCount: 0, - rootEnded: false, - rootCtx: undefined, - startedAtMs: Date.now(), - droppedSpans: 0, - }; - this.traces.set(traceId, buf); - - this.ensureSweepTimer(); - - logger.info( - `[PerRequestSpanProcessor] Trace started traceId=${traceId} maxTraceAgeMs=${this.maxTraceAgeMs}` - ); - } - buf.openCount += 1; - - // Debug lifecycle: span started - logger.info( - `[PerRequestSpanProcessor] Span start name=${span.name} traceId=${traceId} spanId=${span.spanContext().spanId}` + - ` root=${isRootSpan(span)} openCount=${buf.openCount}` - ); - - // Capture a context to export under. - // - Use the first seen context as a fallback. - // - If/when the root span starts, prefer its request-local context. - if (isRootSpan(span)) { - buf.rootCtx = ctx; - } else { - buf.rootCtx ??= ctx; - } - } - - onEnd(span: ReadableSpan): void { - const traceId = span.spanContext().traceId; - const buf = this.traces.get(traceId); - if (!buf) return; - - if (buf.spans.length >= this.maxSpansPerTrace) { - buf.droppedSpans += 1; - if (buf.droppedSpans === 1 || buf.droppedSpans % 100 === 0) { - logger.warn( - `[PerRequestSpanProcessor] Dropping ended span due to maxSpansPerTrace=${this.maxSpansPerTrace} ` + - `traceId=${traceId} droppedSpans=${buf.droppedSpans}` - ); - } - } else { - buf.spans.push(span); - } - buf.openCount -= 1; - if (buf.openCount < 0) { - logger.warn( - `[PerRequestSpanProcessor] openCount underflow traceId=${traceId} spanId=${span.spanContext().spanId} resettingToZero` - ); - buf.openCount = 0; - } - - // Debug lifecycle: span ended - logger.info( - `[PerRequestSpanProcessor] Span end name=${span.name} traceId=${traceId} spanId=${span.spanContext().spanId}` + - ` root=${isRootSpan(span)} openCount=${buf.openCount} rootEnded=${buf.rootEnded}` - ); - - if (isRootSpan(span)) { - buf.rootEnded = true; - buf.rootEndedAtMs = Date.now(); - if (buf.openCount === 0) { - // Trace completed: root ended and no open spans remain. - this.flushTrace(traceId, 'trace_completed'); - } - } else if (buf.rootEnded && buf.openCount === 0) { - // Common case: root ends first, then children finish shortly after. - // Flush immediately when the last child ends instead of waiting for grace/max timers. - this.flushTrace(traceId, 'trace_completed'); - } - } - - async forceFlush(): Promise { - await Promise.all([...this.traces.keys()].map((id) => this.flushTrace(id, 'force_flush'))); - } - - async shutdown(): Promise { - await this.forceFlush(); - this.stopSweepTimerIfIdle(); - await this.exporter.shutdown?.(); - } - - private ensureSweepTimer(): void { - if (this.sweepTimer) return; - - // Keep one lightweight sweeper. Interval is derived from grace/max-age to keep responsiveness reasonable. - const intervalMs = Math.max(10, Math.min(this.flushGraceMs, 250)); - this.sweepTimer = setInterval(() => { - void this.sweep(); - }, intervalMs); - - this.sweepTimer.unref?.(); - } - - private stopSweepTimerIfIdle(): void { - if (this.traces.size !== 0) return; - if (!this.sweepTimer) return; - clearInterval(this.sweepTimer); - this.sweepTimer = undefined; - } - - private async sweep(): Promise { - if (this.isSweeping) return; - this.isSweeping = true; - try { - if (this.traces.size === 0) { - this.stopSweepTimerIfIdle(); - return; - } - - const now = Date.now(); - const toFlush: Array<{ traceId: string; reason: FlushReason }> = []; - - for (const [traceId, trace] of this.traces.entries()) { - // 1) Max age safety flush (clears buffers even if spans never end) - if (now - trace.startedAtMs >= this.maxTraceAgeMs) { - toFlush.push({ traceId, reason: 'max_trace_age' }); - continue; - } - - // 2) Root ended grace window flush (clears buffers if children never end) - if (trace.rootEnded && trace.openCount > 0 && trace.rootEndedAtMs) { - if (now - trace.rootEndedAtMs >= this.flushGraceMs) { - toFlush.push({ traceId, reason: 'root_ended_grace' }); - } - } - } - - // Flush in parallel; flushTrace removes entries eagerly. - await Promise.all(toFlush.map((x) => this.flushTrace(x.traceId, x.reason))); - this.stopSweepTimerIfIdle(); - } finally { - this.isSweeping = false; - } - } - - private async flushTrace(traceId: string, reason: FlushReason): Promise { - const trace = this.traces.get(traceId); - if (!trace) return; - - this.traces.delete(traceId); - this.stopSweepTimerIfIdle(); - - const spans = trace.spans; - if (spans.length === 0) return; - - logger.info( - `[PerRequestSpanProcessor] Flushing trace traceId=${traceId} reason=${reason} spans=${spans.length} rootEnded=${trace.rootEnded}` - ); - - // Restore the original request context for baggage and custom exporters. - if (!trace.rootCtx) { - logger.error(`[PerRequestSpanProcessor] Missing rootCtx for trace ${traceId}, cannot export spans`); - return; - } - - await this.acquireExportSlot(); - - try { - // Credentials are selected by the exporter, independently of this request context. - await new Promise((resolve) => { - try { - context.with(trace.rootCtx as Context, () => { - try { - this.exporter.export(spans, (result) => { - // Log export failures but still resolve to avoid blocking processor - if (result.code !== 0) { - logger.error( - `[PerRequestSpanProcessor] Export failed traceId=${traceId} reason=${reason} code=${result.code}`, - result.error - ); - } else { - logger.info( - `[PerRequestSpanProcessor] Export succeeded traceId=${traceId} reason=${reason} spans=${spans.length}` - ); - } - resolve(); - }); - } catch (err) { - logger.error( - `[PerRequestSpanProcessor] Export threw traceId=${traceId} reason=${reason} spans=${spans.length}`, - err - ); - resolve(); - } - }); - } catch (err) { - logger.error(`[PerRequestSpanProcessor] context.with threw traceId=${traceId} reason=${reason}`, err); - resolve(); - } - }); - } finally { - this.releaseExportSlot(); - } - } - - private async acquireExportSlot(): Promise { - if (this.maxConcurrentExports <= 0) return; - if (this.inFlightExports < this.maxConcurrentExports) { - this.inFlightExports += 1; - return; - } - - await new Promise((resolve) => { - this.exportWaiters.push(() => { - this.inFlightExports += 1; - resolve(); - }); - }); - } - - private releaseExportSlot(): void { - if (this.maxConcurrentExports <= 0) return; - this.inFlightExports = Math.max(0, this.inFlightExports - 1); - const next = this.exportWaiters.shift(); - if (next) next(); - } -} diff --git a/packages/agents-a365-observability/src/tracing/constants.ts b/packages/agents-a365-observability/src/tracing/constants.ts deleted file mode 100644 index a7868d21..00000000 --- a/packages/agents-a365-observability/src/tracing/constants.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { LIB_VERSION } from '../version'; - -/** - * OpenTelemetry constants for Agent 365 - */ -export class OpenTelemetryConstants { - // Span operation names - public static readonly INVOKE_AGENT_OPERATION_NAME = 'invoke_agent'; - public static readonly EXECUTE_TOOL_OPERATION_NAME = 'execute_tool'; - public static readonly OUTPUT_MESSAGES_OPERATION_NAME = 'output_messages'; - public static readonly CHAT_OPERATION_NAME = 'chat'; - public static readonly TEXT_COMPLETION_OPERATION_NAME = 'text_completion'; - public static readonly GENERATE_CONTENT_OPERATION_NAME = 'generate_content'; - - // OpenTelemetry semantic conventions - public static readonly ERROR_TYPE_KEY = 'error.type'; - public static readonly ERROR_TYPE_CANCELLED = 'TaskCanceledException'; - public static readonly ERROR_MESSAGE_KEY = 'error.message'; - public static readonly AZ_NAMESPACE_KEY = 'az.namespace'; - public static readonly SERVER_ADDRESS_KEY = 'server.address'; - public static readonly SERVER_PORT_KEY = 'server.port'; - public static readonly AZURE_RP_NAMESPACE_VALUE = 'Microsoft.CognitiveServices'; - public static readonly SOURCE_NAME = 'Agent365Sdk'; - 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 semantic conventions - 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'; - public static readonly GEN_AI_OPERATION_NAME_KEY = 'gen_ai.operation.name'; - public static readonly GEN_AI_REQUEST_MAX_TOKENS_KEY = 'gen_ai.request.max_tokens'; - public static readonly GEN_AI_REQUEST_MODEL_KEY = 'gen_ai.request.model'; - 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_RESPONSE_FINISH_REASONS_KEY = 'gen_ai.response.finish_reasons'; - public static readonly GEN_AI_RESPONSE_MODEL_KEY = 'gen_ai.response.model'; - public static readonly GEN_AI_AGENT_ID_KEY = 'gen_ai.agent.id'; - public static readonly GEN_AI_AGENT_NAME_KEY = 'gen_ai.agent.name'; - public static readonly GEN_AI_AGENT_DESCRIPTION_KEY = 'gen_ai.agent.description'; - public static readonly GEN_AI_AGENT_TYPE_KEY = 'gen_ai.agent.type'; - public static readonly GEN_AI_AGENT_VERSION_KEY = 'gen_ai.agent.version'; - public static readonly GEN_AI_AGENT_PLATFORM_ID_KEY = 'microsoft.a365.agent.platform.id'; - public static readonly GEN_AI_AGENT_THOUGHT_PROCESS_KEY = 'microsoft.a365.agent.thought.process'; - public static readonly GEN_AI_CONVERSATION_ID_KEY = 'gen_ai.conversation.id'; - public static readonly GEN_AI_CONVERSATION_ITEM_LINK_KEY = 'microsoft.conversation.item.link'; - public static readonly GEN_AI_TOKEN_TYPE_KEY = 'gen_ai.token.type'; - public static readonly GEN_AI_USAGE_INPUT_TOKENS_KEY = 'gen_ai.usage.input_tokens'; - public static readonly GEN_AI_USAGE_OUTPUT_TOKENS_KEY = 'gen_ai.usage.output_tokens'; - public static readonly GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY = 'gen_ai.usage.cache_read.input_tokens'; - public static readonly GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY = 'gen_ai.usage.cache_write.input_tokens'; - public static readonly GEN_AI_CHOICE = 'gen_ai.choice'; - public static readonly GEN_AI_PROVIDER_NAME_KEY = 'gen_ai.provider.name'; - - public static readonly GEN_AI_SYSTEM_INSTRUCTIONS_KEY = 'gen_ai.system_instructions'; - public static readonly GEN_AI_INPUT_MESSAGES_KEY = 'gen_ai.input.messages'; - public static readonly GEN_AI_OUTPUT_MESSAGES_KEY = 'gen_ai.output.messages'; - public static readonly A365_MESSAGES_SCHEMA_VERSION_KEY = 'microsoft.a365.messages.schema_version'; - // Tool execution constants - public static readonly GEN_AI_TOOL_CALL_ID_KEY = 'gen_ai.tool.call.id'; - public static readonly GEN_AI_TOOL_NAME_KEY = 'gen_ai.tool.name'; - public static readonly GEN_AI_TOOL_DESCRIPTION_KEY = 'gen_ai.tool.description'; - public static readonly GEN_AI_TOOL_ARGS_KEY = 'gen_ai.tool.call.arguments'; - public static readonly GEN_AI_TOOL_CALL_RESULT_KEY = 'gen_ai.tool.call.result'; - public static readonly GEN_AI_TOOL_TYPE_KEY = 'gen_ai.tool.type'; - - // Human caller dimensions (OTel user.* namespace) - public static readonly USER_ID_KEY = 'user.id'; - public static readonly USER_NAME_KEY = 'user.name'; - public static readonly USER_EMAIL_KEY = 'user.email'; - public static readonly GEN_AI_CALLER_CLIENT_IP_KEY = 'client.address'; - - // Agent to Agent caller agent dimensions - public static readonly GEN_AI_CALLER_AGENT_USER_ID_KEY = 'microsoft.a365.caller.agent.user.id'; - public static readonly GEN_AI_CALLER_AGENT_EMAIL_KEY = 'microsoft.a365.caller.agent.user.email'; - public static readonly GEN_AI_CALLER_AGENT_NAME_KEY = 'microsoft.a365.caller.agent.name'; - public static readonly GEN_AI_CALLER_AGENT_ID_KEY = 'microsoft.a365.caller.agent.id'; - public static readonly GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY = 'microsoft.a365.caller.agent.blueprint.id'; - public static readonly GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY = 'microsoft.a365.caller.agent.platform.id'; - public static readonly GEN_AI_CALLER_AGENT_VERSION_KEY = 'microsoft.a365.caller.agent.version'; - // Agent-specific dimensions - public static readonly AGENT_ID_KEY = 'gen_ai.agent.id'; - public static readonly GEN_AI_TASK_ID_KEY = 'gen_ai.task.id'; - public static readonly SESSION_ID_KEY = 'microsoft.session.id'; - public static readonly SESSION_DESCRIPTION_KEY = 'microsoft.session.description'; - public static readonly GEN_AI_ICON_URI_KEY = 'gen_ai.agent365.icon_uri'; - public static readonly TENANT_ID_KEY = 'microsoft.tenant.id'; - - // Baggage keys - public static readonly GEN_AI_AGENT_AUID_KEY = 'microsoft.agent.user.id'; - public static readonly GEN_AI_AGENT_EMAIL_KEY = 'microsoft.agent.user.email'; - public static readonly GEN_AI_AGENT_BLUEPRINT_ID_KEY = 'microsoft.a365.agent.blueprint.id'; - - // Execution context dimensions - 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'; - - // Custom parent id and parent name key - public static readonly CUSTOM_PARENT_SPAN_ID_KEY = 'custom.parent.span.id'; - public static readonly CUSTOM_SPAN_NAME_KEY = 'custom.span.name'; - - // Service attributes - public static readonly SERVICE_NAME_KEY = 'service.name'; - - // Telemetry SDK attributes - public static readonly TELEMETRY_SDK_NAME_KEY = 'telemetry.sdk.name'; - public static readonly TELEMETRY_SDK_LANGUAGE_KEY = 'telemetry.sdk.language'; - public static readonly TELEMETRY_SDK_VERSION_KEY = 'telemetry.sdk.version'; - public static readonly TELEMETRY_SDK_NAME_VALUE = 'A365ObservabilitySDK'; - public static readonly TELEMETRY_SDK_LANGUAGE_VALUE = 'nodejs'; - public static readonly TELEMETRY_SDK_VERSION_VALUE = LIB_VERSION; - - // Guardrail operation name - public static readonly APPLY_GUARDRAIL_OPERATION_NAME = 'apply_guardrail'; - - // Guardian attributes - public static readonly GUARDIAN_ID_KEY = 'microsoft.guardian.id'; - public static readonly GUARDIAN_NAME_KEY = 'microsoft.guardian.name'; - public static readonly GUARDIAN_PROVIDER_NAME_KEY = 'microsoft.guardian.provider.name'; - public static readonly GUARDIAN_VERSION_KEY = 'microsoft.guardian.version'; - - // Security decision attributes - public static readonly SECURITY_DECISION_TYPE_KEY = 'microsoft.security.decision.type'; - public static readonly SECURITY_DECISION_REASON_KEY = 'microsoft.security.decision.reason'; - public static readonly SECURITY_DECISION_CODE_KEY = 'microsoft.security.decision.code'; - - // Security target attributes - public static readonly SECURITY_TARGET_TYPE_KEY = 'microsoft.security.target.type'; - public static readonly SECURITY_TARGET_ID_KEY = 'microsoft.security.target.id'; - - // Security policy attributes - public static readonly SECURITY_POLICY_ID_KEY = 'microsoft.security.policy.id'; - public static readonly SECURITY_POLICY_NAME_KEY = 'microsoft.security.policy.name'; - public static readonly SECURITY_POLICY_VERSION_KEY = 'microsoft.security.policy.version'; - public static readonly SECURITY_POLICY_DECISION_TYPE_KEY = 'microsoft.security.policy.decision.type'; - - // Security content attributes - public static readonly SECURITY_CONTENT_INPUT_HASH_KEY = 'microsoft.security.content.input.hash'; - public static readonly SECURITY_CONTENT_MODIFIED_KEY = 'microsoft.security.content.modified'; - public static readonly SECURITY_CONTENT_INPUT_VALUE_KEY = 'microsoft.security.content.input.value'; - public static readonly SECURITY_CONTENT_OUTPUT_VALUE_KEY = 'microsoft.security.content.output.value'; - - // Security correlation attributes - public static readonly SECURITY_EXTERNAL_EVENT_ID_KEY = 'microsoft.security.external_event_id'; - - // Security finding event - public static readonly SECURITY_FINDING_EVENT_NAME = 'microsoft.security.finding'; - public static readonly SECURITY_RISK_CATEGORY_KEY = 'microsoft.security.risk.category'; - public static readonly SECURITY_RISK_SEVERITY_KEY = 'microsoft.security.risk.severity'; - public static readonly SECURITY_RISK_SCORE_KEY = 'microsoft.security.risk.score'; - public static readonly SECURITY_RISK_METADATA_KEY = 'microsoft.security.risk.metadata'; -} diff --git a/packages/agents-a365-observability/src/tracing/context/parent-span-context.ts b/packages/agents-a365-observability/src/tracing/context/parent-span-context.ts deleted file mode 100644 index d59ebfdf..00000000 --- a/packages/agents-a365-observability/src/tracing/context/parent-span-context.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { context, trace, Context, SpanContext, TraceFlags, TraceState } from '@opentelemetry/api'; -import logger from '../../utils/logging'; - -/** - * Reference to a parent span for explicit parent-child linking across async boundaries. - * Used when automatic context propagation fails (e.g., WebSocket callbacks, external event handlers). - */ -export interface ParentSpanRef { - /** - * Trace ID (32-character hex string) - */ - traceId: string; - - /** - * Span ID (16-character hex string) - */ - spanId: string; - - /** - * Optional trace flags - */ - traceFlags?: TraceFlags; - - /** - * Optional W3C trace state for vendor-specific trace context data - */ - traceState?: TraceState; - - /** - * Whether the span originated from a remote service (default: true) - */ - isRemote?: boolean; -} - -function isValidTraceId(traceId: string): boolean { - return /^[0-9a-f]{32}$/i.test(traceId) && traceId !== '00000000000000000000000000000000'; -} - -function isValidSpanId(spanId: string): boolean { - return /^[0-9a-f]{16}$/i.test(spanId) && spanId !== '0000000000000000'; -} - -/** - * Creates a new Context with an explicit parent span reference. - * This allows child spans to be correctly parented even when async context is broken. - * - * @param base The base context to extend (typically context.active()) - * @param parent The parent span reference containing traceId and spanId - * @returns A new Context with the parent span set - */ -export function createContextWithParentSpanRef(base: Context, parent: ParentSpanRef): Context { - logger.info(`[ParentSpanContext] Creating context with parent span: traceId=${parent.traceId}, spanId=${parent.spanId}`); - - if (!isValidTraceId(parent.traceId) || !isValidSpanId(parent.spanId)) { - logger.warn( - `[ParentSpanContext] Invalid parent span reference; returning base context. traceId=${parent.traceId}, spanId=${parent.spanId}` - ); - return base; - } - - // Determine traceFlags: - // 1. Use parent.traceFlags if explicitly provided - // 2. Inherit from active span if its traceId matches - // 3. Default to SAMPLED — manually instrumented spans should always be captured - const activeCtx = trace.getSpan(base)?.spanContext(); - const traceFlags = - parent.traceFlags - ?? (activeCtx?.traceId === parent.traceId ? activeCtx.traceFlags : undefined) - ?? TraceFlags.SAMPLED; - - // Create a SpanContext from the parent reference - const parentSpanContext: SpanContext = { - traceId: parent.traceId, - spanId: parent.spanId, - traceFlags, - traceState: parent.traceState, - isRemote: parent.isRemote ?? true, - }; - - // Create a non-recording span with the parent context - const parentSpan = trace.wrapSpanContext(parentSpanContext); - - // Set this span in the base context - const contextWithParent = trace.setSpan(base, parentSpan); - - return contextWithParent; -} - -/** - * Runs a callback function within a context that has an explicit parent span reference. - * This is useful for creating child spans in async callbacks where context propagation is broken. - * - * @param parent The parent span reference - * @param callback The function to execute with the parent context - * @returns The result of the callback - */ -export function runWithParentSpanRef(parent: ParentSpanRef, callback: () => T): T { - const base = context.active(); - const contextWithParent = createContextWithParentSpanRef(base, parent); - logger.info('[ParentSpanContext] Running callback with parent span context.'); - return context.with(contextWithParent, callback); -} diff --git a/packages/agents-a365-observability/src/tracing/context/token-context.ts b/packages/agents-a365-observability/src/tracing/context/token-context.ts deleted file mode 100644 index cdcf7e7b..00000000 --- a/packages/agents-a365-observability/src/tracing/context/token-context.ts +++ /dev/null @@ -1,69 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -import { context, createContextKey, Context } from '@opentelemetry/api'; -import logger from '../../utils/logging'; - -const EXPORT_TOKEN_KEY = createContextKey('a365_export_token'); - -/** - * Mutable holder stored in Context so the token can be refreshed - * after the context is created (OTel contexts are immutable, but - * the object reference stays the same). - */ -interface TokenHolder { - token: string; -} - -/** - * Run a function within a Context that carries the per-request export token. - * This keeps the token only in OTel Context (ALS), never in any registry. - * These helpers remain available for custom export integrations. Agent365Exporter - * does not consume this token; configure its app-only tokenResolver in every mode. - * - * The token can be updated later via `updateExportToken()` before the trace - * is flushed — useful when the callback is long-running and the original - * token may expire before export. - */ -export function runWithExportToken(token: string, fn: () => T): T { - const holder: TokenHolder = { token }; - const ctxWithToken = context.active().setValue(EXPORT_TOKEN_KEY, holder); - logger.info('[TokenContext] Running function with export token in context.'); - return context.with(ctxWithToken, fn); -} - -/** - * Update the export token in the active OTel Context. - * Call this to refresh the token before ending the root span when the - * original token may have expired during a long-running request. - * - * Must be called within the same async context created by `runWithExportToken`. - * @param token The fresh token to use for export. - * @returns true if the token was updated successfully, false if no token holder was found. - */ -export function updateExportToken(token: string): boolean { - const value = context.active().getValue(EXPORT_TOKEN_KEY); - if (value && typeof value === 'object' && 'token' in value) { - (value as TokenHolder).token = token; - logger.info('[TokenContext] Export token updated in context.'); - return true; - } - logger.warn('[TokenContext] updateExportToken called but no token holder found in active context. Was runWithExportToken called?'); - return false; -} - -/** - * Retrieve the per-request export token from a given OTel Context (or the active one). - */ -export function getExportToken(ctx: Context = context.active()): string | undefined { - const value = ctx.getValue(EXPORT_TOKEN_KEY); - if (value && typeof value === 'object' && 'token' in value) { - return (value as TokenHolder).token; - } - // Backward compat: support raw string values from older callers - if (typeof value === 'string') { - return value; - } - return undefined; -} diff --git a/packages/agents-a365-observability/src/tracing/context/trace-context-propagation.ts b/packages/agents-a365-observability/src/tracing/context/trace-context-propagation.ts deleted file mode 100644 index 6e603a7b..00000000 --- a/packages/agents-a365-observability/src/tracing/context/trace-context-propagation.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { context, propagation, Context } from '@opentelemetry/api'; -import { ParentSpanRef } from './parent-span-context'; - -/** - * Carrier type for HTTP headers used in trace context propagation. - * Compatible with Node.js IncomingHttpHeaders and plain string maps. - */ -export type HeadersCarrier = Record; - -/** - * A parent context for span creation. Accepts either: - * - {@link ParentSpanRef}: explicit traceId/spanId pair (manual approach) - * - {@link Context}: an OTel Context, typically from {@link extractContextFromHeaders} or `propagation.extract()` - */ -export type ParentContext = ParentSpanRef | Context; - -/** - * Type guard to distinguish a {@link ParentSpanRef} from an OTel {@link Context}. - */ -export function isParentSpanRef(value: ParentContext): value is ParentSpanRef { - if (typeof value !== 'object' || value === null) return false; - - const maybeCtx = value as Context; - if ( - typeof maybeCtx.getValue === 'function' && - typeof maybeCtx.setValue === 'function' && - typeof maybeCtx.deleteValue === 'function' - ) { - return false; - } - - const maybeRef = value as ParentSpanRef; - return ( - 'traceId' in maybeRef && - typeof maybeRef.traceId === 'string' && - 'spanId' in maybeRef && - typeof maybeRef.spanId === 'string' - ); -} - -/** - * Injects the current trace context (`traceparent`/`tracestate` headers) into - * the provided headers object using the globally registered W3C propagator. - * - * @param headers Mutable object where trace context headers will be written. - * @param ctx Optional OTel Context to inject from. Defaults to the active context. - * @returns The same `headers` object, for chaining convenience. - * - * @example - * ```typescript - * const headers: Record = {}; - * injectContextToHeaders(headers); - * await fetch('http://service-b/process', { headers }); - * ``` - */ -export function injectContextToHeaders( - headers: Record, - ctx?: Context -): Record { - propagation.inject(ctx ?? context.active(), headers); - return headers; -} - -/** - * Extracts trace context from incoming HTTP headers using the globally - * registered W3C propagator. Returns an OTel {@link Context} that can be - * passed to scope classes as a {@link ParentContext}. - * - * @param headers The incoming HTTP request headers containing `traceparent`/`tracestate`. - * @param baseCtx Optional base context to extend. Defaults to the active context. - * @returns An OTel Context containing the extracted trace information. - * - * @example - * ```typescript - * const parentCtx = extractContextFromHeaders(req.headers); - * const scope = InvokeAgentScope.start(request, scopeDetails, agentDetails, undefined, { parentContext: parentCtx }); - * ``` - */ -export function extractContextFromHeaders( - headers: HeadersCarrier, - baseCtx?: Context -): Context { - return propagation.extract(baseCtx ?? context.active(), headers); -} - -/** - * Extracts trace context from incoming HTTP headers and runs the callback - * within that context. Any spans created inside the callback will be - * parented to the extracted trace. - * - * @param headers The incoming HTTP request headers containing `traceparent`/`tracestate`. - * @param callback The function to execute within the extracted context. - * @returns The result of the callback. - * - * @example - * ```typescript - * runWithExtractedTraceContext(req.headers, () => { - * const scope = InvokeAgentScope.start(request, scopeDetails, agentDetails); - * scope.dispose(); - * }); - * ``` - */ -export function runWithExtractedTraceContext(headers: HeadersCarrier, callback: () => T): T { - return context.with(extractContextFromHeaders(headers), callback); -} diff --git a/packages/agents-a365-observability/src/tracing/contracts.ts b/packages/agents-a365-observability/src/tracing/contracts.ts deleted file mode 100644 index 850c2e65..00000000 --- a/packages/agents-a365-observability/src/tracing/contracts.ts +++ /dev/null @@ -1,610 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { SpanKind, TimeInput, Link } from '@opentelemetry/api'; -import type { ParentContext } from './context/trace-context-propagation'; - - -/** - * Represents different roles that can invoke an agent - */ -export enum InvocationRole { - /** Human user invoking the agent */ - Human = 'Human', - - /** Another agent invoking this agent */ - Agent = 'Agent', - - /** Event-driven invocation (e.g., scheduled, webhook, message queue) */ - Event = 'Event', - - /** Unknown or unspecified role */ - Unknown = 'Unknown' -} - -/** - * Represents different operation for types for model inference - */ -export enum InferenceOperationType { - CHAT = 'Chat', - TEXT_COMPLETION = 'TextCompletion', - GENERATE_CONTENT = 'GenerateContent' -} - -/** - * Represents channel for an invocation - */ -export interface Channel { - /** Unique identifier for the channel (e.g., Teams channel ID, Slack workspace ID) */ - id?: string; - - /** Human-readable name of the channel (e.g., "Teams", "Slack", "web") */ - name?: string; - - /** Optional icon identifier or URL for visual representation of the channel */ - iconUri?: string; - - /** The role of the entity invoking through this channel */ - role?: InvocationRole; - - /** Optional description or link providing additional context about the channel */ - description?: string; -} - -/** - * Represents a request with telemetry context. - * Used across all scope types for channel and conversation tracking. - */ -export interface Request { - /** The content of the request. */ - content?: InputMessagesParam; - - /** Optional session identifier for grouping related requests */ - sessionId?: string; - - /** Optional channel for the invocation */ - channel?: Channel; - - /** Optional conversation identifier */ - conversationId?: string; -} - - -/** - * Details about an AI agent - */ -export interface AgentDetails { - /** The unique identifier for the AI agent */ - agentId: string; - - /** The human-readable name of the AI agent */ - agentName?: string; - - /** A description of the AI agent's purpose or capabilities */ - agentDescription?: string; - - /** The type of the AI agent */ - agentType?: string; - - /** Optional icon identifier or URL for visual representation of the agent */ - iconUri?: string; - - /** Optional platform identifier for the agent */ - platformId?: string; - - /** The agent user ID (AUID) */ - agentAUID?: string; - - /** The agent email address */ - agentEmail?: string; - - /** The agent blueprint/application ID */ - agentBlueprintId?: string; - - /** The tenant ID for the agent */ - tenantId?: string; - - /** The provider name (e.g., az.ai.agent365, openai, anthropic) */ - providerName?: string; - - /** The version of the agent (e.g., '1.0.0', '2025-05-01') */ - agentVersion?: string; -} - -/** - * Details of a tool call made by an agent - */ -export interface ToolCallDetails { - /** The name of the tool being executed */ - toolName: string; - - /** Tool arguments/parameters. Objects are serialized to JSON automatically. */ - arguments?: Record | string; - - /** The unique identifier for the tool call */ - toolCallId?: string; - - /** Optional description of the tool or its purpose */ - description?: string; - - /** The type of the tool being executed */ - toolType?: string; - - /** The endpoint for the tool execution */ - endpoint?: ServiceEndpoint; -} - -/** - * Details about the human user caller. - */ -export interface UserDetails { - /** The unique identifier for the caller */ - userId?: string; - - /** The email address of the caller */ - userEmail?: string; - - /** The display name of the caller */ - userName?: string; - - /** The tenant ID of the caller */ - tenantId?: string; - - /** The client IP address for the caller */ - callerClientIp?: string; -} - -/** - * Caller details for scope creation. - * Supports human callers, agent callers, or both (A2A with a human in the chain). - * - * **Migration note:** In v1 the name `CallerDetails` referred to human caller - * identity (now {@link UserDetails}). In v2 it was repurposed as a wrapper that - * groups both human and agent caller information. - * - * @see {@link UserDetails} — human caller identity (previously `CallerDetails`) - * @see CHANGELOG.md — breaking changes section for migration guidance - */ -export interface CallerDetails { - /** Optional human caller identity */ - userDetails?: UserDetails; - - /** Optional calling agent identity for A2A (agent-to-agent) scenarios */ - callerAgentDetails?: AgentDetails; -} - -/** - * @deprecated Use AgentDetails. EnhancedAgentDetails is now an alias of AgentDetails. - */ -export type EnhancedAgentDetails = AgentDetails; - -/** - * Represents an endpoint for agent invocation - */ -export interface ServiceEndpoint { - /** The host address */ - host: string; - - /** The port number */ - port?: number; - - /** The protocol (e.g., http, https) */ - protocol?: string; -} - -/** - * Details for invoking agent scope. - */ -export interface InvokeAgentScopeDetails { - /** The endpoint for the agent invocation */ - endpoint?: ServiceEndpoint; -} - -/** - * Details for an inference call - */ -export interface InferenceDetails { - /** The operation name/type for the inference */ - operationName: InferenceOperationType; - - /** The requested model name/identifier. At least one of model or responseModel is required. */ - model?: string; - - /** The provider name (e.g., openai, azure, anthropic) */ - providerName: string; - - /** The model name/identifier returned by the provider */ - responseModel?: string; - - /** Number of input tokens used */ - inputTokens?: number; - - /** Number of output tokens generated */ - outputTokens?: number; - - /** Number of input tokens read from a cache */ - cacheReadInputTokens?: number; - - /** Number of input tokens written to a cache */ - cacheWriteInputTokens?: number; - - /** Array of finish reasons */ - finishReasons?: string[]; - - /** The thought process used by the agent */ - thoughtProcess?: string; - - /** The endpoint for the inference call */ - endpoint?: ServiceEndpoint; -} - -/** - * Details for recording the response from an inference call - */ -export interface InferenceResponse { - /** The generated response content */ - content: string; - - /** Response ID from the model provider */ - responseId?: string; - - /** Finish reason (e.g., stop, length, content_filter) */ - finishReason?: string; - - /** Number of input tokens used */ - inputTokens?: number; - - /** Number of output tokens generated */ - outputTokens?: number; -} - -/** - * Represents a response containing output messages from an agent. - * Used with OutputScope for output message tracing. - * Accepts plain strings, structured OTEL OutputMessage objects, or a raw dict - * (treated as a tool call result per OTEL spec). - */ -export interface OutputResponse { - /** The output messages from the agent */ - messages: ResponseMessagesParam; -} - -/** - * Span configuration details for scope creation. - * Groups OpenTelemetry span options into a single object so the scope - * method signature remains stable as new options are added. - */ -export interface SpanDetails { - /** Optional parent context for cross-async-boundary tracing. - * Accepts a ParentSpanRef (manual traceId/spanId) or an OTel Context - * (e.g. from extractContextFromHeaders). */ - parentContext?: ParentContext; - - /** Optional explicit start time (ms epoch, Date, or HrTime). */ - startTime?: TimeInput; - - /** Optional explicit end time (ms epoch, Date, or HrTime). */ - endTime?: TimeInput; - - /** Optional span kind override. */ - spanKind?: SpanKind; - - /** Optional span links to associate with this span. */ - spanLinks?: Link[]; -} - -// --------------------------------------------------------------------------- -// OpenTelemetry Semantic Convention – Gen-AI Message Format -// https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-input-messages.json -// https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-output-messages.json -// --------------------------------------------------------------------------- - -/** - * Role of a message participant per OTEL gen-ai semantic conventions. - */ -export enum MessageRole { - SYSTEM = 'system', - USER = 'user', - ASSISTANT = 'assistant', - TOOL = 'tool' -} - -/** - * Reason a model stopped generating per OTEL gen-ai semantic conventions. - */ -export enum FinishReason { - STOP = 'stop', - LENGTH = 'length', - CONTENT_FILTER = 'content_filter', - TOOL_CALL = 'tool_call', - ERROR = 'error' -} - -/** - * Media modality for blob, file, and URI parts. - */ -export enum Modality { - IMAGE = 'image', - VIDEO = 'video', - AUDIO = 'audio' -} - -// ---- Message part types (discriminated union on `type`) -------------------- - -/** Plain text content. */ -export interface TextPart { - type: 'text'; - content: string; -} - -/** A tool call requested by the model. */ -export interface ToolCallRequestPart { - type: 'tool_call'; - name: string; - id?: string; - arguments?: Record | unknown[]; -} - -/** Result of a tool call. */ -export interface ToolCallResponsePart { - type: 'tool_call_response'; - id?: string; - response?: unknown; -} - -/** Model reasoning / chain-of-thought content. */ -export interface ReasoningPart { - type: 'reasoning'; - content: string; -} - -/** Inline binary data (base64-encoded). */ -export interface BlobPart { - type: 'blob'; - modality: Modality | string; - mime_type?: string; - content: string; -} - -/** Reference to a pre-uploaded file. */ -export interface FilePart { - type: 'file'; - modality: Modality | string; - mime_type?: string; - file_id: string; -} - -/** External URI reference. */ -export interface UriPart { - type: 'uri'; - modality: Modality | string; - mime_type?: string; - uri: string; -} - -/** Extensible server tool call details with a type discriminator. */ -export interface GenericServerToolCall { - type: string; - [key: string]: unknown; -} - -/** Extensible server tool call response with a type discriminator. */ -export interface GenericServerToolCallResponse { - type: string; - [key: string]: unknown; -} - -/** Server-side tool invocation. */ -export interface ServerToolCallPart { - type: 'server_tool_call'; - name: string; - id?: string; - server_tool_call: GenericServerToolCall; -} - -/** Server-side tool response. */ -export interface ServerToolCallResponsePart { - type: 'server_tool_call_response'; - id?: string; - server_tool_call_response: GenericServerToolCallResponse; -} - -/** Extensible part for custom / future types. */ -export interface GenericPart { - type: string; - [key: string]: unknown; -} - -/** - * Union of all message part types per OTEL gen-ai semantic conventions. - * - * Note: {@link GenericPart} acts as a catch-all for forward compatibility with - * custom or future part types. Because its `type` is `string` (not a literal), - * exhaustive `switch`/`case` on `part.type` will not produce compile-time errors - * for unhandled cases. - */ -export type MessagePart = - | TextPart - | ToolCallRequestPart - | ToolCallResponsePart - | ReasoningPart - | BlobPart - | FilePart - | UriPart - | ServerToolCallPart - | ServerToolCallResponsePart - | GenericPart; - -/** - * An input message sent to a model (OTEL gen-ai semantic conventions). - */ -export interface ChatMessage { - role: MessageRole | string; - parts: MessagePart[]; - name?: string; -} - -export interface InputMessages { - messages: ChatMessage[]; -} -/** - * An output message produced by a model (OTEL gen-ai semantic conventions). - * `finish_reason` defaults to `"stop"` per OTel spec when not provided. - */ -export interface OutputMessage extends ChatMessage { - finish_reason?: FinishReason | string; -} - -export interface OutputMessages { - messages: OutputMessage[]; -} - -/** Default finish reason applied when none is provided (per OTel spec). */ -export const DEFAULT_FINISH_REASON = 'stop' as const; - -/** Accepted input for `recordInputMessages`. Supports a single string, an array of strings (backward compat), or the structured wrapper. */ -export type InputMessagesParam = string | string[] | InputMessages; - -/** Accepted input for `recordOutputMessages`. Supports a single string, an array of strings (backward compat), or the structured wrapper. */ -export type OutputMessagesParam = string | string[] | OutputMessages; - -/** - * Accepted input for `OutputResponse.messages`. - * Supports plain strings, structured OutputMessages, or a raw dict (treated as a tool call result - * per OTEL spec and serialized directly via JSON.stringify). - */ -export type ResponseMessagesParam = OutputMessagesParam | Record; - -// --------------------------------------------------------------------------- -// Guardrail / Security Contracts -// --------------------------------------------------------------------------- - -/** - * The decision made by a security guardian during guardrail evaluation. - */ -export enum GuardrailDecisionType { - /** Content or action is allowed to proceed. */ - Allow = 'allow', - - /** Content or action is logged for review but allowed to proceed. */ - Audit = 'audit', - - /** Content or action is denied/blocked. */ - Deny = 'deny', - - /** Content was modified (e.g., redacted, sanitized, rewritten). */ - Modify = 'modify', - - /** Content or action triggered a warning but is allowed to proceed. */ - Warn = 'warn', -} - -/** - * Well-known severity levels for security risks detected by guardrails. - */ -export const GuardrailRiskSeverity = { - None: 'none', - Low: 'low', - Medium: 'medium', - High: 'high', - Critical: 'critical', -} as const; - -/** - * Well-known values for the type of content or action a guardrail is applied to. - * Custom strings are also accepted. - */ -export const GuardrailTargetType = { - LlmInput: 'llm_input', - LlmOutput: 'llm_output', - ToolCall: 'tool_call', - ToolDefinition: 'tool_definition', - MemoryStore: 'memory_store', - MemoryRetrieve: 'memory_retrieve', - KnowledgeQuery: 'knowledge_query', - KnowledgeResult: 'knowledge_result', - Message: 'message', -} as const; - -/** - * Details of a guardrail evaluation for security operations tracing. - */ -export interface GuardrailDetails { - /** The type of content or action the guardrail is applied to (required). */ - targetType: string; - - /** The decision made by the guardian (required). */ - decisionType: GuardrailDecisionType; - - /** Human-readable name of the guardian. */ - guardianName?: string; - - /** Unique identifier of the guardian. */ - guardianId?: string; - - /** Provider of the guardian service (e.g., azure.ai.content_safety). */ - guardianProviderName?: string; - - /** Version of the guardian. */ - guardianVersion?: string; - - /** Identifier of the target being guarded. */ - targetId?: string; - - /** Human-readable explanation for the decision. */ - decisionReason?: string; - - /** Machine-readable decision code. */ - decisionCode?: string; - - /** Identifier of the policy that triggered the decision. */ - policyId?: string; - - /** Human-readable name of the policy. */ - policyName?: string; - - /** Version of the policy. */ - policyVersion?: string; - - /** Hash of the input content for forensic correlation. */ - contentInputHash?: string; - - /** Whether content was modified by the guardrail. */ - contentModified?: boolean; - - /** External correlation identifier for SIEM systems. */ - externalEventId?: string; -} - -/** - * Represents a single security finding detected during guardian evaluation. - * Multiple findings may be emitted for a single guardrail span. - */ -export interface GuardrailFinding { - /** The category of security risk detected (required). */ - riskCategory: string; - - /** The severity level of the detected risk (required). */ - riskSeverity: string; - - /** The decision type for this specific policy finding. */ - policyDecisionType?: string; - - /** Identifier of the policy that triggered the finding. */ - policyId?: string; - - /** Human-readable name of the triggered policy. */ - policyName?: string; - - /** Version of the policy. */ - policyVersion?: string; - - /** Numeric risk/confidence score (0.0 to 1.0). */ - riskScore?: number; - - /** - * Non-content metadata about the detected risk (MUST NOT contain PII). - * Example values: "field:bcc", "pattern:ssn", "count:3". - */ - riskMetadata?: string[]; -} diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts deleted file mode 100644 index 7b58c557..00000000 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ /dev/null @@ -1,473 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { ExportResult, ExportResultCode } from '@opentelemetry/core'; -import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; - -import { ClusterCategory, IConfigurationProvider } from '@microsoft/agents-a365-runtime'; -import type { ObservabilityConfiguration } from '../../configuration'; -import { - partitionByIdentity, - parseIdentityKey, - hexTraceId, - hexSpanId, - kindName, - statusName, - resolveAgent365Endpoint, - getAgent365ObservabilityDomainOverride, - truncateSpan, - estimateSpanBytes, - chunkBySize, -} from './utils'; -import logger, { formatError } from '../../utils/logging'; -import { Agent365ExporterOptions } from './Agent365ExporterOptions'; -import { ExporterEventNames } from './ExporterEventNames'; - -const DEFAULT_MAX_RETRIES = 3; - -interface OTLPExportRequest { - resourceSpans: ResourceSpan[]; -} - -interface ResourceSpan { - resource: { - attributes: Record | null; - }; - scopeSpans: ScopeSpan[]; -} - -interface ScopeSpan { - scope: { - name: string; - version?: string; - }; - spans: OTLPSpan[]; -} - -interface OTLPSpan { - traceId: string; - spanId: string; - parentSpanId?: string; - name: string; - kind: string; - startTimeUnixNano: number; - endTimeUnixNano: number; - attributes: Record | null; - events?: OTLPEvent[] | null; - links?: OTLPLink[] | null; - status: OTLPStatus; -} - -interface OTLPEvent { - timeUnixNano: number; - name: string; - attributes?: Record | null; -} - -interface OTLPLink { - traceId: string; - spanId: string; - attributes?: Record | null; -} - -interface OTLPStatus { - code: string; - message?: string; -} - -interface MappedSpan { - span: OTLPSpan; - scopeKey: string; - scopeName: string; - scopeVersion?: string; -} - -/** - * Observability span exporter for Agent365: - * - Partitions spans by (tenantId, agentId) - * - Builds OTLP-like JSON: resourceSpans -> scopeSpans -> spans - * - POSTs per group to https://{endpoint}/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1 - * - Adds Bearer token via token_resolver(agentId, tenantId) - */ -export class Agent365Exporter implements SpanExporter { - private closed = false; - private readonly options: Agent365ExporterOptions; - private readonly configProvider?: IConfigurationProvider; - - /** - * Initialize exporter with a fully constructed options instance. - * @param options Exporter options controlling batching, timeouts, token acquisition and endpoint shape. - * @param configProvider Optional configuration provider. When supplied, the exporter uses it for - * configuration lookups (custom domain, domain override) instead of the default env-based provider. - */ - constructor(options: Agent365ExporterOptions, configProvider?: IConfigurationProvider) { - if (!options) { - throw new Error('Agent365ExporterOptions must be provided (was null/undefined)'); - } - - if (typeof options.tokenResolver !== 'function') { - throw new Error( - 'Agent365Exporter requires an app-only OBS tokenResolver. ' - + 'Per-request export now requires withTokenResolver(...) or ' - + 'Agent365ExporterOptions.tokenResolver; it no longer reads tokens from ' - + 'runWithExportToken. Resolvers should cache the acquired token; see ' - + 'AgenticTokenCache in @microsoft/agents-a365-observability-hosting.', - ); - } - this.options = options; - this.configProvider = configProvider; - } - - /** - * Export spans to Agent365 service - */ - async export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): Promise { - if (this.closed) { - resultCallback({ code: ExportResultCode.FAILED }); - return; - } - - const startTime = Date.now(); - - try { - logger.info(`[Agent365Exporter] Exporting ${spans.length} spans`); - const groups = partitionByIdentity(spans); - - if (groups.size === 0) { - logger.info('[Agent365Exporter] No groups to export'); - resultCallback({ code: ExportResultCode.SUCCESS }); - return; - } - - logger.info(`[Agent365Exporter] Exporting ${groups.size} identity groups`); - let anyFailure = false; - const promises: Promise[] = []; - - for (const [identityKey, activities] of groups) { - const promise = this.exportGroup(identityKey, activities).catch((err) => { - anyFailure = true; - logger.error(`[Agent365Exporter] Error exporting group ${identityKey}: ${formatError(err)}`); - }); - promises.push(promise); - } - - await Promise.all(promises); - const duration = Date.now() - startTime; - const success = !anyFailure; - const message = success ? 'All spans exported successfully' : 'Not all spans exported successfully'; - logger.event(ExporterEventNames.EXPORT, success, duration, message); - resultCallback({ - code: success ? ExportResultCode.SUCCESS : ExportResultCode.FAILED - }); - - } catch (_error) { - // Exporters should not raise; signal failure - const duration = Date.now() - startTime; - logger.event(ExporterEventNames.EXPORT, false, duration, `Export failed with error: ${formatError(_error)}`); - resultCallback({ code: ExportResultCode.FAILED }); - } - } - - /** - * Export a group of spans for a specific identity - */ - private async exportGroup(identityKey: string, spans: ReadableSpan[]): Promise { - const { tenantId, agentId } = parseIdentityKey(identityKey); - logger.info(`[Agent365Exporter] Exporting ${spans.length} spans for tenantId: ${tenantId}, agentId: ${agentId}`); - - const startTime = Date.now(); - - // Map, truncate, and chunk spans by estimated byte size - const mappedSpans = this.mapAndTruncateSpans(spans); - const resourceAttrs = this.getResourceAttributes(spans); - const chunks = chunkBySize( - mappedSpans, - (ms) => estimateSpanBytes(ms.span), - this.options.maxPayloadBytes, - ); - - if (chunks.length > 1) { - logger.info(`[Agent365Exporter] Split ${spans.length} spans into ${chunks.length} chunks for tenantId: ${tenantId}, agentId: ${agentId}`); - } - - // OBS routing is independent of the agent's workload authentication flow. - const endpointRelativePath = `/observabilityService/tenants/${encodeURIComponent(tenantId)}/otlp/agents/${encodeURIComponent(agentId)}/traces`; - - let url: string; - const domainOverride = getAgent365ObservabilityDomainOverride(this.configProvider); - if (domainOverride) { - url = `${domainOverride}${endpointRelativePath}?api-version=1`; - } else { - const base = resolveAgent365Endpoint(this.options.clusterCategory as ClusterCategory); - url = `${base}${endpointRelativePath}?api-version=1`; - logger.info(`[Agent365Exporter] Using default endpoint: ${url}`); - } - - const headers: Record = { - 'content-type': 'application/json' - }; - - if (typeof this.options.tokenResolver !== 'function') { - // Defensive: constructor already rejects a missing resolver; this catches mutation of options after construction. - throw new Error('Agent365Exporter tokenResolver was cleared after construction'); - } - const token = await this.options.tokenResolver(agentId, tenantId); - - if (token?.trim()) { - logger.info('[Agent365Exporter] Token resolved successfully via app-only tokenResolver'); - headers['authorization'] = `Bearer ${token}`; - } - else { - const skipReason = 'No token resolved via app-only tokenResolver'; - logger.event(ExporterEventNames.EXPORT_GROUP, false, 0, `skip exporting: ${skipReason}`, { tenantId, agentId }); - throw new Error(skipReason); - } - - // Always include tenant id header - headers['x-ms-tenant-id'] = tenantId; - - // Send each chunk (all-or-nothing: fail on first chunk failure) - let lastCorrelationId = 'unknown'; - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - const payload = this.buildEnvelope(chunk, resourceAttrs); - const body = JSON.stringify(payload); - const bodyBytes = Buffer.byteLength(body, 'utf8'); - logger.info(`[Agent365Exporter] Sending chunk ${i + 1} of ${chunks.length} (${chunk.length} spans, ${bodyBytes} bytes)`); - - const { ok, correlationId } = await this.postWithRetries(url, body, headers); - lastCorrelationId = correlationId; - if (!ok) { - const duration = Date.now() - startTime; - logger.event(ExporterEventNames.EXPORT_GROUP, false, duration, `chunk ${i + 1} of ${chunks.length} failed`, { tenantId, agentId, correlationId }); - throw new Error(`Failed to export spans (chunk ${i + 1} of ${chunks.length})`); - } - } - - const duration = Date.now() - startTime; - logger.event(ExporterEventNames.EXPORT_GROUP, true, duration, `${chunks.length} chunk(s) exported successfully`, { tenantId, agentId, correlationId: lastCorrelationId }); - } - - /** - * HTTP POST with retry logic - */ - private async postWithRetries(url: string, body: string, headers: Record): Promise<{ ok: boolean; correlationId: string }> { - let lastCorrelationId = 'unknown'; - for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++) { - let correlationId: string; - try { - logger.info(`[Agent365Exporter] Posting OTLP export request - Attempt ${attempt + 1}`); - const response = await fetch(url, { - method: 'POST', - headers, - body, - signal: AbortSignal.timeout(this.options.httpRequestTimeoutMilliseconds) - }); - - correlationId = response?.headers?.get('x-ms-correlation-id') || response?.headers?.get('x-correlation-id') || 'unknown'; - lastCorrelationId = correlationId; - - // 2xx => success - if (response.status >= 200 && response.status < 300) { - return { ok: true, correlationId }; - } - - // Retry transient errors - if ([408, 429].includes(response.status) || (response.status >= 500 && response.status < 600)) { - if (attempt < DEFAULT_MAX_RETRIES) { - const sleepMs = 200 * (attempt + 1) + Math.floor(Math.random() * 100); - logger.warn(`[Agent365Exporter] Transient error ${response.status}, correlation ID: ${correlationId}, retrying after ${sleepMs}ms`); - await this.sleep(sleepMs); - continue; - } - } - logger.error(`[Agent365Exporter] Failed with status ${response.status}, correlation ID: ${correlationId}`); - return { ok: false, correlationId }; - } catch (error) { - logger.error('[Agent365Exporter] Request error:', formatError(error)); - if (attempt < DEFAULT_MAX_RETRIES) { - const sleepMs = 200 * (attempt + 1); - logger.info(`[Agent365Exporter] Retrying after ${sleepMs}ms`); - await this.sleep(sleepMs); - continue; - } - return { ok: false, correlationId: lastCorrelationId }; - } - } - return { ok: false, correlationId: lastCorrelationId }; - } - - /** - * Sleep for specified milliseconds - */ - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Map ReadableSpans to OTLP format and apply per-span truncation. - */ - private mapAndTruncateSpans(spans: ReadableSpan[]): MappedSpan[] { - logger.info('[Agent365Exporter] Mapping and truncating spans'); - return spans.map(sp => { - const scope = sp.instrumentationScope || (sp as ReadableSpan & { instrumentationLibrary?: { name?: string; version?: string } }).instrumentationLibrary; - const scopeName = scope?.name || 'unknown'; - const scopeVersion = scope?.version || ''; - return { - span: truncateSpan(this.mapSpan(sp)), - scopeKey: `${scopeName}:${scopeVersion}`, - scopeName, - scopeVersion: scopeVersion || undefined, - }; - }); - } - - /** - * Extract resource attributes from the first span in the batch. - */ - private getResourceAttributes(spans: ReadableSpan[]): Record { - if (spans.length > 0 && spans[0].resource?.attributes) { - return { ...spans[0].resource.attributes }; - } - return {}; - } - - /** - * Build an OTLP export request envelope from pre-mapped spans. - */ - private buildEnvelope(mappedSpans: MappedSpan[], resourceAttrs: Record): OTLPExportRequest { - const scopeMap = new Map(); - for (const ms of mappedSpans) { - const existing = scopeMap.get(ms.scopeKey) || []; - existing.push(ms.span); - scopeMap.set(ms.scopeKey, existing); - } - - const scopeSpans: ScopeSpan[] = []; - for (const [scopeKey, spans] of scopeMap) { - const representative = mappedSpans.find(ms => ms.scopeKey === scopeKey)!; - scopeSpans.push({ - scope: { - name: representative.scopeName, - version: representative.scopeVersion, - }, - spans, - }); - } - - return { - resourceSpans: [ - { - resource: { attributes: Object.keys(resourceAttrs).length > 0 ? resourceAttrs : null }, - scopeSpans, - } - ] - }; - } - - /** - * Map a ReadableSpan to OTLP span format - */ - private mapSpan(sp: ReadableSpan): OTLPSpan { - const spanContext = sp.spanContext(); - - // Extract parent span ID - check multiple possible sources - let parentSpanIdHex: string | undefined = undefined; - const parentContext = sp.parentSpanContext; - if (parentContext?.spanId && parentContext.spanId !== '0000000000000000') { - parentSpanIdHex = hexSpanId(parentContext.spanId); - } - - // attributes - const attrs = sp.attributes ? { ...sp.attributes } : {}; - - // events - const events: OTLPEvent[] = []; - for (const ev of sp.events || []) { - // Handle both hrtime arrays and direct nanosecond timestamps - let timeNs: number; - if (Array.isArray(ev.time)) { - timeNs = ev.time[0] * 1000000000 + ev.time[1]; - } else { - timeNs = ev.time as number; - } - - const evAttrs = ev.attributes && Object.keys(ev.attributes).length > 0 ? { ...ev.attributes } : null; - events.push({ - timeUnixNano: timeNs, - name: ev.name, - attributes: evAttrs, - }); - } - - // links - const links: OTLPLink[] = []; - for (const ln of sp.links || []) { - const lnAttrs = ln.attributes && Object.keys(ln.attributes).length > 0 ? { ...ln.attributes } : null; - links.push({ - traceId: hexTraceId(ln.context.traceId), - spanId: hexSpanId(ln.context.spanId), - attributes: lnAttrs, - }); - } - - // status - const statusCode = sp.status?.code ?? 0; // Default to UNSET (0) if no status - const status: OTLPStatus = { - code: statusName(statusCode), - message: sp.status?.message || '', - }; - - // Convert hrtime to nanoseconds - handle both hrtime arrays and direct nanosecond values - let startTimeNs: number; - let endTimeNs: number; - - if (Array.isArray(sp.startTime)) { - // hrtime format [seconds, nanoseconds] - startTimeNs = sp.startTime[0] * 1000000000 + sp.startTime[1]; - } else { - // Direct nanosecond value - startTimeNs = sp.startTime as number; - } - - if (Array.isArray(sp.endTime)) { - // hrtime format [seconds, nanoseconds] - endTimeNs = sp.endTime[0] * 1000000000 + sp.endTime[1]; - } else { - // Direct nanosecond value - endTimeNs = sp.endTime as number; - } - - return { - traceId: hexTraceId(spanContext.traceId), - spanId: hexSpanId(spanContext.spanId), - parentSpanId: parentSpanIdHex, - name: sp.name, - kind: kindName(sp.kind), - startTimeUnixNano: startTimeNs, - endTimeUnixNano: endTimeNs, - attributes: Object.keys(attrs).length > 0 ? attrs : null, - events: events.length > 0 ? events : null, - links: links.length > 0 ? links : null, - status, - }; - } - - /** - * Shutdown the exporter - */ - async shutdown(): Promise { - logger.info('[Agent365Exporter] Shutting down exporter'); - this.closed = true; - } - - /** - * Force flush any pending spans - */ - async forceFlush(): Promise { - // No-op for this implementation - return Promise.resolve(); - } -} diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts deleted file mode 100644 index 9b1e7c97..00000000 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ /dev/null @@ -1,60 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { ClusterCategory } from '@microsoft/agents-a365-runtime'; - -/** - * A function that resolves an app-only OBS token for the given agent and tenant. - * Delegated (scp) tokens are not accepted by the S2S service. - * Implementations may perform synchronous lookup (e.g., in-memory cache) or asynchronous network calls. - * Used in both batch and per-request modes, independently of workload token context. - * Return null if a token cannot be provided; export fails without sending an HTTP request. - */ -export type TokenResolver = (agentId: string, tenantId: string) => string | null | Promise; - -/** - * Options controlling the behavior of the Agent365 OpenTelemetry span exporter. - * - * These values tune batching, timeouts, token acquisition and endpoint shape. All properties have sensible - * defaults so callers can usually construct without arguments and override selectively. - * - * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. ClusterCategory.preprod, ClusterCategory.prod, default to ClusterCategory.prod). - * @property {TokenResolver} [tokenResolver] App-only OBS token resolver required when constructing Agent365Exporter. - * There is no implicit cache lookup or request-context token fallback. - * @property {boolean} [useS2SEndpoint] Deprecated compatibility option. Export always uses the S2S path, even when false. - * @property {number} maxQueueSize Maximum span queue size before drops occur (passed to BatchSpanProcessor). - * @property {number} scheduledDelayMilliseconds Delay between automatic batch flush attempts. - * @property {number} exporterTimeoutMilliseconds Maximum time (ms) the BatchSpanProcessor waits for the entire export() call to complete before giving up. Covers partitioning, token resolution, and all HTTP retries. - * @property {number} httpRequestTimeoutMilliseconds Timeout (ms) for each individual HTTP request to the observability backend. Applies per fetch() call inside the retry loop; each retry gets a fresh timeout. - * @property {number} maxExportBatchSize Maximum number of spans per export batch. - */ -export class Agent365ExporterOptions { - /** Environment / cluster category (e.g. ClusterCategory.preprod, ClusterCategory.prod). */ - public clusterCategory: ClusterCategory | string = ClusterCategory.prod; - - /** Required by Agent365Exporter in every mode; a console-only builder may omit it. */ - public tokenResolver?: TokenResolver; - - /** @deprecated Export always uses /observabilityService. This option is ignored. */ - public useS2SEndpoint: boolean = true; - - /** Maximum span queue size before new spans are dropped. */ - public maxQueueSize: number = 2048; - - /** Delay (ms) between automatic batch flush attempts. */ - public scheduledDelayMilliseconds: number = 5000; - - /** Maximum time (ms) the BatchSpanProcessor waits for the entire export() call to complete. */ - public exporterTimeoutMilliseconds: number = 90000; - - /** Timeout (ms) for each individual HTTP request to the observability backend. Each retry attempt gets a fresh timeout. */ - public httpRequestTimeoutMilliseconds: number = 30000; - - /** Maximum number of spans per export batch. */ - public maxExportBatchSize: number = 512; - - /** Upper bound on HTTP request body size in bytes. 100 KB headroom under the 1 MB server limit for estimator error and JSON/envelope overhead (for example, resource attributes and scope wrappers). */ - public maxPayloadBytes: number = 900_000; -} diff --git a/packages/agents-a365-observability/src/tracing/exporter/ExporterEventNames.ts b/packages/agents-a365-observability/src/tracing/exporter/ExporterEventNames.ts deleted file mode 100644 index 35cc202d..00000000 --- a/packages/agents-a365-observability/src/tracing/exporter/ExporterEventNames.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Event names used by Agent365Exporter for logging and monitoring. - * These are low-cardinality event types to ensure efficient monitoring and aggregation. - */ -export enum ExporterEventNames { - /** - * Overall export operation event - logs the entire batch export success/failure and duration - */ - EXPORT = 'agent365-export', - - /** - * Group export operation event - logs individual tenant/agent group export success/failure and duration. - * Contextual information (tenantId, agentId, correlationId) should be passed in the details parameter. - */ - EXPORT_GROUP = 'export-group', - - /** - * Tracked spans being skipped due to missing tenant or agent ID. Before export event, spans are partitioned by identity (tenant or agent ID) first. - */ - EXPORT_PARTITION_SPAN_MISSING_IDENTITY = 'export-partition-span-missing-identity' -} diff --git a/packages/agents-a365-observability/src/tracing/exporter/utils.ts b/packages/agents-a365-observability/src/tracing/exporter/utils.ts deleted file mode 100644 index 1468f922..00000000 --- a/packages/agents-a365-observability/src/tracing/exporter/utils.ts +++ /dev/null @@ -1,729 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; -import { ClusterCategory, IConfigurationProvider } from '@microsoft/agents-a365-runtime'; -import { OpenTelemetryConstants } from '../constants'; -import { MessageRole } from '../contracts'; -import logger from '../../utils/logging'; -import { ExporterEventNames } from './ExporterEventNames'; -import { - ObservabilityConfiguration, - defaultObservabilityConfigurationProvider, - PerRequestSpanProcessorConfiguration, - defaultPerRequestSpanProcessorConfigurationProvider -} from '../../configuration'; -import { getPerRequestProcessorInternalOverrides } from '../../internal/PerRequestProcessorInternalOverrides'; - -/** - * Convert trace ID to hex string format - */ -export function hexTraceId(value: string | number): string { - if (typeof value === 'number') { - // Convert integer to 32 hex chars (128-bit) - return value.toString(16).padStart(32, '0'); - } - // Handle hex string input - ensure it's 32 hex characters - return value.replace(/^0x/, '').padStart(32, '0'); -} - -/** - * Convert span ID to hex string format - */ -export function hexSpanId(value: string | number): string { - if (typeof value === 'number') { - // Convert integer to 16 hex chars (64-bit) - return value.toString(16).padStart(16, '0'); - } - // Handle hex string input - ensure it's 16 hex characters - return value.replace(/^0x/, '').padStart(16, '0'); -} - -/** - * Convert any value to string, handling null/undefined - */ -export function asStr(v: unknown): string | undefined { - if (v === null || v === undefined) { - return undefined; - } - const s = String(v); - return s.trim() ? s : undefined; -} - -/** - * Get span kind name from SpanKind enum - */ -export function kindName(kind: SpanKind): string { - switch (kind) { - case SpanKind.INTERNAL: - return 'INTERNAL'; - case SpanKind.SERVER: - return 'SERVER'; - case SpanKind.CLIENT: - return 'CLIENT'; - case SpanKind.PRODUCER: - return 'PRODUCER'; - case SpanKind.CONSUMER: - return 'CONSUMER'; - default: - return 'UNSPECIFIED'; - } -} - -/** - * Get status name from SpanStatusCode enum - */ -export function statusName(code: SpanStatusCode): string { - switch (code) { - case SpanStatusCode.UNSET: - return 'UNSET'; - case SpanStatusCode.OK: - return 'OK'; - case SpanStatusCode.ERROR: - return 'ERROR'; - default: - return 'UNSET'; - } -} - -/** - * Known genAI operation names produced by the SDK scopes and auto-instrumentation. - * Only spans whose gen_ai.operation.name matches one of these values are exported. - */ -const GEN_AI_OPERATION_NAMES: ReadonlySet = new Set([ - OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, // 'invoke_agent' - OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, // 'execute_tool' - OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME, // 'output_messages' - OpenTelemetryConstants.APPLY_GUARDRAIL_OPERATION_NAME, // 'apply_guardrail' - OpenTelemetryConstants.CHAT_OPERATION_NAME, // 'chat' - OpenTelemetryConstants.TEXT_COMPLETION_OPERATION_NAME, // 'text_completion' - OpenTelemetryConstants.GENERATE_CONTENT_OPERATION_NAME, // 'generate_content' - 'Chat', // InferenceOperationType.CHAT (legacy PascalCase, pre-canonicalization) - 'TextCompletion', // InferenceOperationType.TEXT_COMPLETION (legacy PascalCase, pre-canonicalization) - 'GenerateContent', // InferenceOperationType.GENERATE_CONTENT (legacy PascalCase, pre-canonicalization) -]); - -/** - * Partition spans by (tenantId, agentId) identity pairs. - * Only genAI spans (those with a known gen_ai.operation.name) are included. - */ -export function partitionByIdentity( - spans: ReadableSpan[] -): Map { - const groups = new Map(); - - let nonGenAICount = 0; - let missingIdentityCount = 0; - for (const span of spans) { - const attrs = span.attributes || {}; - const operationName = asStr(attrs[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]); - - if (!operationName || !GEN_AI_OPERATION_NAMES.has(operationName)) { - nonGenAICount++; - continue; - } - - const tenant = asStr(attrs[OpenTelemetryConstants.TENANT_ID_KEY]); - const agent = asStr(attrs[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]); - - if (!tenant || !agent) { - missingIdentityCount++; - continue; - } - - const key = `${tenant}:${agent}`; - const existing = groups.get(key) || []; - existing.push(span); - groups.set(key, existing); - } - - if (nonGenAICount > 0) { - logger.info(`[Agent365Exporter] ${nonGenAICount} non-genAI spans filtered out`); - } - - if (missingIdentityCount > 0) { - logger.event(ExporterEventNames.EXPORT_PARTITION_SPAN_MISSING_IDENTITY, false, 0, `${missingIdentityCount} spans are skipped due to missing tenant or agent ID`); - } - - const skippedCount = nonGenAICount + missingIdentityCount; - logger.info(`[Agent365Exporter] Partitioned into ${groups.size} identity groups (${skippedCount} spans skipped)`); - return groups; -} - -/** - * Check if Agent 365 exporter is enabled via environment variable - * @param configProvider Optional configuration provider. Defaults to defaultObservabilityConfigurationProvider if not specified. - */ -export function isAgent365ExporterEnabled( - configProvider?: IConfigurationProvider -): boolean { - const provider = configProvider ?? defaultObservabilityConfigurationProvider; - const enabled = provider.getConfiguration().isObservabilityExporterEnabled; - logger.info(`[Agent365Exporter] Agent 365 exporter enabled: ${enabled}`); - return enabled; -} - -/** - * Check if per-request export is enabled. - * Precedence: internal overrides > configuration provider > environment variable. - * When enabled, the PerRequestSpanProcessor is used instead of BatchSpanProcessor. - * Credential selection is unchanged: Agent365Exporter uses its app-only token resolver. - * @param configProvider Optional configuration provider. Defaults to defaultPerRequestSpanProcessorConfigurationProvider if not specified. - */ -export function isPerRequestExportEnabled( - configProvider?: IConfigurationProvider -): boolean { - const overrides = getPerRequestProcessorInternalOverrides(); - const overrideValue = overrides?.isPerRequestExportEnabled?.(); - if (typeof overrideValue === 'boolean') { - logger.info(`[Agent365Exporter] Per-request export enabled (internal override): ${overrideValue}`); - return overrideValue; - } - - const provider = configProvider ?? defaultPerRequestSpanProcessorConfigurationProvider; - const enabled = provider.getConfiguration().isPerRequestExportEnabled; - if (enabled) { - logger.info('[Agent365Exporter] Per-request export is enabled'); - } - return enabled; -} - -/** - * Resolve the Agent365 service endpoint base URI for a given cluster category. - * When an explicit override is not configured, this determines the default base URI. - */ -export function resolveAgent365Endpoint(clusterCategory: ClusterCategory): string { - switch (clusterCategory) { - case 'prod': - default: - return 'https://agent365.svc.cloud.microsoft'; - } -} - -/** - * Get Agent365 Observability domain override. - * Internal development and test clusters can override this by setting the - * `A365_OBSERVABILITY_DOMAIN_OVERRIDE` environment variable. When set to a - * non-empty value, that value is used as the base URI regardless of cluster category. Otherwise, null is returned. - * @param configProvider Optional configuration provider. Defaults to defaultObservabilityConfigurationProvider if not specified. - */ -export function getAgent365ObservabilityDomainOverride( - configProvider?: IConfigurationProvider -): string | null { - const provider = configProvider ?? defaultObservabilityConfigurationProvider; - return provider.getConfiguration().observabilityDomainOverride; -} - - -/** - * Parse identity key back to tenant and agent IDs - */ -export function parseIdentityKey(key: string): { tenantId: string; agentId: string } { - const [tenantId, agentId] = key.split(':'); - return { tenantId, agentId }; -} - -// --------------------------------------------------------------------------- -// Span truncation -// --------------------------------------------------------------------------- - -/** Maximum allowed span size in bytes (250KB). @internal */ -export const MAX_SPAN_SIZE_BYTES = 250 * 1024; - -const BLOB_SENTINEL = '[blob truncated]'; -const JSON_SENTINEL = '[truncated]'; -const TRUNCATED_SUFFIX = '… [truncated]'; -const TRUNCATED_SUFFIX_BYTES = Buffer.byteLength(TRUNCATED_SUFFIX, 'utf8'); -const OVERLIMIT_SENTINEL = '[overlimit]'; - -/** - * Build a sentinel message array indicating the original messages were dropped - * because the span exceeded the size limit. - */ -function serializeOverflowSentinel(totalMessages: number): string { - return JSON.stringify([ - { - role: MessageRole.SYSTEM, - parts: [ - { - type: 'text', - content: `[truncated: ${totalMessages} ${totalMessages === 1 ? 'message' : 'messages'} exceeded limit]` - } - ] - } - ]); -} - -/** Strings shorter than this (in UTF-8 bytes) are not worth truncating. */ -const MIN_SHRINKABLE_STRING_BYTES = 50; - -const MESSAGE_ATTR_KEYS = new Set([ - OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, - OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, -]); - -interface OTLPSpanLike { - attributes: Record | null; -} - -interface ShrinkAction { - /** Approximate byte size of the shrinkable content. Updated after apply for re-shrinkable actions. */ - contentBytes: number; - /** - * Apply shrinking. - * @param bytesToShed Target bytes to shed. Trimmable actions trim this amount; - * one-shot actions perform full replacement regardless. - */ - apply(bytesToShed: number): void; - /** For message-sourced actions, the attribute key to flush after apply. */ - sourceKey?: string; -} - -/** - * Trim a string by a target UTF-8 byte budget while preserving whole code points. - */ -function trimString(value: string, bytesToShed: number): string { - const currentBytes = Buffer.byteLength(value, 'utf8'); - const targetTotalBytes = Math.max(TRUNCATED_SUFFIX_BYTES, currentBytes - Math.max(1, bytesToShed)); - const targetContentBytes = targetTotalBytes - TRUNCATED_SUFFIX_BYTES; - if (targetContentBytes <= 0) { - return TRUNCATED_SUFFIX; - } - - let consumedBytes = 0; - let endIndex = 0; - - for (const codePoint of value) { - const codePointBytes = Buffer.byteLength(codePoint, 'utf8'); - if (consumedBytes + codePointBytes > targetContentBytes) { - break; - } - consumedBytes += codePointBytes; - endIndex += codePoint.length; // length in UTF-16 code units (handles surrogates) - } - - return value.slice(0, endIndex) + TRUNCATED_SUFFIX; -} - -function getSerializedSize(value: unknown): number { - return Buffer.byteLength(JSON.stringify(value), 'utf8'); -} - -function createBlobShrinkAction(part: Record, sourceKey?: string): ShrinkAction | undefined { - const partType = part.type as string; - - if (partType === 'blob' && typeof part.content === 'string') { - const contentSize = Buffer.byteLength(part.content, 'utf8'); - if (contentSize <= 0 || part.content === BLOB_SENTINEL) { - return undefined; - } - - const action: ShrinkAction = { - contentBytes: contentSize, - sourceKey, - apply() { - part.content = BLOB_SENTINEL; - action.contentBytes = 0; - } - }; - return action; - } - - return undefined; -} - -/** - * Collect all shrink candidates from message parts and direct string attributes. - * Blobs, text, reasoning, json-field, and plain-string actions are all collected - * uniformly. When a message attribute contains non-JSON content it falls through - * to regular string trimming. - */ -function collectShrinkActions( - attributes: Record, - parsedMessages: Map> }> }>, -): ShrinkAction[] { - const actions: ShrinkAction[] = []; - - for (const key of Object.keys(attributes)) { - let handledAsMessage = false; - - if (MESSAGE_ATTR_KEYS.has(key)) { - // Parse and cache the message wrapper if not already done - if (!parsedMessages.has(key) && typeof attributes[key] === 'string') { - try { - const parsed = JSON.parse(attributes[key] as string); - if (Array.isArray(parsed)) { - parsedMessages.set(key, { messages: parsed }); - } - } catch { - // Not valid JSON — will fall through to string trim - } - } - - if (parsedMessages.has(key)) { - handledAsMessage = true; - const wrapper = parsedMessages.get(key)!; - for (const message of wrapper.messages) { - if (!Array.isArray(message.parts)) continue; - for (const part of message.parts) { - const partType = part.type as string; - - // Blob content → sentinel (one-shot) - const blobAction = createBlobShrinkAction(part, key); - if (blobAction) { - actions.push(blobAction); - continue; - } - - // Tool/server JSON payload fields → sentinel (one-shot) - const jsonField = - partType === 'tool_call' ? 'arguments' - : partType === 'tool_call_response' ? 'response' - : partType === 'server_tool_call' ? 'server_tool_call' - : partType === 'server_tool_call_response' ? 'server_tool_call_response' - : undefined; - - if (jsonField && part[jsonField] !== undefined && part[jsonField] !== JSON_SENTINEL) { - let fieldSize: number; - try { fieldSize = Buffer.byteLength(JSON.stringify(part[jsonField]), 'utf8'); } - catch { fieldSize = 0; } - if (fieldSize > 0) { - const action: ShrinkAction = { - contentBytes: fieldSize, - sourceKey: key, - apply() { - part[jsonField!] = JSON_SENTINEL; - action.contentBytes = 0; - } - }; - actions.push(action); - } - continue; - } - - // Text/reasoning content → trim (re-shrinkable) - if ((partType === 'text' || partType === 'reasoning') && typeof part.content === 'string') { - const contentSize = Buffer.byteLength(part.content, 'utf8'); - if (contentSize > MIN_SHRINKABLE_STRING_BYTES) { - const action: ShrinkAction = { - contentBytes: contentSize, - sourceKey: key, - apply(bytesToShed: number) { - const cur = Buffer.byteLength(part.content as string, 'utf8'); - if (cur > TRUNCATED_SUFFIX_BYTES) { - part.content = trimString(part.content as string, bytesToShed); - action.contentBytes = Buffer.byteLength(part.content as string, 'utf8'); - } - } - }; - actions.push(action); - } - } - } - } - } - } - - // Non-versioned string attribute → generate shrink action - if (!handledAsMessage && typeof attributes[key] === 'string') { - const valueSize = Buffer.byteLength(attributes[key] as string, 'utf8'); - if (valueSize > MIN_SHRINKABLE_STRING_BYTES) { - // Message key with raw dict JSON → one-shot sentinel replacement (preserves JSON integrity) - // Other strings → incremental trim - let isRawJson = false; - if (MESSAGE_ATTR_KEYS.has(key)) { - try { const p = JSON.parse(attributes[key] as string); isRawJson = p && typeof p === 'object'; } catch { /* not JSON */ } - } - const action: ShrinkAction = isRawJson - ? { - contentBytes: valueSize, - apply() { - attributes[key] = OVERLIMIT_SENTINEL; - action.contentBytes = Buffer.byteLength(OVERLIMIT_SENTINEL, 'utf8'); - } - } - : { - contentBytes: valueSize, - apply(bytesToShed: number) { - const cur = Buffer.byteLength(attributes[key] as string, 'utf8'); - if (cur > TRUNCATED_SUFFIX_BYTES) { - attributes[key] = trimString(attributes[key] as string, bytesToShed); - action.contentBytes = Buffer.byteLength(attributes[key] as string, 'utf8'); - } - } - }; - actions.push(action); - } - } - } - - return actions; -} - -function flushParsedMessages( - attributes: Record, - parsedMessages: Map> }> }>, -): void { - for (const [key, wrapper] of parsedMessages) { - try { - attributes[key] = JSON.stringify(wrapper.messages); - } catch { - // Leave the previous string value intact if serialization fails. - } - } -} - -function flushParsedMessage( - attributes: Record, - parsedMessages: Map> }> }>, - key: string, -): void { - const wrapper = parsedMessages.get(key); - if (wrapper) { - try { - attributes[key] = JSON.stringify(wrapper.messages); - } catch { - // Leave the previous string value intact if serialization fails. - } - } -} - -function runShrinkPhase( - span: OTLPSpanLike, - attributes: Record, - parsedMessages: Map> }> }>, - currentSize: number, -): number { - let nextSize = currentSize; - - const actions = collectShrinkActions(attributes, parsedMessages); - - while (actions.length > 0 && nextSize > MAX_SPAN_SIZE_BYTES) { - // Pick the action with the largest contentBytes (accounts for updated sizes) - let maxIdx = 0; - for (let j = 1; j < actions.length; j++) { - if (actions[j].contentBytes > actions[maxIdx].contentBytes) maxIdx = j; - } - - const excess = nextSize - MAX_SPAN_SIZE_BYTES; - const previousSize = nextSize; - const action = actions[maxIdx]; - action.apply(excess); - - // Flush only the modified message attribute instead of all parsed messages - if (action.sourceKey) { - flushParsedMessage(attributes, parsedMessages, action.sourceKey); - } - nextSize = getSerializedSize(span); - - if (nextSize >= previousSize) { - // Action had no effect — remove it - actions.splice(maxIdx, 1); - } else if (action.contentBytes <= MIN_SHRINKABLE_STRING_BYTES) { - // Exhausted (one-shot or fully trimmed) — remove it - actions.splice(maxIdx, 1); - } - } - - // Final flush to ensure all modified message attributes are written back - flushParsedMessages(attributes, parsedMessages); - - return nextSize; -} - -/** - * Truncate span attributes if the serialized span exceeds MAX_SPAN_SIZE_BYTES. - * - * Phase 1: iteratively shrink all fields (blobs, text, json, strings) by size - * priority, remeasuring after each step. - * Phase 2 (fallback): replace remaining string attributes with overlimit sentinel. - */ -// --------------------------------------------------------------------------- -// Span size estimation and byte-level chunking -// --------------------------------------------------------------------------- - -/** - * Overhead constant for OTLP JSON span fixed fields - * (traceId, spanId, parentSpanId, kind, timestamps, status, scope wrapper, etc.). - * Intentionally generous to account for serializer variance. - * @internal - */ -const SPAN_BASE_OVERHEAD = 2000; - -/** - * Overhead per attribute in OTLP JSON format. - * Covers key/value JSON wrapping overhead. - * @internal - */ -const ATTR_OVERHEAD = 80; - -/** - * Overhead per event in OTLP JSON. - * @internal - */ -const EVENT_OVERHEAD = 200; - -/** - * Estimate the serialized byte size of a single attribute value in OTLP/HTTP JSON. - */ -export function estimateValueBytes(value: unknown): number { - if (typeof value === 'string') { - return 40 + Buffer.byteLength(value, 'utf8'); - } - if (Array.isArray(value)) { - if (value.length === 0) return 60; - if (typeof value[0] === 'string') { - let sum = 60; - for (const s of value) { - sum += 40 + Buffer.byteLength(String(s), 'utf8'); - } - return sum; - } - return 60 + 50 * value.length; - } - return 40; // bool/int/float/null/other -} - -/** - * Heuristic estimator for the serialized size of an OTLP span in HTTP JSON. - * - * Uses generous constants tuned to over-estimate by ~25-50%, providing headroom - * for JSON serializer variance (whitespace, enum representation, integer-as-string). - */ -export function estimateSpanBytes(span: { - name?: string; - attributes?: Record | null; - events?: Array<{ name: string; attributes?: Record | null }> | null; -}): number { - let total = SPAN_BASE_OVERHEAD; - if (span.name) { - total += Buffer.byteLength(span.name, 'utf8'); - } - if (span.attributes) { - for (const [key, value] of Object.entries(span.attributes)) { - total += ATTR_OVERHEAD; - total += Buffer.byteLength(key, 'utf8'); - total += estimateValueBytes(value); - } - } - if (span.events) { - for (const ev of span.events) { - total += EVENT_OVERHEAD; - total += Buffer.byteLength(ev.name, 'utf8'); - if (ev.attributes) { - for (const [key, value] of Object.entries(ev.attributes)) { - total += ATTR_OVERHEAD; - total += Buffer.byteLength(key, 'utf8'); - total += estimateValueBytes(value); - } - } - } - } - return total; -} - -/** - * Split items into sub-batches whose cumulative estimated size stays under maxChunkBytes. - * - * Invariants: - * - Input order is preserved across chunks. - * - Empty input produces empty output. - * - A single item whose size exceeds maxChunkBytes forms its own single-item chunk - * (never silently dropped). - * - No chunk is ever empty. - * - * @param items The items to chunk. - * @param getSize Estimator function returning approximate serialized byte size of one item. - * @param maxChunkBytes Upper bound on cumulative estimated size per chunk. - */ -export function chunkBySize( - items: T[], - getSize: (item: T) => number, - maxChunkBytes: number, -): T[][] { - const chunks: T[][] = []; - let current: T[] = []; - let currentBytes = 0; - - for (const item of items) { - const itemBytes = getSize(item); - if (current.length > 0 && currentBytes + itemBytes > maxChunkBytes) { - chunks.push(current); - current = []; - currentBytes = 0; - } - current.push(item); - currentBytes += itemBytes; - } - - if (current.length > 0) { - chunks.push(current); - } - - return chunks; -} - -export function truncateSpan(spanDict: T): T { - try { - let currentSize = getSerializedSize(spanDict); - if (currentSize <= MAX_SPAN_SIZE_BYTES) return spanDict; - - logger.warn( - `[Agent365Exporter] Span size (${currentSize} bytes) exceeds limit (${MAX_SPAN_SIZE_BYTES} bytes). Shrinking attributes.` - ); - - const truncated = { ...spanDict }; - if (truncated.attributes) truncated.attributes = { ...truncated.attributes }; - const attributes = truncated.attributes; - if (!attributes) return truncated; - - const parsedMessages = new Map> }> }>(); - - // Phase 1: iteratively shrink all fields by size priority - currentSize = runShrinkPhase(truncated, attributes, parsedMessages, currentSize); - - if (currentSize > MAX_SPAN_SIZE_BYTES) { - // Phase 2 (fallback): replace all string attributes with overlimit sentinel, largest first. - // Message attributes get a structured sentinel preserving the original message count. - const stringKeys = Object.keys(attributes) - .filter(k => typeof attributes[k] === 'string' && attributes[k] !== OVERLIMIT_SENTINEL) - .sort((a, b) => Buffer.byteLength(attributes[b] as string, 'utf8') - Buffer.byteLength(attributes[a] as string, 'utf8')); - - for (const key of stringKeys) { - if (currentSize <= MAX_SPAN_SIZE_BYTES) break; - if (MESSAGE_ATTR_KEYS.has(key)) { - let messageCount = 0; - const cached = parsedMessages.get(key); - if (cached) { - messageCount = cached.messages.length; - } else if (typeof attributes[key] === 'string') { - // Attempt to derive count from current attribute value - try { - const parsed = JSON.parse(attributes[key] as string); - if (Array.isArray(parsed)) { - messageCount = parsed.length; - } - } catch { /* not valid JSON — count stays 0 */ } - } - attributes[key] = serializeOverflowSentinel(messageCount); - parsedMessages.delete(key); - } else { - attributes[key] = OVERLIMIT_SENTINEL; - } - currentSize = getSerializedSize(truncated); - } - } - - if (currentSize > MAX_SPAN_SIZE_BYTES) { - logger.warn( - `[Agent365Exporter] Span still ${currentSize} bytes after exhausting all shrink actions (limit: ${MAX_SPAN_SIZE_BYTES}).` - ); - } - - return truncated; - } catch (e) { - logger.error(`[Agent365Exporter] Error during span truncation: ${e}`); - return spanDict; - } -} diff --git a/packages/agents-a365-observability/src/tracing/message-utils.ts b/packages/agents-a365-observability/src/tracing/message-utils.ts deleted file mode 100644 index 6b693360..00000000 --- a/packages/agents-a365-observability/src/tracing/message-utils.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { - ChatMessage, - OutputMessage, - MessageRole, - InputMessages, - OutputMessages, - InputMessagesParam, - OutputMessagesParam, - DEFAULT_FINISH_REASON -} from './contracts'; - -/** - * Type guard that returns `true` when the input is a structured wrapper - * object (`InputMessages` or `OutputMessages`). - */ -export function isWrappedMessages(input: InputMessagesParam | OutputMessagesParam): input is InputMessages | OutputMessages { - return !Array.isArray(input) && typeof input === 'object' && input !== null && 'messages' in input && Array.isArray((input as InputMessages).messages); -} - -/** - * Converts plain input strings into OTEL input messages. - */ -export function toInputMessages(messages: string[]): ChatMessage[] { - return messages.map((content) => ({ - role: MessageRole.USER, - parts: [{ type: 'text' as const, content }] - })); -} - -/** - * Converts plain output strings into OTEL output messages. - * `finish_reason` defaults to `"stop"` per OTel spec. - */ -export function toOutputMessages(messages: string[]): OutputMessage[] { - return messages.map((content) => ({ - role: MessageRole.ASSISTANT, - parts: [{ type: 'text' as const, content }], - finish_reason: DEFAULT_FINISH_REASON - })); -} - -/** - * Normalizes an `InputMessagesParam` to an `InputMessages` instance. - * - `string` / `string[]` → converted to `ChatMessage[]` and wrapped - * - `InputMessages` → returned as-is - */ -export function normalizeInputMessages(param: InputMessagesParam): InputMessages { - if (typeof param === 'string' || Array.isArray(param)) { - const arr = typeof param === 'string' ? [param] : param; - return { messages: toInputMessages(arr) }; - } - return param; -} - -/** - * Normalizes an `OutputMessagesParam` to an `OutputMessages` instance. - * - `string` / `string[]` → converted to `OutputMessage[]` and wrapped - * - `OutputMessages` → returned as-is - */ -export function normalizeOutputMessages(param: OutputMessagesParam): OutputMessages { - if (typeof param === 'string' || Array.isArray(param)) { - const arr = typeof param === 'string' ? [param] : param; - return { messages: toOutputMessages(arr) }; - } - return param; -} - -/** - * Serializes a message wrapper to JSON. - * - * The output is a plain JSON array per OTel gen-ai semantic conventions: `[{...}, ...]`. - * - * The try/catch ensures telemetry recording is non-throwing even when - * message parts contain non-JSON-serializable values (e.g. BigInt, circular refs). - */ -export function serializeMessages(wrapper: InputMessages | OutputMessages): string { - try { - return JSON.stringify(wrapper.messages); - } catch { - return JSON.stringify([ - { - role: MessageRole.SYSTEM, - parts: [ - { - type: 'text', - content: `[serialization failed: ${wrapper.messages.length} ${wrapper.messages.length === 1 ? 'message' : 'messages'}]` - } - ] - } - ]); - } -} diff --git a/packages/agents-a365-observability/src/tracing/middleware/BaggageBuilder.ts b/packages/agents-a365-observability/src/tracing/middleware/BaggageBuilder.ts deleted file mode 100644 index 588d9b90..00000000 --- a/packages/agents-a365-observability/src/tracing/middleware/BaggageBuilder.ts +++ /dev/null @@ -1,375 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -import { propagation, context as otelContext, Context } from '@opentelemetry/api'; -import { OpenTelemetryConstants } from '../constants'; - -/** - * Per request baggage builder for OpenTelemetry context propagation. - * - * This class provides a fluent API for setting baggage values that will be - * propagated in the OpenTelemetry context. - * - * @example - * ```typescript - * const scope = new BaggageBuilder() - * .tenantId("tenant-123") - * .agentId("agent-456") - * .build(); - * - * scope.enter(); - * // Baggage is set in this context - * // ... do work ... - * scope.exit(); - * // Baggage is restored after exiting the context - * ``` - */ -export class BaggageBuilder { - private pairs: Map = new Map(); - - /** - * Set the operation source baggage value. - * Used for server spans to identify the service (e.g., ATG, ACF). - * @param value The operation source - * @returns Self for method chaining - */ - operationSource(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.SERVICE_NAME_KEY, value); - return this; - } - - /** - * Set the tenant ID baggage value. - * @param value The tenant ID - * @returns Self for method chaining - */ - tenantId(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.TENANT_ID_KEY, value); - return this; - } - - /** - * Set the agent ID baggage value. - * @param value The agent ID - * @returns Self for method chaining - */ - agentId(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, value); - return this; - } - - /** - * Set the agent AUID baggage value. - * @param value The agent AUID - * @returns Self for method chaining - */ - agentAuid(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, value); - return this; - } - - /** - * Set the agent email baggage value. - * @param value The agent email - * @returns Self for method chaining - */ - agentEmail(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_AGENT_EMAIL_KEY, value); - return this; - } - - /** - * Set the agent blueprint ID baggage value. - * @param value The agent blueprint ID - * @returns Self for method chaining - */ - agentBlueprintId(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, value); - return this; - } - - /** - * Set the session ID baggage value. - * @param value The session ID - * @returns Self for method chaining - */ - sessionId(value: string): BaggageBuilder { - this.set(OpenTelemetryConstants.SESSION_ID_KEY, value); - return this; - } - - /** - * Set the user ID baggage value. - * @param value The user ID - * @returns Self for method chaining - */ - userId(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.USER_ID_KEY, value); - return this; - } - - /** - * Set the agent name baggage value. - * @param value The agent name - * @returns Self for method chaining - */ - agentName(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, value); - return this; - } - - /** - * Set the agent description baggage value. - * @param value The agent description - * @returns Self for method chaining - */ - agentDescription(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, value); - return this; - } - - /** - * Set the agent platform ID baggage value. - * @param value The agent platform ID - * @returns Self for method chaining - */ - agentPlatformId(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_AGENT_PLATFORM_ID_KEY, value); - return this; - } - - /** - * Set the agent version baggage value. - * @param value The agent version (e.g., '1.0.0', '2025-05-01') - * @returns Self for method chaining - */ - agentVersion(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY, value); - return this; - } - - /** - * Set the session description baggage value. - * @param value The session description - * @returns Self for method chaining - */ - sessionDescription(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.SESSION_DESCRIPTION_KEY, value); - return this; - } - - /** - * Set the user name baggage value. - * @param value The user name - * @returns Self for method chaining - */ - userName(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.USER_NAME_KEY, value); - return this; - } - - /** - * Set the user email baggage value. - * @param value The user email - * @returns Self for method chaining - */ - userEmail(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.USER_EMAIL_KEY, value); - return this; - } - - /** - * Set the caller client IP baggage value. - * Used to capture the originating client IP for the request - * so it can be propagated via OpenTelemetry baggage. - * @param value The caller client IP address - * @returns Self for method chaining - */ - callerClientIp(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, value); - return this; - } - - /** - * Set the caller agent platform ID baggage value. - * @param value The caller agent platform identifier - * @returns Self for method chaining - */ - callerAgentPlatformId(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, value); - return this; - } - - /** - * Set the conversation ID baggage value. - * @param value The conversation ID - * @returns Self for method chaining - */ - conversationId(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, value); - return this; - } - - /** - * Set the conversation item link baggage value. - * @param value The conversation item link - * @returns Self for method chaining - */ - conversationItemLink(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.GEN_AI_CONVERSATION_ITEM_LINK_KEY, value); - return this; - } - - /** - * Set the channel name (e.g., Teams, Slack). - * @param value The channel name - * @returns Self for method chaining - */ - channelName(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.CHANNEL_NAME_KEY, value); - return this; - } - - /** - * Set the channel link/URL. - * @param value The channel link - * @returns Self for method chaining - */ - channelLink(value: string | null | undefined): BaggageBuilder { - this.set(OpenTelemetryConstants.CHANNEL_LINK_KEY, value); - return this; - } - - /** - * Sets the invoke agent server address and port baggage values. - * @param address The server address (hostname) of the target agent service. - * @param port Optional server port. Only recorded when different from 443. - * @returns The current builder instance for method chaining. - */ - invokeAgentServer(address: string | null | undefined, port?: number): BaggageBuilder { - this.set(OpenTelemetryConstants.SERVER_ADDRESS_KEY, address); - if (port !== undefined && port !== 443) { - this.set(OpenTelemetryConstants.SERVER_PORT_KEY, port.toString()); - } else { - this.pairs.delete(OpenTelemetryConstants.SERVER_PORT_KEY); - } - return this; - } - - /** - * Set multiple baggage pairs from a dictionary or iterable. - * @param pairs Dictionary or iterable of key-value pairs - * @returns Self for method chaining - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setPairs(pairs: Record | Iterable<[string, any]> | null | undefined): BaggageBuilder { - if (!pairs) { - return this; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let entries: Iterable<[string, any]>; - if (Symbol.iterator in Object(pairs)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - entries = pairs as Iterable<[string, any]>; - } else { - entries = Object.entries(pairs); - } - - for (const [key, value] of entries) { - if (value !== null && value !== undefined) { - this.set(key, String(value)); - } - } - - return this; - } - - /** - * Apply the collected baggage to the current context. - * @returns A context manager that restores the previous baggage on exit - */ - build(): BaggageScope { - return new BaggageScope(this.pairs); - } - - /** - * Add a baggage key/value if the value is not null or whitespace. - * @param key The baggage key - * @param value The baggage value - */ - private set(key: string, value: string | null | undefined): void { - if (value !== null && value !== undefined) { - const trimmed = value.trim(); - if (trimmed) { - this.pairs.set(key, trimmed); - } - } - } - - /** - * Convenience method to begin a request baggage scope with common fields. - * @param tenantId The tenant ID - * @param agentId The agent ID - * @returns A context manager that restores the previous baggage on exit - */ - static setRequestContext( - tenantId?: string | null, - agentId?: string | null, - ): BaggageScope { - return new BaggageBuilder() - .tenantId(tenantId) - .agentId(agentId) - .build(); - } -} - -/** - * Context manager for baggage scope. - * - * This class manages the lifecycle of baggage values, setting them on enter - * and restoring the previous context on exit. - */ -export class BaggageScope implements Disposable { - private readonly contextWithBaggage: Context; - - constructor(pairs: Map) { - // 1. Start from current active context - const currentCtx = otelContext.active(); - - // 2. Build merged baggage - let bag = propagation.getBaggage(currentCtx) ?? propagation.createBaggage({}); - for (const [key, value] of pairs.entries()) { - if (value && value.trim()) { - bag = bag.setEntry(key, { value }); - } - } - - // 3. Create a new context that carries that baggage - this.contextWithBaggage = propagation.setBaggage(currentCtx, bag); - } - - /** - * Execute a synchronous function under this baggage scope. - * Automatically restores previous context afterward. - */ - run(fn: () => T): T { - return otelContext.with(this.contextWithBaggage, fn); - } - - /** - * Dispose is a no-op because OpenTelemetry JS automatically restores - * the previous context after `context.with()` completes. - */ - [Symbol.dispose](): void { - // Nothing to detach manually; context restoration happens automatically. - } - - /** - * Manual cleanup alternative if caller isn't using `using`. - */ - dispose(): void { - this[Symbol.dispose](); - } -} diff --git a/packages/agents-a365-observability/src/tracing/processors/SpanProcessor.ts b/packages/agents-a365-observability/src/tracing/processors/SpanProcessor.ts deleted file mode 100644 index 843c2cb0..00000000 --- a/packages/agents-a365-observability/src/tracing/processors/SpanProcessor.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { Context, propagation, Span } from '@opentelemetry/api'; -import { SpanProcessor as BaseSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { OpenTelemetryConstants } from '../constants'; -import { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from './util'; - -/** - * Span processor that propagates baggage key/value pairs to span attributes. - * - * This processor copies baggage entries onto spans based on the operation type. - * For invoke_agent operations, it applies both generic and invoke-agent-specific attributes. - * For other operations, it applies only generic attributes. - */ -export class SpanProcessor implements BaseSpanProcessor { - /** - * Called when a span is started. - * Copies relevant baggage entries to span attributes. - */ - onStart(span: Span, parentContext?: Context): void { - const ctx = parentContext; - if (!ctx) { - return; - } - - // Get existing span attributes - const existingAttrs = new Set(); - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const spanRecord = span as any; - if (spanRecord.attributes) { - Object.keys(spanRecord.attributes).forEach(key => existingAttrs.add(key)); - } - } catch { - // Ignore errors accessing span attributes - } - - // Get all baggage entries - const baggage = propagation.getBaggage(ctx); - if (!baggage) { - return; - } - - const baggageMap = new Map(); - baggage.getAllEntries().forEach(([key, entry]) => { - if (entry.value) { - baggageMap.set(key, entry.value); - } - }); - - // Determine if this is an invoke_agent operation - const operationName = - baggageMap.get(OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY) || - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (span as any).attributes?.[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const spanName = (span as any).name || ''; - const isInvokeAgent = - operationName === OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME || - spanName.startsWith(OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME); - - // Build target key set - const targetKeys = new Set(GENERIC_ATTRIBUTES); - if (isInvokeAgent) { - INVOKE_AGENT_ATTRIBUTES.forEach(key => targetKeys.add(key)); - } - - // Set telemetry SDK attributes - if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY)) { - span.setAttribute(OpenTelemetryConstants.TELEMETRY_SDK_NAME_KEY, OpenTelemetryConstants.TELEMETRY_SDK_NAME_VALUE); - } - if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY)) { - span.setAttribute(OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_KEY, OpenTelemetryConstants.TELEMETRY_SDK_LANGUAGE_VALUE); - } - if (!existingAttrs.has(OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY)) { - span.setAttribute(OpenTelemetryConstants.TELEMETRY_SDK_VERSION_KEY, OpenTelemetryConstants.TELEMETRY_SDK_VERSION_VALUE); - } - - // Copy baggage to span attributes - for (const key of targetKeys) { - // Skip if attribute already exists - if (existingAttrs.has(key)) { - continue; - } - - const value = baggageMap.get(key); - if (!value) { - continue; - } - - try { - span.setAttribute(key, value); - } catch { - // Ignore errors setting attributes - } - } - } - - /** - * Called when a span is ended. - */ - onEnd(_span: ReadableSpan): void { - // No-op for this processor - } - - /** - * Shutdown the processor. - */ - async shutdown(): Promise { - // No-op for this processor - } - - /** - * Force flush the processor. - */ - async forceFlush(): Promise { - // No-op for this processor - } -} diff --git a/packages/agents-a365-observability/src/tracing/processors/util.ts b/packages/agents-a365-observability/src/tracing/processors/util.ts deleted file mode 100644 index 32fd0360..00000000 --- a/packages/agents-a365-observability/src/tracing/processors/util.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { OpenTelemetryConstants as consts } from '../constants'; - -/** - * Generic / common tracing attributes applied to all spans - */ -export const GENERIC_ATTRIBUTES: readonly string[] = [ - consts.TENANT_ID_KEY, - consts.CUSTOM_PARENT_SPAN_ID_KEY, - consts.CUSTOM_SPAN_NAME_KEY, - consts.SESSION_ID_KEY, - consts.GEN_AI_CONVERSATION_ID_KEY, - consts.GEN_AI_CONVERSATION_ITEM_LINK_KEY, - consts.GEN_AI_OPERATION_NAME_KEY, - consts.GEN_AI_AGENT_ID_KEY, - consts.GEN_AI_AGENT_NAME_KEY, - consts.GEN_AI_AGENT_DESCRIPTION_KEY, - consts.SESSION_DESCRIPTION_KEY, - consts.GEN_AI_AGENT_EMAIL_KEY, - consts.GEN_AI_AGENT_AUID_KEY, - consts.GEN_AI_AGENT_PLATFORM_ID_KEY, - consts.GEN_AI_AGENT_BLUEPRINT_ID_KEY, - consts.GEN_AI_AGENT_VERSION_KEY, - consts.SERVICE_NAME_KEY, - // Caller / Invoker attributes - consts.USER_ID_KEY, - consts.USER_NAME_KEY, - consts.USER_EMAIL_KEY, - consts.GEN_AI_CALLER_CLIENT_IP_KEY, - // Channel attributes - consts.CHANNEL_NAME_KEY, - consts.CHANNEL_LINK_KEY, -]; - -/** - * Invoke Agent-specific attributes - */ -export const INVOKE_AGENT_ATTRIBUTES: readonly string[] = [ - // Caller Agent (A2A) attributes - consts.GEN_AI_CALLER_AGENT_ID_KEY, - consts.GEN_AI_CALLER_AGENT_NAME_KEY, - consts.GEN_AI_CALLER_AGENT_USER_ID_KEY, - consts.GEN_AI_CALLER_AGENT_EMAIL_KEY, - consts.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, - consts.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, - consts.GEN_AI_CALLER_AGENT_VERSION_KEY, -]; diff --git a/packages/agents-a365-observability/src/tracing/scopes/ApplyGuardrailScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ApplyGuardrailScope.ts deleted file mode 100644 index a2ef5752..00000000 --- a/packages/agents-a365-observability/src/tracing/scopes/ApplyGuardrailScope.ts +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { SpanKind } from '@opentelemetry/api'; -import { OpenTelemetryScope } from './OpenTelemetryScope'; -import { OpenTelemetryConstants } from '../constants'; -import { - GuardrailDetails, - GuardrailDecisionType, - GuardrailFinding, - AgentDetails, - UserDetails, - Request, - SpanDetails, -} from '../contracts'; - -/** - * Provides OpenTelemetry tracing scope for security guardrail evaluation operations. - * - * Describes a security guardian evaluation. Multiple guardian spans MAY exist under a single - * operation span if multiple guardians are chained. - * - * Guardian spans SHOULD be children of the operation span they are protecting - * (e.g., inference or execute_tool spans). - */ -export class ApplyGuardrailScope extends OpenTelemetryScope { - /** - * Creates and starts a new scope for guardrail evaluation tracing. - * - * @param details Details of the guardrail evaluation (target, decision, guardian info, policy). - * @param agentDetails Information about the agent being guarded. - * @param request Optional request details for conversation context. - * @param userDetails Optional human user details. - * @param spanDetails Optional span configuration (parent context, timing, kind, span links). - * @returns A new ApplyGuardrailScope instance. - */ - public static start( - details: GuardrailDetails, - agentDetails: AgentDetails, - request?: Request, - userDetails?: UserDetails, - spanDetails?: SpanDetails, - ): ApplyGuardrailScope { - return new ApplyGuardrailScope(details, agentDetails, request, userDetails, spanDetails); - } - - private constructor( - details: GuardrailDetails, - agentDetails: AgentDetails, - request?: Request, - userDetails?: UserDetails, - spanDetails?: SpanDetails, - ) { - // Validate tenantId is present (required for telemetry) - if (!agentDetails.tenantId) { - throw new Error('ApplyGuardrailScope: tenantId is required on agentDetails'); - } - - // Default to INTERNAL; allow caller override via spanDetails - const resolvedSpanDetails: SpanDetails = { spanKind: SpanKind.INTERNAL, ...spanDetails }; - - const spanName = details.guardianName - ? `${OpenTelemetryConstants.APPLY_GUARDRAIL_OPERATION_NAME} ${details.guardianName} ${details.targetType}` - : `${OpenTelemetryConstants.APPLY_GUARDRAIL_OPERATION_NAME} ${details.targetType}`; - - super( - OpenTelemetryConstants.APPLY_GUARDRAIL_OPERATION_NAME, - spanName, - agentDetails, - resolvedSpanDetails, - userDetails, - ); - - // Required attributes - this.setTagMaybe(OpenTelemetryConstants.SECURITY_DECISION_TYPE_KEY, details.decisionType); - this.setTagMaybe(OpenTelemetryConstants.SECURITY_TARGET_TYPE_KEY, details.targetType); - - // Guardian attributes - this.setTagMaybe(OpenTelemetryConstants.GUARDIAN_ID_KEY, details.guardianId); - this.setTagMaybe(OpenTelemetryConstants.GUARDIAN_NAME_KEY, details.guardianName); - this.setTagMaybe(OpenTelemetryConstants.GUARDIAN_PROVIDER_NAME_KEY, details.guardianProviderName); - this.setTagMaybe(OpenTelemetryConstants.GUARDIAN_VERSION_KEY, details.guardianVersion); - - // Target attributes - this.setTagMaybe(OpenTelemetryConstants.SECURITY_TARGET_ID_KEY, details.targetId); - - // Decision attributes - this.setTagMaybe(OpenTelemetryConstants.SECURITY_DECISION_REASON_KEY, details.decisionReason); - this.setTagMaybe(OpenTelemetryConstants.SECURITY_DECISION_CODE_KEY, details.decisionCode); - - // Policy attributes - this.setTagMaybe(OpenTelemetryConstants.SECURITY_POLICY_ID_KEY, details.policyId); - this.setTagMaybe(OpenTelemetryConstants.SECURITY_POLICY_NAME_KEY, details.policyName); - this.setTagMaybe(OpenTelemetryConstants.SECURITY_POLICY_VERSION_KEY, details.policyVersion); - - // Content attributes - this.setTagMaybe(OpenTelemetryConstants.SECURITY_CONTENT_INPUT_HASH_KEY, details.contentInputHash); - if (details.contentModified != null) { - this.setTagMaybe(OpenTelemetryConstants.SECURITY_CONTENT_MODIFIED_KEY, details.contentModified); - } - - // Correlation attributes - this.setTagMaybe(OpenTelemetryConstants.SECURITY_EXTERNAL_EVENT_ID_KEY, details.externalEventId); - - // Request context - if (request) { - if (typeof request.content === 'string') { - this.setTagMaybe(OpenTelemetryConstants.SECURITY_CONTENT_INPUT_VALUE_KEY, request.content); - } - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - if (request.channel) { - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel.description); - } - } - } - - /** - * Records an updated decision on the guardrail span. - * Use this when the guardrail decision is determined after span creation. - * @param decisionType The decision type made by the guardian. - * @param reason Optional human-readable explanation for the decision. - */ - public recordDecision(decisionType: GuardrailDecisionType, reason?: string): void { - this.setTagMaybe(OpenTelemetryConstants.SECURITY_DECISION_TYPE_KEY, decisionType); - this.setTagMaybe(OpenTelemetryConstants.SECURITY_DECISION_REASON_KEY, reason); - } - - /** - * Records the output content value for the guardrail evaluation (opt-in). - * @param outputValue The output content after guardrail processing. - */ - public recordContentOutput(outputValue: string): void { - this.setTagMaybe(OpenTelemetryConstants.SECURITY_CONTENT_OUTPUT_VALUE_KEY, outputValue); - } - - /** - * Records a security finding event on the current span. - * Multiple findings may be recorded per guardrail evaluation. - * @param finding The security finding to record. - */ - public recordFinding(finding: GuardrailFinding): void { - if (!finding) { - throw new Error('ApplyGuardrailScope.recordFinding: finding is required'); - } - - const attributes: Record = { - [OpenTelemetryConstants.SECURITY_RISK_CATEGORY_KEY]: finding.riskCategory, - [OpenTelemetryConstants.SECURITY_RISK_SEVERITY_KEY]: finding.riskSeverity, - }; - - if (finding.policyDecisionType != null) { - attributes[OpenTelemetryConstants.SECURITY_POLICY_DECISION_TYPE_KEY] = finding.policyDecisionType; - } - if (finding.policyId != null) { - attributes[OpenTelemetryConstants.SECURITY_POLICY_ID_KEY] = finding.policyId; - } - if (finding.policyName != null) { - attributes[OpenTelemetryConstants.SECURITY_POLICY_NAME_KEY] = finding.policyName; - } - if (finding.policyVersion != null) { - attributes[OpenTelemetryConstants.SECURITY_POLICY_VERSION_KEY] = finding.policyVersion; - } - if (finding.riskScore != null) { - attributes[OpenTelemetryConstants.SECURITY_RISK_SCORE_KEY] = finding.riskScore; - } - if (finding.riskMetadata != null) { - attributes[OpenTelemetryConstants.SECURITY_RISK_METADATA_KEY] = finding.riskMetadata; - } - - this.addEvent(OpenTelemetryConstants.SECURITY_FINDING_EVENT_NAME, attributes); - } -} diff --git a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts b/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts deleted file mode 100644 index 86f0fd9d..00000000 --- a/packages/agents-a365-observability/src/tracing/scopes/ExecuteToolScope.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { SpanKind } from '@opentelemetry/api'; -import { OpenTelemetryScope } from './OpenTelemetryScope'; -import { - ToolCallDetails, - AgentDetails, - UserDetails, - Request, - SpanDetails, -} from '../contracts'; -import { OpenTelemetryConstants } from '../constants'; -import { safeSerializeToJson } from '../util'; - -/** - * Provides OpenTelemetry tracing scope for AI tool execution operations. - */ -export class ExecuteToolScope extends OpenTelemetryScope { - /** - * Creates and starts a new scope for tool execution tracing. - * - * @param request Request payload (channel, conversationId, content, sessionId). - * @param details The tool call details (name, type, args, call id, etc.). - * @param agentDetails The agent executing the tool. Tenant ID is derived from `agentDetails.tenantId`. - * @param userDetails Optional human caller identity. - * @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanLinks, spanKind). Defaults to SpanKind.INTERNAL. - * @returns A new ExecuteToolScope instance. - */ - public static start( - request: Request, - details: ToolCallDetails, - agentDetails: AgentDetails, - userDetails?: UserDetails, - spanDetails?: SpanDetails - ): ExecuteToolScope { - return new ExecuteToolScope(request, details, agentDetails, userDetails, spanDetails); - } - - private constructor( - request: Request, - details: ToolCallDetails, - agentDetails: AgentDetails, - userDetails?: UserDetails, - spanDetails?: SpanDetails - ) { - // Validate tenantId is present (required for telemetry) - if (!agentDetails.tenantId) { - throw new Error('ExecuteToolScope: tenantId is required on agentDetails'); - } - - // Default to INTERNAL; allow caller override via spanDetails - const resolvedSpanDetails: SpanDetails = { spanKind: SpanKind.INTERNAL, ...spanDetails }; - - super( - OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME, - `${OpenTelemetryConstants.EXECUTE_TOOL_OPERATION_NAME} ${details.toolName}`, - agentDetails, - resolvedSpanDetails, - userDetails, - ); - - // Destructure the details object - const { toolName, arguments: args, toolCallId, description, toolType, endpoint } = details; - - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_NAME_KEY, toolName); - this.setTagMaybe( - OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY, - args != null ? safeSerializeToJson(args, 'arguments') : undefined - ); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY, toolType); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_CALL_ID_KEY, toolCallId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_DESCRIPTION_KEY, description); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); - - // Set endpoint information if provided - if (endpoint) { - this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, endpoint.host); - - // Only record port if it is different from 443 (default HTTPS port) - if (endpoint.port && endpoint.port !== 443) { - this.setTagMaybe(OpenTelemetryConstants.SERVER_PORT_KEY, endpoint.port); - } - } - } - - /** - * Records response information for telemetry tracking. - * @param response The tool execution response. Objects are serialized to JSON automatically. - */ - public recordResponse(response: Record | string): void { - this.setTagMaybe( - OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY, - safeSerializeToJson(response, 'result') - ); - } -} diff --git a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts deleted file mode 100644 index 619f5be0..00000000 --- a/packages/agents-a365-observability/src/tracing/scopes/InferenceScope.ts +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { SpanKind } from '@opentelemetry/api'; -import { OpenTelemetryScope } from './OpenTelemetryScope'; -import { OpenTelemetryConstants } from '../constants'; -import { - InferenceDetails, - InferenceOperationType, - AgentDetails, - UserDetails, - Request, - SpanDetails, - InputMessagesParam, - OutputMessagesParam, -} from '../contracts'; - -/** - * Maps each {@link InferenceOperationType} to its canonical lowercase `gen_ai.operation.name` - * semconv value. Used for both the `gen_ai.operation.name` attribute and the span-name prefix. - */ -const OPERATION_NAME_MAP: Record = { - [InferenceOperationType.CHAT]: OpenTelemetryConstants.CHAT_OPERATION_NAME, - [InferenceOperationType.TEXT_COMPLETION]: OpenTelemetryConstants.TEXT_COMPLETION_OPERATION_NAME, - [InferenceOperationType.GENERATE_CONTENT]: OpenTelemetryConstants.GENERATE_CONTENT_OPERATION_NAME, -}; - -/** Returns `value` trimmed, or `undefined` if it is missing or blank after trimming. */ -function normalizeOrUndefined(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -/** - * Provides OpenTelemetry tracing scope for generative AI inference operations. - */ -export class InferenceScope extends OpenTelemetryScope { - /** - * Creates and starts a new scope for inference tracing. - * - * @param request Request payload (channel, conversationId, content, sessionId). - * @param details The inference call details (model, provider, tokens, etc.). - * @param agentDetails The agent performing the inference. Tenant ID is derived from `agentDetails.tenantId`. - * @param userDetails Optional human caller identity. - * @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanLinks). Note: `spanKind` is ignored; InferenceScope always uses `SpanKind.CLIENT`. - * @returns A new InferenceScope instance - */ - public static start( - request: Request, - details: InferenceDetails, - agentDetails: AgentDetails, - userDetails?: UserDetails, - spanDetails?: SpanDetails - ): InferenceScope { - return new InferenceScope(request, details, agentDetails, userDetails, spanDetails); - } - - private constructor( - request: Request, - details: InferenceDetails, - agentDetails: AgentDetails, - userDetails?: UserDetails, - spanDetails?: SpanDetails - ) { - // Validate tenantId is present (required for telemetry) - if (!agentDetails.tenantId) { - throw new Error('InferenceScope: tenantId is required on agentDetails'); - } - // Normalize model/responseModel once: blank/whitespace-only strings are treated as absent, - // so they are never emitted as empty attributes and never leak into the span-name fallback. - const normalizedModel = normalizeOrUndefined(details.model); - const normalizedResponseModel = normalizeOrUndefined(details.responseModel); - if (!normalizedModel && !normalizedResponseModel) { - throw new Error('InferenceScope: at least one of model or responseModel is required'); - } - if (!details.providerName?.trim()) { - throw new Error('InferenceScope: providerName is required'); - } - if (details.endpoint?.port !== undefined && - (!Number.isInteger(details.endpoint.port) || details.endpoint.port < 1 || details.endpoint.port > 65535)) { - throw new Error('InferenceScope: endpoint port must be an integer between 1 and 65535'); - } - - // spanKind for InferenceScope is always CLIENT - const resolvedSpanDetails: SpanDetails = { ...spanDetails, spanKind: SpanKind.CLIENT }; - - // Map the operation type to its canonical lowercase semconv value (chat/text_completion/ - // generate_content) for both gen_ai.operation.name and the span-name prefix. - const operationName = OPERATION_NAME_MAP[details.operationName] ?? OpenTelemetryConstants.CHAT_OPERATION_NAME; - - super( - operationName, - `${operationName} ${normalizedModel ?? normalizedResponseModel}`, - agentDetails, - resolvedSpanDetails, - userDetails, - ); - - // Set core inference information - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY, normalizedModel); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY, normalizedResponseModel); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, details.providerName); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY, details.inputTokens); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY, details.outputTokens); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, details.cacheReadInputTokens); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, details.cacheWriteInputTokens); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_RESPONSE_FINISH_REASONS_KEY, details.finishReasons); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_THOUGHT_PROCESS_KEY, details.thoughtProcess); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); - - // Set endpoint information if provided - if (details.endpoint) { - this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, details.endpoint.host); - - if (details.endpoint.port !== undefined) { - this.setTagMaybe(OpenTelemetryConstants.SERVER_PORT_KEY, details.endpoint.port); - } - } - } - - /** - * Records the number of input tokens for telemetry tracking. - * @param inputTokens Number of input tokens - */ - public recordInputTokens(inputTokens: number): void { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY, inputTokens); - } - - /** - * Records the number of output tokens for telemetry tracking. - * @param outputTokens Number of output tokens - */ - public recordOutputTokens(outputTokens: number): void { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY, outputTokens); - } - - /** Records the number of input tokens read from a cache. */ - public recordCacheReadInputTokens(inputTokens: number): void { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, inputTokens); - } - - /** Records the number of input tokens written to a cache. */ - public recordCacheWriteInputTokens(inputTokens: number): void { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, inputTokens); - } - - /** - * Records the finish reasons for telemetry tracking. - * @param finishReasons Array of finish reasons - */ - public recordFinishReasons(finishReasons: string[]): void { - if (finishReasons && finishReasons.length > 0) { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_RESPONSE_FINISH_REASONS_KEY, finishReasons); - } - } - - /** - * Records the input messages for telemetry tracking. - * Accepts plain strings (auto-wrapped as OTEL ChatMessage with role `user`) or a versioned InputMessages wrapper. - * @param messages Array of input message strings or an InputMessages wrapper - */ - public override recordInputMessages(messages: InputMessagesParam): void { - super.recordInputMessages(messages); - } - - /** - * Records the output messages for telemetry tracking. - * Accepts plain strings (auto-wrapped as OTEL OutputMessage with role `assistant`) or a versioned OutputMessages wrapper. - * @param messages Array of output message strings or an OutputMessages wrapper - */ - public override recordOutputMessages(messages: OutputMessagesParam): void { - super.recordOutputMessages(messages); - } -} diff --git a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts b/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts deleted file mode 100644 index 7dae598d..00000000 --- a/packages/agents-a365-observability/src/tracing/scopes/InvokeAgentScope.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { SpanKind } from '@opentelemetry/api'; -import { OpenTelemetryScope } from './OpenTelemetryScope'; -import { - InvokeAgentScopeDetails, - CallerDetails, - Request, - SpanDetails, - AgentDetails, - InputMessagesParam, - OutputMessagesParam, -} from '../contracts'; -import { OpenTelemetryConstants } from '../constants'; - -/** - * Provides OpenTelemetry tracing scope for AI agent invocation operations. - */ -export class InvokeAgentScope extends OpenTelemetryScope { - /** - * Creates and starts a new scope for agent invocation tracing. - * - * @param request Request payload (channel, conversationId, content, sessionId). - * @param invokeScopeDetails Scope-level details - * @param agentDetails The agent identity. Tenant ID is derived from `agentDetails.tenantId` (required). - * @param callerDetails Optional caller information. Supports three scenarios: - * - Human caller only: `{ userDetails: { userId, userName, ... } }` - * - Agent caller only: `{ callerAgentDetails: { agentId, agentName, ... } }` - * - Both (A2A with human in chain): `{ userDetails: { ... }, callerAgentDetails: { ... } }` - * @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanKind, spanLinks). - * @returns A new InvokeAgentScope instance. - */ - public static start( - request: Request, - invokeScopeDetails: InvokeAgentScopeDetails, - agentDetails: AgentDetails, - callerDetails?: CallerDetails, - spanDetails?: SpanDetails, - ): InvokeAgentScope { - return new InvokeAgentScope(request, invokeScopeDetails, agentDetails, callerDetails, spanDetails); - } - - private constructor( - request: Request, - invokeScopeDetails: InvokeAgentScopeDetails, - agentDetails: AgentDetails, - callerDetails?: CallerDetails, - spanDetails?: SpanDetails - ) { - // Validate tenantId is present (required for telemetry) - if (!agentDetails.tenantId) { - throw new Error('InvokeAgentScope: tenantId is required on agentDetails'); - } - - const resolvedSpanDetails: SpanDetails = { ...spanDetails, spanKind: spanDetails?.spanKind ?? SpanKind.CLIENT }; - - super( - OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, - agentDetails.agentName - ? `${OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME} ${agentDetails.agentName}` - : OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, - agentDetails, - resolvedSpanDetails, - callerDetails?.userDetails, - ); - - // Set provider name from agent details - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, agentDetails.providerName); - - // Set session ID from request - this.setTagMaybe(OpenTelemetryConstants.SESSION_ID_KEY, request.sessionId); - - if (invokeScopeDetails.endpoint) { - this.setTagMaybe(OpenTelemetryConstants.SERVER_ADDRESS_KEY, invokeScopeDetails.endpoint.host); - - // Only record port if it is different from 443 (default HTTPS port) - if (invokeScopeDetails.endpoint.port && invokeScopeDetails.endpoint.port !== 443) { - this.setTagMaybe(OpenTelemetryConstants.SERVER_PORT_KEY, invokeScopeDetails.endpoint.port); - } - } - - // Set channel tags from request - if (request.channel) { - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel.description); - } - - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - - // Record request content as input messages - if (request.content != null) { - this.recordInputMessages(request.content); - } - - // Set caller agent details tags for A2A scenarios - const callerAgent = callerDetails?.callerAgentDetails; - if (callerAgent) { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY, callerAgent.agentName); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, callerAgent.agentId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, callerAgent.agentBlueprintId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_USER_ID_KEY, callerAgent.agentAUID); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_EMAIL_KEY, callerAgent.agentEmail); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, callerAgent.platformId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_VERSION_KEY, callerAgent.agentVersion); - } - } - - /** - * Records response information for telemetry tracking. - * @param response The invocation response - */ - public recordResponse(response: string): void { - this.recordOutputMessages(response); - } - - /** - * Records the input messages for telemetry tracking. - * Accepts a single string, an array of strings (auto-wrapped as OTEL ChatMessage with role `user`), or a versioned InputMessages wrapper. - * @param messages A string, array of strings, or an InputMessages wrapper - */ - public override recordInputMessages(messages: InputMessagesParam): void { - super.recordInputMessages(messages); - } - - /** - * Records the output messages for telemetry tracking. - * Accepts a single string, an array of strings (auto-wrapped as OTEL OutputMessage with role `assistant`), or a versioned OutputMessages wrapper. - * @param messages A string, array of strings, or an OutputMessages wrapper - */ - public override recordOutputMessages(messages: OutputMessagesParam): void { - super.recordOutputMessages(messages); - } -} diff --git a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts deleted file mode 100644 index c72e5ef6..00000000 --- a/packages/agents-a365-observability/src/tracing/scopes/OpenTelemetryScope.ts +++ /dev/null @@ -1,311 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { trace, SpanKind, Span, SpanStatusCode, context, AttributeValue, SpanContext, TimeInput, Attributes } from '@opentelemetry/api'; -import { OpenTelemetryConstants } from '../constants'; -import { AgentDetails, UserDetails, SpanDetails, InputMessagesParam, OutputMessagesParam } from '../contracts'; -import { createContextWithParentSpanRef } from '../context/parent-span-context'; -import { isParentSpanRef } from '../context/trace-context-propagation'; -import { normalizeInputMessages, normalizeOutputMessages, serializeMessages } from '../message-utils'; -import logger from '../../utils/logging'; - -/** - * Base class for OpenTelemetry tracing scopes - */ -export abstract class OpenTelemetryScope implements Disposable { - private static readonly tracer = trace.getTracer(OpenTelemetryConstants.SOURCE_NAME); - - protected readonly span: Span; - private readonly wallClockStartMs: number; - private customStartTime?: TimeInput; - private customEndTime?: TimeInput; - private errorType?: string; - private hasEnded = false; - - /** - * Initializes a new instance of the OpenTelemetryScope class - * @param operationName The name of the operation being traced - * @param spanName The name of the span for display purposes - * @param agentDetails Optional agent details. Tenant ID is read from `agentDetails.tenantId`. - * @param spanDetails Optional span configuration including parent context, start/end times, - * span kind, and span links. Subclasses may override `spanDetails.spanKind` before - * calling this constructor; defaults to `SpanKind.CLIENT`. - * @param userDetails Optional human caller identity details (id, upn, name, client ip). - */ - protected constructor( - operationName: string, - spanName: string, - agentDetails?: AgentDetails, - spanDetails?: SpanDetails, - userDetails?: UserDetails, - ) { - const parentContext = spanDetails?.parentContext; - const startTime = spanDetails?.startTime; - const endTime = spanDetails?.endTime; - const spanLinks = spanDetails?.spanLinks; - const kind = spanDetails?.spanKind ?? SpanKind.CLIENT; - - // Determine the context to use for span creation - let currentContext = context.active(); - if (parentContext) { - if (isParentSpanRef(parentContext)) { - // Existing ParentSpanRef path (backward compatible) - currentContext = createContextWithParentSpanRef(currentContext, parentContext); - logger.info(`[A365Observability] Using explicit parent span: traceId=${parentContext.traceId}, spanId=${parentContext.spanId}`); - } else { - // OTel Context path (from extractContextFromHeaders or propagation.extract) - currentContext = parentContext; - } - } - - logger.info(`[A365Observability] Starting span: ${spanName}, operation: ${operationName} for tenantId: ${agentDetails?.tenantId || 'unknown'}, agentId: ${agentDetails?.agentId || 'unknown'}`); - - // Start span with current context to establish parent-child relationship - this.span = OpenTelemetryScope.tracer.startSpan(spanName, { - kind, - startTime, - links: spanLinks, - attributes: { - [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: operationName, - }, - }, currentContext); - - logger.info(`[A365Observability] Span[${this.span.spanContext().spanId}] ${spanName}, operation: ${operationName} started successfully`); - - this.wallClockStartMs = Date.now(); - if (startTime !== undefined) { - this.customStartTime = startTime; - } - this.customEndTime = endTime; - - // Set agent details if provided - if (agentDetails) { - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, agentDetails.agentId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, agentDetails.agentName); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, agentDetails.agentDescription); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_TYPE_KEY, agentDetails.agentType); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_PLATFORM_ID_KEY, agentDetails.platformId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_ICON_URI_KEY, agentDetails.iconUri); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY, agentDetails.agentAUID); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_EMAIL_KEY, agentDetails.agentEmail); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY, agentDetails.agentBlueprintId); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY, agentDetails.agentVersion); - } - - // Set tenant ID from agent details - this.setTagMaybe(OpenTelemetryConstants.TENANT_ID_KEY, agentDetails?.tenantId); - - // Set caller details if provided - if (userDetails) { - this.setTagMaybe(OpenTelemetryConstants.USER_ID_KEY, userDetails.userId); - this.setTagMaybe(OpenTelemetryConstants.USER_EMAIL_KEY, userDetails.userEmail); - this.setTagMaybe(OpenTelemetryConstants.USER_NAME_KEY, userDetails.userName); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, userDetails.callerClientIp); - } - } - - /** - * Makes this span active for the duration of the async callback execution - */ - public withActiveSpanAsync(callback: () => Promise): Promise { - const newContext = trace.setSpan(context.active(), this.span); - return context.with(newContext, callback); - } - - /** - * Gets the span context for this scope. - * This can be used to create a ParentSpanRef for explicit parent-child linking across async boundaries. - * @returns The SpanContext containing traceId and spanId - */ - public getSpanContext(): SpanContext { - return this.span.spanContext(); - } - - /** - * Records an error that occurred during the operation - * @param error The error that occurred - */ - public recordError(error: Error): void { - logger.error(`[A365Observability] Records an error that occurred during the operation span[${this.span.spanContext().spanId}]: ${error.message}`); - // Check if it's an HTTP error with status code - if ('status' in error && typeof error.status === 'number') { - this.errorType = error.status.toString(); - } else { - this.errorType = error.constructor.name; - } - - this.span.setStatus({ - code: SpanStatusCode.ERROR, - message: error.message - }); - - this.span.recordException(error); - } - - /** - * Records multiple attribute key/value pairs for telemetry tracking. - * @param attributes Collection of attribute key/value pairs (array or iterable of [key, value] or object map). - */ - public recordAttributes(attributes: Iterable<[string, AttributeValue]> | Record | null | undefined): void { - if (!attributes) return; - // Support both array/iterable of pairs and object maps - if (Array.isArray(attributes)) { - for (const [key, value] of attributes as Array<[string, AttributeValue]>) { - if (!key || typeof key !== 'string' || !key.trim()) continue; - this.span.setAttribute(key, value); - } - } else if ( - typeof attributes === 'object' && - typeof (attributes as Iterable<[string, AttributeValue]>)[Symbol.iterator] === 'function' && - !Array.isArray(attributes) && - typeof attributes !== 'string' - ) { - for (const [key, value] of attributes as Iterable<[string, AttributeValue]>) { - if (!key || typeof key !== 'string' || !key.trim()) continue; - this.span.setAttribute(key, value); - } - } else if ( - typeof attributes === 'object') { - for (const key of Object.keys(attributes)) { - if (!key || typeof key !== 'string' || !key.trim()) continue; - this.span.setAttribute(key, (attributes as Record)[key]); - } - } - } - - /** - * Records the input messages for telemetry tracking. - * Accepts a single string, an array of strings (auto-wrapped as OTEL ChatMessage), or a versioned InputMessages wrapper. - * @param messages A string, array of strings, or an InputMessages wrapper - */ - protected recordInputMessages(messages: InputMessagesParam): void { - const wrapper = normalizeInputMessages(messages); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, serializeMessages(wrapper)); - } - - /** - * Records the output messages for telemetry tracking. - * Accepts a single string, an array of strings (auto-wrapped as OTEL OutputMessage), or a versioned OutputMessages wrapper. - * @param messages A string, array of strings, or an OutputMessages wrapper - */ - protected recordOutputMessages(messages: OutputMessagesParam): void { - const wrapper = normalizeOutputMessages(messages); - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, serializeMessages(wrapper)); - } - - /** - * Sets a tag on the span if the value is not null or undefined. - * @param name The tag name - * @param value The tag value - */ - protected setTagMaybe(name: string, value: T | null | undefined): void { - if (value != null) { - this.span.setAttributes({ [name]: value as string | number | boolean | string[] | number[] }); - } - } - - /** - * Adds an event to the current span. - * @param name The event name - * @param attributes Optional event attributes - */ - protected addEvent(name: string, attributes?: Attributes): void { - this.span.addEvent(name, attributes); - } - - /** - * Add baggage to the current context - * @param key The baggage key - * @param value The baggage value - */ - protected addBaggage(key: string, value: string): void { - // Note: OpenTelemetry JS doesn't have direct baggage API in span - // This would typically be handled through the baggage API - this.span.setAttributes({ [`baggage.${key}`]: value }); - } - - /** - * Converts a `TimeInput` value to milliseconds since epoch. - * OTel's `TimeInput` can be a `number` (ms epoch), a `Date`, or an `HrTime` tuple `[seconds, nanoseconds]`. - */ - private static timeInputToMs(t: TimeInput): number { - if (typeof t === 'number') return t; - if (t instanceof Date) return t.getTime(); - if (Array.isArray(t) && t.length === 2) return t[0] * 1000 + t[1] / 1_000_000; - logger.warn(`[A365Observability] timeInputToMs received unexpected TimeInput (type=${typeof t}, isArray=${Array.isArray(t)}); falling back to Date.now()`); - return Date.now(); - } - - /** - * Sets a custom end time for the scope. - * When set, {@link dispose} will pass this value to `span.end()` instead of using the current wall-clock time. - * This is useful when the actual end time of the operation is known before the scope is disposed. - * @param endTime The end time as milliseconds since epoch, a Date, or an HrTime tuple. - */ - public setEndTime(endTime: TimeInput): void { - this.customEndTime = endTime; - } - - /** - * Records a cancellation event on the span. - * Sets the span status to ERROR with the cancellation reason and marks the error type as 'TaskCanceledException'. - * @param reason Optional cancellation reason. Defaults to 'Task was cancelled'. - */ - public recordCancellation(reason?: string): void { - const message = reason ?? 'Task was cancelled'; - logger.info(`[A365Observability] Recording cancellation on span[${this.span.spanContext().spanId}]: ${message}`); - this.span.setStatus({ - code: SpanStatusCode.ERROR, - message - }); - this.errorType = OpenTelemetryConstants.ERROR_TYPE_CANCELLED; - } - - /** - * Finalizes the scope and records metrics - */ - private end(): void { - if (this.hasEnded) { - logger.info(`[A365Observability] Span already ended for span[${this.span.spanContext().spanId}]`); - return; - } - - // Calculate duration: use custom start/end when provided, otherwise fall back to wall-clock. - const startMs = this.customStartTime !== undefined - ? OpenTelemetryScope.timeInputToMs(this.customStartTime) - : this.wallClockStartMs; - const endMs = this.customEndTime !== undefined - ? OpenTelemetryScope.timeInputToMs(this.customEndTime) - : Date.now(); - const durationMs = Math.max(0, endMs - startMs); - - if (this.errorType) { - this.span.setAttributes({ [OpenTelemetryConstants.ERROR_TYPE_KEY]: this.errorType }); - } - - this.hasEnded = true; - logger.info(`[A365Observability] Ending span[${this.span.spanContext().spanId}], duration: ${(durationMs / 1000).toFixed(3)}s`); - } - - /** - * Disposes the scope and finalizes telemetry data collection - */ - public [Symbol.dispose](): void { - if (!this.hasEnded) { - this.end(); - if (this.customEndTime !== undefined) { - this.span.end(this.customEndTime); - } else { - this.span.end(); - } - } - } - - /** - * Legacy dispose method for compatibility - */ - public dispose(): void { - this[Symbol.dispose](); - } - -} diff --git a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts b/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts deleted file mode 100644 index b3f387b9..00000000 --- a/packages/agents-a365-observability/src/tracing/scopes/OutputScope.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { SpanKind } from '@opentelemetry/api'; -import { OpenTelemetryScope } from './OpenTelemetryScope'; -import { AgentDetails, UserDetails, OutputResponse, OutputMessages, Request, SpanDetails, ResponseMessagesParam } from '../contracts'; -import { OpenTelemetryConstants } from '../constants'; -import { normalizeOutputMessages, serializeMessages } from '../message-utils'; - -/** - * Provides OpenTelemetry tracing scope for output message tracing with parent span linking. - */ -export class OutputScope extends OpenTelemetryScope { - - /** - * Creates and starts a new scope for output message tracing. - * - * @param request Request payload (channel, conversationId, content, sessionId). - * @param response The response containing initial output messages. - * @param agentDetails The agent producing the output. Tenant ID is derived from `agentDetails.tenantId`. - * @param userDetails Optional human caller identity details. - * @param spanDetails Optional span configuration (parentContext, startTime, endTime, spanLinks). - * @returns A new OutputScope instance. - */ - public static start( - request: Request, - response: OutputResponse, - agentDetails: AgentDetails, - userDetails?: UserDetails, - spanDetails?: SpanDetails - ): OutputScope { - return new OutputScope(request, response, agentDetails, userDetails, spanDetails); - } - - private constructor( - request: Request, - response: OutputResponse, - agentDetails: AgentDetails, - userDetails?: UserDetails, - spanDetails?: SpanDetails - ) { - // Validate tenantId is present (required for telemetry) - if (!agentDetails.tenantId) { - throw new Error('OutputScope: tenantId is required on agentDetails'); - } - - // spanKind for OutputScope is always CLIENT - const resolvedSpanDetails: SpanDetails = { ...spanDetails, spanKind: SpanKind.CLIENT }; - - super( - OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME, - agentDetails.agentName - ? `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentName}` - : `${OpenTelemetryConstants.OUTPUT_MESSAGES_OPERATION_NAME} ${agentDetails.agentId}`, - agentDetails, - resolvedSpanDetails, - userDetails, - ); - - // Normalize and set initial output messages - this._setOutput(response.messages); - - // Set conversation and channel - this.setTagMaybe(OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, request.conversationId); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_NAME_KEY, request.channel?.name); - this.setTagMaybe(OpenTelemetryConstants.CHANNEL_LINK_KEY, request.channel?.description); - } - - /** - * Records the output messages for telemetry tracking. - * Overwrites any previously recorded output messages on the span. - * Accepts a single string, an array of strings (auto-wrapped as OTEL OutputMessage), a versioned OutputMessages wrapper, - * or a raw dict (treated as a tool call result per OTEL spec, serialized directly). - * @param messages A string, array of strings, an OutputMessages wrapper, or a dict. - */ - public recordOutputMessages(messages: ResponseMessagesParam): void { - this._setOutput(messages); - } - - private _setOutput(messages: ResponseMessagesParam): void { - // Dict (Record) — treat as tool call result, serialize directly - if (this._isRawDict(messages)) { - try { - this.setTagMaybe( - OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, - JSON.stringify(messages) - ); - } catch { - this.setTagMaybe( - OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, - JSON.stringify({ error: 'serialization failed' }) - ); - } - return; - } - const normalized = normalizeOutputMessages(messages); - this.setTagMaybe( - OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, - serializeMessages(normalized) - ); - } - - /** - * Check if the value is a raw dict (plain object, not string[] or OutputMessages wrapper). - */ - private _isRawDict(messages: ResponseMessagesParam): messages is Record { - return typeof messages === 'object' && messages !== null - && !Array.isArray(messages) - && !('messages' in messages && Array.isArray((messages as OutputMessages).messages)); - } -} diff --git a/packages/agents-a365-observability/src/tracing/util.ts b/packages/agents-a365-observability/src/tracing/util.ts deleted file mode 100644 index 94a6505e..00000000 --- a/packages/agents-a365-observability/src/tracing/util.ts +++ /dev/null @@ -1,53 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; -import { ObservabilityConfiguration, defaultObservabilityConfigurationProvider } from '../configuration'; - -/** - * Check if exporter is enabled via environment variables. - * - * NOTE: Exporter-specific helpers have been moved to - * tracing/exporter/utils.ts. This file remains only for any - * non-exporter tracing utilities that may be added in the future. - * - * @param configProvider Optional configuration provider. Defaults to defaultObservabilityConfigurationProvider if not specified. - */ -export const isAgent365ExporterEnabled = ( - configProvider?: IConfigurationProvider -): boolean => { - const provider = configProvider ?? defaultObservabilityConfigurationProvider; - return provider.getConfiguration().isObservabilityExporterEnabled; -}; - -/** - * Ensures the value is always a JSON-parseable string. - * - Objects are serialized via JSON.stringify. - * - Strings that are already valid JSON objects/arrays are passed through. - * - All other strings (including bare JSON primitives) are wrapped: `{ [key]: value }`. - * @param value The value to serialize. - * @param key The key to use when wrapping a plain string. - */ -export function safeSerializeToJson(value: Record | string, key: string): string { - if (typeof value === 'object' && value !== null) { - try { - return JSON.stringify(value); - } catch { - return JSON.stringify({ error: 'serialization failed' }); - } - } - // String: check if it's already a valid JSON object/array, otherwise wrap it - const str = value as string; - try { - const parsed = JSON.parse(str); - // Only pass through objects/arrays; bare primitives (numbers, booleans, etc.) get wrapped - if (parsed !== null && typeof parsed === 'object') { - return str; - } - } catch { - // not valid JSON — fall through to wrap - } - return JSON.stringify({ [key]: str }); -} diff --git a/packages/agents-a365-observability/src/utils/logging.ts b/packages/agents-a365-observability/src/utils/logging.ts deleted file mode 100644 index 2e8fb5be..00000000 --- a/packages/agents-a365-observability/src/utils/logging.ts +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; -import { - defaultObservabilityConfigurationProvider, - ObservabilityConfiguration -} from '../configuration'; -import { ExporterEventNames } from '../tracing/exporter/ExporterEventNames'; - -/** - * Custom logger interface for Agent 365 observability - * Implement this interface to support logging backends - */ -export interface ILogger { - /** - * Log an informational message - * @param message The log message - * @param args Optional arguments to include in the log - */ - info(message: string, ...args: unknown[]): void; - - /** - * Log a warning message - * @param message The log message - * @param args Optional arguments to include in the log - */ - warn(message: string, ...args: unknown[]): void; - - /** - * Log an error message - * @param message The log message - * @param args Optional arguments to include in the log - */ - error(message: string, ...args: unknown[]): void; - - /** - * Log an event with standardized parameters - * @param eventType Standardized event name from ExporterEventNames enum (e.g., ExporterEventNames.EXPORT) - * @param isSuccess Whether the operation/event succeeded - * @param durationMs Duration of the operation/event in milliseconds - * @param message Optional message or additional details about the event, especially useful for errors or failures - * @param details Optional key-value pairs with additional context (e.g., correlationId, tenantId, agentId, etc.) - */ - event(eventType: ExporterEventNames, isSuccess: boolean, durationMs: number, message?: string, details?: Record): void; -} - -/** - * Format error object for logging with message and stack trace - */ -export function formatError(error: unknown): string { - if (error instanceof Error) { - return `${error.message}\nStack: ${error.stack || 'No stack trace'}`; - } - return String(error); -} - -const LOG_LEVELS: Record = { - none: 0, - info: 1, - warn: 2, - error: 3 -}; - -/** - * Parse log level string into a set of enabled log levels. - * Supports pipe-separated values like "info|warn|error". - */ -function parseLogLevel(level: string): Set { - const levels = new Set(); - const levelStrings = level.toLowerCase().trim().split('|'); - - for (const levelString of levelStrings) { - const normalizedLevel = levelString.trim(); - const levelValue = LOG_LEVELS[normalizedLevel]; - if (levelValue !== undefined) { - levels.add(levelValue); - } - } - - // If no valid levels found, default to none - if (levels.size === 0) { - levels.add(LOG_LEVELS.none); - } - - return levels; -} - - -/** - * Default console-based logger implementation with configuration provider support. - * - * Environment Variable: - * A365_OBSERVABILITY_LOG_LEVEL=none|info|warn|error (default: none) - * - * Single values: - * none = no logging (default) - * info = info messages only - * warn = warn messages only - * error = error messages only - * - * Multiple values (pipe-separated): - * info|warn = info and warn messages - * warn|error = warn and error messages - * info|warn|error = all message types - */ -export class DefaultLogger implements ILogger { - constructor( - private readonly configProvider: IConfigurationProvider = defaultObservabilityConfigurationProvider - ) {} - - private getEnabledLogLevels(): Set { - return parseLogLevel(this.configProvider.getConfiguration().observabilityLogLevel); - } - - info(message: string, ...args: unknown[]): void { - if (this.getEnabledLogLevels().has(LOG_LEVELS.info)) { - console.log('[INFO]', message, ...args); - } - } - - warn(message: string, ...args: unknown[]): void { - if (this.getEnabledLogLevels().has(LOG_LEVELS.warn)) { - console.warn('[WARN]', message, ...args); - } - } - - error(message: string, ...args: unknown[]): void { - if (this.getEnabledLogLevels().has(LOG_LEVELS.error)) { - console.error('[ERROR]', message, ...args); - } - } - - event(eventType: ExporterEventNames, isSuccess: boolean, durationMs: number, message?: string, details?: Record): void { - const status = isSuccess ? 'succeeded' : 'failed'; - const logLevelNeeded = isSuccess ? 1 : 3; - if (this.getEnabledLogLevels().has(logLevelNeeded)) { - const logFn = isSuccess ? console.log : console.error; - const messageInfo = message ? ` - ${message}` : ''; - const detailsInfo = details && Object.keys(details).length > 0 ? ` ${JSON.stringify(details)}` : ''; - logFn(`[EVENT]: ${eventType} ${status} in ${durationMs}ms${messageInfo}${detailsInfo}`); - } - } -} - -/** - * Global logger instance - can be replaced with a custom logger via setLogger() - */ -let globalLogger: ILogger = new DefaultLogger(); - -/** - * Set a custom logger implementation for the observability SDK - * - * Example with Winston: - * ```typescript - * import * as winston from 'winston'; - * import { setLogger } from '@microsoft/agents-a365-observability'; - * - * const winstonLogger = winston.createLogger({ - * level: 'info', - * format: winston.format.json(), - * transports: [ - * new winston.transports.File({ filename: 'error.log', level: 'error' }), - * new winston.transports.File({ filename: 'combined.log' }) - * ] - * }); - * - * setLogger({ - * info: (msg, ...args) => winstonLogger.info(msg, ...args), - * warn: (msg, ...args) => winstonLogger.warn(msg, ...args), - * error: (msg, ...args) => winstonLogger.error(msg, ...args), - * event: (eventType, isSuccess, durationMs, message, details) => { - * // eventType is ExporterEventNames enum value - * winstonLogger.log({ level: isSuccess ? 'info' : 'error', eventType, isSuccess, durationMs, message, ...details }); - * } - * }); - * ``` - * - * @param customLogger The custom logger implementation - */ -export function setLogger(customLogger: ILogger): void { - if ( - !customLogger || - typeof customLogger.info !== 'function' || - typeof customLogger.warn !== 'function' || - typeof customLogger.error !== 'function' || - typeof customLogger.event !== 'function' - ) { - throw new Error('Custom logger must implement ILogger interface with all methods: info, warn, error, and event'); - } - globalLogger = customLogger; -} - -/** - * Get the current logger instance - */ -export function getLogger(): ILogger { - return globalLogger; -} - -/** - * Reset to the default console logger (mainly for testing) - */ -export function resetLogger(): void { - globalLogger = new DefaultLogger(); -} - -/** - * Default logger instance for backward compatibility. - * Delegates to the global logger which can be replaced via setLogger(). - */ -export const logger: ILogger = { - info: (message: string, ...args: unknown[]) => globalLogger.info(message, ...args), - warn: (message: string, ...args: unknown[]) => globalLogger.warn(message, ...args), - error: (message: string, ...args: unknown[]) => globalLogger.error(message, ...args), - event: (eventType: ExporterEventNames, isSuccess: boolean, durationMs: number, message?: string, details?: Record) => - globalLogger.event(eventType, isSuccess, durationMs, message, details) -}; - -export default logger; diff --git a/packages/agents-a365-observability/tsconfig.cjs.json b/packages/agents-a365-observability/tsconfig.cjs.json deleted file mode 100644 index a4c9f50b..00000000 --- a/packages/agents-a365-observability/tsconfig.cjs.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./dist/cjs", - "module": "CommonJS" - } -} diff --git a/packages/agents-a365-observability/tsconfig.esm.json b/packages/agents-a365-observability/tsconfig.esm.json deleted file mode 100644 index 2532362d..00000000 --- a/packages/agents-a365-observability/tsconfig.esm.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./dist/esm", - "module": "ESNext" - } -} diff --git a/packages/agents-a365-observability/tsconfig.json b/packages/agents-a365-observability/tsconfig.json deleted file mode 100644 index caf5086b..00000000 --- a/packages/agents-a365-observability/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2023", "DOM"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "resolveJsonModule": true, - "moduleResolution": "node", - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "types": ["node", "jest"] - }, - "include": ["src/**/*"], - "exclude": [ - "node_modules", - "dist", - "**/*.spec.ts" - ] -} diff --git a/packages/agents-a365-runtime/docs/design.md b/packages/agents-a365-runtime/docs/design.md index d181225c..fae80f48 100644 --- a/packages/agents-a365-runtime/docs/design.md +++ b/packages/agents-a365-runtime/docs/design.md @@ -166,18 +166,16 @@ const dynamicConfig = new RuntimeConfiguration({ > **Note:** These functions are deprecated. Use the appropriate configuration class instead: > - `RuntimeConfiguration` for `clusterCategory` and `isDevelopmentEnvironment` > - `ToolingConfiguration` for `mcpPlatformAuthenticationScope` -> - `ObservabilityConfiguration` for `observabilityAuthenticationScopes` ```typescript import { - getObservabilityAuthenticationScope, // @deprecated - use ObservabilityConfiguration getClusterCategory, // @deprecated - use RuntimeConfiguration isDevelopmentEnvironment, // @deprecated - use RuntimeConfiguration getMcpPlatformAuthenticationScope, // @deprecated - use ToolingConfiguration } from '@microsoft/agents-a365-runtime'; ``` -These functions are maintained only for backward compatibility. The `getClusterCategory` and `isDevelopmentEnvironment` functions delegate to `defaultRuntimeConfigurationProvider`, while the auth scope functions return hardcoded production defaults. +These functions are maintained only for backward compatibility. The `getClusterCategory` and `isDevelopmentEnvironment` functions delegate to `defaultRuntimeConfigurationProvider`, while the MCP auth scope helper returns a hardcoded production default. The former Observability scope helper and constant have been removed; see the [migration guide](../../../docs/observability-migration.md). ## Type Definitions @@ -293,5 +291,4 @@ src/ The runtime package is a foundational dependency used by: - `@microsoft/agents-a365-tooling` - For agent identity resolution and headers -- `@microsoft/agents-a365-observability` - For cluster category and environment utilities - All tooling extension packages - For authentication and identity resolution diff --git a/packages/agents-a365-runtime/package.json b/packages/agents-a365-runtime/package.json index d82b0b9e..91eb615f 100644 --- a/packages/agents-a365-runtime/package.json +++ b/packages/agents-a365-runtime/package.json @@ -18,7 +18,7 @@ "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", "ci": "npm ci", - "pack": "node ../../copyFiles.js . && pnpm pack --pack-destination=../" + "pack": "pnpm --dir ../.. run pack" }, "keywords": [ "ai", diff --git a/packages/agents-a365-runtime/src/environment-utils.ts b/packages/agents-a365-runtime/src/environment-utils.ts index 74257a49..f79b6af8 100644 --- a/packages/agents-a365-runtime/src/environment-utils.ts +++ b/packages/agents-a365-runtime/src/environment-utils.ts @@ -8,19 +8,11 @@ * For new code, prefer using the configuration classes directly: * - RuntimeConfiguration for clusterCategory, isDevelopmentEnvironment, isNodeEnvDevelopment * - ToolingConfiguration for mcpPlatformAuthenticationScope - * - ObservabilityConfiguration for observabilityAuthenticationScopes */ import { RuntimeConfiguration, defaultRuntimeConfigurationProvider } from './configuration'; import { IConfigurationProvider } from './configuration/IConfigurationProvider'; -/** - * Production observability authentication scope. - * @deprecated This constant is exported for backward compatibility only. - * For new code, use `ObservabilityConfiguration.observabilityAuthenticationScopes` instead. - */ -export const PROD_OBSERVABILITY_SCOPE = 'api://9b975845-388f-4429-889e-eab1ef63949c/.default'; - /** * Production MCP platform authentication scope. * @deprecated This constant is exported for backward compatibility only. @@ -39,27 +31,6 @@ export const PROD_OBSERVABILITY_CLUSTER_CATEGORY = 'prod'; export const PRODUCTION_ENVIRONMENT_NAME = 'production'; export const DEVELOPMENT_ENVIRONMENT_NAME = 'Development'; -/** - * Returns the scope for authenticating to the observability service. - * - * @returns The authentication scopes for the current environment. - * @deprecated Use ObservabilityConfiguration.observabilityAuthenticationScopes instead. - * - * @example - * // Before: - * import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runtime'; - * const scopes = getObservabilityAuthenticationScope(); - * - * // After: - * import { defaultObservabilityConfigurationProvider } from '@microsoft/agents-a365-observability'; - * const scopes = [...defaultObservabilityConfigurationProvider.getConfiguration().observabilityAuthenticationScopes]; - */ -export function getObservabilityAuthenticationScope(): string[] { - // Returns production default - use ObservabilityConfiguration for proper env var support - // eslint-disable-next-line @typescript-eslint/no-deprecated -- Intentional: deprecated function using deprecated constant - return [PROD_OBSERVABILITY_SCOPE]; -} - /** * Gets the cluster category from environment variables. * diff --git a/packages/agents-a365-tooling-extensions-claude/package.json b/packages/agents-a365-tooling-extensions-claude/package.json index fab45372..5cebc90d 100644 --- a/packages/agents-a365-tooling-extensions-claude/package.json +++ b/packages/agents-a365-tooling-extensions-claude/package.json @@ -17,7 +17,7 @@ "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", "ci": "npm ci", - "pack": "node ../../copyFiles.js . && pnpm pack --pack-destination=../" + "pack": "pnpm --dir ../.. run pack" }, "keywords": [ "ai", diff --git a/packages/agents-a365-tooling-extensions-langchain/package.json b/packages/agents-a365-tooling-extensions-langchain/package.json index d0c5e036..e54e20a9 100644 --- a/packages/agents-a365-tooling-extensions-langchain/package.json +++ b/packages/agents-a365-tooling-extensions-langchain/package.json @@ -17,7 +17,7 @@ "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", "ci": "npm ci", - "pack": "node ../../copyFiles.js . && pnpm pack --pack-destination=../" + "pack": "pnpm --dir ../.. run pack" }, "keywords": [ "ai", diff --git a/packages/agents-a365-tooling-extensions-openai/package.json b/packages/agents-a365-tooling-extensions-openai/package.json index 65cc1eb5..6b80faf7 100644 --- a/packages/agents-a365-tooling-extensions-openai/package.json +++ b/packages/agents-a365-tooling-extensions-openai/package.json @@ -17,7 +17,7 @@ "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", "ci": "npm ci", - "pack": "node ../../copyFiles.js . && pnpm pack --pack-destination=../" + "pack": "pnpm --dir ../.. run pack" }, "keywords": [ "ai", diff --git a/packages/agents-a365-tooling/package.json b/packages/agents-a365-tooling/package.json index 7d4364c8..2449522d 100644 --- a/packages/agents-a365-tooling/package.json +++ b/packages/agents-a365-tooling/package.json @@ -17,7 +17,7 @@ "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", "ci": "npm ci", - "pack": "node ../../copyFiles.js . && pnpm pack --pack-destination=../" + "pack": "pnpm --dir ../.. run pack" }, "keywords": [ "ai", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 143bd78f..d19b5929 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,9 +12,6 @@ catalogs: '@azure/identity': specifier: ^4.12.1 version: 4.13.1 - '@babel/preset-typescript': - specifier: ^7.27.1 - version: 7.28.5 '@eslint/js': specifier: ^9.39.1 version: 9.39.4 @@ -27,9 +24,6 @@ catalogs: '@langchain/mcp-adapters': specifier: ^1.1.3 version: 1.1.3 - '@langchain/openai': - specifier: ^0.5.0 - version: 0.5.18 '@microsoft/agents-activity': specifier: ^1.3.1 version: 1.4.2 @@ -45,30 +39,6 @@ catalogs: '@openai/agents-openai': specifier: ^0.7.0 version: 0.7.2 - '@opentelemetry/api': - specifier: ^1.9.0 - version: 1.9.1 - '@opentelemetry/core': - specifier: ^2.6.0 - version: 2.7.0 - '@opentelemetry/exporter-trace-otlp-http': - specifier: ^0.213.0 - version: 0.213.0 - '@opentelemetry/instrumentation': - specifier: ^0.213.0 - version: 0.213.0 - '@opentelemetry/resources': - specifier: ^2.6.0 - version: 2.7.0 - '@opentelemetry/sdk-node': - specifier: ^0.213.0 - version: 0.213.0 - '@opentelemetry/sdk-trace-base': - specifier: ^2.6.0 - version: 2.7.0 - '@opentelemetry/semantic-conventions': - specifier: ^1.40.0 - version: 1.40.0 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -87,9 +57,6 @@ catalogs: cross-env: specifier: ^7.0.3 version: 7.0.3 - dotenv: - specifier: ^17.2.2 - version: 17.4.2 eslint: specifier: ^9.39.1 version: 9.39.4 @@ -99,9 +66,6 @@ catalogs: jest: specifier: ^30.2.0 version: 30.3.0 - js-yaml: - specifier: ^4.1.1 - version: 4.1.1 jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 @@ -161,7 +125,7 @@ importers: version: 9.39.4(jiti@2.6.1) jest: specifier: ^30.2.0 - version: 30.3.0 + version: 30.3.0(@types/node@25.6.0) nerdbank-gitversioning: specifier: ^3.9.50 version: 3.9.50 @@ -218,226 +182,6 @@ importers: specifier: 'catalog:' version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - packages/agents-a365-observability: - dependencies: - '@microsoft/agents-a365-runtime': - specifier: workspace:* - version: link:../agents-a365-runtime - '@opentelemetry/api': - specifier: 'catalog:' - version: 1.9.1 - '@opentelemetry/core': - specifier: 'catalog:' - version: 2.7.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-http': - specifier: 'catalog:' - version: 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': - specifier: 'catalog:' - version: 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': - specifier: 'catalog:' - version: 2.7.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-node': - specifier: 'catalog:' - version: 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': - specifier: 'catalog:' - version: 2.7.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': - specifier: 'catalog:' - version: 1.40.0 - devDependencies: - '@eslint/js': - specifier: 'catalog:' - version: 9.39.4 - '@types/jest': - specifier: 'catalog:' - version: 30.0.0 - '@types/node': - specifier: 'catalog:' - version: 20.19.39 - '@typescript-eslint/eslint-plugin': - specifier: 'catalog:' - version: 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: 'catalog:' - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: - specifier: 'catalog:' - version: 9.39.4(jiti@2.6.1) - jest: - specifier: 'catalog:' - version: 30.3.0(@types/node@20.19.39) - rimraf: - specifier: 'catalog:' - version: 6.1.3 - ts-jest: - specifier: 'catalog:' - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@20.19.39))(typescript@5.9.3) - typescript: - specifier: 'catalog:' - version: 5.9.3 - typescript-eslint: - specifier: 'catalog:' - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - packages/agents-a365-observability-extensions-langchain: - dependencies: - '@microsoft/agents-a365-observability': - specifier: workspace:* - version: link:../agents-a365-observability - '@opentelemetry/api': - specifier: 'catalog:' - version: 1.9.1 - '@opentelemetry/core': - specifier: 'catalog:' - version: 2.7.0(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': - specifier: 'catalog:' - version: 0.213.0(@opentelemetry/api@1.9.1) - devDependencies: - '@eslint/js': - specifier: 'catalog:' - version: 9.39.4 - '@langchain/core': - specifier: 'catalog:' - version: 1.1.42(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3) - '@types/jest': - specifier: 'catalog:' - version: 30.0.0 - '@types/node': - specifier: 'catalog:' - version: 20.19.39 - '@typescript-eslint/eslint-plugin': - specifier: 'catalog:' - version: 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: 'catalog:' - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: - specifier: 'catalog:' - version: 9.39.4(jiti@2.6.1) - jest: - specifier: 'catalog:' - version: 30.3.0(@types/node@20.19.39) - rimraf: - specifier: 'catalog:' - version: 6.1.3 - ts-jest: - specifier: 'catalog:' - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@20.19.39))(typescript@5.9.3) - typescript: - specifier: 'catalog:' - version: 5.9.3 - typescript-eslint: - specifier: 'catalog:' - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - packages/agents-a365-observability-extensions-openai: - dependencies: - '@microsoft/agents-a365-observability': - specifier: workspace:* - version: link:../agents-a365-observability - '@microsoft/agents-a365-runtime': - specifier: workspace:* - version: link:../agents-a365-runtime - '@openai/agents': - specifier: 'catalog:' - version: 0.7.2(@cfworker/json-schema@4.1.1)(ws@8.21.3)(zod@4.3.6) - '@opentelemetry/api': - specifier: 'catalog:' - version: 1.9.1 - '@opentelemetry/instrumentation': - specifier: 'catalog:' - version: 0.213.0(@opentelemetry/api@1.9.1) - hono: - specifier: ^4.13.5 - version: 4.13.5 - devDependencies: - '@eslint/js': - specifier: 'catalog:' - version: 9.39.4 - '@types/jest': - specifier: 'catalog:' - version: 30.0.0 - '@types/node': - specifier: 'catalog:' - version: 20.19.39 - '@typescript-eslint/eslint-plugin': - specifier: 'catalog:' - version: 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: 'catalog:' - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: - specifier: 'catalog:' - version: 9.39.4(jiti@2.6.1) - jest: - specifier: 'catalog:' - version: 30.3.0(@types/node@20.19.39) - rimraf: - specifier: 'catalog:' - version: 6.1.3 - ts-jest: - specifier: 'catalog:' - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@20.19.39))(typescript@5.9.3) - typescript: - specifier: 'catalog:' - version: 5.9.3 - typescript-eslint: - specifier: 'catalog:' - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - - packages/agents-a365-observability-hosting: - dependencies: - '@microsoft/agents-a365-observability': - specifier: workspace:* - version: link:../agents-a365-observability - '@microsoft/agents-a365-runtime': - specifier: workspace:* - version: link:../agents-a365-runtime - '@microsoft/agents-hosting': - specifier: 'catalog:' - version: 1.4.2 - '@opentelemetry/api': - specifier: 'catalog:' - version: 1.9.1 - devDependencies: - '@eslint/js': - specifier: 'catalog:' - version: 9.39.4 - '@types/jest': - specifier: 'catalog:' - version: 30.0.0 - '@types/node': - specifier: 'catalog:' - version: 20.19.39 - '@typescript-eslint/eslint-plugin': - specifier: 'catalog:' - version: 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: 'catalog:' - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: - specifier: 'catalog:' - version: 9.39.4(jiti@2.6.1) - jest: - specifier: 'catalog:' - version: 30.3.0(@types/node@20.19.39) - rimraf: - specifier: 'catalog:' - version: 6.1.3 - ts-jest: - specifier: 'catalog:' - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@20.19.39))(typescript@5.9.3) - typescript: - specifier: 'catalog:' - version: 5.9.3 - typescript-eslint: - specifier: 'catalog:' - version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - packages/agents-a365-runtime: dependencies: '@azure/identity': @@ -727,21 +471,6 @@ importers: '@langchain/langgraph': specifier: 'catalog:' version: 1.2.9(@langchain/core@1.1.42(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6) - '@langchain/openai': - specifier: 'catalog:' - version: 0.5.18(@langchain/core@1.1.42(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3))(ws@8.21.3) - '@microsoft/agents-a365-observability': - specifier: workspace:* - version: link:../packages/agents-a365-observability - '@microsoft/agents-a365-observability-extensions-langchain': - specifier: workspace:* - version: link:../packages/agents-a365-observability-extensions-langchain - '@microsoft/agents-a365-observability-extensions-openai': - specifier: workspace:* - version: link:../packages/agents-a365-observability-extensions-openai - '@microsoft/agents-a365-observability-hosting': - specifier: workspace:* - version: link:../packages/agents-a365-observability-hosting '@microsoft/agents-a365-runtime': specifier: workspace:* version: link:../packages/agents-a365-runtime @@ -757,30 +486,6 @@ importers: '@openai/agents-openai': specifier: 'catalog:' version: 0.7.2(@cfworker/json-schema@4.1.1)(ws@8.21.3)(zod@4.3.6) - '@opentelemetry/api': - specifier: 'catalog:' - version: 1.9.1 - '@opentelemetry/core': - specifier: 'catalog:' - version: 2.7.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-http': - specifier: 'catalog:' - version: 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': - specifier: 'catalog:' - version: 2.7.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-node': - specifier: 'catalog:' - version: 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': - specifier: 'catalog:' - version: 2.7.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': - specifier: 'catalog:' - version: 1.40.0 - dotenv: - specifier: 'catalog:' - version: 17.4.2 hono: specifier: ^4.13.5 version: 4.13.5 @@ -791,9 +496,6 @@ importers: specifier: ^4.1.12 version: 4.3.6 devDependencies: - '@babel/preset-typescript': - specifier: 'catalog:' - version: 7.28.5(@babel/core@7.29.0) '@eslint/js': specifier: 'catalog:' version: 9.39.4 @@ -818,9 +520,6 @@ importers: jest: specifier: 'catalog:' version: 30.3.0(@types/node@20.19.39) - js-yaml: - specifier: 'catalog:' - version: 4.1.1 rimraf: specifier: 'catalog:' version: 6.1.3 @@ -902,28 +601,14 @@ packages: resolution: {integrity: sha1-0Jh2KQERq7sA75Yqe4OlMH+6DVA=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/generator/-/generator-7.29.1.tgz} engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.27.3': - resolution: {integrity: sha1-8x/Ya5FfxNrx86xpdsWb5whO2cU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz} - engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha1-MsSj9B8S7RUyF5sQik10bhBcKyU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.28.6': - resolution: {integrity: sha1-YR/1SC2p7w22KRvNJDA0ALyhcPs=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-globals@7.28.0': resolution: {integrity: sha1-uUMN8qpOF7woZl6t6uiqHZheZnQ=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-globals/-/helper-globals-7.28.0.tgz} engines: {node: '>=6.9.0'} - '@babel/helper-member-expression-to-functions@7.28.5': - resolution: {integrity: sha1-8+B6EL437XpjRhxj5pKVdZRaYVA=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha1-YGMsvW/7cLIoIxhyARFnYqA+LVw=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz} engines: {node: '>=6.9.0'} @@ -934,24 +619,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-optimise-call-expression@7.27.1': - resolution: {integrity: sha1-xlIhthpkPz5icF5d0rXxFeNfkgA=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz} - engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha1-bxPqJRtoyFMumF/VMvKHQaivmsg=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz} engines: {node: '>=6.9.0'} - '@babel/helper-replace-supers@7.28.6': - resolution: {integrity: sha1-lKqaHXQjoArq0/IE94g0zn1T/kQ=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - resolution: {integrity: sha1-YruRs6u6jH8f7AJS2dvqEbPuelY=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha1-VNp5YJerGc5n7Z+ItHuy7Ek2doc=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz} engines: {node: '>=6.9.0'} @@ -1064,24 +735,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-commonjs@7.28.6': - resolution: {integrity: sha1-wCMuDf5mpzTMStDV51/DMhtv3vE=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-typescript@7.28.6': - resolution: {integrity: sha1-HpPZbaitvv39reHUlW9zr6IBoVg=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-typescript@7.28.5': - resolution: {integrity: sha1-VANZ76MCgjaVhGY0KWdSL9jypgw=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/template@7.28.6': resolution: {integrity: sha1-Dn5W7O23iu72bOeXKwgvznaiPlc=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/template/-/template-7.28.6.tgz} engines: {node: '>=6.9.0'} @@ -1147,15 +800,6 @@ packages: resolution: {integrity: sha1-l3nj/Zt+4zVxpXQ1z0M1oXlKbLI=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@grpc/grpc-js@1.14.4': - resolution: {integrity: sha1-5z/1fZeALwY5mVRfQ+uyseymXZ0=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@grpc/grpc-js/-/grpc-js-1.14.4.tgz} - engines: {node: '>=12.10.0'} - - '@grpc/proto-loader@0.8.0': - resolution: {integrity: sha1-tsMk3ZCcRYoOSqm/09ac94pLm9g=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@grpc/proto-loader/-/proto-loader-0.8.0.tgz} - engines: {node: '>=6'} - hasBin: true - '@hono/node-server@1.19.14': resolution: {integrity: sha1-4w+ES8d+POe+RCqsOx9zrYtY0YE=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@hono/node-server/-/node-server-1.19.14.tgz} engines: {node: '>=18.14.1'} @@ -1385,9 +1029,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha1-2xXWeByTHzolGj2sOVAcmKYIL9A=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz} - '@js-sdsl/ordered-map@4.4.2': - resolution: {integrity: sha1-kpn4KHS6ueTH+cSNhlvsv+jWkHw=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz} - '@langchain/core@1.1.42': resolution: {integrity: sha1-YHv9NMFBTJy+soWf2hslShTCgIU=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@langchain/core/-/core-1.1.42.tgz} engines: {node: '>=20'} @@ -1436,12 +1077,6 @@ packages: '@langchain/core': ^1.0.0 '@langchain/langgraph': ^1.0.0 - '@langchain/openai@0.5.18': - resolution: {integrity: sha1-Weu/SARNcRzpUD07mFSjUzy1RoM=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@langchain/openai/-/openai-0.5.18.tgz} - engines: {node: '>=18'} - peerDependencies: - '@langchain/core': '>=0.3.58 <0.4.0' - '@microsoft/agents-activity@1.4.2': resolution: {integrity: sha1-NKZLutfzipEbSUlxm7PblBNSxa8=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@microsoft/agents-activity/-/agents-activity-1.4.2.tgz} engines: {node: '>=20.0.0'} @@ -1494,18 +1129,6 @@ packages: resolution: {integrity: sha1-wbA0beM2ulWvLVp5cIggN7rt7AU=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/api/-/api-1.9.1.tgz} engines: {node: '>=8.0.0'} - '@opentelemetry/configuration@0.213.0': - resolution: {integrity: sha1-xyJ1AocCn64JMYwBfk8imQJlCpo=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/configuration/-/configuration-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.9.0 - - '@opentelemetry/context-async-hooks@2.6.0': - resolution: {integrity: sha1-bIJOkAYws3gjPBp4yn8NxaO0YLI=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.6.0': resolution: {integrity: sha1-cZyCntmL16+Aii0sgzdN8f0fPGY=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.6.0.tgz} engines: {node: ^18.19.0 || >=20.6.0} @@ -1518,108 +1141,24 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/exporter-logs-otlp-grpc@0.213.0': - resolution: {integrity: sha1-XndYvxYmt4sLozn6Cwn0G7A6j90=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/exporter-logs-otlp-http@0.213.0': - resolution: {integrity: sha1-/xZRlEQIEWpKFGb8X0S6fXQNUY8=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/exporter-logs-otlp-proto@0.213.0': - resolution: {integrity: sha1-qv7CQG8yBhPwOg07yugIs4MraWM=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0': - resolution: {integrity: sha1-xBykDVrfyGzIkDX1sC7AohxeQYg=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/exporter-metrics-otlp-http@0.213.0': - resolution: {integrity: sha1-rIIXj7aXz0bgBI31SZ9RM6Gq9ZM=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': - resolution: {integrity: sha1-sN46yZbh0b8wEwxrd+tBDdwLTWg=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/exporter-prometheus@0.213.0': - resolution: {integrity: sha1-mC9cng9SZk35VQFybaFZnfMmHHY=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/exporter-trace-otlp-grpc@0.213.0': - resolution: {integrity: sha1-ve1+Jwvk1Mjkyr5uwlPWWEMXeFw=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - - '@opentelemetry/exporter-trace-otlp-http@0.213.0': - resolution: {integrity: sha1-e7qGGnF4c2G4OgN0btS/XBgEh3U=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-proto@0.213.0': resolution: {integrity: sha1-uuD4NABlI8NbCqgviD4VutFDX9Q=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.213.0.tgz} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-zipkin@2.6.0': - resolution: {integrity: sha1-vwPr/SiZ7ikhWw0hdnhx7475jJM=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.6.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.0.0 - - '@opentelemetry/instrumentation@0.213.0': - resolution: {integrity: sha1-VTYlae/Qy6AKq5khp43SDf3fcLY=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation/-/instrumentation-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-exporter-base@0.213.0': resolution: {integrity: sha1-6afB367MJXO5xfvNfMwAhlE8E1A=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.213.0.tgz} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-grpc-exporter-base@0.213.0': - resolution: {integrity: sha1-W7ypLQYl+zKNfuNfVwpWFgPv5Jw=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-transformer@0.213.0': resolution: {integrity: sha1-6DAkTSGBeAW4lnlj/8RlG49clu4=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/propagator-b3@2.6.0': - resolution: {integrity: sha1-96uHdFehh66xh0Ppq2hazN3s/xU=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/propagator-b3/-/propagator-b3-2.6.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/propagator-jaeger@2.6.0': - resolution: {integrity: sha1-Ro4mgNxqpxVKWoPtmWjib6sDVOM=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.6.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/resources@2.6.0': resolution: {integrity: sha1-GpRdu4mGBD2LWTw1jV2OPea+z1o=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.6.0.tgz} engines: {node: ^18.19.0 || >=20.6.0} @@ -1644,12 +1183,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-node@0.213.0': - resolution: {integrity: sha1-cA3zpNt7y9jjMq0IbSiR6baDb3A=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-node/-/sdk-node-0.213.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.6.0': resolution: {integrity: sha1-1+dSoJBvK8rjwSYeIkrvPjs3Rvk=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz} engines: {node: ^18.19.0 || >=20.6.0} @@ -1662,12 +1195,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-node@2.6.0': - resolution: {integrity: sha1-xKDHcpD0vCNBuArCrlLjoeHYbPE=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.6.0.tgz} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/semantic-conventions@1.40.0': resolution: {integrity: sha1-ELKUTKVZOGWQaDOSAiqJfu/QEdM=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz} engines: {node: '>=14'} @@ -1953,11 +1480,6 @@ packages: resolution: {integrity: sha1-u89LpQdUZ/PyEx6rPP/HPC9deJU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/accepts/-/accepts-2.0.0.tgz} engines: {node: '>= 0.6'} - acorn-import-attributes@1.9.5: - resolution: {integrity: sha1-frFVexugXvGLXtDsZ1kb+rBGiO8=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz} - peerDependencies: - acorn: ^8 - acorn-jsx@5.3.2: resolution: {integrity: sha1-ftW7VZCLOy8bxVxq8WU7rafweTc=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/acorn-jsx/-/acorn-jsx-5.3.2.tgz} peerDependencies: @@ -2257,10 +1779,6 @@ packages: resolution: {integrity: sha1-V29d/GOuGhkv8ZLYrTr2MImRtlE=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/detect-newline/-/detect-newline-3.1.0.tgz} engines: {node: '>=8'} - dotenv@17.4.2: - resolution: {integrity: sha1-wH5Up0bhHroCHdnhBHztWv3BwDQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dotenv/-/dotenv-17.4.2.tgz} - engines: {node: '>=12'} - dunder-proto@1.0.1: resolution: {integrity: sha1-165mfh3INIL4tw/Q9u78UNow9Yo=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dunder-proto/-/dunder-proto-1.0.1.tgz} engines: {node: '>= 0.4'} @@ -2622,10 +2140,6 @@ packages: resolution: {integrity: sha1-nOy1ZQPAraHydB271lRuSxO1fM8=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/import-fresh/-/import-fresh-3.3.1.tgz} engines: {node: '>=6'} - import-in-the-middle@3.0.1: - resolution: {integrity: sha1-igoSMMm4ZcDhJpgXFkauHj//aR0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz} - engines: {node: '>=18'} - import-local@3.2.0: resolution: {integrity: sha1-w9XHRXmMAqb4uJdyarpRABhu4mA=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/import-local/-/import-local-3.2.0.tgz} engines: {node: '>=8'} @@ -2927,7 +2441,7 @@ packages: '@opentelemetry/exporter-trace-otlp-proto': '*' '@opentelemetry/sdk-trace-base': '*' openai: '*' - ws: ^8.21.0 + ws: ^8.21.3 peerDependenciesMeta: '@opentelemetry/api': optional: true @@ -2962,9 +2476,6 @@ packages: resolution: {integrity: sha1-VTIeswn+u8WcSAHZMackUqaB0oY=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/locate-path/-/locate-path-6.0.0.tgz} engines: {node: '>=10'} - lodash.camelcase@4.3.0: - resolution: {integrity: sha1-soqmKIorn8ZRA1x3EfZathkDMaY=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz} - lodash.clonedeep@4.5.0: resolution: {integrity: sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz} @@ -3074,9 +2585,6 @@ packages: resolution: {integrity: sha1-eTibTrG7LQA6m7qH1JLyvTe9xls=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minipass/-/minipass-7.1.3.tgz} engines: {node: '>=16 || 14 >=14.17'} - module-details-from-path@1.0.4: - resolution: {integrity: sha1-tmL9zZP2yD0/JSidoM6ByNloW5Q=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/module-details-from-path/-/module-details-from-path-1.0.4.tgz} - ms@2.1.3: resolution: {integrity: sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz} @@ -3144,23 +2652,11 @@ packages: resolution: {integrity: sha1-udhVvgB2IOgLb7BfrJgUH+Yttzw=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/open/-/open-10.2.0.tgz} engines: {node: '>=18'} - openai@5.23.2: - resolution: {integrity: sha1-8T4twu9riKq2qbl83GjUGh0IPGg=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/openai/-/openai-5.23.2.tgz} - hasBin: true - peerDependencies: - ws: ^8.21.0 - zod: ^4.1.12 - peerDependenciesMeta: - ws: - optional: true - zod: - optional: true - openai@6.34.0: resolution: {integrity: sha1-k0g6EETi3NYokbulgHcrnLeSOV0=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/openai/-/openai-6.34.0.tgz} hasBin: true peerDependencies: - ws: ^8.21.0 + ws: ^8.21.3 zod: ^4.1.12 peerDependenciesMeta: ws: @@ -3327,10 +2823,6 @@ packages: resolution: {integrity: sha1-iaf92TgmEmcxjq/hT5wy5ZjDaQk=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/require-from-string/-/require-from-string-2.0.2.tgz} engines: {node: '>=0.10.0'} - require-in-the-middle@8.0.1: - resolution: {integrity: sha1-294lh/ZpOYYm1WsgyGirh78BzOQ=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz} - engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} - resolve-cwd@3.0.0: resolution: {integrity: sha1-DwB18bslRHZs9zumpuKt/ryxPy0=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/resolve-cwd/-/resolve-cwd-3.0.0.tgz} engines: {node: '>=8'} @@ -3655,11 +3147,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha1-27fa+b/YusmrRev2ArjLrQ1dCP0=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yallist/-/yallist-3.1.1.tgz} - yaml@2.8.3: - resolution: {integrity: sha1-oNa9Lvs90DxZNwIjcBg05gQJvX0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yaml/-/yaml-2.8.3.tgz} - engines: {node: '>= 14.6'} - hasBin: true - yargs-parser@21.1.1: resolution: {integrity: sha1-kJa87r+ZDSG7MfqVFuDt4pSnfTU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs-parser/-/yargs-parser-21.1.1.tgz} engines: {node: '>=12'} @@ -3814,10 +3301,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.27.3': - dependencies: - '@babel/types': 7.29.0 - '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -3826,28 +3309,8 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/helper-globals@7.28.0': {} - '@babel/helper-member-expression-to-functions@7.28.5': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -3864,28 +3327,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-optimise-call-expression@7.27.1': - dependencies: - '@babel/types': 7.29.0 - '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -3951,70 +3394,40 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color '@babel/template@7.28.6': dependencies: @@ -4105,18 +3518,6 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@grpc/grpc-js@1.14.4': - dependencies: - '@grpc/proto-loader': 0.8.0 - '@js-sdsl/ordered-map': 4.4.2 - - '@grpc/proto-loader@0.8.0': - dependencies: - lodash.camelcase: 4.3.0 - long: 5.3.2 - protobufjs: 7.6.5 - yargs: 17.7.2 - '@hono/node-server@1.19.14(hono@4.13.5)': dependencies: hono: 4.13.5 @@ -4411,8 +3812,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@js-sdsl/ordered-map@4.4.2': {} - '@langchain/core@1.1.42(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3)': dependencies: '@cfworker/json-schema': 4.1.1 @@ -4475,15 +3874,6 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@langchain/openai@0.5.18(@langchain/core@1.1.42(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3))(ws@8.21.3)': - dependencies: - '@langchain/core': 1.1.42(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(openai@6.34.0(ws@8.21.3)(zod@4.3.6))(ws@8.21.3) - js-tiktoken: 1.0.21 - openai: 5.23.2(ws@8.21.3)(zod@4.3.6) - zod: 4.3.6 - transitivePeerDependencies: - - ws - '@microsoft/agents-activity@1.4.2': dependencies: debug: 4.4.3 @@ -4591,117 +3981,22 @@ snapshots: '@opentelemetry/api-logs@0.213.0': dependencies: '@opentelemetry/api': 1.9.1 + optional: true - '@opentelemetry/api@1.9.1': {} - - '@opentelemetry/configuration@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - yaml: 2.8.3 - - '@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.1': + optional: true '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 + optional: true '@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 - - '@opentelemetry/exporter-logs-otlp-grpc@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@grpc/grpc-js': 1.14.4 - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/exporter-logs-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/exporter-logs-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@grpc/grpc-js': 1.14.4 - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/exporter-prometheus@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.40.0 - - '@opentelemetry/exporter-trace-otlp-grpc@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@grpc/grpc-js': 1.14.4 - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + optional: true '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': dependencies: @@ -4711,37 +4006,14 @@ snapshots: '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/exporter-zipkin@2.6.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.40.0 - - '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.213.0 - import-in-the-middle: 3.0.1 - require-in-the-middle: 8.0.1 - transitivePeerDependencies: - - supports-color + optional: true '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/otlp-grpc-exporter-base@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@grpc/grpc-js': 1.14.4 - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + optional: true '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.1)': dependencies: @@ -4753,28 +4025,21 @@ snapshots: '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) protobufjs: 7.6.5 - - '@opentelemetry/propagator-b3@2.6.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/propagator-jaeger@2.6.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + optional: true '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 + optional: true '@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 + optional: true '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.1)': dependencies: @@ -4783,42 +4048,14 @@ snapshots: '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 + optional: true '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/sdk-node@0.213.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/configuration': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-logs-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-logs-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-logs-otlp-proto': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-metrics-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-metrics-otlp-proto': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-prometheus': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-proto': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-zipkin': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/propagator-b3': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/propagator-jaeger': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-node': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.40.0 - transitivePeerDependencies: - - supports-color + optional: true '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.1)': dependencies: @@ -4826,6 +4063,7 @@ snapshots: '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 + optional: true '@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1)': dependencies: @@ -4833,40 +4071,44 @@ snapshots: '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 + optional: true - '@opentelemetry/sdk-trace-node@2.6.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) - - '@opentelemetry/semantic-conventions@1.40.0': {} + '@opentelemetry/semantic-conventions@1.40.0': + optional: true '@pkgjs/parseargs@0.11.0': optional: true '@pkgr/core@0.2.9': {} - '@protobufjs/aspromise@1.1.2': {} + '@protobufjs/aspromise@1.1.2': + optional: true - '@protobufjs/base64@1.1.2': {} + '@protobufjs/base64@1.1.2': + optional: true - '@protobufjs/codegen@2.0.5': {} + '@protobufjs/codegen@2.0.5': + optional: true - '@protobufjs/eventemitter@1.1.1': {} + '@protobufjs/eventemitter@1.1.1': + optional: true '@protobufjs/fetch@1.1.1': dependencies: '@protobufjs/aspromise': 1.1.2 + optional: true - '@protobufjs/float@1.0.2': {} + '@protobufjs/float@1.0.2': + optional: true - '@protobufjs/path@1.1.2': {} + '@protobufjs/path@1.1.2': + optional: true - '@protobufjs/pool@1.1.0': {} + '@protobufjs/pool@1.1.0': + optional: true - '@protobufjs/utf8@1.1.1': {} + '@protobufjs/utf8@1.1.1': + optional: true '@sinclair/typebox@0.34.49': {} @@ -5119,10 +4361,6 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-import-attributes@1.9.5(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -5410,8 +4648,6 @@ snapshots: detect-newline@3.1.0: {} - dotenv@17.4.2: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5816,13 +5052,6 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-in-the-middle@3.0.1: - dependencies: - acorn: 8.16.0 - acorn-import-attributes: 1.9.5(acorn@8.16.0) - cjs-module-lexer: 2.2.0 - module-details-from-path: 1.0.4 - import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -5940,7 +5169,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.3.0: + jest-cli@30.3.0(@types/node@20.19.39): dependencies: '@jest/core': 30.3.0 '@jest/test-result': 30.3.0 @@ -5948,7 +5177,7 @@ snapshots: chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.3.0 + jest-config: 30.3.0(@types/node@20.19.39) jest-util: 30.3.0 jest-validate: 30.3.0 yargs: 17.7.2 @@ -5959,7 +5188,7 @@ snapshots: - supports-color - ts-node - jest-cli@30.3.0(@types/node@20.19.39): + jest-cli@30.3.0(@types/node@25.6.0): dependencies: '@jest/core': 30.3.0 '@jest/test-result': 30.3.0 @@ -5967,7 +5196,7 @@ snapshots: chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.3.0(@types/node@20.19.39) + jest-config: 30.3.0(@types/node@25.6.0) jest-util: 30.3.0 jest-validate: 30.3.0 yargs: 17.7.2 @@ -5978,35 +5207,6 @@ snapshots: - supports-color - ts-node - jest-config@30.3.0: - dependencies: - '@babel/core': 7.29.0 - '@jest/get-type': 30.1.0 - '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 4.4.0 - deepmerge: 4.3.1 - glob: 10.5.0 - graceful-fs: 4.2.11 - jest-circus: 30.3.0 - jest-docblock: 30.2.0 - jest-environment-node: 30.3.0 - jest-regex-util: 30.0.1 - jest-resolve: 30.3.0 - jest-runner: 30.3.0 - jest-util: 30.3.0 - jest-validate: 30.3.0 - parse-json: 5.2.0 - pretty-format: 30.3.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@30.3.0(@types/node@20.19.39): dependencies: '@babel/core': 7.29.0 @@ -6284,12 +5484,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.3.0: + jest@30.3.0(@types/node@20.19.39): dependencies: '@jest/core': 30.3.0 '@jest/types': 30.3.0 import-local: 3.2.0 - jest-cli: 30.3.0 + jest-cli: 30.3.0(@types/node@20.19.39) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -6297,12 +5497,12 @@ snapshots: - supports-color - ts-node - jest@30.3.0(@types/node@20.19.39): + jest@30.3.0(@types/node@25.6.0): dependencies: '@jest/core': 30.3.0 '@jest/types': 30.3.0 import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@20.19.39) + jest-cli: 30.3.0(@types/node@25.6.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -6431,8 +5631,6 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.camelcase@4.3.0: {} - lodash.clonedeep@4.5.0: {} lodash.includes@4.3.0: {} @@ -6453,7 +5651,8 @@ snapshots: lodash.once@4.1.1: {} - long@5.3.2: {} + long@5.3.2: + optional: true lru-cache@10.4.3: {} @@ -6516,8 +5715,6 @@ snapshots: minipass@7.1.3: {} - module-details-from-path@1.0.4: {} - ms@2.1.3: {} mustache@4.2.0: {} @@ -6567,11 +5764,6 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - openai@5.23.2(ws@8.21.3)(zod@4.3.6): - optionalDependencies: - ws: 8.21.3 - zod: 4.3.6 - openai@6.34.0(ws@8.21.3)(zod@4.3.6): optionalDependencies: ws: 8.21.3 @@ -6694,6 +5886,7 @@ snapshots: '@protobufjs/utf8': 1.1.1 '@types/node': 25.6.0 long: 5.3.2 + optional: true proxy-addr@2.0.7: dependencies: @@ -6725,13 +5918,6 @@ snapshots: require-from-string@2.0.2: {} - require-in-the-middle@8.0.1: - dependencies: - debug: 4.4.3 - module-details-from-path: 1.0.4 - transitivePeerDependencies: - - supports-color - resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -7054,8 +6240,6 @@ snapshots: yallist@3.1.1: {} - yaml@2.8.3: {} - yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ff90f26..bf3c7889 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,10 @@ packages: - - 'packages/agents-a365-*' - # - 'tests-agent/*' # Excluded from workspace + - 'packages/agents-a365-notifications' + - 'packages/agents-a365-runtime' + - 'packages/agents-a365-tooling' + - 'packages/agents-a365-tooling-extensions-claude' + - 'packages/agents-a365-tooling-extensions-langchain' + - 'packages/agents-a365-tooling-extensions-openai' - 'tests' catalog: @@ -24,7 +28,6 @@ catalog: "@langchain/core": "^1.1.32" "@langchain/langgraph": "^1.2.2" "@langchain/mcp-adapters": "^1.1.3" - "@langchain/openai": "^0.5.0" # Microsoft 365 Agents SDK packages "@microsoft/agents-hosting": "^1.3.1" @@ -42,18 +45,7 @@ catalog: "@openai/agents-openai": "^0.7.0" "openai": "^6.27.0" - # OpenTelemetry packages - align versions - "@opentelemetry/core": "^2.6.0" - "@opentelemetry/api": "^1.9.0" - "@opentelemetry/exporter-trace-otlp-http": "^0.213.0" - "@opentelemetry/instrumentation": "^0.213.0" - "@opentelemetry/resources": "^2.6.0" - "@opentelemetry/sdk-node": "^0.213.0" - "@opentelemetry/sdk-trace-base": "^2.6.0" - "@opentelemetry/semantic-conventions": "^1.40.0" - # Development dependencies - align versions - "@babel/preset-typescript": "^7.27.1" "@eslint/js": "^9.39.1" "@jest/globals": "^30.2.0" "@microsoft/m365agentsplayground": "^0.2.18" @@ -65,10 +57,8 @@ catalog: "@typescript-eslint/eslint-plugin": "^8.47.0" "@typescript-eslint/parser": "^8.47.0" "cross-env": "^7.0.3" - "dotenv": "^17.2.2" "eslint": "^9.39.1" "jest": "^30.2.0" - "js-yaml": "^4.1.1" "nodemon": "^3.1.10" "rimraf": "^6.0.0" "ts-jest": "^29.4.5" diff --git a/setVersion.js b/setVersion.js index c1f3b492..8388d226 100644 --- a/setVersion.js +++ b/setVersion.js @@ -16,7 +16,7 @@ * * Examples: * # For CI/CD pipelines (writes to disk): - * npm run build && node setVersion.js && npm pack + * pnpm build && node setVersion.js && pnpm run pack * * # For local development (dry-run): * node setVersion.js --local @@ -24,6 +24,7 @@ import * as nbgv from 'nerdbank-gitversioning' import fs from 'fs' +import path from 'path' // Parse command line arguments const args = process.argv.slice(2) @@ -101,11 +102,13 @@ const setPackageVersionAndBuildNumber = async versionInfo => { console.log(`⏭️ Skipped updating root package.json (dry-run mode)`) } - const files = await fs.promises.readdir('packages', { withFileTypes: true }) - - const folders = files - .filter(file => file.isDirectory() && file.name !== 'node_modules') - .map(folder => `${folder.parentPath}/${folder.name}`) + const rootManifest = JSON.parse(fs.readFileSync('./package.json', 'utf8')) + const folders = rootManifest.workspaces.filter(folder => folder.startsWith('packages/')) + for (const folder of folders) { + if (folder.includes('*') || !fs.existsSync(path.join(folder, 'package.json'))) { + throw new Error(`Release workspace must be an explicit package directory: ${folder}`) + } + } for (const f of folders) { const action = skipWrite ? '⏭️ Would update' : '✅ Updating' diff --git a/tests/README.md b/tests/README.md index 0e909b83..bccfd9a9 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # Microsoft Agent 365 SDK Tests -Unit and integration tests for the Microsoft Agent 365 SDK - Node.js/TypeScript. This test suite ensures reliability, maintainability, and quality across all modules including runtime, tooling, notifications, and observability extensions. +Tests for the six maintained Microsoft Agent 365 SDK packages: Runtime, Tooling, Notifications, and Tooling extensions. Release tests also reject retired packages and bundled implementations. Dedicated Observability tests are removed; application migration belongs to the [replacement distro](../docs/observability-migration.md). ## Usage diff --git a/tests/RUNNING_TESTS.md b/tests/RUNNING_TESTS.md index d342ae4e..9dd77bb9 100644 --- a/tests/RUNNING_TESTS.md +++ b/tests/RUNNING_TESTS.md @@ -16,7 +16,6 @@ ```plaintext tests/ ├── runtime/ # Runtime tests -├── observability/ # Observability tests ├── tooling/ # Tooling tests ├── notifications/ # Notifications tests └── all-packages-coverage.test.ts # Ensures all packages appear in coverage reports @@ -42,7 +41,7 @@ pnpm test:watch # Watch mode pnpm test -- runtime/power-platform-api-discovery.test.ts # Additional options -pnpm test -- --testPathPattern=observability +pnpm test --testPathPatterns=runtime pnpm test -- --testNamePattern="should return" pnpm test -- --bail # Stop on first failure pnpm test -- --onlyFailures # Re-run failed tests only diff --git a/tests/TEST_PLAN.md b/tests/TEST_PLAN.md index 95624f28..103c4b1f 100644 --- a/tests/TEST_PLAN.md +++ b/tests/TEST_PLAN.md @@ -1,6 +1,6 @@ # Test Plan for Agent365-nodejs SDK -> **Note:** This plan is under active development. Keep updating as testing progresses. +> **Historical draft:** Statuses below describe the original testing roadmap, not current coverage. The maintained scope is now six packages; Observability implementation and dedicated tests have been retired. See [running tests](RUNNING_TESTS.md) for current commands. **Version:** 1.0 **Date:** December 4, 2025 @@ -12,9 +12,8 @@ ### Current State - ✅ Unit tests complete for `runtime` module -- ✅ Unit tests complete for `observability` modules - ❌ Missing tests for `tooling` and `notifications` modules -- ✅ Coverage reporting includes all 9 packages +- Coverage reporting targets the six maintained packages - ❌ No integration tests or CI/CD automation ### Goals @@ -45,7 +44,6 @@ | 1.1 | Runtime unit tests | HIGH | ✅ Complete | | 1.2 | Tooling unit tests | HIGH | ❌ Missing | | 1.3 | Notifications unit tests | HIGH | ❌ Missing | -| 1.4 | Expand observability tests | MEDIUM | ✅ Complete | | 1.5 | Tooling extension tests | LOW | ❌ Missing | | 2 | Integration tests | MEDIUM | ❌ Missing | | 3 | CI/CD automation | HIGH | ❌ Missing | @@ -90,17 +88,6 @@ --- -### 1.4 Observability Extensions - -**Priority:** MEDIUM - -| Extension | Status | -|-----------|--------| -| `openai` | ✅ Expand existing | -| `tokencache` | ✅ Expand existing | - ---- - ### 1.5 Tooling Extensions **Priority:** LOW @@ -119,10 +106,8 @@ | Integration | Status | |-------------|--------| -| Runtime + Observability | ❌ Missing | | Tooling + Runtime | ❌ Missing | | Notifications + Runtime | ❌ Missing | -| OpenAI full flow | ✅ Complete | | Claude full flow | ❌ Missing | | LangChain full flow | ❌ Missing | diff --git a/tests/jest.config.cjs b/tests/jest.config.cjs index 8511a6d0..7eb396ac 100644 --- a/tests/jest.config.cjs +++ b/tests/jest.config.cjs @@ -19,7 +19,7 @@ module.exports = { '**/?(*.)+(spec|test).js' ], - // Exclude integration tests (they require Azure OpenAI credentials) + // Exclude dependencies and any future separately configured integration tests. testPathIgnorePatterns: [ '/node_modules/', '/integration/' @@ -68,17 +68,11 @@ module.exports = { // Module name mapper for package imports moduleNameMapper: { '^@microsoft/agents-a365-runtime$': '/packages/agents-a365-runtime/src', - '^@microsoft/agents-a365-observability$': '/packages/agents-a365-observability/src', - '^@microsoft/agents-a365-observability-hosting$': '/packages/agents-a365-observability-hosting/src', - '^@microsoft/agents-a365-observability-extensions-langchain$': '/packages/agents-a365-observability-extensions-langchain/src', - '^@microsoft/agents-a365-observability-extensions-openai$': '/packages/agents-a365-observability-extensions-openai/src', - '^@microsoft/agents-a365-observability-tokencache$': '/packages/agents-a365-observability-tokencache/src', '^@microsoft/agents-a365-tooling$': '/packages/agents-a365-tooling/src', '^@microsoft/agents-a365-tooling-extensions-claude$': '/packages/agents-a365-tooling-extensions-claude/src', '^@microsoft/agents-a365-tooling-extensions-langchain$': '/packages/agents-a365-tooling-extensions-langchain/src', '^@microsoft/agents-a365-tooling-extensions-openai$': '/packages/agents-a365-tooling-extensions-openai/src', - '^@microsoft/agents-a365-notifications$': '/packages/agents-a365-notifications/src', - '^@opentelemetry/api$': '/node_modules/@opentelemetry/api' + '^@microsoft/agents-a365-notifications$': '/packages/agents-a365-notifications/src' }, // Module resolution diff --git a/tests/observability-extensions-openai/configuration/OpenAIObservabilityConfiguration.test.ts b/tests/observability-extensions-openai/configuration/OpenAIObservabilityConfiguration.test.ts deleted file mode 100644 index ae9075ae..00000000 --- a/tests/observability-extensions-openai/configuration/OpenAIObservabilityConfiguration.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { - OpenAIObservabilityConfiguration, - defaultOpenAIObservabilityConfigurationProvider -} from '../../../packages/agents-a365-observability-extensions-openai/src'; -import { ObservabilityConfiguration } from '../../../packages/agents-a365-observability/src'; -import { RuntimeConfiguration, DefaultConfigurationProvider, ClusterCategory } from '../../../packages/agents-a365-runtime/src'; - -describe('OpenAIObservabilityConfiguration', () => { - const originalEnv = process.env; - - beforeEach(() => { - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - describe('inheritance from ObservabilityConfiguration', () => { - it('should inherit observability settings', () => { - const config = new OpenAIObservabilityConfiguration({ - isObservabilityExporterEnabled: () => true - }); - expect(config.isObservabilityExporterEnabled).toBe(true); - }); - - it('should be instanceof ObservabilityConfiguration', () => { - const config = new OpenAIObservabilityConfiguration(); - expect(config).toBeInstanceOf(ObservabilityConfiguration); - }); - - it('should be instanceof RuntimeConfiguration', () => { - const config = new OpenAIObservabilityConfiguration(); - expect(config).toBeInstanceOf(RuntimeConfiguration); - }); - }); - - describe('inherited runtime settings', () => { - it('should inherit clusterCategory from override', () => { - const config = new OpenAIObservabilityConfiguration({ clusterCategory: () => ClusterCategory.gov }); - expect(config.clusterCategory).toBe(ClusterCategory.gov); - }); - - it('should inherit clusterCategory from env var', () => { - process.env.CLUSTER_CATEGORY = 'dev'; - const config = new OpenAIObservabilityConfiguration({}); - expect(config.clusterCategory).toBe('dev'); - }); - - it('should inherit isDevelopmentEnvironment', () => { - const config = new OpenAIObservabilityConfiguration({ clusterCategory: () => ClusterCategory.local }); - expect(config.isDevelopmentEnvironment).toBe(true); - }); - }); - - describe('inherited observability settings', () => { - it('should inherit isObservabilityExporterEnabled from env var', () => { - process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; - const config = new OpenAIObservabilityConfiguration({}); - expect(config.isObservabilityExporterEnabled).toBe(true); - }); - - it('should inherit observabilityAuthenticationScopes from override', () => { - const config = new OpenAIObservabilityConfiguration({ - observabilityAuthenticationScopes: () => ['custom-scope/.default'] - }); - expect(config.observabilityAuthenticationScopes).toEqual(['custom-scope/.default']); - }); - - it('should inherit observabilityDomainOverride from override', () => { - const config = new OpenAIObservabilityConfiguration({ - observabilityDomainOverride: () => 'https://custom.domain' - }); - expect(config.observabilityDomainOverride).toBe('https://custom.domain'); - }); - - it('should inherit observabilityLogLevel from override', () => { - const config = new OpenAIObservabilityConfiguration({ - observabilityLogLevel: () => 'debug' - }); - expect(config.observabilityLogLevel).toBe('debug'); - }); - - it('should use default isObservabilityExporterEnabled when not overridden', () => { - delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; - const config = new OpenAIObservabilityConfiguration({}); - expect(config.isObservabilityExporterEnabled).toBe(false); - }); - }); - - describe('combined overrides', () => { - it('should allow overriding runtime and observability settings together', () => { - const config = new OpenAIObservabilityConfiguration({ - clusterCategory: () => ClusterCategory.dev, - isObservabilityExporterEnabled: () => true, - observabilityLogLevel: () => 'info' - }); - expect(config.clusterCategory).toBe(ClusterCategory.dev); - expect(config.isDevelopmentEnvironment).toBe(true); - expect(config.isObservabilityExporterEnabled).toBe(true); - expect(config.observabilityLogLevel).toBe('info'); - }); - - it('should support dynamic per-tenant configuration', () => { - let currentTenant = 'tenant-a'; - const tenantSettings: Record = { - 'tenant-a': true, - 'tenant-b': false - }; - - const config = new OpenAIObservabilityConfiguration({ - isObservabilityExporterEnabled: () => tenantSettings[currentTenant] - }); - - expect(config.isObservabilityExporterEnabled).toBe(true); - currentTenant = 'tenant-b'; - expect(config.isObservabilityExporterEnabled).toBe(false); - }); - }); - - describe('constructor', () => { - it('should accept no overrides', () => { - const config = new OpenAIObservabilityConfiguration(); - expect(config).toBeInstanceOf(OpenAIObservabilityConfiguration); - }); - - it('should accept empty overrides object', () => { - const config = new OpenAIObservabilityConfiguration({}); - expect(config).toBeInstanceOf(OpenAIObservabilityConfiguration); - }); - - it('should accept undefined overrides', () => { - const config = new OpenAIObservabilityConfiguration(undefined); - expect(config).toBeInstanceOf(OpenAIObservabilityConfiguration); - }); - }); -}); - -describe('defaultOpenAIObservabilityConfigurationProvider', () => { - it('should be an instance of DefaultConfigurationProvider', () => { - expect(defaultOpenAIObservabilityConfigurationProvider).toBeInstanceOf(DefaultConfigurationProvider); - }); - - it('should return OpenAIObservabilityConfiguration from getConfiguration', () => { - const config = defaultOpenAIObservabilityConfigurationProvider.getConfiguration(); - expect(config).toBeInstanceOf(OpenAIObservabilityConfiguration); - }); - - it('should return the same configuration instance on multiple calls', () => { - const config1 = defaultOpenAIObservabilityConfigurationProvider.getConfiguration(); - const config2 = defaultOpenAIObservabilityConfigurationProvider.getConfiguration(); - expect(config1).toBe(config2); - }); -}); diff --git a/tests/observability/configuration/ObservabilityConfiguration.test.ts b/tests/observability/configuration/ObservabilityConfiguration.test.ts deleted file mode 100644 index e93b2fb6..00000000 --- a/tests/observability/configuration/ObservabilityConfiguration.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { - ObservabilityConfiguration, - defaultObservabilityConfigurationProvider -} from '../../../packages/agents-a365-observability/src'; -import { RuntimeConfiguration, DefaultConfigurationProvider, ClusterCategory } from '../../../packages/agents-a365-runtime/src'; - -describe('ObservabilityConfiguration', () => { - const originalEnv = process.env; - - beforeEach(() => { - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - describe('inheritance from RuntimeConfiguration', () => { - it('should inherit runtime settings', () => { - const config = new ObservabilityConfiguration({ clusterCategory: () => ClusterCategory.gov }); - expect(config.clusterCategory).toBe(ClusterCategory.gov); - expect(config.isDevelopmentEnvironment).toBe(false); - }); - - it('should be instanceof RuntimeConfiguration', () => { - const config = new ObservabilityConfiguration(); - expect(config).toBeInstanceOf(RuntimeConfiguration); - }); - }); - - describe('observabilityAuthenticationScopes', () => { - it('should use override function when provided', () => { - const config = new ObservabilityConfiguration({ - observabilityAuthenticationScopes: () => ['scope1/.default', 'scope2/.default'] - }); - expect(config.observabilityAuthenticationScopes).toEqual(['scope1/.default', 'scope2/.default']); - }); - - it('should fall back to env var when override returns undefined', () => { - process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = 'custom-scope/.default'; - const config = new ObservabilityConfiguration({ - observabilityAuthenticationScopes: () => undefined as unknown as string[] - }); - expect(config.observabilityAuthenticationScopes).toEqual(['custom-scope/.default']); - }); - - it('should fall back to default when override returns undefined and no env var', () => { - delete process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE; - const config = new ObservabilityConfiguration({ - observabilityAuthenticationScopes: () => undefined as unknown as string[] - }); - expect(config.observabilityAuthenticationScopes).toEqual(['api://9b975845-388f-4429-889e-eab1ef63949c/.default']); - }); - - it('should fall back to env var when override not provided', () => { - process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = 'scope-a/.default scope-b/.default'; - const config = new ObservabilityConfiguration({}); - expect(config.observabilityAuthenticationScopes).toEqual(['scope-a/.default', 'scope-b/.default']); - }); - - it('should fall back to default when neither override nor env var', () => { - delete process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE; - const config = new ObservabilityConfiguration({}); - expect(config.observabilityAuthenticationScopes).toEqual(['api://9b975845-388f-4429-889e-eab1ef63949c/.default']); - }); - - it('should fall back to default when env var is empty string', () => { - process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = ''; - const config = new ObservabilityConfiguration(); - expect(config.observabilityAuthenticationScopes).toEqual(['api://9b975845-388f-4429-889e-eab1ef63949c/.default']); - }); - - it('should fall back to default when env var is whitespace only', () => { - process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = ' '; - const config = new ObservabilityConfiguration(); - expect(config.observabilityAuthenticationScopes).toEqual(['api://9b975845-388f-4429-889e-eab1ef63949c/.default']); - }); - - it('should return readonly array', () => { - const config = new ObservabilityConfiguration({}); - const scopes = config.observabilityAuthenticationScopes; - expect(Array.isArray(scopes)).toBe(true); - }); - }); - - describe('isObservabilityExporterEnabled', () => { - it('should use override function when provided', () => { - const config = new ObservabilityConfiguration({ - isObservabilityExporterEnabled: () => true - }); - expect(config.isObservabilityExporterEnabled).toBe(true); - }); - - it.each([ - ['true', true], - ['TRUE', true], - ['1', true], - ['yes', true], - ['on', true], - ['false', false], - ['0', false], - ['no', false], - ['', false] - ])('should return %s when env var is "%s"', (envValue, expected) => { - process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = envValue; - const config = new ObservabilityConfiguration({}); - expect(config.isObservabilityExporterEnabled).toBe(expected); - }); - - it('should return false when env var is not set', () => { - delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; - const config = new ObservabilityConfiguration({}); - expect(config.isObservabilityExporterEnabled).toBe(false); - }); - - it('should call override function on each access (dynamic resolution)', () => { - let callCount = 0; - const config = new ObservabilityConfiguration({ - isObservabilityExporterEnabled: () => { - callCount++; - return true; - } - }); - void config.isObservabilityExporterEnabled; - void config.isObservabilityExporterEnabled; - expect(callCount).toBe(2); - }); - }); - - describe('observabilityDomainOverride', () => { - it('should use override function when provided', () => { - const config = new ObservabilityConfiguration({ - observabilityDomainOverride: () => 'https://custom.domain' - }); - expect(config.observabilityDomainOverride).toBe('https://custom.domain'); - }); - - it('should return trimmed value from env var', () => { - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = ' https://env.domain '; - const config = new ObservabilityConfiguration({}); - expect(config.observabilityDomainOverride).toBe('https://env.domain'); - }); - - it('should remove trailing slashes', () => { - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://env.domain///'; - const config = new ObservabilityConfiguration({}); - expect(config.observabilityDomainOverride).toBe('https://env.domain'); - }); - - it('should return null when env var is not set', () => { - delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; - const config = new ObservabilityConfiguration({}); - expect(config.observabilityDomainOverride).toBeNull(); - }); - - it('should return null when env var is whitespace', () => { - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = ' '; - const config = new ObservabilityConfiguration({}); - expect(config.observabilityDomainOverride).toBeNull(); - }); - }); - - describe('observabilityLogLevel', () => { - it('should use override function when provided', () => { - const config = new ObservabilityConfiguration({ - observabilityLogLevel: () => 'info|warn' - }); - expect(config.observabilityLogLevel).toBe('info|warn'); - }); - - it('should fall back to env var when override not provided', () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; - const config = new ObservabilityConfiguration({}); - expect(config.observabilityLogLevel).toBe('error'); - }); - - it('should fall back to none when neither override nor env var', () => { - delete process.env.A365_OBSERVABILITY_LOG_LEVEL; - const config = new ObservabilityConfiguration({}); - expect(config.observabilityLogLevel).toBe('none'); - }); - }); - -}); - -describe('defaultObservabilityConfigurationProvider', () => { - it('should be an instance of DefaultConfigurationProvider', () => { - expect(defaultObservabilityConfigurationProvider).toBeInstanceOf(DefaultConfigurationProvider); - }); - - it('should return ObservabilityConfiguration from getConfiguration', () => { - const config = defaultObservabilityConfigurationProvider.getConfiguration(); - expect(config).toBeInstanceOf(ObservabilityConfiguration); - }); - - it('should return the same configuration instance on multiple calls', () => { - const config1 = defaultObservabilityConfigurationProvider.getConfiguration(); - const config2 = defaultObservabilityConfigurationProvider.getConfiguration(); - expect(config1).toBe(config2); - }); -}); diff --git a/tests/observability/configuration/PerRequestSpanProcessorConfiguration.test.ts b/tests/observability/configuration/PerRequestSpanProcessorConfiguration.test.ts deleted file mode 100644 index a07971b2..00000000 --- a/tests/observability/configuration/PerRequestSpanProcessorConfiguration.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { - PerRequestSpanProcessorConfiguration, - defaultPerRequestSpanProcessorConfigurationProvider, -} from '../../../packages/agents-a365-observability/src'; -import { RuntimeConfiguration, DefaultConfigurationProvider, ClusterCategory } from '../../../packages/agents-a365-runtime/src'; - -describe('PerRequestSpanProcessorConfiguration', () => { - const originalEnv = process.env; - - beforeEach(() => { - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - describe('inheritance', () => { - it('should be instanceof RuntimeConfiguration', () => { - const config = new PerRequestSpanProcessorConfiguration(); - expect(config).toBeInstanceOf(RuntimeConfiguration); - }); - - it('should inherit runtime settings', () => { - const config = new PerRequestSpanProcessorConfiguration({ clusterCategory: () => ClusterCategory.gov }); - expect(config.clusterCategory).toBe(ClusterCategory.gov); - expect(config.isDevelopmentEnvironment).toBe(false); - }); - }); - - describe('isPerRequestExportEnabled', () => { - it('should use override function when provided', () => { - const config = new PerRequestSpanProcessorConfiguration({ - isPerRequestExportEnabled: () => true - }); - expect(config.isPerRequestExportEnabled).toBe(true); - }); - - it.each([ - ['true', true], - ['1', true], - ['yes', true], - ['on', true], - ['false', false], - ['0', false], - ['', false] - ])('should return %s when env var is "%s"', (envValue, expected) => { - process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = envValue; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.isPerRequestExportEnabled).toBe(expected); - }); - - it('should return false when env var is not set', () => { - delete process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.isPerRequestExportEnabled).toBe(false); - }); - - it('should call override function on each access (dynamic resolution)', () => { - let callCount = 0; - const config = new PerRequestSpanProcessorConfiguration({ - isPerRequestExportEnabled: () => { - callCount++; - return true; - } - }); - void config.isPerRequestExportEnabled; - void config.isPerRequestExportEnabled; - expect(callCount).toBe(2); - }); - }); - - describe('perRequestMaxTraces', () => { - it('should use override function when provided', () => { - const config = new PerRequestSpanProcessorConfiguration({ - perRequestMaxTraces: () => 500 - }); - expect(config.perRequestMaxTraces).toBe(500); - }); - - it('should fall back to env var when override not provided', () => { - process.env.A365_PER_REQUEST_MAX_TRACES = '2000'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxTraces).toBe(2000); - }); - - it('should fall back to default 1000 when neither override nor env var', () => { - delete process.env.A365_PER_REQUEST_MAX_TRACES; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxTraces).toBe(1000); - }); - - it('should fall back to default for invalid env var', () => { - process.env.A365_PER_REQUEST_MAX_TRACES = 'invalid'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxTraces).toBe(1000); - }); - - it('should fall back to default for negative values from env var', () => { - process.env.A365_PER_REQUEST_MAX_TRACES = '-100'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxTraces).toBe(1000); - }); - - it('should fall back to default for zero from env var', () => { - process.env.A365_PER_REQUEST_MAX_TRACES = '0'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxTraces).toBe(1000); - }); - - it('should fall back to default for negative override', () => { - const config = new PerRequestSpanProcessorConfiguration({ - perRequestMaxTraces: () => -50 - }); - expect(config.perRequestMaxTraces).toBe(1000); - }); - }); - - describe('perRequestMaxSpansPerTrace', () => { - it('should use override function when provided', () => { - const config = new PerRequestSpanProcessorConfiguration({ - perRequestMaxSpansPerTrace: () => 10000 - }); - expect(config.perRequestMaxSpansPerTrace).toBe(10000); - }); - - it('should fall back to env var when override not provided', () => { - process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = '8000'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxSpansPerTrace).toBe(8000); - }); - - it('should fall back to default 5000 when neither override nor env var', () => { - delete process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxSpansPerTrace).toBe(5000); - }); - - it('should fall back to default for invalid env var', () => { - process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = 'not-a-number'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxSpansPerTrace).toBe(5000); - }); - - it('should fall back to default for negative values from env var', () => { - process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = '-500'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxSpansPerTrace).toBe(5000); - }); - - it('should fall back to default for zero from env var', () => { - process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = '0'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxSpansPerTrace).toBe(5000); - }); - - it('should fall back to default for negative override', () => { - const config = new PerRequestSpanProcessorConfiguration({ - perRequestMaxSpansPerTrace: () => -100 - }); - expect(config.perRequestMaxSpansPerTrace).toBe(5000); - }); - }); - - describe('perRequestMaxConcurrentExports', () => { - it('should use override function when provided', () => { - const config = new PerRequestSpanProcessorConfiguration({ - perRequestMaxConcurrentExports: () => 50 - }); - expect(config.perRequestMaxConcurrentExports).toBe(50); - }); - - it('should fall back to env var when override not provided', () => { - process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = '30'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxConcurrentExports).toBe(30); - }); - - it('should fall back to default 20 when neither override nor env var', () => { - delete process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxConcurrentExports).toBe(20); - }); - - it('should fall back to default for invalid env var', () => { - process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = ''; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxConcurrentExports).toBe(20); - }); - - it('should fall back to default for negative values from env var', () => { - process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = '-10'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxConcurrentExports).toBe(20); - }); - - it('should fall back to default for zero from env var', () => { - process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = '0'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxConcurrentExports).toBe(20); - }); - - it('should fall back to default for negative override', () => { - const config = new PerRequestSpanProcessorConfiguration({ - perRequestMaxConcurrentExports: () => -5 - }); - expect(config.perRequestMaxConcurrentExports).toBe(20); - }); - }); - - describe('perRequestFlushGraceMs', () => { - it('should use override function when provided', () => { - const config = new PerRequestSpanProcessorConfiguration({ - perRequestFlushGraceMs: () => 100 - }); - expect(config.perRequestFlushGraceMs).toBe(100); - }); - - it('should fall back to env var when override not provided', () => { - process.env.A365_PER_REQUEST_FLUSH_GRACE_MS = '500'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestFlushGraceMs).toBe(500); - }); - - it('should fall back to default 250 when neither override nor env var', () => { - delete process.env.A365_PER_REQUEST_FLUSH_GRACE_MS; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestFlushGraceMs).toBe(250); - }); - - it('should fall back to default for invalid env var', () => { - process.env.A365_PER_REQUEST_FLUSH_GRACE_MS = 'invalid'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestFlushGraceMs).toBe(250); - }); - - it('should fall back to default for negative values from env var', () => { - process.env.A365_PER_REQUEST_FLUSH_GRACE_MS = '-50'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestFlushGraceMs).toBe(250); - }); - - it('should fall back to default for zero from env var', () => { - process.env.A365_PER_REQUEST_FLUSH_GRACE_MS = '0'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestFlushGraceMs).toBe(250); - }); - - it('should fall back to default for negative override', () => { - const config = new PerRequestSpanProcessorConfiguration({ - perRequestFlushGraceMs: () => -10 - }); - expect(config.perRequestFlushGraceMs).toBe(250); - }); - }); - - describe('perRequestMaxTraceAgeMs', () => { - it('should use override function when provided', () => { - const config = new PerRequestSpanProcessorConfiguration({ - perRequestMaxTraceAgeMs: () => 60000 - }); - expect(config.perRequestMaxTraceAgeMs).toBe(60000); - }); - - it('should fall back to env var when override not provided', () => { - process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS = '120000'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxTraceAgeMs).toBe(120000); - }); - - it('should fall back to default 1800000 when neither override nor env var', () => { - delete process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxTraceAgeMs).toBe(1800000); - }); - - it('should fall back to default for invalid env var', () => { - process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS = 'not-a-number'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxTraceAgeMs).toBe(1800000); - }); - - it('should fall back to default for negative values from env var', () => { - process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS = '-1000'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxTraceAgeMs).toBe(1800000); - }); - - it('should fall back to default for zero from env var', () => { - process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS = '0'; - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config.perRequestMaxTraceAgeMs).toBe(1800000); - }); - - it('should fall back to default for negative override', () => { - const config = new PerRequestSpanProcessorConfiguration({ - perRequestMaxTraceAgeMs: () => -5000 - }); - expect(config.perRequestMaxTraceAgeMs).toBe(1800000); - }); - }); - - describe('constructor', () => { - it('should accept no overrides', () => { - const config = new PerRequestSpanProcessorConfiguration(); - expect(config).toBeInstanceOf(PerRequestSpanProcessorConfiguration); - }); - - it('should accept empty overrides object', () => { - const config = new PerRequestSpanProcessorConfiguration({}); - expect(config).toBeInstanceOf(PerRequestSpanProcessorConfiguration); - }); - - it('should accept undefined overrides', () => { - const config = new PerRequestSpanProcessorConfiguration(undefined); - expect(config).toBeInstanceOf(PerRequestSpanProcessorConfiguration); - }); - }); -}); - -describe('defaultPerRequestSpanProcessorConfigurationProvider', () => { - it('should be an instance of DefaultConfigurationProvider', () => { - expect(defaultPerRequestSpanProcessorConfigurationProvider).toBeInstanceOf(DefaultConfigurationProvider); - }); - - it('should return PerRequestSpanProcessorConfiguration from getConfiguration', () => { - const config = defaultPerRequestSpanProcessorConfigurationProvider.getConfiguration(); - expect(config).toBeInstanceOf(PerRequestSpanProcessorConfiguration); - }); - - it('should return the same configuration instance on multiple calls', () => { - const config1 = defaultPerRequestSpanProcessorConfigurationProvider.getConfiguration(); - const config2 = defaultPerRequestSpanProcessorConfigurationProvider.getConfiguration(); - expect(config1).toBe(config2); - }); -}); diff --git a/tests/observability/core/BaggageBuilder.test.ts b/tests/observability/core/BaggageBuilder.test.ts deleted file mode 100644 index 32126ff2..00000000 --- a/tests/observability/core/BaggageBuilder.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -import { context, propagation } from '@opentelemetry/api'; -import { BaggageBuilder, BaggageScope } from '@microsoft/agents-a365-observability/src/tracing/middleware/BaggageBuilder'; -import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants'; - -describe('BaggageBuilder', () => { - describe('fluent setters', () => { - it('should set tenant ID', () => { - const builder = new BaggageBuilder(); - const result = builder.tenantId('tenant-123'); - expect(result).toBe(builder); // Fluent API - - const scope = builder.build(); - expect(scope).toBeInstanceOf(BaggageScope); - }); - - it('should set agent ID', () => { - const builder = new BaggageBuilder(); - builder.agentId('agent-456'); - - const scope = builder.build(); - expect(scope).toBeInstanceOf(BaggageScope); - }); - - it('should chain multiple setters', () => { - const builder = new BaggageBuilder() - .tenantId('tenant-123') - .agentId('agent-456') - .agentName('TestAgent') - .agentPlatformId('platform-xyz-123') - .conversationId('conv-001'); - - const scope = builder.build(); - expect(scope).toBeInstanceOf(BaggageScope); - }); - - it('should set agent platform ID', () => { - const builder = new BaggageBuilder(); - builder.agentPlatformId('platform-abc-456'); - - const scope = builder.build(); - expect(scope).toBeInstanceOf(BaggageScope); - - const bag = propagation.getBaggage((scope as any).contextWithBaggage); - expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_AGENT_PLATFORM_ID_KEY)?.value).toBe('platform-abc-456'); - }); - - it('should set caller agent platform ID via fluent API', () => { - const builder = new BaggageBuilder(); - builder.callerAgentPlatformId('caller-platform-xyz'); - - const scope = builder.build(); - expect(scope).toBeInstanceOf(BaggageScope); - - const bag = propagation.getBaggage((scope as any).contextWithBaggage); - expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY)?.value).toBe('caller-platform-xyz'); - }); - - it.each([ - ['agentVersion', '1.0.0', OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY], - ] as const)('%s should set the correct baggage key', (method, value, expectedKey) => { - const builder = new BaggageBuilder(); - (builder as any)[method](value); - const scope = builder.build(); - const bag = propagation.getBaggage((scope as any).contextWithBaggage); - expect(bag?.getEntry(expectedKey)?.value).toBe(value); - }); - - it.each([ - ['agentVersion', null], - ['agentVersion', ' '], - ] as const)('%s should ignore %s', (method, value) => { - const builder = new BaggageBuilder(); - (builder as any)[method](value); - const scope = builder.build(); - const bag = propagation.getBaggage((scope as any).contextWithBaggage); - expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY)).toBeUndefined(); - }); - }); - - describe('setPairs', () => { - it('should accept dictionary of pairs', () => { - const builder = new BaggageBuilder(); - builder.setPairs({ - [OpenTelemetryConstants.TENANT_ID_KEY]: 'tenant-123', - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: 'agent-456' - }); - - const scope = builder.build(); - expect(scope).toBeInstanceOf(BaggageScope); - }); - - it('should accept iterable of pairs', () => { - const builder = new BaggageBuilder(); - const pairs: Array<[string, string]> = [ - [OpenTelemetryConstants.TENANT_ID_KEY, 'tenant-123'], - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, 'agent-456'], - [OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, '10.0.0.5'] - ]; - builder.setPairs(pairs); - - const scope = builder.build(); - expect(scope).toBeInstanceOf(BaggageScope); - - const bag = propagation.getBaggage((scope as any).contextWithBaggage); - expect(bag?.getEntry(OpenTelemetryConstants.TENANT_ID_KEY)?.value).toBe('tenant-123'); - expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY)?.value).toBe('agent-456'); - expect(bag?.getEntry(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY)?.value).toBe('10.0.0.5'); - }); - - it('should ignore null values', () => { - const builder = new BaggageBuilder(); - builder.setPairs({ - [OpenTelemetryConstants.TENANT_ID_KEY]: 'tenant-123', - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: null - }); - - const scope = builder.build(); - expect(scope).toBeInstanceOf(BaggageScope); - }); - }); - - describe('null and whitespace handling', () => { - it('should ignore null values', () => { - const builder = new BaggageBuilder(); - builder.tenantId(null); - builder.agentId(undefined); - - const scope = builder.build(); - expect(scope).toBeInstanceOf(BaggageScope); - }); - - it('should ignore whitespace-only values', () => { - const builder = new BaggageBuilder(); - builder.tenantId(' '); - builder.agentId('\t\n'); - - const scope = builder.build(); - expect(scope).toBeInstanceOf(BaggageScope); - }); - - it('should trim values', () => { - const builder = new BaggageBuilder(); - builder.tenantId(' tenant-123 '); - - const scope = builder.build(); - expect(scope).toBeInstanceOf(BaggageScope); - }); - }); - - describe('operationSource, channelName, and channelLink', () => { - it.each([ - ['operationSource', 'ATG', OpenTelemetryConstants.SERVICE_NAME_KEY], - ['channelName', 'teams', OpenTelemetryConstants.CHANNEL_NAME_KEY], - ['channelLink', 'https://teams/channel', OpenTelemetryConstants.CHANNEL_LINK_KEY], - ] as const)('%s should set the correct baggage key', (method, value, expectedKey) => { - const builder = new BaggageBuilder(); - (builder as any)[method](value); - const scope = builder.build(); - const bag = propagation.getBaggage((scope as any).contextWithBaggage); - expect(bag?.getEntry(expectedKey)?.value).toBe(value); - }); - }); - - describe('invokeAgentServer', () => { - it.each([ - ['api.example.com', 8080, 'api.example.com', '8080'], - ['api.example.com', 443, 'api.example.com', undefined], - ['api.example.com', undefined, 'api.example.com', undefined], - ] as const)('address=%s port=%s should set address=%s portBaggage=%s', (address, port, expectedAddress, expectedPort) => { - const builder = new BaggageBuilder(); - builder.invokeAgentServer(address, port as number | undefined); - const scope = builder.build(); - const bag = propagation.getBaggage((scope as any).contextWithBaggage); - expect(bag?.getEntry(OpenTelemetryConstants.SERVER_ADDRESS_KEY)?.value).toBe(expectedAddress); - expect(bag?.getEntry(OpenTelemetryConstants.SERVER_PORT_KEY)?.value).toBe(expectedPort); - }); - - it('should clear previously set non-443 port when port is 443', () => { - const builder = new BaggageBuilder(); - // First set a non-443 port - builder.invokeAgentServer('api.example.com', 8080); - // Then call again with port 443, which should clear the port baggage - builder.invokeAgentServer('api.example.com', 443); - const scope = builder.build(); - const bag = propagation.getBaggage((scope as any).contextWithBaggage); - expect(bag?.getEntry(OpenTelemetryConstants.SERVER_ADDRESS_KEY)?.value).toBe('api.example.com'); - expect(bag?.getEntry(OpenTelemetryConstants.SERVER_PORT_KEY)).toBeUndefined(); - }); - - it('should return self for method chaining', () => { - const builder = new BaggageBuilder(); - expect(builder.invokeAgentServer('api.example.com', 8080)).toBe(builder); - }); - }); - - describe('setRequestContext static method', () => { - it('should create scope with common fields', () => { - const scope = BaggageBuilder.setRequestContext( - 'tenant-123', - 'agent-456' - ); - - expect(scope).toBeInstanceOf(BaggageScope); - }); - - it('should handle null values', () => { - const scope = BaggageBuilder.setRequestContext( - null, - 'agent-456' - ); - - expect(scope).toBeInstanceOf(BaggageScope); - }); - }); - - describe('sessionId support', () => { - it('should set sessionId via fluent API', () => { - const scope = new BaggageBuilder() - .tenantId('tenant-123') - .agentId('agent-456') - .sessionId('session-0001') - .sessionDescription('My session desc') - .build(); - const bag = propagation.getBaggage((scope as any).contextWithBaggage); - expect(bag?.getEntry(OpenTelemetryConstants.SESSION_ID_KEY)?.value).toBe('session-0001'); - expect(bag?.getEntry(OpenTelemetryConstants.SESSION_DESCRIPTION_KEY)?.value).toBe('My session desc'); - }); - - it('should omit empty sessionId value', () => { - const scope = new BaggageBuilder() - .sessionId(' ') - .build(); - const bag = propagation.getBaggage((scope as any).contextWithBaggage); - expect(bag?.getEntry(OpenTelemetryConstants.SESSION_ID_KEY)).toBeUndefined(); - }); - - it('should omit null sessionDescription value', () => { - const scope = new BaggageBuilder() - .sessionDescription(null) - .build(); - const bag = propagation.getBaggage((scope as any).contextWithBaggage); - expect(bag?.getEntry(OpenTelemetryConstants.SESSION_DESCRIPTION_KEY)).toBeUndefined(); - }); - }); -}); - -describe('BaggageScope', () => { - describe('run method', () => { - it('should execute function with baggage context', () => { - const builder = new BaggageBuilder() - .tenantId('tenant-123') - .agentId('agent-456'); - - const scope = builder.build(); - let executed = false; - - const result = scope.run(() => { - executed = true; - // Baggage should be set in the current context - // Note: The baggage may not be immediately retrievable in the test environment - // This is a basic structural test to ensure no errors occur - return 'test-result'; - }); - - expect(executed).toBe(true); - expect(result).toBe('test-result'); - }); - - it('should restore context after execution', () => { - const _originalContext = context.active(); - - const scope = new BaggageBuilder() - .tenantId('tenant-123') - .build(); - - scope.run(() => { - // Inside the scope - context should be different - const currentContext = context.active(); - expect(currentContext).toBeDefined(); - }); - - // After scope.run() completes, context should be restored - const restoredContext = context.active(); - expect(restoredContext).toBeDefined(); - }); - }); - - describe('disposable pattern', () => { - it('should implement Symbol.dispose', () => { - const scope = new BaggageBuilder() - .tenantId('tenant-123') - .build(); - - expect(typeof scope[Symbol.dispose]).toBe('function'); - - // Should not throw when disposing - expect(() => scope[Symbol.dispose]()).not.toThrow(); - }); - - it('should implement dispose method', () => { - const scope = new BaggageBuilder() - .tenantId('tenant-123') - .build(); - - expect(typeof scope.dispose).toBe('function'); - - // Should not throw when disposing - expect(() => scope.dispose()).not.toThrow(); - }); - }); -}); diff --git a/tests/observability/core/PerRequestSpanProcessor.test.ts b/tests/observability/core/PerRequestSpanProcessor.test.ts deleted file mode 100644 index 09de9444..00000000 --- a/tests/observability/core/PerRequestSpanProcessor.test.ts +++ /dev/null @@ -1,636 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import { PerRequestSpanProcessor } from '@microsoft/agents-a365-observability/src/tracing/PerRequestSpanProcessor'; -import { runWithExportToken, updateExportToken, getExportToken } from '@microsoft/agents-a365-observability/src/tracing/context/token-context'; -import type { SpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { ExportResult, ExportResultCode } from '@opentelemetry/core'; -import { context, trace } from '@opentelemetry/api'; -import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; -import { PerRequestSpanProcessorConfiguration } from '@microsoft/agents-a365-observability/src/configuration/PerRequestSpanProcessorConfiguration'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; - -describe('PerRequestSpanProcessor', () => { - let provider: BasicTracerProvider; - let processor: PerRequestSpanProcessor; - let exportedSpans: ReadableSpan[][] = []; - let mockExporter: SpanExporter; - let originalEnv: NodeJS.ProcessEnv; - - const getActiveTraceCount = () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const traces: Map | undefined = (processor as any).traces; - return traces?.size ?? 0; - }; - - beforeEach(() => { - originalEnv = { ...process.env }; - exportedSpans = []; - mockExporter = { - export: (spans: ReadableSpan[], resultCallback: (result: ExportResult) => void) => { - exportedSpans.push([...spans]); - resultCallback({ code: ExportResultCode.SUCCESS }); - }, - shutdown: async () => { - // No-op: provider shutdown is handled explicitly in afterEach. - } - }; - - processor = new PerRequestSpanProcessor(mockExporter); - provider = new BasicTracerProvider({ - spanProcessors: [processor] - }); - }); - - afterEach(async () => { - await provider.shutdown(); - jest.useRealTimers(); - process.env = originalEnv; - }); - - const recreateProvider = async (newProcessor: PerRequestSpanProcessor) => { - // Important: ensure old provider is fully shut down before replacing it. - // Otherwise, providers/processors/timers can accumulate across a Jest run. - await provider.shutdown(); - processor = newProcessor; - provider = new BasicTracerProvider({ - spanProcessors: [processor] - }); - }; - - describe('per-request export with token context', () => { - it('should cap the number of buffered traces (maxBufferedTraces)', async () => { - process.env.A365_PER_REQUEST_MAX_TRACES = '2'; - await recreateProvider(new PerRequestSpanProcessor(mockExporter)); - - const tracer = provider.getTracer('test'); - - // The guardrail caps *concurrently buffered* traces. To make that observable, - // keep trace-1 buffered (root ended but child still open) while starting trace-2. - await new Promise((resolve) => { - runWithExportToken('token-1', () => { - const root1 = tracer.startSpan('trace-1', { root: true }); - const ctx1 = trace.setSpan(context.active(), root1); - const child1 = tracer.startSpan('trace-1-child', undefined, ctx1); - - // End root but leave child open so the trace remains buffered. - root1.end(); - - runWithExportToken('token-2', () => { - const root2 = tracer.startSpan('trace-2'); - root2.end(); - }); - - // Finish trace-1 so it can flush. - setTimeout(() => { - child1.end(); - setTimeout(resolve, 50); - }, 10); - }); - }); - - // With max traces=2, both traces are allowed (trace-2 should NOT be dropped). - const exportedNames = exportedSpans.flatMap((s) => s.map((sp) => sp.name)); - expect(exportedNames).toContain('trace-1'); - expect(exportedNames).toContain('trace-1-child'); - expect(exportedNames).toContain('trace-2'); - }); - - it('should drop additional traces beyond maxBufferedTraces (drop case)', async () => { - process.env.A365_PER_REQUEST_MAX_TRACES = '2'; - await recreateProvider(new PerRequestSpanProcessor(mockExporter)); - - const tracer = provider.getTracer('test'); - - // Keep two traces buffered (root ended but child still open), then attempt a third. - await new Promise((resolve) => { - runWithExportToken('token-1', () => { - const root1 = tracer.startSpan('trace-1', { root: true }); - const ctx1 = trace.setSpan(context.active(), root1); - const child1 = tracer.startSpan('trace-1-child', undefined, ctx1); - root1.end(); - - runWithExportToken('token-2', () => { - const root2 = tracer.startSpan('trace-2', { root: true }); - const ctx2 = trace.setSpan(context.active(), root2); - const child2 = tracer.startSpan('trace-2-child', undefined, ctx2); - root2.end(); - - // This third trace should be dropped because two traces are already buffered. - runWithExportToken('token-3', () => { - const root3 = tracer.startSpan('trace-3', { root: true }); - root3.end(); - }); - - // Finish the buffered traces so they can flush. - setTimeout(() => { - child2.end(); - child1.end(); - setTimeout(resolve, 50); - }, 10); - }); - }); - }); - - const exportedNames = exportedSpans.flatMap((s) => s.map((sp) => sp.name)); - expect(exportedNames).toContain('trace-1'); - expect(exportedNames).toContain('trace-1-child'); - expect(exportedNames).toContain('trace-2'); - expect(exportedNames).toContain('trace-2-child'); - expect(exportedNames).not.toContain('trace-3'); - }); - - it('should cap the number of buffered spans per trace (maxSpansPerTrace)', async () => { - process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = '2'; - await recreateProvider(new PerRequestSpanProcessor(mockExporter)); - - const tracer = provider.getTracer('test'); - - await new Promise((resolve) => { - runWithExportToken('test-token', () => { - const rootSpan = tracer.startSpan('root', { root: true }); - const ctxWithRoot = trace.setSpan(context.active(), rootSpan); - - const child1 = tracer.startSpan('child-1', undefined, ctxWithRoot); - const child2 = tracer.startSpan('child-2', undefined, ctxWithRoot); - child1.end(); - child2.end(); - // Ending root after 2 children makes the drop deterministic: root is the 3rd ended span. - rootSpan.end(); - - setTimeout(resolve, 50); - }); - }); - - // We exported a single trace flush, but only 2 ended spans should be buffered/exported. - expect(exportedSpans.length).toBe(1); - expect(exportedSpans[0].length).toBe(2); - - const exportedNames = exportedSpans[0].map((sp) => sp.name); - expect(exportedNames).toContain('child-1'); - expect(exportedNames).toContain('child-2'); - expect(exportedNames).not.toContain('root'); - }); - - it('should respect max concurrent exports (A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS)', async () => { - process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = '2'; - - let inFlight = 0; - let maxInFlight = 0; - - // Hold each export "in flight" for a bit. If concurrency limiting is broken, - // all 3 exports would start immediately and maxInFlight would hit 3. - const exportHoldMs = 50; - - exportedSpans = []; - mockExporter = { - export: (spans: ReadableSpan[], resultCallback: (result: ExportResult) => void) => { - inFlight += 1; - maxInFlight = Math.max(maxInFlight, inFlight); - exportedSpans.push([...spans]); - - setTimeout(() => { - inFlight -= 1; - resultCallback({ code: ExportResultCode.SUCCESS }); - }, exportHoldMs); - }, - shutdown: async () => { - // No-op: provider shutdown is handled explicitly in afterEach. - } - }; - - await recreateProvider(new PerRequestSpanProcessor(mockExporter)); - - - const tracer = provider.getTracer('test'); - - // Create multiple independent traces that will flush around the same time. - runWithExportToken('token-1', () => { - const span = tracer.startSpan('trace-1'); - span.end(); - }); - runWithExportToken('token-2', () => { - const span = tracer.startSpan('trace-2'); - span.end(); - }); - runWithExportToken('token-3', () => { - const span = tracer.startSpan('trace-3'); - span.end(); - }); - - // Wait long enough for 3 exports to be attempted and completed. - await new Promise((resolve) => setTimeout(resolve, exportHoldMs * 6)); - - expect(maxInFlight).toBeLessThanOrEqual(2); - const exportedNames = exportedSpans.flatMap((s) => s.map((sp) => sp.name)); - expect(exportedNames).toContain('trace-1'); - expect(exportedNames).toContain('trace-2'); - expect(exportedNames).toContain('trace-3'); - }); - - it('should capture root span context and export under that context', async () => { - const tracer = provider.getTracer('test'); - - // Run within a context with a token set - await new Promise((resolve) => { - runWithExportToken('test-token-123', () => { - const rootSpan = tracer.startSpan('root-span'); - rootSpan.end(); - - // Wait a bit for async processing - setTimeout(() => { - resolve(); - }, 100); - }); - }); - - expect(exportedSpans.length).toBeGreaterThan(0); - expect(exportedSpans[0][0].name).toBe('root-span'); - }); - - it('should export with refreshed token when updateExportToken is called before root span ends', async () => { - const contextManager = new AsyncLocalStorageContextManager(); - contextManager.enable(); - context.setGlobalContextManager(contextManager); - - try { - let authorizationHeader: string | undefined; - const tokenCapturingExporter: SpanExporter = { - export: (spans: ReadableSpan[], resultCallback: (result: ExportResult) => void) => { - const token = getExportToken() ?? null; - if (token) { - authorizationHeader = `Bearer ${token}`; - } - exportedSpans.push([...spans]); - resultCallback({ code: ExportResultCode.SUCCESS }); - }, - shutdown: async () => {} - }; - await recreateProvider(new PerRequestSpanProcessor(tokenCapturingExporter)); - const tracer = provider.getTracer('test'); - - await new Promise((resolve) => { - runWithExportToken('initial-token', () => { - const rootSpan = tracer.startSpan('long-running-root'); - const child = tracer.startSpan('child-work'); - child.end(); - updateExportToken('refreshed-token'); - - // Root ends → triggers flushTrace which restores rootCtx and calls exporter - rootSpan.end(); - - setTimeout(() => resolve(), 100); - }); - }); - - // Verify the exporter built the auth header with the refreshed token, - // proving the mutable TokenHolder was visible through the restored rootCtx - expect(exportedSpans.length).toBeGreaterThanOrEqual(1); - expect(authorizationHeader).toBe('Bearer refreshed-token'); - } finally { - contextManager.disable(); - context.disable(); - } - }); - - it('should collect multiple spans from a single trace', async () => { - const tracer = provider.getTracer('test'); - - await new Promise((resolve) => { - runWithExportToken('test-token', () => { - const rootSpan = tracer.startSpan('root-span'); - const child1 = tracer.startSpan('child-1'); - const child2 = tracer.startSpan('child-2'); - - child1.end(); - child2.end(); - rootSpan.end(); - - setTimeout(() => { - resolve(); - }, 100); - }); - }); - - expect(exportedSpans.length).toEqual(3); - // Should have spans from the same trace exported together - const spanNames = exportedSpans.flatMap((s: ReadableSpan[]) => s.map((span) => span.name)); - expect(spanNames).toContain('root-span'); - expect(spanNames).toContain('child-1'); - expect(spanNames).toContain('child-2'); - }); - - it('should handle multiple independent traces', async () => { - const tracer = provider.getTracer('test'); - - await new Promise((resolve) => { - let completed = 0; - const checkDone = () => { - completed++; - if (completed === 3) { - setTimeout(() => { - resolve(); - }, 100); - } - }; - - runWithExportToken('token-1', () => { - const span1 = tracer.startSpan('trace-1-span'); - span1.end(); - checkDone(); - }); - - runWithExportToken('token-2', () => { - const span2 = tracer.startSpan('trace-2-span'); - span2.end(); - checkDone(); - }); - - runWithExportToken('token-3', () => { - const span3 = tracer.startSpan('trace-3-span'); - span3.end(); - checkDone(); - }); - }); - - // Should have collected 3 independent traces - expect(exportedSpans.length).toBeGreaterThanOrEqual(3); - // Verify we have different span names from different traces - const spanNames = exportedSpans.flatMap((spans) => spans.map((s) => s.name)); - expect(spanNames).toContain('trace-1-span'); - expect(spanNames).toContain('trace-2-span'); - expect(spanNames).toContain('trace-3-span'); - }); - - it('should correctly identify root spans', async () => { - const tracer = provider.getTracer('test'); - - await new Promise((resolve) => { - runWithExportToken('test-token', () => { - const rootSpan = tracer.startSpan('actual-root'); - const childSpan = tracer.startSpan('child-of-root'); - const grandchildSpan = tracer.startSpan('grandchild'); - - grandchildSpan.end(); - childSpan.end(); - rootSpan.end(); - - setTimeout(() => { - resolve(); - }, 100); - }); - }); - - expect(exportedSpans.length).toBe(3); - // Verify the order: grandchild, child-of-root, actual-root - expect(exportedSpans[0][0].name).toBe('grandchild'); - expect(exportedSpans[1][0].name).toBe('child-of-root'); - expect(exportedSpans[2][0].name).toBe('actual-root'); - }); - - it('should respect custom grace flush timeout', async () => { - exportedSpans = []; - const customGrace = 30; - const configProvider = new DefaultConfigurationProvider(() => new PerRequestSpanProcessorConfiguration({ - perRequestFlushGraceMs: () => customGrace, - })); - await recreateProvider(new PerRequestSpanProcessor(mockExporter, configProvider)); - - const tracer = provider.getTracer('test'); - - await new Promise((resolve) => { - runWithExportToken('test-token', () => { - const rootSpan = tracer.startSpan('root'); - const childSpan = tracer.startSpan('child'); - - rootSpan.end(); // Root ends, child still pending - - setTimeout(() => { - childSpan.end(); // Child ends after grace period should flush - setTimeout(() => { - resolve(); - }, 50); - }, 50); - }); - }); - - expect(exportedSpans.length).toEqual(2); - }); - - it('should handle forceFlush correctly', async () => { - const tracer = provider.getTracer('test'); - - runWithExportToken('test-token', () => { - const rootSpan = tracer.startSpan('root'); - tracer.startSpan('child'); - - rootSpan.end(); // Root ends, child pending - // Don't end child - let forceFlush handle it - }); - - await processor.forceFlush(); - - expect(exportedSpans.length).toBe(1); - }); - - it('should not retain trace buffers after trace completion', async () => { - const tracer = provider.getTracer('test'); - - await new Promise((resolve) => { - runWithExportToken('test-token', () => { - const rootSpan = tracer.startSpan('root'); - const childSpan = tracer.startSpan('child'); - - childSpan.end(); - rootSpan.end(); - - setTimeout(() => resolve(), 100); - }); - }); - - expect(getActiveTraceCount()).toBe(0); - }); - - it('should drop trace buffers after grace flush if children never end', async () => { - exportedSpans = []; - const customGrace = 10; - const customMaxAge = 1000; - - const configProvider = new DefaultConfigurationProvider(() => new PerRequestSpanProcessorConfiguration({ - perRequestFlushGraceMs: () => customGrace, - perRequestMaxTraceAgeMs: () => customMaxAge, - })); - await recreateProvider(new PerRequestSpanProcessor(mockExporter, configProvider)); - - const tracer = provider.getTracer('test'); - - // Make the sweep deterministic by controlling time and invoking sweep directly. - let now = 1_000_000; - const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now); - try { - runWithExportToken('test-token', () => { - const rootSpan = tracer.startSpan('root', { root: true }); - const ctxWithRoot = trace.setSpan(context.active(), rootSpan); - - // Start a child span in the same trace and never end it. - // Pass ctxWithRoot explicitly so we don't depend on a global context manager. - tracer.startSpan('child', undefined, ctxWithRoot); - - rootSpan.end(); - }); - - // Should have exactly one trace buffered (root + child share traceId). - expect(getActiveTraceCount()).toBe(1); - - // Validate the trace is in the expected lifecycle state. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const traces: Map = (processor as any).traces; - const buf = [...traces.values()][0]; - expect(buf.rootEnded).toBe(true); - expect(buf.openCount).toBeGreaterThan(0); - expect(buf.rootEndedAtMs).toBeDefined(); - - // Avoid races with the background interval sweeper; drive sweep manually. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sweepTimer: any = (processor as any).sweepTimer; - if (sweepTimer) { - clearInterval(sweepTimer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (processor as any).sweepTimer = undefined; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (processor as any).isSweeping = false; - - now += customGrace + 1; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (processor as any).sweep(); - - expect(getActiveTraceCount()).toBe(0); - } finally { - nowSpy.mockRestore(); - } - }); - - it('should drop trace buffers after max trace age even if no spans end (prevents unbounded growth)', async () => { - exportedSpans = []; - const customGrace = 250; - const customMaxAge = 10; - - const configProvider = new DefaultConfigurationProvider(() => new PerRequestSpanProcessorConfiguration({ - perRequestFlushGraceMs: () => customGrace, - perRequestMaxTraceAgeMs: () => customMaxAge, - })); - await recreateProvider(new PerRequestSpanProcessor(mockExporter, configProvider)); - - const tracer = provider.getTracer('test'); - - let now = 2_000_000; - const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now); - try { - runWithExportToken('test-token', () => { - tracer.startSpan('root-never-ended'); - }); - - expect(getActiveTraceCount()).toBe(1); - - now += customMaxAge + 1; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (processor as any).sweep(); - - expect(getActiveTraceCount()).toBe(0); - } finally { - nowSpy.mockRestore(); - } - }); - - it('should use default values when env vars are not set', async () => { - delete process.env.A365_PER_REQUEST_MAX_TRACES; - delete process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE; - delete process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS; - delete process.env.A365_PER_REQUEST_FLUSH_GRACE_MS; - delete process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS; - - await recreateProvider(new PerRequestSpanProcessor(mockExporter)); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const proc = processor as any; - expect(proc.maxBufferedTraces).toBe(1000); - expect(proc.maxSpansPerTrace).toBe(5000); - expect(proc.maxConcurrentExports).toBe(20); - expect(proc.flushGraceMs).toBe(250); - expect(proc.maxTraceAgeMs).toBe(1800000); - }); - - it('should fallback to defaults for invalid env var values', async () => { - process.env.A365_PER_REQUEST_MAX_TRACES = 'not-a-number'; - process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = ''; - process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = 'NaN'; - process.env.A365_PER_REQUEST_FLUSH_GRACE_MS = 'abc'; - process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS = ''; - - await recreateProvider(new PerRequestSpanProcessor(mockExporter)); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const proc = processor as any; - expect(proc.maxBufferedTraces).toBe(1000); - expect(proc.maxSpansPerTrace).toBe(5000); - expect(proc.maxConcurrentExports).toBe(20); - expect(proc.flushGraceMs).toBe(250); - expect(proc.maxTraceAgeMs).toBe(1800000); - }); - - it('should parse valid numeric string env vars correctly', async () => { - process.env.A365_PER_REQUEST_MAX_TRACES = '50'; - process.env.A365_PER_REQUEST_MAX_SPANS_PER_TRACE = '100'; - process.env.A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS = '5'; - process.env.A365_PER_REQUEST_FLUSH_GRACE_MS = '500'; - process.env.A365_PER_REQUEST_MAX_TRACE_AGE_MS = '60000'; - - await recreateProvider(new PerRequestSpanProcessor(mockExporter)); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const proc = processor as any; - expect(proc.maxBufferedTraces).toBe(50); - expect(proc.maxSpansPerTrace).toBe(100); - expect(proc.maxConcurrentExports).toBe(5); - expect(proc.flushGraceMs).toBe(500); - expect(proc.maxTraceAgeMs).toBe(60000); - }); - - it('should handle shutdown gracefully', async () => { - const tracer = provider.getTracer('test'); - - runWithExportToken('test-token', () => { - const span = tracer.startSpan('root'); - span.end(); - }); - - // Shutdown should complete without throwing - await expect(processor.shutdown()).resolves.not.toThrow(); - }); - - it('should handle onStart with null parentSpanContext as root span', async () => { - const tracer = provider.getTracer('test'); - - await new Promise((resolve) => { - runWithExportToken('test-token', () => { - // Root span has no parent - const rootSpan = tracer.startSpan('root', { root: true }); - rootSpan.end(); - - setTimeout(() => resolve(), 50); - }); - }); - - expect(exportedSpans.length).toBe(1); - expect(exportedSpans[0][0].name).toBe('root'); - }); - - it('should handle empty traces array in forceFlush', async () => { - // No spans created, forceFlush should still complete - await expect(processor.forceFlush()).resolves.not.toThrow(); - }); - }); -}); diff --git a/tests/observability/core/SpanProcessor.test.ts b/tests/observability/core/SpanProcessor.test.ts deleted file mode 100644 index b2169c55..00000000 --- a/tests/observability/core/SpanProcessor.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -import { context, propagation, Span, SpanKind } from '@opentelemetry/api'; -import { tracing } from '@opentelemetry/sdk-node'; -import { SpanProcessor } from '@microsoft/agents-a365-observability/src/tracing/processors/SpanProcessor'; -import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants'; -import { GENERIC_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES } from '@microsoft/agents-a365-observability/src/tracing/processors/util'; - -const { BasicTracerProvider } = tracing; - -describe('SpanProcessor', () => { - let provider: any; - let processor: SpanProcessor; - - beforeEach(() => { - processor = new SpanProcessor(); - provider = new BasicTracerProvider({ - spanProcessors: [processor] - } as any); - }); - - afterEach(async () => { - await provider.shutdown(); - }); - - describe('baggage to span attribute enrichment', () => { - it('should copy generic attributes from baggage to span', () => { - const baggageEntries = { - [OpenTelemetryConstants.TENANT_ID_KEY]: 'tenant-123', - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: 'agent-789' - }; - - let baggage = propagation.createBaggage(); - for (const [key, value] of Object.entries(baggageEntries)) { - baggage = baggage.setEntry(key, { value }); - } - - const ctx = propagation.setBaggage(context.active(), baggage); - - // Create a span in this context (parentContext not passed so processor may no-op) - const tracer = provider.getTracer('test'); - let testSpan: Span | undefined; - - context.with(ctx, () => { - testSpan = tracer.startSpan('test-span', { kind: SpanKind.CLIENT }); - if (testSpan) { - testSpan.end(); - } - }); - - expect(testSpan).toBeDefined(); - }); - - - it('should copy sessionId from baggage to span', () => { - let baggage = propagation.createBaggage(); - baggage = baggage.setEntry(OpenTelemetryConstants.SESSION_ID_KEY, { value: 'session-abc' }); - - const ctx = propagation.setBaggage(context.active(), baggage); - const tracer = provider.getTracer('test'); - const testSpan = tracer.startSpan('test-span', { kind: SpanKind.CLIENT }, ctx as any); - testSpan.end(); - - const attrs = (testSpan as any)._attributes ?? (testSpan as any).attributes ?? {}; - expect(attrs[OpenTelemetryConstants.SESSION_ID_KEY]).toBe('session-abc'); - }); - - it('should copy sessionDescription from baggage to span', () => { - let baggage = propagation.createBaggage(); - baggage = baggage.setEntry(OpenTelemetryConstants.SESSION_DESCRIPTION_KEY, { value: 'Test session description' }); - - const ctx = propagation.setBaggage(context.active(), baggage); - const tracer = provider.getTracer('test'); - const testSpan = tracer.startSpan('test-span', { kind: SpanKind.CLIENT }, ctx as any); - testSpan.end(); - - const attrs = (testSpan as any)._attributes ?? (testSpan as any).attributes ?? {}; - expect(attrs[OpenTelemetryConstants.SESSION_DESCRIPTION_KEY]).toBe('Test session description'); - }); - - it('should copy invoke agent attributes for invoke_agent operations', () => { - // Set baggage with invoke agent specific fields - const baggageEntries = { - [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: OpenTelemetryConstants.INVOKE_AGENT_OPERATION_NAME, - [OpenTelemetryConstants.TENANT_ID_KEY]: 'tenant-123', - [OpenTelemetryConstants.USER_ID_KEY]: 'caller-456' - }; - - let baggage = propagation.createBaggage(); - for (const [key, value] of Object.entries(baggageEntries)) { - baggage = baggage.setEntry(key, { value }); - } - - const ctx = propagation.setBaggage(context.active(), baggage); - - // Create a span in this context - const tracer = provider.getTracer('test'); - let testSpan: Span | undefined; - - context.with(ctx, () => { - testSpan = tracer.startSpan('invoke_agent test', { kind: SpanKind.CLIENT }); - if (testSpan) { - testSpan.end(); - } - }); - - expect(testSpan).toBeDefined(); - }); - - it('should not overwrite existing span attributes', () => { - // Set baggage - let baggage = propagation.createBaggage(); - baggage = baggage.setEntry(OpenTelemetryConstants.TENANT_ID_KEY, { value: 'tenant-from-baggage' }); - - const ctx = propagation.setBaggage(context.active(), baggage); - - // Create a span with existing attribute - const tracer = provider.getTracer('test'); - let testSpan: Span | undefined; - - context.with(ctx, () => { - testSpan = tracer.startSpan('test-span', { - kind: SpanKind.CLIENT, - attributes: { - [OpenTelemetryConstants.TENANT_ID_KEY]: 'tenant-existing' - } - }); - if (testSpan) { - testSpan.end(); - } - }); - - expect(testSpan).toBeDefined(); - // In a real test, we'd verify the attribute wasn't overwritten - }); - - it('should ignore empty baggage values', () => { - // Set baggage with empty values - let baggage = propagation.createBaggage(); - baggage = baggage.setEntry(OpenTelemetryConstants.TENANT_ID_KEY, { value: '' }); - - const ctx = propagation.setBaggage(context.active(), baggage); - - // Create a span - const tracer = provider.getTracer('test'); - let testSpan: Span | undefined; - - context.with(ctx, () => { - testSpan = tracer.startSpan('test-span', { kind: SpanKind.CLIENT }); - if (testSpan) { - testSpan.end(); - } - }); - - expect(testSpan).toBeDefined(); - }); - }); - - describe('attribute registry application', () => { - it('should apply all generic attributes', () => { - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.TENANT_ID_KEY); - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY); - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.SESSION_ID_KEY); - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.USER_ID_KEY); - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.USER_NAME_KEY); - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.USER_EMAIL_KEY); - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY); - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_AGENT_EMAIL_KEY); - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.CHANNEL_NAME_KEY); - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.CHANNEL_LINK_KEY); - expect(GENERIC_ATTRIBUTES).not.toContain('correlation.id'); - }); - - it('should apply invoke agent specific attributes', () => { - expect(INVOKE_AGENT_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY); - expect(INVOKE_AGENT_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_EMAIL_KEY); - expect(INVOKE_AGENT_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_VERSION_KEY); - }); - - it('should include blueprint ID and agent version in generic attributes', () => { - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY); - expect(GENERIC_ATTRIBUTES).toContain(OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY); - }); - }); - - describe('processor lifecycle', () => { - it('should shutdown gracefully', async () => { - await expect(processor.shutdown()).resolves.toBeUndefined(); - }); - - it('should force flush gracefully', async () => { - await expect(processor.forceFlush()).resolves.toBeUndefined(); - }); - }); -}); diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts deleted file mode 100644 index 8c3027ee..00000000 --- a/tests/observability/core/agent365-exporter.test.ts +++ /dev/null @@ -1,1253 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; -import { Agent365Exporter } from '@microsoft/agents-a365-observability/src/tracing/exporter/Agent365Exporter'; -import { Agent365ExporterOptions } from '@microsoft/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions'; -// Using standard import instead of 'import type' to avoid Babel/Jest transform issues in this workspace -import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { ExportResultCode } from '@opentelemetry/core'; -import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants'; -import { truncateSpan } from '@microsoft/agents-a365-observability/src/tracing/exporter/utils'; -import { runWithExportToken } from '@microsoft/agents-a365-observability/src/tracing/context/token-context'; -import { context as otelContext } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { AgenticTokenCache } from '@microsoft/agents-a365-observability-hosting'; - -// Minimal mock span factory -function makeSpan(attrs: Record, name = 'test'): ReadableSpan { - return { - name, - kind: 0, - spanContext: () => ({ traceId: '1', spanId: '2', traceFlags: 1 }), - parentSpanId: undefined, - parentSpanContext: undefined, - startTime: [Math.floor(Date.now() / 1000), 0], - endTime: [Math.floor(Date.now() / 1000) + 1, 0], - status: { code: 0 }, - attributes: { - [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: 'invoke_agent', - ...attrs, - }, - events: [], - links: [], - duration: [1, 0], - resource: { attributes: {} }, - instrumentationScope: { name: 'tests', version: '1.0.0' } - } as unknown as ReadableSpan; -} - -// Helpers -const tenantId = 'tenant-11111111-1111-1111-1111-111111111111'; -const agentId = 'agent-22222222-2222-2222-2222-222222222222'; - -function makeToken(claims: Record): string { - return `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.offline-signature`; -} - -// Patch global fetch -const originalFetch = global.fetch; - -type FetchCallArgs = [string, { headers: Record; body?: unknown; signal?: AbortSignal }]; - -function getFetchCalls(): FetchCallArgs[] { - const f = global.fetch as unknown as { mock?: { calls?: unknown[][] } }; - return (f.mock?.calls ?? []) as unknown as FetchCallArgs[]; -} - -function mockFetchSequence(statuses: number[]): void { - let call = 0; - global.fetch = jest.fn(async () => ({ - status: statuses[Math.min(call++, statuses.length - 1)], - headers: { get: () => 'cid' } - })) as unknown as typeof fetch; -} - -describe('Agent365Exporter', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - afterEach(() => { - jest.clearAllTimers(); - jest.useRealTimers(); - global.fetch = originalFetch; - delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; - delete process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT; - }); - - it('returns success immediately with no spans', async () => { - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; - opts.tokenResolver = () => null; - const exporter = new Agent365Exporter(opts); - const callback = jest.fn(); - await exporter.export([], callback); - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - }); - - it('uses provided token resolver and sets authorization header', async () => { - const token = 'abc123'; - mockFetchSequence([200]); - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; - opts.tokenResolver = () => token; - const exporter = new Agent365Exporter(opts); - - const spans = [ - makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, - [OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY]: '10.0.0.5' - }) - ]; - - const callback = jest.fn(); - await exporter.export(spans, callback); - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - // Ensure fetch saw auth header - const fetchCalls = getFetchCalls(); - expect(fetchCalls.length).toBe(1); - const headersArg = fetchCalls[0][1].headers; - expect(headersArg['authorization']).toBe(`Bearer ${token}`); - // Validate attributes in exported payload - const bodyStr = fetchCalls[0][1].body as string; - const bodyJson = JSON.parse(bodyStr); - const exportedSpan = bodyJson.resourceSpans[0].scopeSpans[0].spans[0]; - expect(exportedSpan.attributes).toBeDefined(); - expect(exportedSpan.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(tenantId); - expect(exportedSpan.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(agentId); - expect(exportedSpan.attributes[OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY]).toBe('10.0.0.5'); - }); - - it.each([ - { cluster: 'prod', expectedUrl: 'https://agent365.svc.cloud.microsoft', token: 'tok-prod' }, - { cluster: 'preprod', expectedUrl: 'https://agent365.svc.cloud.microsoft', token: 'tok-preprod' } - ])('exports to custom domain by default (cluster=%s)', async ({ cluster, expectedUrl, token }) => { - mockFetchSequence([200]); - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = cluster; - opts.tokenResolver = () => token; - const exporter = new Agent365Exporter(opts); - const spans = [ - makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId - }) - ]; - const callback = jest.fn(); - await exporter.export(spans, callback); - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - const fetchCalls = getFetchCalls(); - expect(fetchCalls.length).toBe(1); - const urlArg = fetchCalls[0][0]; - const headersArg = fetchCalls[0][1].headers; - expect(urlArg).toBe(`${expectedUrl}/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - expect(headersArg['x-ms-tenant-id']).toBe(tenantId); - expect(headersArg['authorization']).toBe(`Bearer ${token}`); - }); - - it.each([ - { - description: 'set to non-empty value', - override: 'https://custom-observability.internal', - expectedBaseUrl: 'https://custom-observability.internal' - }, - { - description: 'set to empty string', - override: '', - expectedBaseUrl: 'https://agent365.svc.cloud.microsoft' - }, - { - description: 'set to whitespace only', - override: ' ', - expectedBaseUrl: 'https://agent365.svc.cloud.microsoft' - }, - { - description: 'unset (undefined)', - override: undefined, - expectedBaseUrl: 'https://agent365.svc.cloud.microsoft' - }, - ])('uses correct domain when A365_OBSERVABILITY_DOMAIN_OVERRIDE is $description', async ({ override, expectedBaseUrl }) => { - mockFetchSequence([200]); - - if (override !== undefined) { - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = override as string; - } - - const token = 'tok-override-domain'; - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'prod'; - opts.tokenResolver = () => token; - - const exporter = new Agent365Exporter(opts); - const spans = [ - makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId - }) - ]; - - const callback = jest.fn(); - await exporter.export(spans, callback); - - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - const fetchCalls = getFetchCalls(); - expect(fetchCalls.length).toBe(1); - const urlArg = fetchCalls[0][0] as string; - const headersArg = fetchCalls[0][1].headers as Record; - - expect(urlArg).toBe(`${expectedBaseUrl}/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - expect(headersArg['x-ms-tenant-id']).toBe(tenantId); - expect(headersArg['authorization']).toBe(`Bearer ${token}`); - }); - - it('exports to default custom domain endpoint when no env vars set', async () => { - mockFetchSequence([200]); - const token = 'tok-prod-default'; - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'prod'; - opts.tokenResolver = () => token; - const exporter = new Agent365Exporter(opts); - const spans = [ - makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId - }) - ]; - const callback = jest.fn(); - await exporter.export(spans, callback); - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - const fetchCalls = getFetchCalls(); - expect(fetchCalls.length).toBe(1); - const urlArg = fetchCalls[0][0]; - const headersArg = fetchCalls[0][1].headers; - expect(urlArg).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - expect(headersArg['x-ms-tenant-id']).toBe(tenantId); - expect(headersArg['authorization']).toBe(`Bearer ${token}`); - }); - - it('requires a tokenResolver and fails export when missing', async () => { - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; - // Intentionally omit tokenResolver - expect(() => new Agent365Exporter(opts)) - .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/); - }); - - it.each([null, '', ' '])('reports failed export without sending an empty token (%j)', async (token) => { - mockFetchSequence([200]); - const opts = new Agent365ExporterOptions(); - opts.tokenResolver = () => token; - const exporter = new Agent365Exporter(opts); - const callback = jest.fn(); - - await exporter.export([makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, - })], callback); - - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); - expect(getFetchCalls()).toHaveLength(0); - }); - - it('reports resolver acquisition failure without sending a request or falling back', async () => { - mockFetchSequence([200]); - const opts = new Agent365ExporterOptions(); - const resolver = jest.fn(async (_agentId: string, _tenantId: string) => { - throw new Error('app-only token acquisition failed'); - }); - opts.tokenResolver = resolver; - const exporter = new Agent365Exporter(opts); - const callback = jest.fn(); - - await exporter.export([makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, - })], callback); - - expect(resolver).toHaveBeenCalledTimes(1); - expect(resolver).toHaveBeenCalledWith(agentId, tenantId); - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); - expect(getFetchCalls()).toHaveLength(0); - }); - - it.each([ - { description: 'absent', roles: undefined }, - { description: 'empty', roles: [] }, - ])('exports an app token with $description roles from the hosting resolver', async ({ roles }) => { - mockFetchSequence([200]); - const claims = { - idtyp: 'app', tid: tenantId, azp: agentId, roles, - aud: '9b975845-388f-4429-889e-eab1ef63949c', - exp: Math.floor(Date.now() / 1000) + 300, - }; - const token = makeToken(claims); - const cache = new AgenticTokenCache(); - const resolver = jest.fn(async (_agentId: string, _tenantId: string, _scopes: readonly string[]) => token); - await cache.RefreshObservabilityToken(agentId, tenantId, resolver); - const opts = new Agent365ExporterOptions(); - opts.useS2SEndpoint = false; - opts.tokenResolver = (agent, tenant) => cache.getObservabilityToken(agent, tenant); - const exporter = new Agent365Exporter(opts); - const callback = jest.fn(); - - await exporter.export([makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, - })], callback); - - expect(resolver).toHaveBeenCalledWith(agentId, tenantId, [ - 'api://9b975845-388f-4429-889e-eab1ef63949c/.default', - ]); - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - const calls = getFetchCalls(); - expect(calls).toHaveLength(1); - expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - expect(calls[0][1].headers['authorization']).toBe(`Bearer ${token}`); - }); - - it('defaults the legacy endpoint option to S2S', () => { - expect(new Agent365ExporterOptions().useS2SEndpoint).toBe(true); - }); - - it.each([undefined, false, true])('always uses S2S when useS2SEndpoint is %s', async (useS2SEndpoint) => { - mockFetchSequence([200]); - const token = 'tok-s2s'; - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'prod'; - opts.tokenResolver = () => token; - if (useS2SEndpoint !== undefined) { - opts.useS2SEndpoint = useS2SEndpoint; - } - - const exporter = new Agent365Exporter(opts); - const spans = [ - makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId - }, 's2s-span') - ]; - - const callback = jest.fn(); - await exporter.export(spans, callback); - - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - const fetchCalls = getFetchCalls(); - expect(fetchCalls.length).toBe(1); - - const urlArg = fetchCalls[0][0] as string; - expect(urlArg).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - const headersArg = fetchCalls[0][1].headers as Record; - expect(headersArg['authorization']).toBe(`Bearer ${token}`); - expect(headersArg['x-ms-tenant-id']).toBe(tenantId); - }); - - it.each([false, true])('uses S2S with a domain override and legacy option %s', async (useS2SEndpoint) => { - mockFetchSequence([200]); - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://custom.domain'; - const token = 'tok-s2s-custom'; - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'prod'; - opts.tokenResolver = () => token; - opts.useS2SEndpoint = useS2SEndpoint; - - const exporter = new Agent365Exporter(opts); - const spans = [ - makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId - }, 's2s-custom-span') - ]; - - const callback = jest.fn(); - await exporter.export(spans, callback); - - const fetchCalls = getFetchCalls(); - expect(fetchCalls.length).toBe(1); - const urlArg = fetchCalls[0][0] as string; - expect(urlArg).toBe(`https://custom.domain/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - const headersArg = fetchCalls[0][1].headers as Record; - expect(headersArg['authorization']).toBe(`Bearer ${token}`); - expect(headersArg['x-ms-tenant-id']).toBe(tenantId); - }); - - - it.each([401, 403, 404])('does not fall back to OBO when S2S returns %s', async (status) => { - mockFetchSequence([status]); - const opts = new Agent365ExporterOptions(); - opts.tokenResolver = () => 'test-obs-token'; - opts.useS2SEndpoint = false; - const exporter = new Agent365Exporter(opts); - const callback = jest.fn(); - - await exporter.export([makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, - })], callback); - - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); - const calls = getFetchCalls(); - expect(calls).toHaveLength(1); - expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - }); - - it('passes httpRequestTimeoutMilliseconds to fetch AbortSignal.timeout', async () => { - const customTimeout = 12345; - mockFetchSequence([200]); - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; - opts.tokenResolver = () => 'tok'; - opts.httpRequestTimeoutMilliseconds = customTimeout; - - const timeoutSpy = jest.spyOn(AbortSignal, 'timeout'); - const exporter = new Agent365Exporter(opts); - const spans = [ - makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId - }) - ]; - - const callback = jest.fn(); - await exporter.export(spans, callback); - - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - expect(timeoutSpy).toHaveBeenCalledWith(customTimeout); - timeoutSpy.mockRestore(); - }); - - it('exports ApplyGuardrailScope span with all guardrail attributes and finding event', async () => { - mockFetchSequence([200]); - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; - opts.tokenResolver = () => 'tok-guardrail'; - const exporter = new Agent365Exporter(opts); - - const spans = [ - { - ...makeSpan({ - [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: 'apply_guardrail', - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, - [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]: 'Guardrail Agent', - // Guardian attributes - ['microsoft.guardian.id']: 'azure-content-safety-001', - ['microsoft.guardian.name']: 'Azure Content Safety', - ['microsoft.guardian.provider.name']: 'Azure', - ['microsoft.guardian.version']: '2.0.0', - // Decision attributes - ['microsoft.security.decision.type']: 'deny', - ['microsoft.security.target.type']: 'llm_input', - ['microsoft.security.target.id']: 'msg-12345', - ['microsoft.security.decision.reason']: 'Content violates hate speech policy', - ['microsoft.security.decision.code']: 'HATE_SPEECH_001', - // Policy attributes - ['microsoft.security.policy.id']: 'policy-abc', - ['microsoft.security.policy.name']: 'Content Safety Policy', - ['microsoft.security.policy.version']: '1.2.0', - // Content attributes - ['microsoft.security.content.input.hash']: 'sha256:abc123def456', - ['microsoft.security.content.modified']: false, - ['microsoft.security.content.output.value']: 'sanitized-hash-output', - ['microsoft.security.external_event_id']: 'ext-event-789', - }, 'apply_guardrail Azure Content Safety llm_input'), - events: [{ - name: 'microsoft.security.finding', - time: [Math.floor(Date.now() / 1000), 0], - attributes: { - ['microsoft.security.risk.category']: 'hate_speech', - ['microsoft.security.risk.severity']: 'high', - ['microsoft.security.policy.decision.type']: 'deny', - ['microsoft.security.policy.id']: 'policy-abc', - ['microsoft.security.risk.score']: 0.95, - }, - }], - } as unknown as ReadableSpan, - ]; - - const callback = jest.fn(); - await exporter.export(spans, callback); - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - - const fetchCalls = getFetchCalls(); - expect(fetchCalls.length).toBe(1); - - const bodyJson = JSON.parse(fetchCalls[0][1].body as string); - const exportedSpan = bodyJson.resourceSpans[0].scopeSpans[0].spans[0]; - - // Verify span name - expect(exportedSpan.name).toBe('apply_guardrail Azure Content Safety llm_input'); - - // Verify guardrail attributes survived export - expect(exportedSpan.attributes['microsoft.guardian.id']).toBe('azure-content-safety-001'); - expect(exportedSpan.attributes['microsoft.guardian.name']).toBe('Azure Content Safety'); - expect(exportedSpan.attributes['microsoft.security.decision.type']).toBe('deny'); - expect(exportedSpan.attributes['microsoft.security.target.type']).toBe('llm_input'); - expect(exportedSpan.attributes['microsoft.security.policy.id']).toBe('policy-abc'); - expect(exportedSpan.attributes['microsoft.security.content.input.hash']).toBe('sha256:abc123def456'); - expect(exportedSpan.attributes['microsoft.security.content.modified']).toBe(false); - expect(exportedSpan.attributes['microsoft.security.content.output.value']).toBe('sanitized-hash-output'); - expect(exportedSpan.attributes['microsoft.security.external_event_id']).toBe('ext-event-789'); - - // Verify finding event survived export - expect(exportedSpan.events).toHaveLength(1); - expect(exportedSpan.events[0].name).toBe('microsoft.security.finding'); - expect(exportedSpan.events[0].attributes['microsoft.security.risk.category']).toBe('hate_speech'); - expect(exportedSpan.events[0].attributes['microsoft.security.risk.severity']).toBe('high'); - expect(exportedSpan.events[0].attributes['microsoft.security.risk.score']).toBe(0.95); - }); - - describe('truncateSpan (span-level size enforcement)', () => { - const MAX_SPAN_SIZE_BYTES = 250 * 1024; - - /** Export a single span and return its attributes from the serialized payload. */ - async function exportAndGetAttributes(attrs: Record) { - mockFetchSequence([200]); - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; - opts.tokenResolver = () => 'tok'; - const exporter = new Agent365Exporter(opts); - - const spans = [makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, - ...attrs, - })]; - - const callback = jest.fn(); - await exporter.export(spans, callback); - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - - const body = JSON.parse(getFetchCalls()[0][1].body as string); - const exportedSpan = body.resourceSpans[0].scopeSpans[0].spans[0]; - return { exportedSpan, spans }; - } - - it('should preserve all attribute types when span is within size limit', async () => { - const { exportedSpan } = await exportAndGetAttributes({ - 'string_attr': 'hello', - 'number_attr': 42, - 'boolean_attr': true, - 'string_array_attr': ['a', 'b', 'c'], - 'number_array_attr': [1, 2, 3], - }); - - expect(exportedSpan.attributes['string_attr']).toBe('hello'); - expect(exportedSpan.attributes['number_attr']).toBe(42); - expect(exportedSpan.attributes['boolean_attr']).toBe(true); - expect(exportedSpan.attributes['string_array_attr']).toEqual(['a', 'b', 'c']); - expect(exportedSpan.attributes['number_array_attr']).toEqual([1, 2, 3]); - }); - - it('should trim oversized string attribute and preserve all smaller attribute types', async () => { - const { exportedSpan } = await exportAndGetAttributes({ - 'small_string': 'keep me', - 'small_number': 123, - 'small_boolean': false, - 'small_array': ['x', 'y'], - 'large_string': 'x'.repeat(MAX_SPAN_SIZE_BYTES), - }); - - expect(exportedSpan.attributes['large_string']).toContain('… [truncated]'); - expect(exportedSpan.attributes['large_string'].length).toBeLessThan(MAX_SPAN_SIZE_BYTES); - expect(exportedSpan.attributes['small_string']).toBe('keep me'); - expect(exportedSpan.attributes['small_number']).toBe(123); - expect(exportedSpan.attributes['small_boolean']).toBe(false); - expect(exportedSpan.attributes['small_array']).toEqual(['x', 'y']); - }); - - it('should trim both large string attributes when each alone exceeds limit', async () => { - const { exportedSpan } = await exportAndGetAttributes({ - 'gen_ai.input.messages': 'a'.repeat(MAX_SPAN_SIZE_BYTES), - 'gen_ai.output.messages': 'b'.repeat(MAX_SPAN_SIZE_BYTES), - 'small_attr': 'keep me', - }); - - // Both are plain strings (not valid JSON messages), so they get trimmed as regular strings - expect(exportedSpan.attributes['gen_ai.input.messages']).toContain('… [truncated]'); - expect(exportedSpan.attributes['gen_ai.output.messages']).toContain('… [truncated]'); - expect(exportedSpan.attributes['small_attr']).toBe('keep me'); - }); - - it('should not mutate the original ReadableSpan attributes', async () => { - const largeValue = 'x'.repeat(MAX_SPAN_SIZE_BYTES); - const { spans } = await exportAndGetAttributes({ - 'gen_ai.input.messages': largeValue, - }); - - expect(spans[0].attributes['gen_ai.input.messages']).toBe(largeValue); - }); - - it('should guarantee exported span is within 250KB after truncation', async () => { - const largeValue = 'x'.repeat(MAX_SPAN_SIZE_BYTES); - const { exportedSpan } = await exportAndGetAttributes({ - 'gen_ai.input.messages': largeValue, - 'gen_ai.output.messages': largeValue, - }); - - const spanSize = Buffer.byteLength(JSON.stringify(exportedSpan), 'utf8'); - expect(spanSize).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - }); - - it('should shrink blob content in message attributes with sentinel', async () => { - const largeBlobContent = 'x'.repeat(MAX_SPAN_SIZE_BYTES); // >50KB blob, span will exceed limit - const messageWrapper = JSON.stringify([{ - role: 'user', - parts: [ - { type: 'blob', modality: 'image', mime_type: 'image/png', content: largeBlobContent }, - { type: 'text', content: 'Keep this text' }, - ] - }]); - const { exportedSpan } = await exportAndGetAttributes({ - 'gen_ai.input.messages': messageWrapper, - 'small_attr': 'keep me', - }); - - const parsed = JSON.parse(exportedSpan.attributes['gen_ai.input.messages'] as string); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].parts[0].content).toBe('[blob truncated]'); - expect(parsed[0].parts[1].content).toBe('Keep this text'); - expect(exportedSpan.attributes['small_attr']).toBe('keep me'); - }); - - it('should shrink tool_call arguments with sentinel in message attributes', async () => { - const largeArgs = { data: 'x'.repeat(MAX_SPAN_SIZE_BYTES) }; - const messageWrapper = JSON.stringify([{ - role: 'assistant', - parts: [ - { type: 'tool_call', name: 'search', id: 'call_1', arguments: largeArgs }, - { type: 'text', content: 'short text' }, - ] - }]); - const { exportedSpan } = await exportAndGetAttributes({ - 'gen_ai.input.messages': messageWrapper, - }); - - const parsed = JSON.parse(exportedSpan.attributes['gen_ai.input.messages'] as string); - expect(parsed[0].parts[0].arguments).toBe('[truncated]'); - expect(parsed[0].parts[0].name).toBe('search'); - expect(parsed[0].parts[1].content).toBe('short text'); - }); - - it('should trim text content in message attributes when oversized', async () => { - const largeText = 'y'.repeat(MAX_SPAN_SIZE_BYTES); - const messageWrapper = JSON.stringify([{ - role: 'user', - parts: [{ type: 'text', content: largeText }] - }]); - const { exportedSpan } = await exportAndGetAttributes({ - 'gen_ai.input.messages': messageWrapper, - }); - - const parsed = JSON.parse(exportedSpan.attributes['gen_ai.input.messages'] as string); - expect(parsed[0].parts[0].content).toContain('… [truncated]'); - expect(parsed[0].parts[0].content.length).toBeLessThan(largeText.length); - const spanSize = Buffer.byteLength(JSON.stringify(exportedSpan), 'utf8'); - expect(spanSize).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - }); - - it('should trim utf8 text content without splitting code points', () => { - const largeEmojiText = '🙂'.repeat(90 * 1024); - const messageWrapper = JSON.stringify([{ - role: 'user', - parts: [{ type: 'text', content: largeEmojiText }] - }]); - - const span = { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name: 'test', - kind: 'INTERNAL', - startTimeUnixNano: 0, - endTimeUnixNano: 1, - attributes: { - 'gen_ai.input.messages': messageWrapper, - 'other_large_text': 'x'.repeat(MAX_SPAN_SIZE_BYTES), - } as Record, - status: { code: 'UNSET' }, - }; - - const result = truncateSpan(span); - const parsed = JSON.parse(result.attributes!['gen_ai.input.messages'] as string); - const trimmedContent = parsed[0].parts[0].content as string; - expect(trimmedContent).toContain('… [truncated]'); - - const prefix = trimmedContent.slice(0, -'… [truncated]'.length); - expect(Array.from(prefix).every((codePoint) => codePoint === '🙂')).toBe(true); - expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - }); - - it('should handle span with null attributes without error', async () => { - const span = { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name: 'test', - kind: 'INTERNAL', - startTimeUnixNano: 0, - endTimeUnixNano: 1, - attributes: null, - status: { code: 'UNSET' }, - }; - - const result = truncateSpan(span); - expect(result.attributes).toBeNull(); - }); - - it('should handle non-serializable attribute values gracefully', async () => { - // Build an OTLPSpan directly with a non-serializable value (BigInt) - const span = { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name: 'test', - kind: 'INTERNAL', - startTimeUnixNano: 0, - endTimeUnixNano: 1, - attributes: { - 'normal_attr': 'hello', - 'bigint_attr': BigInt(999), - } as Record, - status: { code: 'UNSET' }, - }; - - // truncateSpan should catch the JSON.stringify failure and return span unchanged - const result = truncateSpan(span); - expect(result.attributes['normal_attr']).toBe('hello'); - expect(result.attributes['bigint_attr']).toBe(BigInt(999)); - }); - - it('should shrink tool_call_response response with sentinel in message attributes', async () => { - const largeResponse = { data: 'x'.repeat(MAX_SPAN_SIZE_BYTES) }; - const messageWrapper = JSON.stringify([{ - role: 'tool', - parts: [ - { type: 'tool_call_response', id: 'call_1', response: largeResponse }, - { type: 'text', content: 'short text' }, - ] - }]); - const { exportedSpan } = await exportAndGetAttributes({ - 'gen_ai.input.messages': messageWrapper, - }); - - const parsed = JSON.parse(exportedSpan.attributes['gen_ai.input.messages'] as string); - expect(parsed[0].parts[0].response).toBe('[truncated]'); - expect(parsed[0].parts[0].id).toBe('call_1'); - expect(parsed[0].parts[1].content).toBe('short text'); - }); - - it('should shrink server_tool_call payload with sentinel in message attributes', async () => { - const largePayload = { type: 'web_search', query: 'x'.repeat(MAX_SPAN_SIZE_BYTES) }; - const messageWrapper = JSON.stringify([{ - role: 'assistant', - parts: [ - { type: 'server_tool_call', name: 'web_search', id: 'stc_1', server_tool_call: largePayload }, - { type: 'text', content: 'keep me' }, - ] - }]); - const { exportedSpan } = await exportAndGetAttributes({ - 'gen_ai.input.messages': messageWrapper, - }); - - const parsed = JSON.parse(exportedSpan.attributes['gen_ai.input.messages'] as string); - expect(parsed[0].parts[0].server_tool_call).toBe('[truncated]'); - expect(parsed[0].parts[0].name).toBe('web_search'); - expect(parsed[0].parts[1].content).toBe('keep me'); - }); - - it('should shrink server_tool_call_response payload with sentinel in message attributes', async () => { - const largePayload = { type: 'web_search_result', results: 'x'.repeat(MAX_SPAN_SIZE_BYTES) }; - const messageWrapper = JSON.stringify([{ - role: 'tool', - parts: [ - { type: 'server_tool_call_response', id: 'stc_1', server_tool_call_response: largePayload }, - { type: 'text', content: 'keep me' }, - ] - }]); - const { exportedSpan } = await exportAndGetAttributes({ - 'gen_ai.input.messages': messageWrapper, - }); - - const parsed = JSON.parse(exportedSpan.attributes['gen_ai.input.messages'] as string); - expect(parsed[0].parts[0].server_tool_call_response).toBe('[truncated]'); - expect(parsed[0].parts[0].id).toBe('stc_1'); - expect(parsed[0].parts[1].content).toBe('keep me'); - }); - - it('should shrink blobs alongside other fields by size priority', () => { - const blobSize = 40 * 1024; // 40KB each - const numBlobs = 8; // 8 * 40KB = 320KB > 250KB limit - const blobParts = Array.from({ length: numBlobs }, (_, _i) => ({ - type: 'blob' as const, - modality: 'image', - mime_type: 'image/png', - content: 'x'.repeat(blobSize), - })); - // Add a text part — blobs are larger so they should be shrunk first - const textPart = { type: 'text' as const, content: 'y'.repeat(1024) }; - const messageWrapper = JSON.stringify( - [{ role: 'user', parts: [...blobParts, textPart] }], - ); - - const span = { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name: 'test', - kind: 'INTERNAL', - startTimeUnixNano: 0, - endTimeUnixNano: 1, - attributes: { - 'gen_ai.input.messages': messageWrapper, - } as Record, - status: { code: 'UNSET' }, - }; - - const result = truncateSpan(span); - const resultSize = Buffer.byteLength(JSON.stringify(result), 'utf8'); - expect(resultSize).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - - const parsed = JSON.parse(result.attributes!['gen_ai.input.messages'] as string); - const sentinelCount = parsed[0].parts - .filter((p: Record) => p.type === 'blob' && p.content === '[blob truncated]') - .length; - expect(sentinelCount).toBeGreaterThan(0); - expect(sentinelCount).toBeLessThanOrEqual(numBlobs); - }); - - it('should repeatedly shrink regular candidates by size priority until the span fits', () => { - const regularParts = [ - { type: 'text', content: 'a'.repeat(100 * 1024) }, - { type: 'reasoning', content: 'b'.repeat(100 * 1024) }, - { type: 'text', content: 'c'.repeat(100 * 1024) }, - { type: 'reasoning', content: 'd'.repeat(100 * 1024) }, - ]; - const messageWrapper = JSON.stringify([{ - role: 'user', - parts: regularParts, - }]); - - const span = { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name: 'test', - kind: 'INTERNAL', - startTimeUnixNano: 0, - endTimeUnixNano: 1, - attributes: { - 'gen_ai.input.messages': messageWrapper, - } as Record, - status: { code: 'UNSET' }, - }; - - const result = truncateSpan(span); - const resultSize = Buffer.byteLength(JSON.stringify(result), 'utf8'); - expect(resultSize).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - - const parsed = JSON.parse(result.attributes!['gen_ai.input.messages'] as string); - const truncatedCount = parsed[0].parts - .filter((part: Record) => typeof part.content === 'string' && (part.content as string).includes('… [truncated]')) - .length; - expect(truncatedCount).toBeGreaterThan(1); - }); - - it('should not throw when shrink actions are exhausted and span still exceeds limit', () => { - // Create a span where non-string/non-message attributes make it huge and unshrinkable. - // Use number arrays which are not shrinkable by the shrinker. - const hugeArray = new Array(100000).fill(42); - const span = { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name: 'test', - kind: 'INTERNAL', - startTimeUnixNano: 0, - endTimeUnixNano: 1, - attributes: { - 'non_shrinkable_1': hugeArray, - 'non_shrinkable_2': hugeArray, - 'small_string': 'hello', - } as Record, - status: { code: 'UNSET' }, - }; - - // Should not throw - const result = truncateSpan(span); - // The span is still returned even though it exceeds the limit - expect(result.attributes).toBeDefined(); - // Phase 2 fallback replaces string attributes with overlimit sentinel - expect(result.attributes!['small_string']).toBe('[overlimit]'); - // The result may still exceed MAX_SPAN_SIZE_BYTES since there are no shrinkable attributes - const resultSize = Buffer.byteLength(JSON.stringify(result), 'utf8'); - expect(resultSize).toBeGreaterThan(MAX_SPAN_SIZE_BYTES); - }); - - it('should only trim the excess bytes, preserving as much content as possible', () => { - // A single large text part that slightly exceeds the limit. - // After trimming, most of the content should be preserved. - - const textSize = MAX_SPAN_SIZE_BYTES + 5000; // only ~5KB over - const messageWrapper = JSON.stringify([{ - role: 'user', - parts: [{ type: 'text', content: 'x'.repeat(textSize) }] - }]); - - const span = { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name: 'test', - kind: 'INTERNAL', - startTimeUnixNano: 0, - endTimeUnixNano: 1, - attributes: { - 'gen_ai.input.messages': messageWrapper, - } as Record, - status: { code: 'UNSET' }, - }; - - const result = truncateSpan(span); - const parsed = JSON.parse(result.attributes!['gen_ai.input.messages'] as string); - const trimmedContent = parsed[0].parts[0].content as string; - - expect(trimmedContent).toContain('… [truncated]'); - // The trimmed content should retain most of the original — at least 90% - const trimmedLength = Buffer.byteLength(trimmedContent, 'utf8'); - expect(trimmedLength).toBeGreaterThan(textSize * 0.9); - expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - }); - - it('should leave other fields untouched when trimming the largest field is sufficient', () => { - // Two text fields: one very large (~300KB), one medium (~50KB). - // Only the largest should be trimmed; the medium one should be preserved intact. - const largeContent = 'L'.repeat(300 * 1024); - const mediumContent = 'M'.repeat(50 * 1024); - const messageWrapper = JSON.stringify([{ - role: 'user', - parts: [ - { type: 'text', content: largeContent }, - { type: 'text', content: mediumContent }, - ] - }]); - - const span = { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name: 'test', - kind: 'INTERNAL', - startTimeUnixNano: 0, - endTimeUnixNano: 1, - attributes: { - 'gen_ai.input.messages': messageWrapper, - } as Record, - status: { code: 'UNSET' }, - }; - - const result = truncateSpan(span); - const parsed = JSON.parse(result.attributes!['gen_ai.input.messages'] as string); - - // The large field was trimmed - expect((parsed[0].parts[0].content as string)).toContain('… [truncated]'); - // The medium field should be fully preserved - expect(parsed[0].parts[1].content).toBe(mediumContent); - expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - }); - - it('should skip strings shorter than 50 bytes during truncation', () => { - // Many small strings (under 50 bytes each) plus one huge non-shrinkable array. - // The small strings should not be trimmed (only replaced in phase 4 fallback). - const span = { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name: 'test', - kind: 'INTERNAL', - startTimeUnixNano: 0, - endTimeUnixNano: 1, - attributes: { - 'short_a': 'a'.repeat(49), // 49 bytes — under threshold - 'short_b': 'b'.repeat(30), - 'large_string': 'x'.repeat(MAX_SPAN_SIZE_BYTES), - } as Record, - status: { code: 'UNSET' }, - }; - - const result = truncateSpan(span); - // short strings should be preserved (not truncated via phase 1) - expect(result.attributes!['short_a']).toBe('a'.repeat(49)); - expect(result.attributes!['short_b']).toBe('b'.repeat(30)); - // large string gets trimmed - expect((result.attributes!['large_string'] as string)).toContain('… [truncated]'); - expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - }); - - it('should only remove enough blobs to fit under the limit', () => { - // 6 blobs of 45KB each = 270KB > 250KB limit. - // Shrink phase should replace blobs one at a time until under limit. - const blobSize = 45 * 1024; - const numBlobs = 6; - const blobParts = Array.from({ length: numBlobs }, () => ({ - type: 'blob' as const, - modality: 'image', - mime_type: 'image/png', - content: 'x'.repeat(blobSize), - })); - const messageWrapper = JSON.stringify( - [{ role: 'user', parts: blobParts }], - ); - - const span = { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name: 'test', - kind: 'INTERNAL', - startTimeUnixNano: 0, - endTimeUnixNano: 1, - attributes: { - 'gen_ai.input.messages': messageWrapper, - } as Record, - status: { code: 'UNSET' }, - }; - - const result = truncateSpan(span); - const resultSize = Buffer.byteLength(JSON.stringify(result), 'utf8'); - expect(resultSize).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - - const parsed = JSON.parse(result.attributes!['gen_ai.input.messages'] as string); - const sentinelCount = parsed[0].parts - .filter((p: Record) => p.content === '[blob truncated]').length; - const preservedCount = parsed[0].parts - .filter((p: Record) => p.content !== '[blob truncated]').length; - // Some blobs should be preserved — not all replaced - expect(sentinelCount).toBeGreaterThan(0); - expect(preservedCount).toBeGreaterThan(0); - }); - - it('should use structured overflow sentinel for message attributes in phase 4 fallback', () => { - // Make the span exceed the limit with non-shrinkable arrays, plus a message attribute - // that phase 1 cannot shrink enough. Phase 2 should replace it with the overflow sentinel. - const hugeArray = new Array(100000).fill(42); - const messageWrapper = JSON.stringify([ - { role: 'user', parts: [{ type: 'text', content: 'hello user' }] }, - { role: 'assistant', parts: [{ type: 'text', content: 'hello back' }] }, - { role: 'user', parts: [{ type: 'text', content: 'another msg' }] }, - ]); - - const span = { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name: 'test', - kind: 'INTERNAL', - startTimeUnixNano: 0, - endTimeUnixNano: 1, - attributes: { - 'non_shrinkable': hugeArray, - 'gen_ai.input.messages': messageWrapper, - } as Record, - status: { code: 'UNSET' }, - }; - - const result = truncateSpan(span); - // The message attribute should be replaced with the overflow sentinel - const sentinelValue = result.attributes!['gen_ai.input.messages'] as string; - const parsed = JSON.parse(sentinelValue); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(1); - expect(parsed[0].role).toBe('system'); - expect(parsed[0].parts[0].type).toBe('text'); - expect(parsed[0].parts[0].content).toBe('[truncated: 3 messages exceeded limit]'); - }); - - it('should replace oversized raw dict in gen_ai.output.messages with overlimit sentinel', () => { - // Raw dict plus other large attr to push span over 250KB limit - const rawDict = JSON.stringify({ result: 'x'.repeat(200 * 1024) }); - const span = makeSpan({ - 'gen_ai.output.messages': rawDict, - 'other_large': 'y'.repeat(100 * 1024), - }); - - const result = truncateSpan(span); - // Raw dict (no version field) should get OVERLIMIT_SENTINEL, not message-aware shrinking - expect(result.attributes!['gen_ai.output.messages']).toBe('[overlimit]'); - expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - }); - - it('should replace oversized dict (not an array) with overlimit sentinel', () => { - const dictWithMessages = JSON.stringify({ messages: ['y'.repeat(200 * 1024)], type: 'tool_result' }); - const span = makeSpan({ - 'gen_ai.output.messages': dictWithMessages, - 'other_large': 'z'.repeat(100 * 1024), - }); - - const result = truncateSpan(span); - // Object (not an array) should NOT be treated as message array format - expect(result.attributes!['gen_ai.output.messages']).toBe('[overlimit]'); - expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - }); - - it('should preserve small raw dict in gen_ai.output.messages when span is within limit', () => { - const smallDict = JSON.stringify({ result: 'ok', count: 42 }); - const span = makeSpan({ - 'gen_ai.output.messages': smallDict, - 'gen_ai.agent.id': 'test-agent', - }); - - const result = truncateSpan(span); - // Small dict should be preserved as-is - expect(result.attributes!['gen_ai.output.messages']).toBe(smallDict); - }); - - it('should still use message-aware shrinking for array wrapper in gen_ai.output.messages', () => { - const messageWrapper = JSON.stringify([{ - role: 'assistant', - parts: [{ type: 'text', content: 'z'.repeat(200 * 1024) }], - }]); - const span = makeSpan({ - 'gen_ai.output.messages': messageWrapper, - 'other_large': 'a'.repeat(100 * 1024), - }); - - const result = truncateSpan(span); - const output = result.attributes!['gen_ai.output.messages'] as string; - // Array wrapper should get message-aware shrinking (trimmed text), not sentinel - expect(output).not.toBe('[overlimit]'); - const parsed = JSON.parse(output); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].parts[0].type).toBe('text'); - // Text content should be trimmed (shorter than original) - expect(parsed[0].parts[0].content.length).toBeLessThan(200 * 1024); - expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThanOrEqual(MAX_SPAN_SIZE_BYTES); - }); - }); - - describe('per-request app-only export', () => { - let contextManager: AsyncLocalStorageContextManager | undefined; - - beforeEach(() => { - // Ensure OpenTelemetry context propagates across async/await in this group. - contextManager = new AsyncLocalStorageContextManager(); - contextManager.enable(); - otelContext.setGlobalContextManager(contextManager); - }); - - afterEach(() => { - contextManager?.disable(); - otelContext.disable(); - contextManager = undefined; - }); - - it.each([ - { useS2SEndpoint: false, roles: undefined }, - { useS2SEndpoint: true, roles: [] }, - ])('uses its app-only resolver with legacy option $useS2SEndpoint and ignores delegated context', async ({ useS2SEndpoint, roles }) => { - mockFetchSequence([200]); - process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; - - const opts = new Agent365ExporterOptions(); - opts.clusterCategory = 'local'; - opts.useS2SEndpoint = useS2SEndpoint; - const exportToken = makeToken({ - idtyp: 'app', tid: tenantId, azp: agentId, roles, - aud: '9b975845-388f-4429-889e-eab1ef63949c', - exp: Math.floor(Date.now() / 1000) + 300, - }); - const resolver = jest.fn(async (_agentId: string, _tenantId: string) => exportToken); - opts.tokenResolver = resolver; - - const exporter = new Agent365Exporter(opts); - const spans = [ - makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId - }) - ]; - - const callback = jest.fn(); - const delegatedToken = makeToken({ scp: 'User.Read', idtyp: 'user', tid: tenantId }); - await runWithExportToken(delegatedToken, async () => exporter.export(spans, callback)); - - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - expect(resolver).toHaveBeenCalledTimes(1); - expect(resolver).toHaveBeenCalledWith(agentId, tenantId); - - const fetchCalls = getFetchCalls(); - expect(fetchCalls).toHaveLength(1); - expect(fetchCalls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - - // The workload's delegated context token must not become an OBS credential. - const headersArg = fetchCalls[0][1].headers as Record; - expect(headersArg['authorization']).toBe(`Bearer ${exportToken}`); - }); - - it.each([401, 403, 404])('does not fall back from per-request S2S after HTTP %s', async (status) => { - mockFetchSequence([status]); - process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; - const opts = new Agent365ExporterOptions(); - opts.useS2SEndpoint = false; - const resolver = jest.fn(async () => makeToken({ idtyp: 'app', tid: tenantId, azp: agentId })); - opts.tokenResolver = resolver; - const exporter = new Agent365Exporter(opts); - const callback = jest.fn(); - - await runWithExportToken(makeToken({ scp: 'User.Read', idtyp: 'user' }), async () => exporter.export([makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, - })], callback)); - - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); - expect(resolver).toHaveBeenCalledTimes(1); - const calls = getFetchCalls(); - expect(calls).toHaveLength(1); - expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - }); - - it('exports with the app-only resolver when no context token exists', async () => { - mockFetchSequence([200]); - process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; - const opts = new Agent365ExporterOptions(); - const resolver = jest.fn(() => makeToken({ idtyp: 'app', tid: tenantId, azp: agentId })); - opts.tokenResolver = resolver; - const exporter = new Agent365Exporter(opts); - const callback = jest.fn(); - - await exporter.export([makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, - })], callback); - - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); - expect(resolver).toHaveBeenCalledTimes(1); - expect(getFetchCalls()).toHaveLength(1); - }); - - it('requires an app-only resolver even when a context token is present', () => { - mockFetchSequence([200]); - process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; - - runWithExportToken(makeToken({ scp: 'User.Read' }), () => { - expect(() => new Agent365Exporter(new Agent365ExporterOptions())) - .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/); - }); - expect(getFetchCalls()).toHaveLength(0); - }); - - it.each(['missing', 'failed'])('does not use a delegated context token after %s app-only acquisition', async (failure) => { - mockFetchSequence([200]); - process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; - const opts = new Agent365ExporterOptions(); - const resolver = jest.fn(async () => { - if (failure === 'failed') throw new Error('App-only acquisition failed'); - return null; - }); - opts.tokenResolver = resolver; - const exporter = new Agent365Exporter(opts); - const callback = jest.fn(); - - await runWithExportToken(makeToken({ scp: 'User.Read' }), async () => exporter.export([makeSpan({ - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, - })], callback)); - - expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); - expect(resolver).toHaveBeenCalledTimes(1); - expect(getFetchCalls()).toHaveLength(0); - }); - }); -}); diff --git a/tests/observability/core/apply-guardrail-scope.test.ts b/tests/observability/core/apply-guardrail-scope.test.ts deleted file mode 100644 index eeebe835..00000000 --- a/tests/observability/core/apply-guardrail-scope.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeAll, afterAll, afterEach, jest } from '@jest/globals'; -import { trace, SpanKind } from '@opentelemetry/api'; - -import { - ApplyGuardrailScope, - AgentDetails, - OpenTelemetryConstants, - GuardrailDecisionType, - GuardrailDetails, - GuardrailRiskSeverity, - GuardrailTargetType, -} from '@microsoft/agents-a365-observability'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base'; - -let sharedExporter: InMemorySpanExporter; - -const originalConsoleWarn = console.warn; -const originalConsoleError = console.error; -beforeAll(() => { - sharedExporter = new InMemorySpanExporter(); - const processor = new SimpleSpanProcessor(sharedExporter); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const globalProvider: any = trace.getTracerProvider(); - if (globalProvider && typeof globalProvider.addSpanProcessor === 'function') { - globalProvider.addSpanProcessor(processor); - } else { - const provider = new BasicTracerProvider({ - spanProcessors: [processor], - }); - trace.setGlobalTracerProvider(provider); - } - - console.warn = jest.fn(); - console.error = jest.fn(); -}); - -afterAll(() => { - console.warn = originalConsoleWarn; - console.error = originalConsoleError; -}); - -describe('ApplyGuardrailScope', () => { - const testAgentDetails: AgentDetails = { - agentId: 'guardrail-agent', - agentName: 'Guardrail Agent', - agentDescription: 'Agent for guardrail testing', - tenantId: 'test-tenant-456', - }; - - afterEach(() => { - sharedExporter.reset(); - }); - - const getFinishedSpan = (): ReadableSpan => { - const spans = sharedExporter.getFinishedSpans(); - expect(spans.length).toBeGreaterThanOrEqual(1); - return spans[spans.length - 1]; - }; - - it('should use SpanKind.INTERNAL and build span name with guardian name', () => { - const scope = ApplyGuardrailScope.start( - { targetType: GuardrailTargetType.LlmInput, decisionType: GuardrailDecisionType.Allow, guardianName: 'Content Safety' }, - testAgentDetails, - ); - scope.dispose(); - - const span = getFinishedSpan(); - expect(span.kind).toBe(SpanKind.INTERNAL); - expect(span.name).toBe('apply_guardrail Content Safety llm_input'); - }); - - it('should omit guardian name from span name when not provided', () => { - const scope = ApplyGuardrailScope.start( - { targetType: GuardrailTargetType.LlmOutput, decisionType: GuardrailDecisionType.Deny }, - testAgentDetails, - ); - scope.dispose(); - - expect(getFinishedSpan().name).toBe('apply_guardrail llm_output'); - }); - - it('should allow spanKind override via spanDetails', () => { - const scope = ApplyGuardrailScope.start( - { targetType: GuardrailTargetType.LlmInput, decisionType: GuardrailDecisionType.Allow }, - testAgentDetails, - undefined, - undefined, - { spanKind: SpanKind.CLIENT }, - ); - scope.dispose(); - - expect(getFinishedSpan().kind).toBe(SpanKind.CLIENT); - }); - - it('should throw when agentDetails.tenantId is missing', () => { - expect(() => ApplyGuardrailScope.start( - { targetType: GuardrailTargetType.LlmInput, decisionType: GuardrailDecisionType.Allow }, - { agentId: 'a' } as any, - )).toThrow('ApplyGuardrailScope: tenantId is required on agentDetails'); - }); - - it('should accept custom targetType strings', () => { - const scope = ApplyGuardrailScope.start( - { targetType: 'custom_target', decisionType: GuardrailDecisionType.Allow }, - testAgentDetails, - ); - scope.dispose(); - - expect(getFinishedSpan().attributes[OpenTelemetryConstants.SECURITY_TARGET_TYPE_KEY]).toBe('custom_target'); - }); - - it('should record contentModified=false without being swallowed by null-check', () => { - const scope = ApplyGuardrailScope.start( - { targetType: GuardrailTargetType.LlmInput, decisionType: GuardrailDecisionType.Deny, contentModified: false }, - testAgentDetails, - ); - scope.dispose(); - - expect(getFinishedSpan().attributes[OpenTelemetryConstants.SECURITY_CONTENT_MODIFIED_KEY]).toBe(false); - }); - - it('recordDecision should overwrite decision type and set reason', () => { - const scope = ApplyGuardrailScope.start( - { targetType: GuardrailTargetType.LlmInput, decisionType: GuardrailDecisionType.Allow }, - testAgentDetails, - ); - scope.recordDecision(GuardrailDecisionType.Deny, 'Prompt injection detected'); - scope.dispose(); - - const attrs = getFinishedSpan().attributes; - expect(attrs[OpenTelemetryConstants.SECURITY_DECISION_TYPE_KEY]).toBe('deny'); - expect(attrs[OpenTelemetryConstants.SECURITY_DECISION_REASON_KEY]).toBe('Prompt injection detected'); - }); - - it('recordContentOutput should set output value attribute', () => { - const scope = ApplyGuardrailScope.start( - { targetType: GuardrailTargetType.LlmOutput, decisionType: GuardrailDecisionType.Modify }, - testAgentDetails, - ); - scope.recordContentOutput('Redacted content'); - scope.dispose(); - - expect(getFinishedSpan().attributes[OpenTelemetryConstants.SECURITY_CONTENT_OUTPUT_VALUE_KEY]).toBe('Redacted content'); - }); - - it('recordFinding should emit event with all finding attributes', () => { - const scope = ApplyGuardrailScope.start( - { targetType: GuardrailTargetType.LlmInput, decisionType: GuardrailDecisionType.Deny }, - testAgentDetails, - ); - scope.recordFinding({ - riskCategory: 'sensitive_info_disclosure', - riskSeverity: GuardrailRiskSeverity.High, - policyDecisionType: 'deny', - policyId: 'policy_pii_v2', - policyName: 'PII Policy', - policyVersion: '2.0', - riskScore: 0.92, - riskMetadata: ['pattern:ssn', 'count:2'], - }); - scope.dispose(); - - const span = getFinishedSpan(); - expect(span.events).toHaveLength(1); - const evt = span.events[0]; - expect(evt.name).toBe(OpenTelemetryConstants.SECURITY_FINDING_EVENT_NAME); - expect(evt.attributes?.[OpenTelemetryConstants.SECURITY_RISK_CATEGORY_KEY]).toBe('sensitive_info_disclosure'); - expect(evt.attributes?.[OpenTelemetryConstants.SECURITY_RISK_SEVERITY_KEY]).toBe('high'); - expect(evt.attributes?.[OpenTelemetryConstants.SECURITY_POLICY_DECISION_TYPE_KEY]).toBe('deny'); - expect(evt.attributes?.[OpenTelemetryConstants.SECURITY_POLICY_ID_KEY]).toBe('policy_pii_v2'); - expect(evt.attributes?.[OpenTelemetryConstants.SECURITY_POLICY_NAME_KEY]).toBe('PII Policy'); - expect(evt.attributes?.[OpenTelemetryConstants.SECURITY_POLICY_VERSION_KEY]).toBe('2.0'); - expect(evt.attributes?.[OpenTelemetryConstants.SECURITY_RISK_SCORE_KEY]).toBe(0.92); - expect(evt.attributes?.[OpenTelemetryConstants.SECURITY_RISK_METADATA_KEY]).toEqual(['pattern:ssn', 'count:2']); - }); - - it('recordFinding should support multiple events per span', () => { - const scope = ApplyGuardrailScope.start( - { targetType: GuardrailTargetType.LlmOutput, decisionType: GuardrailDecisionType.Modify }, - testAgentDetails, - ); - scope.recordFinding({ riskCategory: 'pii', riskSeverity: GuardrailRiskSeverity.Medium }); - scope.recordFinding({ riskCategory: 'toxicity', riskSeverity: GuardrailRiskSeverity.Low }); - scope.dispose(); - - expect(getFinishedSpan().events).toHaveLength(2); - }); - - it('recordFinding should throw when finding is null', () => { - const scope = ApplyGuardrailScope.start( - { targetType: GuardrailTargetType.LlmInput, decisionType: GuardrailDecisionType.Allow }, - testAgentDetails, - ); - expect(() => scope.recordFinding(null as any)).toThrow('finding is required'); - scope.dispose(); - }); - - it('recordError should set error status on the span', () => { - const scope = ApplyGuardrailScope.start( - { targetType: GuardrailTargetType.LlmInput, decisionType: GuardrailDecisionType.Allow }, - testAgentDetails, - ); - scope.recordError(new Error('Guardian service unavailable')); - scope.dispose(); - - const span = getFinishedSpan(); - expect(span.status.code).toBe(2); // SpanStatusCode.ERROR - expect(span.status.message).toBe('Guardian service unavailable'); - }); - - it('should record all guardrail, agent, user, channel, and finding data in a single span', () => { - const guardrailDetails: GuardrailDetails = { - targetType: GuardrailTargetType.LlmInput, - decisionType: GuardrailDecisionType.Deny, - guardianId: 'azure-content-safety-001', - guardianName: 'Azure Content Safety', - guardianProviderName: 'Azure', - guardianVersion: '2.0.0', - targetId: 'msg-12345', - decisionReason: 'Content violates hate speech policy', - decisionCode: 'HATE_SPEECH_001', - policyId: 'policy-abc', - policyName: 'Content Safety Policy', - policyVersion: '1.2.0', - contentInputHash: 'sha256:abc123def456', - contentModified: false, - externalEventId: 'ext-event-789', - }; - - const scope = ApplyGuardrailScope.start( - guardrailDetails, - testAgentDetails, - { conversationId: 'conv-1', channel: { name: 'msteams', description: 'https://channel.link' } }, - { userId: 'user-1', userName: 'User One', userEmail: 'u1@test.com', callerClientIp: '10.0.0.1' }, - ); - scope.recordContentOutput('sanitized-output'); - scope.recordFinding({ - riskCategory: 'hate_speech', - riskSeverity: GuardrailRiskSeverity.High, - policyDecisionType: 'deny', - policyId: 'policy-abc', - riskScore: 0.95, - }); - scope.dispose(); - - const span = getFinishedSpan(); - const a = span.attributes; - - // Span metadata - expect(span.name).toBe('apply_guardrail Azure Content Safety llm_input'); - expect(a[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe('apply_guardrail'); - - // Agent - expect(a[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe('guardrail-agent'); - expect(a[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe('Guardrail Agent'); - expect(a[OpenTelemetryConstants.TENANT_ID_KEY]).toBe('test-tenant-456'); - - // Guardian - expect(a[OpenTelemetryConstants.GUARDIAN_ID_KEY]).toBe('azure-content-safety-001'); - expect(a[OpenTelemetryConstants.GUARDIAN_NAME_KEY]).toBe('Azure Content Safety'); - expect(a[OpenTelemetryConstants.GUARDIAN_PROVIDER_NAME_KEY]).toBe('Azure'); - expect(a[OpenTelemetryConstants.GUARDIAN_VERSION_KEY]).toBe('2.0.0'); - - // Decision - expect(a[OpenTelemetryConstants.SECURITY_DECISION_TYPE_KEY]).toBe('deny'); - expect(a[OpenTelemetryConstants.SECURITY_TARGET_TYPE_KEY]).toBe('llm_input'); - expect(a[OpenTelemetryConstants.SECURITY_TARGET_ID_KEY]).toBe('msg-12345'); - expect(a[OpenTelemetryConstants.SECURITY_DECISION_REASON_KEY]).toBe('Content violates hate speech policy'); - expect(a[OpenTelemetryConstants.SECURITY_DECISION_CODE_KEY]).toBe('HATE_SPEECH_001'); - - // Policy - expect(a[OpenTelemetryConstants.SECURITY_POLICY_ID_KEY]).toBe('policy-abc'); - expect(a[OpenTelemetryConstants.SECURITY_POLICY_NAME_KEY]).toBe('Content Safety Policy'); - expect(a[OpenTelemetryConstants.SECURITY_POLICY_VERSION_KEY]).toBe('1.2.0'); - - // Content - expect(a[OpenTelemetryConstants.SECURITY_CONTENT_INPUT_HASH_KEY]).toBe('sha256:abc123def456'); - expect(a[OpenTelemetryConstants.SECURITY_CONTENT_MODIFIED_KEY]).toBe(false); - expect(a[OpenTelemetryConstants.SECURITY_CONTENT_OUTPUT_VALUE_KEY]).toBe('sanitized-output'); - expect(a[OpenTelemetryConstants.SECURITY_EXTERNAL_EVENT_ID_KEY]).toBe('ext-event-789'); - - // User - expect(a[OpenTelemetryConstants.USER_ID_KEY]).toBe('user-1'); - expect(a[OpenTelemetryConstants.USER_NAME_KEY]).toBe('User One'); - expect(a[OpenTelemetryConstants.USER_EMAIL_KEY]).toBe('u1@test.com'); - expect(a[OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY]).toBe('10.0.0.1'); - - // Channel / conversation - expect(a[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe('conv-1'); - expect(a[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe('msteams'); - expect(a[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe('https://channel.link'); - - // Finding event - expect(span.events).toHaveLength(1); - const evt = span.events[0]; - expect(evt.name).toBe('microsoft.security.finding'); - expect(evt.attributes?.[OpenTelemetryConstants.SECURITY_RISK_CATEGORY_KEY]).toBe('hate_speech'); - expect(evt.attributes?.[OpenTelemetryConstants.SECURITY_RISK_SEVERITY_KEY]).toBe('high'); - expect(evt.attributes?.[OpenTelemetryConstants.SECURITY_RISK_SCORE_KEY]).toBe(0.95); - }); -}); diff --git a/tests/observability/core/custom-logger.test.ts b/tests/observability/core/custom-logger.test.ts deleted file mode 100644 index d4381d5f..00000000 --- a/tests/observability/core/custom-logger.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { setLogger, getLogger, resetLogger, ILogger } from '@microsoft/agents-a365-observability/src/utils/logging'; -import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/src/ObservabilityBuilder'; -import { ExporterEventNames } from '@microsoft/agents-a365-observability'; - -describe('Custom Logger Support', () => { - beforeEach(() => { - resetLogger(); - delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; - }); - - afterEach(() => { - jest.restoreAllMocks(); - resetLogger(); - delete process.env.A365_OBSERVABILITY_LOG_LEVEL; - }); - - describe('Default Logger', () => { - it('should default to no logging when environment variable is not set', () => { - delete process.env.A365_OBSERVABILITY_LOG_LEVEL; - resetLogger(); - - const logSpy = jest.spyOn(console, 'log').mockImplementation(); - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); - - const logger = getLogger(); - logger.info('info msg'); - logger.warn('warn msg'); - logger.error('error msg'); - - expect(logSpy).not.toHaveBeenCalled(); - expect(warnSpy).not.toHaveBeenCalled(); - expect(errorSpy).not.toHaveBeenCalled(); - - logSpy.mockRestore(); - warnSpy.mockRestore(); - errorSpy.mockRestore(); - }); - - it('should respect A365_OBSERVABILITY_LOG_LEVEL environment variable', () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info|warn|error'; - resetLogger(); - - const logSpy = jest.spyOn(console, 'log').mockImplementation(); - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); - - const logger = getLogger(); - logger.info('info msg'); - logger.warn('warn msg'); - logger.error('error msg'); - - expect(logSpy).toHaveBeenCalledWith('[INFO]', 'info msg'); - expect(warnSpy).toHaveBeenCalledWith('[WARN]', 'warn msg'); - expect(errorSpy).toHaveBeenCalledWith('[ERROR]', 'error msg'); - - logSpy.mockRestore(); - warnSpy.mockRestore(); - errorSpy.mockRestore(); - }); - - it('should support selective log levels via environment variable', () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'warn|error'; - resetLogger(); - - const logSpy = jest.spyOn(console, 'log').mockImplementation(); - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); - - const logger = getLogger(); - logger.info('info msg'); - logger.warn('warn msg'); - logger.error('error msg'); - - expect(logSpy).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledWith('[WARN]', 'warn msg'); - expect(errorSpy).toHaveBeenCalledWith('[ERROR]', 'error msg'); - - logSpy.mockRestore(); - warnSpy.mockRestore(); - errorSpy.mockRestore(); - }); - }); - - describe('Global Logger Management', () => { - it('should set and get custom logger', () => { - const custom: ILogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), event: jest.fn() }; - - setLogger(custom); - expect(getLogger()).toBe(custom); - }); - - it('should reset to default logger', () => { - const custom: ILogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), event: jest.fn() }; - setLogger(custom); - - resetLogger(); - expect(getLogger()).not.toBe(custom); - }); - - it('should throw when setting invalid logger', () => { - expect(() => setLogger(null as any)).toThrow('Custom logger must implement ILogger interface'); - expect(() => setLogger({ info: jest.fn() } as any)).toThrow('Custom logger must implement ILogger interface'); - }); - }); - - describe('Custom ILogger Implementation', () => { - it('should route all log levels to custom logger', () => { - const customLogger: ILogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - event: jest.fn() - }; - - setLogger(customLogger); - const logger = getLogger(); - - logger.info('test info', { data: 1 }); - logger.warn('test warn', { data: 2 }); - logger.error('test error', { data: 3 }); - - expect(customLogger.info).toHaveBeenCalledWith('test info', { data: 1 }); - expect(customLogger.warn).toHaveBeenCalledWith('test warn', { data: 2 }); - expect(customLogger.error).toHaveBeenCalledWith('test error', { data: 3 }); - }); - - it('should support selective level logging', () => { - const selectiveLogger: ILogger = { - info: () => {}, - warn: jest.fn(), - error: () => {}, - event: () => {} - }; - - setLogger(selectiveLogger); - const logger = getLogger(); - - logger.info('info msg'); - logger.warn('warn msg'); - logger.error('error msg'); - - expect(selectiveLogger.warn).toHaveBeenCalledWith('warn msg'); - }); - - it('should route event calls to custom logger', () => { - const customLogger: ILogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - event: jest.fn() - }; - - setLogger(customLogger); - const logger = getLogger(); - - logger.event(ExporterEventNames.EXPORT, true, 150); - logger.event(ExporterEventNames.EXPORT_GROUP, false, 200); - - expect(customLogger.event).toHaveBeenCalledWith(ExporterEventNames.EXPORT, true, 150); - expect(customLogger.event).toHaveBeenCalledWith(ExporterEventNames.EXPORT_GROUP, false, 200); - expect(customLogger.event).toHaveBeenCalledTimes(2); - }); - }); - - describe('Event Logging', () => { - it('should respect log level settings for events (info succeeds, error fails)', () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info|error'; - resetLogger(); - - const logSpy = jest.spyOn(console, 'log').mockImplementation(); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); - - const logger = getLogger(); - logger.event(ExporterEventNames.EXPORT, true, 100); - logger.event(ExporterEventNames.EXPORT_GROUP, false, 200); - - expect(logSpy).toHaveBeenCalledWith('[EVENT]: agent365-export succeeded in 100ms'); - expect(errorSpy).toHaveBeenCalledWith('[EVENT]: export-group failed in 200ms'); - - logSpy.mockRestore(); - errorSpy.mockRestore(); - }); - - it('should format event with optional message and details correctly', () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info|error'; - resetLogger(); - - const logSpy = jest.spyOn(console, 'log').mockImplementation(); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); - - const logger = getLogger(); - logger.event(ExporterEventNames.EXPORT, true, 150, 'Task completed successfully', { correlationId: 'corr-123' }); - logger.event(ExporterEventNames.EXPORT_GROUP, false, 250, 'Connection timeout', { correlationId: 'corr-456', tenantId: 'tenant1' }); - - expect(logSpy).toHaveBeenCalledWith('[EVENT]: agent365-export succeeded in 150ms - Task completed successfully {"correlationId":"corr-123"}'); - expect(errorSpy).toHaveBeenCalledWith('[EVENT]: export-group failed in 250ms - Connection timeout {"correlationId":"corr-456","tenantId":"tenant1"}'); - - logSpy.mockRestore(); - errorSpy.mockRestore(); - }); - - it('should format event with only message (no details)', () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - resetLogger(); - - const logSpy = jest.spyOn(console, 'log').mockImplementation(); - - const logger = getLogger(); - logger.event(ExporterEventNames.EXPORT_PARTITION_SPAN_MISSING_IDENTITY, true, 500, 'Data synced from 3 sources'); - - expect(logSpy).toHaveBeenCalledWith('[EVENT]: export-partition-span-missing-identity succeeded in 500ms - Data synced from 3 sources'); - - logSpy.mockRestore(); - }); - - it('should format event with only details (no message)', () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; - resetLogger(); - - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); - - const logger = getLogger(); - logger.event(ExporterEventNames.EXPORT_GROUP, false, 100, undefined, { correlationId: 'corr-789' }); - - expect(errorSpy).toHaveBeenCalledWith('[EVENT]: export-group failed in 100ms {"correlationId":"corr-789"}'); - - errorSpy.mockRestore(); - }); - }); - - describe('ObservabilityBuilder Integration', () => { - it('should set custom logger when building', () => { - const customLogger: ILogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - event: jest.fn() - }; - - new ObservabilityBuilder() - .withService('test-service', '1.0.0') - .withCustomLogger(customLogger) - .build(); - - const currentLogger = getLogger(); - currentLogger.info('test message'); - currentLogger.event(ExporterEventNames.EXPORT, true, 100); - - expect(customLogger.info).toHaveBeenCalledWith('test message'); - expect(customLogger.event).toHaveBeenCalledWith(ExporterEventNames.EXPORT, true, 100); - }); - }); -}); - diff --git a/tests/observability/core/observabilityBuilder-configProvider.test.ts b/tests/observability/core/observabilityBuilder-configProvider.test.ts deleted file mode 100644 index cba65ee2..00000000 --- a/tests/observability/core/observabilityBuilder-configProvider.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/src/ObservabilityBuilder'; -import { ObservabilityManager } from '@microsoft/agents-a365-observability/src/ObservabilityManager'; -import { ObservabilityConfiguration } from '@microsoft/agents-a365-observability/src/configuration/ObservabilityConfiguration'; -import { DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; - -// Capture Agent365Exporter constructor args without performing network calls. -jest.mock('@microsoft/agents-a365-observability/src/tracing/exporter/Agent365Exporter', () => ({ - Agent365Exporter: class { - constructor(opts: any, configProvider?: any) { - (global as any).__capturedOpts = opts; - (global as any).__capturedConfigProvider = configProvider; - } - export() {/* no-op */} - shutdown() {/* no-op */} - forceFlush() {/* no-op */} - } -})); - -// Capture setLogger calls. -let capturedLogger: any = null; -jest.mock('@microsoft/agents-a365-observability/src/utils/logging', () => { - const actual = jest.requireActual('@microsoft/agents-a365-observability/src/utils/logging') as any; - return { ...actual, __esModule: true, default: actual.default, DefaultLogger: actual.DefaultLogger, - setLogger: (l: any) => { capturedLogger = l; actual.setLogger(l); } }; -}); - -const makeProvider = (exporterEnabled?: boolean) => - new DefaultConfigurationProvider(() => new ObservabilityConfiguration({ - ...(exporterEnabled !== undefined && { isObservabilityExporterEnabled: () => exporterEnabled }), - })); - -const capturedOpts = () => (global as any).__capturedOpts as any | undefined; -const capturedConfigProvider = () => (global as any).__capturedConfigProvider as any | undefined; - -describe('ObservabilityBuilder configProvider', () => { - beforeEach(() => { - delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; - delete (global as any).__capturedOpts; - delete (global as any).__capturedConfigProvider; - capturedLogger = null; - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(async () => { - delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; - await ObservabilityManager.shutdown(); - const actualLogging = jest.requireActual('@microsoft/agents-a365-observability/src/utils/logging') as any; - if (typeof actualLogging.resetLogger === 'function') { - actualLogging.resetLogger(); - } - jest.restoreAllMocks(); - }); - - it('withConfigurationProvider returns the builder for chaining', () => { - const builder = new ObservabilityBuilder(); - expect(builder.withConfigurationProvider(makeProvider())).toBe(builder); - }); - - it('threads configProvider into Agent365ExporterOptions alongside other options', () => { - const provider = makeProvider(true); - new ObservabilityBuilder() - .withConfigurationProvider(provider) - .withExporterOptions({ maxQueueSize: 42 }) - .withTokenResolver(() => 'tok') - .build(); - - expect(capturedOpts()).toBeDefined(); - expect(capturedConfigProvider()).toBe(provider); - expect(capturedOpts().configProvider).toBeUndefined(); - expect(capturedOpts().maxQueueSize).toBe(42); - expect(capturedOpts().tokenResolver('a', 'b')).toBe('tok'); - }); - - it('creates DefaultLogger from configProvider when no custom logger is set', () => { - new ObservabilityBuilder().withConfigurationProvider(makeProvider(false)).build(); - expect(capturedLogger).toBeDefined(); - expect(typeof capturedLogger.info).toBe('function'); - }); - - it('custom logger takes precedence over configProvider-based logger', () => { - const custom = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), event: jest.fn() }; - new ObservabilityBuilder() - .withConfigurationProvider(makeProvider(false)) - .withCustomLogger(custom) - .build(); - expect(capturedLogger).toBe(custom); - }); - - it('does not call setLogger when no configProvider or customLogger is provided', () => { - new ObservabilityBuilder().build(); - expect(capturedLogger).toBeNull(); - }); - - it('builds without configProvider using env var fallback (backward compat)', () => { - process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; - new ObservabilityBuilder().withTokenResolver(() => 't').build(); - expect(capturedOpts()).toBeDefined(); - expect(capturedConfigProvider()).toBeUndefined(); - }); - - it('ObservabilityManager.start() passes configProvider through to exporter', () => { - const provider = makeProvider(true); - ObservabilityManager.start({ serviceName: 'svc', tokenResolver: () => 't', configProvider: provider }); - expect(capturedConfigProvider()).toBe(provider); - }); -}); diff --git a/tests/observability/core/observabilityBuilder-options.test.ts b/tests/observability/core/observabilityBuilder-options.test.ts deleted file mode 100644 index a4f4c98d..00000000 --- a/tests/observability/core/observabilityBuilder-options.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/src/ObservabilityBuilder'; -import { ClusterCategory } from '@microsoft/agents-a365-runtime'; -import { ATTR_SERVICE_NAMESPACE } from '@opentelemetry/semantic-conventions'; -import { trace } from '@opentelemetry/api'; - -// Mock the Agent365Exporter so we can capture the constructed options without performing network calls. -jest.mock('@microsoft/agents-a365-observability/src/tracing/exporter/Agent365Exporter', () => { - return { - Agent365Exporter: class { - public static lastOptions: any; - constructor(opts: any) { - // Capture the options passed from ObservabilityBuilder - (global as any).__capturedExporterOptions = opts; - (global as any).__capturedExporterOptionsCallCount = ((global as any).__capturedExporterOptionsCallCount || 0) + 1; - (this.constructor as any).lastOptions = opts; - } - export() {/* no-op */} - shutdown() {/* no-op */} - forceFlush() {/* no-op */} - } - }; -}); - -describe('ObservabilityBuilder exporterOptions merging', () => { - beforeEach(() => { - // Ensure exporter is enabled so BatchSpanProcessor is created with Agent365Exporter - process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; - delete (global as any).__capturedExporterOptions; - delete (global as any).__capturedExporterOptionsCallCount; - }); - - afterEach(() => { - delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; - }); - - it('applies provided exporterOptions and allows builder overrides to take precedence', () => { - const builder = new ObservabilityBuilder() - .withExporterOptions({ - maxQueueSize: 10, - scheduledDelayMilliseconds: 1111, - exporterTimeoutMilliseconds: 2222, - maxExportBatchSize: 33, - // These should be overridden by explicit builder methods below - clusterCategory: ClusterCategory.dev, - tokenResolver: () => 'token-from-exporterOptions' - }) - .withClusterCategory(ClusterCategory.test) - .withTokenResolver(() => 'token-from-builder'); - - const built = builder.build(); - expect(built).toBe(true); - - const captured: any = (global as any).__capturedExporterOptions; - expect(captured).toBeDefined(); - // Custom numeric options preserved - expect(captured.maxQueueSize).toBe(10); - expect(captured.scheduledDelayMilliseconds).toBe(1111); - expect(captured.exporterTimeoutMilliseconds).toBe(2222); - expect(captured.maxExportBatchSize).toBe(33); - // Explicit builder overrides should win - expect(captured.clusterCategory).toBe('test'); - expect(typeof captured.tokenResolver).toBe('function'); - expect(captured.tokenResolver('a','b')).toBe('token-from-builder'); - }); - - it('defaults to prod clusterCategory when none provided', () => { - const builder = new ObservabilityBuilder() - .withExporterOptions({ maxQueueSize: 15 }); // no cluster category passed - - builder.build(); - const captured: any = (global as any).__capturedExporterOptions; - expect(captured.clusterCategory).toBe('prod'); - expect(captured.maxQueueSize).toBe(15); - expect(captured.scheduledDelayMilliseconds).toBe(5000); // default value - }); -}); - - -describe('ObservabilityBuilder serviceNamespace', () => { - let createResourceSpy: jest.SpyInstance; - - beforeEach(() => { - process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; - // Spy on the private createResource method to capture the resource attributes. - createResourceSpy = jest.spyOn(ObservabilityBuilder.prototype as any, 'createResource'); - // Force the builder to take the NodeSDK code path (which calls createResource) - // by making getTracerProvider return a provider without addSpanProcessor/resource. - jest.spyOn(trace, 'getTracerProvider').mockReturnValue({} as any); - }); - - afterEach(() => { - delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; - }); - - it('does not include service.namespace when withServiceNamespace is not called', () => { - new ObservabilityBuilder().withService('svc').build(); - expect(createResourceSpy).toHaveBeenCalled(); - const result = createResourceSpy.mock.results[0].value; - // The resource should not contain ATTR_SERVICE_NAMESPACE - expect(result?.attributes?.[ATTR_SERVICE_NAMESPACE]).toBeUndefined(); - }); - - it('includes service.namespace when withServiceNamespace is called', () => { - new ObservabilityBuilder().withService('svc').withServiceNamespace('ns').build(); - expect(createResourceSpy).toHaveBeenCalled(); - const result = createResourceSpy.mock.results[0].value; - expect(result?.attributes?.[ATTR_SERVICE_NAMESPACE]).toBe('ns'); - }); -}); diff --git a/tests/observability/core/observabilityBuilder-per-request-auth.test.ts b/tests/observability/core/observabilityBuilder-per-request-auth.test.ts deleted file mode 100644 index 04022b8d..00000000 --- a/tests/observability/core/observabilityBuilder-per-request-auth.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; -import { context, trace } from '@opentelemetry/api'; -import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/src/ObservabilityBuilder'; -import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants'; -import { runWithExportToken } from '@microsoft/agents-a365-observability/src/tracing/context/token-context'; -import type { TokenResolver } from '@microsoft/agents-a365-observability'; - -const tenantId = '11111111-1111-1111-1111-111111111111'; -const agentId = '22222222-2222-2222-2222-222222222222'; -const originalFetch = global.fetch; - -function token(claims: Record): string { - return `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.offline-signature`; -} - -function appToken(agent = agentId, tenant = tenantId): string { - return token({ - idtyp: 'app', azp: agent, tid: tenant, - aud: '9b975845-388f-4429-889e-eab1ef63949c', - exp: Math.floor(Date.now() / 1000) + 300, - }); -} - -describe('ObservabilityBuilder per-request OBS authentication', () => { - let originalEnv: NodeJS.ProcessEnv; - let builder: ObservabilityBuilder; - let requests: Request[]; - - beforeEach(() => { - originalEnv = { ...process.env }; - process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; - process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; - delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; - requests = []; - global.fetch = jest.fn(async (input, init) => { - requests.push(new Request(input, init)); - return new Response('{}', { status: 200 }); - }); - builder = new ObservabilityBuilder().withService('per-request-auth-regression'); - }); - - afterEach(async () => { - await builder.shutdown(); - trace.disable(); - context.disable(); - process.env = originalEnv; - global.fetch = originalFetch; - }); - - async function invoke(agent = agentId, tenant = tenantId, contextToken?: string): Promise { - const emit = () => { - trace.getTracer('per-request-auth-regression').startSpan('invoke_agent offline', { - attributes: { - [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: 'invoke_agent', - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agent, - [OpenTelemetryConstants.TENANT_ID_KEY]: tenant, - }, - }).end(); - }; - if (contextToken === undefined) emit(); - else runWithExportToken(contextToken, emit); - // Span completion schedules asynchronous token resolution and export. - await new Promise(resolve => setImmediate(resolve)); - } - - it.each(['builder', 'exporterOptions', 'builderOverride'])('uses the %s app resolver through the real processor and exporter', async (source) => { - const expectedToken = appToken(); - const resolver = jest.fn(async () => expectedToken); - const overridden = jest.fn(async () => 'must-not-be-used'); - builder.withExporterOptions({ useS2SEndpoint: false }); - if (source !== 'builder') { - builder.withExporterOptions({ - useS2SEndpoint: false, - tokenResolver: source === 'exporterOptions' ? resolver : overridden, - }); - } - if (source !== 'exporterOptions') builder.withTokenResolver(resolver); - builder.start(); - - await invoke(agentId, tenantId, token({ idtyp: 'user', scp: 'User.Read', tid: tenantId })); - - expect(resolver).toHaveBeenCalledWith(agentId, tenantId); - expect(overridden).not.toHaveBeenCalled(); - expect(requests).toHaveLength(1); - expect(requests[0].url).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - expect(requests[0].headers.get('authorization')).toBe(`Bearer ${expectedToken}`); - }); - - it('does not require a request-context credential', async () => { - const resolver = jest.fn(async () => appToken()); - builder.withTokenResolver(resolver).start(); - - await invoke(); - - expect(resolver).toHaveBeenCalledWith(agentId, tenantId); - expect(requests).toHaveLength(1); - }); - - it('resolves separate app identities for concurrent workload contexts', async () => { - const otherAgent = '33333333-3333-3333-3333-333333333333'; - const otherTenant = '44444444-4444-4444-4444-444444444444'; - const tokens = new Map([ - [`${agentId}:${tenantId}`, appToken()], - [`${otherAgent}:${otherTenant}`, appToken(otherAgent, otherTenant)], - ]); - const resolver = jest.fn(async (agent, tenant) => tokens.get(`${agent}:${tenant}`) ?? null); - builder.withTokenResolver(resolver).start(); - - await Promise.all([ - invoke(agentId, tenantId, token({ scp: 'User.Read', oid: 'first-user' })), - invoke(otherAgent, otherTenant, token({ scp: 'User.Read', oid: 'second-user' })), - ]); - - expect(resolver).toHaveBeenCalledTimes(2); - expect(resolver).toHaveBeenCalledWith(agentId, tenantId); - expect(resolver).toHaveBeenCalledWith(otherAgent, otherTenant); - expect(requests).toHaveLength(2); - for (const [agent, tenant] of [[agentId, tenantId], [otherAgent, otherTenant]]) { - const request = requests.find(candidate => candidate.headers.get('x-ms-tenant-id') === tenant); - expect(request?.headers.get('authorization')).toBe(`Bearer ${tokens.get(`${agent}:${tenant}`)}`); - } - }); - - it('fails configuration without an app resolver when OBS export is enabled', () => { - expect(() => builder.start()) - .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/); - expect(requests).toHaveLength(0); - }); - - it('keeps console-only configuration usable without an app resolver', () => { - process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'false'; - expect(() => builder.start()).not.toThrow(); - expect(requests).toHaveLength(0); - }); -}); diff --git a/tests/observability/core/observabilityManager-exporter-options.test.ts b/tests/observability/core/observabilityManager-exporter-options.test.ts deleted file mode 100644 index 9cd2d9da..00000000 --- a/tests/observability/core/observabilityManager-exporter-options.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; -import { context, trace } from '@opentelemetry/api'; -import { ObservabilityManager } from '@microsoft/agents-a365-observability/src/ObservabilityManager'; -import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants'; -import type { TokenResolver } from '@microsoft/agents-a365-observability'; - -const tenantId = '11111111-1111-1111-1111-111111111111'; -const agentId = '22222222-2222-2222-2222-222222222222'; -const originalFetch = global.fetch; - -function appToken(): string { - const claims = { - idtyp: 'app', azp: agentId, tid: tenantId, - aud: '9b975845-388f-4429-889e-eab1ef63949c', - exp: Math.floor(Date.now() / 1000) + 300, - }; - return `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.offline-signature`; -} - -describe('ObservabilityManager.start exporter options', () => { - let originalEnv: NodeJS.ProcessEnv; - let requests: Request[]; - - beforeEach(() => { - originalEnv = { ...process.env }; - process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; - process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; - delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; - requests = []; - global.fetch = jest.fn(async (input, init) => { - requests.push(new Request(input, init)); - return new Response('{}', { status: 200 }); - }); - }); - - afterEach(async () => { - await ObservabilityManager.shutdown(); - trace.disable(); - context.disable(); - process.env = originalEnv; - global.fetch = originalFetch; - }); - - async function emitSpan(): Promise { - trace.getTracer('manager-exporter-options').startSpan('invoke_agent offline', { - attributes: { - [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: 'invoke_agent', - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, - [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, - }, - }).end(); - // Span completion schedules asynchronous token resolution and export. - await new Promise(resolve => setImmediate(resolve)); - } - - it('forwards exporterOptions.tokenResolver so the documented migration path starts and exports', async () => { - const expectedToken = appToken(); - const resolver = jest.fn(async () => expectedToken); - - // Without forwarding, start() throws because the exporter requires an app-only resolver. - expect(() => ObservabilityManager.start({ - serviceName: 'manager-exporter-options', - exporterOptions: { tokenResolver: resolver }, - })).not.toThrow(); - await emitSpan(); - - expect(resolver).toHaveBeenCalledWith(agentId, tenantId); - expect(requests).toHaveLength(1); - expect(requests[0].url).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - expect(requests[0].headers.get('authorization')).toBe(`Bearer ${expectedToken}`); - }); - - it('keeps the top-level tokenResolver ahead of exporterOptions.tokenResolver', async () => { - const expectedToken = appToken(); - const resolver = jest.fn(async () => expectedToken); - const overridden = jest.fn(async () => 'must-not-be-used'); - - ObservabilityManager.start({ - serviceName: 'manager-exporter-options', - tokenResolver: resolver, - exporterOptions: { tokenResolver: overridden }, - }); - await emitSpan(); - - expect(resolver).toHaveBeenCalledWith(agentId, tenantId); - expect(overridden).not.toHaveBeenCalled(); - expect(requests[0].headers.get('authorization')).toBe(`Bearer ${expectedToken}`); - }); -}); diff --git a/tests/observability/core/observabilityManager.test.ts b/tests/observability/core/observabilityManager.test.ts deleted file mode 100644 index f5d1bb81..00000000 --- a/tests/observability/core/observabilityManager.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, it, expect, afterEach } from '@jest/globals'; -import { ObservabilityManager, Builder } from '@microsoft/agents-a365-observability'; -import { trace } from '@opentelemetry/api'; -import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/src/ObservabilityBuilder'; - -describe('Agent 365 SDK', () => { - afterEach(async () => { - // Clean up after each test - await ObservabilityManager.shutdown(); - }); - - describe('ObservabilityManager Main Class', () => { - it('should configure with builder pattern', () => { - const builder = ObservabilityManager.configure((b: Builder) => - b.withService('Test Service', '1.0.0') - ); - - expect(builder).toBeInstanceOf(Builder); - expect(ObservabilityManager.getInstance()).toBe(builder); - }); - - it('should start with builder configuration', () => { - const builder = ObservabilityManager.configure((b: Builder) => - b.withService('Test Service 2', '2.0.0') - ); - - builder.start(); - - expect(builder).toBeInstanceOf(Builder); - expect(ObservabilityManager.getInstance()).toBe(builder); - }); - - it('should return null when no instance configured', async () => { - await ObservabilityManager.shutdown(); // Ensure clean state - expect(ObservabilityManager.getInstance()).toBeNull(); - }); - }); - - describe('Builder', () => { - it('should chain builder methods', () => { - const builder = new Builder(); - - const result = builder - .withService('Test', '1.0.0'); - - expect(result).toBe(builder); - }); - - it('should build successfully', () => { - const builder = new Builder(); - const result = builder.build(); - - expect(result).toBe(true); - expect(typeof builder.start).toBe('function'); - expect(typeof builder.shutdown).toBe('function'); - }); - - }); - - describe('Resource detectors', () => { - it('should not include host.name or host.arch in exported spans', async () => { - // Force ObservabilityBuilder to take the NodeSDK code path - const getTracerProviderSpy = jest - .spyOn(trace, 'getTracerProvider') - .mockReturnValue({} as any); - - const builder = new ObservabilityBuilder() - .withService('resource-detector-test', '1.0.0'); - - try { - builder.build(); - builder.start(); - - // Restore only this spy so we can read the real provider set by NodeSDK - getTracerProviderSpy.mockRestore(); - - // Give async resource detectors time to resolve - await new Promise(resolve => setTimeout(resolve, 1000)); - - // The NodeSDK sets a ProxyTracerProvider as global. - const globalProvider = trace.getTracerProvider() as any; - - // Walk the internal chain to find the resource - let resource: any; - const candidates = [ - globalProvider?.resource, - globalProvider?.getDelegate?.()?.resource, - globalProvider?._delegate?.resource, - globalProvider?._delegate?.getDelegate?.()?.resource, - ]; - - for (const candidate of candidates) { - if (candidate?.attributes) { - resource = candidate; - break; - } - } - - // Fallback: access from the builder's internal sdk field - if (!resource) { - const sdk = (builder as any).sdk; - if (sdk) { - const internalCandidates = [ - sdk?._tracerProvider?.resource, - sdk?._resource, - sdk?.resource, - ]; - for (const candidate of internalCandidates) { - if (candidate?.attributes) { - resource = candidate; - break; - } - } - } - } - - expect(resource).toBeDefined(); - expect(resource.attributes).toBeDefined(); - - // host.name and host.arch should NOT be present (hostDetector excluded) - expect(resource.attributes['host.name']).toBeUndefined(); - expect(resource.attributes['host.arch']).toBeUndefined(); - - // service.instance.id should NOT be present (serviceInstanceIdDetector excluded) - expect(resource.attributes['service.instance.id']).toBeUndefined(); - - // process.pid SHOULD be present (processDetector kept) - expect(resource.attributes['process.pid']).toBeDefined(); - expect(resource.attributes['process.pid']).toBe(process.pid); - - // service.name SHOULD be present (explicitly set) - expect(resource.attributes['service.name']).toContain('resource-detector-test'); - } finally { - if (getTracerProviderSpy.mockRestore) { - getTracerProviderSpy.mockRestore(); - } - - // Clean up - await builder.shutdown(); - } - }); - }); -}); diff --git a/tests/observability/core/output-scope.test.ts b/tests/observability/core/output-scope.test.ts deleted file mode 100644 index 9939288b..00000000 --- a/tests/observability/core/output-scope.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { trace, context as otelContext } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; - -import { - OutputScope, - AgentDetails, - OutputResponse, - OpenTelemetryConstants, - ParentSpanRef, - OutputMessage, - OutputMessages, - MessageRole, - FinishReason, -} from '@microsoft/agents-a365-observability'; - -describe('OutputScope', () => { - const testAgentDetails: AgentDetails = { - agentId: 'test-agent-123', - agentName: 'Test Agent', - agentDescription: 'A test agent for output scope testing', - tenantId: '12345678-1234-5678-1234-567812345678', - }; - - const testRequest = { conversationId: 'test-conv-out', channel: { name: 'OutputChannel', description: 'https://output.channel' } }; - - let exporter: InMemorySpanExporter; - let provider: BasicTracerProvider; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let flushProvider: any; - 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); - flushProvider = globalProvider; - } else { - provider = new BasicTracerProvider({ - spanProcessors: [processor] - }); - trace.setGlobalTracerProvider(provider); - flushProvider = provider; - } - }); - - beforeEach(() => { - exporter.reset(); - }); - - afterAll(async () => { - exporter.reset(); - await provider?.shutdown?.(); - contextManager.disable(); - otelContext.disable(); - }); - - function getLastSpan() { - const spans = exporter.getFinishedSpans(); - expect(spans.length).toBeGreaterThan(0); - const span = spans[spans.length - 1]; - return { span, attributes: span.attributes }; - } - - it('should create scope with correct span attributes and output messages (string[])', async () => { - const response: OutputResponse = { messages: ['First message', 'Second message'] }; - - const scope = OutputScope.start( - { conversationId: 'conv-out-1', channel: { name: 'Email', description: 'https://email.link' } }, - response, testAgentDetails - ); - expect(scope).toBeInstanceOf(OutputScope); - scope.dispose(); - - await flushProvider.forceFlush(); - const { span, attributes } = getLastSpan(); - - expect(span.name).toBe('output_messages Test Agent'); - expect(attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe('output_messages'); - expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(testAgentDetails.agentId); - expect(attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe(testAgentDetails.agentName); - expect(attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe('conv-out-1'); - expect(attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe('Email'); - expect(attributes[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe('https://email.link'); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toEqual([ - { role: 'assistant', parts: [{ type: 'text', content: 'First message' }], finish_reason: 'stop' }, - { role: 'assistant', parts: [{ type: 'text', content: 'Second message' }], finish_reason: 'stop' }, - ]); - // Separate version attribute should not be set - expect(attributes[OpenTelemetryConstants.A365_MESSAGES_SCHEMA_VERSION_KEY]).toBeUndefined(); - }); - - it('should overwrite messages with recordOutputMessages', async () => { - const response: OutputResponse = { messages: ['Initial'] }; - - const scope = OutputScope.start(testRequest, response, testAgentDetails); - scope.recordOutputMessages(['Overwritten 1']); - scope.recordOutputMessages(['Overwritten 2', 'Overwritten 3']); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - // Only the last recordOutputMessages call should be present (overwrite semantics) - expect(parsed).toEqual([ - { role: 'assistant', parts: [{ type: 'text', content: 'Overwritten 2' }], finish_reason: 'stop' }, - { role: 'assistant', parts: [{ type: 'text', content: 'Overwritten 3' }], finish_reason: 'stop' }, - ]); - }); - - it('should use parent span reference for linking', async () => { - const parentTraceId = '1234567890abcdef1234567890abcdef'; - const parentSpanId = 'abcdefabcdef1234'; - - const scope = OutputScope.start( - {}, { messages: ['Test'] }, testAgentDetails, - undefined, { parentContext: { traceId: parentTraceId, spanId: parentSpanId } as ParentSpanRef } - ); - scope.dispose(); - - await flushProvider.forceFlush(); - const { span } = getLastSpan(); - expect(span.spanContext().traceId).toBe(parentTraceId); - expect(span.parentSpanContext?.spanId).toBe(parentSpanId); - }); - - it('should throw when agentDetails.tenantId is missing', () => { - expect(() => OutputScope.start({}, { messages: ['m'] }, { agentId: 'a' } as AgentDetails)).toThrow('OutputScope: tenantId is required on agentDetails'); - }); - - it('should accept structured OutputMessages wrapper without re-wrapping', async () => { - const structured: OutputMessages = { - messages: [ - { role: MessageRole.ASSISTANT, parts: [{ type: 'text', content: 'Hello structured' }], finish_reason: FinishReason.STOP }, - { role: MessageRole.ASSISTANT, parts: [{ type: 'text', content: 'Second structured' }] }, - ], - }; - const response: OutputResponse = { messages: structured }; - - const scope = OutputScope.start(testRequest, response, testAgentDetails); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - // Should pass through as-is, preserving finish_reason and not double-wrapping - expect(parsed).toEqual(structured.messages); - }); - - it('should accept structured OutputMessages wrapper in recordOutputMessages (overwrite)', async () => { - const initial: OutputMessages = { - messages: [ - { role: MessageRole.ASSISTANT, parts: [{ type: 'text', content: 'Initial structured' }] }, - ], - }; - const overwrite: OutputMessages = { - messages: [ - { role: MessageRole.ASSISTANT, parts: [{ type: 'text', content: 'Overwritten structured' }], finish_reason: FinishReason.STOP }, - ], - }; - - const scope = OutputScope.start(testRequest, { messages: initial }, testAgentDetails); - scope.recordOutputMessages(overwrite); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - // Overwrite: only the last call's messages should be present - expect(parsed).toEqual(overwrite.messages); - }); - - it('should overwrite structured OutputMessages with plain string[]', async () => { - const initial: OutputMessages = { - messages: [ - { role: MessageRole.ASSISTANT, parts: [{ type: 'text', content: 'Structured initial' }], finish_reason: FinishReason.STOP }, - ], - }; - - const scope = OutputScope.start(testRequest, { messages: initial }, testAgentDetails); - scope.recordOutputMessages(['Plain string overwrite']); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - // Overwrite: only the plain string call should be present - expect(parsed).toHaveLength(1); - expect(parsed[0]).toEqual({ role: 'assistant', parts: [{ type: 'text', content: 'Plain string overwrite' }], finish_reason: 'stop' }); - }); - - it('should serialize raw dict (tool call result) directly via constructor', async () => { - const dict = { result: 'ok', count: 42 }; - const scope = OutputScope.start(testRequest, { messages: dict as any }, testAgentDetails); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - expect(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY]).toBe(JSON.stringify(dict)); - }); - - it('should serialize raw dict (tool call result) directly via recordOutputMessages', async () => { - const scope = OutputScope.start(testRequest, { messages: ['initial'] }, testAgentDetails); - const dict = { status: 'success', data: [1, 2, 3] }; - scope.recordOutputMessages(dict as any); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - // Overwrite: dict replaces initial messages - expect(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY]).toBe(JSON.stringify(dict)); - }); - - it('should accept a single string in constructor', async () => { - const scope = OutputScope.start(testRequest, { messages: 'single output' }, testAgentDetails); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(1); - expect(parsed[0].role).toBe('assistant'); - expect(parsed[0].parts[0].content).toBe('single output'); - expect(parsed[0].finish_reason).toBe('stop'); - }); - - it('should accept a single string in recordOutputMessages', async () => { - const scope = OutputScope.start(testRequest, { messages: ['initial'] }, testAgentDetails); - scope.recordOutputMessages('overwritten single'); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(1); - expect(parsed[0].parts[0].content).toBe('overwritten single'); - expect(parsed[0].finish_reason).toBe('stop'); - }); -}); diff --git a/tests/observability/core/parent-span-ref.test.ts b/tests/observability/core/parent-span-ref.test.ts deleted file mode 100644 index e1a764bf..00000000 --- a/tests/observability/core/parent-span-ref.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeEach, beforeAll, afterAll } from '@jest/globals'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { trace, context as otelContext, TraceFlags } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { - ParentSpanRef, - runWithParentSpanRef, - InvokeAgentScope, - InferenceScope, - ExecuteToolScope, - InferenceDetails, - InferenceOperationType, - ToolCallDetails, - AgentDetails, -} from '@microsoft/agents-a365-observability'; - -describe('ParentSpanRef - Explicit Parent Span Support', () => { - let provider: BasicTracerProvider; - // The provider we should flush against (either the global SDK provider or our local fallback). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let flushProvider: any; - let exporter: InMemorySpanExporter; - let contextManager: AsyncLocalStorageContextManager; - - beforeAll(() => { - // OTel API global tracer provider can only be set once per process. - // Set it once for this test file, and reset the exporter per test for isolation. - contextManager = new AsyncLocalStorageContextManager(); - contextManager.enable(); - otelContext.setGlobalContextManager(contextManager); - - exporter = new InMemorySpanExporter(); - const processor = new SimpleSpanProcessor(exporter); - - // Prefer wiring our exporter into whatever global provider already exists. - // OTel only allows setting the global tracer provider once; other test files may have done so. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const globalProvider: any = trace.getTracerProvider(); - if (globalProvider && typeof globalProvider.addSpanProcessor === 'function') { - globalProvider.addSpanProcessor(processor); - flushProvider = globalProvider; - } else { - provider = new BasicTracerProvider({ - spanProcessors: [processor] - }); - trace.setGlobalTracerProvider(provider); - flushProvider = provider; - } - }); - - beforeEach(() => { - exporter.reset(); - }); - - afterAll(async () => { - exporter.reset(); - await provider?.shutdown?.(); - contextManager.disable(); - otelContext.disable(); - }); - - const testAgentDetails: AgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent', - agentDescription: 'A test agent', - tenantId: 'test-tenant-456' - }; - - const testRequest = { conversationId: 'test-conv-psr', channel: { name: 'PSRChannel', description: 'https://psr.channel' } }; - - - describe('runWithParentSpanRef', () => { - it('should execute callback with parent span context', () => { - const parentRef: ParentSpanRef = { - traceId: '0123456789abcdef0123456789abcdef', - spanId: '0123456789abcdef', - }; - - let executed = false; - const result = runWithParentSpanRef(parentRef, () => { - executed = true; - return 'test-result'; - }); - - expect(executed).toBe(true); - expect(result).toBe('test-result'); - }); - }); - - it.each([ - [ - 'InvokeAgentScope', - (parentRef: ParentSpanRef) => { - return InvokeAgentScope.start(testRequest, {}, { - agentId: 'test-agent', - agentName: 'Test Agent', - tenantId: 'test-tenant-456' - }, undefined, { parentContext: parentRef }); - }, - (name: string) => name.toLowerCase().includes('invokeagent') || name.toLowerCase().includes('invoke_agent'), - ], - [ - 'InferenceScope', - (parentRef: ParentSpanRef) => { - const inferenceDetails: InferenceDetails = { - operationName: InferenceOperationType.CHAT, - model: 'gpt-4', - providerName: 'openai', - }; - return InferenceScope.start(testRequest, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }); - }, - (name: string) => name.toLowerCase().includes('chat'), - ], - [ - 'ExecuteToolScope', - (parentRef: ParentSpanRef) => { - const toolDetails: ToolCallDetails = { - toolName: 'test-tool', - arguments: '{"param": "value"}', - }; - return ExecuteToolScope.start(testRequest, toolDetails, testAgentDetails, undefined, { parentContext: parentRef }); - }, - (name: string) => name.toLowerCase().includes('execute_tool'), - ], - ])('should create a child span with correct parent relationship (%s)', async (_label, createScope, nameMatches) => { - const tracer = trace.getTracer('test'); - const rootSpan = tracer.startSpan('root-span'); - const parentSpanContext = rootSpan.spanContext(); - - const parentRef: ParentSpanRef = { - traceId: parentSpanContext.traceId, - spanId: parentSpanContext.spanId, - }; - - const baseCtx = trace.setSpan(otelContext.active(), rootSpan); - await otelContext.with(baseCtx, async () => { - const childScope = createScope(parentRef); - expect(childScope.getSpanContext().traceId).toBe(parentSpanContext.traceId); - childScope.dispose(); - }); - - rootSpan.end(); - - await flushProvider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const childSpan = spans.find((s) => nameMatches(s.name)); - expect(childSpan).toBeDefined(); - expect(childSpan!.spanContext().traceId).toBe(parentSpanContext.traceId); - expect(childSpan!.parentSpanContext?.spanId).toBe(parentSpanContext.spanId); - }); - - describe('runWithParentSpanRef with nested scope creation', () => { - it('should correctly parent spans created inside runWithParentSpanRef', async () => { - const tracer = trace.getTracer('test'); - const rootSpan = tracer.startSpan('root-span'); - const parentSpanContext = rootSpan.spanContext(); - - const parentRef: ParentSpanRef = { - traceId: parentSpanContext.traceId, - spanId: parentSpanContext.spanId, - }; - - // Ensure the sampled parent span is active so createContextWithParentSpanRef can derive traceFlags. - const baseCtx = trace.setSpan(otelContext.active(), rootSpan); - await otelContext.with(baseCtx, async () => { - // Run a callback with parent context - runWithParentSpanRef(parentRef, () => { - // Create a scope inside - it should automatically inherit the parent - const nestedScope = InvokeAgentScope.start( - testRequest, - {}, - { agentId: 'nested-agent', tenantId: 'test-tenant-456' } - ); - - const nestedSpanContext = nestedScope.getSpanContext(); - expect(nestedSpanContext.traceId).toBe(parentSpanContext.traceId); - - nestedScope.dispose(); - }); - }); - - rootSpan.end(); - - await flushProvider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const nestedSpan = spans.find(s => s.name.includes('invoke_agent')); - expect(nestedSpan).toBeDefined(); - expect(nestedSpan!.spanContext().traceId).toBe(parentSpanContext.traceId); - expect(nestedSpan!.parentSpanContext?.spanId).toBe(parentSpanContext.spanId); - }); - }); - - describe('getSpanContext method', () => { - it('should return the span context from a scope (and be usable as ParentSpanRef)', async () => { - const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); - const spanContext = scope.getSpanContext(); - - expect(spanContext).toBeDefined(); - expect(spanContext.traceId).toBeDefined(); - expect(spanContext.spanId).toBeDefined(); - expect(spanContext.traceId.length).toBe(32); // 32 hex chars - expect(spanContext.spanId.length).toBe(16); // 16 hex chars - - // Create a ParentSpanRef from this scope and ensure it can parent another scope. - const parentRef: ParentSpanRef = { traceId: spanContext.traceId, spanId: spanContext.spanId }; - const inferenceDetails: InferenceDetails = { - operationName: InferenceOperationType.CHAT, - model: 'gpt-4', - providerName: 'openai', - }; - const activeParentSpan = trace.wrapSpanContext(spanContext); - const baseCtx = trace.setSpan(otelContext.active(), activeParentSpan); - const childScope = otelContext.with(baseCtx, () => - InferenceScope.start(testRequest, inferenceDetails, testAgentDetails, undefined, { parentContext: parentRef }) - ); - expect(childScope.getSpanContext().traceId).toBe(spanContext.traceId); - - scope.dispose(); - childScope.dispose(); - - await flushProvider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const parentSpan = spans.find(s => s.name.toLowerCase().includes('invoke_agent')); - const childSpan = spans.find(s => s.name.toLowerCase().includes('chat')); - - expect(parentSpan).toBeDefined(); - expect(childSpan).toBeDefined(); - expect(childSpan!.spanContext().traceId).toBe(parentSpan!.spanContext().traceId); - expect(childSpan!.parentSpanContext?.spanId).toBe(parentSpan!.spanContext().spanId); - }); - }); - - describe('traceFlags propagation', () => { - it('should record child spans when parentRef.traceFlags is SAMPLED', async () => { - const parentRef: ParentSpanRef = { - traceId: '0123456789abcdef0123456789abcdef', - spanId: '0123456789abcdef', - traceFlags: TraceFlags.SAMPLED, - }; - - runWithParentSpanRef(parentRef, () => { - const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'sampled-agent', tenantId: 'test-tenant-456' }); - scope.dispose(); - }); - - await flushProvider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const childSpan = spans.find(s => - s.name.toLowerCase().includes('invokeagent') || s.name.toLowerCase().includes('invoke_agent') - ); - - expect(childSpan).toBeDefined(); - expect(childSpan!.spanContext().traceId).toBe(parentRef.traceId); - expect(childSpan!.parentSpanContext?.spanId).toBe(parentRef.spanId); - expect(childSpan!.spanContext().traceFlags).toBe(TraceFlags.SAMPLED); - }); - - it('should not record child spans when parentRef.traceFlags is NONE', async () => { - const parentRef: ParentSpanRef = { - traceId: 'abcdef0123456789abcdef0123456789', - spanId: 'abcdef0123456789', - traceFlags: TraceFlags.NONE, - }; - - runWithParentSpanRef(parentRef, () => { - const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'unsampled-agent', tenantId: 'test-tenant-456' }); - scope.dispose(); - }); - - await flushProvider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - // When traceFlags is NONE, the span should still be created but not recorded/exported - const childSpan = spans.find(s => - (s.name.toLowerCase().includes('invokeagent') || s.name.toLowerCase().includes('invoke_agent')) && - s.spanContext().traceId === parentRef.traceId - ); - - // The span should not be exported when traceFlags is NONE - expect(childSpan).toBeUndefined(); - }); - - it('should default to SAMPLED when parentRef.traceFlags is not provided and no active span matches', async () => { - const parentRef: ParentSpanRef = { - traceId: 'fedcba9876543210fedcba9876543210', - spanId: 'fedcba9876543210', - // traceFlags is not provided — should default to SAMPLED - }; - - runWithParentSpanRef(parentRef, () => { - const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'default-sampled-agent', tenantId: 'test-tenant-456' }); - scope.dispose(); - }); - - await flushProvider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const childSpan = spans.find(s => - (s.name.toLowerCase().includes('invokeagent') || s.name.toLowerCase().includes('invoke_agent')) && - s.spanContext().traceId === parentRef.traceId - ); - - // Should be recorded when traceFlags defaults to SAMPLED - expect(childSpan).toBeDefined(); - expect(childSpan!.spanContext().traceFlags).toBe(TraceFlags.SAMPLED); - }); - - it('should inherit traceFlags from active span when parentRef.traceFlags is not provided but traceId matches', async () => { - const tracer = trace.getTracer('test'); - const rootSpan = tracer.startSpan('active-root-span'); - const parentSpanContext = rootSpan.spanContext(); - - const parentRef: ParentSpanRef = { - traceId: parentSpanContext.traceId, - spanId: parentSpanContext.spanId, - // traceFlags is not provided - }; - - const baseCtx = trace.setSpan(otelContext.active(), rootSpan); - await otelContext.with(baseCtx, async () => { - runWithParentSpanRef(parentRef, () => { - const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'inherited-flags-agent', tenantId: 'test-tenant-456' }); - scope.dispose(); - }); - }); - - rootSpan.end(); - - await flushProvider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const childSpan = spans.find(s => - (s.name.toLowerCase().includes('invokeagent') || s.name.toLowerCase().includes('invoke_agent')) && - s.spanContext().traceId === parentRef.traceId - ); - - // Should be recorded with traceFlags inherited from active span - expect(childSpan).toBeDefined(); - expect(childSpan!.spanContext().traceFlags).toBe(parentSpanContext.traceFlags); - }); - }); -}); diff --git a/tests/observability/core/payload-chunking.test.ts b/tests/observability/core/payload-chunking.test.ts deleted file mode 100644 index 788c688a..00000000 --- a/tests/observability/core/payload-chunking.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { describe, it, expect } from '@jest/globals'; -import { - estimateSpanBytes, - estimateValueBytes, - chunkBySize, - truncateSpan, -} from '@microsoft/agents-a365-observability/src/tracing/exporter/utils'; - -function makeOTLPSpan(attrs: Record, name = 'test') { - return { - traceId: '00000000000000000000000000000001', - spanId: '0000000000000002', - name, - kind: 'INTERNAL', - startTimeUnixNano: 1000000000, - endTimeUnixNano: 2000000000, - attributes: attrs, - events: null, - links: null, - status: { code: 'UNSET', message: '' }, - }; -} - -// --------------------------------------------------------------------------- -// estimateSpanBytes -// --------------------------------------------------------------------------- - -describe('estimateSpanBytes', () => { - it('over-estimates relative to actual JSON size', () => { - const span = makeOTLPSpan({ - 'gen_ai.system': 'openai', - 'gen_ai.tool.arguments': 'x'.repeat(1000), - 'gen_ai.tool.call_result': 'y'.repeat(1000), - }); - const actual = Buffer.byteLength(JSON.stringify(span), 'utf8'); - expect(estimateSpanBytes(span)).toBeGreaterThanOrEqual(actual); - }); - - it('grows with attribute content', () => { - const small = makeOTLPSpan({ key: 'val' }); - const large = makeOTLPSpan({ key: 'x'.repeat(10000) }); - expect(estimateSpanBytes(large)).toBeGreaterThan(estimateSpanBytes(small)); - }); - - it('accounts for events', () => { - const withEvents = { ...makeOTLPSpan({}), events: [{ name: 'ev', attributes: { k: 'v' } }] }; - expect(estimateSpanBytes(withEvents)).toBeGreaterThan(estimateSpanBytes(makeOTLPSpan({}))); - }); -}); - -// --------------------------------------------------------------------------- -// estimateValueBytes -// --------------------------------------------------------------------------- - -describe('estimateValueBytes', () => { - it('handles all value types', () => { - expect(estimateValueBytes('hello')).toBe(40 + 5); - expect(estimateValueBytes([])).toBe(60); - expect(estimateValueBytes(['a', 'bb'])).toBe(60 + (40 + 1) + (40 + 2)); - expect(estimateValueBytes([1, 2])).toBe(60 + 50 * 2); - expect(estimateValueBytes(true)).toBe(40); - expect(estimateValueBytes(42)).toBe(40); - expect(estimateValueBytes(null)).toBe(40); - }); -}); - -// --------------------------------------------------------------------------- -// chunkBySize -// --------------------------------------------------------------------------- - -describe('chunkBySize', () => { - type SizedItem = { id: string; size: number }; - const sizedItem = (id: string, size: number): SizedItem => ({ id, size }); - const getSize = (item: SizedItem) => item.size; - - it('empty input returns empty output', () => { - expect(chunkBySize([], getSize, 900_000)).toEqual([]); - }); - - it('small items fit in one chunk', () => { - const items = Array.from({ length: 10 }, (_, i) => sizedItem(`s${i}`, 100)); - const chunks = chunkBySize(items, getSize, 900_000); - expect(chunks).toHaveLength(1); - expect(chunks[0]).toHaveLength(10); - }); - - it('splits when cumulative exceeds limit and preserves order', () => { - const items = Array.from({ length: 5 }, (_, i) => sizedItem(`s${i}`, 300_000)); - const chunks = chunkBySize(items, getSize, 900_000); - expect(chunks.length).toBeGreaterThanOrEqual(2); - expect(chunks.flat().map(i => i.id)).toEqual(['s0', 's1', 's2', 's3', 's4']); - }); - - it('oversize single item gets its own chunk', () => { - const chunks = chunkBySize([sizedItem('big', 2_000_000)], getSize, 900_000); - expect(chunks).toHaveLength(1); - expect(chunks[0][0].id).toBe('big'); - }); - - it('multi-item chunks respect limit; no chunk is empty', () => { - const items = Array.from({ length: 5 }, (_, i) => sizedItem(`s${i}`, 200_000)); - const chunks = chunkBySize(items, getSize, 500_000); - for (const chunk of chunks) { - expect(chunk.length).toBeGreaterThan(0); - if (chunk.length > 1) { - expect(chunk.reduce((s, i) => s + i.size, 0)).toBeLessThanOrEqual(500_000); - } - } - }); -}); - -// --------------------------------------------------------------------------- -// truncateSpan verification -// --------------------------------------------------------------------------- - -describe('truncateSpan verification', () => { - const MAX = 250 * 1024; - - it('leaves small span unchanged', () => { - const span = makeOTLPSpan({ k: 'small' }); - expect(truncateSpan(span).attributes!['k']).toBe('small'); - }); - - it('shrinks oversize span below limit, largest string first', () => { - const span = makeOTLPSpan({ small: 'ok', fat: 'x'.repeat(300_000) }); - const result = truncateSpan(span); - expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThanOrEqual(MAX); - expect(result.attributes!['small']).toBe('ok'); - expect((result.attributes!['fat'] as string).length).toBeLessThan(300_000); - }); - - it('does not loop forever on already-sentinel values', () => { - const span = makeOTLPSpan({ - a: '[overlimit]', - b: '[overlimit]', - big: new Array(5200).fill(42), - }); - expect(truncateSpan(span)).toBeDefined(); - }); -}); diff --git a/tests/observability/core/scope-messages.test.ts b/tests/observability/core/scope-messages.test.ts deleted file mode 100644 index 860ecb0e..00000000 --- a/tests/observability/core/scope-messages.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { trace, context as otelContext } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; - -import { - InferenceScope, - InvokeAgentScope, - AgentDetails, - InferenceDetails, - InferenceOperationType, - OpenTelemetryConstants, - ChatMessage, - OutputMessage, - MessageRole, - FinishReason, - Modality, - InputMessages, - OutputMessages, -} from '@microsoft/agents-a365-observability'; -import { - serializeMessages, -} from '@microsoft/agents-a365-observability/src/tracing/message-utils'; - -const testAgentDetails: AgentDetails = { - agentId: 'test-agent-msg', - agentName: 'Message Test Agent', - tenantId: '11111111-1111-1111-1111-111111111111', -}; - -const testInferenceDetails: InferenceDetails = { - operationName: InferenceOperationType.CHAT, - model: 'gpt-4', - providerName: 'openai', -}; - -const testRequest = { conversationId: 'conv-msg-1' }; - -describe('Scope message recording', () => { - let exporter: InMemorySpanExporter; - let provider: BasicTracerProvider; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let flushProvider: any; - 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); - flushProvider = globalProvider; - } else { - provider = new BasicTracerProvider({ spanProcessors: [processor] }); - trace.setGlobalTracerProvider(provider); - flushProvider = provider; - } - }); - - beforeEach(() => { - exporter.reset(); - }); - - afterAll(async () => { - exporter.reset(); - await provider?.shutdown?.(); - contextManager.disable(); - otelContext.disable(); - }); - - function getLastSpan() { - const spans = exporter.getFinishedSpans(); - expect(spans.length).toBeGreaterThan(0); - const span = spans[spans.length - 1]; - return { span, attributes: span.attributes }; - } - - // --------------------------------------------------------------------------- - // recordInputMessages / recordOutputMessages (tested via InferenceScope; - // InvokeAgentScope overrides are trivial super calls sharing the same path) - // --------------------------------------------------------------------------- - describe('recordInputMessages / recordOutputMessages', () => { - it('should convert string[] input to plain array with OTEL ChatMessage format', async () => { - const scope = InferenceScope.start(testRequest, testInferenceDetails, testAgentDetails); - scope.recordInputMessages(['What is the weather?', 'And traffic?']); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string); - - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toEqual([ - { role: 'user', parts: [{ type: 'text', content: 'What is the weather?' }] }, - { role: 'user', parts: [{ type: 'text', content: 'And traffic?' }] }, - ]); - }); - - it('should pass through InputMessages wrapper without re-wrapping', async () => { - const structured: InputMessages = { - messages: [ - { role: MessageRole.SYSTEM, parts: [{ type: 'text', content: 'You are a helpful assistant.' }] }, - { role: MessageRole.USER, parts: [{ type: 'text', content: 'Hello!' }] }, - ], - }; - - const scope = InferenceScope.start(testRequest, testInferenceDetails, testAgentDetails); - scope.recordInputMessages(structured); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string); - - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toEqual(structured.messages); - }); - - it('should convert string[] output to plain array with OTEL OutputMessage format', async () => { - const scope = InferenceScope.start(testRequest, testInferenceDetails, testAgentDetails); - scope.recordOutputMessages(['The weather is sunny.']); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toEqual([ - { role: 'assistant', parts: [{ type: 'text', content: 'The weather is sunny.' }], finish_reason: 'stop' }, - ]); - }); - - it('should serialize as plain array with no version property or attribute', async () => { - const scope = InferenceScope.start(testRequest, testInferenceDetails, testAgentDetails); - scope.recordInputMessages(['Hello']); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - // Separate version attribute should no longer be set - expect(attributes[OpenTelemetryConstants.A365_MESSAGES_SCHEMA_VERSION_KEY]).toBeUndefined(); - }); - - it('should preserve finish_reason on OutputMessages wrapper', async () => { - const structured: OutputMessages = { - messages: [ - { role: MessageRole.ASSISTANT, parts: [{ type: 'text', content: 'Done.' }], finish_reason: FinishReason.STOP }, - { role: MessageRole.ASSISTANT, parts: [{ type: 'text', content: 'Partial...' }], finish_reason: FinishReason.LENGTH }, - ], - }; - - const scope = InferenceScope.start(testRequest, testInferenceDetails, testAgentDetails); - scope.recordOutputMessages(structured); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - - expect(parsed[0].finish_reason).toBe('stop'); - expect(parsed[1].finish_reason).toBe('length'); - }); - }); - - // --------------------------------------------------------------------------- - // InvokeAgentScope-specific: recordResponse() delegates to recordOutputMessages - // --------------------------------------------------------------------------- - describe('InvokeAgentScope.recordResponse', () => { - it('should convert response string to plain array with OTEL OutputMessage format', async () => { - const scope = InvokeAgentScope.start(testRequest, {}, testAgentDetails); - scope.recordResponse('Test response'); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(1); - expect(parsed[0].role).toBe('assistant'); - expect(parsed[0].parts[0].content).toBe('Test response'); - }); - }); - - // --------------------------------------------------------------------------- - // Complex OTEL message part types (serialization round-trips) - // --------------------------------------------------------------------------- - describe('Complex message part types', () => { - it('should serialize tool call request and response parts as plain array', async () => { - const messages: InputMessages = { - messages: [ - { - role: MessageRole.ASSISTANT, - parts: [ - { type: 'text', content: 'Let me search for that.' }, - { type: 'tool_call', name: 'search', id: 'call_123', arguments: { query: 'test' } }, - ], - }, - { - role: MessageRole.TOOL, - parts: [ - { type: 'tool_call_response', id: 'call_123', response: { results: ['item1'] } }, - ], - }, - ], - }; - - const scope = InferenceScope.start(testRequest, testInferenceDetails, testAgentDetails); - scope.recordInputMessages(messages); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string); - - expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].parts[1].type).toBe('tool_call'); - expect(parsed[0].parts[1].arguments).toEqual({ query: 'test' }); - expect(parsed[1].parts[0].type).toBe('tool_call_response'); - expect(parsed[1].parts[0].response).toEqual({ results: ['item1'] }); - }); - - it('should serialize reasoning parts with finish_reason as plain array', async () => { - const messages: OutputMessages = { - messages: [{ - role: MessageRole.ASSISTANT, - parts: [ - { type: 'reasoning', content: 'The user is asking about weather...' }, - { type: 'text', content: 'The weather is sunny.' }, - ], - finish_reason: FinishReason.STOP, - }], - }; - - const scope = InferenceScope.start(testRequest, testInferenceDetails, testAgentDetails); - scope.recordOutputMessages(messages); - scope.dispose(); - - await flushProvider.forceFlush(); - const { attributes } = getLastSpan(); - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - - expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].parts[0].type).toBe('reasoning'); - expect(parsed[0].parts[1].type).toBe('text'); - expect(parsed[0].finish_reason).toBe('stop'); - }); - - it('should serialize blob, file, and URI parts', () => { - const wrapper: InputMessages = { - messages: [{ - role: MessageRole.USER, - parts: [ - { type: 'blob', modality: Modality.IMAGE, mime_type: 'image/png', content: 'iVBORw0KGgo=' }, - { type: 'file', modality: Modality.VIDEO, mime_type: 'video/mp4', file_id: 'file-123' }, - { type: 'uri', modality: Modality.AUDIO, mime_type: 'audio/mp3', uri: 'https://example.com/audio.mp3' }, - ], - }], - }; - - const parsed = JSON.parse(serializeMessages(wrapper)); - - expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].parts[0].modality).toBe('image'); - expect(parsed[0].parts[1].file_id).toBe('file-123'); - expect(parsed[0].parts[2].uri).toBe('https://example.com/audio.mp3'); - }); - - it('should serialize server tool call and generic parts', () => { - const wrapper: InputMessages = { - messages: [ - { role: MessageRole.ASSISTANT, parts: [{ type: 'server_tool_call', name: 'mcp_tool', id: 'stc_1', server_tool_call: { endpoint: '/api' } }] }, - { role: MessageRole.TOOL, parts: [{ type: 'server_tool_call_response', id: 'stc_1', server_tool_call_response: { status: 'ok' } }] }, - { role: MessageRole.USER, parts: [{ type: 'custom_annotation', timestamp: '00:01:23', note: 'Important' }] }, - ], - }; - - const parsed = JSON.parse(serializeMessages(wrapper)); - - expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].parts[0].server_tool_call.endpoint).toBe('/api'); - expect(parsed[1].parts[0].server_tool_call_response.status).toBe('ok'); - expect(parsed[2].parts[0].type).toBe('custom_annotation'); - }); - }); -}); diff --git a/tests/observability/core/scopes.test.ts b/tests/observability/core/scopes.test.ts deleted file mode 100644 index 6adaadad..00000000 --- a/tests/observability/core/scopes.test.ts +++ /dev/null @@ -1,1102 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeAll, afterAll, afterEach, jest, beforeEach } from '@jest/globals'; -import { trace, SpanKind } from '@opentelemetry/api'; - -import { - ExecuteToolScope, - InvokeAgentScope, - InferenceScope, - AgentDetails, - InvokeAgentScopeDetails, - ToolCallDetails, - InferenceDetails, - InferenceOperationType, - UserDetails, - OpenTelemetryConstants, - OpenTelemetryScope, - MessageRole, -} from '@microsoft/agents-a365-observability'; -import { safeSerializeToJson } from '@microsoft/agents-a365-observability/src/tracing/util'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base'; - -// Shared file-level exporter that all describe blocks can use. -// OTel only allows setGlobalTracerProvider once per process, so we create the -// provider and register it in a top-level beforeAll to avoid module-load side effects. -let sharedExporter: InMemorySpanExporter; - -// Mock console to avoid cluttering test output -const originalConsoleWarn = console.warn; -const originalConsoleError = console.error; -beforeAll(() => { - sharedExporter = new InMemorySpanExporter(); - const processor = new SimpleSpanProcessor(sharedExporter); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const globalProvider: any = trace.getTracerProvider(); - if (globalProvider && typeof globalProvider.addSpanProcessor === 'function') { - globalProvider.addSpanProcessor(processor); - } else { - const provider = new BasicTracerProvider({ - spanProcessors: [processor], - }); - trace.setGlobalTracerProvider(provider); - } - - console.warn = jest.fn(); - console.error = jest.fn(); -}); - -afterAll(() => { - console.warn = originalConsoleWarn; - console.error = originalConsoleError; -}); - -describe('Scopes', () => { - const testAgentDetails: AgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent', - agentDescription: 'A test agent', - tenantId: 'test-tenant-456' - }; - - const testRequest = { conversationId: 'test-conv-req', channel: { name: 'TestChannel', description: 'https://test.channel' } }; - - describe('InvokeAgentScope', () => { - it('should create scope with agent details', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - - const scope = InvokeAgentScope.start( - { conversationId: 'conv-req-1', channel: { name: 'Teams', description: 'https://teams.link' } }, - {}, - { - agentId: 'test-agent', - agentName: 'Test Agent', - agentDescription: 'A test agent', - tenantId: 'test-tenant-456' - } - ); - - expect(scope).toBeInstanceOf(InvokeAgentScope); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: 'conv-req-1' }), - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: 'Teams' }), - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: 'https://teams.link' }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should create scope with agent ID only', () => { - const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'simple-agent', tenantId: 'test-tenant-456' }); - - expect(scope).toBeInstanceOf(InvokeAgentScope); - scope?.dispose(); - }); - - it('should create scope with additional details', () => { - const scope = InvokeAgentScope.start(testRequest, {}, { - agentId: 'test-agent', - agentName: 'Test Agent', - agentDescription: 'A test agent', - iconUri: 'https://example.com/icon.png', - tenantId: 'test-tenant-456' - }); - - expect(scope).toBeInstanceOf(InvokeAgentScope); - scope?.dispose(); - }); - - it('should create scope with platformId', () => { - const scope = InvokeAgentScope.start(testRequest, {}, { - agentId: 'test-agent', - agentName: 'Test Agent', - platformId: 'platform-xyz-123', - tenantId: 'test-tenant-456' - }); - - expect(scope).toBeInstanceOf(InvokeAgentScope); - scope?.dispose(); - }); - - it('should create scope with caller details', () => { - const callerDetails: UserDetails = { - userId: 'user-123', - userName: 'Test User', - userEmail: 'test.user@contoso.com', - tenantId: 'test-tenant' - }; - - const scope = InvokeAgentScope.start(testRequest, {}, { - agentId: 'test-agent', - agentName: 'Test Agent', - tenantId: 'test-tenant-456' - }, { userDetails: callerDetails }); - - expect(scope).toBeInstanceOf(InvokeAgentScope); - scope?.dispose(); - }); - - it('should set sessionId from request', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = InvokeAgentScope.start( - { conversationId: 'conv-1', sessionId: 'session-abc-123' }, - {}, - { agentId: 'test-agent', tenantId: 'test-tenant-456' } - ); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.SESSION_ID_KEY, val: 'session-abc-123' }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should record error', () => { - const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); - const error = new Error('Test error'); - - expect(() => scope?.recordError(error)).not.toThrow(); - scope?.dispose(); - }); - - it('should set conversationId from request', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = InvokeAgentScope.start( - { conversationId: 'explicit-conv-id' }, - {}, - { agentId: 'test-agent', tenantId: 'test-tenant-456' } - ); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: 'explicit-conv-id' }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should set channel tags from request.channel', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = InvokeAgentScope.start( - { channel: { name: 'Teams', description: 'https://teams.link' } }, - {}, - { agentId: 'test-agent', tenantId: 'test-tenant-456' } - ); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: 'Teams' }), - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: 'https://teams.link' }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should propagate platformId in span attributes', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - - const scope = InvokeAgentScope.start(testRequest, {}, { - agentId: 'test-agent', - agentName: 'Test Agent', - platformId: 'test-platform-123', - tenantId: 'test-tenant-456' - }); - expect(scope).toBeInstanceOf(InvokeAgentScope); - - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_AGENT_PLATFORM_ID_KEY, val: 'test-platform-123' }) - ])); - - scope?.dispose(); - spy.mockRestore(); - }); - - it('should propagate caller agent platformId in span attributes', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const callerAgentDetails: AgentDetails = { - agentId: 'caller-agent', - agentName: 'Caller Agent', - agentDescription: 'desc', - platformId: 'caller-platform-xyz' - } as any; - - const scope = InvokeAgentScope.start(testRequest, {}, { - agentId: 'test-agent', - agentName: 'Test Agent', - tenantId: 'test-tenant-456' - }, { callerAgentDetails }); - expect(scope).toBeInstanceOf(InvokeAgentScope); - - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, val: 'caller-platform-xyz' }) - ])); - - scope?.dispose(); - spy.mockRestore(); - }); - - it('should propagate agent version and caller agent version in span attributes', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const callerAgentDetails: AgentDetails = { - agentId: 'caller-agent', - agentName: 'Caller Agent', - agentVersion: '2025-05-01' - } as any; - - const scope = InvokeAgentScope.start(testRequest, {}, { - agentId: 'test-agent', - agentName: 'Test Agent', - tenantId: 'test-tenant-456', - agentVersion: '1.2.3' - }, { callerAgentDetails }); - expect(scope).toBeInstanceOf(InvokeAgentScope); - - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_AGENT_VERSION_KEY, val: '1.2.3' }), - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_VERSION_KEY, val: '2025-05-01' }) - ])); - - scope?.dispose(); - spy.mockRestore(); - }); - - it('should set caller and caller-agent IP tags', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const agentDets = { - agentId: 'test-agent', - agentName: 'Test Agent', - tenantId: 'test-tenant-456' - }; - const callerDetails: UserDetails = { - userId: 'user-123', - tenantId: 'test-tenant', - callerClientIp: '10.0.0.5' - }; - const callerAgentDetails: AgentDetails = { - agentId: 'caller-agent', - agentName: 'Caller Agent', - agentDescription: 'desc', - agentClientIP: '192.168.1.100' - } as any; - - const scope1 = InvokeAgentScope.start(testRequest, {}, agentDets, { userDetails: callerDetails }); - expect(scope1).toBeInstanceOf(InvokeAgentScope); - const scope2 = InvokeAgentScope.start(testRequest, {}, agentDets, { callerAgentDetails }); - expect(scope2).toBeInstanceOf(InvokeAgentScope); - - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, val: '10.0.0.5' }), - ])); - - scope1?.dispose(); - scope2?.dispose(); - spy.mockRestore(); - }); - - it('should throw when agentDetails.tenantId is missing', () => { - expect(() => InvokeAgentScope.start(testRequest, {}, { agentId: 'a' } as any)).toThrow('InvokeAgentScope: tenantId is required on agentDetails'); - }); - - it('should set both userDetails and callerAgentDetails tags when both are provided', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = InvokeAgentScope.start(testRequest, {}, { - agentId: 'test-agent', - agentName: 'Test Agent', - tenantId: 'test-tenant-456' - }, { - userDetails: { userId: 'user-1', userName: 'User One' }, - callerAgentDetails: { agentId: 'caller-agent-1', agentName: 'Caller Agent' } as any - }); - - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.USER_ID_KEY, val: 'user-1' }), - expect.objectContaining({ key: OpenTelemetryConstants.USER_NAME_KEY, val: 'User One' }), - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, val: 'caller-agent-1' }), - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY, val: 'Caller Agent' }), - ])); - - scope?.dispose(); - spy.mockRestore(); - }); - - it('should set endpoint tags from typed InvokeAgentScopeDetails', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const details: InvokeAgentScopeDetails = { endpoint: { host: 'agent-api.contoso.com', port: 8443 } }; - const scope = InvokeAgentScope.start(testRequest, details, { agentId: 'typed-agent', tenantId: 'test-tenant-456' }); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.SERVER_ADDRESS_KEY, val: 'agent-api.contoso.com' }), - expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY, val: 8443 }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should omit endpoint tags when InvokeAgentScopeDetails is empty', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); - const keys = new Set(spy.mock.calls.map(args => args[0])); - expect(keys).not.toContain(OpenTelemetryConstants.SERVER_ADDRESS_KEY); - expect(keys).not.toContain(OpenTelemetryConstants.SERVER_PORT_KEY); - scope?.dispose(); - spy.mockRestore(); - }); - - }); - - describe('ExecuteToolScope', () => { - it('should create scope with tool details', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const callerDetails: UserDetails = { - userId: 'caller-tool-1', - userEmail: 'tool.user@contoso.com', - userName: 'Tool User', - tenantId: 'tool-tenant', - callerClientIp: '10.0.0.10' - }; - const scope = ExecuteToolScope.start(testRequest, { - toolName: 'test-tool', - arguments: '{"param": "value"}', - toolCallId: 'call-123', - description: 'A test tool', - toolType: 'test' - }, testAgentDetails, callerDetails); - - expect(scope).toBeInstanceOf(ExecuteToolScope); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.USER_ID_KEY, val: 'caller-tool-1' }), - expect.objectContaining({ key: OpenTelemetryConstants.USER_NAME_KEY, val: 'Tool User' }), - expect.objectContaining({ key: OpenTelemetryConstants.USER_EMAIL_KEY, val: 'tool.user@contoso.com' }), - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, val: '10.0.0.10' }) - ])); - // Validate raw attribute key strings for schema correctness - const keySet = new Set(calls.map(c => c.key)); - expect(keySet).toContain('user.id'); - expect(keySet).toContain('user.name'); - expect(keySet).toContain('user.email'); - expect(keySet).toContain('client.address'); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should record response', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = ExecuteToolScope.start( - { conversationId: 'conv-tool-resp', channel: { name: 'Web', description: 'https://web.link' } }, - { toolName: 'test-tool' }, testAgentDetails - ); - - expect(() => scope?.recordResponse('Tool result')).not.toThrow(); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: 'conv-tool-resp' }), - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: 'Web' }), - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: 'https://web.link' }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should set conversationId and channel tags when provided', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = ExecuteToolScope.start( - { conversationId: 'conv-tool-123', channel: { name: 'ChannelTool', description: 'https://channel/tool' } }, - { toolName: 'test-tool' }, testAgentDetails - ); - expect(scope).toBeInstanceOf(ExecuteToolScope); - - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: 'conv-tool-123' }), - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: 'ChannelTool' }), - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: 'https://channel/tool' }) - ])); - - scope?.dispose(); - spy.mockRestore(); - }); - - }); - - describe('endpoint.port serialization', () => { - it('should record non-443 port as a number on ExecuteToolScope', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = ExecuteToolScope.start( - testRequest, { toolName: 'test-tool', endpoint: { host: 'tools.example.com', port: 8080 } }, - testAgentDetails - ); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY, val: 8080 }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should omit port 443 on ExecuteToolScope', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = ExecuteToolScope.start( - testRequest, { toolName: 'test-tool', endpoint: { host: 'tools.example.com', port: 443 } }, - testAgentDetails - ); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).not.toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should record non-443 port as a number on InferenceScope', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = InferenceScope.start( - testRequest, { operationName: InferenceOperationType.CHAT, model: 'gpt-4', providerName: 'openai', endpoint: { host: 'api.openai.com', port: 8443 } }, - testAgentDetails - ); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY, val: 8443 }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should record port 443 as a number on InferenceScope', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = InferenceScope.start( - testRequest, { operationName: InferenceOperationType.CHAT, model: 'gpt-4', providerName: 'openai', endpoint: { host: 'api.openai.com', port: 443 } }, - testAgentDetails - ); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY, val: 443 }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should record non-443 port as a number on InvokeAgentScope', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = InvokeAgentScope.start( - testRequest, - { endpoint: { host: 'agent.example.com', port: 9090 } }, - { agentId: 'test-agent', tenantId: 'test-tenant-456' } - ); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.SERVER_ADDRESS_KEY, val: 'agent.example.com' }), - expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY, val: 9090 }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should omit port 443 on InvokeAgentScope', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = InvokeAgentScope.start( - testRequest, - { endpoint: { host: 'agent.example.com', port: 443 } }, - { agentId: 'test-agent', tenantId: 'test-tenant-456' } - ); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).not.toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.SERVER_PORT_KEY }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - }); - - describe('InferenceScope', () => { - it('emits the model observation schema without changing source values', () => { - sharedExporter.reset(); - const scope = InferenceScope.start( - testRequest, - { - operationName: InferenceOperationType.TEXT_COMPLETION, - responseModel: 'gpt-4o-2024-08-06', - providerName: 'openai', - inputTokens: 120, - outputTokens: 40, - cacheReadInputTokens: 30, - cacheWriteInputTokens: 5, - endpoint: { host: 'api.openai.com', port: 443 }, - }, - { ...testAgentDetails, agentType: 'declarative' } - ); - scope.dispose(); - - const spans = sharedExporter.getFinishedSpans(); - const attributes = spans[spans.length - 1].attributes; - expect(attributes).toEqual(expect.objectContaining({ - [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: 'text_completion', - [OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY]: 'openai', - [OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY]: 'gpt-4o-2024-08-06', - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: 'test-agent', - [OpenTelemetryConstants.GEN_AI_AGENT_TYPE_KEY]: 'declarative', - [OpenTelemetryConstants.SERVER_ADDRESS_KEY]: 'api.openai.com', - [OpenTelemetryConstants.SERVER_PORT_KEY]: 443, - [OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY]: 120, - [OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY]: 40, - [OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY]: 30, - [OpenTelemetryConstants.GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY]: 5, - })); - expect(attributes[OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY]).toBeUndefined(); - }); - - it('requires at least a request or response model', () => { - expect(() => InferenceScope.start( - testRequest, - { operationName: InferenceOperationType.CHAT, providerName: 'openai' }, - testAgentDetails - )).toThrow('at least one of model or responseModel is required'); - }); - - it('requires a provider name', () => { - expect(() => InferenceScope.start( - testRequest, - { operationName: InferenceOperationType.CHAT, model: 'gpt-4', providerName: '' }, - testAgentDetails - )).toThrow('providerName is required'); - }); - - it('requires an integer endpoint port', () => { - expect(() => InferenceScope.start( - testRequest, - { - operationName: InferenceOperationType.CHAT, - model: 'gpt-4', - providerName: 'openai', - endpoint: { host: 'api.openai.com', port: 443.5 }, - }, - testAgentDetails - )).toThrow('endpoint port must be an integer'); - }); - - it.each([-1, 0, 65536])('rejects endpoint port %p as out of the valid 1-65535 range', (port) => { - expect(() => InferenceScope.start( - testRequest, - { - operationName: InferenceOperationType.CHAT, - model: 'gpt-4', - providerName: 'openai', - endpoint: { host: 'api.openai.com', port }, - }, - testAgentDetails - )).toThrow('endpoint port must be an integer between 1 and 65535'); - }); - - it.each([1, 443, 65535])('accepts endpoint port %p as within the valid 1-65535 range', (port) => { - expect(() => InferenceScope.start( - testRequest, - { - operationName: InferenceOperationType.CHAT, - model: 'gpt-4', - providerName: 'openai', - endpoint: { host: 'api.openai.com', port }, - }, - testAgentDetails - )).not.toThrow(); - }); - - it.each([ - [InferenceOperationType.CHAT, 'chat'], - [InferenceOperationType.TEXT_COMPLETION, 'text_completion'], - [InferenceOperationType.GENERATE_CONTENT, 'generate_content'], - ])('maps operationName %s to canonical gen_ai.operation.name %s for both the attribute and span name', (operationName, expected) => { - sharedExporter.reset(); - const scope = InferenceScope.start( - testRequest, - { operationName, model: 'gpt-4', providerName: 'openai' }, - testAgentDetails - ); - scope.dispose(); - - const spans = sharedExporter.getFinishedSpans(); - const span = spans[spans.length - 1]; - expect(span.attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe(expected); - expect(span.name).toBe(`${expected} gpt-4`); - }); - - it('treats an empty model string as absent and falls back to responseModel for the span name and request-model attribute', () => { - sharedExporter.reset(); - const scope = InferenceScope.start( - testRequest, - { operationName: InferenceOperationType.CHAT, model: '', responseModel: 'gpt-4o-2024-08-06', providerName: 'openai' }, - testAgentDetails - ); - scope.dispose(); - - const spans = sharedExporter.getFinishedSpans(); - const span = spans[spans.length - 1]; - expect(span.name).toBe('chat gpt-4o-2024-08-06'); - expect(span.attributes[OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY]).toBeUndefined(); - expect(span.attributes[OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY]).toBe('gpt-4o-2024-08-06'); - }); - - it('treats a whitespace-only model string as absent, same as an empty string', () => { - expect(() => InferenceScope.start( - testRequest, - { operationName: InferenceOperationType.CHAT, model: ' ', responseModel: ' ', providerName: 'openai' }, - testAgentDetails - )).toThrow('at least one of model or responseModel is required'); - }); - - it('should create scope with inference details', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const callerDetails: UserDetails = { - userId: 'caller-inf-1', - userEmail: 'inf.user@contoso.com', - userName: 'Inf User', - tenantId: 'inf-tenant', - callerClientIp: '10.0.0.20' - }; - const inferenceDetails: InferenceDetails = { - operationName: InferenceOperationType.CHAT, - model: 'gpt-4', - providerName: 'openai', - inputTokens: 100, - outputTokens: 150, - finishReasons: ['stop'] - }; - - const scope = InferenceScope.start(testRequest, inferenceDetails, testAgentDetails, callerDetails); - - expect(scope).toBeInstanceOf(InferenceScope); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.USER_ID_KEY, val: 'caller-inf-1' }), - expect.objectContaining({ key: OpenTelemetryConstants.USER_NAME_KEY, val: 'Inf User' }), - expect.objectContaining({ key: OpenTelemetryConstants.USER_EMAIL_KEY, val: 'inf.user@contoso.com' }), - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, val: '10.0.0.20' }) - ])); - // Validate raw attribute key strings for schema correctness - const keySet = new Set(calls.map(c => c.key)); - expect(keySet).toContain('user.id'); - expect(keySet).toContain('user.name'); - expect(keySet).toContain('user.email'); - expect(keySet).toContain('client.address'); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should create scope with minimal details', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const inferenceDetails: InferenceDetails = { - operationName: InferenceOperationType.TEXT_COMPLETION, - model: 'gpt-3.5-turbo', - providerName: 'openai' - }; - - const scope = InferenceScope.start( - { conversationId: 'conv-inf-min', channel: { name: 'Slack', description: 'https://slack.link' } }, - inferenceDetails, testAgentDetails - ); - - expect(scope).toBeInstanceOf(InferenceScope); - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: 'conv-inf-min' }), - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: 'Slack' }), - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: 'https://slack.link' }) - ])); - scope?.dispose(); - spy.mockRestore(); - }); - - it('should record granular telemetry', () => { - const inferenceDetails: InferenceDetails = { - operationName: InferenceOperationType.CHAT, - model: 'gpt-4', - providerName: 'openai' - }; - - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const scope = InferenceScope.start(testRequest, inferenceDetails, testAgentDetails); - - expect(() => scope?.recordInputMessages(['Input message'])).not.toThrow(); - expect(() => scope?.recordOutputMessages(['Generated response'])).not.toThrow(); - expect(() => scope?.recordInputTokens(50)).not.toThrow(); - expect(() => scope?.recordOutputTokens(100)).not.toThrow(); - expect(() => scope?.recordCacheReadInputTokens(10)).not.toThrow(); - expect(() => scope?.recordCacheWriteInputTokens(5)).not.toThrow(); - expect(() => scope?.recordFinishReasons(['stop', 'length'])).not.toThrow(); - - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY, val: 50 }), - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY, val: 100 }), - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, val: 10 }), - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, val: 5 }), - ])); - - scope?.dispose(); - spy.mockRestore(); - }); - - it('should set conversationId and channel tags when provided', () => { - const spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - const inferenceDetails: InferenceDetails = { - operationName: InferenceOperationType.CHAT, - model: 'gpt-4', - providerName: 'openai' - }; - - const scope = InferenceScope.start( - { conversationId: 'conv-inf-123', channel: { name: 'ChannelInf', description: 'https://channel/inf' } }, - inferenceDetails, testAgentDetails - ); - expect(scope).toBeInstanceOf(InferenceScope); - - const calls = spy.mock.calls.map(args => ({ key: args[0], val: args[1] })); - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, val: 'conv-inf-123' }), - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_NAME_KEY, val: 'ChannelInf' }), - expect.objectContaining({ key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: 'https://channel/inf' }) - ])); - - scope?.dispose(); - spy.mockRestore(); - }); - }); - - describe('Dispose pattern', () => { - it('should support manual dispose', () => { - const scope = InvokeAgentScope.start(testRequest, {}, { agentId: 'test-agent', tenantId: 'test-tenant-456' }); - scope?.recordResponse('Manual dispose test'); - - expect(() => scope?.dispose()).not.toThrow(); - }); - - it('should support automatic disposal pattern', () => { - const toolDetails: ToolCallDetails = { toolName: 'test-tool' }; - - expect(() => { - const scope = ExecuteToolScope.start(testRequest, toolDetails, testAgentDetails); - try { - scope?.recordResponse('Automatic disposal test'); - } finally { - scope?.dispose(); - } - }).not.toThrow(); - }); - }); - - describe('Custom start and end time', () => { - afterEach(() => { - sharedExporter.reset(); - }); - - /** Extract the last finished span from the in-memory exporter. */ - const getFinishedSpan = (): ReadableSpan => { - const spans = sharedExporter.getFinishedSpans(); - expect(spans.length).toBeGreaterThanOrEqual(1); - return spans[spans.length - 1]; - }; - - /** Convert an hrtime tuple to milliseconds. */ - const hrtimeToMs = (hr: [number, number]): number => hr[0] * 1000 + hr[1] / 1_000_000; - - it('should record constructor-provided start and end times on the span', () => { - const customStart = 1700000000000; // 2023-11-14T22:13:20Z - const customEnd = 1700000005000; // 5 seconds later - - const scope = ExecuteToolScope.start( - testRequest, { toolName: 'my-tool' }, testAgentDetails, - undefined, { startTime: customStart, endTime: customEnd } - ); - scope.dispose(); - - const span = getFinishedSpan(); - expect(hrtimeToMs(span.startTime as [number, number])).toBeCloseTo(customStart, -1); - expect(hrtimeToMs(span.endTime as [number, number])).toBeCloseTo(customEnd, -1); - }); - - it('setEndTime should override end time when called before dispose', () => { - const customStart = 1700000040000; - const laterEnd = 1700000048000; // 8 seconds later - - const scope = ExecuteToolScope.start( - testRequest, { toolName: 'my-tool' }, testAgentDetails, - undefined, { startTime: customStart } - ); - scope.setEndTime(laterEnd); - scope.dispose(); - - const span = getFinishedSpan(); - expect(hrtimeToMs(span.startTime as [number, number])).toBeCloseTo(customStart, -1); - expect(hrtimeToMs(span.endTime as [number, number])).toBeCloseTo(laterEnd, -1); - }); - - it('should support Date objects as start and end times', () => { - const customStart = new Date('2023-11-14T22:13:20.000Z'); - const customEnd = new Date('2023-11-14T22:13:25.000Z'); // 5 seconds later - - const scope = ExecuteToolScope.start( - testRequest, { toolName: 'my-tool' }, testAgentDetails, - undefined, { startTime: customStart, endTime: customEnd } - ); - scope.dispose(); - - const span = getFinishedSpan(); - expect(hrtimeToMs(span.startTime as [number, number])).toBeCloseTo(customStart.getTime(), -1); - expect(hrtimeToMs(span.endTime as [number, number])).toBeCloseTo(customEnd.getTime(), -1); - }); - - it('should support HrTime tuples as start and end times', () => { - // HrTime: [seconds, nanoseconds] - const customStart: [number, number] = [1700000000, 0]; // 2023-11-14T22:13:20Z - const customEnd: [number, number] = [1700000005, 500000000]; // 5.5 seconds later - - const scope = ExecuteToolScope.start( - testRequest, { toolName: 'my-tool' }, testAgentDetails, - undefined, { startTime: customStart, endTime: customEnd } - ); - scope.dispose(); - - const span = getFinishedSpan(); - expect(hrtimeToMs(span.startTime as [number, number])).toBeCloseTo(1700000000000, -1); - expect(hrtimeToMs(span.endTime as [number, number])).toBeCloseTo(1700000005500, -1); - }); - - it('should use wall-clock time when no custom times are provided', () => { - const before = Date.now(); - const scope = ExecuteToolScope.start(testRequest, { toolName: 'my-tool' }, testAgentDetails); - scope.dispose(); - const after = Date.now(); - - const span = getFinishedSpan(); - const spanStartMs = hrtimeToMs(span.startTime as [number, number]); - const spanEndMs = hrtimeToMs(span.endTime as [number, number]); - - expect(spanStartMs).toBeGreaterThanOrEqual(before - 1); - expect(spanEndMs).toBeLessThanOrEqual(after + 1); - }); - - it.each([ - ['CLIENT (default)', undefined, SpanKind.CLIENT], - ['SERVER', SpanKind.SERVER, SpanKind.SERVER], - ])('InvokeAgentScope spanKind: %s', (_label, input, expected) => { - const scope = InvokeAgentScope.start( - testRequest, - {}, - { agentId: 'test-agent', tenantId: 'test-tenant-456' }, - undefined, - input !== undefined ? { spanKind: input } : undefined - ); - scope.dispose(); - expect(getFinishedSpan().kind).toBe(expected); - }); - - it.each([ - ['INTERNAL (default)', undefined, SpanKind.INTERNAL], - ['CLIENT (override)', SpanKind.CLIENT, SpanKind.CLIENT], - ])('ExecuteToolScope spanKind: %s', (_label, input, expected) => { - const scope = ExecuteToolScope.start( - testRequest, { toolName: 'my-tool' }, testAgentDetails, - undefined, input !== undefined ? { spanKind: input } : undefined - ); - scope.dispose(); - expect(getFinishedSpan().kind).toBe(expected); - }); - - it('recordCancellation should set error status and error.type attribute with default reason', () => { - const scope = ExecuteToolScope.start( - testRequest, { toolName: 'my-tool' }, testAgentDetails - ); - scope.recordCancellation(); - scope.dispose(); - - const span = getFinishedSpan(); - expect(span.status.code).toBe(2); // SpanStatusCode.ERROR - expect(span.status.message).toBe('Task was cancelled'); - expect(span.attributes[OpenTelemetryConstants.ERROR_TYPE_KEY]).toBe('TaskCanceledException'); - }); - - it('recordCancellation should use custom reason', () => { - const scope = ExecuteToolScope.start( - testRequest, { toolName: 'my-tool' }, testAgentDetails - ); - scope.recordCancellation('User aborted'); - scope.dispose(); - - const span = getFinishedSpan(); - expect(span.status.code).toBe(2); // SpanStatusCode.ERROR - expect(span.status.message).toBe('User aborted'); - expect(span.attributes[OpenTelemetryConstants.ERROR_TYPE_KEY]).toBe('TaskCanceledException'); - }); - }); -}); - -describe('Request content and message serialization (span attributes)', () => { - const testAgentDetails: AgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent', - tenantId: 'test-tenant-456', - }; - const testRequest = { conversationId: 'conv-1', channel: { name: 'TestChannel' } }; - - beforeEach(() => { sharedExporter.reset(); }); - - const getLastSpan = (): ReadableSpan => { - const spans = sharedExporter.getFinishedSpans(); - expect(spans.length).toBeGreaterThanOrEqual(1); - return spans[spans.length - 1]; - }; - - describe('InvokeAgentScope – request.content as input messages', () => { - it('should record a single string as input message attribute', () => { - const scope = InvokeAgentScope.start({ ...testRequest, content: 'Hello agent' }, {}, testAgentDetails); - scope.dispose(); - - const attributes = getLastSpan().attributes; - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(1); - expect(parsed[0].role).toBe('user'); - expect(parsed[0].parts[0]).toEqual({ type: 'text', content: 'Hello agent' }); - }); - - it('should record a string array as input message attributes', () => { - const scope = InvokeAgentScope.start({ ...testRequest, content: ['msg1', 'msg2'] }, {}, testAgentDetails); - scope.dispose(); - - const attributes = getLastSpan().attributes; - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(2); - expect(parsed[0].parts[0].content).toBe('msg1'); - expect(parsed[1].parts[0].content).toBe('msg2'); - }); - - it('should record a structured InputMessages wrapper as-is', () => { - const wrapper = { - messages: [{ role: MessageRole.SYSTEM, parts: [{ type: 'text', content: 'system prompt' }] }], - }; - const scope = InvokeAgentScope.start({ ...testRequest, content: wrapper }, {}, testAgentDetails); - scope.dispose(); - - const attributes = getLastSpan().attributes; - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(1); - expect(parsed[0].role).toBe('system'); - }); - - it('should not set input messages when content is undefined', () => { - const scope = InvokeAgentScope.start(testRequest, {}, testAgentDetails); - scope.dispose(); - - const attributes = getLastSpan().attributes; - expect(attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY]).toBeUndefined(); - }); - }); - - describe('InvokeAgentScope – recordOutputMessages single string', () => { - it('should record a single string as output message attribute', () => { - const scope = InvokeAgentScope.start(testRequest, {}, testAgentDetails); - scope.recordOutputMessages('single output'); - scope.dispose(); - - const attributes = getLastSpan().attributes; - const parsed = JSON.parse(attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(1); - expect(parsed[0].role).toBe('assistant'); - expect(parsed[0].parts[0].content).toBe('single output'); - expect(parsed[0].finish_reason).toBe('stop'); - }); - }); - - describe('ExecuteToolScope – tool args and response serialization', () => { - it('should serialize object arguments to span attribute', () => { - const objArgs = { query: 'GDPR', maxResults: 5 }; - const scope = ExecuteToolScope.start(testRequest, { toolName: 'search', arguments: objArgs }, testAgentDetails); - scope.dispose(); - - const attributes = getLastSpan().attributes; - expect(attributes[OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY]).toBe(JSON.stringify(objArgs)); - }); - - it('should serialize object response to span attribute', () => { - const objResponse = { results: [{ title: 'Doc A', relevance: 0.95 }] }; - const scope = ExecuteToolScope.start(testRequest, { toolName: 'tool' }, testAgentDetails); - scope.recordResponse(objResponse); - scope.dispose(); - - const attributes = getLastSpan().attributes; - expect(attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY]).toBe(JSON.stringify(objResponse)); - }); - }); -}); - -// Validate attribute key constant values use the new schema namespace. -describe('Attribute key schema values', () => { - it('caller keys use user.* / client.* namespace', () => { - expect(OpenTelemetryConstants.USER_ID_KEY).toBe('user.id'); - expect(OpenTelemetryConstants.USER_NAME_KEY).toBe('user.name'); - expect(OpenTelemetryConstants.USER_EMAIL_KEY).toBe('user.email'); - expect(OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY).toBe('client.address'); - }); - - it('agent baggage keys use microsoft.agent.* namespace', () => { - expect(OpenTelemetryConstants.GEN_AI_AGENT_EMAIL_KEY).toBe('microsoft.agent.user.email'); - expect(OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY).toBe('microsoft.agent.user.id'); - }); - - it('caller agent keys use microsoft.a365.* namespace', () => { - expect(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY).toBe('microsoft.a365.caller.agent.id'); - expect(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY).toBe('microsoft.a365.caller.agent.name'); - expect(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY).toBe('microsoft.a365.caller.agent.blueprint.id'); - expect(OpenTelemetryConstants.GEN_AI_CALLER_AGENT_EMAIL_KEY).toBe('microsoft.a365.caller.agent.user.email'); - }); - - it('channel keys use microsoft.channel.* namespace', () => { - expect(OpenTelemetryConstants.CHANNEL_NAME_KEY).toBe('microsoft.channel.name'); - expect(OpenTelemetryConstants.CHANNEL_LINK_KEY).toBe('microsoft.channel.link'); - }); - - it('session and tenant keys use microsoft.* namespace', () => { - expect(OpenTelemetryConstants.SESSION_ID_KEY).toBe('microsoft.session.id'); - expect(OpenTelemetryConstants.SESSION_DESCRIPTION_KEY).toBe('microsoft.session.description'); - expect(OpenTelemetryConstants.TENANT_ID_KEY).toBe('microsoft.tenant.id'); - }); -}); - -describe('safeSerializeToJson', () => { - it('should serialize an object to JSON', () => { - const obj = { query: 'test', count: 5 }; - expect(safeSerializeToJson(obj, 'arguments')).toBe(JSON.stringify(obj)); - }); - - it('should return JSON error object for circular reference objects', () => { - const circular: Record = { a: 1 }; - circular.self = circular; - expect(safeSerializeToJson(circular, 'arguments')).toBe(JSON.stringify({ error: 'serialization failed' })); - }); - - it('should pass through a valid JSON object string as-is', () => { - expect(safeSerializeToJson('{"query":"test"}', 'arguments')).toBe('{"query":"test"}'); - }); - - it('should pass through a valid JSON array string as-is', () => { - expect(safeSerializeToJson('[1,2,3]', 'result')).toBe('[1,2,3]'); - }); - - it('should wrap a plain non-JSON string', () => { - expect(safeSerializeToJson('hello world', 'arguments')).toBe('{"arguments":"hello world"}'); - }); - - it('should wrap bare JSON primitives instead of passing through', () => { - expect(safeSerializeToJson('42', 'arguments')).toBe('{"arguments":"42"}'); - expect(safeSerializeToJson('true', 'result')).toBe('{"result":"true"}'); - expect(safeSerializeToJson('null', 'result')).toBe('{"result":"null"}'); - }); -}); diff --git a/tests/observability/core/span-links.test.ts b/tests/observability/core/span-links.test.ts deleted file mode 100644 index fd1acbf4..00000000 --- a/tests/observability/core/span-links.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, jest } from '@jest/globals'; -import { trace, TraceFlags, Link } from '@opentelemetry/api'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base'; - -import { - ExecuteToolScope, - InvokeAgentScope, - InferenceScope, - OutputScope, - AgentDetails, - ToolCallDetails, - InferenceDetails, - InferenceOperationType, - OutputResponse, - Request, - SpanDetails, -} from '@microsoft/agents-a365-observability'; - -// Mock console to avoid cluttering test output. -// Use beforeEach because jest config has restoreMocks: true which restores spies after each test. -beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); -}); - -describe('Span Links', () => { - const testAgentDetails: AgentDetails = { - agentId: 'test-agent', - agentName: 'Test Agent', - agentDescription: 'A test agent', - tenantId: 'test-tenant-456', - }; - - const testRequest: Request = { - conversationId: 'test-conv-123', - }; - - let exporter: InMemorySpanExporter; - let provider: BasicTracerProvider | undefined; - - beforeAll(() => { - exporter = new InMemorySpanExporter(); - const processor = new SimpleSpanProcessor(exporter); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const globalProvider: any = trace.getTracerProvider(); - if (globalProvider && typeof globalProvider.addSpanProcessor === 'function') { - globalProvider.addSpanProcessor(processor); - } else { - provider = new BasicTracerProvider({ - spanProcessors: [processor], - }); - trace.setGlobalTracerProvider(provider); - } - }); - - afterEach(() => { - exporter.reset(); - }); - - afterAll(async () => { - exporter.reset(); - await provider?.shutdown?.(); - }); - - /** Extract the last finished span from the in-memory exporter. */ - const getFinishedSpan = (): ReadableSpan => { - const spans = exporter.getFinishedSpans(); - expect(spans.length).toBeGreaterThanOrEqual(1); - return spans[spans.length - 1]; - }; - - const sampleLinks: Link[] = [ - { - context: { - traceId: '0aa4621e5ae09963a3de354f3d18aa65', - spanId: 'c1aaa519600b1bf0', - traceFlags: TraceFlags.SAMPLED, - }, - }, - { - context: { - traceId: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - spanId: 'aaaaaaaaaaaaaaaa', - traceFlags: TraceFlags.NONE, - }, - attributes: { 'link.reason': 'retry' }, - }, - ]; - - const spanDetailsWithLinks: SpanDetails = { spanLinks: sampleLinks }; - - it('should record span links with full context and attributes', () => { - const scope = ExecuteToolScope.start( - testRequest, - { toolName: 'my-tool' } as ToolCallDetails, - testAgentDetails, - undefined, - spanDetailsWithLinks - ); - scope.dispose(); - - const span = getFinishedSpan(); - expect(span.links).toHaveLength(2); - expect(span.links[0].context.traceId).toBe('0aa4621e5ae09963a3de354f3d18aa65'); - expect(span.links[0].context.spanId).toBe('c1aaa519600b1bf0'); - expect(span.links[0].context.traceFlags).toBe(TraceFlags.SAMPLED); - expect(span.links[1].context.traceId).toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); - expect(span.links[1].context.spanId).toBe('aaaaaaaaaaaaaaaa'); - expect(span.links[1].attributes?.['link.reason']).toBe('retry'); - }); - - it('should have empty links when spanLinks is omitted', () => { - const scope = ExecuteToolScope.start( - testRequest, - { toolName: 'my-tool' } as ToolCallDetails, - testAgentDetails - ); - scope.dispose(); - - const span = getFinishedSpan(); - expect(span.links).toHaveLength(0); - }); - - it('should preserve typed link attributes', () => { - const linksWithAttrs: Link[] = [ - { - context: { - traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - spanId: 'bbbbbbbbbbbbbbbb', - traceFlags: TraceFlags.SAMPLED, - }, - attributes: { 'link.type': 'causal', 'link.index': 0 }, - }, - ]; - - const scope = InvokeAgentScope.start( - testRequest, - {}, - { ...testAgentDetails, agentId: 'attr-agent' }, - undefined, - { spanLinks: linksWithAttrs } - ); - scope.dispose(); - - const span = getFinishedSpan(); - expect(span.links).toHaveLength(1); - expect(span.links[0].attributes?.['link.type']).toBe('causal'); - expect(span.links[0].attributes?.['link.index']).toBe(0); - }); - - it.each([ - ['InvokeAgentScope', () => InvokeAgentScope.start( - testRequest, {}, testAgentDetails, undefined, spanDetailsWithLinks)], - ['InferenceScope', () => InferenceScope.start( - testRequest, - { operationName: InferenceOperationType.CHAT, model: 'gpt-4', providerName: 'openai' } as InferenceDetails, - testAgentDetails, undefined, spanDetailsWithLinks)], - ['OutputScope', () => OutputScope.start( - testRequest, - { messages: ['hi'] } as OutputResponse, - testAgentDetails, undefined, spanDetailsWithLinks)], - ])('should forward span links on %s', (_name, createScope) => { - const scope = createScope(); - scope.dispose(); - - const span = getFinishedSpan(); - expect(span.links).toHaveLength(2); - expect(span.links[0].context.traceId).toBe('0aa4621e5ae09963a3de354f3d18aa65'); - expect(span.links[0].context.spanId).toBe('c1aaa519600b1bf0'); - expect(span.links[0].context.traceFlags).toBe(TraceFlags.SAMPLED); - expect(span.links[1].context.traceId).toBe('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); - expect(span.links[1].context.spanId).toBe('aaaaaaaaaaaaaaaa'); - expect(span.links[1].context.traceFlags).toBe(TraceFlags.NONE); - expect(span.links[1].attributes?.['link.reason']).toBe('retry'); - }); -}); diff --git a/tests/observability/core/token-context.test.ts b/tests/observability/core/token-context.test.ts deleted file mode 100644 index 25a60cf5..00000000 --- a/tests/observability/core/token-context.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; -import { context, createContextKey } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { - runWithExportToken, - updateExportToken, - getExportToken, -} from '@microsoft/agents-a365-observability/src/tracing/context/token-context'; - -describe('token-context', () => { - let contextManager: AsyncLocalStorageContextManager; - - beforeAll(() => { - contextManager = new AsyncLocalStorageContextManager(); - contextManager.enable(); - context.setGlobalContextManager(contextManager); - }); - - afterAll(() => { - contextManager.disable(); - context.disable(); - }); - - describe('updateExportToken', () => { - it('should return false when called outside runWithExportToken context', () => { - expect(updateExportToken('some-token')).toBe(false); - }); - - it('should return true and mutate the token visible to getExportToken', () => { - runWithExportToken('v1', () => { - expect(updateExportToken('v2')).toBe(true); - expect(getExportToken()).toBe('v2'); - - // successive updates keep working - expect(updateExportToken('v3')).toBe(true); - expect(getExportToken()).toBe('v3'); - }); - }); - }); - - describe('getExportToken - backward compatibility', () => { - it('should handle legacy raw string values stored directly in context', () => { - const legacyKey = createContextKey('a365_export_token'); - const ctxWithLegacyToken = context.active().setValue(legacyKey, 'legacy-string-token'); - - expect(getExportToken(ctxWithLegacyToken)).toBe('legacy-string-token'); - }); - - it('should return undefined for non-string, non-TokenHolder values in context', () => { - const key = createContextKey('a365_export_token'); - const ctxWithBadValue = context.active().setValue(key, 12345); - - expect(getExportToken(ctxWithBadValue)).toBeUndefined(); - }); - }); -}); diff --git a/tests/observability/core/trace-context-propagation.test.ts b/tests/observability/core/trace-context-propagation.test.ts deleted file mode 100644 index beb6c1b1..00000000 --- a/tests/observability/core/trace-context-propagation.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeEach, beforeAll, afterAll } from '@jest/globals'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { trace, context as otelContext, propagation, TraceFlags } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { W3CTraceContextPropagator } from '@opentelemetry/core'; -import { - ParentSpanRef, - injectContextToHeaders, - extractContextFromHeaders, - runWithExtractedTraceContext, - InvokeAgentScope, -} from '@microsoft/agents-a365-observability'; - -describe('Trace Context Propagation', () => { - let provider: BasicTracerProvider; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let flushProvider: any; - let exporter: InMemorySpanExporter; - let contextManager: AsyncLocalStorageContextManager; - - beforeAll(() => { - contextManager = new AsyncLocalStorageContextManager(); - contextManager.enable(); - otelContext.setGlobalContextManager(contextManager); - propagation.setGlobalPropagator(new W3CTraceContextPropagator()); - - exporter = new InMemorySpanExporter(); - const processor = new SimpleSpanProcessor(exporter); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const globalProvider: any = trace.getTracerProvider(); - if (globalProvider && typeof globalProvider.addSpanProcessor === 'function') { - globalProvider.addSpanProcessor(processor); - flushProvider = globalProvider; - } else { - provider = new BasicTracerProvider({ spanProcessors: [processor] }); - trace.setGlobalTracerProvider(provider); - flushProvider = provider; - } - }); - - beforeEach(() => exporter.reset()); - - afterAll(async () => { - exporter.reset(); - await provider?.shutdown?.(); - contextManager.disable(); - otelContext.disable(); - }); - - describe('injectContextToHeaders', () => { - it('should inject traceparent header from active span', () => { - const tracer = trace.getTracer('test'); - const span = tracer.startSpan('sender'); - const { traceId, spanId, traceFlags } = span.spanContext(); - const ctx = trace.setSpan(otelContext.active(), span); - - otelContext.with(ctx, () => { - const headers: Record = {}; - const result = injectContextToHeaders(headers); - expect(result).toBe(headers); - - const traceparent = headers['traceparent']; - expect(traceparent).toBeDefined(); - - // W3C traceparent format: {version}-{trace-id}-{parent-id}-{trace-flags} - const parts = traceparent.split('-'); - expect(parts).toHaveLength(4); - expect(parts[0]).toBe('00'); // version - expect(parts[1]).toBe(traceId); - expect(parts[2]).toBe(spanId); - expect(parts[3]).toBe(traceFlags.toString(16).padStart(2, '0')); - }); - - span.end(); - }); - - it('should be a no-op when no active span exists', () => { - const headers: Record = {}; - injectContextToHeaders(headers); - expect(headers['traceparent']).toBeUndefined(); - }); - }); - - describe('extractContextFromHeaders', () => { - it('should extract valid traceparent into Context with correct traceId/spanId', () => { - const traceId = '0af7651916cd43dd8448eb211c80319c'; - const spanId = 'b7ad6b7169203331'; - - const extracted = extractContextFromHeaders({ traceparent: `00-${traceId}-${spanId}-01` }); - const span = trace.getSpan(extracted); - - expect(span).toBeDefined(); - expect(span!.spanContext().traceId).toBe(traceId); - expect(span!.spanContext().spanId).toBe(spanId); - expect(span!.spanContext().traceFlags).toBe(TraceFlags.SAMPLED); - }); - - it('should return base context for missing or malformed headers', () => { - expect(trace.getSpan(extractContextFromHeaders({}))).toBeUndefined(); - expect(trace.getSpan(extractContextFromHeaders({ traceparent: 'invalid' }))).toBeUndefined(); - }); - }); - - describe('end-to-end inject → extract round-trip', () => { - it('should preserve trace and parent-child relationship across services', async () => { - const tracer = trace.getTracer('test'); - const senderSpan = tracer.startSpan('sender'); - const senderCtx = trace.setSpan(otelContext.active(), senderSpan); - - const headers: Record = {}; - otelContext.with(senderCtx, () => injectContextToHeaders(headers)); - - const result = runWithExtractedTraceContext(headers, () => { - const child = tracer.startSpan('receiver'); - expect(child.spanContext().traceId).toBe(senderSpan.spanContext().traceId); - child.end(); - return 'ok'; - }); - expect(result).toBe('ok'); - - senderSpan.end(); - await flushProvider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const receiver = spans.find(s => s.name === 'receiver'); - expect(receiver!.spanContext().traceId).toBe(senderSpan.spanContext().traceId); - expect(receiver!.parentSpanContext?.spanId).toBe(senderSpan.spanContext().spanId); - }); - }); - - describe('scope with extracted Context as ParentContext', () => { - it('should create scope as child of extracted trace context', async () => { - const traceId = '0af7651916cd43dd8448eb211c80319c'; - const spanId = 'b7ad6b7169203331'; - const extractedCtx = extractContextFromHeaders({ traceparent: `00-${traceId}-${spanId}-01` }); - - const scope = InvokeAgentScope.start( - {}, - {}, - { agentId: 'ctx-agent', tenantId: 'test-tenant' }, - undefined, - { parentContext: extractedCtx } - ); - expect(scope.getSpanContext().traceId).toBe(traceId); - scope.dispose(); - - await flushProvider.forceFlush(); - - const span = exporter.getFinishedSpans().find(s => s.name.toLowerCase().includes('invoke_agent')); - expect(span!.parentSpanContext?.spanId).toBe(spanId); - }); - }); - - describe('ParentSpanRef with isRemote', () => { - it('should propagate isRemote=true to child span parentSpanContext', async () => { - const parentRef: ParentSpanRef = { - traceId: '3af7651916cd43dd8448eb211c80319c', - spanId: 'e7ad6b7169203331', - traceFlags: TraceFlags.SAMPLED, - isRemote: true, - }; - - const scope = InvokeAgentScope.start( - {}, - {}, - { agentId: 'remote-agent', tenantId: 'test-tenant' }, - undefined, - { parentContext: parentRef } - ); - scope.dispose(); - - await flushProvider.forceFlush(); - - const span = exporter.getFinishedSpans().find(s => s.spanContext().traceId === parentRef.traceId); - expect(span!.parentSpanContext?.spanId).toBe(parentRef.spanId); - expect(span!.parentSpanContext?.isRemote).toBe(true); - }); - }); -}); diff --git a/tests/observability/extension/helpers/message-schema-validator.ts b/tests/observability/extension/helpers/message-schema-validator.ts deleted file mode 100644 index c3e874f6..00000000 --- a/tests/observability/extension/helpers/message-schema-validator.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { expect } from '@jest/globals'; - -function validateMessageArray(value: unknown): Array> { - // Attributes are stored as JSON strings; parse and validate the plain array format. - expect(typeof value).toBe('string'); - const parsed = JSON.parse(value as string); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - return parsed; -} - -function validateMessagePart(part: Record): void { - // Validate required fields and type-specific shape for each message part. - expect(typeof part.type).toBe('string'); - expect((part.type as string).length).toBeGreaterThan(0); - - const type = part.type as string; - if (type === 'text' || type === 'reasoning') { - expect(typeof part.content).toBe('string'); - } else if (type === 'tool_call') { - expect(typeof part.name).toBe('string'); - if (part.id !== undefined) expect(typeof part.id).toBe('string'); - } else if (type === 'tool_call_response') { - if (part.id !== undefined) expect(typeof part.id).toBe('string'); - } else if (type === 'blob' || type === 'file' || type === 'uri') { - expect(part).toHaveProperty('modality'); - } -} - -export function expectValidInputMessages(value: unknown): void { - // Input messages must have a role and at least one valid part. - const parsed = validateMessageArray(value); - for (const msg of parsed) { - expect(typeof msg.role).toBe('string'); - const parts = msg.parts as Array>; - expect(parts.length).toBeGreaterThan(0); - parts.forEach(validateMessagePart); - } -} - -export function expectValidOutputMessages(value: unknown): void { - // Output messages follow the same structure, with optional finish_reason. - const parsed = validateMessageArray(value); - for (const msg of parsed) { - expect(typeof msg.role).toBe('string'); - const parts = msg.parts as Array>; - expect(parts.length).toBeGreaterThan(0); - parts.forEach(validateMessagePart); - if (msg.finish_reason !== undefined) { - expect(typeof msg.finish_reason).toBe('string'); - } - } -} - -export function getSpanAttribute(mockSpan: { setAttribute: jest.Mock }, key: string): unknown { - // Helper to read a specific attribute from a mocked span. - const match = mockSpan.setAttribute.mock.calls.find(([k]: [string, unknown]) => k === key); - return match ? match[1] : undefined; -} - -export function getAttrFromArray(attrs: Array<[string, unknown]>, key: string): unknown { - // Helper to read a key from an array of [key, value] tuples. - const entry = attrs.find(([k]) => k === key); - return entry ? entry[1] : undefined; -} diff --git a/tests/observability/extension/hosting/BaggageBuilderUtils.test.ts b/tests/observability/extension/hosting/BaggageBuilderUtils.test.ts deleted file mode 100644 index 023d2d03..00000000 --- a/tests/observability/extension/hosting/BaggageBuilderUtils.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { BaggageBuilderUtils } from '@microsoft/agents-a365-observability-hosting'; -import { BaggageBuilder, OpenTelemetryConstants } from '@microsoft/agents-a365-observability'; - -describe('BaggageBuilderUtils', () => { - const mockTurnContext = { - activity: { - from: { id: 'user1', name: 'User One', agenticUserId: 'agentic-user-1', tenantId: 'tenant1', role: 'user' }, - recipient: { id: 'agent1', name: 'Agent One', agenticAppId: 'agent-app-1', agenticUserId: 'agentic-agent-1', tenantId: 'tenant1', role: 'agent' }, - conversation: { id: 'conv-1', tenantId: 'tenant1' }, - isAgenticRequest: () => true, - getAgenticInstanceId: () => 'agent-app-1', - getAgenticUser: () => 'agentic-agent-1', - getAgenticTenantId: () => 'tenant1', - }, - } as any; - - it('should populate all baggage pairs from TurnContext', () => { - // Mock BaggageBuilder to capture setPairs calls - const capturedPairs: Array<[string, string]> = []; - class MockBaggageBuilder extends BaggageBuilder { - setPairs(pairs: Record | Iterable<[string, any]> | null | undefined): this { - if (pairs) { - let entries: Iterable<[string, any]>; - if (Symbol.iterator in Object(pairs)) { - 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 MockBaggageBuilder(); - const result = BaggageBuilderUtils.fromTurnContext(builder, mockTurnContext); - expect(result).toBe(builder); - // Validate every expected OpenTelemetry baggage key and value - const asObj = Object.fromEntries(capturedPairs); - expect(asObj[OpenTelemetryConstants.USER_ID_KEY]).toBeUndefined(); - expect(asObj[OpenTelemetryConstants.USER_NAME_KEY]).toBe('User One'); - expect(asObj[OpenTelemetryConstants.USER_EMAIL_KEY]).toBe('agentic-user-1'); - 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_AUID_KEY]).toBeUndefined(); - expect(asObj[OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY]).toBe('agent'); - expect(asObj[OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY]).toBeUndefined(); - expect(asObj[OpenTelemetryConstants.GEN_AI_AGENT_EMAIL_KEY]).toBeUndefined(); - expect(asObj[OpenTelemetryConstants.TENANT_ID_KEY]).toBe('tenant1'); - }); - - it('should throw if turnContext is missing', () => { - const builder = new BaggageBuilder(); - expect(() => BaggageBuilderUtils.fromTurnContext(builder, undefined as any)).toThrow(); - }); -}); diff --git a/tests/observability/extension/hosting/TurnContextUtils.test.ts b/tests/observability/extension/hosting/TurnContextUtils.test.ts deleted file mode 100644 index 675c01ce..00000000 --- a/tests/observability/extension/hosting/TurnContextUtils.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -import { - getCallerBaggagePairs, - getTargetAgentBaggagePairs, - getTenantIdPair, - getChannelBaggagePairs, - getConversationIdAndItemLinkPairs -} from '@microsoft/agents-a365-observability-hosting'; -import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability'; - -describe('TurnContextUtils', () => { - const mockTurnContext = { - activity: { - from: { id: 'user1', name: 'User One', agenticUserId: 'agentic-user-1', tenantId: 'tenant1', role: 'agenticUser' }, - recipient: { id: 'agent1', name: 'Agent One', agenticAppId: 'agent-app-1', agenticUserId: 'agentic-agent-1', tenantId: 'tenant1', role: 'agenticUser' }, - conversation: { id: 'conv-1', tenantId: 'tenant1' }, - text: 'Hello world', - isAgenticRequest: () => true, - getAgenticInstanceId: () => 'agent-app-1', - getAgenticUser: () => 'agentic-agent-1', - getAgenticTenantId: () => 'tenant1', - }, - } as any; - - it('should get caller baggage pairs', () => { - const pairs = getCallerBaggagePairs(mockTurnContext); - expect(Array.isArray(pairs)).toBe(true); - expect(pairs.length).toBeGreaterThan(0); - }); - - it('should get target agent baggage pairs', () => { - const pairs = getTargetAgentBaggagePairs(mockTurnContext); - 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-app-1'); - expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]).toBe('Agent One'); - expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_AUID_KEY]).toBeUndefined(); - expect(obj[OpenTelemetryConstants.GEN_AI_AGENT_BLUEPRINT_ID_KEY]).toBeUndefined(); - }); - - it('should get tenant id pair', () => { - const pairs = getTenantIdPair(mockTurnContext); - expect(Array.isArray(pairs)).toBe(true); - expect(pairs.length).toBeGreaterThan(0); - }); - - it('should get channel baggage pairs', () => { - const pairs = getChannelBaggagePairs(mockTurnContext); - expect(Array.isArray(pairs)).toBe(true); - }); - - it('should get conversation id and item link pairs', () => { - const pairs = getConversationIdAndItemLinkPairs(mockTurnContext); - expect(Array.isArray(pairs)).toBe(true); - }); -}); diff --git a/tests/observability/extension/hosting/agentic-token-cache.test.ts b/tests/observability/extension/hosting/agentic-token-cache.test.ts deleted file mode 100644 index 9cccf58d..00000000 --- a/tests/observability/extension/hosting/agentic-token-cache.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { AgenticTokenCache, ObservabilityTokenResolver } from '@microsoft/agents-a365-observability-hosting'; -import { ObservabilityConfiguration, resetLogger, setLogger } from '@microsoft/agents-a365-observability'; -import type { Authorization, TurnContext } from '@microsoft/agents-hosting'; - -const obsScopes = ['api://9b975845-388f-4429-889e-eab1ef63949c/.default']; - -function makeJwtWithExp(expSecondsFromNow: number, claims: Record = {}): string { - const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); - const payload = Buffer.from(JSON.stringify({ - exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, - idtyp: 'app', - ...claims, - })).toString('base64url'); - return `${header}.${payload}.test-signature`; -} - -describe('AgenticTokenCache app-only OBS authentication', () => { - let cache: AgenticTokenCache; - - beforeEach(() => { - cache = new AgenticTokenCache(); - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('returns null when no entry exists', () => { - expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); - }); - - it('passes exporting identity and configured OBS scopes to an app-only resolver', async () => { - const token = makeJwtWithExp(300); - const resolver = jest.fn, Parameters>(() => token); - - await cache.RefreshObservabilityToken('agent', 'tenant', resolver); - - expect(resolver).toHaveBeenCalledTimes(1); - expect(resolver).toHaveBeenCalledWith('agent', 'tenant', obsScopes); - expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); - }); - - it('uses a configuration provider for app-only scopes', async () => { - const scopes = ['api://custom-obs/.default']; - cache = new AgenticTokenCache({ - getConfiguration: () => new ObservabilityConfiguration({ - observabilityAuthenticationScopes: () => scopes, - }), - }); - const resolver = jest.fn(() => makeJwtWithExp(300)); - - await cache.RefreshObservabilityToken('agent', 'tenant', resolver); - - expect(resolver).toHaveBeenCalledWith('agent', 'tenant', scopes); - }); - - it.each([ - { description: 'absent', roles: undefined }, - { description: 'empty', roles: [] }, - ])('caches an explicitly app-only token with $description roles', async ({ roles }) => { - const token = makeJwtWithExp(300, { roles }); - await cache.RefreshObservabilityToken('agent', 'tenant', () => token); - expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); - }); - - it('deduplicates concurrent roleless-token acquisitions for the same identity', async () => { - const token = makeJwtWithExp(300); - const resolver = jest.fn(async () => token); - await Promise.all(Array.from({ length: 8 }, () => - cache.RefreshObservabilityToken('agent', 'tenant', resolver))); - expect(resolver).toHaveBeenCalledTimes(1); - expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); - }); - - it('fails for an empty scope configuration without requesting a token', async () => { - cache = new AgenticTokenCache({ - getConfiguration: () => new ObservabilityConfiguration({ - observabilityAuthenticationScopes: () => [], - }), - }); - const resolver = jest.fn(() => makeJwtWithExp(300)); - await expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toThrow('No valid scopes'); - expect(resolver).not.toHaveBeenCalled(); - }); - - it.each(['agentic', 'obo'])('ignores the removed %s user-authorization overload without exchanging a token', async (handler) => { - const exchangeToken = jest.fn(); - const authorization: Authorization = { - exchangeToken, - getToken: jest.fn(), - signOut: jest.fn(), - onSignInSuccess: jest.fn(), - onSignInFailure: jest.fn(), - }; - const context = {} as TurnContext; - const error = jest.fn(); - setLogger({ info: jest.fn(), warn: jest.fn(), error, event: jest.fn() }); - // Untyped (JavaScript) callers can still reach the removed overload at runtime. - const untypedRefresh = cache.RefreshObservabilityToken.bind(cache) as unknown as (...args: unknown[]) => Promise; - - try { - await expect(untypedRefresh('agent', 'tenant', context, authorization, obsScopes, handler)).resolves.toBeUndefined(); - await expect(untypedRefresh('agent', 'tenant', context, authorization, obsScopes, handler)).resolves.toBeUndefined(); - - expect(exchangeToken).not.toHaveBeenCalled(); - expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); - expect(error.mock.calls.filter(([message]) => String(message).includes('was removed in 2.0.0'))).toHaveLength(1); - } finally { - resetLogger(); - } - }); - - it('no longer types the removed TurnContext/Authorization overload', async () => { - setLogger({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), event: jest.fn() }); - try { - // @ts-expect-error The TurnContext/Authorization overload was removed in 2.0.0; callers must pass a token resolver. - await cache.RefreshObservabilityToken('agent', 'tenant', {} as TurnContext, {} as Authorization, obsScopes); - } finally { - resetLogger(); - } - }); - - it.each([['', 'tenant'], ['agent', ' ']])('rejects empty identity (%s, %s)', async (agent, tenant) => { - const resolver = jest.fn(() => makeJwtWithExp(300)); - await expect(cache.RefreshObservabilityToken(agent, tenant, resolver)).rejects.toThrow('Agent and tenant IDs'); - expect(resolver).not.toHaveBeenCalled(); - }); - - it('retries a transient acquisition failure then caches the app-only token', async () => { - const token = makeJwtWithExp(300); - const resolver = jest.fn() - .mockRejectedValueOnce({ status: 500, message: 'service unavailable' }) - .mockResolvedValueOnce(token); - - const pending = cache.RefreshObservabilityToken('agent', 'tenant', resolver); - await jest.advanceTimersByTimeAsync(1000); - await pending; - - expect(resolver).toHaveBeenCalledTimes(2); - expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); - }); - - it('surfaces a permanent acquisition failure and clears stale tokens', async () => { - await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(120)); - jest.advanceTimersByTime(61_000); - const error = new Error('permission denied'); - const resolver = jest.fn().mockRejectedValue(error); - - await expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toBe(error); - - expect(resolver).toHaveBeenCalledTimes(1); - expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); - }); - - it('surfaces exhausted transient failures without caching a token', async () => { - const error = { status: 503, message: 'service unavailable' }; - const resolver = jest.fn().mockRejectedValue(error); - const pending = expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toBe(error); - await jest.advanceTimersByTimeAsync(1000); - await pending; - expect(resolver).toHaveBeenCalledTimes(3); - expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); - }); - - it.each([null, '', ' '])('surfaces an empty resolver result (%s)', async (token) => { - await expect(cache.RefreshObservabilityToken('agent', 'tenant', () => token)).rejects.toThrow('returned no token'); - expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); - }); - - it.each([-30, 0, 30])('does not return a token expiring in %s seconds', async (seconds) => { - await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(seconds)); - expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); - }); - - it('reuses a cached token and refreshes after expiry skew', async () => { - const token = makeJwtWithExp(120); - const resolver = jest.fn(() => token); - await cache.RefreshObservabilityToken('agent', 'tenant', resolver); - await cache.RefreshObservabilityToken('agent', 'tenant', resolver); - expect(resolver).toHaveBeenCalledTimes(1); - expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); - jest.advanceTimersByTime(61_000); - expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); - resolver.mockReturnValue(makeJwtWithExp(300)); - await cache.RefreshObservabilityToken('agent', 'tenant', resolver); - expect(resolver).toHaveBeenCalledTimes(2); - }); - - it('isolates tokens by agent and tenant', async () => { - const resolver = (agent: string, tenant: string) => `${agent}-${tenant}`; - await cache.RefreshObservabilityToken('one', 'tenant-a', resolver); - await cache.RefreshObservabilityToken('two', 'tenant-a', resolver); - await cache.RefreshObservabilityToken('one', 'tenant-b', resolver); - expect(cache.getObservabilityToken('one', 'tenant-a')).toBe('one-tenant-a'); - expect(cache.getObservabilityToken('two', 'tenant-a')).toBe('two-tenant-a'); - expect(cache.getObservabilityToken('one', 'tenant-b')).toBe('one-tenant-b'); - }); - - it('uses a fresh fallback TTL when an opaque token replaces an expired JWT', async () => { - await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(120)); - jest.advanceTimersByTime(61_000); - await cache.RefreshObservabilityToken('agent', 'tenant', () => 'opaque-app-only-token'); - expect(cache.getObservabilityToken('agent', 'tenant')).toBe('opaque-app-only-token'); - jest.advanceTimersByTime(3_600_000); - expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); - }); - - it('evicts the oldest token when the cache reaches capacity', async () => { - const token = makeJwtWithExp(300); - const capacity = cache['_maxCacheSize']; - const resolver = () => token; - for (let i = 0; i <= capacity; i++) { - await cache.RefreshObservabilityToken(`agent-${i}`, 'tenant', resolver); - } - expect(cache.getObservabilityToken('agent-0', 'tenant')).toBeNull(); - expect(cache.getObservabilityToken('agent-1', 'tenant')).toBe(token); - expect(cache.getObservabilityToken(`agent-${capacity}`, 'tenant')).toBe(token); - }); - - it('caps token lifetime to 24 hours', async () => { - const token = makeJwtWithExp(48 * 60 * 60); - await cache.RefreshObservabilityToken('agent', 'tenant', () => token); - jest.advanceTimersByTime(24 * 60 * 60 * 1000); - expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); - }); - - it('invalidates one token independently, then all tokens', async () => { - const token = makeJwtWithExp(300); - await cache.RefreshObservabilityToken('one', 'tenant', () => token); - await cache.RefreshObservabilityToken('two', 'tenant', () => token); - cache.invalidateToken('one', 'tenant'); - expect(cache.getObservabilityToken('one', 'tenant')).toBeNull(); - expect(cache.getObservabilityToken('two', 'tenant')).toBe(token); - cache.invalidateAll(); - expect(cache.getObservabilityToken('two', 'tenant')).toBeNull(); - }); -}); diff --git a/tests/observability/extension/hosting/baggage-middleware.test.ts b/tests/observability/extension/hosting/baggage-middleware.test.ts deleted file mode 100644 index f1428e79..00000000 --- a/tests/observability/extension/hosting/baggage-middleware.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { trace, context as otelContext, propagation } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { RoleTypes, ActivityTypes, ActivityEventNames } from '@microsoft/agents-activity'; -import type { TurnContext } from '@microsoft/agents-hosting'; - -import { BaggageMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware'; -import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability'; - -function makeMockTurnContext(options?: { - text?: string; - recipientId?: string; - recipientTenantId?: string; - channelId?: string; - conversationId?: string; - fromRole?: string; - activityType?: string; - activityName?: string; -}): TurnContext { - const recipientTenantId = options?.recipientTenantId ?? 'tenant-123'; - const ctx: any = { - activity: { - type: options?.activityType, - name: options?.activityName, - text: options?.text ?? 'Hello agent', - channelId: options?.channelId ?? 'web', - conversation: { id: options?.conversationId ?? 'conv-001' }, - from: { - role: options?.fromRole ?? RoleTypes.User, - aadObjectId: 'user-oid', - name: 'Test User', - agenticUserId: 'user@contoso.com', - tenantId: 'from-tenant', - }, - recipient: { - agenticAppId: options?.recipientId ?? 'agent-1', - name: 'Agent One', - aadObjectId: 'agent-oid', - agenticAppBlueprintId: 'blueprint-1', - agenticUserId: 'agent@contoso.com', - role: 'assistant', - tenantId: recipientTenantId, - }, - getAgenticTenantId: () => recipientTenantId, - getAgenticUser: () => 'agent@contoso.com', - getAgenticInstanceId: () => options?.recipientId ?? 'agent-1', - isAgenticRequest: () => false, - }, - turnState: new Map(), - }; - - return ctx; -} - -describe('BaggageMiddleware', () => { - let provider: BasicTracerProvider; - let contextManager: AsyncLocalStorageContextManager; - - 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 { - provider = new BasicTracerProvider({ - spanProcessors: [processor] - }); - trace.setGlobalTracerProvider(provider); - } - }); - - afterAll(async () => { - await provider?.shutdown?.(); - contextManager.disable(); - otelContext.disable(); - }); - - it('should propagate baggage context during turn', async () => { - const middleware = new BaggageMiddleware(); - const ctx = makeMockTurnContext(); - let 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.GEN_AI_AGENT_ID_KEY]).toBeUndefined(); - 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: ActivityTypes.Event, - activityName: ActivityEventNames.ContinueConversation, - }); - let 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: ActivityTypes.Event, - activityName: ActivityEventNames.ContinueConversation, - }); - let nextCalled = false; - - await middleware.onTurn(ctx, async () => { nextCalled = true; }); - - expect(nextCalled).toBe(true); - }); - - it('should extract productContext from channelData when channelIdSubChannel is not set', async () => { - const middleware = new BaggageMiddleware(); - const ctx: any = makeMockTurnContext({ channelId: 'msteams' }); - - // Add channelData with productContext, no channelIdSubChannel - ctx.activity.channelData = { productContext: 'COPILOT' }; - ctx.activity.channelIdSubChannel = undefined; - - let capturedChannelLink: string | undefined; - - await middleware.onTurn(ctx, async () => { - const bag = propagation.getBaggage(otelContext.active()); - if (bag) { - const entry = bag.getEntry(OpenTelemetryConstants.CHANNEL_LINK_KEY); - capturedChannelLink = entry?.value; - } - }); - - expect(capturedChannelLink).toBe('COPILOT'); - }); - - it('should use channelIdSubChannel when both channelIdSubChannel and productContext are present', async () => { - const middleware = new BaggageMiddleware(); - const ctx: any = makeMockTurnContext({ channelId: 'msteams' }); - - // Set BOTH channelIdSubChannel and productContext in channelData - ctx.activity.channelIdSubChannel = 'teams-subchannel'; - ctx.activity.channelData = { productContext: 'COPILOT' }; - - let capturedChannelLink: string | undefined; - - await middleware.onTurn(ctx, async () => { - const bag = propagation.getBaggage(otelContext.active()); - if (bag) { - const entry = bag.getEntry(OpenTelemetryConstants.CHANNEL_LINK_KEY); - capturedChannelLink = entry?.value; - } - }); - - // channelIdSubChannel should take precedence, productContext should be ignored - expect(capturedChannelLink).toBe('teams-subchannel'); - }); - - it('should extract productContext from channelData when it is a JSON string', async () => { - const middleware = new BaggageMiddleware(); - const ctx: any = makeMockTurnContext({ channelId: 'msteams' }); - - // Set channelData as a JSON string (simulating wire format) - ctx.activity.channelIdSubChannel = undefined; - ctx.activity.channelData = JSON.stringify({ productContext: 'COPILOT' }); - - let capturedChannelLink: string | undefined; - - await middleware.onTurn(ctx, async () => { - const bag = propagation.getBaggage(otelContext.active()); - if (bag) { - const entry = bag.getEntry(OpenTelemetryConstants.CHANNEL_LINK_KEY); - capturedChannelLink = entry?.value; - } - }); - - expect(capturedChannelLink).toBe('COPILOT'); - }); - - it('should not set channel link when channelData is invalid JSON', async () => { - const middleware = new BaggageMiddleware(); - const ctx: any = makeMockTurnContext({ channelId: 'msteams' }); - - // Set channelData as an invalid JSON string - ctx.activity.channelIdSubChannel = undefined; - ctx.activity.channelData = 'not valid json'; - - let capturedChannelLink: string | undefined; - - await middleware.onTurn(ctx, async () => { - const bag = propagation.getBaggage(otelContext.active()); - if (bag) { - const entry = bag.getEntry(OpenTelemetryConstants.CHANNEL_LINK_KEY); - capturedChannelLink = entry?.value; - } - }); - - // Channel link should not be set when JSON parsing fails - expect(capturedChannelLink).toBeUndefined(); - }); -}); diff --git a/tests/observability/extension/hosting/observability-hosting-manager.test.ts b/tests/observability/extension/hosting/observability-hosting-manager.test.ts deleted file mode 100644 index 7b2d5db7..00000000 --- a/tests/observability/extension/hosting/observability-hosting-manager.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect } from '@jest/globals'; -import { ObservabilityHostingManager } from '../../../../packages/agents-a365-observability-hosting/src/middleware/ObservabilityHostingManager'; -import { BaggageMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/BaggageMiddleware'; -import { OutputLoggingMiddleware } from '../../../../packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware'; - -function mockAdapter() { - const registered: any[] = []; - return { use(...mw: any[]) { registered.push(...mw); }, registered }; -} - -describe('ObservabilityHostingManager', () => { - it('does not register BaggageMiddleware by default', () => { - const adapter = mockAdapter(); - new ObservabilityHostingManager().configure(adapter, {}); - expect(adapter.registered).toHaveLength(0); - }); - - it('registers BaggageMiddleware when enableBaggage is true', () => { - const adapter = mockAdapter(); - new ObservabilityHostingManager().configure(adapter, { enableBaggage: true }); - expect(adapter.registered).toHaveLength(1); - expect(adapter.registered[0]).toBeInstanceOf(BaggageMiddleware); - }); - - it('registers both middleware when enableBaggage and enableOutputLogging are true', () => { - const adapter = mockAdapter(); - new ObservabilityHostingManager().configure(adapter, { enableBaggage: true, enableOutputLogging: true }); - expect(adapter.registered).toHaveLength(2); - expect(adapter.registered[0]).toBeInstanceOf(BaggageMiddleware); - expect(adapter.registered[1]).toBeInstanceOf(OutputLoggingMiddleware); - }); - - it('registers only OutputLoggingMiddleware when enableOutputLogging is true and enableBaggage is omitted', () => { - const adapter = mockAdapter(); - new ObservabilityHostingManager().configure(adapter, { enableOutputLogging: true }); - expect(adapter.registered).toHaveLength(1); - expect(adapter.registered[0]).toBeInstanceOf(OutputLoggingMiddleware); - }); - - it('subsequent configure calls on same instance are no-ops', () => { - const adapter = mockAdapter(); - const manager = new ObservabilityHostingManager(); - manager.configure(adapter, { enableBaggage: true, enableOutputLogging: true }); - manager.configure(adapter, { enableBaggage: true, enableOutputLogging: true }); - expect(adapter.registered).toHaveLength(2); - }); -}); diff --git a/tests/observability/extension/hosting/output-logging-middleware.test.ts b/tests/observability/extension/hosting/output-logging-middleware.test.ts deleted file mode 100644 index 6ef92e7d..00000000 --- a/tests/observability/extension/hosting/output-logging-middleware.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Mock isPerRequestExportEnabled before importing the middleware -const mockIsPerRequestExportEnabled = jest.fn().mockReturnValue(false); -jest.mock('@microsoft/agents-a365-observability', () => { - const actual = jest.requireActual('@microsoft/agents-a365-observability'); - return { - ...actual, - isPerRequestExportEnabled: (...args: unknown[]) => mockIsPerRequestExportEnabled(...args), - }; -}); - -// Mock AgenticTokenCacheInstance -const mockGetObservabilityToken = jest.fn().mockReturnValue(null); -jest.mock('../../../../packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache', () => ({ - AgenticTokenCacheInstance: { - getObservabilityToken: (...args: [string, string]) => mockGetObservabilityToken(...args), - }, -})); - -import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; -import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { trace, context as otelContext } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { RoleTypes, ActivityTypes, ActivityEventNames } from '@microsoft/agents-activity'; -import type { TurnContext, SendActivitiesHandler } from '@microsoft/agents-hosting'; - -import { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY, A365_AUTH_TOKEN_KEY } from '../../../../packages/agents-a365-observability-hosting/src/middleware/OutputLoggingMiddleware'; -import { OpenTelemetryConstants, ParentSpanRef } from '@microsoft/agents-a365-observability'; - -function makeMockTurnContext(options?: { - text?: string; - recipientId?: string; - recipientName?: string; - recipientTenantId?: string; - channelId?: string; - conversationId?: string; - fromRole?: string; - activityType?: string; - activityName?: string; -}): TurnContext & { _sendHandlers: SendActivitiesHandler[]; turnState: Map; simulateSend: (activities: Array<{ type?: string; text?: string }>) => Promise> } { - const sendHandlers: SendActivitiesHandler[] = []; - - const recipientTenantId = options?.recipientTenantId ?? 'tenant-123'; - const ctx: any = { - activity: { - type: options?.activityType, - name: options?.activityName, - text: options?.text ?? 'Hello agent', - channelId: options?.channelId ?? 'web', - conversation: { id: options?.conversationId ?? 'conv-001' }, - from: { - role: options?.fromRole ?? RoleTypes.User, - aadObjectId: 'user-oid', - name: 'Test User', - agenticUserId: 'user@contoso.com', - tenantId: 'from-tenant', - }, - recipient: { - agenticAppId: options?.recipientId ?? 'agent-1', - name: options?.recipientName ?? 'Agent One', - aadObjectId: 'agent-oid', - agenticAppBlueprintId: 'blueprint-1', - agenticUserId: 'agent@contoso.com', - role: 'assistant', - tenantId: recipientTenantId, - }, - getAgenticTenantId: () => recipientTenantId, - getAgenticUser: () => 'agent@contoso.com', - getAgenticInstanceId: () => options?.recipientId ?? 'agent-1', - isAgenticRequest: () => false, - }, - turnState: new Map(), - onSendActivities(handler: SendActivitiesHandler) { - sendHandlers.push(handler); - return ctx; - }, - _sendHandlers: sendHandlers, - 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, activities as any, prev as any); - } - return await current(); - }, - }; - - return ctx; -} - -describe('OutputLoggingMiddleware', () => { - let exporter: InMemorySpanExporter; - let provider: BasicTracerProvider; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let flushProvider: any; - 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); - flushProvider = globalProvider; - } else { - provider = new BasicTracerProvider({ - spanProcessors: [processor] - }); - trace.setGlobalTracerProvider(provider); - flushProvider = provider; - } - }); - - beforeEach(() => { - exporter.reset(); - mockIsPerRequestExportEnabled.mockReturnValue(false); - mockGetObservabilityToken.mockReturnValue(null); - }); - - afterAll(async () => { - exporter.reset(); - await provider?.shutdown?.(); - contextManager.disable(); - otelContext.disable(); - }); - - it('should create OutputScope for outgoing messages', async () => { - const middleware = new OutputLoggingMiddleware(); - const ctx = makeMockTurnContext({ text: 'Hello' }); - - 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(); - expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY]).toBe( - JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'Hi there!' }], finish_reason: 'stop' }]) - ); - }); - - it('should skip non-message activities in OutputScope', async () => { - const middleware = new OutputLoggingMiddleware(); - const ctx = makeMockTurnContext({ text: 'Hello' }); - - await middleware.onTurn(ctx, async () => { - await ctx.simulateSend([ - { type: 'typing' }, - { type: 'event', text: 'some event data' }, - ]); - }); - - await flushProvider.forceFlush(); - expect(exporter.getFinishedSpans().find(s => s.name.includes('output_messages'))).toBeUndefined(); - }); - - it('should pass through without tracing for skip conditions', async () => { - const middleware = new OutputLoggingMiddleware(); - - // Missing agent details (no recipient) - const ctx1: any = { activity: { text: 'Hello', isAgenticRequest: () => false, getAgenticTenantId: () => undefined, getAgenticUser: () => undefined }, turnState: new Map(), onSendActivities: jest.fn() }; - let nextCalled = false; - await middleware.onTurn(ctx1, async () => { nextCalled = true; }); - expect(nextCalled).toBe(true); - - // Missing tenant details (recipient but no tenantId) - const ctx2: any = { - activity: { text: 'Hello', recipient: { agenticAppId: 'agent-1', name: 'Agent' }, getAgenticTenantId: () => undefined, getAgenticUser: () => undefined, isAgenticRequest: () => false }, - turnState: new Map(), - onSendActivities: jest.fn(), - }; - nextCalled = false; - await middleware.onTurn(ctx2, async () => { nextCalled = true; }); - expect(nextCalled).toBe(true); - }); - - it('should set caller details and enrichment on OutputScope span', async () => { - const middleware = new OutputLoggingMiddleware(); - const ctx = makeMockTurnContext({ text: 'Hello', channelId: 'teams' }); - - 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 in turnState', async () => { - const middleware = new OutputLoggingMiddleware(); - const ctx = makeMockTurnContext({ text: 'Hello' }); - - const parentSpanRef: 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(); - expect(outputSpan!.parentSpanContext?.traceId).toBe(parentSpanRef.traceId); - expect(outputSpan!.parentSpanContext?.spanId).toBe(parentSpanRef.spanId); - }); - - it('should create OutputScope for async reply (ContinueConversation) when messages are sent', async () => { - const middleware = new OutputLoggingMiddleware(); - const ctx = makeMockTurnContext({ - text: 'Hello', - activityType: ActivityTypes.Event, - activityName: ActivityEventNames.ContinueConversation, - }); - - await middleware.onTurn(ctx, async () => { - await ctx.simulateSend([{ type: 'message', text: 'Async reply' }]); - }); - - await flushProvider.forceFlush(); - const outputSpan = exporter.getFinishedSpans().find(s => s.name.includes('output_messages')); - expect(outputSpan).toBeDefined(); - expect(outputSpan!.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY]).toBe( - JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'Async reply' }], finish_reason: 'stop' }]) - ); - }); - - it('should link async reply OutputScope to parent when parentSpanRef is set', async () => { - const middleware = new OutputLoggingMiddleware(); - const ctx = makeMockTurnContext({ - text: 'Hello', - activityType: ActivityTypes.Event, - activityName: ActivityEventNames.ContinueConversation, - }); - - const parentSpanRef: ParentSpanRef = { - traceId: '1af7651916cd43dd8448eb211c80319c', - spanId: 'c7ad6b7169203331', - traceFlags: 1, - }; - ctx.turnState.set(A365_PARENT_SPAN_KEY, parentSpanRef); - - await middleware.onTurn(ctx, async () => { - await ctx.simulateSend([{ type: 'message', text: 'Async reply' }]); - }); - - await flushProvider.forceFlush(); - const outputSpan = exporter.getFinishedSpans().find(s => s.name.includes('output_messages')); - expect(outputSpan).toBeDefined(); - expect(outputSpan!.parentSpanContext?.traceId).toBe(parentSpanRef.traceId); - expect(outputSpan!.parentSpanContext?.spanId).toBe(parentSpanRef.spanId); - }); - - it('should not create spans when no messages are sent', async () => { - const middleware = new OutputLoggingMiddleware(); - const ctx = makeMockTurnContext({ - text: 'Hello', - activityType: ActivityTypes.Event, - activityName: ActivityEventNames.ContinueConversation, - }); - - await middleware.onTurn(ctx, async () => { - // next() without sending any messages - }); - - await flushProvider.forceFlush(); - expect(exporter.getFinishedSpans().length).toBe(0); - }); - - it('should re-throw errors from sendNext after recording on OutputScope', async () => { - const middleware = new OutputLoggingMiddleware(); - const ctx = makeMockTurnContext({ text: 'Hello' }); - const sendError = new Error('send pipeline failed'); - - // Override simulateSend to make the send pipeline throw - ctx.simulateSend = async () => { throw sendError; }; - - // Register handler manually since simulateSend is overridden - await middleware.onTurn(ctx, async () => { - ctx.turnState.set(A365_PARENT_SPAN_KEY, { traceId: '0af7651916cd43dd8448eb211c80319c', spanId: 'b7ad6b7169203331', traceFlags: 1 }); - // Simulate the handler being called with message activities - const handler = ctx._sendHandlers[0]; - if (handler) { - await expect( - handler(ctx, [{ type: 'message', text: 'Reply' }] as any, async () => { throw sendError; }) - ).rejects.toThrow('send pipeline failed'); - } - }); - }); - - describe('resolveAuthToken', () => { - it('should read token from turnState when per-request export is enabled', async () => { - mockIsPerRequestExportEnabled.mockReturnValue(true); - const middleware = new OutputLoggingMiddleware(); - const ctx = makeMockTurnContext({ text: 'Hello' }); - ctx.turnState.set(A365_AUTH_TOKEN_KEY, 'per-request-token'); - - 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' }]); - }); - - expect(mockIsPerRequestExportEnabled).toHaveBeenCalled(); - expect(mockGetObservabilityToken).not.toHaveBeenCalled(); - }); - - it('should read token from cache when per-request export is disabled', async () => { - mockIsPerRequestExportEnabled.mockReturnValue(false); - mockGetObservabilityToken.mockReturnValue('cached-obs-token'); - const middleware = new OutputLoggingMiddleware(); - const ctx = makeMockTurnContext({ text: 'Hello', recipientId: 'agent-1', recipientTenantId: 'tenant-123' }); - - 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' }]); - }); - - expect(mockGetObservabilityToken).toHaveBeenCalledWith('agent-1', 'tenant-123'); - }); - - it('should skip cache when per-request export is disabled and agentId is empty', async () => { - mockIsPerRequestExportEnabled.mockReturnValue(false); - const middleware = new OutputLoggingMiddleware(); - const ctx: any = { - activity: { - text: 'Hello', - channelId: 'web', - conversation: { id: 'conv-001' }, - from: { role: RoleTypes.User, aadObjectId: 'user-oid', name: 'User', tenantId: 't' }, - recipient: { name: 'Agent', aadObjectId: 'oid', role: 'assistant' }, - getAgenticTenantId: () => 'tenant-123', - getAgenticUser: () => 'agent@contoso.com', - getAgenticInstanceId: () => '', - isAgenticRequest: () => false, - }, - turnState: new Map(), - onSendActivities: jest.fn(), - }; - - let nextCalled = false; - await middleware.onTurn(ctx, async () => { nextCalled = true; }); - - expect(nextCalled).toBe(true); - expect(mockGetObservabilityToken).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/observability/extension/hosting/scope-utils.test.ts b/tests/observability/extension/hosting/scope-utils.test.ts deleted file mode 100644 index ffe42c52..00000000 --- a/tests/observability/extension/hosting/scope-utils.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -// ------------------------------------------------------------------------------ - -// Mock RuntimeUtility.getAgentIdFromToken so tests don't depend on real JWT parsing -jest.mock('@microsoft/agents-a365-runtime', () => { - const actual = jest.requireActual('@microsoft/agents-a365-runtime'); - return { - ...actual, - Utility: { - ...actual.Utility, - getAgentIdFromToken: () => 'test-blueprint-id', - }, - }; -}); - -import { ScopeUtils } from '../../../../packages/agents-a365-observability-hosting/src/utils/ScopeUtils'; -import { InferenceScope, InvokeAgentScope, ExecuteToolScope, OpenTelemetryConstants, OpenTelemetryScope } from '@microsoft/agents-a365-observability'; -import { SpanKind } from '@opentelemetry/api'; -import { RoleTypes } from '@microsoft/agents-activity'; -import type { TurnContext } from '@microsoft/agents-hosting'; - -const testAuthToken = 'mock-auth-token'; - -function makeTurnContext( - text?: string, - channelName?: string, - channelLink?: string, - conversationId?: string, -): TurnContext { - const base: any = { - activity: { - text: text ?? 'hello world', - channelId: channelName ?? 'web', - channelIdSubChannel: channelLink ?? 'https://example/channel', - conversation: { id: conversationId ?? 'conv-001', tenantId: 'tenant-123' }, - isAgenticRequest: () => true, - getAgenticInstanceId: () => 'agent-1', - getAgenticUser: () => 'agent-upn@contoso.com', - getAgenticTenantId: () => 'tenant-123', - } - }; - - base.activity.from = { - role: RoleTypes.User, - aadObjectId: 'user-oid', - name: 'Test User', - agenticUserId: 'user@contoso.com', - tenantId: 'tenant-xyz', - agenticAppBlueprintId: 'caller-agentBlueprintId', - agenticAppId: 'callerAgent-1' - }; - base.activity.recipient = { - agenticAppId: 'agent-1', - agenticAppBlueprintId: 'agent-blueprint-1', - name: 'Agent One', - aadObjectId: 'agent-oid', - agenticUserId: 'agent-upn@contoso.com', - role: 'assistant', - tenantId: 'tenant-123' - }; - return base as TurnContext; -} - -describe('ScopeUtils.populateFromTurnContext', () => { - let spy: jest.SpyInstance; - beforeEach(() => { - spy = jest.spyOn(OpenTelemetryScope.prototype as any, 'setTagMaybe'); - }); - - afterEach(() => { - spy.mockRestore(); - }); - - test('build InferenceScope based on turn context', () => { - 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) as InferenceScope; - 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.GEN_AI_AGENT_BLUEPRINT_ID_KEY, 'test-blueprint-id'], - [OpenTelemetryConstants.GEN_AI_AGENT_EMAIL_KEY, 'agent-upn@contoso.com'], - [OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, 'assistant'], - [OpenTelemetryConstants.TENANT_ID_KEY, 'tenant-123'], - [OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: 'input text' }] }])] - ]) - ); - scope?.dispose(); - }); - - // Error path coverage for missing required fields in populate* helpers - describe('error conditions', () => { - test('populateInferenceScopeFromTurnContext throws when agent details are missing', () => { - const details: any = { operationName: 'inference', model: 'm', providerName: 'prov' }; - const ctx = makeCtx({ activity: { /* no recipient */ getAgenticTenantId: () => 't1' } as any }); - expect(() => ScopeUtils.populateInferenceScopeFromTurnContext(details, ctx, testAuthToken)) - .toThrow('populateInferenceScopeFromTurnContext: Missing agent details on TurnContext (recipient)'); - }); - - test('populateInferenceScopeFromTurnContext throws when tenant details are missing', () => { - const details: any = { operationName: 'inference', model: 'm', providerName: 'prov' }; - const ctx = makeCtx({ activity: { recipient: { agenticAppId: 'aid' }, isAgenticRequest: () => false, getAgenticInstanceId: () => 'aid', getAgenticUser: () => undefined, getAgenticTenantId: () => undefined } as any }); // agent ok, no tenantId - expect(() => ScopeUtils.populateInferenceScopeFromTurnContext(details, ctx, testAuthToken)) - .toThrow('InferenceScope: tenantId is required on agentDetails'); - }); - - test('populateExecuteToolScopeFromTurnContext throws when agent details are missing', () => { - const details: any = { toolName: 'tool' }; - const ctx = makeCtx({ activity: { /* no recipient */ getAgenticTenantId: () => 't1' } as any }); - expect(() => ScopeUtils.populateExecuteToolScopeFromTurnContext(details, ctx, testAuthToken)) - .toThrow('populateExecuteToolScopeFromTurnContext: Missing agent details on TurnContext (recipient)'); - }); - - test('populateExecuteToolScopeFromTurnContext throws when tenant details are missing', () => { - const details: any = { toolName: 'tool' }; - const ctx = makeCtx({ activity: { recipient: { agenticAppId: 'aid' }, isAgenticRequest: () => false, getAgenticInstanceId: () => 'aid', getAgenticUser: () => undefined, getAgenticTenantId: () => undefined } as any }); // agent ok, no tenantId - expect(() => ScopeUtils.populateExecuteToolScopeFromTurnContext(details, ctx, testAuthToken)) - .toThrow('ExecuteToolScope: tenantId is required on agentDetails'); - }); - - test('populateInvokeAgentScopeFromTurnContext throws when tenant details are missing', () => { - const ctx = makeCtx({ activity: { recipient: { agenticAppId: 'aid' }, isAgenticRequest: () => false, getAgenticInstanceId: () => 'aid', getAgenticUser: () => undefined, getAgenticTenantId: () => undefined } as any }); // no tenantId - expect(() => ScopeUtils.populateInvokeAgentScopeFromTurnContext({ agentId: 'aid' } as any, {}, ctx, testAuthToken)) - .toThrow('InvokeAgentScope: tenantId is required on agentDetails'); - }); - }); - - test('build InvokeAgentScope based on turn context', () => { - const ctx = makeTurnContext('invoke message', 'teams', 'https://teams', 'conv-B'); - ctx.activity.from!.role = RoleTypes.AgenticUser; - const scope = ScopeUtils.populateInvokeAgentScopeFromTurnContext({ agentId: 'invoke-agent', providerName: 'internal' } as any, {}, ctx, testAuthToken) as InvokeAgentScope; - expect(scope).toBeInstanceOf(InvokeAgentScope); - const calls = spy.mock.calls.map(args => [args[0], args[1]]); - const expected = [ - [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.GEN_AI_CALLER_AGENT_USER_ID_KEY, 'user-oid'], - [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY, 'Test User'], - [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_ID_KEY, 'callerAgent-1'], - [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, 'caller-agentBlueprintId'], - [OpenTelemetryConstants.GEN_AI_CALLER_AGENT_EMAIL_KEY, 'user@contoso.com'], - [OpenTelemetryConstants.TENANT_ID_KEY, 'tenant-123'], - [OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: 'invoke message' }] }])], - [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY, 'agent-1'], - [OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY, 'Agent One'], - [OpenTelemetryConstants.GEN_AI_AGENT_DESCRIPTION_KEY, 'assistant'] - ]; - expect(calls).toEqual(expect.arrayContaining(expected)); - scope?.dispose(); - }); - - test('build ExecuteToolScope based on turn context', () => { - const details = { toolName: 'search', arguments: '{}' } as any; - const ctx = makeTurnContext(undefined, 'cli', 'https://cli', 'conv-C'); - const scope = ScopeUtils.populateExecuteToolScopeFromTurnContext(details, ctx, testAuthToken) as ExecuteToolScope; - 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(); - }); -}); - -// Simple helper to craft partial TurnContext objects for edge cases -function makeCtx(partial: Partial): TurnContext { - return partial as unknown as TurnContext; -} - -test('deriveAgentDetails maps recipient fields to AgentDetails', () => { - const ctx = makeCtx({ activity: { recipient: { name: 'A', aadObjectId: 'auid', role: 'bot' }, isAgenticRequest: () => false, getAgenticInstanceId: () => 'aid', getAgenticUser: () => 'upn1', getAgenticTenantId: () => 't1' } as any }); - expect(ScopeUtils.deriveAgentDetails(ctx, testAuthToken)).toEqual({ - agentId: undefined, - agentName: 'A', - agentAUID: 'auid', - agentBlueprintId: undefined, - agentEmail: 'upn1', - agentDescription: 'bot', - tenantId: 't1', - }); -}); - -test('deriveAgentDetails returns undefined without recipient', () => { - const ctx = makeCtx({ activity: {} as any }); - expect(ScopeUtils.deriveAgentDetails(ctx, testAuthToken)).toBeUndefined(); -}); - -test('deriveCallerAgent maps 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' } } as any }); - expect(ScopeUtils.deriveCallerAgent(ctx)).toEqual({ - agentBlueprintId: 'bp', - agentName: 'Caller', - agentAUID: 'uid', - agentEmail: 'caller-upn', - agentDescription: 'agent', - tenantId: 't2', - agentId: 'agent-caller', - }); -}); - -test('deriveCallerAgent returns undefined without from', () => { - const ctx = makeCtx({ activity: {} as any }); - expect(ScopeUtils.deriveCallerAgent(ctx)).toBeUndefined(); -}); - -test('deriveCallerDetails maps from to UserDetails', () => { - const ctx = makeCtx({ activity: { from: { aadObjectId: 'uid', agenticUserId: 'upn', name: 'User', tenantId: 't3' } } as any }); - expect(ScopeUtils.deriveCallerDetails(ctx)).toEqual({ - userId: 'uid', - userEmail: 'upn', - userName: 'User', - tenantId: 't3', - }); -}); - -test('deriveCallerDetails returns undefined without from', () => { - const ctx = makeCtx({ activity: {} as any }); - expect(ScopeUtils.deriveCallerDetails(ctx)).toBeUndefined(); -}); - -test('deriveConversationId returns id when present', () => { - const ctx = makeCtx({ activity: { conversation: { id: 'conv-1' } } as any }); - expect(ScopeUtils.deriveConversationId(ctx)).toBe('conv-1'); -}); - -test('deriveConversationId returns undefined when missing', () => { - const ctx = makeCtx({ activity: {} as any }); - expect(ScopeUtils.deriveConversationId(ctx)).toBeUndefined(); -}); - -test('deriveChannelObject maps channel name/description', () => { - const ctx = makeCtx({ activity: { channelId: 'teams', channelIdSubChannel: 'chat' } as any }); - expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ name: 'teams', description: 'chat' }); -}); - -test('deriveChannelObject returns undefined fields when none', () => { - const ctx = makeCtx({ activity: {} as any }); - expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ name: undefined, description: undefined }); -}); - -test('buildInvokeAgentDetails merges agent (recipient) into details', () => { - const ctx = makeCtx({ - activity: { - recipient: { name: 'Rec', role: 'bot' }, - conversation: { id: 'c-2' }, - channelId: 'web', - channelIdSubChannel: 'inbox', - isAgenticRequest: () => false, - getAgenticInstanceId: () => 'rec-agent', - getAgenticUser: () => undefined, - getAgenticTenantId: () => 'tX', - } as any - }); - - const result = ScopeUtils.buildInvokeAgentDetails({ agentId: 'provided' } as any, ctx, testAuthToken); - // Agent identity is merged into result - expect(result.agentName).toBe('Rec'); -}); - -test('buildInvokeAgentDetails keeps base details when TurnContext has no overrides', () => { - const ctx = makeCtx({ activity: {} as any }); - const result = ScopeUtils.buildInvokeAgentDetails({ agentId: 'base-agent' } as any, ctx, testAuthToken); - expect(result.agentId).toBe('base-agent'); -}); - -describe('ScopeUtils spanKind forwarding', () => { - test('populateInvokeAgentScopeFromTurnContext forwards SpanKind.SERVER', () => { - const spy = jest.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(); - }); - - test('populateExecuteToolScopeFromTurnContext forwards SpanKind.CLIENT', () => { - const spy = jest.spyOn(ExecuteToolScope, 'start'); - const ctx = makeTurnContext(undefined, 'cli', 'https://cli', 'conv-span'); - const scope = ScopeUtils.populateExecuteToolScopeFromTurnContext( - { toolName: 'search' }, 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(); - }); -}); diff --git a/tests/observability/extension/langchain/LangChainMessageContract.test.ts b/tests/observability/extension/langchain/LangChainMessageContract.test.ts deleted file mode 100644 index d6ea598c..00000000 --- a/tests/observability/extension/langchain/LangChainMessageContract.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { HumanMessage, AIMessage, SystemMessage, ToolMessage } from "@langchain/core/messages"; -import { Run } from "@langchain/core/tracers/base"; -import { Span } from "@opentelemetry/api"; -import { OpenTelemetryConstants } from "@microsoft/agents-a365-observability"; -import * as Utils from "../../../../packages/agents-a365-observability-extensions-langchain/src/Utils"; -import { expectValidInputMessages, expectValidOutputMessages, getSpanAttribute } from "../helpers/message-schema-validator"; - -describe("LangChain Message Contract Tests", () => { - let mockSpan: { setAttribute: jest.Mock; setStatus: jest.Mock; end: jest.Mock; recordException: jest.Mock }; - - beforeEach(() => { - mockSpan = { - setAttribute: jest.fn(), - setStatus: jest.fn(), - end: jest.fn(), - recordException: jest.fn(), - }; - }); - - function getInputAttr(): string { - const value = getSpanAttribute(mockSpan, OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY); - expect(value).toBeDefined(); - return value as string; - } - - function getOutputAttr(): string { - const value = getSpanAttribute(mockSpan, OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY); - expect(value).toBeDefined(); - return value as string; - } - - function setInput(messages: unknown[]): void { - const run: Partial = { run_type: "llm", inputs: { messages: [messages] } }; - Utils.setInputMessagesAttribute(run as Run, mockSpan as unknown as Span); - } - - describe("Input messages from real LangChain types", () => { - it("should map a full conversation with all message types", () => { - setInput([ - new SystemMessage("You are a weather assistant."), - new HumanMessage("What's the weather in Seattle?"), - new AIMessage({ - content: "Let me check.", - tool_calls: [{ name: "get_weather", args: { city: "Seattle" }, id: "call_1" }], - }), - new ToolMessage({ content: "Rainy, 45°F", tool_call_id: "call_1" }), - ]); - - const value = getInputAttr(); - expectValidInputMessages(value); - - const parsed = JSON.parse(value); - const roles = parsed.map((m: Record) => m.role); - expect(roles).toEqual(["system", "user", "assistant", "tool"]); - - expect(parsed[0].parts[0].content).toBe("You are a weather assistant."); - expect(parsed[1].parts[0].content).toBe("What's the weather in Seattle?"); - - const aiParts = parsed[2].parts; - expect(aiParts.find((p: Record) => p.type === "text").content).toBe("Let me check."); - const toolCallPart = aiParts.find((p: Record) => p.type === "tool_call"); - expect(toolCallPart.name).toBe("get_weather"); - expect(toolCallPart.id).toBe("call_1"); - - expect(parsed[3].parts[0].content).toBe("Rainy, 45°F"); - }); - }); - - describe("Output messages from real LangChain types", () => { - it("should map AIMessage text output via generations", () => { - const run: Partial = { - run_type: "llm", - outputs: { generations: [[{ text: "Hello!", message: new AIMessage("Hello!") }]] }, - }; - Utils.setOutputMessagesAttribute(run as Run, mockSpan as unknown as Span); - - const value = getOutputAttr(); - expectValidOutputMessages(value); - - const parsed = JSON.parse(value); - expect(parsed[0].role).toBe("assistant"); - expect(parsed[0].parts[0].content).toBe("Hello!"); - }); - - it("should map AIMessage with tool_calls in output via generations", () => { - const aiMsg = new AIMessage({ - content: "", - tool_calls: [{ name: "search", args: { query: "weather" }, id: "call_456" }], - }); - const run: Partial = { - run_type: "llm", - outputs: { generations: [[{ text: "", message: aiMsg }]] }, - }; - Utils.setOutputMessagesAttribute(run as Run, mockSpan as unknown as Span); - - const value = getOutputAttr(); - expectValidOutputMessages(value); - - const toolPart = JSON.parse(value)[0].parts.find((p: Record) => p.type === "tool_call"); - expect(toolPart.name).toBe("search"); - expect(toolPart.id).toBe("call_456"); - }); - - it("should map direct output messages array (LangGraph path)", () => { - const run: Partial = { - run_type: "chain", - serialized: { id: ["langgraph", "graph", "CompiledStateGraph"] }, - outputs: { messages: [new AIMessage("Task complete.")] }, - }; - Utils.setOutputMessagesAttribute(run as Run, mockSpan as unknown as Span); - - const value = getOutputAttr(); - expectValidOutputMessages(value); - }); - }); -}); diff --git a/tests/observability/extension/langchain/LangChainObservabilityAttributes.test.ts b/tests/observability/extension/langchain/LangChainObservabilityAttributes.test.ts deleted file mode 100644 index c4983ce6..00000000 --- a/tests/observability/extension/langchain/LangChainObservabilityAttributes.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { Run } from "@langchain/core/tracers/base"; -import { Span } from "@opentelemetry/api"; -import { OpenTelemetryConstants } from "@microsoft/agents-a365-observability"; -import * as Utils from "../../../../packages/agents-a365-observability-extensions-langchain/src/Utils"; -import { expectValidInputMessages, expectValidOutputMessages, getSpanAttribute } from "../helpers/message-schema-validator"; - -describe("LangChain Observability - InvokeAgentScope Attributes", () => { - let mockSpan: Partial; - - beforeEach(() => { - mockSpan = { - setAttribute: jest.fn(), - setStatus: jest.fn(), - end: jest.fn(), - recordException: jest.fn(), - }; - }); - - describe("InvokeAgentScope - Agent Invoke Execution (LangGraph chain run)", () => { - it("should extract operation type as agent_invoke for LangGraph chain", () => { - Utils.setOperationTypeAttribute("agent_invoke", mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, - "agent_invoke" - ); - }); - - it("should extract input messages (user role only) for agent invoke", () => { - const run: Partial = { - inputs: { - messages: [ - { - role: "user", - content: "hi", - }, - ], - }, - }; - - Utils.setInputMessagesAttribute(run as Run, mockSpan as Span); - - const inputValue = getSpanAttribute(mockSpan as any, OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY); - expectValidInputMessages(inputValue); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, - JSON.stringify([{"role":"user","parts":[{"type":"text","content":"hi"}]}]) - ); - }); - - it("should extract input messages from LangChain HumanMessage format", () => { - const run: Partial = { - inputs: { - messages: [ - { - lc_type: "human", - lc_kwargs: { - content: "hello agent", - }, - }, - ], - }, - }; - - Utils.setInputMessagesAttribute(run as Run, mockSpan as Span); - - const inputValue = getSpanAttribute(mockSpan as any, OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY); - expectValidInputMessages(inputValue); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, - JSON.stringify([{"role":"user","parts":[{"type":"text","content":"hello agent"}]}]) - ); - }); - - it("should extract output messages (assistant role only) for agent response", () => { - const run: Partial = { - outputs: { - messages: [ - { - lc_type: "ai", - lc_kwargs: { - content: "Hello! How can I assist you today?", - }, - }, - ], - }, - }; - - Utils.setOutputMessagesAttribute(run as Run, mockSpan as Span); - - const outputValue = getSpanAttribute(mockSpan as any, OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY); - expectValidOutputMessages(outputValue); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY, - JSON.stringify([{"role":"assistant","parts":[{"type":"text","content":"Hello! How can I assist you today?"}]}]) - ); - }); - - it("should map conversation_id to gen_ai.conversation.id and session_id to session.id", () => { - const run: Partial = { - extra: { - metadata: { - conversation_id: "conv-789", - session_id: "sess-123", - }, - }, - }; - - Utils.setSessionIdAttribute(run as Run, mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY, - "conv-789" - ); - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.SESSION_ID_KEY, - "sess-123" - ); - }); - - }); -}); - -describe("LangChain Observability - ExecuteToolScope Attributes", () => { - let mockSpan: Partial; - - beforeEach(() => { - mockSpan = { - setAttribute: jest.fn(), - setStatus: jest.fn(), - end: jest.fn(), - recordException: jest.fn(), - }; - }); - - describe("ExecuteToolScope - Tool Execution (LangChain tool run)", () => { - it("should extract operation type as execute_tool", () => { - Utils.setOperationTypeAttribute("execute_tool", mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY, - "execute_tool" - ); - }); - - it("should extract tool attributes", () => { - const run: Partial = { - run_type: "tool", - name: "get_weather", - serialized: { - name: "get_weather", - }, - inputs: { - input: '{"city": "Seattle"}', - }, - outputs: { - output: { - lc: 1, - type: "constructor", - id: [ - "langchain_core", - "messages", - "ToolMessage" - ], - kwargs: { - status: "success", - content: "The weather in Seattle is currently rainy with a temperature of 39°C.", - tool_call_id: "call_8urCabH7q39GzN0rMdN2BjaJ", - name: "get_weather", - metadata: {}, - additional_kwargs: {}, - response_metadata: {} - } - } - } - }; - - Utils.setToolAttributes(run as Run, mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY, - "extension" - ); - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_TOOL_NAME_KEY, - "get_weather" - ); - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY, - '{"result":"The weather in Seattle is currently rainy with a temperature of 39°C."}' - ); - // Tool args: string input is already valid JSON, passed through by safeSerializeToJson - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY, - '{"city": "Seattle"}' - ); - }); - - it("should extract tool result from v1 plain string output", () => { - const run: Partial = { - run_type: "tool", - name: "get_weather", - serialized: { name: "get_weather" }, - inputs: { - input: '{"city": "Seattle"}', - tool_call_id: "call_v1_abc123", - }, - outputs: { - output: "Sunny, 25°C in Seattle.", - }, - }; - - Utils.setToolAttributes(run as Run, mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY, - '{"result":"Sunny, 25°C in Seattle."}' - ); - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY, - '{"city": "Seattle"}' - ); - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_TOOL_CALL_ID_KEY, - "call_v1_abc123" - ); - }); - - }); -}); - -describe("LangChain Observability - InferenceScope Attributes", () => { - let mockSpan: Partial; - - beforeEach(() => { - mockSpan = { - setAttribute: jest.fn(), - setStatus: jest.fn(), - end: jest.fn(), - recordException: jest.fn(), - }; - }); - - describe("InferenceScope - LLM Model Inference", () => { - - it("should extract model name from multiple fallback sources", () => { - const run: Partial = { - outputs: { - generations: [ - [ - { - message: { - kwargs: { - response_metadata: { - model_name: "gpt-3.5-turbo-0125", - }, - }, - }, - }, - ], - ], - }, - extra: { - metadata: { - ls_model_name: "gpt-3.5-turbo", - }, - invocation_params: { - model: "gpt-3.5-turbo", - }, - }, - }; - - Utils.setModelAttribute(run as Run, mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY, - "gpt-3.5-turbo" - ); - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY, - "gpt-3.5-turbo-0125" - ); - }); - - it("should extract provider name from metadata", () => { - const run: Partial = { - extra: { - metadata: { - ls_provider: "openai", - }, - }, - }; - - Utils.setProviderNameAttribute(run as Run, mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY, - "openai" - ); - }); - - - it("should extract token usage information", () => { - const run: Partial = { - extra: { - invocation_params: { - max_tokens: 2048, - }, - }, - outputs: { - generations: [ - [ - { - text: "Hello! How can I assist you today?", - message: { - lc: 1, - type: "constructor", - id: [ - "langchain_core", - "messages", - "AIMessage" - ], - kwargs: { - id: "chatcmpl-D1Kbl7pp0zgZBrfB9daIIOR1xRpEF", - content: "Hello! How can I assist you today?", - additional_kwargs: {}, - response_metadata: { - tokenUsage: { - promptTokens: 356, - completionTokens: 10, - totalTokens: 366 - }, - finish_reason: "stop", - model_provider: "openai", - model_name: "gpt-3.5-turbo-0125" - }, - type: "ai", - tool_calls: [], - invalid_tool_calls: [], - usage_metadata: { - output_tokens: 10, - input_tokens: 356, - total_tokens: 366, - input_token_details: { - audio: 0, - cache_read: 7, - cache_creation: 3 - }, - output_token_details: { - audio: 0, - reasoning: 0 - } - } - } - }, - generationInfo: { - finish_reason: "stop" - } - } - ] - ], - llmOutput: { - tokenUsage: { - promptTokens: 356, - completionTokens: 10, - totalTokens: 366 - } - } - } - }; - - Utils.setTokenAttributes(run as Run, mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY, - 356 - ); - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY, - 10 - ); - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY, - 7 - ); - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_KEY, - 3 - ); - }); - - it("should extract system instructions from prompts", () => { - const run: Partial = { - inputs: { - prompts: ["You are a helpful assistant"], - messages: [], - }, - }; - - Utils.setSystemInstructionsAttribute(run as Run, mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_SYSTEM_INSTRUCTIONS_KEY, - "You are a helpful assistant" - ); - }); - - it("should extract system instructions from SystemMessage", () => { - const run: Partial = { - inputs: { - messages: [ - { - lc_type: "system", - lc_kwargs: { - content: "You are a code generator", - }, - }, - { - lc_type: "human", - lc_kwargs: { - content: "Write a function", - }, - }, - ], - }, - }; - - Utils.setSystemInstructionsAttribute(run as Run, mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_SYSTEM_INSTRUCTIONS_KEY, - "You are a code generator" - ); - }); - - it("should extract system instructions from v1 constructor format", () => { - const run: Partial = { - inputs: { - messages: [[ - { - lc: 1, - type: "constructor", - id: ["langchain_core", "messages", "SystemMessage"], - kwargs: { content: "v1 system prompt" }, - }, - { - lc: 1, - type: "constructor", - id: ["langchain_core", "messages", "HumanMessage"], - kwargs: { content: "user input" }, - }, - ]], - }, - }; - - Utils.setSystemInstructionsAttribute(run as Run, mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_SYSTEM_INSTRUCTIONS_KEY, - "v1 system prompt" - ); - }); - - it("should extract tokens from tokenUsage shape (promptTokens/completionTokens)", () => { - const run: Partial = { - outputs: { - generations: [[{ - message: { - kwargs: { - response_metadata: { - tokenUsage: { - promptTokens: 100, - completionTokens: 50, - totalTokens: 150, - }, - }, - }, - }, - }]], - }, - }; - - Utils.setTokenAttributes(run as Run, mockSpan as Span); - - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY, - 100 - ); - expect(mockSpan.setAttribute).toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY, - 50 - ); - }); - }); -}); - -describe("LangChain Observability - v1 Format Coverage", () => { - let mockSpan: Partial; - - beforeEach(() => { - mockSpan = { setAttribute: jest.fn() }; - }); - - it("should extract input content from v1 constructor format", () => { - const run: Partial = { - run_type: "llm", - inputs: { - messages: [[ - { - lc: 1, - type: "constructor", - id: ["langchain_core", "messages", "HumanMessage"], - kwargs: { content: "v1 format message" }, - }, - ]], - }, - }; - - Utils.setInputMessagesAttribute(run as Run, mockSpan as Span); - - const value = getSpanAttribute(mockSpan as any, OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY); - expectValidInputMessages(value); - const parsed = JSON.parse(value as string); - expect(parsed[0].parts[0].content).toBe("v1 format message"); - }); - - it("should extract output content from v1 AIMessage constructor format", () => { - const run: Partial = { - run_type: "llm", - outputs: { - generations: [[{ - message: { - lc: 1, - type: "constructor", - id: ["langchain_core", "messages", "AIMessage"], - kwargs: { content: "v1 AI response", tool_calls: [] }, - }, - }]], - }, - }; - - Utils.setOutputMessagesAttribute(run as Run, mockSpan as Span); - - const value = getSpanAttribute(mockSpan as any, OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY); - expectValidOutputMessages(value); - const parsed = JSON.parse(value as string); - expect(parsed[0].role).toBe("assistant"); - expect(parsed[0].parts[0].content).toBe("v1 AI response"); - }); - - it("should not set input attribute when inputs.messages is missing", () => { - const run: Partial = { inputs: { other_key: "value" } }; - Utils.setInputMessagesAttribute(run as Run, mockSpan as Span); - expect(mockSpan.setAttribute).not.toHaveBeenCalledWith( - OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY, - expect.anything() - ); - }); - - it("should filter out non-AI messages from agent scope outputs", () => { - const run: Partial = { - run_type: "chain", - serialized: { id: ["langgraph", "graph", "CompiledStateGraph"] }, - outputs: { - messages: [ - { role: "user", content: "user message in output" }, - { role: "system", content: "system in output" }, - { role: "assistant", content: "ai response" }, - ], - }, - }; - - Utils.setOutputMessagesAttribute(run as Run, mockSpan as Span); - - const value = getSpanAttribute(mockSpan as any, OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY); - const parsed = JSON.parse(value as string); - expect(parsed).toHaveLength(1); - expect(parsed[0].role).toBe("assistant"); - expect(parsed[0].parts[0].content).toBe("ai response"); - }); -}); diff --git a/tests/observability/extension/langchain/LangChainTraceInstrumentor.test.ts b/tests/observability/extension/langchain/LangChainTraceInstrumentor.test.ts deleted file mode 100644 index 0e8d117e..00000000 --- a/tests/observability/extension/langchain/LangChainTraceInstrumentor.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, jest, beforeEach } from '@jest/globals'; -import { Tracer } from '@opentelemetry/api'; -import { BaseCallbackHandler } from '@langchain/core/callbacks/base'; -import * as LangChainCallbacks from '@langchain/core/callbacks/manager'; -import { ObservabilityManager } from '@microsoft/agents-a365-observability'; -import { LangChainTraceInstrumentor, addTracerToHandlers } from '../../../../packages/agents-a365-observability-extensions-langchain/src/LangChainTraceInstrumentor'; -import { LangChainTracer } from '../../../../packages/agents-a365-observability-extensions-langchain/src/tracer'; - -// Configure ObservabilityManager for testing (real configuration, in-memory) -ObservabilityManager.start({ - serviceName: 'LangChain Test Service', - serviceVersion: '1.0.0' -}); - -describe('LangChainTraceInstrumentor', () => { - beforeEach(() => { - jest.clearAllMocks(); - LangChainTraceInstrumentor.resetInstance(); - }); - - it('throws error when enable is called before initialization', () => { - expect(() => { - LangChainTraceInstrumentor.enable(); - }).toThrow("LangChainTraceInstrumentor must be initialized first"); - }); - - it('throws error when disable is called before initialization', () => { - expect(() => { - LangChainTraceInstrumentor.disable(); - }).toThrow("LangChainTraceInstrumentor must be initialized first"); - }); - - it('initializes when Instrument is called', () => { - expect(() => { - LangChainTraceInstrumentor.instrument(LangChainCallbacks as any); - }).not.toThrow(); - }); - - it('can enable/disable after initialization', () => { - LangChainTraceInstrumentor.instrument(LangChainCallbacks as any); - - expect(() => { - LangChainTraceInstrumentor.disable(); - }).not.toThrow(); - - expect(() => { - LangChainTraceInstrumentor.enable(); - }).not.toThrow(); - }); -}); - -class MockCallbackHandler extends BaseCallbackHandler { - name = 'MockCallbackHandler'; - // Minimal implementation to satisfy BaseCallbackHandler requirements - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; -} - -describe('addTracerToHandlers', () => { - beforeEach(() => { - jest.clearAllMocks(); - LangChainTraceInstrumentor.resetInstance(); - }); - - it('adds tracer when handlers is undefined', () => { - const tracer = {} as Tracer; - - const result = addTracerToHandlers(tracer, undefined); - - expect(Array.isArray(result)).toBe(true); - if (Array.isArray(result)) { - expect(result).toHaveLength(1); - expect(result[0]).toBeInstanceOf(LangChainTracer); - } - }); - - it('adds tracer to an existing array of handlers', () => { - const tracer = {} as Tracer; - const handlers = [new MockCallbackHandler()]; - - const result = addTracerToHandlers(tracer, handlers); - - expect(result).toBe(handlers); - expect(Array.isArray(result)).toBe(true); - if (Array.isArray(result)) { - expect(result).toHaveLength(2); - expect(result[0]).toBeInstanceOf(MockCallbackHandler); - expect(result[1]).toBeInstanceOf(LangChainTracer); - } - }); - - it('Patches CallbackManager with LangChainTracer when instrumented', async () => { - // Initialize first - LangChainTraceInstrumentor.instrument(LangChainCallbacks as any); - - // Verify tracing works by checking if LangChainTracer is injected - const inheritableHandlers: LangChainCallbacks.Callbacks = []; - const manager = (LangChainCallbacks.CallbackManager as typeof LangChainCallbacks.CallbackManager)._configureSync(inheritableHandlers); - - expect(manager).toBeDefined(); - expect(Array.isArray(inheritableHandlers)).toBe(true); - // Should have LangChainTracer injected - expect(inheritableHandlers.length).toBeGreaterThanOrEqual(1); - expect(inheritableHandlers.some((h) => h instanceof LangChainTracer)).toBe(true); - }); -}); diff --git a/tests/observability/extension/openai/OpenAIAgentsTraceInstrumentor.test.ts b/tests/observability/extension/openai/OpenAIAgentsTraceInstrumentor.test.ts deleted file mode 100644 index 5ac42ce3..00000000 --- a/tests/observability/extension/openai/OpenAIAgentsTraceInstrumentor.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -/** - * Unit tests for OpenAIAgentsTraceInstrumentor - * Tests the enable/disable lifecycle and integration with OpenAI Agents SDK - */ - -import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; - -// Mock OpenAI Agents SDK first -const mockSetTraceProcessors = jest.fn(); -const mockSetTracingDisabled = jest.fn(); - -jest.mock('@openai/agents', () => ({ - setTraceProcessors: mockSetTraceProcessors, - setTracingDisabled: mockSetTracingDisabled, -})); - -// Import and configure ObservabilityManager after mocks -import { ObservabilityManager } from '@microsoft/agents-a365-observability'; - -// Configure ObservabilityManager for testing -export const observability = ObservabilityManager.start({ - serviceName: 'TypeScript Test Agent', - serviceVersion: '1.0.0' -}); - -import { OpenAIAgentsTraceInstrumentor } from '@microsoft/agents-a365-observability-extensions-openai'; - -describe('OpenAIAgentsTraceInstrumentor', () => { - let instrumentor: OpenAIAgentsTraceInstrumentor; - beforeEach(() => { - jest.clearAllMocks(); - // ObservabilityManager is now configured using real configuration above - }); - - afterEach(() => { - if (instrumentor) { - instrumentor.disable(); - } - }); - - describe('Constructor', () => { - it('should create instrumentor when observabilityManager is configured', () => { - expect(() => { - instrumentor = new OpenAIAgentsTraceInstrumentor(); - }).not.toThrow(); - - expect(instrumentor).toBeDefined(); - }); - - it('should accept custom configuration', () => { - instrumentor = new OpenAIAgentsTraceInstrumentor({ - tracerName: 'custom-tracer', - tracerVersion: '2.0.0', - }); - - expect(instrumentor).toBeDefined(); - }); - - it('should default enabled to false', () => { - instrumentor = new OpenAIAgentsTraceInstrumentor(); - - // Access the _config property to check enabled value - // Note: This is accessing a private property for testing purposes - expect((instrumentor as any)._config.enabled).toBe(false); - }); - - it('should respect explicit enabled configuration', () => { - instrumentor = new OpenAIAgentsTraceInstrumentor({ - enabled: true, - }); - - expect((instrumentor as any)._config.enabled).toBe(true); - }); - - it('should respect explicit enabled: false configuration', () => { - instrumentor = new OpenAIAgentsTraceInstrumentor({ - enabled: false, - }); - - expect((instrumentor as any)._config.enabled).toBe(false); - }); - - it('should auto-enable when enabled: true is passed', () => { - // Mock the OpenAI Agents functions to verify they get called - const setTracingDisabledSpy = jest.spyOn(require('@openai/agents'), 'setTracingDisabled'); - const setTraceProcessorsSpy = jest.spyOn(require('@openai/agents'), 'setTraceProcessors'); - - instrumentor = new OpenAIAgentsTraceInstrumentor({ - enabled: true, - }); - - // Verify the config was set correctly - expect((instrumentor as any)._config.enabled).toBe(true); - - // Verify that OpenTelemetry automatically called enable() which triggered our setup - expect(setTracingDisabledSpy).toHaveBeenCalledWith(false); - expect(setTraceProcessorsSpy).toHaveBeenCalled(); - - // Verify processor was created - expect(instrumentor.getProcessor()).toBeDefined(); - - setTracingDisabledSpy.mockRestore(); - setTraceProcessorsSpy.mockRestore(); - }); - }); - - describe('enable()', () => { - beforeEach(() => { - instrumentor = new OpenAIAgentsTraceInstrumentor(); - }); - - it('should enable tracing in OpenAI Agents SDK', () => { - instrumentor.enable(); - - expect(mockSetTracingDisabled).toHaveBeenCalledWith(false); - }); - - it('should create and register trace processor', () => { - instrumentor.enable(); - - expect(mockSetTraceProcessors).toHaveBeenCalled(); - const calls = mockSetTraceProcessors.mock.calls; - expect(calls[0][0]).toHaveLength(1); // Should register one processor - }); - - it('should get processor instance', () => { - instrumentor.enable(); - - const processor = instrumentor.getProcessor(); - expect(processor).toBeDefined(); - }); - }); - - describe('disable()', () => { - beforeEach(() => { - instrumentor = new OpenAIAgentsTraceInstrumentor(); - instrumentor.enable(); - }); - - it('should shutdown processor', () => { - const processor = instrumentor.getProcessor(); - const shutdownSpy = jest.spyOn(processor!, 'shutdown'); - - instrumentor.disable(); - - expect(shutdownSpy).toHaveBeenCalled(); - }); - - it('should clear processor reference', () => { - instrumentor.disable(); - - const processor = instrumentor.getProcessor(); - expect(processor).toBeUndefined(); - }); - - it('should reset trace processors', () => { - instrumentor.disable(); - - expect(mockSetTraceProcessors).toHaveBeenCalledWith([]); - }); - - it('should allow re-enabling after disable', () => { - instrumentor.disable(); - - expect(() => { - instrumentor.enable(); - }).not.toThrow(); - - expect(instrumentor.getProcessor()).toBeDefined(); - }); - }); - - describe('instrumentationDependencies()', () => { - beforeEach(() => { - instrumentor = new OpenAIAgentsTraceInstrumentor(); - }); - - it('should return OpenAI Agents dependency', () => { - const deps = instrumentor.instrumentationDependencies(); - - expect(deps).toContain('@openai/agents >= 0.1.5'); - }); - }); - - describe('Integration', () => { - it('should complete full lifecycle', () => { - instrumentor = new OpenAIAgentsTraceInstrumentor({ - tracerName: 'test-tracer', - tracerVersion: '1.0.0', - }); - - // Enable - instrumentor.enable(); - expect(mockSetTracingDisabled).toHaveBeenCalledWith(false); - expect(instrumentor.getProcessor()).toBeDefined(); - - // Disable - instrumentor.disable(); - expect(mockSetTraceProcessors).toHaveBeenCalledWith([]); - expect(instrumentor.getProcessor()).toBeUndefined(); - }); - }); -}); diff --git a/tests/observability/extension/openai/OpenAIAgentsTraceProcessor.test.ts b/tests/observability/extension/openai/OpenAIAgentsTraceProcessor.test.ts deleted file mode 100644 index e1208208..00000000 --- a/tests/observability/extension/openai/OpenAIAgentsTraceProcessor.test.ts +++ /dev/null @@ -1,876 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -/** - * Unit tests for OpenAIAgentsTraceProcessor - * Tests the trace and span processing lifecycle - */ - -import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { Tracer } from '@opentelemetry/api'; -import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability'; -import { OpenAIAgentsTraceProcessor } from '@microsoft/agents-a365-observability-extensions-openai'; -import { ObservabilityManager } from '@microsoft/agents-a365-observability'; -import { trace } from '@opentelemetry/api'; -import { expectValidInputMessages, expectValidOutputMessages, getAttrFromArray } from '../helpers/message-schema-validator'; - -describe('OpenAIAgentsTraceProcessor', () => { - let tracer: Tracer; - - beforeEach(() => { - // Initialize ObservabilityManager for testing - ObservabilityManager.start({ - serviceName: 'openai-agents-test', - serviceVersion: '1.0.0' - }); - tracer = trace.getTracer('openai-agents-test', '1.0.0'); - }); - - afterEach(async () => { - await ObservabilityManager.shutdown(); - }); - - describe('Functional Tests with Real Processor', () => { - let processor: OpenAIAgentsTraceProcessor; - - beforeEach(() => { - processor = new OpenAIAgentsTraceProcessor(tracer, {}); - }); - - afterEach(async () => { - await processor.shutdown(); - }); - - describe('Trace Lifecycle', () => { - it('should handle trace start', () => { - const traceData = { - traceId: 'trace-123', - name: 'Test Agent', - } as any; - - processor.onTraceStart(traceData); - - // Check what actually happens with root spans - const rootSpans = (processor as any).rootSpans; - expect(rootSpans.size).toBeGreaterThanOrEqual(0); - }); - - it('should handle trace end', () => { - const traceData = { - traceId: 'trace-456', - name: 'Test Agent', - } as any; - - processor.onTraceStart(traceData); - processor.onTraceEnd(traceData); - - const rootSpans = (processor as any).rootSpans; - expect(rootSpans.has('trace-456')).toBe(false); - }); - }); - - describe('Span Processing by Type', () => { - it('should process generation span', () => { - const traceData = { traceId: 'trace-1', name: 'Agent' } as any; - processor.onTraceStart(traceData); - - const genSpan = { - spanId: 'gen-1', - traceId: 'trace-1', - startedAt: new Date().toISOString(), - spanData: { - type: 'generation' as const, - name: 'Generate', - model: 'gpt-4', - }, - } as any; - - processor.onSpanStart(genSpan); - - const otelSpans = (processor as any).otelSpans; - expect(otelSpans.has('gen-1')).toBe(true); - - processor.onSpanEnd(genSpan); - expect(otelSpans.has('gen-1')).toBe(false); - }); - - it('should process function span', () => { - const traceData = { traceId: 'trace-2', name: 'Agent' } as any; - processor.onTraceStart(traceData); - - const funcSpan = { - spanId: 'func-1', - traceId: 'trace-2', - startedAt: new Date().toISOString(), - spanData: { - type: 'function' as const, - name: 'tool_call', - toolName: 'calculator', - }, - } as any; - - processor.onSpanStart(funcSpan); - - const otelSpans = (processor as any).otelSpans; - expect(otelSpans.has('func-1')).toBe(true); - - processor.onSpanEnd(funcSpan); - expect(otelSpans.has('func-1')).toBe(false); - }); - - it('should process agent span', () => { - const traceData = { traceId: 'trace-4', name: 'Agent' } as any; - processor.onTraceStart(traceData); - - const agentSpan = { - spanId: 'agent-1', - traceId: 'trace-4', - startedAt: new Date().toISOString(), - spanData: { - type: 'agent' as const, - name: 'agent_action', - agentName: 'assistant', - }, - } as any; - - processor.onSpanStart(agentSpan); - - const otelSpans = (processor as any).otelSpans; - expect(otelSpans.has('agent-1')).toBe(true); - - processor.onSpanEnd(agentSpan); - expect(otelSpans.has('agent-1')).toBe(false); - }); - - it('should process response span', () => { - const traceData = { traceId: 'trace-5', name: 'Agent' } as any; - processor.onTraceStart(traceData); - - const respSpan = { - spanId: 'resp-1', - traceId: 'trace-5', - startedAt: new Date().toISOString(), - spanData: { - type: 'response' as const, - name: 'agent_response', - content: 'Hello!', - }, - } as any; - - processor.onSpanStart(respSpan); - - const otelSpans = (processor as any).otelSpans; - expect(otelSpans.has('resp-1')).toBe(true); - - processor.onSpanEnd(respSpan); - expect(otelSpans.has('resp-1')).toBe(false); - }); - - it('should process mcp_tools span', () => { - const traceData = { traceId: 'trace-6', name: 'Agent' } as any; - processor.onTraceStart(traceData); - - const mcpSpan = { - spanId: 'mcp-1', - traceId: 'trace-6', - startedAt: new Date().toISOString(), - spanData: { - type: 'mcp_tools' as const, - name: 'mcp_tool_call', - toolName: 'external_api', - }, - } as any; - - processor.onSpanStart(mcpSpan); - - const otelSpans = (processor as any).otelSpans; - expect(otelSpans.has('mcp-1')).toBe(true); - - processor.onSpanEnd(mcpSpan); - expect(otelSpans.has('mcp-1')).toBe(false); - }); - }); - - describe('Error Handling', () => { - it('should handle span with error', () => { - const traceData = { traceId: 'trace-error', name: 'Agent' } as any; - processor.onTraceStart(traceData); - - const errorSpan = { - spanId: 'error-1', - traceId: 'trace-error', - startedAt: new Date().toISOString(), - spanData: { - type: 'generation' as const, - name: 'Failed Gen', - error: 'Rate limit exceeded', - }, - } as any; - - processor.onSpanStart(errorSpan); - processor.onSpanEnd(errorSpan); - - const otelSpans = (processor as any).otelSpans; - expect(otelSpans.has('error-1')).toBe(false); // Cleaned up after end - }); - }); - - describe('Shutdown', () => { - it('should clear all internal state', async () => { - const traceData = { traceId: 'trace-shutdown', name: 'Agent' } as any; - processor.onTraceStart(traceData); - - const span = { - spanId: 'span-shutdown', - traceId: 'trace-shutdown', - startedAt: new Date().toISOString(), - spanData: { - type: 'generation' as const, - name: 'Test', - }, - } as any; - - processor.onSpanStart(span); - - await processor.shutdown(); - - const rootSpans = (processor as any).rootSpans; - const otelSpans = (processor as any).otelSpans; - const tokens = (processor as any).tokens; - - expect(rootSpans.size).toBe(0); - expect(otelSpans.size).toBe(0); - expect(tokens.size).toBe(0); - }); - }); - - describe('Complex Scenarios', () => { - it('should handle multiple spans in same trace', () => { - const traceData = { traceId: 'trace-multi', name: 'Agent' } as any; - processor.onTraceStart(traceData); - - const spans = [ - { - spanId: 'span-1', - traceId: 'trace-multi', - startedAt: new Date().toISOString(), - spanData: { type: 'generation' as const, name: 'Gen1' }, - }, - { - spanId: 'span-2', - traceId: 'trace-multi', - startedAt: new Date().toISOString(), - spanData: { type: 'function' as const, name: 'Func1' }, - }, - { - spanId: 'span-3', - traceId: 'trace-multi', - startedAt: new Date().toISOString(), - spanData: { type: 'response' as const, name: 'Resp1' }, - }, - ] as any[]; - - // Start all spans - spans.forEach(span => processor.onSpanStart(span)); - - const otelSpans = (processor as any).otelSpans; - expect(otelSpans.size).toBe(3); - - // End all spans - spans.forEach(span => processor.onSpanEnd(span)); - expect(otelSpans.size).toBe(0); - - processor.onTraceEnd(traceData); - }); - - it('should maintain parent-child span relationships with correct trace ID', () => { - const traceData = { traceId: 'trace-parent-child', name: 'Agent' } as any; - processor.onTraceStart(traceData); - - // Check the actual behavior of root spans - const rootSpans = (processor as any).rootSpans; - const rootSpan = rootSpans.get('trace-parent-child'); - // Based on test output, root spans are being created, so let's adjust accordingly - - // Create parent span - const parentSpan = { - spanId: 'parent-span-1', - traceId: 'trace-parent-child', - parentId: undefined, // No parent, uses active context or root span if exists - startedAt: new Date().toISOString(), - spanData: { - type: 'agent' as const, - name: 'Parent Agent', - }, - } as any; - - processor.onSpanStart(parentSpan); - - const otelSpans = (processor as any).otelSpans; - const parentOtelSpan = otelSpans.get('parent-span-1'); - expect(parentOtelSpan).toBeDefined(); - - const parentSpanContext = parentOtelSpan.spanContext(); - expect(parentSpanContext.spanId).toBeDefined(); - expect(parentSpanContext.traceId).toBeDefined(); - - // Create child span with explicit parent - const childSpan = { - spanId: 'child-span-1', - traceId: 'trace-parent-child', - parentId: 'parent-span-1', // Explicitly set parent - startedAt: new Date().toISOString(), - spanData: { - type: 'generation' as const, - name: 'Child Generation', - }, - } as any; - - processor.onSpanStart(childSpan); - - const childOtelSpan = otelSpans.get('child-span-1'); - expect(childOtelSpan).toBeDefined(); - - const childSpanContext = childOtelSpan.spanContext(); - expect(childSpanContext.spanId).toBeDefined(); - expect(childSpanContext.traceId).toBeDefined(); - - // Verify child span has same trace ID as parent - expect(childSpanContext.traceId).toBe(parentSpanContext.traceId); - - // Verify child span ID is different from parent - expect(childSpanContext.spanId).not.toBe(parentSpanContext.spanId); - - // Create grandchild span - const grandchildSpan = { - spanId: 'grandchild-span-1', - traceId: 'trace-parent-child', - parentId: 'child-span-1', // Parent is the child span - startedAt: new Date().toISOString(), - spanData: { - type: 'function' as const, - name: 'Grandchild Function', - }, - } as any; - - processor.onSpanStart(grandchildSpan); - - const grandchildOtelSpan = otelSpans.get('grandchild-span-1'); - expect(grandchildOtelSpan).toBeDefined(); - - const grandchildSpanContext = grandchildOtelSpan.spanContext(); - - // Verify all spans share the same trace ID - expect(grandchildSpanContext.traceId).toBe(parentSpanContext.traceId); - expect(grandchildSpanContext.traceId).toBe(childSpanContext.traceId); - - // Verify each span has unique span ID - expect(grandchildSpanContext.spanId).not.toBe(childSpanContext.spanId); - expect(grandchildSpanContext.spanId).not.toBe(parentSpanContext.spanId); - expect(childSpanContext.spanId).not.toBe(parentSpanContext.spanId); - - // Clean up - end spans in reverse order (child before parent) - processor.onSpanEnd(grandchildSpan); - expect(otelSpans.has('grandchild-span-1')).toBe(false); - - processor.onSpanEnd(childSpan); - expect(otelSpans.has('child-span-1')).toBe(false); - - processor.onSpanEnd(parentSpan); - expect(otelSpans.has('parent-span-1')).toBe(false); - - processor.onTraceEnd(traceData); - }); - }); - }); - - describe('Prompt Suppression in InvokeAgent traces', () => { - let spansByName: Record; - let tracerSpy: jest.SpyInstance; - - const createMockSpan = (name: string) => { - const attrs: Array<[string, unknown]> = []; - return { - setAttribute: jest.fn((k: string, v: unknown) => { attrs.push([k, v]); }), - updateName: jest.fn(), - setStatus: jest.fn(), - end: jest.fn(), - spanContext: jest.fn(() => ({ traceId: 'tid-' + name, spanId: 'sid-' + name })), - _attrs: attrs, - }; - }; - - beforeEach(() => { - spansByName = {}; - tracerSpy = jest.spyOn(tracer as any, 'startSpan').mockImplementation((...args: unknown[]) => { - const name = args[0] as string; - const s = createMockSpan(name); - spansByName[name] = s; - return s; - }); - }); - - afterEach(() => { - tracerSpy.mockRestore(); - }); - - it('should record tool arguments, result, and type on function span', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer); - const traceData = { traceId: 'trace-func-args', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const funcSpan = { - spanId: 'func-args-1', - traceId: 'trace-func-args', - startedAt: new Date().toISOString(), - spanData: { - type: 'function' as const, - name: 'get_weather', - input: { city: 'Seattle' }, - output: 'Sunny, 25°C', - }, - } as any; - - await processor.onSpanStart(funcSpan); - await processor.onSpanEnd(funcSpan); - - const mock = spansByName['get_weather']; - const attrs = mock._attrs as Array<[string, unknown]>; - - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY)?.[1]).toBe('{"city":"Seattle"}'); - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY)?.[1]).toBe('{"result":"Sunny, 25°C"}'); - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY)?.[1]).toBe('function'); - }); - - it('does not record GEN_AI_INPUT_MESSAGES when disabled', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, { suppressInvokeAgentInput: true }); - const traceData = { traceId: 'trace-suppress', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const agentSpan = { - spanId: 'agent-span', traceId: 'trace-suppress', startedAt: new Date().toISOString(), - spanData: { type: 'agent' as const, name: 'agent-node' }, - } as any; - await processor.onSpanStart(agentSpan); - await processor.onSpanEnd(agentSpan); - - const genSpan = { - spanId: 'gen-span', traceId: 'trace-suppress', startedAt: new Date().toISOString(), - spanData: { type: 'generation' as const, name: 'Generate', model: 'gpt-4', input: 'Hello prompt' }, - } as any; - await processor.onSpanStart(genSpan); - await processor.onSpanEnd(genSpan); - - const genMock = spansByName['Generate']; - const keys = (genMock._attrs as Array<[string, unknown]>).map(([k]) => k); - expect(keys).not.toContain(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY); - }); - - it('records GEN_AI_INPUT_MESSAGES when content recording is enabled', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - const traceData = { traceId: 'trace-allow', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const agentSpan = { - spanId: 'agent-span-2', traceId: 'trace-allow', startedAt: new Date().toISOString(), - spanData: { type: 'agent' as const, name: 'agent-node-2' }, - } as any; - await processor.onSpanStart(agentSpan); - await processor.onSpanEnd(agentSpan); - - const genSpan = { - spanId: 'gen-span-2', traceId: 'trace-allow', startedAt: new Date().toISOString(), - spanData: { type: 'generation' as const, name: 'Generate2', model: 'gpt-4', input: 'Hello prompt' }, - } as any; - await processor.onSpanStart(genSpan); - await processor.onSpanEnd(genSpan); - - const genMock = spansByName['Generate2']; - const keys = (genMock._attrs as Array<[string, unknown]>).map(([k]) => k); - expect(keys).toContain(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY); - }); - - it('suppresses input on response spans when disabled', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, { suppressInvokeAgentInput: true }); - const traceData = { traceId: 'trace-resp', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const agentSpan = { - spanId: 'agent-span-3', traceId: 'trace-resp', startedAt: new Date().toISOString(), - spanData: { type: 'agent' as const, name: 'agent-node-3' }, - } as any; - await processor.onSpanStart(agentSpan); - await processor.onSpanEnd(agentSpan); - - const respSpan = { - spanId: 'resp-span', traceId: 'trace-resp', startedAt: new Date().toISOString(), - spanData: { type: 'response' as const, name: 'Response', _input: 'Prompt text', _response: { model: 'gpt-4', output: 'ok' } }, - } as any; - await processor.onSpanStart(respSpan); - await processor.onSpanEnd(respSpan); - - const respMock = spansByName['Response']; - const keys = (respMock._attrs as Array<[string, unknown]>).map(([k]) => k); - expect(keys).not.toContain(OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY); - }); - - it('records structured InputMessages when only assistant messages are present', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - const traceData = { traceId: 'trace-assistant-only', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const inputArray = [ - { - role: 'assistant', - content: 'Assistant reply', - }, - ]; - - const respSpan = { - spanId: 'resp-assistant-span', - traceId: 'trace-assistant-only', - startedAt: new Date().toISOString(), - spanData: { - type: 'response' as const, - name: 'ResponseAssistantOnly', - _input: inputArray, - _response: { model: 'gpt-4', output: 'ok' }, - }, - } as any; - - await processor.onSpanStart(respSpan); - await processor.onSpanEnd(respSpan); - - const respMock = spansByName['ResponseAssistantOnly']; - const attrs = respMock._attrs as Array<[string, unknown]>; - const entry = attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY); - expect(entry).toBeDefined(); - - expectValidInputMessages(entry![1]); - - const value = entry![1] as string; - const parsed = JSON.parse(value); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(1); - expect(parsed[0].role).toBe('assistant'); - expect(parsed[0].parts[0]).toEqual({ type: 'text', content: 'Assistant reply' }); - }); - it('records structured InputMessages for array _input on response spans', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - const traceData = { traceId: 'trace-array-input', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const respSpan = { - spanId: 'resp-array-span', - traceId: 'trace-array-input', - startedAt: new Date().toISOString(), - spanData: { - type: 'response' as const, - name: 'ResponseArray', - _input: [ - { role: 'user', content: 'Hello user 1' }, - { role: 'user', content: 'Hello user 2' }, - ], - _response: { model: 'gpt-4', output: 'ok' }, - }, - } as any; - - await processor.onSpanStart(respSpan); - await processor.onSpanEnd(respSpan); - - const respMock = spansByName['ResponseArray']; - const attrs = respMock._attrs as Array<[string, unknown]>; - const entry = attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY); - expect(entry).toBeDefined(); - - const value = entry![1] as string; - const parsed = JSON.parse(value); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(2); - expectValidInputMessages(entry![1]); - expect(parsed[0]).toEqual({ role: 'user', parts: [{ type: 'text', content: 'Hello user 1' }] }); - expect(parsed[1]).toEqual({ role: 'user', parts: [{ type: 'text', content: 'Hello user 2' }] }); - }); - - it('parses stringified array _input and records all messages in structured format', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - const traceData = { traceId: 'trace-array-input-string', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const inputArray = [ - { role: 'user', content: 'Hello user 1' }, - { role: 'user', content: 'Hello user 2' }, - { role: 'assistant', content: 'Assistant reply' }, - ]; - - const respSpan = { - spanId: 'resp-array-span-string', - traceId: 'trace-array-input-string', - startedAt: new Date().toISOString(), - spanData: { - type: 'response' as const, - name: 'ResponseArrayString', - _input: JSON.stringify(inputArray), - _response: { model: 'gpt-4', output: 'ok' }, - }, - } as any; - - await processor.onSpanStart(respSpan); - await processor.onSpanEnd(respSpan); - - const respMock = spansByName['ResponseArrayString']; - const attrs = respMock._attrs as Array<[string, unknown]>; - const entry = attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY); - expect(entry).toBeDefined(); - - const value = entry![1] as string; - const parsed = JSON.parse(value); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(3); - expectValidInputMessages(entry![1]); - expect(parsed[0].role).toBe('user'); - expect(parsed[1].role).toBe('user'); - expect(parsed[2].role).toBe('assistant'); - }); - - it('records structured InputMessages for array input with non standard schema on response spans', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - const traceData = { traceId: 'trace-array-input', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - const inputArray = [ - { type: 'text', content: 'message 1' }, - { type: 'text', content: 'message 2' }, - ]; - const respSpan = { - spanId: 'resp-array-span', - traceId: 'trace-array-input', - startedAt: new Date().toISOString(), - spanData: { - type: 'response' as const, - name: 'ResponseArray', - _input: inputArray, - _response: { model: 'gpt-4', output: 'ok' }, - }, - } as any; - - await processor.onSpanStart(respSpan); - await processor.onSpanEnd(respSpan); - - const respMock = spansByName['ResponseArray']; - const attrs = respMock._attrs as Array<[string, unknown]>; - const entry = attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY); - expect(entry).toBeDefined(); - - const value = entry![1] as string; - const parsed = JSON.parse(value); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - }); - - it('records GEN_AI_OUTPUT_MESSAGES in versioned envelope when output is a string', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - const traceData = { traceId: 'trace-output-string', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const respSpan = { - spanId: 'resp-output-string', - traceId: 'trace-output-string', - startedAt: new Date().toISOString(), - spanData: { - type: 'response' as const, - name: 'ResponseOutputString', - _input: 'ignored', - _response: { model: 'gpt-4', output: 'final answer' }, - }, - } as any; - - await processor.onSpanStart(respSpan); - await processor.onSpanEnd(respSpan); - - const respMock = spansByName['ResponseOutputString']; - const attrs = respMock._attrs as Array<[string, unknown]>; - const entry = attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY); - expect(entry).toBeDefined(); - expectValidOutputMessages(entry![1]); - const parsed = JSON.parse(entry![1] as string); - expect(parsed[0].parts[0].content).toBe('final answer'); - }); - - it('records structured OutputMessages when output is structured', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - const traceData = { traceId: 'trace-output-structured', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const outputArray = [ - { - role: 'assistant', - content: [ - { type: 'output_text', text: 'Hello user 1' }, - { type: 'output_text', text: 'Hello user 2' }, - ], - }, - ]; - - const respSpan = { - spanId: 'resp-output-structured', - traceId: 'trace-output-structured', - startedAt: new Date().toISOString(), - spanData: { - type: 'response' as const, - name: 'ResponseOutputStructured', - _input: 'ignored', - _response: { model: 'gpt-4', output: outputArray }, - }, - } as any; - - await processor.onSpanStart(respSpan); - await processor.onSpanEnd(respSpan); - - const respMock = spansByName['ResponseOutputStructured']; - const attrs = respMock._attrs as Array<[string, unknown]>; - const entry = attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY); - expect(entry).toBeDefined(); - - const value = entry![1] as string; - const parsed = JSON.parse(value); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(1); - expect(parsed[0].role).toBe('assistant'); - expectValidOutputMessages(entry![1]); - expect(parsed[0].parts).toEqual([ - { type: 'text', content: 'Hello user 1' }, - { type: 'text', content: 'Hello user 2' }, - ]); - }); - - it('maps Chat Completions usage (prompt_tokens/completion_tokens) from output[0].usage', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - const traceData = { traceId: 'trace-usage-chat', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const genSpan = { - spanId: 'gen-usage-chat', - traceId: 'trace-usage-chat', - startedAt: new Date().toISOString(), - endedAt: new Date().toISOString(), - spanData: { - type: 'generation' as const, - name: 'GenChat', - model: 'gpt-4', - output: [{ - choices: [{ finish_reason: 'stop' }], - usage: { - prompt_tokens: 20, - completion_tokens: 11, - total_tokens: 31, - prompt_tokens_details: { cached_tokens: 8 }, - }, - }], - }, - } as any; - - await processor.onSpanStart(genSpan); - await processor.onSpanEnd(genSpan); - - const genMock = spansByName['GenChat']; - const attrs = genMock._attrs as Array<[string, unknown]>; - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY)?.[1]).toBe(20); - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY)?.[1]).toBe(11); - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY)?.[1]).toBe(8); - }); - - it('maps Responses API usage (input_tokens/output_tokens) from top-level usage', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - const traceData = { traceId: 'trace-usage-resp', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const genSpan = { - spanId: 'gen-usage-resp', - traceId: 'trace-usage-resp', - startedAt: new Date().toISOString(), - endedAt: new Date().toISOString(), - spanData: { - type: 'generation' as const, - name: 'GenResp', - model: 'gpt-4', - usage: { - input_tokens: 42, - output_tokens: 7, - input_tokens_details: { cached_tokens: 12 }, - }, - }, - } as any; - - await processor.onSpanStart(genSpan); - await processor.onSpanEnd(genSpan); - - const genMock = spansByName['GenResp']; - const attrs = genMock._attrs as Array<[string, unknown]>; - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY)?.[1]).toBe(42); - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY)?.[1]).toBe(7); - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_KEY)?.[1]).toBe(12); - }); - - it('maps the served model on response spans as gen_ai.response.model', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - const traceData = { traceId: 'trace-response-model', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - const responseSpan = { - spanId: 'response-model', - traceId: 'trace-response-model', - startedAt: new Date().toISOString(), - endedAt: new Date().toISOString(), - spanData: { - type: 'response' as const, - name: 'ResponseModel', - _response: { model: 'gpt-4o-2024-08-06' }, - }, - } as any; - - await processor.onSpanStart(responseSpan); - await processor.onSpanEnd(responseSpan); - - const attrs = spansByName.ResponseModel._attrs as Array<[string, unknown]>; - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY)?.[1]) - .toBe('gpt-4o-2024-08-06'); - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY)).toBeUndefined(); - }); - - it('maps the served model on Chat Completions generation spans while keeping the requested alias', async () => { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - const traceData = { traceId: 'trace-gen-model', name: 'Agent' } as any; - await processor.onTraceStart(traceData); - - const genSpan = { - spanId: 'gen-model', - traceId: 'trace-gen-model', - startedAt: new Date().toISOString(), - endedAt: new Date().toISOString(), - spanData: { - type: 'generation' as const, - name: 'GenModel', - model: 'production-deployment', - output: [{ - model: 'gpt-4o-2024-08-06', - choices: [], - usage: { prompt_tokens: 2048, completion_tokens: 11 }, - }], - }, - } as any; - - await processor.onSpanStart(genSpan); - await processor.onSpanEnd(genSpan); - - const attrs = spansByName.GenModel._attrs as Array<[string, unknown]>; - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY)?.[1]) - .toBe('production-deployment'); - expect(attrs.find(([k]) => k === OpenTelemetryConstants.GEN_AI_RESPONSE_MODEL_KEY)?.[1]) - .toBe('gpt-4o-2024-08-06'); - }); - - }); - - // SpanKind, caller.agent.name, handoff A→B, and error.type are validated by - // the integration test (openai-agent-instrument.test.ts). -}); diff --git a/tests/observability/extension/openai/OpenAIMessageContract.test.ts b/tests/observability/extension/openai/OpenAIMessageContract.test.ts deleted file mode 100644 index be1fe68f..00000000 --- a/tests/observability/extension/openai/OpenAIMessageContract.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { Tracer, trace } from '@opentelemetry/api'; -import { OpenTelemetryConstants, ObservabilityManager, serializeMessages } from '@microsoft/agents-a365-observability'; -import { OpenAIAgentsTraceProcessor } from '@microsoft/agents-a365-observability-extensions-openai'; -import { - buildStructuredInputMessages, - buildStructuredOutputMessages, - wrapRawContentAsInputMessages, - wrapRawContentAsOutputMessages, -} from '../../../../packages/agents-a365-observability-extensions-openai/src/Utils'; -import { expectValidInputMessages, expectValidOutputMessages, getAttrFromArray } from '../helpers/message-schema-validator'; - -describe('OpenAI Message Contract Tests', () => { - - describe('buildStructuredInputMessages', () => { - it('should produce valid InputMessages from a multi-role conversation', () => { - const result = buildStructuredInputMessages([ - { role: 'system', content: 'You are a helpful assistant.' }, - { role: 'user', content: 'Hi!' }, - { role: 'assistant', content: 'Hello! How can I help?' }, - { role: 'user', content: 'What is 2+2?' }, - ]); - expectValidInputMessages(serializeMessages(result)); - expect(result.messages.map(m => m.role)).toEqual(['system', 'user', 'assistant', 'user']); - expect(result.messages[0].parts[0]).toEqual({ type: 'text', content: 'You are a helpful assistant.' }); - }); - - it('should handle array content blocks (input_text, input_image)', () => { - const result = buildStructuredInputMessages([{ - role: 'user', - content: [ - { type: 'input_text', text: 'Describe this image' }, - { type: 'input_image', image: 'https://example.com/img.png' }, - ], - }] as any); - expectValidInputMessages(serializeMessages(result)); - expect(result.messages[0].parts).toHaveLength(2); - expect(result.messages[0].parts[0]).toEqual({ type: 'text', content: 'Describe this image' }); - }); - }); - - describe('buildStructuredOutputMessages', () => { - it('should handle text, tool_call, and reasoning content', () => { - const result = buildStructuredOutputMessages([{ - role: 'assistant', - content: [ - { type: 'reasoning', text: 'The user asked about weather.' }, - { type: 'output_text', text: 'Let me check.' }, - { type: 'tool_call', name: 'get_weather', call_id: 'call_1', arguments: '{"city":"Seattle"}' }, - ], - }]); - expectValidOutputMessages(serializeMessages(result)); - - expect(result.messages[0].parts).toHaveLength(3); - const toolPart = result.messages[0].parts.find(p => p.type === 'tool_call') as any; - expect(toolPart.name).toBe('get_weather'); - expect(toolPart.id).toBe('call_1'); - expect(result.messages[0].parts.find(p => p.type === 'reasoning')).toBeDefined(); - }); - - it('should handle mixed output types including refusal', () => { - const result = buildStructuredOutputMessages([{ - role: 'assistant', - content: [ - { type: 'output_text', text: 'Here is the answer.' }, - { type: 'refusal', refusal: 'I cannot help with that.' }, - ], - }]); - expectValidOutputMessages(serializeMessages(result)); - expect(result.messages[0].parts).toHaveLength(2); - }); - }); - - describe('wrapRawContent', () => { - it.each([ - ['string', 'Hello prompt'], - ['object', { complex: 'data', nested: [1, 2] }], - ])('should produce valid InputMessages from raw %s', (_label, raw) => { - expectValidInputMessages(serializeMessages(wrapRawContentAsInputMessages(raw))); - }); - - it.each([ - ['string', 'Model response'], - ['object', { result: 'data' }], - ])('should produce valid OutputMessages from raw %s', (_label, raw) => { - expectValidOutputMessages(serializeMessages(wrapRawContentAsOutputMessages(raw))); - }); - }); - - describe('End-to-end: response span with real-shaped data', () => { - let tracer: Tracer; - let spansByName: Record; - let tracerSpy: jest.SpyInstance; - - const createMockSpan = (name: string) => { - const attrs: Array<[string, unknown]> = []; - return { - setAttribute: jest.fn((k: string, v: unknown) => { attrs.push([k, v]); }), - updateName: jest.fn(), - setStatus: jest.fn(), - end: jest.fn(), - spanContext: jest.fn(() => ({ traceId: 'tid-' + name, spanId: 'sid-' + name })), - _attrs: attrs, - }; - }; - - beforeEach(() => { - ObservabilityManager.start({ serviceName: 'contract-test', serviceVersion: '1.0.0' }); - tracer = trace.getTracer('contract-test', '1.0.0'); - spansByName = {}; - tracerSpy = jest.spyOn(tracer as any, 'startSpan').mockImplementation((...args: unknown[]) => { - const s = createMockSpan(args[0] as string); - spansByName[args[0] as string] = s; - return s; - }); - }); - - afterEach(async () => { - tracerSpy.mockRestore(); - await ObservabilityManager.shutdown(); - }); - - async function runResponseSpan(traceId: string, spanName: string, input: any[], responseOutput: any[]): Promise> { - const processor = new OpenAIAgentsTraceProcessor(tracer, {}); - await processor.onTraceStart({ traceId, name: 'Agent' } as any); - const span = { - spanId: `sid-${spanName}`, - traceId, - startedAt: new Date().toISOString(), - spanData: { - type: 'response' as const, - name: spanName, - _input: input, - _response: { model: 'gpt-4o', output: responseOutput }, - }, - } as any; - await processor.onSpanStart(span); - await processor.onSpanEnd(span); - await processor.shutdown(); - return spansByName[spanName]._attrs; - } - - it('should produce valid InputMessages and OutputMessages for text response', async () => { - const attrs = await runResponseSpan('t1', 'TextResponse', - [{ role: 'system', content: 'You are helpful.' }, { role: 'user', content: 'What is 2+2?' }], - [{ role: 'assistant', content: [{ type: 'output_text', text: 'The answer is 4.' }] }], - ); - - expectValidInputMessages(getAttrFromArray(attrs, OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY)); - expectValidOutputMessages(getAttrFromArray(attrs, OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY)); - }); - - it('should produce valid OutputMessages with tool_call in response', async () => { - const attrs = await runResponseSpan('t2', 'ToolResponse', - [{ role: 'user', content: 'Get weather in Seattle' }], - [{ role: 'assistant', content: [{ type: 'tool_call', name: 'get_weather', call_id: 'call_abc', arguments: '{"city":"Seattle"}' }] }], - ); - - const outputValue = getAttrFromArray(attrs, OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY); - expectValidOutputMessages(outputValue); - - const toolPart = JSON.parse(outputValue as string)[0].parts.find((p: any) => p.type === 'tool_call'); - expect(toolPart.name).toBe('get_weather'); - expect(toolPart.id).toBe('call_abc'); - expect(toolPart.arguments).toEqual({ city: 'Seattle' }); - }); - }); - - describe('Edge cases', () => { - it('should return empty messages array for empty input', () => { - const result = buildStructuredInputMessages([]); - expect(result.messages).toHaveLength(0); - }); - - it('should skip null/non-object entries in input array', () => { - const result = buildStructuredInputMessages([null as any, undefined as any, { role: 'user', content: 'valid' }]); - expect(result.messages).toHaveLength(1); - expect(result.messages[0].parts[0]).toEqual({ type: 'text', content: 'valid' }); - }); - - it('should return empty messages array for empty output', () => { - const result = buildStructuredOutputMessages([]); - expect(result.messages).toHaveLength(0); - }); - - it('should map function_call content block to tool_call part', () => { - const result = buildStructuredOutputMessages([{ - role: 'assistant', - content: [ - { type: 'function_call', name: 'my_func', id: 'fc_1', arguments: '{"x":1}' }, - ], - }]); - expect(result.messages[0].parts[0].type).toBe('tool_call'); - const part = result.messages[0].parts[0] as any; - expect(part.name).toBe('my_func'); - expect(part.id).toBe('fc_1'); - expect(part.arguments).toEqual({ x: 1 }); - }); - - it('should map input_file block with modality from mime_type', () => { - const result = buildStructuredInputMessages([{ - role: 'user', - content: [ - { type: 'input_file', mime_type: 'application/pdf', file_id: 'file_123' }, - ], - }] as any); - expect(result.messages[0].parts[0].type).toBe('file'); - expect((result.messages[0].parts[0] as any).modality).toBe('application'); - }); - - it('should fall back to raw wrapper when tool_call arguments are malformed JSON', () => { - const result = buildStructuredOutputMessages([{ - role: 'assistant', - content: [ - { type: 'tool_call', name: 'get_weather', call_id: 'c1', arguments: 'not-json{{{' }, - ], - }]); - const part = result.messages[0].parts[0] as any; - expect(part.type).toBe('tool_call'); - expect(part.arguments).toEqual({ raw: 'not-json{{{' }); - }); - - it('should produce a generic part for unknown input content block types', () => { - const result = buildStructuredInputMessages([{ - role: 'user', - content: [ - { type: 'future_block_type', some_field: 'value' }, - ], - }] as any); - const part = result.messages[0].parts[0] as any; - expect(part.type).toBe('future_block_type'); - expect(typeof part.content).toBe('string'); - }); - }); -}); diff --git a/tests/observability/integration/.env.example b/tests/observability/integration/.env.example deleted file mode 100644 index 199bd60b..00000000 --- a/tests/observability/integration/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# Environment variables for integration tests -# Copy this file to tests/.env and fill in your values - -# Azure OpenAI Configuration (required for integration tests) -AZURE_OPENAI_API_KEY=your-api-key-here -AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com -AZURE_OPENAI_DEPLOYMENT=gpt-4 - -# Test Configuration -TEST_LOG_LEVEL=info - -# Skip tests when Azure OpenAI config is missing (default: true) -# Set to 'false' to run tests anyway (they will fail without valid config) -SKIP_ON_MISSING_CONFIG=false diff --git a/tests/observability/integration/conftest.ts b/tests/observability/integration/conftest.ts deleted file mode 100644 index b43dd53f..00000000 --- a/tests/observability/integration/conftest.ts +++ /dev/null @@ -1,80 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -/** - * Test configuration and utilities based on Python conftest.py pattern - */ - -import * as dotenv from 'dotenv'; -import * as path from 'path'; - -// Load .env file if it exists (for local development) -const envFile = path.join(__dirname, '.', '.env'); - -try { - dotenv.config({ path: envFile }); -} catch (error) { - // .env file is optional -} - -/** - * Azure OpenAI configuration interface - */ -export interface AzureOpenAIConfig { - apiKey: string; - endpoint: string; - deployment: string; - apiVersion?: string; -} - -/** - * Agent 365 configuration interface - */ -export interface Agent365Config { - tenantId?: string; - correlationId?: string; - agentId?: string; -} - -/** - * Get Azure OpenAI configuration from environment variables - */ -export function getAzureOpenAIConfig(): AzureOpenAIConfig | null { - const apiKey = process.env.AZURE_OPENAI_API_KEY; - const endpoint = process.env.AZURE_OPENAI_ENDPOINT; - const deployment = process.env.AZURE_OPENAI_DEPLOYMENT; - - if (!apiKey || !endpoint || !deployment) { - return null; - } - - return { - apiKey, - endpoint, - deployment, - apiVersion: process.env.AZURE_OPENAI_API_VERSION || '2024-08-01-preview', - }; -} - -/** - * Get Agent 365 configuration from environment variables - */ -export function getAgent365Config(): Agent365Config { - return { - tenantId: process.env.AGENT365_TENANT_ID || 'test-tenant-id', - correlationId: process.env.AGENT365_CORRELATION_ID || 'test-correlation-id', - agentId: process.env.AGENT365_AGENT_ID || 'test-agent-id', - }; -} - -/** - * Environment validation helper - */ -export function validateEnvironment(): void { - const azureConfig = getAzureOpenAIConfig(); - - if (!azureConfig) { - throw new Error('Missing required environment variables: AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_DEPLOYMENT'); - } -} diff --git a/tests/observability/integration/helpers/span-validators.ts b/tests/observability/integration/helpers/span-validators.ts deleted file mode 100644 index c8bf2dfc..00000000 --- a/tests/observability/integration/helpers/span-validators.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { expect } from "@jest/globals"; -import { ReadableSpan } from "@opentelemetry/sdk-trace-base"; -import { OpenTelemetryConstants } from "@microsoft/agents-a365-observability"; -import { - expectValidInputMessages, - expectValidOutputMessages, -} from "../../extension/helpers/message-schema-validator"; - -/** - * Validate instrumentation scope for a span - */ -export function validateInstrumentationScope( - span: ReadableSpan, - expectedName: string, - expectedVersion: string, -): void { - expect(span.instrumentationScope).toBeDefined(); - expect(span.instrumentationScope.name).toBe(expectedName); - expect(span.instrumentationScope.version).toBe(expectedVersion); -} - -/** - * Validate basic span properties (traceId, id, timestamp) - */ -export function validateSpanProperties(span: ReadableSpan): void { - expect((span as any).traceId).toBeDefined(); - expect((span as any).id).toBeDefined(); - expect((span as any).timestamp).toBeDefined(); -} - -/** - * Validate parent-child span relationship via CUSTOM_PARENT_SPAN_ID_KEY - */ -export function validateParentChildRelationship( - childSpan: ReadableSpan, - parentSpan: ReadableSpan, -): void { - expect( - childSpan.attributes[OpenTelemetryConstants.CUSTOM_PARENT_SPAN_ID_KEY], - ).toBe(`0x${(parentSpan as any).id}`); -} - -/** - * Validate A365 message schema on a span's input/output messages. - * Calls expectValidInputMessages/expectValidOutputMessages from the shared - * message-schema-validator, which check for a plain JSON array of messages with roles and parts. - */ -export function validateMessageSchema(span: ReadableSpan): void { - const inputMessages = - span.attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY]; - if (inputMessages !== undefined) { - expectValidInputMessages(inputMessages); - } - - const outputMessages = - span.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY]; - if (outputMessages !== undefined) { - expectValidOutputMessages(outputMessages); - } -} - -/** - * Validate input message content structure - */ -export function validateInputMessageContent( - span: ReadableSpan, - expectations: { - hasRole?: string; - hasPartType?: string; - containsText?: string; - }, -): void { - const raw = - span.attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string; - expect(raw).toBeDefined(); - const parsed = JSON.parse(raw); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - - if (expectations.hasRole) { - expect( - parsed.some((m: any) => m.role === expectations.hasRole), - ).toBe(true); - } - if (expectations.hasPartType) { - expect( - parsed.some((m: any) => - m.parts?.some((p: any) => p.type === expectations.hasPartType), - ), - ).toBe(true); - } - if (expectations.containsText) { - const allText = JSON.stringify(parsed); - expect(allText).toContain(expectations.containsText); - } -} - -/** - * Validate output message content structure - */ -export function validateOutputMessageContent( - span: ReadableSpan, - expectations: { - hasRole?: string; - hasPartType?: string; - }, -): void { - const raw = - span.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string; - expect(raw).toBeDefined(); - const parsed = JSON.parse(raw); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - - if (expectations.hasRole) { - expect( - parsed.some((m: any) => m.role === expectations.hasRole), - ).toBe(true); - } - if (expectations.hasPartType) { - expect( - parsed.some((m: any) => - m.parts?.some((p: any) => p.type === expectations.hasPartType), - ), - ).toBe(true); - } -} - -/** - * Wait for spans to accumulate with a polling timeout - */ -export async function waitForSpans( - spans: ReadableSpan[], - minCount: number, - timeoutMs: number = 5000, -): Promise { - const startTime = Date.now(); - while (spans.length < minCount && Date.now() - startTime < timeoutMs) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } -} diff --git a/tests/observability/integration/jest.polyfills.js b/tests/observability/integration/jest.polyfills.js deleted file mode 100644 index 4ab973a0..00000000 --- a/tests/observability/integration/jest.polyfills.js +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Node.js 18 polyfills for integration tests. - * - * In Node.js 18, the Web Crypto API is exposed as `globalThis.crypto` but is - * NOT automatically aliased to the bare `crypto` global in CommonJS module - * context. @langchain/core (uuid/rng.ts) references `crypto` directly, so we - * need to polyfill it here before any test modules are loaded. - * - * In Node.js 19+, `crypto` was promoted to a first-class global; this guard - * ensures the polyfill is a no-op on newer runtimes. - */ -if (typeof globalThis.crypto === "undefined") { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { webcrypto } = require("crypto"); - globalThis.crypto = webcrypto; -} diff --git a/tests/observability/integration/langchain-agent-instrument.test.ts b/tests/observability/integration/langchain-agent-instrument.test.ts deleted file mode 100644 index 841a4748..00000000 --- a/tests/observability/integration/langchain-agent-instrument.test.ts +++ /dev/null @@ -1,534 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeAll, afterAll, beforeEach } from "@jest/globals"; -import { getAzureOpenAIConfig, validateEnvironment } from "./conftest"; -import { ReadableSpan } from "@opentelemetry/sdk-trace-base"; -import { SpanKind } from "@opentelemetry/api"; -import { ObservabilityManager, Builder, OpenTelemetryConstants } from "@microsoft/agents-a365-observability"; -import { LangChainTraceInstrumentor } from "@microsoft/agents-a365-observability-extensions-langchain"; -import * as LangChainCallbacks from "@langchain/core/callbacks/manager"; -import { AzureChatOpenAI } from "@langchain/openai"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore — ts-jest module:commonjs cannot resolve package exports subpaths -import { createReactAgent } from "@langchain/langgraph/prebuilt"; -import { DynamicStructuredTool } from "@langchain/core/tools"; -import { z } from "zod"; -import { ObservabilityBuilder } from "@microsoft/agents-a365-observability/dist/esm/ObservabilityBuilder"; -import { BaggageBuilder } from "@microsoft/agents-a365-observability"; -import { - validateInstrumentationScope, - validateSpanProperties, - validateMessageSchema, - validateInputMessageContent, - validateOutputMessageContent, - waitForSpans, -} from "./helpers/span-validators"; - -// The LangChain instrumentor uses hardcoded tracer name/version -const TEST_INSTRUMENTATION_NAME = "agent365-langchain"; -const TEST_INSTRUMENTATION_VERSION = "1.0.0"; - -describe("LangChain Trace Processor Integration Tests", () => { - let a365Observability: ObservabilityBuilder; - let consoleDirSpy: jest.SpyInstance; - let spans: ReadableSpan[] = []; - - beforeAll(async () => { - validateEnvironment(); - console.log("Setting up LangChain Trace Processor test suite..."); - - // Spy on console.dir which ConsoleSpanExporter uses - consoleDirSpy = jest - .spyOn(console, "dir") - .mockImplementation((obj: any) => { - spans.push(obj as ReadableSpan); - }); - - // Configure observability (must happen before instrumentor init) - a365Observability = ObservabilityManager.configure((builder: Builder) => - builder.withService("LangChain Agent Instrumentation Test", "1.0.0"), - ); - - // Instrument LangChain callbacks and enable - LangChainTraceInstrumentor.instrument(LangChainCallbacks as any); - LangChainTraceInstrumentor.enable(); - - // Start observability - a365Observability.start(); - }); - - afterAll(async () => { - console.log("Tearing down LangChain Trace Processor test suite..."); - - if (consoleDirSpy) { - consoleDirSpy.mockRestore(); - } - - LangChainTraceInstrumentor.disable(); - LangChainTraceInstrumentor.resetInstance(); - - if (a365Observability) { - await a365Observability.shutdown(); - } - - console.log("LangChain Trace Processor test suite teardown complete"); - }); - - beforeEach(() => { - spans = []; - }); - - it("validate chat span", async () => { - const azureConfig = getAzureOpenAIConfig(); - - if (!azureConfig) { - throw new Error("Azure OpenAI configuration is required"); - } - - try { - const model = new AzureChatOpenAI({ - azureOpenAIApiKey: azureConfig.apiKey, - azureOpenAIEndpoint: azureConfig.endpoint, - azureOpenAIApiDeploymentName: azureConfig.deployment, - azureOpenAIApiVersion: azureConfig.apiVersion, - }); - - const agentName = "LangChain Test Agent"; - const agent = createReactAgent({ - llm: model, - tools: [], - name: agentName, - }); - - const prompt = "Say hello!"; - const result = await agent.invoke({ - messages: [{ role: "user", content: prompt }], - }); - - // Wait for spans - await waitForSpans(spans, 2); - - // Verify we captured spans - expect(spans.length).toBeGreaterThanOrEqual(2); - console.log("Total spans captured:", spans.length); - - // Output all the spans - spans.forEach((span, idx) => { - console.log(`\n--- Span ${idx + 1} of ${spans.length} ---`); - console.log(JSON.stringify(span, null, 2)); - }); - - // Find the chat span (LLM inference) - const chatSpan = spans.find( - (span) => - span.attributes[ - OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY - ] === "chat", - ); - expect(chatSpan).toBeDefined(); - expect(chatSpan?.name?.toLowerCase()).toContain("chat"); - console.log("Validate chat span"); - - if (chatSpan) { - validateInstrumentationScope(chatSpan, TEST_INSTRUMENTATION_NAME, TEST_INSTRUMENTATION_VERSION); - validateSpanProperties(chatSpan); - expect(chatSpan.kind).toBe(SpanKind.CLIENT); - expect(chatSpan.name.toLowerCase()).toContain("chat"); - - // Validate gen_ai attributes - expect( - chatSpan.attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY], - ).toBe("chat"); - const provider = chatSpan.attributes[OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY]; - expect(typeof provider).toBe("string"); - expect((provider as string).length).toBeGreaterThan(0); - expect( - chatSpan.attributes[OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY], - ).toBeDefined(); - expect( - chatSpan.attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY], - ).toBeDefined(); - expect( - chatSpan.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY], - ).toBeDefined(); - - // Token usage from AzureChatOpenAI - const inputTokens = chatSpan.attributes[OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY]; - const outputTokens = chatSpan.attributes[OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY]; - expect(typeof inputTokens).toBe("number"); - expect(typeof outputTokens).toBe("number"); - expect(inputTokens as number).toBeGreaterThan(0); - expect(outputTokens as number).toBeGreaterThan(0); - - // Validate A365 message schema - validateMessageSchema(chatSpan); - validateInputMessageContent(chatSpan, { - hasRole: "user", - hasPartType: "text", - }); - validateOutputMessageContent(chatSpan, { - hasRole: "assistant", - hasPartType: "text", - }); - - // Detailed envelope + parts checks - const parsedInput = JSON.parse( - chatSpan.attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string, - ); - expect(Array.isArray(parsedInput)).toBe(true); - const userMsg = parsedInput.find((m: any) => m.role === "user"); - expect(userMsg).toBeDefined(); - expect(Array.isArray(userMsg.parts)).toBe(true); - expect(userMsg.parts[0].type).toBe("text"); - expect(typeof userMsg.parts[0].content).toBe("string"); - expect(userMsg.parts[0].content).toContain(prompt); - - const parsedOutput = JSON.parse( - chatSpan.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string, - ); - expect(Array.isArray(parsedOutput)).toBe(true); - const assistantMsg = parsedOutput.find((m: any) => m.role === "assistant"); - expect(assistantMsg).toBeDefined(); - expect(Array.isArray(assistantMsg.parts)).toBe(true); - expect(assistantMsg.parts[0].type).toBe("text"); - expect(typeof assistantMsg.parts[0].content).toBe("string"); - expect((assistantMsg.parts[0].content as string).length).toBeGreaterThan(0); - - // Validate status - expect(chatSpan.status).toBeDefined(); - expect(chatSpan.status.code).toBe(1); - - console.log("Chat span validation passed"); - } - - // Verify the response - expect(result).toBeDefined(); - console.log("Agent response received"); - } catch (error) { - console.error("Test error:", error); - throw error; - } - }); - - it("validate agent span", async () => { - const azureConfig = getAzureOpenAIConfig(); - - if (!azureConfig) { - throw new Error("Azure OpenAI configuration is required"); - } - - try { - const model = new AzureChatOpenAI({ - azureOpenAIApiKey: azureConfig.apiKey, - azureOpenAIEndpoint: azureConfig.endpoint, - azureOpenAIApiDeploymentName: azureConfig.deployment, - azureOpenAIApiVersion: azureConfig.apiVersion, - }); - - const agentName = "LangChain Agent Span Test"; - const agent = createReactAgent({ - llm: model, - tools: [], - name: agentName, - }); - - const result = await agent.invoke({ - messages: [{ role: "user", content: "Say hello!" }], - }); - - await waitForSpans(spans, 2); - - // Find and validate the agent span only - const agentSpan = spans.find( - (span) => span.name === `invoke_agent ${agentName}`, - ); - expect(agentSpan).toBeDefined(); - console.log("Validate agent span"); - - if (agentSpan) { - validateInstrumentationScope(agentSpan, TEST_INSTRUMENTATION_NAME, TEST_INSTRUMENTATION_VERSION); - validateSpanProperties(agentSpan); - expect(agentSpan.kind).toBe(SpanKind.SERVER); - expect(agentSpan.name).toBe(`invoke_agent ${agentName}`); - expect( - agentSpan.attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY], - ).toBe("invoke_agent"); - expect( - agentSpan.attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY], - ).toBe(agentName); - // Top-level agent: no inbound caller - expect( - agentSpan.attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY], - ).toBeUndefined(); - expect(agentSpan.status).toBeDefined(); - expect(agentSpan.status.code).toBe(1); - console.log("Agent span validation passed"); - } - - expect(result).toBeDefined(); - console.log("Agent response received"); - } catch (error) { - console.error("Test error:", error); - throw error; - } - }); - - it("validate execute_tool span", async () => { - const azureConfig = getAzureOpenAIConfig(); - - if (!azureConfig) { - throw new Error("Azure OpenAI configuration is required"); - } - - try { - const model = new AzureChatOpenAI({ - azureOpenAIApiKey: azureConfig.apiKey, - azureOpenAIEndpoint: azureConfig.endpoint, - azureOpenAIApiDeploymentName: azureConfig.deployment, - azureOpenAIApiVersion: azureConfig.apiVersion, - }); - - const addTool = new DynamicStructuredTool({ - name: "add_numbers", - description: "Add two numbers together", - schema: z.object({ - a: z.number().describe("The first number"), - b: z.number().describe("The second number"), - }), - func: async ({ a, b }: { a: number; b: number }) => { - const result = a + b; - return `The sum of ${a} and ${b} is ${result}`; - }, - }); - - const agentName = "MathAgent"; - const agent = createReactAgent({ - llm: model, - tools: [addTool], - name: agentName, - }); - - const prompt = "What is 15 plus 27?"; - const result = await agent.invoke({ - messages: [{ role: "user", content: prompt }], - }); - - // Wait for spans (agent + chat + tool, possibly more chat spans for multi-turn) - await waitForSpans(spans, 3); - - // Verify we captured spans - expect(spans.length).toBeGreaterThanOrEqual(3); - console.log("Total spans captured:", spans.length); - - // Output all the spans - spans.forEach((span, idx) => { - console.log(`\n--- Span ${idx + 1} of ${spans.length} ---`); - console.log(JSON.stringify(span, null, 2)); - }); - - // Find and validate the tool execution span only - const toolSpan = spans.find( - (span) => span.name === "execute_tool add_numbers", - ); - expect(toolSpan).toBeDefined(); - console.log("Validate tool execution span"); - - if (toolSpan) { - validateInstrumentationScope(toolSpan, TEST_INSTRUMENTATION_NAME, TEST_INSTRUMENTATION_VERSION); - validateSpanProperties(toolSpan); - expect(toolSpan.kind).toBe(SpanKind.CLIENT); - - // Validate tool-specific attributes - expect( - toolSpan.attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY], - ).toBe("execute_tool"); - expect( - toolSpan.attributes[OpenTelemetryConstants.GEN_AI_TOOL_NAME_KEY], - ).toBe("add_numbers"); - expect( - toolSpan.attributes[OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY], - ).toBe("extension"); - - // Validate tool args — serialized as JSON object - const toolArgs = toolSpan.attributes[ - OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY - ] as string; - expect(typeof toolArgs).toBe("string"); - const parsedArgs = JSON.parse(toolArgs); - expect(parsedArgs.a).toBe(15); - expect(parsedArgs.b).toBe(27); - - // Validate tool result — string results are wrapped as { result: "..." } - const toolResult = toolSpan.attributes[ - OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY - ] as string; - expect(typeof toolResult).toBe("string"); - const parsedResult = JSON.parse(toolResult); - expect(typeof parsedResult.result).toBe("string"); - expect(parsedResult.result).toContain("42"); - expect(parsedResult.result).toContain("The sum of 15 and 27"); - - // Validate status - expect(toolSpan.status).toBeDefined(); - expect(toolSpan.status.code).toBe(1); - - console.log("Tool execution span validated"); - } - - // Verify the response - expect(result).toBeDefined(); - console.log("Agent response received"); - } catch (error) { - console.error("Test error:", error); - throw error; - } - }); - - it("validate baggage propagation to spans", async () => { - const azureConfig = getAzureOpenAIConfig(); - - if (!azureConfig) { - throw new Error("Azure OpenAI configuration is required"); - } - - try { - const model = new AzureChatOpenAI({ - azureOpenAIApiKey: azureConfig.apiKey, - azureOpenAIEndpoint: azureConfig.endpoint, - azureOpenAIApiDeploymentName: azureConfig.deployment, - azureOpenAIApiVersion: azureConfig.apiVersion, - }); - - const agentName = "BaggageTestAgent"; - const agent = createReactAgent({ - llm: model, - tools: [], - name: agentName, - }); - - // Set up baggage context with known values - const testTenantId = "test-tenant-123"; - const testAgentId = "test-agent-456"; - const testUserId = "test-user-789"; - const testSessionId = "test-session-abc"; - const testChannelName = "test-channel"; - const testConversationId = "test-conversation-def"; - - const baggageScope = new BaggageBuilder() - .tenantId(testTenantId) - .agentId(testAgentId) - .userId(testUserId) - .sessionId(testSessionId) - .channelName(testChannelName) - .conversationId(testConversationId) - .build(); - - // Run agent within baggage scope - const result = await baggageScope.run(async () => { - return await agent.invoke({ - messages: [{ role: "user", content: "Say hello!" }], - }); - }); - - await waitForSpans(spans, 2); - - expect(spans.length).toBeGreaterThanOrEqual(2); - console.log("Total spans captured:", spans.length); - - // Validate baggage propagation on all spans - for (const span of spans) { - console.log(`Checking baggage on span: ${span.name}`); - - expect(span.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(testTenantId); - expect(span.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(testAgentId); - expect(span.attributes[OpenTelemetryConstants.USER_ID_KEY]).toBe(testUserId); - expect(span.attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe(testSessionId); - expect(span.attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe(testChannelName); - expect(span.attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe(testConversationId); - - console.log(`Baggage validated on span: ${span.name}`); - } - - expect(result).toBeDefined(); - console.log("Baggage propagation test passed"); - } catch (error) { - console.error("Test error:", error); - throw error; - } - }); - - it("validate nested-agent caller.agent.name and error.type on tool failure", async () => { - const azureConfig = getAzureOpenAIConfig(); - if (!azureConfig) throw new Error("Azure OpenAI configuration is required"); - - const model = new AzureChatOpenAI({ - azureOpenAIApiKey: azureConfig.apiKey, - azureOpenAIEndpoint: azureConfig.endpoint, - azureOpenAIApiDeploymentName: azureConfig.deployment, - azureOpenAIApiVersion: azureConfig.apiVersion, - }); - - // Nested agent: outer agent calls a tool that invokes an inner agent - const innerAgent = createReactAgent({ llm: model, tools: [], name: "InnerAgent" }); - const delegateTool = new DynamicStructuredTool({ - name: "delegate", - description: "Delegate the request to InnerAgent", - schema: z.object({ query: z.string() }), - // Pass the tool's RunnableConfig so the inner agent's run is linked as a child. - func: async ({ query }: { query: string }, _runManager, config) => { - const r = await innerAgent.invoke( - { messages: [{ role: "user", content: query }] }, - config, - ); - return JSON.stringify(r); - }, - }); - // Error-producing tool - const throwingTool = new DynamicStructuredTool({ - name: "will_throw", - description: "Always fails with an error", - schema: z.object({}), - func: async () => { throw new Error("simulated failure"); }, - }); - - const outerAgent = createReactAgent({ - llm: model, - tools: [delegateTool, throwingTool], - name: "OuterAgent", - }); - - try { - await outerAgent.invoke({ - messages: [{ role: "user", content: "Call the will_throw tool and also delegate 'ping' to InnerAgent." }], - }); - } catch { - // Errors bubble up from the agent run; we still expect spans to be recorded. - } - - await waitForSpans(spans, 3); - - // LangGraph renames the nested agent run after the calling tool ("delegate"), - // so we look for any invoke_agent span that isn't the outer one and verify - // its caller.agent.name points back to OuterAgent via the parent-run walk. - const nestedAgentSpan = spans.find( - (s) => - s.attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY] === "invoke_agent" && - s.attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY] !== "OuterAgent" && - s.attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY] !== undefined, - ); - expect(nestedAgentSpan).toBeDefined(); - expect(nestedAgentSpan?.attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY]).toBe("OuterAgent"); - console.log( - `caller.agent.name via parent walk validated (nested agent name: ${nestedAgentSpan?.attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY]})`, - ); - - const errorSpan = spans.find((s) => s.attributes[OpenTelemetryConstants.ERROR_TYPE_KEY]); - expect(errorSpan).toBeDefined(); - const errorType = errorSpan?.attributes[OpenTelemetryConstants.ERROR_TYPE_KEY]; - expect(typeof errorType).toBe("string"); - expect((errorType as string).length).toBeGreaterThan(0); - expect(errorSpan?.attributes[OpenTelemetryConstants.ERROR_MESSAGE_KEY]).toContain("simulated failure"); - console.log(`error.type="${errorType}", error.message validated`); - }); -}); diff --git a/tests/observability/integration/openai-agent-instrument.test.ts b/tests/observability/integration/openai-agent-instrument.test.ts deleted file mode 100644 index d43c68b3..00000000 --- a/tests/observability/integration/openai-agent-instrument.test.ts +++ /dev/null @@ -1,635 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -import { describe, it, expect, beforeAll, afterAll, beforeEach } from "@jest/globals"; -import { getAzureOpenAIConfig, validateEnvironment } from "./conftest"; -import { ReadableSpan } from "@opentelemetry/sdk-trace-base"; -import { SpanKind } from "@opentelemetry/api"; -import { ObservabilityManager, Builder, OpenTelemetryConstants } from "@microsoft/agents-a365-observability"; -import { OpenAIAgentsTraceInstrumentor } from "@microsoft/agents-a365-observability-extensions-openai"; -import { Agent, run, tool } from "@openai/agents"; -import { OpenAIChatCompletionsModel } from "@openai/agents-openai"; -import { ObservabilityBuilder } from "@microsoft/agents-a365-observability/dist/esm/ObservabilityBuilder"; -import { AzureOpenAI } from "openai"; -import { BaggageBuilder } from "@microsoft/agents-a365-observability"; -import { - validateInstrumentationScope, - validateSpanProperties, - validateMessageSchema, - validateInputMessageContent, - validateOutputMessageContent, - validateParentChildRelationship, - waitForSpans, -} from "./helpers/span-validators"; - -// Test instrumentation constants -const TEST_INSTRUMENTATION_NAME = "openai-agent-test-instrumentation"; -const TEST_INSTRUMENTATION_VERSION = "1.0.0"; - -describe("OpenAI Trace Processor Integration Tests", () => { - let openAIAgentsTraceInstrumentor: OpenAIAgentsTraceInstrumentor; - let a365Observability: ObservabilityBuilder; - let consoleDirSpy: jest.SpyInstance; - let spans: ReadableSpan[] = []; - - beforeAll(async () => { - validateEnvironment(); - console.log("Setting up OpenAI Trace Processor test suite..."); - - // Also spy on console.dir which ConsoleSpanExporter uses - consoleDirSpy = jest - .spyOn(console, "dir") - .mockImplementation((obj: any) => { - spans.push(obj as ReadableSpan); - }); - - // Configure observability following the sample pattern - a365Observability = ObservabilityManager.configure((builder: Builder) => - builder.withService("OpenAI Agent Instrumentation Sample", "1.0.0"), - ); - - // Initialize OpenAI Agents instrumentation - openAIAgentsTraceInstrumentor = new OpenAIAgentsTraceInstrumentor({ - enabled: true, - tracerName: TEST_INSTRUMENTATION_NAME, - tracerVersion: TEST_INSTRUMENTATION_VERSION, - }); - - // Start observability - a365Observability.start(); - }); - - afterAll(async () => { - console.log("🧹 Tearing down OpenAI Trace Processor test suite..."); - - if (consoleDirSpy) { - consoleDirSpy.mockRestore(); - } - - if (openAIAgentsTraceInstrumentor) { - openAIAgentsTraceInstrumentor.disable(); - } - - if (a365Observability) { - await a365Observability.shutdown(); - } - - console.log("✅ OpenAI Trace Processor test suite teardown complete"); - }); - - beforeEach(() => { - spans = []; - }); - - it("validate chat span", async () => { - const azureConfig = getAzureOpenAIConfig(); - - if (!azureConfig) { - throw new Error("Azure OpenAI configuration is required"); - } - - try { - let agent: Agent; - - const azureClient = new AzureOpenAI({ - endpoint: azureConfig.endpoint, - deployment: azureConfig.deployment, - apiKey: azureConfig.apiKey, - apiVersion: azureConfig.apiVersion, - }); - - agent = new Agent({ - name: "Test Agent", - model: new OpenAIChatCompletionsModel( - azureClient as any, - azureConfig.deployment, - ), - instructions: "You are a helpful assistant.", - }); - - // Run agent with a simple prompt - const prompt = "Say hello!"; - const result = await run(agent, prompt); - - // Wait for spans with timeout - await waitForSpans(spans, 2); - - // Verify we captured spans - expect(spans.length).toBeGreaterThanOrEqual(2); - console.log("Total spans captured:", spans.length); - - // Output all the spans - spans.forEach((span, idx) => { - console.log(`\n--- Span ${idx + 1} of ${spans.length} ---`); - console.log(JSON.stringify(span, null, 2)); - }); - - // Find and validate the chat span - const inferenceSpan = spans.find( - (span) => - span.attributes[ - OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY - ] === "chat", - ); - expect(inferenceSpan).toBeDefined(); - expect(inferenceSpan?.name?.toLowerCase()).toContain("chat"); - console.log("Validate inference span"); - if (inferenceSpan) { - validateInstrumentationScope(inferenceSpan, TEST_INSTRUMENTATION_NAME, TEST_INSTRUMENTATION_VERSION); - validateSpanProperties(inferenceSpan); - expect(inferenceSpan.kind).toBe(SpanKind.CLIENT); - expect(inferenceSpan.name.toLowerCase()).toContain("chat"); - - // Validate gen_ai attributes - expect( - inferenceSpan.attributes[ - OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY - ], - ).toBe("chat"); - expect( - inferenceSpan.attributes[OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY], - ).toBe("openai"); - expect( - inferenceSpan.attributes[ - OpenTelemetryConstants.GEN_AI_REQUEST_MODEL_KEY - ], - ).toBe(azureConfig.deployment); - expect( - inferenceSpan.attributes[ - OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY - ], - ).toBeDefined(); - expect( - inferenceSpan.attributes[ - OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY - ], - ).toContain(prompt); - expect( - inferenceSpan.attributes[ - OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY - ], - ).toBeDefined(); - expect( - inferenceSpan.attributes[ - OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY - ], - ).toContain("chat.completion"); - - // Validate A365 message schema - validateMessageSchema(inferenceSpan); - validateInputMessageContent(inferenceSpan, { - hasRole: "user", - hasPartType: "text", - containsText: prompt, - }); - validateOutputMessageContent(inferenceSpan, { - hasRole: "assistant", - hasPartType: "text", - }); - - // Detailed envelope + parts checks - const parsedInput = JSON.parse( - inferenceSpan.attributes[OpenTelemetryConstants.GEN_AI_INPUT_MESSAGES_KEY] as string, - ); - expect(Array.isArray(parsedInput)).toBe(true); - const userMsg = parsedInput.find((m: any) => m.role === "user"); - expect(userMsg).toBeDefined(); - expect(Array.isArray(userMsg.parts)).toBe(true); - expect(userMsg.parts[0].type).toBe("text"); - expect(typeof userMsg.parts[0].content).toBe("string"); - expect(userMsg.parts[0].content).toContain(prompt); - - const parsedOutput = JSON.parse( - inferenceSpan.attributes[OpenTelemetryConstants.GEN_AI_OUTPUT_MESSAGES_KEY] as string, - ); - expect(Array.isArray(parsedOutput)).toBe(true); - const assistantMsg = parsedOutput.find((m: any) => m.role === "assistant"); - expect(assistantMsg).toBeDefined(); - expect(Array.isArray(assistantMsg.parts)).toBe(true); - expect(assistantMsg.parts[0].type).toBe("text"); - expect(typeof assistantMsg.parts[0].content).toBe("string"); - expect((assistantMsg.parts[0].content as string).length).toBeGreaterThan(0); - - // Token usage — our processor maps both Responses API (input_tokens/output_tokens) - // and Chat Completions (prompt_tokens/completion_tokens) into schema-defined attrs. - const inputTokens = inferenceSpan.attributes[OpenTelemetryConstants.GEN_AI_USAGE_INPUT_TOKENS_KEY]; - const outputTokens = inferenceSpan.attributes[OpenTelemetryConstants.GEN_AI_USAGE_OUTPUT_TOKENS_KEY]; - expect(typeof inputTokens).toBe("number"); - expect(typeof outputTokens).toBe("number"); - expect(inputTokens as number).toBeGreaterThan(0); - expect(outputTokens as number).toBeGreaterThan(0); - - // Validate status - expect(inferenceSpan.status).toBeDefined(); - expect(inferenceSpan.status.code).toBe(1); - - console.log("✅ Inference span validation passed"); - } - - // Verify the response - expect(result.finalOutput).toBeDefined(); - console.log("✅ Agent response received"); - } catch (error) { - console.error("Test error:", error); - throw error; - } - }); - - it("validate agent span", async () => { - const azureConfig = getAzureOpenAIConfig(); - - if (!azureConfig) { - throw new Error("Azure OpenAI configuration is required"); - } - - try { - const azureClient = new AzureOpenAI({ - endpoint: azureConfig.endpoint, - deployment: azureConfig.deployment, - apiKey: azureConfig.apiKey, - apiVersion: azureConfig.apiVersion, - }); - - const agentName = "Agent Span Test Agent"; - const agent = new Agent({ - name: agentName, - model: new OpenAIChatCompletionsModel( - azureClient as any, - azureConfig.deployment, - ), - instructions: "You are a helpful assistant.", - }); - - const result = await run(agent, "Say hello!"); - await waitForSpans(spans, 2); - - // Find and validate the agent span only - const agentSpan = spans.find( - (span) => span.name === `invoke_agent ${agentName}`, - ); - const generationSpan = spans.find( - (span) => span.attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY] === "chat", - ); - expect(agentSpan).toBeDefined(); - expect(generationSpan).toBeDefined(); - console.log("Validate agent span"); - - validateInstrumentationScope(agentSpan!, TEST_INSTRUMENTATION_NAME, TEST_INSTRUMENTATION_VERSION); - validateSpanProperties(agentSpan!); - expect(agentSpan!.kind).toBe(SpanKind.SERVER); - expect(agentSpan!.name).toBe(`invoke_agent ${agentName}`); - expect( - agentSpan!.attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY], - ).toBe("invoke_agent"); - expect( - agentSpan!.attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY], - ).toBe(agentName); - expect( - agentSpan!.attributes[OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY], - ).toBe("openai"); - // Top-level agent: no inbound caller - expect( - agentSpan!.attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY], - ).toBeUndefined(); - expect( - agentSpan!.attributes[OpenTelemetryConstants.CUSTOM_PARENT_SPAN_ID_KEY], - ).toBeUndefined(); - expect(agentSpan!.status).toBeDefined(); - expect(agentSpan!.status.code).toBe(1); - - // Validate parent-child relationship: generation span should reference agent span as custom parent - validateParentChildRelationship(generationSpan!, agentSpan!); - - console.log("✅ Agent span validation passed"); - - expect(result.finalOutput).toBeDefined(); - console.log("✅ Agent response received"); - } catch (error) { - console.error("Test error:", error); - throw error; - } - }); - - it("validate execute_tool span", async () => { - const azureConfig = getAzureOpenAIConfig(); - - if (!azureConfig) { - throw new Error("Azure OpenAI configuration is required"); - } - - try { - const azureClient = new AzureOpenAI({ - endpoint: azureConfig.endpoint, - deployment: azureConfig.deployment, - apiKey: azureConfig.apiKey, - apiVersion: azureConfig.apiVersion, - }); - - const addTool: any = tool({ - name: "add_numbers", - description: "Add two numbers together", - parameters: { - type: "object", - properties: { - a: { - type: "number", - description: "The first number" - }, - b: { - type: "number", - description: "The second number" - } - }, - required: ["a", "b"], - additionalProperties: false - } as any, - execute: async ({ a, b }: { a: number; b: number }) => { - const result = a + b; - return `The sum of ${a} and ${b} is ${result}`; - }, - }); - - const agentName = "Math Agent"; - const agent = new Agent({ - name: "Math Agent", - model: new OpenAIChatCompletionsModel( - azureClient as any, - process.env.AZURE_OPENAI_DEPLOYMENT!, - ), - instructions: - "You are a helpful math assistant. When asked to add numbers, use the 'add_numbers' tool.", - tools: [addTool], - }); - const prompt = "What is 15 plus 27?"; - const result = await run(agent, prompt); - - await waitForSpans(spans, 3); - - // Verify we captured spans - expect(spans.length).toBeGreaterThanOrEqual(3); - console.log("Total spans captured:", spans.length); - - // Output all the spans - spans.forEach((span, idx) => { - console.log(`\n--- Span ${idx + 1} of ${spans.length} ---`); - console.log(JSON.stringify(span, null, 2)); - }); - - // Find and validate the tool execution span only - const toolSpan = spans.find( - (span) => span.name === "execute_tool add_numbers", - ); - const agentSpanForTool = spans.find( - (span) => span.name === "invoke_agent Math Agent", - ); - expect(toolSpan).toBeDefined(); - expect(agentSpanForTool).toBeDefined(); - console.log("Validate tool execution span"); - - if (toolSpan) { - validateInstrumentationScope(toolSpan, TEST_INSTRUMENTATION_NAME, TEST_INSTRUMENTATION_VERSION); - validateSpanProperties(toolSpan); - expect(toolSpan.kind).toBe(SpanKind.CLIENT); - - // Validate tool-specific attributes - expect( - toolSpan.attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY], - ).toBe("execute_tool"); - expect( - toolSpan.attributes[OpenTelemetryConstants.GEN_AI_PROVIDER_NAME_KEY], - ).toBe("openai"); - expect( - toolSpan.attributes[OpenTelemetryConstants.GEN_AI_TOOL_NAME_KEY], - ).toBe("add_numbers"); - expect( - toolSpan.attributes[OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY], - ).toBe("function"); - expect( - toolSpan.attributes[OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY], - ).toBe('{"a":15,"b":27}'); - expect( - toolSpan.attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY], - ).toBe('{"result":"The sum of 15 and 27 is 42"}'); - - // Validate status - expect(toolSpan.status).toBeDefined(); - expect(toolSpan.status.code).toBe(1); - - // Validate parent-child relationship: tool span should reference agent span as custom parent - validateParentChildRelationship(toolSpan, agentSpanForTool!); - - console.log("✅ Tool execution span validated"); - } - - // Verify the response - expect(result.finalOutput).toBeDefined(); - console.log("✅ Agent response received"); - } catch (error) { - console.error("Test error:", error); - throw error; - } - }); - - it("validate baggage propagation to spans", async () => { - const azureConfig = getAzureOpenAIConfig(); - - if (!azureConfig) { - throw new Error("Azure OpenAI configuration is required"); - } - - try { - const azureClient = new AzureOpenAI({ - endpoint: azureConfig.endpoint, - deployment: azureConfig.deployment, - apiKey: azureConfig.apiKey, - apiVersion: azureConfig.apiVersion, - }); - - const agentName = "Baggage Test Agent"; - const agent = new Agent({ - name: agentName, - model: new OpenAIChatCompletionsModel( - azureClient as any, - azureConfig.deployment, - ), - instructions: "You are a helpful assistant.", - }); - - // Set up baggage context with known values - const testTenantId = "test-tenant-123"; - const testAgentId = "test-agent-456"; - const testUserId = "test-user-789"; - const testSessionId = "test-session-abc"; - const testChannelName = "test-channel"; - const testConversationId = "test-conversation-def"; - - const baggageScope = new BaggageBuilder() - .tenantId(testTenantId) - .agentId(testAgentId) - .userId(testUserId) - .sessionId(testSessionId) - .channelName(testChannelName) - .conversationId(testConversationId) - .build(); - - // Run agent within baggage scope - const result = await baggageScope.run(async () => { - return await run(agent, "Say hello!"); - }); - - await waitForSpans(spans, 2); - - expect(spans.length).toBeGreaterThanOrEqual(2); - console.log("Total spans captured:", spans.length); - - // Validate baggage propagation on all spans - for (const span of spans) { - console.log(`Checking baggage on span: ${span.name}`); - - expect(span.attributes[OpenTelemetryConstants.TENANT_ID_KEY]).toBe(testTenantId); - expect(span.attributes[OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]).toBe(testAgentId); - expect(span.attributes[OpenTelemetryConstants.USER_ID_KEY]).toBe(testUserId); - expect(span.attributes[OpenTelemetryConstants.SESSION_ID_KEY]).toBe(testSessionId); - expect(span.attributes[OpenTelemetryConstants.CHANNEL_NAME_KEY]).toBe(testChannelName); - expect(span.attributes[OpenTelemetryConstants.GEN_AI_CONVERSATION_ID_KEY]).toBe(testConversationId); - - console.log(`✅ Baggage validated on span: ${span.name}`); - } - - expect(result.finalOutput).toBeDefined(); - console.log("✅ Baggage propagation test passed"); - } catch (error) { - console.error("Test error:", error); - throw error; - } - }); - - it("validate handoff emits CLIENT InvokeAgent with caller + SERVER InvokeAgent for target", async () => { - const azureConfig = getAzureOpenAIConfig(); - - if (!azureConfig) { - throw new Error("Azure OpenAI configuration is required"); - } - - try { - const azureClient = new AzureOpenAI({ - endpoint: azureConfig.endpoint, - deployment: azureConfig.deployment, - apiKey: azureConfig.apiKey, - apiVersion: azureConfig.apiVersion, - }); - - const billingAgent = new Agent({ - name: "BillingAgent", - model: new OpenAIChatCompletionsModel(azureClient as any, azureConfig.deployment), - instructions: "You handle billing-related questions. Respond briefly.", - }); - - const triageAgent = new Agent({ - name: "TriageAgent", - model: new OpenAIChatCompletionsModel(azureClient as any, azureConfig.deployment), - instructions: - "For any question about billing, invoices, refunds, or payments, immediately hand off to BillingAgent. Do not answer directly.", - handoffs: [billingAgent], - }); - - const prompt = "I was double-charged on my invoice last month — can I get a refund?"; - const result = await run(triageAgent, prompt); - - await waitForSpans(spans, 3); - expect(spans.length).toBeGreaterThanOrEqual(3); - - spans.forEach((span, idx) => { - console.log(`\n--- Span ${idx + 1} of ${spans.length} ---`); - console.log(JSON.stringify({ name: span.name, kind: span.kind, attributes: span.attributes }, null, 2)); - }); - - // CLIENT-kind InvokeAgent span emitted for the handoff itself - const handoffSpan = spans.find( - (s) => - s.kind === SpanKind.CLIENT && - s.attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY] === "invoke_agent" && - s.attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY] === "BillingAgent" - ); - expect(handoffSpan).toBeDefined(); - if (handoffSpan) { - expect(handoffSpan.attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY]).toBe("TriageAgent"); - expect(handoffSpan.name).toBe("invoke_agent BillingAgent"); - console.log("✅ CLIENT-kind handoff InvokeAgent span validated"); - } - - // SERVER-kind InvokeAgent span emitted for BillingAgent's actual work - const billingAgentSpan = spans.find( - (s) => - s.kind === SpanKind.SERVER && - s.attributes[OpenTelemetryConstants.GEN_AI_AGENT_NAME_KEY] === "BillingAgent" - ); - expect(billingAgentSpan).toBeDefined(); - if (billingAgentSpan) { - expect(billingAgentSpan.attributes[OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]).toBe("invoke_agent"); - expect(billingAgentSpan.attributes[OpenTelemetryConstants.GEN_AI_CALLER_AGENT_NAME_KEY]).toBe("TriageAgent"); - console.log("✅ SERVER-kind BillingAgent InvokeAgent span validated"); - } - - expect(result.finalOutput).toBeDefined(); - console.log("✅ Handoff span validation passed"); - } catch (error) { - console.error("Test error:", error); - throw error; - } - }); - - it("validate error.type on failing tool", async () => { - const azureConfig = getAzureOpenAIConfig(); - if (!azureConfig) throw new Error("Azure OpenAI configuration is required"); - - const azureClient = new AzureOpenAI({ - endpoint: azureConfig.endpoint, - deployment: azureConfig.deployment, - apiKey: azureConfig.apiKey, - apiVersion: azureConfig.apiVersion, - }); - - const throwingTool: any = tool({ - name: "will_throw", - description: "Always fails", - parameters: { type: "object", properties: {}, additionalProperties: false } as any, - execute: async () => { throw new Error("simulated failure"); }, - }); - - const agent = new Agent({ - name: "ErrorAgent", - model: new OpenAIChatCompletionsModel(azureClient as any, azureConfig.deployment), - instructions: "Call the will_throw tool exactly once.", - tools: [throwingTool], - }); - - try { - await run(agent, "Please call the will_throw tool.", { maxTurns: 3 }); - } catch { - // run may surface the tool failure; spans should still be emitted - } - - // Wait for the tool span to appear — the agent may produce multiple spans - // and the tool span may arrive after the inference span - const startTime = Date.now(); - const timeout = 15000; - let toolSpan: ReadableSpan | undefined; - while (!toolSpan && Date.now() - startTime < timeout) { - toolSpan = spans.find((s) => s.name === "execute_tool will_throw"); - if (!toolSpan) await new Promise((resolve) => setTimeout(resolve, 200)); - } - - expect(toolSpan).toBeDefined(); - const errorSpan = toolSpan!; - expect(errorSpan.attributes[OpenTelemetryConstants.ERROR_TYPE_KEY]).toBeDefined(); - const errorTypeValue = errorSpan.attributes[OpenTelemetryConstants.ERROR_TYPE_KEY]; - expect(typeof errorTypeValue).toBe("string"); - expect((errorTypeValue as string).length).toBeGreaterThan(0); - // The Agents SDK wraps tool failures — status code is ERROR, message describes tool failure - expect(errorSpan.status.code).toBe(2); // SpanStatusCode.ERROR - expect(errorSpan.status.message).toContain("tool"); - console.log(`✅ error.type validated: type="${errorTypeValue}", message="${errorSpan.status.message}"`); - }); -}); diff --git a/tests/observability/integration/setup.ts b/tests/observability/integration/setup.ts deleted file mode 100644 index 95086b8f..00000000 --- a/tests/observability/integration/setup.ts +++ /dev/null @@ -1,37 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// ------------------------------------------------------------------------------ - -/** - * Global setup for all integration tests - * This file is loaded by Jest before any tests run - */ - -import { beforeAll, afterAll, afterEach } from '@jest/globals'; - -// Global setup before all tests -beforeAll(() => { - console.log('🚀 Starting integration tests...'); - // Set up required environment variables for testing - process.env.AZURE_EXPERIMENTAL_ENABLE_ACTIVITY_SOURCE = 'true'; - process.env.AZURE_TRACING_GEN_AI_CONTENT_RECORDING_ENABLED = 'true'; - process.env.OPENAI_AGENTS_DISABLE_TRACING = 'false'; - process.env.OTEL_SDK_DISABLED = 'false'; - - // Initialize global observability/telemetry before tests - // Setup global test data and fixtures - // Set global timeout if needed - jest.setTimeout(120000); -}); - -afterAll(async () => { - console.log('🏁 Integration tests completed'); - - // Clean up global resources after tests - // Export global telemetry for validation -}); - -afterEach(async () => { - // Clean up after each test (common cleanup) - // Reset global mocks, clear timers, etc. -}); diff --git a/tests/observability/internal/PerRequestProcessorOverrides.test.ts b/tests/observability/internal/PerRequestProcessorOverrides.test.ts deleted file mode 100644 index 48e0080f..00000000 --- a/tests/observability/internal/PerRequestProcessorOverrides.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { PerRequestSpanProcessor } from '@microsoft/agents-a365-observability/src/tracing/PerRequestSpanProcessor'; -import { isPerRequestExportEnabled } from '@microsoft/agents-a365-observability/src/tracing/exporter/utils'; -import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/src/ObservabilityBuilder'; -import { - setPerRequestProcessorInternalOverrides, - getPerRequestProcessorInternalOverrides, -} from '@microsoft/agents-a365-observability/src/internal/PerRequestProcessorInternalOverrides'; -import type { SpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { ExportResultCode } from '@opentelemetry/core'; - -// Mock Agent365Exporter to avoid network calls -jest.mock('@microsoft/agents-a365-observability/src/tracing/exporter/Agent365Exporter', () => ({ - Agent365Exporter: class { - export(_spans: unknown[], cb: (result: { code: number }) => void) { - cb({ code: 0 }); - } - shutdown() {/* no-op */} - forceFlush() {/* no-op */} - }, -})); - -const savedEnv: Record = {}; -const envKeysUsed = [ - 'ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT', - 'ENABLE_A365_OBSERVABILITY_EXPORTER', -]; - -beforeEach(() => { - // Save env vars that tests may mutate - for (const key of envKeysUsed) { - savedEnv[key] = process.env[key]; - } - // Suppress logger output - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); -}); - -afterEach(() => { - setPerRequestProcessorInternalOverrides(undefined); - // Restore env vars - for (const key of envKeysUsed) { - if (savedEnv[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = savedEnv[key]; - } - } - jest.restoreAllMocks(); -}); - -const noopExporter: SpanExporter = { - export: (_spans: ReadableSpan[], cb: (result: { code: number }) => void) => - cb({ code: ExportResultCode.SUCCESS }), - shutdown: async () => {}, -}; - -describe('PerRequestProcessorOverrides', () => { - it('get/set round-trips correctly', () => { - expect(getPerRequestProcessorInternalOverrides()).toBeUndefined(); - - setPerRequestProcessorInternalOverrides({ isPerRequestExportEnabled: () => true }); - expect(getPerRequestProcessorInternalOverrides()?.isPerRequestExportEnabled?.()).toBe(true); - - setPerRequestProcessorInternalOverrides(undefined); - expect(getPerRequestProcessorInternalOverrides()).toBeUndefined(); - }); - - it('isPerRequestExportEnabled returns override value, ignoring env var', () => { - process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; - setPerRequestProcessorInternalOverrides({ isPerRequestExportEnabled: () => false }); - expect(isPerRequestExportEnabled()).toBe(false); - - setPerRequestProcessorInternalOverrides({ isPerRequestExportEnabled: () => true }); - expect(isPerRequestExportEnabled()).toBe(true); - }); - - it('PerRequestSpanProcessor uses overrides for guardrails', () => { - setPerRequestProcessorInternalOverrides({ - perRequestMaxTraces: () => 7, - perRequestMaxConcurrentExports: () => 3, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const processor = new PerRequestSpanProcessor(noopExporter) as any; - expect(processor.maxBufferedTraces).toBe(7); - expect(processor.maxConcurrentExports).toBe(3); - // unset field falls back to config default - expect(processor.maxSpansPerTrace).toBe(5000); - }); - - it('PerRequestSpanProcessor ignores non-positive override values', () => { - setPerRequestProcessorInternalOverrides({ - perRequestMaxTraces: () => -1, - perRequestMaxSpansPerTrace: () => 0, - perRequestMaxConcurrentExports: () => 5, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const processor = new PerRequestSpanProcessor(noopExporter) as any; - expect(processor.maxBufferedTraces).toBe(1000); // -1 ignored, falls back - expect(processor.maxSpansPerTrace).toBe(5000); // 0 ignored, falls back - expect(processor.maxConcurrentExports).toBe(5); // positive, kept - }); - - it('PerRequestSpanProcessor uses config defaults when no overrides set', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const processor = new PerRequestSpanProcessor(noopExporter) as any; - expect(processor.maxBufferedTraces).toBe(1000); - expect(processor.maxSpansPerTrace).toBe(5000); - expect(processor.maxConcurrentExports).toBe(20); - }); - - it('ObservabilityBuilder uses PerRequestSpanProcessor when override enables it', () => { - setPerRequestProcessorInternalOverrides({ isPerRequestExportEnabled: () => true }); - - const builder = new ObservabilityBuilder().withService('test-agent'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const processor = (builder as any).createExportProcessor(); - expect(processor).toBeInstanceOf(PerRequestSpanProcessor); - }); -}); diff --git a/tests/observability/tracing/exporter-utils.test.ts b/tests/observability/tracing/exporter-utils.test.ts deleted file mode 100644 index f86ccece..00000000 --- a/tests/observability/tracing/exporter-utils.test.ts +++ /dev/null @@ -1,500 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; -import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; -import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { ClusterCategory, DefaultConfigurationProvider } from '@microsoft/agents-a365-runtime'; -import { ObservabilityConfiguration } from '@microsoft/agents-a365-observability/src/configuration/ObservabilityConfiguration'; -import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants'; - -describe('exporter/utils', () => { - const originalEnv = process.env; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...originalEnv }; - // Suppress logger output during tests - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - process.env = originalEnv; - jest.restoreAllMocks(); - }); - - describe('hexTraceId', () => { - it('should convert number to 32-character hex string', async () => { - const { hexTraceId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - expect(hexTraceId(255)).toBe('000000000000000000000000000000ff'); - expect(hexTraceId(0)).toBe('00000000000000000000000000000000'); - expect(hexTraceId(4096)).toBe('00000000000000000000000000001000'); - }); - - it('should handle hex string input with 0x prefix', async () => { - const { hexTraceId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - expect(hexTraceId('0xabc')).toBe('00000000000000000000000000000abc'); - expect(hexTraceId('0x1234567890abcdef')).toBe('00000000000000001234567890abcdef'); - }); - - it('should handle hex string input without prefix', async () => { - const { hexTraceId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - expect(hexTraceId('abc')).toBe('00000000000000000000000000000abc'); - expect(hexTraceId('1234567890abcdef1234567890abcdef')).toBe('1234567890abcdef1234567890abcdef'); - }); - - it('should pad short strings to 32 characters', async () => { - const { hexTraceId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - const result = hexTraceId('1'); - expect(result.length).toBe(32); - expect(result).toBe('00000000000000000000000000000001'); - }); - }); - - describe('hexSpanId', () => { - it('should convert number to 16-character hex string', async () => { - const { hexSpanId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - expect(hexSpanId(255)).toBe('00000000000000ff'); - expect(hexSpanId(0)).toBe('0000000000000000'); - expect(hexSpanId(4096)).toBe('0000000000001000'); - }); - - it('should handle hex string input with 0x prefix', async () => { - const { hexSpanId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - expect(hexSpanId('0xabc')).toBe('0000000000000abc'); - }); - - it('should handle hex string input without prefix', async () => { - const { hexSpanId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - expect(hexSpanId('1234567890abcdef')).toBe('1234567890abcdef'); - }); - - it('should pad short strings to 16 characters', async () => { - const { hexSpanId } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - const result = hexSpanId('1'); - expect(result.length).toBe(16); - expect(result).toBe('0000000000000001'); - }); - }); - - describe('asStr', () => { - it('should return undefined for null', async () => { - const { asStr } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(asStr(null)).toBeUndefined(); - }); - - it('should return undefined for undefined', async () => { - const { asStr } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(asStr(undefined)).toBeUndefined(); - }); - - it('should convert values to string', async () => { - const { asStr } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - expect(asStr('hello')).toBe('hello'); - expect(asStr(123)).toBe('123'); - expect(asStr(true)).toBe('true'); - expect(asStr(false)).toBe('false'); - }); - - it('should return undefined for empty or whitespace-only strings', async () => { - const { asStr } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - expect(asStr('')).toBeUndefined(); - expect(asStr(' ')).toBeUndefined(); - expect(asStr('\t\n')).toBeUndefined(); - }); - - it('should preserve non-empty strings with whitespace', async () => { - const { asStr } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - expect(asStr(' hello ')).toBe(' hello '); - expect(asStr('hello world')).toBe('hello world'); - }); - }); - - describe('kindName', () => { - it('should return INTERNAL for SpanKind.INTERNAL', async () => { - const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(kindName(SpanKind.INTERNAL)).toBe('INTERNAL'); - }); - - it('should return SERVER for SpanKind.SERVER', async () => { - const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(kindName(SpanKind.SERVER)).toBe('SERVER'); - }); - - it('should return CLIENT for SpanKind.CLIENT', async () => { - const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(kindName(SpanKind.CLIENT)).toBe('CLIENT'); - }); - - it('should return PRODUCER for SpanKind.PRODUCER', async () => { - const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(kindName(SpanKind.PRODUCER)).toBe('PRODUCER'); - }); - - it('should return CONSUMER for SpanKind.CONSUMER', async () => { - const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(kindName(SpanKind.CONSUMER)).toBe('CONSUMER'); - }); - - it('should return UNSPECIFIED for unknown kind', async () => { - const { kindName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(kindName(999 as SpanKind)).toBe('UNSPECIFIED'); - }); - }); - - describe('statusName', () => { - it('should return UNSET for SpanStatusCode.UNSET', async () => { - const { statusName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(statusName(SpanStatusCode.UNSET)).toBe('UNSET'); - }); - - it('should return OK for SpanStatusCode.OK', async () => { - const { statusName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(statusName(SpanStatusCode.OK)).toBe('OK'); - }); - - it('should return ERROR for SpanStatusCode.ERROR', async () => { - const { statusName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(statusName(SpanStatusCode.ERROR)).toBe('ERROR'); - }); - - it('should return UNSET for unknown status code', async () => { - const { statusName } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(statusName(999 as SpanStatusCode)).toBe('UNSET'); - }); - }); - - describe('isAgent365ExporterEnabled', () => { - it.each([ - { value: 'true', expected: true }, - { value: 'TRUE', expected: true }, - { value: '1', expected: true }, - { value: 'yes', expected: true }, - { value: 'YES', expected: true }, - { value: 'on', expected: true }, - { value: 'ON', expected: true }, - ])('should return $expected when ENABLE_A365_OBSERVABILITY_EXPORTER is "$value"', async ({ value, expected }) => { - process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = value; - const { isAgent365ExporterEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(isAgent365ExporterEnabled()).toBe(expected); - }); - - it.each([ - { value: 'false', expected: false }, - { value: 'FALSE', expected: false }, - { value: '0', expected: false }, - { value: 'no', expected: false }, - { value: 'off', expected: false }, - { value: '', expected: false }, - { value: 'invalid', expected: false }, - ])('should return $expected when ENABLE_A365_OBSERVABILITY_EXPORTER is "$value"', async ({ value, expected }) => { - process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = value; - const { isAgent365ExporterEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(isAgent365ExporterEnabled()).toBe(expected); - }); - - it('should return false when env var is not set', async () => { - delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; - const { isAgent365ExporterEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(isAgent365ExporterEnabled()).toBe(false); - }); - - it('should use configProvider override when provided (enabled)', async () => { - delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; - const provider = new DefaultConfigurationProvider(() => new ObservabilityConfiguration({ - isObservabilityExporterEnabled: () => true, - })); - const { isAgent365ExporterEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(isAgent365ExporterEnabled(provider)).toBe(true); - }); - - it('should use configProvider override when provided (disabled)', async () => { - process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; - const provider = new DefaultConfigurationProvider(() => new ObservabilityConfiguration({ - isObservabilityExporterEnabled: () => false, - })); - const { isAgent365ExporterEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(isAgent365ExporterEnabled(provider)).toBe(false); - }); - - it('should return false when no env var is set and configProvider has no override', async () => { - delete process.env.ENABLE_A365_OBSERVABILITY_EXPORTER; - const provider = new DefaultConfigurationProvider(() => new ObservabilityConfiguration()); - const { isAgent365ExporterEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(isAgent365ExporterEnabled(provider)).toBe(false); - }); - }); - - describe('isPerRequestExportEnabled', () => { - it.each([ - { value: 'true', expected: true }, - { value: 'TRUE', expected: true }, - { value: '1', expected: true }, - { value: 'yes', expected: true }, - { value: 'on', expected: true }, - ])('should return $expected when ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT is "$value"', async ({ value, expected }) => { - process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = value; - const { isPerRequestExportEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(isPerRequestExportEnabled()).toBe(expected); - }); - - it.each([ - { value: 'false', expected: false }, - { value: '0', expected: false }, - { value: '', expected: false }, - ])('should return $expected when ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT is "$value"', async ({ value, expected }) => { - process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = value; - const { isPerRequestExportEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(isPerRequestExportEnabled()).toBe(expected); - }); - - it('should return false when env var is not set', async () => { - delete process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT; - const { isPerRequestExportEnabled } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(isPerRequestExportEnabled()).toBe(false); - }); - }); - - describe('resolveAgent365Endpoint', () => { - it('should return production endpoint for prod cluster', async () => { - const { resolveAgent365Endpoint } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(resolveAgent365Endpoint(ClusterCategory.prod)).toBe('https://agent365.svc.cloud.microsoft'); - }); - - it('should return production endpoint for unknown cluster category', async () => { - const { resolveAgent365Endpoint } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - // Cast to ClusterCategory to test default behavior - expect(resolveAgent365Endpoint('unknown' as never)).toBe('https://agent365.svc.cloud.microsoft'); - }); - - it.each([ - ClusterCategory.local, ClusterCategory.dev, ClusterCategory.test, ClusterCategory.preprod, - ClusterCategory.gov, ClusterCategory.high, ClusterCategory.dod, ClusterCategory.mooncake, - ClusterCategory.ex, ClusterCategory.rx - ])( - 'should return production endpoint for %s cluster (current implementation)', - async (cluster) => { - const { resolveAgent365Endpoint } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - // Current implementation returns prod endpoint for all clusters - expect(resolveAgent365Endpoint(cluster)).toBe('https://agent365.svc.cloud.microsoft'); - } - ); - }); - - describe('getAgent365ObservabilityDomainOverride', () => { - it('should return trimmed URL when env var is set', async () => { - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://custom.domain.com'; - const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(getAgent365ObservabilityDomainOverride()).toBe('https://custom.domain.com'); - }); - - it('should remove trailing slashes from URL', async () => { - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://custom.domain.com/'; - const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(getAgent365ObservabilityDomainOverride()).toBe('https://custom.domain.com'); - }); - - it('should remove multiple trailing slashes from URL', async () => { - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://custom.domain.com///'; - const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(getAgent365ObservabilityDomainOverride()).toBe('https://custom.domain.com'); - }); - - it('should trim whitespace from URL', async () => { - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = ' https://custom.domain.com '; - const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(getAgent365ObservabilityDomainOverride()).toBe('https://custom.domain.com'); - }); - - it('should return null when env var is not set', async () => { - delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; - const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(getAgent365ObservabilityDomainOverride()).toBeNull(); - }); - - it('should return null when env var is empty string', async () => { - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = ''; - const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(getAgent365ObservabilityDomainOverride()).toBeNull(); - }); - - it('should return null when env var is whitespace only', async () => { - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = ' '; - const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(getAgent365ObservabilityDomainOverride()).toBeNull(); - }); - - it('should use configProvider override when provided', async () => { - delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; - const provider = new DefaultConfigurationProvider(() => new ObservabilityConfiguration({ - observabilityDomainOverride: () => 'https://override.example.com', - })); - const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(getAgent365ObservabilityDomainOverride(provider)).toBe('https://override.example.com'); - }); - - it('should use configProvider override even when env var is set', async () => { - process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://env.example.com'; - const provider = new DefaultConfigurationProvider(() => new ObservabilityConfiguration({ - observabilityDomainOverride: () => 'https://provider-wins.example.com', - })); - const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(getAgent365ObservabilityDomainOverride(provider)).toBe('https://provider-wins.example.com'); - }); - - it('should return null when no env var is set and configProvider has no override', async () => { - delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; - const provider = new DefaultConfigurationProvider(() => new ObservabilityConfiguration()); - const { getAgent365ObservabilityDomainOverride } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - expect(getAgent365ObservabilityDomainOverride(provider)).toBeNull(); - }); - }); - - describe('parseIdentityKey', () => { - it('should parse tenant and agent from key', async () => { - const { parseIdentityKey } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - const result = parseIdentityKey('tenant123:agent456'); - expect(result).toEqual({ tenantId: 'tenant123', agentId: 'agent456' }); - }); - - it('should handle keys with empty values', async () => { - const { parseIdentityKey } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - const result = parseIdentityKey(':agent'); - expect(result).toEqual({ tenantId: '', agentId: 'agent' }); - }); - - it('should handle keys with GUIDs', async () => { - const { parseIdentityKey } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - const result = parseIdentityKey('12345678-1234-1234-1234-123456789abc:98765432-1234-1234-1234-123456789def'); - expect(result).toEqual({ - tenantId: '12345678-1234-1234-1234-123456789abc', - agentId: '98765432-1234-1234-1234-123456789def' - }); - }); - }); - - describe('partitionByIdentity', () => { - const createMockSpan = (name: string, tenantId?: string, agentId?: string, operationName?: string): ReadableSpan => ({ - name, - kind: SpanKind.INTERNAL, - spanContext: () => ({ - traceId: '1234567890abcdef1234567890abcdef', - spanId: '1234567890abcdef', - traceFlags: 1 - }), - startTime: [0, 0], - endTime: [1, 0], - ended: true, - status: { code: SpanStatusCode.OK }, - attributes: { - ...(tenantId !== undefined && { [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId }), - ...(agentId !== undefined && { [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId }), - ...(operationName !== undefined && { [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: operationName }), - }, - links: [], - events: [], - duration: [1, 0], - resource: { attributes: {} }, - instrumentationLibrary: { name: 'test' }, - droppedAttributesCount: 0, - droppedEventsCount: 0, - droppedLinksCount: 0, - } as unknown as ReadableSpan); - - it('should group spans by tenant and agent ID', async () => { - const { partitionByIdentity } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - const spans = [ - createMockSpan('span1', 'tenant1', 'agent1', 'invoke_agent'), - createMockSpan('span2', 'tenant1', 'agent1', 'chat'), - createMockSpan('span3', 'tenant2', 'agent2', 'execute_tool'), - ]; - - const result = partitionByIdentity(spans); - - expect(result.size).toBe(2); - expect(result.get('tenant1:agent1')?.length).toBe(2); - expect(result.get('tenant2:agent2')?.length).toBe(1); - }); - - it('should skip spans without tenant ID', async () => { - const { partitionByIdentity } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - const spans = [ - createMockSpan('span1', undefined, 'agent1', 'invoke_agent'), - createMockSpan('span2', 'tenant1', 'agent1', 'invoke_agent'), - ]; - - const result = partitionByIdentity(spans); - - expect(result.size).toBe(1); - expect(result.get('tenant1:agent1')?.length).toBe(1); - }); - - it('should skip spans without agent ID', async () => { - const { partitionByIdentity } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - const spans = [ - createMockSpan('span1', 'tenant1', undefined, 'invoke_agent'), - createMockSpan('span2', 'tenant1', 'agent1', 'invoke_agent'), - ]; - - const result = partitionByIdentity(spans); - - expect(result.size).toBe(1); - expect(result.get('tenant1:agent1')?.length).toBe(1); - }); - - it('should return empty map for empty spans array', async () => { - const { partitionByIdentity } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - const result = partitionByIdentity([]); - - expect(result.size).toBe(0); - }); - - it('should return empty map when all spans lack identity', async () => { - const { partitionByIdentity } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - const spans = [ - createMockSpan('span1', undefined, undefined, 'invoke_agent'), - createMockSpan('span2', 'tenant1', undefined, 'invoke_agent'), - createMockSpan('span3', undefined, 'agent1', 'invoke_agent'), - ]; - - const result = partitionByIdentity(spans); - - expect(result.size).toBe(0); - }); - - it('should skip spans with missing or unknown gen_ai.operation.name', async () => { - const { partitionByIdentity } = await import('@microsoft/agents-a365-observability/src/tracing/exporter/utils'); - - const spans = [ - createMockSpan('genai-span', 'tenant1', 'agent1', 'invoke_agent'), - createMockSpan('http-span', 'tenant1', 'agent1', undefined), - createMockSpan('db-span', 'tenant1', 'agent1'), - createMockSpan('unknown-op', 'tenant1', 'agent1', 'some_random_op'), - ]; - - const result = partitionByIdentity(spans); - - expect(result.size).toBe(1); - expect(result.get('tenant1:agent1')?.length).toBe(1); - expect(result.get('tenant1:agent1')?.[0].name).toBe('genai-span'); - }); - }); -}); diff --git a/tests/observability/tracing/message-utils.test.ts b/tests/observability/tracing/message-utils.test.ts deleted file mode 100644 index 4453a48c..00000000 --- a/tests/observability/tracing/message-utils.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { - MessageRole, - ChatMessage, - OutputMessage, - InputMessages, - OutputMessages, -} from '@microsoft/agents-a365-observability'; -import { - isWrappedMessages, - toInputMessages, - toOutputMessages, - normalizeInputMessages, - normalizeOutputMessages, - serializeMessages, -} from '@microsoft/agents-a365-observability/src/tracing/message-utils'; - -describe('isWrappedMessages', () => { - it('returns true for InputMessages wrapper', () => { - const wrapper: InputMessages = { - messages: [{ role: MessageRole.USER, parts: [{ type: 'text', content: 'hi' }] }], - }; - expect(isWrappedMessages(wrapper)).toBe(true); - }); - - it('returns true for OutputMessages wrapper', () => { - const wrapper: OutputMessages = { - messages: [{ role: MessageRole.ASSISTANT, parts: [{ type: 'text', content: 'hello' }] }], - }; - expect(isWrappedMessages(wrapper)).toBe(true); - }); - - it('returns false for string[]', () => { - expect(isWrappedMessages(['hello'])).toBe(false); - }); - - it('returns false for empty array', () => { - expect(isWrappedMessages([])).toBe(false); - }); - - it('returns false for null', () => { - expect(isWrappedMessages(null as unknown as any)).toBe(false); - }); - - it('returns false for object missing messages property', () => { - expect(isWrappedMessages({ foo: 'bar' } as unknown as any)).toBe(false); - }); - - it('returns false for object with non-array messages property', () => { - expect(isWrappedMessages({ messages: 'not-an-array' } as unknown as any)).toBe(false); - }); -}); - -describe('toInputMessages', () => { - it('wraps strings as ChatMessage with role=user and TextPart', () => { - const result = toInputMessages(['hello', 'world']); - expect(result).toEqual([ - { role: 'user', parts: [{ type: 'text', content: 'hello' }] }, - { role: 'user', parts: [{ type: 'text', content: 'world' }] }, - ]); - }); - - it('handles empty array', () => { - expect(toInputMessages([])).toEqual([]); - }); - - it('preserves message content exactly', () => { - const content = ' special chars: <>&"\' \n\ttabs '; - const result = toInputMessages([content]); - expect(result[0].parts[0]).toEqual({ type: 'text', content }); - }); -}); - -describe('toOutputMessages', () => { - it('wraps strings as OutputMessage with role=assistant, TextPart, and default finish_reason', () => { - const result = toOutputMessages(['response 1', 'response 2']); - expect(result).toEqual([ - { role: 'assistant', parts: [{ type: 'text', content: 'response 1' }], finish_reason: 'stop' }, - { role: 'assistant', parts: [{ type: 'text', content: 'response 2' }], finish_reason: 'stop' }, - ]); - }); - - it('handles empty array', () => { - expect(toOutputMessages([])).toEqual([]); - }); -}); - -describe('normalizeInputMessages', () => { - it('wraps string[] into InputMessages', () => { - const result = normalizeInputMessages(['hello']); - expect(result).toEqual({ - messages: [{ role: 'user', parts: [{ type: 'text', content: 'hello' }] }], - }); - }); - - it('returns InputMessages wrapper as-is', () => { - const wrapper: InputMessages = { - messages: [{ role: MessageRole.SYSTEM, parts: [{ type: 'text', content: 'system prompt' }] }], - }; - expect(normalizeInputMessages(wrapper)).toBe(wrapper); - }); - - it('wraps a single string into InputMessages', () => { - const result = normalizeInputMessages('hello'); - expect(result).toEqual({ - messages: [{ role: 'user', parts: [{ type: 'text', content: 'hello' }] }], - }); - }); - - it('wraps empty string[] into wrapper with empty messages', () => { - const result = normalizeInputMessages([]); - expect(result).toEqual({ messages: [] }); - }); -}); - -describe('normalizeOutputMessages', () => { - it('wraps string[] into OutputMessages with default finish_reason', () => { - const result = normalizeOutputMessages(['response']); - expect(result).toEqual({ - messages: [{ role: 'assistant', parts: [{ type: 'text', content: 'response' }], finish_reason: 'stop' }], - }); - }); - - it('wraps a single string into OutputMessages with default finish_reason', () => { - const result = normalizeOutputMessages('response'); - expect(result).toEqual({ - messages: [{ role: 'assistant', parts: [{ type: 'text', content: 'response' }], finish_reason: 'stop' }], - }); - }); - - it('returns OutputMessages wrapper as-is', () => { - const wrapper: OutputMessages = { - messages: [{ role: MessageRole.ASSISTANT, parts: [{ type: 'text', content: 'done' }], finish_reason: 'stop' }], - }; - expect(normalizeOutputMessages(wrapper)).toBe(wrapper); - }); -}); - -describe('serializeMessages', () => { - it('returns JSON array for message wrapper', () => { - const wrapper: InputMessages = { - messages: [{ role: MessageRole.USER, parts: [{ type: 'text', content: 'hello' }] }], - }; - const result = serializeMessages(wrapper); - const parsed = JSON.parse(result); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toEqual(wrapper.messages); - }); - - it('serializes empty messages wrapper as empty array', () => { - const wrapper: InputMessages = { messages: [] }; - const parsed = JSON.parse(serializeMessages(wrapper)); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toEqual([]); - }); - - it('serializes large messages without truncation', () => { - const largeContent = 'x'.repeat(100_000); - const wrapper: InputMessages = { - messages: [{ role: MessageRole.USER, parts: [{ type: 'text', content: largeContent }] }], - }; - - const result = serializeMessages(wrapper); - const parsed = JSON.parse(result); - expect(parsed[0].parts[0].content).toBe(largeContent); - }); - - it('does not mutate the original messages', () => { - const original = 'z'.repeat(50_000); - const wrapper: InputMessages = { - messages: [{ role: MessageRole.USER, parts: [{ type: 'text', content: original }] }], - }; - - serializeMessages(wrapper); - - expect((wrapper.messages[0].parts[0] as { content: string }).content).toBe(original); - }); - - it('returns fallback sentinel when messages contain non-serializable values', () => { - const circular: Record = { a: 1 }; - circular.self = circular; - - const wrapper: InputMessages = { - messages: [ - { role: MessageRole.TOOL, parts: [{ type: 'tool_call_response', id: 'tc1', response: circular }] }, - ], - }; - - const result = serializeMessages(wrapper); - const parsed = JSON.parse(result); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].parts[0].content).toContain('serialization failed'); - expect(parsed[0].parts[0].content).toContain('1 message'); - }); -}); diff --git a/tests/observability/utils/logging.test.ts b/tests/observability/utils/logging.test.ts deleted file mode 100644 index 166382d2..00000000 --- a/tests/observability/utils/logging.test.ts +++ /dev/null @@ -1,693 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; -import { ExporterEventNames } from '@microsoft/agents-a365-observability'; - -describe('logging', () => { - const originalEnv = process.env; - let consoleLogSpy: ReturnType; - let consoleWarnSpy: ReturnType; - let consoleErrorSpy: ReturnType; - - beforeEach(() => { - // Reset modules to allow re-importing with different env vars - jest.resetModules(); - // Clone environment - process.env = { ...originalEnv }; - // Spy on console methods - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - process.env = originalEnv; - jest.restoreAllMocks(); - }); - - describe('formatError', () => { - it('should format Error objects with message and stack trace', async () => { - const { formatError } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - const error = new Error('Test error message'); - const result = formatError(error); - - expect(result).toContain('Test error message'); - expect(result).toContain('Stack:'); - }); - - it('should format Error objects without stack trace', async () => { - const { formatError } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - const error = new Error('Test error'); - error.stack = undefined; - const result = formatError(error); - - expect(result).toContain('Test error'); - expect(result).toContain('No stack trace'); - }); - - it('should convert non-Error values to string', async () => { - const { formatError } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - expect(formatError('string error')).toBe('string error'); - expect(formatError(123)).toBe('123'); - expect(formatError(null)).toBe('null'); - expect(formatError(undefined)).toBe('undefined'); - expect(formatError({ foo: 'bar' })).toBe('[object Object]'); - }); - }); - - describe('logger with level: none (default)', () => { - it('should not log info messages when level is none', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test info message'); - - expect(consoleLogSpy).not.toHaveBeenCalled(); - }); - - it('should not log warn messages when level is none', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.warn('Test warn message'); - - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - - it('should not log error messages when level is none', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.error('Test error message'); - - expect(consoleErrorSpy).not.toHaveBeenCalled(); - }); - - it('should default to none when env var is not set', async () => { - delete process.env.A365_OBSERVABILITY_LOG_LEVEL; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test message'); - logger.warn('Test message'); - logger.error('Test message'); - - expect(consoleLogSpy).not.toHaveBeenCalled(); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - }); - }); - - describe('logger with level: info', () => { - it('should log info messages when level is info', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test info message'); - - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info message'); - }); - - it('should not log warn messages when level is info only', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.warn('Test warn message'); - - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - - it('should not log error messages when level is info only', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.error('Test error message'); - - expect(consoleErrorSpy).not.toHaveBeenCalled(); - }); - }); - - describe('logger with level: warn', () => { - it('should log warn messages when level is warn', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'warn'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.warn('Test warn message'); - - expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Test warn message'); - }); - - it('should not log info messages when level is warn only', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'warn'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test info message'); - - expect(consoleLogSpy).not.toHaveBeenCalled(); - }); - }); - - describe('logger with level: error', () => { - it('should log error messages when level is error', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.error('Test error message'); - - expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Test error message'); - }); - - it('should not log info or warn messages when level is error only', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test info message'); - logger.warn('Test warn message'); - - expect(consoleLogSpy).not.toHaveBeenCalled(); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - }); - - describe('logger with multiple levels (pipe-separated)', () => { - it('should log info and warn when level is info|warn', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info|warn'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test info'); - logger.warn('Test warn'); - logger.error('Test error'); - - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info'); - expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Test warn'); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - }); - - it('should log warn and error when level is warn|error', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'warn|error'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test info'); - logger.warn('Test warn'); - logger.error('Test error'); - - expect(consoleLogSpy).not.toHaveBeenCalled(); - expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Test warn'); - expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Test error'); - }); - - it('should log all levels when level is info|warn|error', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info|warn|error'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test info'); - logger.warn('Test warn'); - logger.error('Test error'); - - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info'); - expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Test warn'); - expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Test error'); - }); - - it('should handle whitespace in pipe-separated levels', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = ' info | warn | error '; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test info'); - logger.warn('Test warn'); - logger.error('Test error'); - - expect(consoleLogSpy).toHaveBeenCalled(); - expect(consoleWarnSpy).toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalled(); - }); - }); - - describe('logger with case insensitivity', () => { - it('should handle uppercase log levels', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'INFO'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test info'); - - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info'); - }); - - it('should handle mixed case log levels', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'InFo|WaRn'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test info'); - logger.warn('Test warn'); - - expect(consoleLogSpy).toHaveBeenCalled(); - expect(consoleWarnSpy).toHaveBeenCalled(); - }); - }); - - describe('logger with invalid values', () => { - it('should default to none for invalid log level', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'invalid'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test message'); - logger.warn('Test message'); - logger.error('Test message'); - - expect(consoleLogSpy).not.toHaveBeenCalled(); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - }); - - it('should default to none for empty string', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = ''; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test message'); - - expect(consoleLogSpy).not.toHaveBeenCalled(); - }); - - it('should ignore invalid levels in pipe-separated string', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'invalid|info|alsoinvalid'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test info'); - logger.warn('Test warn'); - - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info'); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - }); - - describe('logger with additional arguments', () => { - it('should pass additional arguments to console.log', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.info('Test message', { data: 'value' }, 123); - - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test message', { data: 'value' }, 123); - }); - - it('should pass additional arguments to console.warn', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'warn'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.warn('Test message', ['array', 'data']); - - expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Test message', ['array', 'data']); - }); - - it('should pass additional arguments to console.error', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - const errorObj = new Error('test'); - logger.error('Test message', errorObj); - - expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Test message', errorObj); - }); - }); - - describe('default export', () => { - it('should export logger as default', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const loggingModule = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - expect(loggingModule.default).toBe(loggingModule.logger); - }); - }); - - describe('dynamic log level resolution', () => { - it('should support runtime log level changes without module reload', async () => { - // Start with no logging - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - // Should not log with 'none' - logger.info('Should not appear'); - expect(consoleLogSpy).not.toHaveBeenCalled(); - - // Change log level at runtime - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - - // Should now log without module reload - logger.info('Should appear now'); - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Should appear now'); - }); - - it('should support dynamic multi-tenant log level scenarios', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - // Tenant A - error only - logger.info('Tenant A info'); - logger.error('Tenant A error'); - expect(consoleLogSpy).not.toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Tenant A error'); - - consoleErrorSpy.mockClear(); - - // Switch to Tenant B - all logging - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info|warn|error'; - - logger.info('Tenant B info'); - logger.warn('Tenant B warn'); - logger.error('Tenant B error'); - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Tenant B info'); - expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Tenant B warn'); - expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Tenant B error'); - }); - - it('should evaluate log level on each call', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - // First call - should log - logger.info('First call'); - expect(consoleLogSpy).toHaveBeenCalledTimes(1); - - // Disable logging - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; - - // Second call - should not log - logger.info('Second call'); - expect(consoleLogSpy).toHaveBeenCalledTimes(1); // Still 1, not 2 - - // Re-enable logging - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - - // Third call - should log again - logger.info('Third call'); - expect(consoleLogSpy).toHaveBeenCalledTimes(2); - }); - }); - - describe('setLogger and getLogger', () => { - it('should allow setting a custom logger', async () => { - const { setLogger, getLogger, logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - const customLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - event: jest.fn() - }; - - setLogger(customLogger); - - logger.info('Test message', 'arg1'); - logger.warn('Warning message'); - logger.error('Error message'); - - expect(customLogger.info).toHaveBeenCalledWith('Test message', 'arg1'); - expect(customLogger.warn).toHaveBeenCalledWith('Warning message'); - expect(customLogger.error).toHaveBeenCalledWith('Error message'); - - // getLogger should return the custom logger - expect(getLogger()).toBe(customLogger); - }); - - it('should throw when setting invalid logger (null)', async () => { - const { setLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - expect(() => setLogger(null as never)).toThrow('Custom logger must implement ILogger interface'); - }); - - it('should throw when setting logger missing info method', async () => { - const { setLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - const invalidLogger = { - warn: jest.fn(), - error: jest.fn() - }; - - expect(() => setLogger(invalidLogger as never)).toThrow('Custom logger must implement ILogger interface'); - }); - - it('should throw when setting logger missing warn method', async () => { - const { setLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - const invalidLogger = { - info: jest.fn(), - error: jest.fn() - }; - - expect(() => setLogger(invalidLogger as never)).toThrow('Custom logger must implement ILogger interface'); - }); - - it('should throw when setting logger missing error method', async () => { - const { setLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - const invalidLogger = { - info: jest.fn(), - warn: jest.fn() - }; - - expect(() => setLogger(invalidLogger as never)).toThrow('Custom logger must implement ILogger interface'); - }); - }); - - describe('resetLogger', () => { - it('should reset to default logger', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { setLogger, resetLogger, logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - const customLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - event: jest.fn() - }; - - setLogger(customLogger); - logger.info('Custom logger call'); - expect(customLogger.info).toHaveBeenCalled(); - - // Reset to default - resetLogger(); - - // Now should use default logger (console) - logger.info('Default logger call'); - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Default logger call'); - }); - }); - - describe('DefaultLogger with custom configuration provider', () => { - it('should use custom configuration provider for log level resolution', async () => { - const { DefaultLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - const { ObservabilityConfiguration } = await import('@microsoft/agents-a365-observability/src/configuration'); - const { DefaultConfigurationProvider } = await import('@microsoft/agents-a365-runtime'); - - // Create a custom configuration that returns 'info' log level - const customConfig = new ObservabilityConfiguration({ - observabilityLogLevel: () => 'info' - }); - const customProvider = new DefaultConfigurationProvider(() => customConfig); - - // Create DefaultLogger with custom provider - const customLogger = new DefaultLogger(customProvider); - - // Should log info messages based on custom config - customLogger.info('Test info message'); - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Test info message'); - - // Should not log error (since level is 'info' only) - customLogger.error('Test error message'); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - }); - - it('should use different log levels for different configuration providers', async () => { - const { DefaultLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - const { ObservabilityConfiguration } = await import('@microsoft/agents-a365-observability/src/configuration'); - const { DefaultConfigurationProvider } = await import('@microsoft/agents-a365-runtime'); - - // Provider 1: info only - const config1 = new ObservabilityConfiguration({ - observabilityLogLevel: () => 'info' - }); - const provider1 = new DefaultConfigurationProvider(() => config1); - const logger1 = new DefaultLogger(provider1); - - // Provider 2: error only - const config2 = new ObservabilityConfiguration({ - observabilityLogLevel: () => 'error' - }); - const provider2 = new DefaultConfigurationProvider(() => config2); - const logger2 = new DefaultLogger(provider2); - - // Logger1 should log info but not error - logger1.info('Logger1 info'); - logger1.error('Logger1 error'); - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Logger1 info'); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - - consoleLogSpy.mockClear(); - - // Logger2 should log error but not info - logger2.info('Logger2 info'); - logger2.error('Logger2 error'); - expect(consoleLogSpy).not.toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Logger2 error'); - }); - - it('should support dynamic log level changes via override function', async () => { - const { DefaultLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - const { ObservabilityConfiguration } = await import('@microsoft/agents-a365-observability/src/configuration'); - const { DefaultConfigurationProvider } = await import('@microsoft/agents-a365-runtime'); - - // Dynamic log level that can be changed at runtime - let currentLogLevel = 'none'; - - const dynamicConfig = new ObservabilityConfiguration({ - observabilityLogLevel: () => currentLogLevel - }); - const dynamicProvider = new DefaultConfigurationProvider(() => dynamicConfig); - const logger = new DefaultLogger(dynamicProvider); - - // Initially none - no logging - logger.info('Should not appear'); - expect(consoleLogSpy).not.toHaveBeenCalled(); - - // Change to info - currentLogLevel = 'info'; - logger.info('Should appear now'); - expect(consoleLogSpy).toHaveBeenCalledWith('[INFO]', 'Should appear now'); - - // Change to error only - currentLogLevel = 'error'; - consoleLogSpy.mockClear(); - logger.info('Should not appear'); - logger.error('Error should appear'); - expect(consoleLogSpy).not.toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Error should appear'); - }); - - it('should support pipe-separated log levels via custom config', async () => { - const { DefaultLogger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - const { ObservabilityConfiguration } = await import('@microsoft/agents-a365-observability/src/configuration'); - const { DefaultConfigurationProvider } = await import('@microsoft/agents-a365-runtime'); - - const customConfig = new ObservabilityConfiguration({ - observabilityLogLevel: () => 'warn|error' - }); - const customProvider = new DefaultConfigurationProvider(() => customConfig); - const logger = new DefaultLogger(customProvider); - - logger.info('Info message'); - logger.warn('Warn message'); - logger.error('Error message'); - - expect(consoleLogSpy).not.toHaveBeenCalled(); - expect(consoleWarnSpy).toHaveBeenCalledWith('[WARN]', 'Warn message'); - expect(consoleErrorSpy).toHaveBeenCalledWith('[ERROR]', 'Error message'); - }); - }); - - describe('event method', () => { - it('should log successful events with info level when enabled', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.event(ExporterEventNames.EXPORT, true, 100, 'Operation completed'); - - expect(consoleLogSpy).toHaveBeenCalledWith('[EVENT]: agent365-export succeeded in 100ms - Operation completed'); - }); - - it('should log failed events with error level when enabled', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.event(ExporterEventNames.EXPORT, false, 200, 'Operation failed'); - - expect(consoleErrorSpy).toHaveBeenCalledWith('[EVENT]: agent365-export failed in 200ms - Operation failed'); - }); - - it('should include details in event logs', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.event(ExporterEventNames.EXPORT_GROUP, true, 150, 'Success', { correlationId: 'abc123', tenantId: 'tenant1' }); - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[EVENT]: export-group succeeded in 150ms - Success {"correlationId":"abc123","tenantId":"tenant1"}' - ); - }); - - it('should log event without message', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.event(ExporterEventNames.EXPORT, true, 100); - - expect(consoleLogSpy).toHaveBeenCalledWith('[EVENT]: agent365-export succeeded in 100ms'); - }); - - it('should log event without details', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.event(ExporterEventNames.EXPORT, true, 100, 'Completed'); - - expect(consoleLogSpy).toHaveBeenCalledWith('[EVENT]: agent365-export succeeded in 100ms - Completed'); - }); - - it('should log event with empty details object', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.event(ExporterEventNames.EXPORT, true, 100, 'Completed', {}); - - expect(consoleLogSpy).toHaveBeenCalledWith('[EVENT]: agent365-export succeeded in 100ms - Completed'); - }); - - it('should not log successful events when log level is none', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.event(ExporterEventNames.EXPORT, true, 100, 'Success'); - - expect(consoleLogSpy).not.toHaveBeenCalled(); - }); - - it('should not log failed events when log level is none', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'none'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.event(ExporterEventNames.EXPORT, false, 100, 'Failure'); - - expect(consoleErrorSpy).not.toHaveBeenCalled(); - }); - - it('should log failed events with error level and details', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'error'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - logger.event(ExporterEventNames.EXPORT_GROUP, false, 250, 'Failed', { - correlationId: 'xyz789', - tenantId: 'tenant2', - agentId: 'agent1' - }); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - '[EVENT]: export-group failed in 250ms - Failed {"correlationId":"xyz789","tenantId":"tenant2","agentId":"agent1"}' - ); - }); - - it('should use ExporterEventNames enum values', async () => { - process.env.A365_OBSERVABILITY_LOG_LEVEL = 'info'; - const { logger } = await import('@microsoft/agents-a365-observability/src/utils/logging'); - - // Test all enum values - logger.event(ExporterEventNames.EXPORT, true, 100); - logger.event(ExporterEventNames.EXPORT_GROUP, true, 100); - logger.event(ExporterEventNames.EXPORT_PARTITION_SPAN_MISSING_IDENTITY, true, 100); - - expect(consoleLogSpy).toHaveBeenCalledWith('[EVENT]: agent365-export succeeded in 100ms'); - expect(consoleLogSpy).toHaveBeenCalledWith('[EVENT]: export-group succeeded in 100ms'); - expect(consoleLogSpy).toHaveBeenCalledWith('[EVENT]: export-partition-span-missing-identity succeeded in 100ms'); - }); - }); -}); diff --git a/tests/package.json b/tests/package.json index 2ab52202..1aacf6d3 100644 --- a/tests/package.json +++ b/tests/package.json @@ -2,7 +2,7 @@ "name": "@microsoft/agents-a365-tests", "version": "0.1.0", "private": true, - "description": "Unit tests for Agent365 SDK packages (runtime, observability, tooling, notifications)", + "description": "Unit tests for Agent365 SDK packages (runtime, tooling, notifications)", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { @@ -25,32 +25,18 @@ "directory": "tests" }, "dependencies": { - "@microsoft/agents-a365-observability": "workspace:*", - "@microsoft/agents-a365-observability-extensions-langchain": "workspace:*", - "@microsoft/agents-a365-observability-extensions-openai": "workspace:*", - "@microsoft/agents-a365-observability-hosting": "workspace:*", "@microsoft/agents-a365-runtime": "workspace:*", "@microsoft/agents-hosting": "catalog:", "@modelcontextprotocol/sdk": "catalog:", "@openai/agents": "catalog:", "@openai/agents-openai": "catalog:", - "@opentelemetry/api": "catalog:", - "@opentelemetry/core": "catalog:", - "@opentelemetry/exporter-trace-otlp-http": "catalog:", - "@opentelemetry/resources": "catalog:", - "@opentelemetry/sdk-node": "catalog:", - "@opentelemetry/sdk-trace-base": "catalog:", - "@opentelemetry/semantic-conventions": "catalog:", "@langchain/core": "catalog:", "@langchain/langgraph": "catalog:", - "@langchain/openai": "catalog:", - "dotenv": "catalog:", "hono": "catalog:", "openai": "catalog:", "zod": "catalog:" }, "devDependencies": { - "@babel/preset-typescript": "catalog:", "@eslint/js": "catalog:", "@types/jest": "catalog:", "@types/node": "catalog:", @@ -59,7 +45,6 @@ "cross-env": "catalog:", "eslint": "catalog:", "jest": "catalog:", - "js-yaml": "catalog:", "rimraf": "catalog:", "ts-jest": "catalog:", "typescript": "catalog:", diff --git a/tests/release-packages.test.ts b/tests/release-packages.test.ts new file mode 100644 index 00000000..58e5aa34 --- /dev/null +++ b/tests/release-packages.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFileSync, spawnSync } from 'child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; + +const script = path.resolve(__dirname, '../packPackages.js'); +let fixture: string; + +beforeEach(() => { + fixture = mkdtempSync(path.join(tmpdir(), 'a365-release-test-')); +}); + +afterEach(() => { + rmSync(fixture, { recursive: true, force: true }); +}); + +function packageFixture(name: string, files: Record) { + const dir = path.join(fixture, name); + mkdirSync(path.join(dir, 'package'), { recursive: true }); + for (const [file, content] of Object.entries(files)) { + writeFileSync(path.join(dir, 'package', file), content); + } + execFileSync('tar', ['-czf', path.join(fixture, `${name}.tgz`), '-C', dir, 'package']); +} + +it('rejects a retired package even when its tarball has an innocent filename', () => { + packageFixture('runtime', { + 'package.json': JSON.stringify({ name: '@microsoft/agents-a365-observability', version: '1.0.0' }), + }); + const result = spawnSync(process.execPath, [script, '--verify', fixture], { encoding: 'utf8' }); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/Unexpected package/); +}); + +it('rejects bundled retired implementations in an allowed package', () => { + packageFixture('runtime', { + 'package.json': JSON.stringify({ name: '@microsoft/agents-a365-runtime', version: '1.0.0' }), + 'index.js': 'class ObservabilityManager {}', + }); + const result = spawnSync(process.execPath, [script, '--verify', fixture], { encoding: 'utf8' }); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/Retired implementation/); +}); + +it.each(['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'])( + 'rejects retired dependencies in %s', section => { + packageFixture('runtime', { + 'package.json': JSON.stringify({ + name: '@microsoft/agents-a365-runtime', + version: '1.0.0', + [section]: { '@microsoft/agents-a365-observability-hosting': '1.0.0' }, + }), + }); + const result = spawnSync(process.execPath, [script, '--verify', fixture], { encoding: 'utf8' }); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/Retired dependency/); + }); + +it('accepts exactly six clean packages with complete entry points', () => { + for (const suffix of [ + 'notifications', 'runtime', 'tooling', 'tooling-extensions-claude', + 'tooling-extensions-langchain', 'tooling-extensions-openai', + ]) { + packageFixture(suffix, { + 'package.json': JSON.stringify({ + name: `@microsoft/agents-a365-${suffix}`, version: '1.0.0', + main: 'index.js', module: 'index.js', types: 'index.d.ts', + }), + 'index.js': 'exports.maintained = true;', + 'index.d.ts': 'export declare const maintained: boolean;', + 'CHANGELOG.md': 'Historical: @microsoft/agents-a365-observability', + }); + } + const result = spawnSync(process.execPath, [script, '--verify', fixture], { encoding: 'utf8' }); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toHaveLength(6); +}); + +it('rejects an incomplete release even if the remaining tarball is valid', () => { + packageFixture('runtime', { + 'package.json': JSON.stringify({ + name: '@microsoft/agents-a365-runtime', version: '1.0.0', + main: 'index.js', module: 'index.js', types: 'index.d.ts', + }), + 'index.js': 'exports.maintained = true;', + 'index.d.ts': 'export declare const maintained: boolean;', + }); + const result = spawnSync(process.execPath, [script, '--verify', fixture], { encoding: 'utf8' }); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/Expected exactly 6 maintained packages/); +}); + +it('keeps release selection explicit rather than collecting cached wildcard tarballs', () => { + const manifest = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')); + expect(manifest.workspaces.filter((workspace: string) => workspace.startsWith('packages/'))) + .toHaveLength(6); + expect(manifest.workspaces.some((workspace: string) => workspace.includes('*'))).toBe(false); +}); diff --git a/tests/runtime/environment-utils.test.ts b/tests/runtime/environment-utils.test.ts index 235893ff..4331c856 100644 --- a/tests/runtime/environment-utils.test.ts +++ b/tests/runtime/environment-utils.test.ts @@ -3,11 +3,9 @@ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; import { - getObservabilityAuthenticationScope, getClusterCategory, isDevelopmentEnvironment, getMcpPlatformAuthenticationScope, - PROD_OBSERVABILITY_SCOPE, PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE, } from '@microsoft/agents-a365-runtime'; @@ -22,27 +20,6 @@ describe('environment-utils', () => { process.env = originalEnv; }); - describe('getObservabilityAuthenticationScope (deprecated)', () => { - it('should always return production observability scope (hardcoded default)', () => { - // This function is deprecated and now returns a hardcoded default - // Use ObservabilityConfiguration for env var support - const scopes = getObservabilityAuthenticationScope(); - - expect(scopes).toEqual([PROD_OBSERVABILITY_SCOPE]); - expect(scopes[0]).toEqual('api://9b975845-388f-4429-889e-eab1ef63949c/.default'); - }); - - it('should ignore A365_OBSERVABILITY_SCOPES_OVERRIDE env var (deprecated behavior)', () => { - // The deprecated function no longer reads from env vars - process.env.A365_OBSERVABILITY_SCOPES_OVERRIDE = 'https://override.example.com/.default'; - - const scopes = getObservabilityAuthenticationScope(); - - // Should still return the hardcoded default, not the env var value - expect(scopes).toEqual([PROD_OBSERVABILITY_SCOPE]); - }); - }); - describe('getClusterCategory', () => { it('should return prod when CLUSTER_CATEGORY is not set', () => { delete process.env.CLUSTER_CATEGORY; From c5100c010b0c0134369571bcdd1b0688934422bc Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 29 Oct 2026 12:10:36 -0600 Subject: [PATCH 2/8] Fix retirement release guards and migration guidance Enforce pnpm packaging, validate resolved dependency specifications, and terminate tar options. Clarify release version stamping and unchanged app-only authentication scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9339e0c8-5126-4e42-b471-9dddfb80d4a8 --- HOW_TO_BUILD.md | 4 +- HOW_TO_RELEASE.md | 19 +++++--- docs/observability-migration.md | 11 +++-- packPackages.js | 17 ++++--- setVersion.js | 2 +- tests/release-packages.test.ts | 81 ++++++++++++++++++++++++++------- 6 files changed, 97 insertions(+), 37 deletions(-) diff --git a/HOW_TO_BUILD.md b/HOW_TO_BUILD.md index 97620d2d..7a792128 100644 --- a/HOW_TO_BUILD.md +++ b/HOW_TO_BUILD.md @@ -46,7 +46,9 @@ Use the **root script**, not the built-in `pnpm pack` command or wildcard worksp pnpm run pack ``` -This creates a fresh `artifacts/packages-` directory, cleans and rebuilds maintained packages, copies license/changelog files, packs all six explicit workspaces, inspects their archives, and writes a SHA-256 `packages.json` receipt. It rejects retired directories/dependencies/code, duplicate or missing packages, and missing entry points. +This creates a fresh `artifacts/packages-` directory, cleans and rebuilds maintained packages, copies license/changelog files, packs all six explicit workspaces, inspects their archives, and writes a SHA-256 `packages.json` receipt. It requires pnpm and rejects retired directories/dependencies/code, unresolved `catalog:`/`workspace:` dependency specifications, duplicate or missing packages, and missing entry points. + +For **release artifacts**, first run `node setVersion.js` in write mode before building or packing. Neither `--dry-run` nor the build/pack commands apply versions. Local validation may retain placeholder versions; verify the intended release version in `packages.json` before publishing. For a named output and independent inspection: diff --git a/HOW_TO_RELEASE.md b/HOW_TO_RELEASE.md index 3957ad4e..becd1d47 100644 --- a/HOW_TO_RELEASE.md +++ b/HOW_TO_RELEASE.md @@ -148,11 +148,11 @@ The version will automatically be calculated as a stable release (without `-prev ```bash # Clone fresh to test git clone https://github.com/microsoft/Agent365-nodejs.git test-release -cd test-release/nodejs +cd test-release git checkout v1.1.0 # Install and check version (dry-run mode) -npm install +pnpm install --frozen-lockfile node setVersion.js --dry-run # Expected output: @@ -164,16 +164,20 @@ node setVersion.js --dry-run ```bash # On the release branch/tag -npm ci -npm run lint -npm run build -npm test +pnpm install --frozen-lockfile +node setVersion.js +pnpm lint +pnpm build +pnpm typecheck +pnpm test # Verify all packages have correct version grep -r "\"version\"" packages/*/package.json # Should show: "version": "1.1.0" for all packages ``` +Run `node setVersion.js` in **write mode** before building: `--dry-run`, `pnpm build`, and `pnpm run pack` do not apply release versions. Keep the generated version changes local to the release checkout. + ### Step 7: Create GitHub Release 1. Go to: https://github.com/microsoft/Agent365-nodejs/releases/new @@ -235,6 +239,7 @@ From the approved release commit, create a **new** output directory through the ```shell pnpm install --frozen-lockfile +node setVersion.js pnpm build pnpm test pnpm lint @@ -243,7 +248,7 @@ pnpm run pack artifacts/release-candidate node packPackages.js --verify artifacts/release-candidate ``` -Use backslashes for Windows paths. Verify that `packages.json` lists exactly the six maintained packages and compare its SHA-256 hashes immediately before publishing. An authorized maintainer may publish **each exact verified tarball**, for example: +Use backslashes for Windows paths. Verify that `packages.json` lists exactly the six maintained packages at the intended release version, never `0.0.0-placeholder`, and compare its SHA-256 hashes immediately before publishing. An authorized maintainer may publish **each exact verified tarball**, for example: ```shell pnpm publish artifacts/release-candidate/microsoft-agents-a365-runtime-.tgz --access public diff --git a/docs/observability-migration.md b/docs/observability-migration.md index ede0dd3b..764ee490 100644 --- a/docs/observability-migration.md +++ b/docs/observability-migration.md @@ -25,7 +25,7 @@ The remaining Agent365 SDK packages retain their own Node >=18 requirement; that | `@microsoft/agents-a365-observability-extensions-openai` | `packages/agents-a365-observability-extensions-openai` | OpenAI tests under `tests/observability` and `tests/observability-extensions-openai` | | `@microsoft/agents-a365-observability-extensions-langchain` | `packages/agents-a365-observability-extensions-langchain` | LangChain tests under `tests/observability` | -Their package-root public exports, dual CJS/ESM builds, release/versioning participation, and dedicated Azure OpenAI integration workflow are removed. A historical `observability-tokencache` diagram/mapping was stale, not a fifth current package. The Runtime-only `getObservabilityAuthenticationScope()` and `PROD_OBSERVABILITY_SCOPE` exports were also removed: resolve telemetry authentication with the distro instead of continuing to use a hardcoded legacy scope. +Their package-root public exports, dual CJS/ESM builds, release/versioning participation, and dedicated Azure OpenAI integration workflow are removed. A historical `observability-tokencache` diagram/mapping was stale, not a fifth current package. The Runtime-only `getObservabilityAuthenticationScope()` and `PROD_OBSERVABILITY_SCOPE` exports were also removed: configure telemetry authentication through the distro APIs. Removing these exports does not mean the scope value must change. Notifications, Runtime, Tooling, and Tooling framework adapters remain maintained. **Do not uninstall Runtime if your application still uses its authentication, identity, configuration, or discovery APIs.** No maintained implementation depended on the removed packages. The deprecated Runtime constant `PROD_OBSERVABILITY_CLUSTER_CATEGORY` remains a general cluster-default compatibility alias, not a telemetry implementation. @@ -122,9 +122,11 @@ Set service identity with the distro's OpenTelemetry resource options or `OTEL_R Supply a token resolver for the chosen identity/endpoint. The simple resolver receives `(agentId, tenantId, authScopes)` and returns a string or `Promise`. The contextual resolver accepts `TokenResolverContext`, supports `identity.agenticUserId`, and takes precedence when supplied. A cache must be populated/refreshed through your authenticated flow; do not return empty strings as successful credentials. -The retired exporter used `/observabilityService` with an **app-only resolver**, including per-request export. Request-context tokens were not fallback credentials. The distro's `useS2SEndpoint` defaults to `false`: explicitly choose and validate the endpoint and identity flow rather than assuming old behavior. Check current scopes, audience, tenant consent, and `Agent365.Observability.OtelWrite` permissions in the official migration/authentication documentation. Do not reuse the removed Runtime hardcoded scope or confuse MCP Tooling tokens with telemetry tokens. +The retired exporter used `/observabilityService` with an **app-only resolver**, including per-request export. Request-context tokens were not fallback credentials. The distro's `useS2SEndpoint` defaults to `false`: explicitly choose and validate the endpoint and identity flow rather than assuming old behavior. -The distro documents a changed default scope (`api://9b975845-388f-4429-889e-eab1ef63949c/.default`) and distinct OBO/S2S requirements. Treat these as identity-flow-specific configuration, not interchangeable strings. Verify acquisition, expiry/refresh, denied consent, and 401/403 handling against your tenant before rollout. +The removed Runtime helper and core configuration already defaulted to `api://9b975845-388f-4429-889e-eab1ef63949c/.default`, which is also the distro's default. **This scope has not changed for consumers of this SDK.** For app-only export, request that `/.default` scope with an app-only credential; custom resolvers should honor the configured authentication scopes. Do not substitute an MCP Tooling scope/token or a delegated `Agent365.Observability.OtelWrite` token for app-only export. + +The upstream migration guide's scope-change warning concerns the older `https://api.powerplatform.com/.default` value, not this SDK's current default. For an explicitly delegated/OBO flow, follow the distro's identity-flow-specific guidance separately rather than unintentionally changing the existing app-only flow. Verify audience, tenant consent, acquisition, expiry/refresh, denied consent, and 401/403 handling before rollout. Some environment names remain supported upstream: `ENABLE_A365_OBSERVABILITY_EXPORTER`, `A365_OBSERVABILITY_SCOPES_OVERRIDE`, `A365_OBSERVABILITY_DOMAIN_OVERRIDE`, `A365_OBSERVABILITY_LOG_LEVEL`, and `CLUSTER_CATEGORY`. They do not make every old setting compatible. Revalidate precedence against the selected distro version. Old `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` and `A365_PER_REQUEST_MAX_*` settings have no verified equivalent; remove or redesign them explicitly. @@ -176,11 +178,12 @@ Unsupported migration assumptions include old per-request buffering/flush semant Root and pnpm workspace lists explicitly contain six maintained packages; version tooling uses that list. CI uses the root guarded packaging path: ```shell +node setVersion.js pnpm run pack artifacts/release-candidate node packPackages.js --verify artifacts/release-candidate ``` -Use an output directory that does **not** already exist (on Windows, use backslashes). The guard rejects physical retired package directories, cleans/rebuilds maintained packages, packs only explicit allowed manifests, and independently inspects all six archives. It checks package identity, dependency sections, entry points, retired paths/imports/known implementation signatures, and duplicate/missing packages, then emits a SHA-256 `packages.json` receipt. Historical changelog entries are preserved and are not executable implementation. +For releases, run `node setVersion.js` in write mode before building or packing and verify the intended version in the receipt; the pack guard does not stamp versions. Use an output directory that does **not** already exist (on Windows, use backslashes). The guard requires pnpm, rejects physical retired package directories, cleans/rebuilds maintained packages, packs only explicit allowed manifests, and independently inspects all six archives. It checks package identity, dependency sections (including unresolved `catalog:`/`workspace:` specifications), entry points, retired paths/imports/known implementation signatures, and duplicate/missing packages, then emits a SHA-256 `packages.json` receipt. Historical changelog entries are preserved and are not executable implementation. Reviewed stale `dist` trees from deleted packages must be removed before packing. Do not copy cached `packages/*.tgz`, reuse old output directories, publish by wildcard discovery, or treat a successful individual `pnpm pack` as removal verification. Package owners must publish only verified receipt-listed artifacts from the approved commit. These repository safeguards do **not** revoke already uploaded artifacts, old-branch pipelines, registry permissions, or external release jobs. diff --git a/packPackages.js b/packPackages.js index b3f720d7..347a754c 100644 --- a/packPackages.js +++ b/packPackages.js @@ -21,15 +21,18 @@ const allowedNames = new Set([ const retiredDependency = /^@microsoft\/agents-a365-observability(?:$|-)/; const retiredCode = /@microsoft\/agents-a365-observability|ObservabilityManager|ObservabilityBuilder|PerRequestSpanProcessor|LangChainTraceInstrumentor|OpenAIAgentsTraceInstrumentor|OpenAIAgentsTraceProcessor|Agent365Exporter|BaggageMiddleware|OutputLoggingMiddleware/; -function assertManifest(manifest) { +function assertManifest(manifest, requireResolvedDependencies = false) { if (!allowedNames.has(manifest.name)) { throw new Error(`Unexpected package: ${manifest.name}`); } for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { - for (const name of Object.keys(manifest[section] ?? {})) { + for (const [name, specification] of Object.entries(manifest[section] ?? {})) { if (retiredDependency.test(name)) { throw new Error(`Retired dependency: ${manifest.name} -> ${name} (${section})`); } + if (requireResolvedDependencies && /^(?:catalog|workspace):/.test(specification)) { + throw new Error(`Unresolved dependency specification: ${manifest.name} -> ${name} (${section}): ${specification}`); + } } } } @@ -44,11 +47,11 @@ function verify(directory) { if (!entries.includes('package/package.json')) { throw new Error(`Missing package manifest: ${file}`); } - const readEntry = entry => execFileSync('tar', ['-xOzf', tarball, entry], { + const readEntry = entry => execFileSync('tar', ['-xOzf', tarball, '--', entry], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, }); const manifest = JSON.parse(readEntry('package/package.json')); - assertManifest(manifest); + assertManifest(manifest, true); if (seen.has(manifest.name)) { throw new Error(`Duplicate package: ${manifest.name}`); } @@ -81,13 +84,13 @@ function verify(directory) { // Use pnpm's JS entry point rather than shell execution (also works on Windows). function pnpm(args, cwd = root) { - if (!process.env.npm_execpath) { - throw new Error('Run packaging through pnpm run pack, not node directly.'); - } execFileSync(process.execPath, [process.env.npm_execpath, ...args], { cwd, stdio: 'inherit' }); } function pack(destination) { + if (!process.env.npm_execpath || !process.env.npm_config_user_agent?.startsWith('pnpm/')) { + throw new Error('Run packaging through pnpm run pack, not npm or node directly.'); + } if (workspaces.length !== allowedNames.size || workspaces.some(dir => dir.includes('*'))) { throw new Error('Release workspaces must list exactly the six maintained package directories.'); } diff --git a/setVersion.js b/setVersion.js index 8388d226..8a81abfe 100644 --- a/setVersion.js +++ b/setVersion.js @@ -16,7 +16,7 @@ * * Examples: * # For CI/CD pipelines (writes to disk): - * pnpm build && node setVersion.js && pnpm run pack + * node setVersion.js && pnpm run pack * * # For local development (dry-run): * node setVersion.js --local diff --git a/tests/release-packages.test.ts b/tests/release-packages.test.ts index 58e5aa34..12622c65 100644 --- a/tests/release-packages.test.ts +++ b/tests/release-packages.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { execFileSync, spawnSync } from 'child_process'; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import path from 'path'; @@ -17,13 +17,36 @@ afterEach(() => { rmSync(fixture, { recursive: true, force: true }); }); -function packageFixture(name: string, files: Record) { +function packageFixture(name: string, files: Record, extraFiles: Record = {}) { const dir = path.join(fixture, name); mkdirSync(path.join(dir, 'package'), { recursive: true }); for (const [file, content] of Object.entries(files)) { writeFileSync(path.join(dir, 'package', file), content); } - execFileSync('tar', ['-czf', path.join(fixture, `${name}.tgz`), '-C', dir, 'package']); + for (const [file, content] of Object.entries(extraFiles)) { + writeFileSync(path.join(dir, file), content); + } + execFileSync('tar', [ + '-czf', path.join(fixture, `${name}.tgz`), '-C', dir, '--', 'package', ...Object.keys(extraFiles), + ]); +} + +function maintainedPackageFixtures(extraFiles: Record = {}) { + for (const suffix of [ + 'notifications', 'runtime', 'tooling', 'tooling-extensions-claude', + 'tooling-extensions-langchain', 'tooling-extensions-openai', + ]) { + packageFixture(suffix, { + 'package.json': JSON.stringify({ + name: `@microsoft/agents-a365-${suffix}`, version: '1.0.0', + main: 'index.js', module: 'index.js', types: 'index.d.ts', + dependencies: { external: '^1.0.0', alias: 'npm:external@^1.0.0' }, + }), + 'index.js': 'exports.maintained = true;', + 'index.d.ts': 'export declare const maintained: boolean;', + 'CHANGELOG.md': 'Historical: @microsoft/agents-a365-observability', + }, extraFiles); + } } it('rejects a retired package even when its tarball has an innocent filename', () => { @@ -59,21 +82,45 @@ it.each(['dependencies', 'devDependencies', 'peerDependencies', 'optionalDepende expect(result.stderr).toMatch(/Retired dependency/); }); +it.each(['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].flatMap( + section => ['catalog:', 'workspace:*'].map(specification => [section, specification]), +))('rejects unresolved %s specification %s', (section, specification) => { + packageFixture('runtime', { + 'package.json': JSON.stringify({ + name: '@microsoft/agents-a365-runtime', version: '1.0.0', + [section]: { external: specification }, + }), + }); + const result = spawnSync(process.execPath, [script, '--verify', fixture], { encoding: 'utf8' }); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/Unresolved dependency specification/); +}); + +it('rejects npm packaging before creating output or running build commands', () => { + const destination = path.join(fixture, 'release'); + const result = spawnSync(process.execPath, [script, destination], { + encoding: 'utf8', + env: { + ...process.env, + npm_execpath: path.join(fixture, 'npm-cli.js'), + npm_config_user_agent: 'npm/10.9.2 node/v22.14.0', + }, + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/Run packaging through pnpm run pack/); + expect(existsSync(destination)).toBe(false); +}); + it('accepts exactly six clean packages with complete entry points', () => { - for (const suffix of [ - 'notifications', 'runtime', 'tooling', 'tooling-extensions-claude', - 'tooling-extensions-langchain', 'tooling-extensions-openai', - ]) { - packageFixture(suffix, { - 'package.json': JSON.stringify({ - name: `@microsoft/agents-a365-${suffix}`, version: '1.0.0', - main: 'index.js', module: 'index.js', types: 'index.d.ts', - }), - 'index.js': 'exports.maintained = true;', - 'index.d.ts': 'export declare const maintained: boolean;', - 'CHANGELOG.md': 'Historical: @microsoft/agents-a365-observability', - }); - } + maintainedPackageFixtures(); + const result = spawnSync(process.execPath, [script, '--verify', fixture], { encoding: 'utf8' }); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toHaveLength(6); +}); + +it('treats option-shaped archive member names as literal filenames', () => { + maintainedPackageFixtures({ '--not-a-tar-option.js': 'exports.maintained = true;' }); const result = spawnSync(process.execPath, [script, '--verify', fixture], { encoding: 'utf8' }); expect(result.stderr).toBe(''); expect(result.status).toBe(0); From 283bf793541b358b9cf36d8e29777a2d42978ea3 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 29 Oct 2026 14:34:47 -0600 Subject: [PATCH 3/8] Avoid reinstalling after release version stamping Continue packaging from the installed and stamped Step 6 checkout so frozen lockfile validation runs only before manifests are rewritten. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9339e0c8-5126-4e42-b471-9dddfb80d4a8 --- HOW_TO_RELEASE.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/HOW_TO_RELEASE.md b/HOW_TO_RELEASE.md index becd1d47..9447a203 100644 --- a/HOW_TO_RELEASE.md +++ b/HOW_TO_RELEASE.md @@ -235,15 +235,11 @@ npm install @microsoft/agents-a365-runtime@1.1.0 Observability is retired. First complete the [migration and owner-operated npm retirement checklist](docs/observability-migration.md), including external publisher/old-branch restrictions. Never publish every discovered directory or `*.tgz` file. -From the approved release commit, create a **new** output directory through the guarded root script: +Continue in the already installed, version-stamped checkout from Step 6. Do not repeat `pnpm install --frozen-lockfile` after stamping: `setVersion.js` changes internal dependency specifications without updating the lockfile. If starting from a fresh checkout of the approved release commit, complete Step 6 first. + +Create a **new** output directory through the guarded root script, which cleans and rebuilds the packages: ```shell -pnpm install --frozen-lockfile -node setVersion.js -pnpm build -pnpm test -pnpm lint -pnpm typecheck pnpm run pack artifacts/release-candidate node packPackages.js --verify artifacts/release-candidate ``` From e991ffa96c1c00dd5d19fd7a12a8e302cab28c75 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 30 Oct 2026 09:30:21 -0600 Subject: [PATCH 4/8] Address migration guidance and recursive packaging review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9339e0c8-5126-4e42-b471-9dddfb80d4a8 --- HOW_TO_BUILD.md | 6 +- HOW_TO_RELEASE.md | 43 +++++++++++- docs/observability-migration.md | 65 +++++++------------ .../agents-a365-notifications/package.json | 3 +- packages/agents-a365-runtime/package.json | 3 +- .../package.json | 3 +- .../package.json | 3 +- .../package.json | 3 +- packages/agents-a365-tooling/package.json | 3 +- tests/release-packages.test.ts | 34 ++++++++++ 10 files changed, 109 insertions(+), 57 deletions(-) diff --git a/HOW_TO_BUILD.md b/HOW_TO_BUILD.md index 7a792128..f530554c 100644 --- a/HOW_TO_BUILD.md +++ b/HOW_TO_BUILD.md @@ -40,7 +40,7 @@ pnpm clean ## Pack verified artifacts -Use the **root script**, not the built-in `pnpm pack` command or wildcard workspace packing: +Use the **root script**, not the built-in `pnpm pack` command or wildcard workspace packing. Per-package `pack` scripts are intentionally absent so recursive script execution cannot start concurrent clean/build/release runs: ```shell pnpm run pack @@ -57,7 +57,7 @@ pnpm run pack artifacts/release-candidate node packPackages.js --verify artifacts/release-candidate ``` -On Windows, use `artifacts\release-candidate`. The destination must not already exist. Reviewed stale generated output under retired package directories must be removed first. Never collect old `packages/*.tgz` or reuse cached tarballs as a release. +The destination must not already exist. Reviewed stale generated output under retired package directories must be removed first. Never collect old `packages/*.tgz` or reuse cached tarballs as a release. Samples are maintained in [Agent365-Samples](https://github.com/microsoft/Agent365-Samples), not this workspace. Install the exact verified tarball(s) needed by your sample; published workspace dependencies may also need corresponding local overrides in that application. @@ -65,7 +65,7 @@ Samples are maintained in [Agent365-Samples](https://github.com/microsoft/Agent3 For missing dependencies, restore with `pnpm install --frozen-lockfile`; for stale compilation output, run `pnpm clean` then `pnpm build`. Check Node and pnpm versions before changing configuration. Do not clear shared caches as a routine repair. -See [release instructions](HOW_TO_RELEASE.md) and the [owner-operated retirement checklist](docs/observability-migration.md#owner-operated-npm-retirement-checklist). Only authorized owners publish receipt-listed artifacts. Repository checks cannot stop external pipelines or old branches from releasing retired packages. +See [release instructions](HOW_TO_RELEASE.md) and the [owner-operated retirement checklist](HOW_TO_RELEASE.md#owner-operated-npm-retirement-checklist). Only authorized owners publish receipt-listed artifacts. Repository checks cannot stop external pipelines or old branches from releasing retired packages. ## Resources diff --git a/HOW_TO_RELEASE.md b/HOW_TO_RELEASE.md index 9447a203..1a01a3a2 100644 --- a/HOW_TO_RELEASE.md +++ b/HOW_TO_RELEASE.md @@ -7,6 +7,8 @@ This guide describes the process for creating an official release of the Microso - [Prerequisites](#prerequisites) - [Release Types](#release-types) - [Release Process](#release-process) +- [Repository Release Safeguards](#repository-release-safeguards) +- [Owner-Operated npm Retirement Checklist](#owner-operated-npm-retirement-checklist) - [Post-Release Tasks](#post-release-tasks) - [Troubleshooting](#troubleshooting) @@ -233,7 +235,7 @@ npm install @microsoft/agents-a365-runtime@1.1.0 ### Step 8: Publish to NPM (Optional) -Observability is retired. First complete the [migration and owner-operated npm retirement checklist](docs/observability-migration.md), including external publisher/old-branch restrictions. Never publish every discovered directory or `*.tgz` file. +Observability is retired. First complete the [owner-operated npm retirement checklist](#owner-operated-npm-retirement-checklist), including external publisher/old-branch restrictions. Never publish every discovered directory or `*.tgz` file. Continue in the already installed, version-stamped checkout from Step 6. Do not repeat `pnpm install --frozen-lockfile` after stamping: `setVersion.js` changes internal dependency specifications without updating the lockfile. If starting from a fresh checkout of the approved release commit, complete Step 6 first. @@ -244,7 +246,7 @@ pnpm run pack artifacts/release-candidate node packPackages.js --verify artifacts/release-candidate ``` -Use backslashes for Windows paths. Verify that `packages.json` lists exactly the six maintained packages at the intended release version, never `0.0.0-placeholder`, and compare its SHA-256 hashes immediately before publishing. An authorized maintainer may publish **each exact verified tarball**, for example: +Verify that `packages.json` lists exactly the six maintained packages at the intended release version, never `0.0.0-placeholder`, and compare its SHA-256 hashes immediately before publishing. An authorized maintainer may publish **each exact verified tarball**, for example: ```shell pnpm publish artifacts/release-candidate/microsoft-agents-a365-runtime-.tgz --access public @@ -254,6 +256,43 @@ Repeat only for receipt-listed maintained packages; do not use wildcard publishi --- +## Repository release safeguards + +Root and pnpm workspace lists explicitly contain six maintained packages; version tooling uses that list. From an installed checkout (see Step 6), CI and releases use the root guarded packaging path: + +```shell +node setVersion.js +pnpm run pack artifacts/release-candidate +node packPackages.js --verify artifacts/release-candidate +``` + +For releases, run `node setVersion.js` in write mode before building or packing and verify the intended version in the receipt; the pack guard does not stamp versions. Use an output directory that does **not** already exist. The guard requires pnpm, rejects physical retired package directories, cleans/rebuilds maintained packages, packs only explicit allowed manifests, and independently inspects all six archives. It checks package identity, dependency sections (including unresolved `catalog:`/`workspace:` specifications), entry points, retired paths/imports/known implementation signatures, and duplicate/missing packages, then emits a SHA-256 `packages.json` receipt. Historical changelog entries are preserved and are not executable implementation. + +Reviewed stale `dist` trees from deleted packages must be removed before packing. Do not copy cached `packages/*.tgz`, reuse old output directories, publish by wildcard discovery, or treat a successful individual `pnpm pack` as removal verification. Package owners must publish only verified receipt-listed artifacts from the approved commit. These repository safeguards do **not** revoke already uploaded artifacts, old-branch pipelines, registry permissions, or external release jobs. + +## Owner-operated npm retirement checklist + +No registry operation is performed by the retirement change. An authorized package owner/maintainer must: + +1. Publish the [application migration guide](docs/observability-migration.md) at the canonical link below and announce the breaking removal, including Runtime scope-helper removal. Confirm migration destinations, ownership, and support routing. +2. Audit npm owners/tokens/trusted publishers and every external pipeline, release branch/tag trigger, scheduled job, artifact feed, and manual release runbook. Disable retired-package publishing or backport the allowlist/artifact checks to every branch still allowed to release. Enforce the package-name allowlist independently in the external publisher. +3. Quarantine/delete specifically identified retired cached tarballs and artifacts in external drops/caches. Rebuild from the approved commit into fresh directories; compare receipt hashes immediately before publishing. Do not replay an old successful build. +4. Review all versions/dist-tags of the four exact packages, including prereleases, and confirm authorization. Run the following **only after the migration link is live**, using your normal authenticated npm environment. +5. Check registry deprecation metadata for representative stable/prerelease versions and confirm installation displays the warning. Monitor consumer migration and stop new retired-package releases. Deprecation **warns**; it neither replaces dependencies nor prevents installation. **Do not unpublish.** + +[npm's current deprecation syntax](https://docs.npmjs.com/cli/v11/commands/npm-deprecate) is `npm deprecate `. Quoted `@*` targets all versions, including prereleases. This command is workspace-unaware; run once for each exact package: + +```shell +npm deprecate "@microsoft/agents-a365-observability@*" "Retired: migrate your application to @microsoft/opentelemetry using useMicrosoftOpenTelemetry(). Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" +npm deprecate "@microsoft/agents-a365-observability-hosting@*" "Retired: migrate hosting telemetry to @microsoft/opentelemetry. Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" +npm deprecate "@microsoft/agents-a365-observability-extensions-openai@*" "Retired: use OpenAI Agents instrumentation in @microsoft/opentelemetry. Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" +npm deprecate "@microsoft/agents-a365-observability-extensions-langchain@*" "Retired: use LangChain instrumentation in @microsoft/opentelemetry. Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" +``` + +These are ready-to-paste owner actions, not commands for CI or this implementation session. Preserve historical changelogs and released package availability. + +--- + ## Post-Release Tasks ### 1. Update CHANGELOG.md diff --git a/docs/observability-migration.md b/docs/observability-migration.md index 764ee490..ef92a0ce 100644 --- a/docs/observability-migration.md +++ b/docs/observability-migration.md @@ -8,7 +8,7 @@ Use the [Microsoft OpenTelemetry JavaScript distro](https://github.com/microsoft Consult the distro's [official Agent365 documentation](https://github.com/microsoft/opentelemetry-distro-javascript/blob/main/A365_DOCUMENTATION.md), [Agent365 migration guide](https://github.com/microsoft/opentelemetry-distro-javascript/blob/main/MIGRATION_A365.md), [API reference](https://microsoft.github.io/opentelemetry-distro-javascript/), and [Microsoft Learn onboarding](https://learn.microsoft.com/microsoft-agent-365/developer/microsoft-opentelemetry?tabs=nodejs). This guide supplements them; it does not promise drop-in compatibility. -Verification snapshot: **2026-10-28**, published package **1.5.0**. Its npm metadata declares Node.js **>=22.0.0**, root `import` and `require` exports with declarations, and the public `@microsoft/opentelemetry/loader` subpath. The repository's `main` currently declares stricter engines (`^22.23.4 || ^24.22.1 || >=26.12.1`); some Learn prerequisites still describe older runtimes. Choose a supported Node release satisfying the **selected published version's** engines and deployment policy, and recheck before upgrading: +Verification snapshot: **2026-10-30**, published package **1.5.0**. Its npm metadata declares Node.js **>=22.0.0**, root `import` and `require` exports with declarations, and the public `@microsoft/opentelemetry/loader` subpath. The repository's `main` currently declares stricter engines (`^22.23.4 || ^24.22.1 || >=26.12.1`); some Learn prerequisites still describe older runtimes. Choose a supported Node release satisfying the **selected published version's** engines and deployment policy, and recheck before upgrading: ```shell npm view @microsoft/opentelemetry version engines exports --json @@ -49,7 +49,7 @@ Use your application's existing package manager, update its lockfile, and remove | `BaggageBuilder`, trace/context and export-token helpers | Available distro APIs; review propagation, identity, and token semantics | | Hosting manager, middleware, token cache | Prefer distro `configureA365Hosting`; explicitly configure authentication and content capture | | OpenAI instrumentor/trace processor; `LangChainTraceInstrumentor`, `LangChainTracer` | Replace explicit registration with distro `instrumentationOptions`; remove old callbacks/instrumentors | -| `PerRequestSpanProcessor`, its configuration/providers and buffer limits | No matching public replacement contract verified; redesign and test delivery/latency requirements rather than copying options | +| `PerRequestSpanProcessor`, its configuration/providers and buffer limits | No `PerRequestSpanProcessor` root export in 1.5.0; use the documented `A365_PER_REQUEST_*` buffer controls below rather than copying old processor/provider wiring | Names such as `Agent365Exporter`, `ObservabilityHostingManager`, and `A365_OBSERVABILITY_*` can legitimately remain when imported/configured through the **replacement**. A name match alone is not a retired dependency. Do not copy deleted implementations into the remaining packages. @@ -124,11 +124,27 @@ Supply a token resolver for the chosen identity/endpoint. The simple resolver re The retired exporter used `/observabilityService` with an **app-only resolver**, including per-request export. Request-context tokens were not fallback credentials. The distro's `useS2SEndpoint` defaults to `false`: explicitly choose and validate the endpoint and identity flow rather than assuming old behavior. +**Permission prerequisite:** The app registration or managed identity used for app-only export must have the Agent365 API's `Agent365.Observability.OtelWrite` **application permission**, with administrator consent. For an app registration, use **Entra portal > App registrations > your app > API permissions > Add a permission > APIs my organization uses**, find `9b975845-388f-4429-889e-eab1ef63949c`, select **Application permissions**, add `Agent365.Observability.OtelWrite`, and **Grant admin consent**. See the [upstream permission setup](https://github.com/microsoft/opentelemetry-distro-javascript/blob/main/MIGRATION_A365.md) for administrator/CLI and identity-flow guidance. A valid token without the required grant can still produce HTTP 403. + The removed Runtime helper and core configuration already defaulted to `api://9b975845-388f-4429-889e-eab1ef63949c/.default`, which is also the distro's default. **This scope has not changed for consumers of this SDK.** For app-only export, request that `/.default` scope with an app-only credential; custom resolvers should honor the configured authentication scopes. Do not substitute an MCP Tooling scope/token or a delegated `Agent365.Observability.OtelWrite` token for app-only export. The upstream migration guide's scope-change warning concerns the older `https://api.powerplatform.com/.default` value, not this SDK's current default. For an explicitly delegated/OBO flow, follow the distro's identity-flow-specific guidance separately rather than unintentionally changing the existing app-only flow. Verify audience, tenant consent, acquisition, expiry/refresh, denied consent, and 401/403 handling before rollout. -Some environment names remain supported upstream: `ENABLE_A365_OBSERVABILITY_EXPORTER`, `A365_OBSERVABILITY_SCOPES_OVERRIDE`, `A365_OBSERVABILITY_DOMAIN_OVERRIDE`, `A365_OBSERVABILITY_LOG_LEVEL`, and `CLUSTER_CATEGORY`. They do not make every old setting compatible. Revalidate precedence against the selected distro version. Old `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` and `A365_PER_REQUEST_MAX_*` settings have no verified equivalent; remove or redesign them explicitly. +Some environment names remain supported upstream: `ENABLE_A365_OBSERVABILITY_EXPORTER`, `A365_OBSERVABILITY_SCOPES_OVERRIDE`, `A365_OBSERVABILITY_DOMAIN_OVERRIDE`, `A365_OBSERVABILITY_LOG_LEVEL`, and `CLUSTER_CATEGORY`. **`ENABLE_A365_OBSERVABILITY_EXPORTER` is only a secondary toggle:** it cannot activate A365 on its own. You must still pass `a365` options with `enabled: true` in code, as shown above. Revalidate option/environment precedence against the selected distro version. + +### Per-request buffer settings + +The distro's [documented environment controls](https://github.com/microsoft/opentelemetry-distro-javascript/blob/main/README.md#a365-environment-variables), also present in the published 1.5.0 README, include: + +| Variable | Purpose | Default | +|---|---|---| +| `A365_PER_REQUEST_MAX_TRACES` | Maximum buffered traces | `1000` | +| `A365_PER_REQUEST_MAX_SPANS_PER_TRACE` | Maximum spans per trace | `5000` | +| `A365_PER_REQUEST_MAX_CONCURRENT_EXPORTS` | Maximum concurrent exports | `20` | +| `A365_PER_REQUEST_FLUSH_GRACE_MS` | Grace period after the root span ends, in milliseconds | `250` | +| `A365_PER_REQUEST_MAX_TRACE_AGE_MS` | Maximum trace age before forced flush, in milliseconds | `1800000` | + +These settings are supported, but do not guarantee identical buffering, drop, or flush semantics. The legacy `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` switch is not documented in the current upstream environment contract; do not assume it selects the old processing mode. Verify the selected distro version's delivery and latency behavior in application tests. ## Context, manual spans, hosting, and frameworks @@ -158,10 +174,10 @@ OpenAI Agents and LangChain instrumentation are bundled and enabled by default f ## Shutdown, flushing, and unsupported assumptions -During an application-owned graceful shutdown, first stop accepting requests and await in-flight work, then: +During an application-owned graceful shutdown, you **must** stop accepting requests, await in-flight work, then **call and await `shutdownMicrosoftOpenTelemetry()`** to flush pending telemetry and release resources. Skipping shutdown can lose buffered spans and metrics: ```javascript -// ESM; CommonJS: const { shutdownMicrosoftOpenTelemetry } = require('...'); +// ESM; CommonJS: const { shutdownMicrosoftOpenTelemetry } = require('@microsoft/opentelemetry'); import { shutdownMicrosoftOpenTelemetry } from '@microsoft/opentelemetry'; await shutdownMicrosoftOpenTelemetry(); @@ -169,41 +185,10 @@ await shutdownMicrosoftOpenTelemetry(); Await this in your asynchronous signal/lifecycle handler; a synchronous `exit` handler or immediate `process.exit()` cannot flush reliably. Handle rejected shutdown explicitly through application logging/error policy. No generic root-level `forceFlush()` API was verified. Applications explicitly owning an `Agent365Exporter` can use its documented `forceFlush()` contract; do not reach into private distro internals. -The current A365 HTTP exporter requests durable delivery by default. Review its platform requirements: on Unix, provision an existing absolute private durable-storage root; persisted telemetry is plaintext and delivery is at least once. `durableDelivery: { enabled: false }` chooses network-only delivery, with different loss guarantees. Shutdown is bounded and does not drain an existing spool; exporter force-flush replay is also bounded. See upstream operational guidance for permissions, storage, replay, timeouts, and recovery. - -Unsupported migration assumptions include old per-request buffering/flush semantics, custom provider factories and builder hooks, identical trace schemas, automatic token fallback, identical log/content policies, and identical instrumentation coverage. Build application regression tests for these requirements. Do not retain both SDKs to mask gaps or assume OTLP success proves Agent365 ingestion. Validate actual backend receipt, identity separation, retries, shutdown, and failures. - -## Repository release safeguards +The A365 HTTP exporter requests durable delivery by default. Provision an existing absolute private durable-storage root: upstream `main` now requires this on Unix, while published 1.5.0 documents automatic temporary-directory selection. Follow the selected version's storage guidance; persisted telemetry is plaintext and delivery is at least once. `durableDelivery: { enabled: false }` chooses network-only delivery, with different loss guarantees. Shutdown is bounded and does not drain an existing spool; exporter force-flush replay is also bounded. See upstream operational guidance for permissions, storage, replay, timeouts, and recovery. -Root and pnpm workspace lists explicitly contain six maintained packages; version tooling uses that list. CI uses the root guarded packaging path: +Unsupported migration assumptions include identical per-request buffering/flush semantics, custom provider factories and builder hooks, identical trace schemas, automatic token fallback, identical log/content policies, and identical instrumentation coverage. Build application regression tests for these requirements. Do not retain both SDKs to mask gaps or assume OTLP success proves Agent365 ingestion. Validate actual backend receipt, identity separation, retries, shutdown, and failures. -```shell -node setVersion.js -pnpm run pack artifacts/release-candidate -node packPackages.js --verify artifacts/release-candidate -``` - -For releases, run `node setVersion.js` in write mode before building or packing and verify the intended version in the receipt; the pack guard does not stamp versions. Use an output directory that does **not** already exist (on Windows, use backslashes). The guard requires pnpm, rejects physical retired package directories, cleans/rebuilds maintained packages, packs only explicit allowed manifests, and independently inspects all six archives. It checks package identity, dependency sections (including unresolved `catalog:`/`workspace:` specifications), entry points, retired paths/imports/known implementation signatures, and duplicate/missing packages, then emits a SHA-256 `packages.json` receipt. Historical changelog entries are preserved and are not executable implementation. - -Reviewed stale `dist` trees from deleted packages must be removed before packing. Do not copy cached `packages/*.tgz`, reuse old output directories, publish by wildcard discovery, or treat a successful individual `pnpm pack` as removal verification. Package owners must publish only verified receipt-listed artifacts from the approved commit. These repository safeguards do **not** revoke already uploaded artifacts, old-branch pipelines, registry permissions, or external release jobs. - -## Owner-operated npm retirement checklist - -No registry operation is performed by this change. An authorized package owner/maintainer must: - -1. Publish this guide at the canonical link below and announce the breaking removal, including Runtime scope-helper removal. Confirm migration destinations, ownership, and support routing. -2. Audit npm owners/tokens/trusted publishers and every external pipeline, release branch/tag trigger, scheduled job, artifact feed, and manual release runbook. Disable retired-package publishing or backport the allowlist/artifact checks to every branch still allowed to release. Enforce the package-name allowlist independently in the external publisher. -3. Quarantine/delete specifically identified retired cached tarballs and artifacts in external drops/caches. Rebuild from the approved commit into fresh directories; compare receipt hashes immediately before publishing. Do not replay an old successful build. -4. Review all versions/dist-tags of the four exact packages, including prereleases, and confirm authorization. Run the following **only after the migration link is live**, using your normal authenticated npm environment. -5. Check registry deprecation metadata for representative stable/prerelease versions and confirm installation displays the warning. Monitor consumer migration and stop new retired-package releases. Deprecation **warns**; it neither replaces dependencies nor prevents installation. **Do not unpublish.** - -[npm's current deprecation syntax](https://docs.npmjs.com/cli/v11/commands/npm-deprecate) is `npm deprecate `. Quoted `@*` targets all versions, including prereleases. This command is workspace-unaware; run once for each exact package: - -```shell -npm deprecate "@microsoft/agents-a365-observability@*" "Retired: migrate your application to @microsoft/opentelemetry using useMicrosoftOpenTelemetry(). Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" -npm deprecate "@microsoft/agents-a365-observability-hosting@*" "Retired: migrate hosting telemetry to @microsoft/opentelemetry. Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" -npm deprecate "@microsoft/agents-a365-observability-extensions-openai@*" "Retired: use OpenAI Agents instrumentation in @microsoft/opentelemetry. Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" -npm deprecate "@microsoft/agents-a365-observability-extensions-langchain@*" "Retired: use LangChain instrumentation in @microsoft/opentelemetry. Migration: https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md" -``` +## Maintainer actions -These are ready-to-paste owner actions, not commands for CI or this implementation session. Preserve historical changelogs and released package availability. +Repository packaging safeguards and registry deprecation are maintainer tasks, not application migration steps. See [release safeguards](../HOW_TO_RELEASE.md#repository-release-safeguards) and the [owner-operated npm retirement checklist and deprecation messages](../HOW_TO_RELEASE.md#owner-operated-npm-retirement-checklist). Applications must migrate explicitly; npm deprecation neither replaces dependencies nor prevents installation. diff --git a/packages/agents-a365-notifications/package.json b/packages/agents-a365-notifications/package.json index 97013740..7cb00b58 100644 --- a/packages/agents-a365-notifications/package.json +++ b/packages/agents-a365-notifications/package.json @@ -16,8 +16,7 @@ "lint": "eslint src", "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", - "ci": "npm ci", - "pack": "pnpm --dir ../.. run pack" + "ci": "npm ci" }, "keywords": [ "ai", diff --git a/packages/agents-a365-runtime/package.json b/packages/agents-a365-runtime/package.json index 91eb615f..4269c6a7 100644 --- a/packages/agents-a365-runtime/package.json +++ b/packages/agents-a365-runtime/package.json @@ -17,8 +17,7 @@ "lint": "eslint src", "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", - "ci": "npm ci", - "pack": "pnpm --dir ../.. run pack" + "ci": "npm ci" }, "keywords": [ "ai", diff --git a/packages/agents-a365-tooling-extensions-claude/package.json b/packages/agents-a365-tooling-extensions-claude/package.json index 5cebc90d..c19a82e3 100644 --- a/packages/agents-a365-tooling-extensions-claude/package.json +++ b/packages/agents-a365-tooling-extensions-claude/package.json @@ -16,8 +16,7 @@ "lint": "eslint src", "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", - "ci": "npm ci", - "pack": "pnpm --dir ../.. run pack" + "ci": "npm ci" }, "keywords": [ "ai", diff --git a/packages/agents-a365-tooling-extensions-langchain/package.json b/packages/agents-a365-tooling-extensions-langchain/package.json index e54e20a9..45960f3f 100644 --- a/packages/agents-a365-tooling-extensions-langchain/package.json +++ b/packages/agents-a365-tooling-extensions-langchain/package.json @@ -16,8 +16,7 @@ "lint": "eslint src", "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", - "ci": "npm ci", - "pack": "pnpm --dir ../.. run pack" + "ci": "npm ci" }, "keywords": [ "ai", diff --git a/packages/agents-a365-tooling-extensions-openai/package.json b/packages/agents-a365-tooling-extensions-openai/package.json index 6b80faf7..1739e404 100644 --- a/packages/agents-a365-tooling-extensions-openai/package.json +++ b/packages/agents-a365-tooling-extensions-openai/package.json @@ -16,8 +16,7 @@ "lint": "eslint src", "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", - "ci": "npm ci", - "pack": "pnpm --dir ../.. run pack" + "ci": "npm ci" }, "keywords": [ "ai", diff --git a/packages/agents-a365-tooling/package.json b/packages/agents-a365-tooling/package.json index 2449522d..cbdc0b94 100644 --- a/packages/agents-a365-tooling/package.json +++ b/packages/agents-a365-tooling/package.json @@ -16,8 +16,7 @@ "lint": "eslint src", "lint:fix": "eslint src --fix", "prepublishOnly": "npm run clean && npm run build", - "ci": "npm ci", - "pack": "pnpm --dir ../.. run pack" + "ci": "npm ci" }, "keywords": [ "ai", diff --git a/tests/release-packages.test.ts b/tests/release-packages.test.ts index 12622c65..bce48074 100644 --- a/tests/release-packages.test.ts +++ b/tests/release-packages.test.ts @@ -147,3 +147,37 @@ it('keeps release selection explicit rather than collecting cached wildcard tarb .toHaveLength(6); expect(manifest.workspaces.some((workspace: string) => workspace.includes('*'))).toBe(false); }); + +it('does not start root release packaging from recursive workspace pack scripts', () => { + const pnpmCli = process.env.npm_execpath; + if (!pnpmCli || !process.env.npm_config_user_agent?.startsWith('pnpm/')) { + throw new Error('Run release tests through pnpm test.'); + } + const root = path.resolve(__dirname, '..'); + const manifest = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); + const workspaces: string[] = manifest.workspaces.filter((workspace: string) => + workspace.startsWith('packages/')); + const marker = path.join(fixture, 'release-started'); + writeFileSync(path.join(fixture, 'package.json'), JSON.stringify({ + private: true, scripts: { pack: 'node pack.cjs' }, + })); + writeFileSync(path.join(fixture, 'pack.cjs'), + "require('node:fs').writeFileSync('release-started', 'true');"); + writeFileSync(path.join(fixture, 'pnpm-workspace.yaml'), + `packages:\n${workspaces.map(workspace => ` - ${JSON.stringify(workspace)}`).join('\n')}\n`); + for (const workspace of workspaces) { + const source = JSON.parse(readFileSync(path.join(root, workspace, 'package.json'), 'utf8')); + const directory = path.join(fixture, workspace); + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, 'package.json'), JSON.stringify({ + name: source.name, version: source.version, scripts: source.scripts, + })); + } + const result = spawnSync(process.execPath, + [pnpmCli, '--include-workspace-root=false', '-r', 'run', 'pack'], { + cwd: fixture, encoding: 'utf8', timeout: 30000, + }); + expect(result.error).toBeUndefined(); + expect(existsSync(marker)).toBe(false); + expect(result.status).toBe(0); +}); From 04a91fcbf7a267a842d658eae30bcf8cc76ae0f2 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 30 Oct 2026 10:06:30 -0600 Subject: [PATCH 5/8] Make missing recursive pack scripts explicitly optional Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9339e0c8-5126-4e42-b471-9dddfb80d4a8 --- tests/release-packages.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/release-packages.test.ts b/tests/release-packages.test.ts index bce48074..042bd72b 100644 --- a/tests/release-packages.test.ts +++ b/tests/release-packages.test.ts @@ -174,7 +174,7 @@ it('does not start root release packaging from recursive workspace pack scripts' })); } const result = spawnSync(process.execPath, - [pnpmCli, '--include-workspace-root=false', '-r', 'run', 'pack'], { + [pnpmCli, '--include-workspace-root=false', '-r', '--if-present', 'run', 'pack'], { cwd: fixture, encoding: 'utf8', timeout: 30000, }); expect(result.error).toBeUndefined(); From d7b5ec8db99e2ad83b92ccb29f7ff444dcf0a4de Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 30 Oct 2026 10:38:14 -0600 Subject: [PATCH 6/8] Preserve editor runner and CI Node matrix coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9339e0c8-5126-4e42-b471-9dddfb80d4a8 --- .github/workflows/ci.yml | 5 +++++ .vscode/settings.json | 2 +- tests/release-packages.test.ts | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad3150b7..9bf81398 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,9 @@ jobs: with: version: 10.20.0 + - name: Match pnpm runtime to selected Node.js + run: pnpm config set --location=project useNodeVersion "$(node -p 'process.versions.node')" + - name: Install dependencies run: pnpm install --frozen-lockfile @@ -95,6 +98,8 @@ jobs: run: pnpm typecheck - name: Run tests + env: + A365_EXPECTED_NODE_MAJOR: ${{ matrix.node-version }} run: pnpm test - name: Pack packages diff --git a/.vscode/settings.json b/.vscode/settings.json index 55cf2b93..947b2a92 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,6 @@ "code-reviewer", "test-coverage-reviewer" ], - "jest.jestCommandLine": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config=tests/jest.config.cjs", + "jest.jestCommandLine": "pnpm test", "jest.rootPath": "." } \ No newline at end of file diff --git a/tests/release-packages.test.ts b/tests/release-packages.test.ts index 042bd72b..7894afdc 100644 --- a/tests/release-packages.test.ts +++ b/tests/release-packages.test.ts @@ -149,6 +149,10 @@ it('keeps release selection explicit rather than collecting cached wildcard tarb }); it('does not start root release packaging from recursive workspace pack scripts', () => { + const expectedNodeMajor = process.env.A365_EXPECTED_NODE_MAJOR; + if (expectedNodeMajor) { + expect(process.versions.node.split('.')[0]).toBe(expectedNodeMajor); + } const pnpmCli = process.env.npm_execpath; if (!pnpmCli || !process.env.npm_config_user_agent?.startsWith('pnpm/')) { throw new Error('Run release tests through pnpm test.'); From edc0528be91fd4d20777a6be9b5d70ef3335d732 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 30 Oct 2026 11:32:42 -0600 Subject: [PATCH 7/8] Bump major version for Observability API retirement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9339e0c8-5126-4e42-b471-9dddfb80d4a8 --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 5703ab28..df0f010e 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.1.0-preview.{height}", + "version": "3.0.0-preview.{height}", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/master$", From 2713ee001f016d72ed4a238a6dced31649a04ae4 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 30 Oct 2026 12:04:18 -0600 Subject: [PATCH 8/8] docs: ship observability retirement release notes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9339e0c8-5126-4e42-b471-9dddfb80d4a8 --- CHANGELOG.md | 20 ++++++++++++++++++++ HOW_TO_RELEASE.md | 32 ++++++++++++++++---------------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 089f2cc7..4f875659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to the Agent365 TypeScript SDK will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - Unreleased + +### Breaking Changes + +- **Observability packages retired** - This repository no longer builds or releases + `@microsoft/agents-a365-observability`, + `@microsoft/agents-a365-observability-hosting`, + `@microsoft/agents-a365-observability-extensions-openai`, or + `@microsoft/agents-a365-observability-extensions-langchain`. + Notifications, Runtime, Tooling, and the Tooling extensions remain maintained. +- **Runtime telemetry exports removed** - `getObservabilityAuthenticationScope()` and + `PROD_OBSERVABILITY_SCOPE` are no longer exported by `@microsoft/agents-a365-runtime`. + Configure telemetry authentication in the consuming application through the replacement distro. +- **Application migration required** - Use + [`@microsoft/opentelemetry`](https://www.npmjs.com/package/@microsoft/opentelemetry) + from the [Microsoft OpenTelemetry JavaScript distro](https://github.com/microsoft/opentelemetry-distro-javascript). + This is not a drop-in replacement or an automatic dependency redirect; follow the + [Observability migration guide](https://github.com/microsoft/Agent365-nodejs/blob/main/docs/observability-migration.md) + for initialization, authentication, context propagation, instrumentation, and shutdown. + ## [2.0.0] - 2026-10-20 ### Breaking Changes (`@microsoft/agents-a365-observability`, `@microsoft/agents-a365-observability-hosting`, `@microsoft/agents-a365-observability-extensions-openai`, `@microsoft/agents-a365-observability-extensions-langchain`) diff --git a/HOW_TO_RELEASE.md b/HOW_TO_RELEASE.md index 1a01a3a2..b514a700 100644 --- a/HOW_TO_RELEASE.md +++ b/HOW_TO_RELEASE.md @@ -102,12 +102,17 @@ Use when: } ``` -### Step 2: Commit Version Bump +Update `CHANGELOG.md` for the target version before committing or tagging the release. +Document breaking changes and migration guidance, preserve historical entries, and replace +`Unreleased` with the release date when finalizing release notes. The guarded pack path +copies this file into every npm tarball, so post-publish edits cannot update shipped notes. + +### Step 2: Commit Version and Changelog ```bash -# Commit the version.json change -git add version.json -git commit -m "Bump version to 1.1.0 for release" +# Commit the version configuration and release notes together +git add version.json CHANGELOG.md +git commit -m "Bump version to 1.1.0 and document release changes" git push origin main ``` @@ -295,17 +300,11 @@ These are ready-to-paste owner actions, not commands for CI or this implementati ## Post-Release Tasks -### 1. Update CHANGELOG.md +### 1. Verify the Published Changelog -```bash -# Switch back to main -git checkout main - -# Update CHANGELOG.md with release notes -git add CHANGELOG.md -git commit -m "Update CHANGELOG for v1.1.0 release" -git push origin main -``` +Confirm the published tarballs contain the release notes from the approved release commit. +Corrections to shipped notes require a new package version; editing `CHANGELOG.md` on +`main` after publishing does not change existing artifacts. ### 2. Announce the Release @@ -351,7 +350,8 @@ Use this checklist for each release: - [ ] All CI checks passing - [ ] Release notes drafted - [ ] `version.json` updated with new base version -- [ ] Version bump committed to `main` +- [ ] `CHANGELOG.md` updated for the target version before packing or publishing +- [ ] Version configuration and changelog committed to `main` - [ ] Release branch created (`release/v{MAJOR}.{MINOR}`) - [ ] Release tag created (`v{MAJOR}.{MINOR}.{PATCH}`) - [ ] Tag pushed to GitHub @@ -359,7 +359,7 @@ Use this checklist for each release: - [ ] All packages have correct version number - [ ] GitHub release created with notes - [ ] Packages published to NPM (if applicable) -- [ ] CHANGELOG.md updated +- [ ] Published tarballs contain the approved changelog - [ ] Release announced to team - [ ] `version.json` prepared for next version - [ ] Documentation updated