From ca2d9cca7461bdcc6944c1e6a417911584be274e Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 15 Sep 2026 15:17:56 -0600 Subject: [PATCH 01/10] feat(samples): add Agent365 S2S observability demo Add safe exporter success diagnostics, two-stage MSAL authentication, expiry-aware caching, deterministic manual telemetry, sample documentation, and CI validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- .github/workflows/pr-validation.yml | 9 + samples/README.md | 2 + samples/agent365-s2s/.gitignore | 4 + samples/agent365-s2s/README.md | 105 ++++ samples/agent365-s2s/appsettings.example.json | 8 + samples/agent365-s2s/package-lock.json | 404 ++++++++++++++ samples/agent365-s2s/package.json | 27 + samples/agent365-s2s/src/config.ts | 92 ++++ samples/agent365-s2s/src/index.ts | 81 +++ samples/agent365-s2s/src/s2sTokenProvider.ts | 70 +++ samples/agent365-s2s/src/safeLogger.ts | 16 + samples/agent365-s2s/src/scenario.ts | 177 ++++++ .../agent365-s2s/src/tokenExchangeClient.ts | 110 ++++ samples/agent365-s2s/test/sample.test.ts | 502 ++++++++++++++++++ samples/agent365-s2s/tsconfig.json | 14 + src/a365/exporter/Agent365Exporter.ts | 14 +- src/index.ts | 2 + .../unit/a365/agent365Exporter.test.ts | 69 +++ 18 files changed, 1702 insertions(+), 4 deletions(-) create mode 100644 samples/agent365-s2s/.gitignore create mode 100644 samples/agent365-s2s/README.md create mode 100644 samples/agent365-s2s/appsettings.example.json create mode 100644 samples/agent365-s2s/package-lock.json create mode 100644 samples/agent365-s2s/package.json create mode 100644 samples/agent365-s2s/src/config.ts create mode 100644 samples/agent365-s2s/src/index.ts create mode 100644 samples/agent365-s2s/src/s2sTokenProvider.ts create mode 100644 samples/agent365-s2s/src/safeLogger.ts create mode 100644 samples/agent365-s2s/src/scenario.ts create mode 100644 samples/agent365-s2s/src/tokenExchangeClient.ts create mode 100644 samples/agent365-s2s/test/sample.test.ts create mode 100644 samples/agent365-s2s/tsconfig.json diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 32a41d5..613a489 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -27,6 +27,15 @@ jobs: - name: Build run: npm run build + - name: Install Agent365 S2S sample dependencies + run: npm ci --prefix samples/agent365-s2s + + - name: Build Agent365 S2S sample + run: npm run --prefix samples/agent365-s2s build + + - name: Test Agent365 S2S sample + run: npm test --prefix samples/agent365-s2s + - name: Format check run: npm run format diff --git a/samples/README.md b/samples/README.md index 3ff4f90..1833f70 100644 --- a/samples/README.md +++ b/samples/README.md @@ -28,6 +28,7 @@ These sample programs show how to use the `@microsoft/opentelemetry` distributio | [a365Export.ts][a365export] | Demonstrates A365 observability export: token resolver setup, dual export with Azure Monitor, and span routing by tenant/agent. | | [a365ManualScopes.ts][a365manualscopes] | Traces a full agent turn with manual scopes (InvokeAgent → Inference → ExecuteTool → Inference → Output) and cross-service context propagation. | | [a365HostingMiddleware.ts][a365hostingmiddleware] | Demonstrates A365 hosting middleware (BaggageMiddleware, OutputLoggingMiddleware, ObservabilityHostingManager, ScopeUtils). | +| [agent365-s2s][agent365s2s] | Standalone Agent365 S2S sample with two-stage MSAL app-only authentication, expiry-aware token caching, and four deterministic manual spans. | ## Prerequisites @@ -116,3 +117,4 @@ useMicrosoftOpenTelemetry({ [a365export]: https://github.com/microsoft/opentelemetry-distro-javascript/blob/main/samples/src/a365Export.ts [a365manualscopes]: https://github.com/microsoft/opentelemetry-distro-javascript/blob/main/samples/src/a365ManualScopes.ts [a365hostingmiddleware]: https://github.com/microsoft/opentelemetry-distro-javascript/blob/main/samples/src/a365HostingMiddleware.ts +[agent365s2s]: https://github.com/microsoft/opentelemetry-distro-javascript/tree/main/samples/agent365-s2s diff --git a/samples/agent365-s2s/.gitignore b/samples/agent365-s2s/.gitignore new file mode 100644 index 0000000..bec292d --- /dev/null +++ b/samples/agent365-s2s/.gitignore @@ -0,0 +1,4 @@ +appsettings.json +dist/ +node_modules/ +.test-tmp/ diff --git a/samples/agent365-s2s/README.md b/samples/agent365-s2s/README.md new file mode 100644 index 0000000..7c51f1c --- /dev/null +++ b/samples/agent365-s2s/README.md @@ -0,0 +1,105 @@ +# Agent365 service-to-service observability sample + +This standalone Node.js 22 sample publishes a deterministic agent trace to the +Agent365 service-to-service observability endpoint. It uses app-only +authentication; no interactive user sign-in or pre-generated bearer token is +required. + +## Prerequisites + +- Node.js 22 or later. +- An Agent365 blueprint application with a client secret. +- An Agent365 agent application in the same Microsoft Entra tenant. +- The Agent365 agent application must have the + `Agent365.Observability.OtelWrite` application permission with tenant admin + consent. +- The blueprint and agent application must be configured for the Agent365 + federated managed identity (FMI) token-exchange flow. + +Never commit `appsettings.json`. The included `.gitignore` excludes it. + +## Configure and run + +Build the root distro first so the sample's local `file:../..` dependency can +resolve its generated package exports: + +```powershell +Set-Location ..\.. +npm ci +npm run build +Set-Location samples\agent365-s2s +``` + +Then configure and run the sample: + +```powershell +Copy-Item appsettings.example.json appsettings.json +npm ci +npm run build +npm start +``` + +Replace every placeholder in `appsettings.json`: + +| Setting | Description | +| ----------------------- | ------------------------------------------------------------------------------------- | +| `authority` | HTTPS Microsoft Entra authority root, for example `https://login.microsoftonline.com` | +| `blueprintClientId` | Blueprint application client ID | +| `blueprintClientSecret` | Blueprint application client secret | +| `tenantId` | Microsoft Entra tenant ID | +| `agentId` | Agent365 agent application client ID and FMI path | +| `clusterCategory` | Must be `prod` | + +The sample rejects missing placeholders, malformed GUIDs, non-HTTPS +authorities, and authorities containing tenant paths, queries, or fragments. +Configuration errors name only the invalid setting and never echo its value. + +## Authentication flow + +The sample performs exactly two confidential-client requests: + +1. The blueprint application requests + `api://AzureADTokenExchange/.default`, using the configured `agentId` as + `fmiPath`. +2. The returned blueprint token becomes the `clientAssertion` for the agent + application, which requests + `api://9b975845-388f-4429-889e-eab1ef63949c/.default`. + +The final observability token is cached per normalized tenant/agent identity +and reused only while it expires more than 60 seconds in the future. +Concurrent refreshes share one request, and failed refreshes can be retried. + +## Expected telemetry + +Each run creates exactly four spans in one trace: + +1. `invoke_agent` for the complete synthetic request. +2. `Chat` inference selecting `lookup_weather`. +3. `execute_tool` with deterministic synthetic arguments and result. +4. `Chat` inference producing the final answer. + +All three operation spans are direct children of `invoke_agent`. The run starts +at the current time and uses fixed relative offsets and durations; tests inject +a fixed start time for repeatability. Published agent, caller, user, +conversation, message, and tool values are explicitly synthetic; only the +configured tenant and agent IDs identify the destination. + +The distro is configured with `enableObservabilityExporter: true`, +`useS2SEndpoint: true`, the exact observability scope, and `prod` routing. The +sample shuts down the SDK after the scenario so queued telemetry is flushed +without a fixed sleep. + +## Safe diagnostics + +The logger prints only preformatted messages and discards additional error +arguments. Tokens, client secrets, raw MSAL responses, exception messages, +nested errors, and stacks are never rendered. Authentication failures contain +only the failed stage and a sanitized MSAL error code. Successful exporter +diagnostics contain only the HTTP status and correlation ID (`N/A` when the +header is absent). + +Run the focused tests with: + +```powershell +npm test +``` diff --git a/samples/agent365-s2s/appsettings.example.json b/samples/agent365-s2s/appsettings.example.json new file mode 100644 index 0000000..a81d5e2 --- /dev/null +++ b/samples/agent365-s2s/appsettings.example.json @@ -0,0 +1,8 @@ +{ + "authority": "https://login.microsoftonline.com", + "blueprintClientId": "", + "blueprintClientSecret": "", + "tenantId": "", + "agentId": "", + "clusterCategory": "prod" +} diff --git a/samples/agent365-s2s/package-lock.json b/samples/agent365-s2s/package-lock.json new file mode 100644 index 0000000..a2efbe2 --- /dev/null +++ b/samples/agent365-s2s/package-lock.json @@ -0,0 +1,404 @@ +{ + "name": "@microsoft/opentelemetry-agent365-s2s-sample", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@microsoft/opentelemetry-agent365-s2s-sample", + "version": "1.0.0", + "dependencies": { + "@azure/msal-node": "^6.0.0", + "@microsoft/opentelemetry": "file:../..", + "@opentelemetry/api": "^1.9.1" + }, + "devDependencies": { + "@opentelemetry/sdk-trace-base": "^2.10.0", + "@opentelemetry/sdk-trace-node": "^2.10.0", + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "../..": { + "name": "@microsoft/opentelemetry", + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.8.0", + "@azure/core-auth": "^1.11.0", + "@azure/core-rest-pipeline": "^1.25.0", + "@azure/logger": "^1.4.0", + "@azure/monitor-opentelemetry-exporter": ">=1.0.0-beta.45 <1.0.0-c", + "@azure/opentelemetry-instrumentation-azure-sdk": "^1.1.0-beta.1", + "@microsoft/applicationinsights-web-snippet": "^1.2.3", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.221.0", + "@opentelemetry/core": "^2.10.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.221.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.221.0", + "@opentelemetry/instrumentation": "^0.221.0", + "@opentelemetry/instrumentation-bunyan": "^0.66.0", + "@opentelemetry/instrumentation-console": "^0.3.0", + "@opentelemetry/instrumentation-http": "^0.221.0", + "@opentelemetry/instrumentation-mongodb": "^0.74.0", + "@opentelemetry/instrumentation-mysql": "^0.67.0", + "@opentelemetry/instrumentation-pg": "^0.73.0", + "@opentelemetry/instrumentation-redis": "^0.69.0", + "@opentelemetry/instrumentation-winston": "^0.65.0", + "@opentelemetry/resource-detector-azure": "^0.29.0", + "@opentelemetry/resources": "^2.10.0", + "@opentelemetry/sdk-logs": "^0.221.0", + "@opentelemetry/sdk-metrics": "^2.10.0", + "@opentelemetry/sdk-node": "^0.221.0", + "@opentelemetry/sdk-trace-base": "^2.10.0", + "@opentelemetry/sdk-trace-node": "^2.10.0", + "@opentelemetry/semantic-conventions": "^1.43.0", + "@opentelemetry/winston-transport": "^0.31.0", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@azure/functions": "^4.9.0", + "@eslint/js": "^10.0.1", + "@langchain/core": "^1.1.39", + "@openai/agents": "^0.8.3", + "@types/node": "^22.0.0", + "@typescript-eslint/eslint-plugin": "^8.65.0", + "@typescript-eslint/parser": "^8.65.0", + "@vitest/coverage-istanbul": "^4.1.8", + "dotenv": "^16.0.0", + "eslint": "^10.8.0", + "eslint-config-prettier": "^10.1.8", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", + "prettier": "^3.3.3", + "rimraf": "^6.0.0", + "typedoc": "^0.28.19", + "typescript": "^5.6.0", + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.8" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.14.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/msal-common/-/msal-common-16.14.0.tgz", + "integrity": "sha1-zd2zjYMr4OG+YifqZNTrEZmXInw=", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "6.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/msal-node/-/msal-node-6.0.0.tgz", + "integrity": "sha1-WBqVaCpBRWwm89vVMpiR+9PrEC4=", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.0", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/opentelemetry": { + "resolved": "../..", + "link": true + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha1-wbA0beM2ulWvLVp5cIggN7rt7AU=", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.11.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/context-async-hooks/-/context-async-hooks-2.11.0.tgz", + "integrity": "sha1-E3etPLESUQa6NtTNPEkzSE0VdpE=", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha1-l4SdOAi7plJWi31T4gyfw4vLfEk=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.11.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.11.0.tgz", + "integrity": "sha1-tOYLqSpPEi3JefNAKuqXE6mOrFs=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.11.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", + "integrity": "sha1-Rru6nTZnVd99b63tD3zSWvfyZcI=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.11.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.11.0.tgz", + "integrity": "sha1-P3JUAfz7FuWyM1wQPNyUiIKWMtE=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-trace": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.11.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.11.0.tgz", + "integrity": "sha1-PhI+/y39o9qPXJOnxCsYXt/te8M=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.11.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/sdk-trace-base": "2.11.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha1-8/Rn42wnMy8Oc17IbNzXjdbyeGU=", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-22.20.1.tgz", + "integrity": "sha1-hOfN9jzaogwTSqMXzMkBqiHhbw4=", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=", + "license": "BSD-3-Clause" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha1-rg8PothQRe8UqBfao86azQSJ5b8=", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha1-bNV6sB6bCsB8uEfVPTybbuMfeuI=", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha1-v4F20a0M1y4PP1gzhZWhPhELyAQ=", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jws/-/jws-4.0.1.tgz", + "integrity": "sha1-B+3Bvo+sIOZ3soPs4mFJi9OPBpA=", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", + "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha1-Hq+fqb2x/dTsdfWPnNtOa3gn7sY=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/semver/-/semver-7.8.5.tgz", + "integrity": "sha1-ObZGA33VDBT7RR5+TKxY7YuGP2k=", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha1-aR0ArzkJvpOn+qE75hs6W1DvEss=", + "dev": true, + "license": "MIT" + } + } +} diff --git a/samples/agent365-s2s/package.json b/samples/agent365-s2s/package.json new file mode 100644 index 0000000..fb8e930 --- /dev/null +++ b/samples/agent365-s2s/package.json @@ -0,0 +1,27 @@ +{ + "name": "@microsoft/opentelemetry-agent365-s2s-sample", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Agent365 service-to-service observability sample", + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "build": "npm run clean && tsc -p tsconfig.json", + "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true });\"", + "start": "node dist/src/index.js", + "test": "npm run build && node --test dist/test/sample.test.js" + }, + "dependencies": { + "@azure/msal-node": "^6.0.0", + "@microsoft/opentelemetry": "file:../..", + "@opentelemetry/api": "^1.9.1" + }, + "devDependencies": { + "@opentelemetry/sdk-trace-base": "^2.10.0", + "@opentelemetry/sdk-trace-node": "^2.10.0", + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/samples/agent365-s2s/src/config.ts b/samples/agent365-s2s/src/config.ts new file mode 100644 index 0000000..33860ef --- /dev/null +++ b/samples/agent365-s2s/src/config.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFile } from "node:fs/promises"; + +export interface SampleConfig { + authority: URL; + blueprintClientId: string; + blueprintClientSecret: string; + tenantId: string; + agentId: string; + clusterCategory: "prod"; +} + +const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function invalid(key: string): Error { + return new Error(`Invalid sample configuration (${key}).`); +} + +function requiredString(input: Record, key: string): string { + const value = input[key]; + if (typeof value !== "string") { + throw invalid(key); + } + + const normalized = value.trim(); + if (normalized.length === 0 || (normalized.startsWith("<") && normalized.endsWith(">"))) { + throw invalid(key); + } + return normalized; +} + +function requiredGuid(input: Record, key: string): string { + const value = requiredString(input, key); + if (!GUID_PATTERN.test(value)) { + throw invalid(key); + } + return value; +} + +function parseAuthority(value: string): URL { + let authority: URL; + try { + authority = new URL(value); + } catch { + throw invalid("authority"); + } + + if ( + authority.protocol !== "https:" || + authority.pathname !== "/" || + authority.search !== "" || + authority.hash !== "" || + authority.username !== "" || + authority.password !== "" + ) { + throw invalid("authority"); + } + return authority; +} + +export function parseSampleConfig(value: unknown): SampleConfig { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw invalid("root"); + } + + const input = value as Record; + const clusterCategory = input.clusterCategory ?? "prod"; + if (clusterCategory !== "prod") { + throw invalid("clusterCategory"); + } + + return { + authority: parseAuthority(requiredString(input, "authority")), + blueprintClientId: requiredGuid(input, "blueprintClientId"), + blueprintClientSecret: requiredString(input, "blueprintClientSecret"), + tenantId: requiredGuid(input, "tenantId"), + agentId: requiredGuid(input, "agentId"), + clusterCategory, + }; +} + +export async function loadSampleConfig(path = "appsettings.json"): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + } catch { + throw new Error("Unable to load sample configuration."); + } + return parseSampleConfig(parsed); +} diff --git a/samples/agent365-s2s/src/index.ts b/samples/agent365-s2s/src/index.ts new file mode 100644 index 0000000..151b9f3 --- /dev/null +++ b/samples/agent365-s2s/src/index.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { pathToFileURL } from "node:url"; +import { + configureA365Logger, + shutdownMicrosoftOpenTelemetry, + useMicrosoftOpenTelemetry, + type MicrosoftOpenTelemetryOptions, +} from "@microsoft/opentelemetry"; + +import { loadSampleConfig, type SampleConfig } from "./config.js"; +import { safeConsoleLogger } from "./safeLogger.js"; +import { runScenario } from "./scenario.js"; +import { S2STokenProvider } from "./s2sTokenProvider.js"; +import { MsalTokenExchangeClient, OBSERVABILITY_SCOPES } from "./tokenExchangeClient.js"; + +interface TokenProvider { + resolve(agentId: string, tenantId: string, scopes?: string[]): Promise; +} + +export function createTelemetryOptions( + config: SampleConfig, + tokenProvider: TokenProvider, +): MicrosoftOpenTelemetryOptions { + return { + a365: { + enabled: true, + enableObservabilityExporter: true, + tokenResolver: (agentId, tenantId, scopes) => + tokenProvider.resolve(agentId, tenantId, scopes), + authScopes: [...OBSERVABILITY_SCOPES], + clusterCategory: "prod", + useS2SEndpoint: true, + }, + }; +} + +function safeFailureMessage(error: unknown): string { + if (!(error instanceof Error)) { + return "Agent365 S2S sample failed."; + } + if ( + /^(?:Invalid sample configuration \([A-Za-z]+\)|Unable to load sample configuration|(?:Blueprint|Agent) token exchange failed \([A-Za-z0-9_.-]+\))\.$/.test( + error.message, + ) + ) { + return error.message; + } + return "Agent365 S2S sample failed."; +} + +export async function main(configPath = "appsettings.json"): Promise { + configureA365Logger({ + logger: safeConsoleLogger, + logLevel: "info|warn|error", + }); + + let initialized = false; + try { + const config = await loadSampleConfig(configPath); + const tokenProvider = new S2STokenProvider(config, new MsalTokenExchangeClient(config)); + useMicrosoftOpenTelemetry(createTelemetryOptions(config, tokenProvider)); + initialized = true; + await runScenario(config); + } catch (error) { + safeConsoleLogger.error(`[S2S sample] ${safeFailureMessage(error)}`); + throw new Error("Agent365 S2S sample failed."); + } finally { + if (initialized) { + await shutdownMicrosoftOpenTelemetry(); + } + } +} + +const entryPoint = process.argv[1]; +if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { + void main(process.argv[2]).catch(() => { + process.exitCode = 1; + }); +} diff --git a/samples/agent365-s2s/src/s2sTokenProvider.ts b/samples/agent365-s2s/src/s2sTokenProvider.ts new file mode 100644 index 0000000..6d4be0e --- /dev/null +++ b/samples/agent365-s2s/src/s2sTokenProvider.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { SampleConfig } from "./config.js"; +import type { TokenExchangeClient, TokenExchangeResult } from "./tokenExchangeClient.js"; + +interface CacheEntry { + token?: TokenExchangeResult; + inFlight?: Promise; +} + +const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const REFRESH_WINDOW_MILLISECONDS = 60_000; + +function normalizeGuid(value: string, key: "agentId" | "tenantId"): string { + const normalized = value.trim().toLowerCase(); + if (!GUID_PATTERN.test(normalized)) { + throw new Error(`S2S token identity mismatch (${key}).`); + } + return normalized; +} + +export class S2STokenProvider { + private readonly cache = new Map(); + private readonly configuredAgentId: string; + private readonly configuredTenantId: string; + + public constructor( + config: SampleConfig, + private readonly exchangeClient: TokenExchangeClient, + private readonly now: () => number = Date.now, + ) { + this.configuredAgentId = normalizeGuid(config.agentId, "agentId"); + this.configuredTenantId = normalizeGuid(config.tenantId, "tenantId"); + } + + public async resolve(agentId: string, tenantId: string, _scopes?: string[]): Promise { + const normalizedAgentId = normalizeGuid(agentId, "agentId"); + const normalizedTenantId = normalizeGuid(tenantId, "tenantId"); + if (normalizedAgentId !== this.configuredAgentId) { + throw new Error("S2S token identity mismatch (agentId)."); + } + if (normalizedTenantId !== this.configuredTenantId) { + throw new Error("S2S token identity mismatch (tenantId)."); + } + + const key = `${normalizedTenantId}:${normalizedAgentId}`; + let entry = this.cache.get(key); + if (!entry) { + entry = {}; + this.cache.set(key, entry); + } + + if (entry.token && entry.token.expiresOn.getTime() > this.now() + REFRESH_WINDOW_MILLISECONDS) { + return entry.token.accessToken; + } + + if (!entry.inFlight) { + entry.inFlight = this.exchangeClient.exchange(); + } + + try { + const token = await entry.inFlight; + entry.token = token; + return token.accessToken; + } finally { + entry.inFlight = undefined; + } + } +} diff --git a/samples/agent365-s2s/src/safeLogger.ts b/samples/agent365-s2s/src/safeLogger.ts new file mode 100644 index 0000000..ecf9b15 --- /dev/null +++ b/samples/agent365-s2s/src/safeLogger.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ILogger } from "@microsoft/opentelemetry"; + +export const safeConsoleLogger: ILogger = { + info(message: string): void { + console.info(message); + }, + warn(message: string): void { + console.warn(message); + }, + error(message: string): void { + console.error(message); + }, +}; diff --git a/samples/agent365-s2s/src/scenario.ts b/samples/agent365-s2s/src/scenario.ts new file mode 100644 index 0000000..7d3bdd4 --- /dev/null +++ b/samples/agent365-s2s/src/scenario.ts @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ExecuteToolScope, + FinishReason, + InferenceOperationType, + InferenceScope, + InvokeAgentScope, + MessageRole, + type AgentDetails, + type A365Request, + type CallerDetails, +} from "@microsoft/opentelemetry"; + +import type { SampleConfig } from "./config.js"; + +function at(startMilliseconds: number, offsetMilliseconds: number): Date { + return new Date(startMilliseconds + offsetMilliseconds); +} + +export async function runScenario( + config: Pick, + startMilliseconds = Date.now(), +): Promise { + const agentDetails: AgentDetails = { + agentId: config.agentId, + tenantId: config.tenantId, + agentName: "Synthetic Weather Agent", + agentDescription: "Publishes deterministic sample telemetry only", + agentAUID: "synthetic-agentic-user-id", + agentEmail: "synthetic-agent@invalid.example", + agentBlueprintId: "66666666-6666-4666-8666-666666666666", + providerName: "sample", + agentVersion: "1.0.0", + }; + const callerDetails: CallerDetails = { + userDetails: { + userId: "synthetic-publisher-user", + userName: "Synthetic Publisher", + userEmail: "synthetic-publisher@invalid.example", + tenantId: config.tenantId, + }, + callerAgentDetails: { + agentId: "44444444-4444-4444-8444-444444444444", + agentName: "Synthetic Publishing Agent", + agentAUID: "synthetic-publishing-agent-user", + agentEmail: "synthetic-publishing-agent@invalid.example", + agentBlueprintId: "77777777-7777-4777-8777-777777777777", + platformId: "agent365-s2s-sample", + agentVersion: "1.0.0", + tenantId: config.tenantId, + }, + }; + const request: A365Request = { + conversationId: "synthetic-conversation", + sessionId: "synthetic-session", + channel: { + id: "synthetic-channel", + name: "Agent365 S2S sample", + description: "Synthetic local scenario", + }, + content: "What is the weather in Seattle?", + }; + + const invoke = InvokeAgentScope.start( + request, + { endpoint: { host: "synthetic-agent.invalid", port: 443 } }, + agentDetails, + callerDetails, + { startTime: at(startMilliseconds, 0), endTime: at(startMilliseconds, 400) }, + ); + + try { + await invoke.withActiveSpanAsync(async () => { + const firstInference = InferenceScope.start( + request, + { + operationName: InferenceOperationType.CHAT, + model: "synthetic-tool-selector", + providerName: "sample", + endpoint: { host: "synthetic-model.invalid", port: 443 }, + }, + agentDetails, + callerDetails.userDetails, + { + startTime: at(startMilliseconds, 10), + endTime: at(startMilliseconds, 110), + }, + ); + firstInference.recordInputMessages(["Select a tool for the synthetic weather request."]); + firstInference.recordOutputMessages({ + messages: [ + { + role: MessageRole.ASSISTANT, + finish_reason: FinishReason.TOOL_CALL, + parts: [ + { + type: "tool_call", + id: "synthetic-tool-call", + name: "lookup_weather", + arguments: { city: "Seattle" }, + }, + ], + }, + ], + }); + firstInference.recordInputTokens(48); + firstInference.recordOutputTokens(18); + firstInference.recordFinishReasons([FinishReason.TOOL_CALL]); + firstInference.dispose(); + + const tool = ExecuteToolScope.start( + request, + { + toolName: "lookup_weather", + toolCallId: "synthetic-tool-call", + toolType: "function", + description: "Returns deterministic synthetic weather", + arguments: { city: "Seattle" }, + }, + agentDetails, + callerDetails.userDetails, + { + startTime: at(startMilliseconds, 130), + endTime: at(startMilliseconds, 180), + }, + ); + tool.recordResponse({ + condition: "sunny", + temperatureFahrenheit: 72, + }); + tool.dispose(); + + const finalInference = InferenceScope.start( + request, + { + operationName: InferenceOperationType.CHAT, + model: "synthetic-response-writer", + providerName: "sample", + endpoint: { host: "synthetic-model.invalid", port: 443 }, + }, + agentDetails, + callerDetails.userDetails, + { + startTime: at(startMilliseconds, 200), + endTime: at(startMilliseconds, 300), + }, + ); + finalInference.recordInputMessages([ + "The synthetic tool returned sunny and 72 degrees Fahrenheit.", + ]); + finalInference.recordOutputMessages({ + messages: [ + { + role: MessageRole.ASSISTANT, + finish_reason: FinishReason.STOP, + parts: [ + { + type: "text", + content: "The synthetic weather is sunny and 72°F in Seattle.", + }, + ], + }, + ], + }); + finalInference.recordInputTokens(32); + finalInference.recordOutputTokens(14); + finalInference.recordFinishReasons([FinishReason.STOP]); + finalInference.dispose(); + }); + + invoke.recordResponse("The synthetic weather is sunny and 72°F in Seattle."); + } finally { + invoke.dispose(); + } +} diff --git a/samples/agent365-s2s/src/tokenExchangeClient.ts b/samples/agent365-s2s/src/tokenExchangeClient.ts new file mode 100644 index 0000000..fae481b --- /dev/null +++ b/samples/agent365-s2s/src/tokenExchangeClient.ts @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ConfidentialClientApplication, + type AuthenticationResult, + type ClientCredentialRequest, + type Configuration, +} from "@azure/msal-node"; + +import type { SampleConfig } from "./config.js"; + +export const TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default"; +export const OBSERVABILITY_SCOPES = [ + "api://9b975845-388f-4429-889e-eab1ef63949c/.default", +] as const; + +export interface TokenExchangeResult { + accessToken: string; + expiresOn: Date; +} + +export interface TokenExchangeClient { + exchange(): Promise; +} + +type MsalCredentialResult = Pick; + +export interface ConfidentialClientLike { + acquireTokenByClientCredential( + request: ClientCredentialRequest, + ): Promise; +} + +export type ConfidentialClientFactory = (configuration: Configuration) => ConfidentialClientLike; + +const defaultFactory: ConfidentialClientFactory = (configuration) => + new ConfidentialClientApplication(configuration); + +function safeErrorCode(error: unknown): string { + if (error && typeof error === "object" && "errorCode" in error) { + const errorCode = (error as { errorCode?: unknown }).errorCode; + if (typeof errorCode === "string" && /^[a-z0-9_.-]{1,64}$/i.test(errorCode)) { + return errorCode; + } + } + return "unknown_error"; +} + +function stageError(stage: "Blueprint" | "Agent", errorCode: string): Error { + return new Error(`${stage} token exchange failed (${errorCode}).`); +} + +export class MsalTokenExchangeClient implements TokenExchangeClient { + public constructor( + private readonly config: SampleConfig, + private readonly createClient: ConfidentialClientFactory = defaultFactory, + ) {} + + public async exchange(): Promise { + const authority = `${this.config.authority.origin}/${this.config.tenantId}`; + let blueprintResult: MsalCredentialResult | null; + try { + const blueprintClient = this.createClient({ + auth: { + authority, + clientId: this.config.blueprintClientId, + clientSecret: this.config.blueprintClientSecret, + }, + }); + blueprintResult = await blueprintClient.acquireTokenByClientCredential({ + scopes: [TOKEN_EXCHANGE_SCOPE], + fmiPath: this.config.agentId, + }); + } catch (error) { + throw stageError("Blueprint", safeErrorCode(error)); + } + if (!blueprintResult?.accessToken) { + throw stageError("Blueprint", "empty_result"); + } + + let agentResult: MsalCredentialResult | null; + try { + const agentClient = this.createClient({ + auth: { + authority, + clientId: this.config.agentId, + clientAssertion: blueprintResult.accessToken, + }, + }); + agentResult = await agentClient.acquireTokenByClientCredential({ + scopes: [...OBSERVABILITY_SCOPES], + }); + } catch (error) { + throw stageError("Agent", safeErrorCode(error)); + } + if ( + !agentResult?.accessToken || + !(agentResult.expiresOn instanceof Date) || + !Number.isFinite(agentResult.expiresOn.getTime()) + ) { + throw stageError("Agent", "empty_result"); + } + + return { + accessToken: agentResult.accessToken, + expiresOn: agentResult.expiresOn, + }; + } +} diff --git a/samples/agent365-s2s/test/sample.test.ts b/samples/agent365-s2s/test/sample.test.ts new file mode 100644 index 0000000..d3a4dbd --- /dev/null +++ b/samples/agent365-s2s/test/sample.test.ts @@ -0,0 +1,502 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { trace } from "@opentelemetry/api"; +import { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; +import { configureA365Logger, type ILogger } from "@microsoft/opentelemetry"; + +import { loadSampleConfig, parseSampleConfig } from "../src/config.js"; +import { createTelemetryOptions } from "../src/index.js"; +import { safeConsoleLogger } from "../src/safeLogger.js"; +import { runScenario } from "../src/scenario.js"; +import { S2STokenProvider } from "../src/s2sTokenProvider.js"; +import { + MsalTokenExchangeClient, + OBSERVABILITY_SCOPES, + TOKEN_EXCHANGE_SCOPE, + type ConfidentialClientFactory, + type TokenExchangeClient, +} from "../src/tokenExchangeClient.js"; + +const TENANT_ID = "11111111-1111-4111-8111-111111111111"; +const BLUEPRINT_CLIENT_ID = "22222222-2222-4222-8222-222222222222"; +const AGENT_ID = "33333333-3333-4333-8333-333333333333"; +const TEST_DIRECTORY = join(process.cwd(), ".test-tmp"); + +function validConfigObject(): Record { + return { + authority: "https://login.microsoftonline.com", + blueprintClientId: BLUEPRINT_CLIENT_ID, + blueprintClientSecret: "sample-client-secret", + tenantId: TENANT_ID, + agentId: AGENT_ID, + }; +} + +after(async () => { + await rm(TEST_DIRECTORY, { recursive: true, force: true }); +}); + +describe("sample configuration", () => { + it("parses valid settings and defaults the cluster to prod", () => { + const config = parseSampleConfig(validConfigObject()); + + assert.equal(config.authority.href, "https://login.microsoftonline.com/"); + assert.equal(config.blueprintClientId, BLUEPRINT_CLIENT_ID); + assert.equal(config.blueprintClientSecret, "sample-client-secret"); + assert.equal(config.tenantId, TENANT_ID); + assert.equal(config.agentId, AGENT_ID); + assert.equal(config.clusterCategory, "prod"); + }); + + it("loads configuration from a JSON file", async () => { + await mkdir(TEST_DIRECTORY, { recursive: true }); + const path = join(TEST_DIRECTORY, "appsettings.json"); + await writeFile(path, JSON.stringify({ ...validConfigObject(), clusterCategory: "prod" })); + + const config = await loadSampleConfig(path); + + assert.equal(config.agentId, AGENT_ID); + assert.equal(config.clusterCategory, "prod"); + }); + + for (const key of [ + "authority", + "blueprintClientId", + "blueprintClientSecret", + "tenantId", + "agentId", + ]) { + it(`rejects missing ${key} without exposing another setting`, () => { + const input = validConfigObject(); + delete input[key]; + + assert.throws( + () => parseSampleConfig(input), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, new RegExp(key, "i")); + assert.doesNotMatch(error.message, /sample-client-secret/); + return true; + }, + ); + }); + + it(`rejects placeholder ${key} without echoing it`, () => { + const input = { ...validConfigObject(), [key]: `` }; + + assert.throws( + () => parseSampleConfig(input), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, new RegExp(key, "i")); + assert.doesNotMatch(error.message, /replace-|secret-value/); + return true; + }, + ); + }); + } + + for (const authority of [ + "http://login.microsoftonline.com", + "https://login.microsoftonline.com/common", + "https://login.microsoftonline.com/?query=unsafe", + ]) { + it(`rejects the non-HTTPS or non-root authority ${authority}`, () => { + assert.throws(() => parseSampleConfig({ ...validConfigObject(), authority }), /authority/i); + }); + } + + for (const [key, value] of [ + ["tenantId", "not-a-guid"], + ["agentId", "agent-name"], + ["blueprintClientId", "blueprint-name"], + ] as const) { + it(`rejects invalid ${key} GUIDs`, () => { + assert.throws( + () => parseSampleConfig({ ...validConfigObject(), [key]: value }), + new RegExp(key, "i"), + ); + }); + } + + it("rejects unsupported cluster values without echoing them", () => { + assert.throws( + () => parseSampleConfig({ ...validConfigObject(), clusterCategory: "secret-environment" }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /clusterCategory/i); + assert.doesNotMatch(error.message, /secret-environment/); + return true; + }, + ); + }); +}); + +describe("two-stage MSAL token exchange", () => { + it("uses the blueprint token as the agent application client assertion", async () => { + const configurations: Array> = []; + const requests: Array> = []; + const expiresOn = new Date("2030-01-01T00:00:00.000Z"); + let clientNumber = 0; + const factory: ConfidentialClientFactory = (configuration) => { + configurations.push(configuration as unknown as Record); + const currentClient = clientNumber++; + return { + acquireTokenByClientCredential: async (request) => { + requests.push(request as unknown as Record); + return currentClient === 0 + ? { accessToken: "blueprint-exchange-token", expiresOn } + : { accessToken: "agent-observability-token", expiresOn }; + }, + }; + }; + const config = parseSampleConfig(validConfigObject()); + const client = new MsalTokenExchangeClient(config, factory); + + const result = await client.exchange(); + + assert.deepEqual(requests[0], { + scopes: [TOKEN_EXCHANGE_SCOPE], + fmiPath: AGENT_ID, + }); + assert.deepEqual(requests[1], { scopes: [...OBSERVABILITY_SCOPES] }); + assert.deepEqual(configurations[0], { + auth: { + authority: `https://login.microsoftonline.com/${TENANT_ID}`, + clientId: BLUEPRINT_CLIENT_ID, + clientSecret: "sample-client-secret", + }, + }); + assert.deepEqual(configurations[1], { + auth: { + authority: `https://login.microsoftonline.com/${TENANT_ID}`, + clientId: AGENT_ID, + clientAssertion: "blueprint-exchange-token", + }, + }); + assert.deepEqual(result, { + accessToken: "agent-observability-token", + expiresOn, + }); + }); +}); + +describe("S2STokenProvider", () => { + it("reuses a cached token only outside the 60-second refresh window", async () => { + let now = Date.parse("2029-01-01T00:00:00.000Z"); + let exchangeCount = 0; + const client: TokenExchangeClient = { + exchange: async () => ({ + accessToken: `token-${++exchangeCount}`, + expiresOn: new Date(now + 120_000), + }), + }; + const config = parseSampleConfig(validConfigObject()); + const provider = new S2STokenProvider(config, client, () => now); + + assert.equal(await provider.resolve(AGENT_ID, TENANT_ID), "token-1"); + now += 59_999; + assert.equal(await provider.resolve(AGENT_ID, TENANT_ID), "token-1"); + now += 1; + assert.equal(await provider.resolve(AGENT_ID, TENANT_ID), "token-2"); + assert.equal(exchangeCount, 2); + }); + + it("uses one in-flight exchange for concurrent callers", async () => { + let completeExchange: ((value: { accessToken: string; expiresOn: Date }) => void) | undefined; + let exchangeCount = 0; + const client: TokenExchangeClient = { + exchange: () => { + exchangeCount++; + return new Promise((resolve) => { + completeExchange = resolve; + }); + }, + }; + const config = parseSampleConfig(validConfigObject()); + const provider = new S2STokenProvider(config, client); + + const resolutions = [ + provider.resolve(AGENT_ID, TENANT_ID), + provider.resolve(AGENT_ID.toUpperCase(), TENANT_ID.toUpperCase()), + provider.resolve(AGENT_ID, TENANT_ID, [...OBSERVABILITY_SCOPES]), + ]; + await Promise.resolve(); + assert.equal(exchangeCount, 1); + completeExchange?.({ + accessToken: "shared-token", + expiresOn: new Date(Date.now() + 120_000), + }); + + assert.deepEqual(await Promise.all(resolutions), [ + "shared-token", + "shared-token", + "shared-token", + ]); + }); + + it("clears a failed in-flight exchange so a later call retries", async () => { + let exchangeCount = 0; + const client: TokenExchangeClient = { + exchange: async () => { + exchangeCount++; + if (exchangeCount === 1) { + throw new Error("Blueprint token exchange failed (temporarily_unavailable)."); + } + return { + accessToken: "retry-token", + expiresOn: new Date(Date.now() + 120_000), + }; + }, + }; + const config = parseSampleConfig(validConfigObject()); + const provider = new S2STokenProvider(config, client); + + await assert.rejects(provider.resolve(AGENT_ID, TENANT_ID), /temporarily_unavailable/); + assert.equal(await provider.resolve(AGENT_ID, TENANT_ID), "retry-token"); + assert.equal(exchangeCount, 2); + }); + + for (const [agentId, tenantId, expectedKey] of [ + ["44444444-4444-4444-8444-444444444444", TENANT_ID, "agentId"], + [AGENT_ID, "55555555-5555-4555-8555-555555555555", "tenantId"], + ["not-a-guid", TENANT_ID, "agentId"], + ]) { + it(`rejects ${expectedKey} identity mismatch before MSAL`, async () => { + let exchangeCount = 0; + const client: TokenExchangeClient = { + exchange: async () => { + exchangeCount++; + throw new Error("MSAL must not run"); + }, + }; + const provider = new S2STokenProvider(parseSampleConfig(validConfigObject()), client); + + await assert.rejects(provider.resolve(agentId, tenantId), new RegExp(expectedKey, "i")); + assert.equal(exchangeCount, 0); + }); + } +}); + +describe("safe logger and package exports", () => { + it("exposes the A365 logger API from the root package", () => { + const logger: ILogger = safeConsoleLogger; + assert.equal(logger, safeConsoleLogger); + assert.equal(typeof configureA365Logger, "function"); + }); + + it("writes only the formatted message and ignores unsafe additional arguments", () => { + const original = { + info: console.info, + warn: console.warn, + error: console.error, + }; + const output: string[] = []; + console.info = (message?: unknown) => output.push(String(message)); + console.warn = (message?: unknown) => output.push(String(message)); + console.error = (message?: unknown) => output.push(String(message)); + + try { + safeConsoleLogger.info("[S2S sample] Token cache refreshed.", { + accessToken: "unsafe-token", + }); + safeConsoleLogger.warn("[S2S sample] Retry scheduled.", "raw-secret-message"); + safeConsoleLogger.error("[S2S sample] Authentication failed (invalid_client).", { + clientSecret: "unsafe-secret", + stack: "unsafe-stack", + nested: new Error("nested-error-message"), + }); + } finally { + console.info = original.info; + console.warn = original.warn; + console.error = original.error; + } + + assert.deepEqual(output, [ + "[S2S sample] Token cache refreshed.", + "[S2S sample] Retry scheduled.", + "[S2S sample] Authentication failed (invalid_client).", + ]); + assert.doesNotMatch(output.join("\n"), /unsafe|raw-secret|nested-error/); + }); +}); + +describe("deterministic four-span scenario", () => { + it("configures the explicit S2S exporter and observability scope", () => { + const config = parseSampleConfig(validConfigObject()); + const tokenProvider = { + resolve: async () => "token", + }; + + const options = createTelemetryOptions(config, tokenProvider); + + assert.equal(options.a365?.enabled, true); + assert.equal(options.a365?.enableObservabilityExporter, true); + assert.equal(options.a365?.useS2SEndpoint, true); + assert.equal(options.a365?.clusterCategory, "prod"); + assert.deepEqual(options.a365?.authScopes, [...OBSERVABILITY_SCOPES]); + assert.equal(typeof options.a365?.tokenResolver, "function"); + }); + + it("emits exactly four deterministic spans in one trace with direct child parentage", async () => { + const exporter = new InMemorySpanExporter(); + const provider = new NodeTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(exporter)], + }); + const scenarioStart = Date.parse("2029-01-01T00:00:00.000Z"); + trace.disable(); + provider.register(); + + try { + await runScenario(parseSampleConfig(validConfigObject()), scenarioStart); + await provider.forceFlush(); + const spans = exporter + .getFinishedSpans() + .sort( + (left, right) => + left.startTime[0] - right.startTime[0] || left.startTime[1] - right.startTime[1], + ); + + assert.equal(spans.length, 4); + const [invoke, toolSelectingInference, executeTool, finalInference] = spans; + assert.deepEqual( + spans.map((span) => span.attributes["gen_ai.operation.name"]), + ["invoke_agent", "Chat", "execute_tool", "Chat"], + ); + assert.equal(new Set(spans.map((span) => span.spanContext().traceId)).size, 1); + assert.equal(invoke.parentSpanContext, undefined); + assert.equal(invoke.startTime[0] * 1_000 + invoke.startTime[1] / 1_000_000, scenarioStart); + for (const child of spans.slice(1)) { + assert.equal(child.parentSpanContext?.spanId, invoke.spanContext().spanId); + } + + assert.equal(invoke.attributes["gen_ai.agent.id"], AGENT_ID); + assert.equal(invoke.attributes["microsoft.tenant.id"], TENANT_ID); + assert.equal( + invoke.attributes["microsoft.a365.caller.agent.id"], + "44444444-4444-4444-8444-444444444444", + ); + assert.equal(invoke.attributes["user.id"], "synthetic-publisher-user"); + + assert.equal(toolSelectingInference.attributes["gen_ai.usage.input_tokens"], 48); + assert.equal(toolSelectingInference.attributes["gen_ai.usage.output_tokens"], 18); + assert.deepEqual(toolSelectingInference.attributes["gen_ai.response.finish_reasons"], [ + "tool_call", + ]); + assert.equal(finalInference.attributes["gen_ai.usage.input_tokens"], 32); + assert.equal(finalInference.attributes["gen_ai.usage.output_tokens"], 14); + assert.deepEqual(finalInference.attributes["gen_ai.response.finish_reasons"], ["stop"]); + + assert.equal(executeTool.attributes["gen_ai.tool.name"], "lookup_weather"); + assert.equal( + executeTool.attributes["gen_ai.tool.call.arguments"], + JSON.stringify({ city: "Seattle" }), + ); + assert.equal( + executeTool.attributes["gen_ai.tool.call.result"], + JSON.stringify({ condition: "sunny", temperatureFahrenheit: 72 }), + ); + + const durations = spans.map( + (span) => span.duration[0] * 1_000 + span.duration[1] / 1_000_000, + ); + assert.deepEqual(durations, [400, 100, 50, 100]); + } finally { + await provider.shutdown(); + trace.disable(); + } + }); +}); + +describe("sanitized MSAL failures", () => { + it("sanitizes client-construction failures at the correct stage", async () => { + let clientNumber = 0; + const factory: ConfidentialClientFactory = () => { + if (clientNumber++ === 1) { + throw { + errorCode: "invalid_client", + message: "constructor exposed sample-client-secret", + }; + } + return { + acquireTokenByClientCredential: async () => ({ + accessToken: "blueprint-exchange-token", + expiresOn: new Date("2030-01-01T00:00:00.000Z"), + }), + }; + }; + const client = new MsalTokenExchangeClient(parseSampleConfig(validConfigObject()), factory); + + await assert.rejects( + client.exchange(), + new Error("Agent token exchange failed (invalid_client)."), + ); + }); + + it("sanitizes blueprint-stage MSAL failures", async () => { + const factory: ConfidentialClientFactory = () => ({ + acquireTokenByClientCredential: async () => { + throw { + errorCode: "invalid_client", + message: "raw failure with sample-client-secret", + stack: "raw-stack-with-token", + nested: { accessToken: "nested-token" }, + }; + }, + }); + const client = new MsalTokenExchangeClient(parseSampleConfig(validConfigObject()), factory); + + await assert.rejects(client.exchange(), (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, "Blueprint token exchange failed (invalid_client)."); + assert.equal(error.cause, undefined); + assert.doesNotMatch(error.message, /sample-client-secret|raw|nested/i); + return true; + }); + }); + + it("sanitizes agent-stage MSAL failures", async () => { + let clientNumber = 0; + const factory: ConfidentialClientFactory = () => { + const currentClient = clientNumber++; + return { + acquireTokenByClientCredential: async () => { + if (currentClient === 0) { + return { + accessToken: "blueprint-exchange-token", + expiresOn: new Date("2030-01-01T00:00:00.000Z"), + }; + } + throw new Error("agent-stage-secret-and-stack"); + }, + }; + }; + const client = new MsalTokenExchangeClient(parseSampleConfig(validConfigObject()), factory); + + await assert.rejects(client.exchange(), (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, "Agent token exchange failed (unknown_error)."); + assert.equal(error.cause, undefined); + assert.doesNotMatch(error.message, /secret|stack/); + return true; + }); + }); + + it("treats missing MSAL results as sanitized stage failures", async () => { + const factory: ConfidentialClientFactory = () => ({ + acquireTokenByClientCredential: async () => null, + }); + const client = new MsalTokenExchangeClient(parseSampleConfig(validConfigObject()), factory); + + await assert.rejects( + client.exchange(), + new Error("Blueprint token exchange failed (empty_result)."), + ); + }); +}); diff --git a/samples/agent365-s2s/tsconfig.json b/samples/agent365-s2s/tsconfig.json new file mode 100644 index 0000000..37eeca6 --- /dev/null +++ b/samples/agent365-s2s/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/src/a365/exporter/Agent365Exporter.ts b/src/a365/exporter/Agent365Exporter.ts index 028b561..7d03b7e 100644 --- a/src/a365/exporter/Agent365Exporter.ts +++ b/src/a365/exporter/Agent365Exporter.ts @@ -529,7 +529,7 @@ export class Agent365Exporter implements SpanExporter { const url = this.buildReplayUrl(record); const stats = createRequestStats(url); const requestStart = Date.now(); - let correlationId = "unknown"; + let correlationId = "N/A"; const headers: Record = { "content-type": "application/json", "x-ms-tenant-id": record.tenantId, @@ -550,11 +550,14 @@ export class Agent365Exporter implements SpanExporter { correlationId = response.headers.get("x-ms-correlation-id") ?? response.headers.get("x-correlation-id") ?? - "unknown"; + "N/A"; recordResponseStats(stats, response.status, requestStart); if (response.status >= 200 && response.status < 300) { + this.logger.info( + `[Agent365Exporter] HTTP ${response.status} success. Correlation ID: ${correlationId}.`, + ); return { kind: "success", correlationId }; } if ( @@ -586,7 +589,7 @@ export class Agent365Exporter implements SpanExporter { body: string, headers: Record, ): Promise<{ ok: boolean; correlationId: string }> { - let lastCorrelationId = "unknown"; + let lastCorrelationId = "N/A"; // Resolve the short host (and the SDKStats kill-switch) once per call // so each retry attempt records under the same key without re-parsing @@ -607,12 +610,15 @@ export class Agent365Exporter implements SpanExporter { const correlationId = response.headers.get("x-ms-correlation-id") ?? response.headers.get("x-correlation-id") ?? - "unknown"; + "N/A"; lastCorrelationId = correlationId; recordResponseStats(stats, response.status, requestStart); if (response.status >= 200 && response.status < 300) { + this.logger.info( + `[Agent365Exporter] HTTP ${response.status} success. Correlation ID: ${correlationId}.`, + ); return { ok: true, correlationId }; } diff --git a/src/index.ts b/src/index.ts index e5f4564..8f97d19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,9 @@ export type { // ── Re-exports from A365 configuration ────────────────────────────────────── export { A365Configuration } from "./a365/index.js"; +export { configureA365Logger } from "./a365/index.js"; export type { ClusterCategory } from "./a365/index.js"; +export type { ILogger } from "./a365/index.js"; export { Agent365Exporter } from "./a365/index.js"; export type { Agent365ExporterOptions, diff --git a/test/internal/unit/a365/agent365Exporter.test.ts b/test/internal/unit/a365/agent365Exporter.test.ts index 1712e74..8092e19 100644 --- a/test/internal/unit/a365/agent365Exporter.test.ts +++ b/test/internal/unit/a365/agent365Exporter.test.ts @@ -216,6 +216,46 @@ describe("Agent365Exporter", () => { assert.strictEqual(options.headers["content-type"], "application/json"); }); + it("logs successful HTTP status and correlation ID without unsafe response data", async () => { + const customLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + configureA365Logger({ logger: customLogger, logLevel: "info|warn|error" }); + fetchSpy.mockResolvedValue({ + status: 202, + headers: new Headers({ "x-ms-correlation-id": "corr-safe-123" }), + json: () => ({ accessToken: "must-not-be-logged" }), + }); + + const exporter = createTestExporter({ tokenResolver: () => "secret-token" }); + const result = await exportResult(exporter, [makeSpan()]); + + assert.strictEqual(result, ExportResultCode.SUCCESS); + const output = customLogger.info.mock.calls.flat().map(String).join("\n"); + assert.include(output, "[Agent365Exporter] HTTP 202 success. Correlation ID: corr-safe-123."); + assert.notInclude(output, "secret-token"); + assert.notInclude(output, "must-not-be-logged"); + }); + + it("logs N/A when a successful HTTP response has no correlation ID", async () => { + const customLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + configureA365Logger({ logger: customLogger, logLevel: "info|warn|error" }); + fetchSpy.mockResolvedValue({ status: 204, headers: new Headers() }); + + const exporter = createTestExporter({ tokenResolver: () => "secret-token" }); + const result = await exportResult(exporter, [makeSpan()]); + + assert.strictEqual(result, ExportResultCode.SUCCESS); + const output = customLogger.info.mock.calls.flat().map(String).join("\n"); + assert.include(output, "[Agent365Exporter] HTTP 204 success. Correlation ID: N/A."); + }); + it("should use provided token resolver and set authorization header", async () => { const token = "abc123"; const exporter = createTestExporter({ @@ -1260,6 +1300,35 @@ describe("Agent365Exporter", () => { }); describe("durable delivery", () => { + it("logs successful durable HTTP status and correlation ID", async () => { + const directory = await createStorageDirectory(); + const customLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + configureA365Logger({ logger: customLogger, logLevel: "info|warn|error" }); + fetchSpy.mockResolvedValue({ + status: 201, + headers: new Headers({ "x-correlation-id": "durable-corr-123" }), + }); + const exporter = new Agent365Exporter({ + tokenResolver: () => "durable-secret-token", + durableDelivery: { enabled: true, storageDirectory: directory }, + }); + + const result = await exportResult(exporter, [makeSpan()]); + + assert.strictEqual(result, ExportResultCode.SUCCESS); + const output = customLogger.info.mock.calls.flat().map(String).join("\n"); + assert.include( + output, + "[Agent365Exporter] HTTP 201 success. Correlation ID: durable-corr-123.", + ); + assert.notInclude(output, "durable-secret-token"); + await exporter.shutdown(); + }); + it("hands a retryable failure to durable storage after one attempt", async () => { const directory = await createStorageDirectory(); fetchSpy.mockResolvedValue({ From a126d2929b3f8f654da596756a3e25fe15a23c41 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 15 Sep 2026 15:31:54 -0600 Subject: [PATCH 02/10] fix(samples): harden S2S refresh handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- .github/workflows/pr-validation.yml | 6 ++ samples/agent365-s2s/README.md | 7 ++ samples/agent365-s2s/package.json | 4 + samples/agent365-s2s/src/index.ts | 2 +- samples/agent365-s2s/src/s2sTokenProvider.ts | 10 +-- samples/agent365-s2s/test/sample.test.ts | 85 ++++++++++++++++++++ src/sdkstats/metrics.ts | 2 +- test/internal/unit/sdkstats/metrics.test.ts | 2 +- 8 files changed, 110 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 613a489..6f5897f 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -30,6 +30,12 @@ jobs: - name: Install Agent365 S2S sample dependencies run: npm ci --prefix samples/agent365-s2s + - name: Format Agent365 S2S sample + run: npm run --prefix samples/agent365-s2s format + + - name: Lint Agent365 S2S sample + run: npm run --prefix samples/agent365-s2s lint + - name: Build Agent365 S2S sample run: npm run --prefix samples/agent365-s2s build diff --git a/samples/agent365-s2s/README.md b/samples/agent365-s2s/README.md index 7c51f1c..1f8e206 100644 --- a/samples/agent365-s2s/README.md +++ b/samples/agent365-s2s/README.md @@ -103,3 +103,10 @@ Run the focused tests with: ```powershell npm test ``` + +The sample also reuses the repository's root Prettier and ESLint configuration: + +```powershell +npm run format +npm run lint +``` diff --git a/samples/agent365-s2s/package.json b/samples/agent365-s2s/package.json index fb8e930..9ceb1d2 100644 --- a/samples/agent365-s2s/package.json +++ b/samples/agent365-s2s/package.json @@ -10,6 +10,10 @@ "scripts": { "build": "npm run clean && tsc -p tsconfig.json", "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true });\"", + "format": "node ../../node_modules/prettier/bin/prettier.cjs --check --config ../../.prettierrc.json --ignore-path ../../.prettierignore \"src/**/*.ts\" \"test/**/*.ts\" \"*.json\"", + "format:fix": "node ../../node_modules/prettier/bin/prettier.cjs --write --config ../../.prettierrc.json --ignore-path ../../.prettierignore \"src/**/*.ts\" \"test/**/*.ts\" \"*.json\"", + "lint": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs src test", + "lint:fix": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs src test --fix", "start": "node dist/src/index.js", "test": "npm run build && node --test dist/test/sample.test.js" }, diff --git a/samples/agent365-s2s/src/index.ts b/samples/agent365-s2s/src/index.ts index 151b9f3..ffed939 100644 --- a/samples/agent365-s2s/src/index.ts +++ b/samples/agent365-s2s/src/index.ts @@ -65,7 +65,7 @@ export async function main(configPath = "appsettings.json"): Promise { await runScenario(config); } catch (error) { safeConsoleLogger.error(`[S2S sample] ${safeFailureMessage(error)}`); - throw new Error("Agent365 S2S sample failed."); + throw new Error("Agent365 S2S sample failed.", { cause: error }); } finally { if (initialized) { await shutdownMicrosoftOpenTelemetry(); diff --git a/samples/agent365-s2s/src/s2sTokenProvider.ts b/samples/agent365-s2s/src/s2sTokenProvider.ts index 6d4be0e..2e862bd 100644 --- a/samples/agent365-s2s/src/s2sTokenProvider.ts +++ b/samples/agent365-s2s/src/s2sTokenProvider.ts @@ -55,16 +55,16 @@ export class S2STokenProvider { return entry.token.accessToken; } - if (!entry.inFlight) { - entry.inFlight = this.exchangeClient.exchange(); - } + const inFlight = entry.inFlight ?? (entry.inFlight = this.exchangeClient.exchange()); try { - const token = await entry.inFlight; + const token = await inFlight; entry.token = token; return token.accessToken; } finally { - entry.inFlight = undefined; + if (entry.inFlight === inFlight) { + entry.inFlight = undefined; + } } } } diff --git a/samples/agent365-s2s/test/sample.test.ts b/samples/agent365-s2s/test/sample.test.ts index d3a4dbd..fdad1cb 100644 --- a/samples/agent365-s2s/test/sample.test.ts +++ b/samples/agent365-s2s/test/sample.test.ts @@ -263,6 +263,91 @@ describe("S2STokenProvider", () => { assert.equal(exchangeCount, 2); }); + it("does not let a stale rejected awaiter clear a newer refresh", async () => { + let firstAwaiterRejected: ((reason?: unknown) => void) | undefined; + let secondAwaiterRejected: ((reason?: unknown) => void) | undefined; + let resolveSecondExchange: + | (( + value: + | { accessToken: string; expiresOn: Date } + | PromiseLike<{ accessToken: string; expiresOn: Date }>, + ) => void) + | undefined; + let exchangeCount = 0; + const firstExchange = { + then: ( + _onFulfilled?: ((value: { accessToken: string; expiresOn: Date }) => unknown) | null, + onRejected?: ((reason: unknown) => unknown) | null, + ) => { + if (!onRejected) { + throw new Error("Missing rejection handler."); + } + if (!firstAwaiterRejected) { + firstAwaiterRejected = onRejected; + } else if (!secondAwaiterRejected) { + secondAwaiterRejected = onRejected; + } else { + throw new Error("Unexpected extra awaiter."); + } + }, + } as unknown as Promise<{ accessToken: string; expiresOn: Date }>; + const client: TokenExchangeClient = { + exchange: () => { + exchangeCount++; + if (exchangeCount === 1) { + return firstExchange; + } + if (exchangeCount === 2) { + return new Promise((resolve) => { + resolveSecondExchange = resolve; + }); + } + return Promise.resolve({ + accessToken: `unexpected-token-${exchangeCount}`, + expiresOn: new Date(Date.now() + 120_000), + }); + }, + }; + const config = parseSampleConfig(validConfigObject()); + const provider = new S2STokenProvider(config, client); + + const first = provider.resolve(AGENT_ID, TENANT_ID); + const second = provider.resolve(AGENT_ID, TENANT_ID); + const firstFailure = assert.rejects(first, /temporarily_unavailable/); + const secondFailure = assert.rejects(second, /temporarily_unavailable/); + await Promise.resolve(); + assert.equal(exchangeCount, 1); + assert.ok(firstAwaiterRejected); + assert.ok(secondAwaiterRejected); + + const staleFailure = new Error("Blueprint token exchange failed (temporarily_unavailable)."); + firstAwaiterRejected(staleFailure); + await Promise.resolve(); + + const refresh = provider.resolve(AGENT_ID, TENANT_ID); + await Promise.resolve(); + assert.equal(exchangeCount, 2); + assert.ok(resolveSecondExchange); + + secondAwaiterRejected(staleFailure); + await Promise.resolve(); + + const follower = provider.resolve(AGENT_ID, TENANT_ID); + await Promise.resolve(); + assert.equal(exchangeCount, 2); + + resolveSecondExchange({ + accessToken: "shared-refresh-token", + expiresOn: new Date(Date.now() + 120_000), + }); + + await firstFailure; + await secondFailure; + assert.equal(await refresh, "shared-refresh-token"); + assert.equal(await follower, "shared-refresh-token"); + assert.equal(exchangeCount, 2); + }); + for (const [agentId, tenantId, expectedKey] of [ ["44444444-4444-4444-8444-444444444444", TENANT_ID, "agentId"], [AGENT_ID, "55555555-5555-4555-8555-555555555555", "tenantId"], diff --git a/src/sdkstats/metrics.ts b/src/sdkstats/metrics.ts index 5967efc..3442285 100644 --- a/src/sdkstats/metrics.ts +++ b/src/sdkstats/metrics.ts @@ -186,7 +186,7 @@ export class SdkStatsMetrics { // statusCode/exceptionType where applicable). `cikey` falls back to // "N/A" when unset. this.commonAttributes = { - rp: "unknown", + rp: "N/A", attach: "Manual", runtimeVersion: process.version, os: os.type(), diff --git a/test/internal/unit/sdkstats/metrics.test.ts b/test/internal/unit/sdkstats/metrics.test.ts index bee96ff..aaaedb2 100644 --- a/test/internal/unit/sdkstats/metrics.test.ts +++ b/test/internal/unit/sdkstats/metrics.test.ts @@ -236,7 +236,7 @@ describe("sdkstats/metrics", () => { // Common dimensions per spec. for (const dp of success) { - expect(dp.attributes.rp).toBe("unknown"); + expect(dp.attributes.rp).toBe("N/A"); expect(dp.attributes.attach).toBe("Manual"); expect(dp.attributes.cikey).toBe("N/A"); expect(dp.attributes.language).toBe("node"); From 76ed13eed689b2176148d5bf8bf2394290f6e227 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 15 Sep 2026 15:47:25 -0600 Subject: [PATCH 03/10] fix(a365): align correlation fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- src/a365/exporter/Agent365Exporter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/a365/exporter/Agent365Exporter.ts b/src/a365/exporter/Agent365Exporter.ts index 7d03b7e..4c2f73c 100644 --- a/src/a365/exporter/Agent365Exporter.ts +++ b/src/a365/exporter/Agent365Exporter.ts @@ -309,7 +309,7 @@ export class Agent365Exporter implements SpanExporter { headers["authorization"] = `Bearer ${token}`; // Send each chunk (all-or-nothing: fail on first chunk failure) - let lastCorrelationId = "unknown"; + let lastCorrelationId = "N/A"; for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; const payload = this.buildEnvelope(chunk, resourceAttrs); From 528db2c0a59e8633c6099bbfb8bac72d3295b998 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 15 Sep 2026 15:53:19 -0600 Subject: [PATCH 04/10] fix(samples): keep SDKStats dimensions unchanged Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- src/sdkstats/metrics.ts | 2 +- test/internal/unit/sdkstats/metrics.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sdkstats/metrics.ts b/src/sdkstats/metrics.ts index 3442285..5967efc 100644 --- a/src/sdkstats/metrics.ts +++ b/src/sdkstats/metrics.ts @@ -186,7 +186,7 @@ export class SdkStatsMetrics { // statusCode/exceptionType where applicable). `cikey` falls back to // "N/A" when unset. this.commonAttributes = { - rp: "N/A", + rp: "unknown", attach: "Manual", runtimeVersion: process.version, os: os.type(), diff --git a/test/internal/unit/sdkstats/metrics.test.ts b/test/internal/unit/sdkstats/metrics.test.ts index aaaedb2..bee96ff 100644 --- a/test/internal/unit/sdkstats/metrics.test.ts +++ b/test/internal/unit/sdkstats/metrics.test.ts @@ -236,7 +236,7 @@ describe("sdkstats/metrics", () => { // Common dimensions per spec. for (const dp of success) { - expect(dp.attributes.rp).toBe("N/A"); + expect(dp.attributes.rp).toBe("unknown"); expect(dp.attributes.attach).toBe("Manual"); expect(dp.attributes.cikey).toBe("N/A"); expect(dp.attributes.language).toBe("node"); From abb290b724f340e67ddaa40ae2a520637a3d5c90 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 1 Oct 2026 10:40:22 -0600 Subject: [PATCH 05/10] fix(samples): complete Agent365 S2S span coverage Keep the PR sample-focused, demonstrate all manual scope types, and remove sample tests and changelog changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-validation.yml | 3 - samples/README.md | 2 +- samples/agent365-s2s/README.md | 33 +- samples/agent365-s2s/package-lock.json | 113 ---- samples/agent365-s2s/package.json | 13 +- samples/agent365-s2s/src/scenario.ts | 68 +- samples/agent365-s2s/test/sample.test.ts | 587 ------------------ samples/agent365-s2s/tsconfig.json | 2 +- src/a365/exporter/Agent365Exporter.ts | 16 +- src/index.ts | 2 - .../unit/a365/agent365Exporter.test.ts | 69 -- 11 files changed, 85 insertions(+), 823 deletions(-) delete mode 100644 samples/agent365-s2s/test/sample.test.ts diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 6f5897f..4170450 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -39,9 +39,6 @@ jobs: - name: Build Agent365 S2S sample run: npm run --prefix samples/agent365-s2s build - - name: Test Agent365 S2S sample - run: npm test --prefix samples/agent365-s2s - - name: Format check run: npm run format diff --git a/samples/README.md b/samples/README.md index 1833f70..87d5160 100644 --- a/samples/README.md +++ b/samples/README.md @@ -28,7 +28,7 @@ These sample programs show how to use the `@microsoft/opentelemetry` distributio | [a365Export.ts][a365export] | Demonstrates A365 observability export: token resolver setup, dual export with Azure Monitor, and span routing by tenant/agent. | | [a365ManualScopes.ts][a365manualscopes] | Traces a full agent turn with manual scopes (InvokeAgent → Inference → ExecuteTool → Inference → Output) and cross-service context propagation. | | [a365HostingMiddleware.ts][a365hostingmiddleware] | Demonstrates A365 hosting middleware (BaggageMiddleware, OutputLoggingMiddleware, ObservabilityHostingManager, ScopeUtils). | -| [agent365-s2s][agent365s2s] | Standalone Agent365 S2S sample with two-stage MSAL app-only authentication, expiry-aware token caching, and four deterministic manual spans. | +| [agent365-s2s][agent365s2s] | Standalone Agent365 S2S sample with two-stage MSAL app-only authentication, expiry-aware token caching, and all five manual scope types. | ## Prerequisites diff --git a/samples/agent365-s2s/README.md b/samples/agent365-s2s/README.md index 1f8e206..5c0e818 100644 --- a/samples/agent365-s2s/README.md +++ b/samples/agent365-s2s/README.md @@ -71,18 +71,21 @@ Concurrent refreshes share one request, and failed refreshes can be retried. ## Expected telemetry -Each run creates exactly four spans in one trace: +Each run creates exactly six spans in one trace and demonstrates all five +concrete manual scope types: 1. `invoke_agent` for the complete synthetic request. -2. `Chat` inference selecting `lookup_weather`. -3. `execute_tool` with deterministic synthetic arguments and result. -4. `Chat` inference producing the final answer. - -All three operation spans are direct children of `invoke_agent`. The run starts -at the current time and uses fixed relative offsets and durations; tests inject -a fixed start time for repeatability. Published agent, caller, user, -conversation, message, and tool values are explicitly synthetic; only the -configured tenant and agent IDs identify the destination. +2. `apply_guardrail` allowing the synthetic input. +3. `Chat` inference selecting `lookup_weather`. +4. `execute_tool` with deterministic synthetic arguments and result. +5. `Chat` inference producing the final answer. +6. `output_messages` recording the response sent to the caller. + +All five child spans are direct children of `invoke_agent`. The run starts at +the current time and uses fixed relative offsets and durations. Published +agent, caller, user, conversation, message, guardrail, and tool values are +explicitly synthetic; only the configured tenant and agent IDs identify the +destination. The distro is configured with `enableObservabilityExporter: true`, `useS2SEndpoint: true`, the exact observability scope, and `prod` routing. The @@ -94,15 +97,7 @@ without a fixed sleep. The logger prints only preformatted messages and discards additional error arguments. Tokens, client secrets, raw MSAL responses, exception messages, nested errors, and stacks are never rendered. Authentication failures contain -only the failed stage and a sanitized MSAL error code. Successful exporter -diagnostics contain only the HTTP status and correlation ID (`N/A` when the -header is absent). - -Run the focused tests with: - -```powershell -npm test -``` +only the failed stage and a sanitized MSAL error code. The sample also reuses the repository's root Prettier and ESLint configuration: diff --git a/samples/agent365-s2s/package-lock.json b/samples/agent365-s2s/package-lock.json index a2efbe2..d6d8343 100644 --- a/samples/agent365-s2s/package-lock.json +++ b/samples/agent365-s2s/package-lock.json @@ -13,8 +13,6 @@ "@opentelemetry/api": "^1.9.1" }, "devDependencies": { - "@opentelemetry/sdk-trace-base": "^2.10.0", - "@opentelemetry/sdk-trace-node": "^2.10.0", "@types/node": "^22.0.0", "typescript": "^5.6.0" }, @@ -120,117 +118,6 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/context-async-hooks/-/context-async-hooks-2.11.0.tgz", - "integrity": "sha1-E3etPLESUQa6NtTNPEkzSE0VdpE=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.11.0.tgz", - "integrity": "sha1-l4SdOAi7plJWi31T4gyfw4vLfEk=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.11.0.tgz", - "integrity": "sha1-tOYLqSpPEi3JefNAKuqXE6mOrFs=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.11.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace": { - "version": "2.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", - "integrity": "sha1-Rru6nTZnVd99b63tD3zSWvfyZcI=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.11.0", - "@opentelemetry/resources": "2.11.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.11.0.tgz", - "integrity": "sha1-P3JUAfz7FuWyM1wQPNyUiIKWMtE=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.11.0", - "@opentelemetry/resources": "2.11.0", - "@opentelemetry/sdk-trace": "2.11.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.11.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.11.0.tgz", - "integrity": "sha1-PhI+/y39o9qPXJOnxCsYXt/te8M=", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/context-async-hooks": "2.11.0", - "@opentelemetry/core": "2.11.0", - "@opentelemetry/sdk-trace-base": "2.11.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.43.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", - "integrity": "sha1-8/Rn42wnMy8Oc17IbNzXjdbyeGU=", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-22.20.1.tgz", diff --git a/samples/agent365-s2s/package.json b/samples/agent365-s2s/package.json index 9ceb1d2..037f2a2 100644 --- a/samples/agent365-s2s/package.json +++ b/samples/agent365-s2s/package.json @@ -10,12 +10,11 @@ "scripts": { "build": "npm run clean && tsc -p tsconfig.json", "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true });\"", - "format": "node ../../node_modules/prettier/bin/prettier.cjs --check --config ../../.prettierrc.json --ignore-path ../../.prettierignore \"src/**/*.ts\" \"test/**/*.ts\" \"*.json\"", - "format:fix": "node ../../node_modules/prettier/bin/prettier.cjs --write --config ../../.prettierrc.json --ignore-path ../../.prettierignore \"src/**/*.ts\" \"test/**/*.ts\" \"*.json\"", - "lint": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs src test", - "lint:fix": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs src test --fix", - "start": "node dist/src/index.js", - "test": "npm run build && node --test dist/test/sample.test.js" + "format": "node ../../node_modules/prettier/bin/prettier.cjs --check --config ../../.prettierrc.json --ignore-path ../../.prettierignore \"src/**/*.ts\" \"*.json\"", + "format:fix": "node ../../node_modules/prettier/bin/prettier.cjs --write --config ../../.prettierrc.json --ignore-path ../../.prettierignore \"src/**/*.ts\" \"*.json\"", + "lint": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs src", + "lint:fix": "node ../../node_modules/eslint/bin/eslint.js --config ../../eslint.config.mjs src --fix", + "start": "node dist/src/index.js" }, "dependencies": { "@azure/msal-node": "^6.0.0", @@ -23,8 +22,6 @@ "@opentelemetry/api": "^1.9.1" }, "devDependencies": { - "@opentelemetry/sdk-trace-base": "^2.10.0", - "@opentelemetry/sdk-trace-node": "^2.10.0", "@types/node": "^22.0.0", "typescript": "^5.6.0" } diff --git a/samples/agent365-s2s/src/scenario.ts b/samples/agent365-s2s/src/scenario.ts index 7d3bdd4..0ab25cb 100644 --- a/samples/agent365-s2s/src/scenario.ts +++ b/samples/agent365-s2s/src/scenario.ts @@ -2,12 +2,17 @@ // Licensed under the MIT License. import { + ApplyGuardrailScope, ExecuteToolScope, FinishReason, + GuardrailDecisionType, + GuardrailRiskSeverity, + GuardrailTargetType, InferenceOperationType, InferenceScope, InvokeAgentScope, MessageRole, + OutputScope, type AgentDetails, type A365Request, type CallerDetails, @@ -62,17 +67,49 @@ export async function runScenario( }, content: "What is the weather in Seattle?", }; + const finalResponse = "The synthetic weather is sunny and 72°F in Seattle."; const invoke = InvokeAgentScope.start( request, { endpoint: { host: "synthetic-agent.invalid", port: 443 } }, agentDetails, callerDetails, - { startTime: at(startMilliseconds, 0), endTime: at(startMilliseconds, 400) }, + { startTime: at(startMilliseconds, 0), endTime: at(startMilliseconds, 500) }, ); try { await invoke.withActiveSpanAsync(async () => { + const guardrail = ApplyGuardrailScope.start( + { + targetType: GuardrailTargetType.LlmInput, + targetId: "synthetic-weather-request", + decisionType: GuardrailDecisionType.Allow, + guardianId: "synthetic-input-guardian", + guardianName: "Synthetic Input Guardian", + guardianProviderName: "sample", + guardianVersion: "1.0.0", + decisionReason: "Synthetic weather request is safe.", + policyId: "synthetic-weather-policy", + policyName: "Synthetic Weather Policy", + policyVersion: "1.0.0", + }, + agentDetails, + request, + callerDetails.userDetails, + { + startTime: at(startMilliseconds, 10), + endTime: at(startMilliseconds, 40), + }, + ); + guardrail.recordFinding({ + riskCategory: "synthetic_weather_request", + riskSeverity: GuardrailRiskSeverity.Low, + riskScore: 0.01, + }); + guardrail.recordDecision(GuardrailDecisionType.Allow, "Synthetic weather request is safe."); + guardrail.recordContentOutput("What is the weather in Seattle?"); + guardrail.dispose(); + const firstInference = InferenceScope.start( request, { @@ -84,8 +121,8 @@ export async function runScenario( agentDetails, callerDetails.userDetails, { - startTime: at(startMilliseconds, 10), - endTime: at(startMilliseconds, 110), + startTime: at(startMilliseconds, 60), + endTime: at(startMilliseconds, 160), }, ); firstInference.recordInputMessages(["Select a tool for the synthetic weather request."]); @@ -122,8 +159,8 @@ export async function runScenario( agentDetails, callerDetails.userDetails, { - startTime: at(startMilliseconds, 130), - endTime: at(startMilliseconds, 180), + startTime: at(startMilliseconds, 180), + endTime: at(startMilliseconds, 230), }, ); tool.recordResponse({ @@ -143,8 +180,8 @@ export async function runScenario( agentDetails, callerDetails.userDetails, { - startTime: at(startMilliseconds, 200), - endTime: at(startMilliseconds, 300), + startTime: at(startMilliseconds, 250), + endTime: at(startMilliseconds, 350), }, ); finalInference.recordInputMessages([ @@ -158,7 +195,7 @@ export async function runScenario( parts: [ { type: "text", - content: "The synthetic weather is sunny and 72°F in Seattle.", + content: finalResponse, }, ], }, @@ -168,9 +205,22 @@ export async function runScenario( finalInference.recordOutputTokens(14); finalInference.recordFinishReasons([FinishReason.STOP]); finalInference.dispose(); + + const output = OutputScope.start( + request, + { messages: [finalResponse] }, + agentDetails, + callerDetails.userDetails, + { + startTime: at(startMilliseconds, 370), + endTime: at(startMilliseconds, 420), + }, + ); + output.recordOutputMessages({ messages: [finalResponse] }); + output.dispose(); }); - invoke.recordResponse("The synthetic weather is sunny and 72°F in Seattle."); + invoke.recordResponse(finalResponse); } finally { invoke.dispose(); } diff --git a/samples/agent365-s2s/test/sample.test.ts b/samples/agent365-s2s/test/sample.test.ts deleted file mode 100644 index fdad1cb..0000000 --- a/samples/agent365-s2s/test/sample.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import assert from "node:assert/strict"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { after, describe, it } from "node:test"; -import { trace } from "@opentelemetry/api"; -import { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; -import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; -import { configureA365Logger, type ILogger } from "@microsoft/opentelemetry"; - -import { loadSampleConfig, parseSampleConfig } from "../src/config.js"; -import { createTelemetryOptions } from "../src/index.js"; -import { safeConsoleLogger } from "../src/safeLogger.js"; -import { runScenario } from "../src/scenario.js"; -import { S2STokenProvider } from "../src/s2sTokenProvider.js"; -import { - MsalTokenExchangeClient, - OBSERVABILITY_SCOPES, - TOKEN_EXCHANGE_SCOPE, - type ConfidentialClientFactory, - type TokenExchangeClient, -} from "../src/tokenExchangeClient.js"; - -const TENANT_ID = "11111111-1111-4111-8111-111111111111"; -const BLUEPRINT_CLIENT_ID = "22222222-2222-4222-8222-222222222222"; -const AGENT_ID = "33333333-3333-4333-8333-333333333333"; -const TEST_DIRECTORY = join(process.cwd(), ".test-tmp"); - -function validConfigObject(): Record { - return { - authority: "https://login.microsoftonline.com", - blueprintClientId: BLUEPRINT_CLIENT_ID, - blueprintClientSecret: "sample-client-secret", - tenantId: TENANT_ID, - agentId: AGENT_ID, - }; -} - -after(async () => { - await rm(TEST_DIRECTORY, { recursive: true, force: true }); -}); - -describe("sample configuration", () => { - it("parses valid settings and defaults the cluster to prod", () => { - const config = parseSampleConfig(validConfigObject()); - - assert.equal(config.authority.href, "https://login.microsoftonline.com/"); - assert.equal(config.blueprintClientId, BLUEPRINT_CLIENT_ID); - assert.equal(config.blueprintClientSecret, "sample-client-secret"); - assert.equal(config.tenantId, TENANT_ID); - assert.equal(config.agentId, AGENT_ID); - assert.equal(config.clusterCategory, "prod"); - }); - - it("loads configuration from a JSON file", async () => { - await mkdir(TEST_DIRECTORY, { recursive: true }); - const path = join(TEST_DIRECTORY, "appsettings.json"); - await writeFile(path, JSON.stringify({ ...validConfigObject(), clusterCategory: "prod" })); - - const config = await loadSampleConfig(path); - - assert.equal(config.agentId, AGENT_ID); - assert.equal(config.clusterCategory, "prod"); - }); - - for (const key of [ - "authority", - "blueprintClientId", - "blueprintClientSecret", - "tenantId", - "agentId", - ]) { - it(`rejects missing ${key} without exposing another setting`, () => { - const input = validConfigObject(); - delete input[key]; - - assert.throws( - () => parseSampleConfig(input), - (error: unknown) => { - assert.ok(error instanceof Error); - assert.match(error.message, new RegExp(key, "i")); - assert.doesNotMatch(error.message, /sample-client-secret/); - return true; - }, - ); - }); - - it(`rejects placeholder ${key} without echoing it`, () => { - const input = { ...validConfigObject(), [key]: `` }; - - assert.throws( - () => parseSampleConfig(input), - (error: unknown) => { - assert.ok(error instanceof Error); - assert.match(error.message, new RegExp(key, "i")); - assert.doesNotMatch(error.message, /replace-|secret-value/); - return true; - }, - ); - }); - } - - for (const authority of [ - "http://login.microsoftonline.com", - "https://login.microsoftonline.com/common", - "https://login.microsoftonline.com/?query=unsafe", - ]) { - it(`rejects the non-HTTPS or non-root authority ${authority}`, () => { - assert.throws(() => parseSampleConfig({ ...validConfigObject(), authority }), /authority/i); - }); - } - - for (const [key, value] of [ - ["tenantId", "not-a-guid"], - ["agentId", "agent-name"], - ["blueprintClientId", "blueprint-name"], - ] as const) { - it(`rejects invalid ${key} GUIDs`, () => { - assert.throws( - () => parseSampleConfig({ ...validConfigObject(), [key]: value }), - new RegExp(key, "i"), - ); - }); - } - - it("rejects unsupported cluster values without echoing them", () => { - assert.throws( - () => parseSampleConfig({ ...validConfigObject(), clusterCategory: "secret-environment" }), - (error: unknown) => { - assert.ok(error instanceof Error); - assert.match(error.message, /clusterCategory/i); - assert.doesNotMatch(error.message, /secret-environment/); - return true; - }, - ); - }); -}); - -describe("two-stage MSAL token exchange", () => { - it("uses the blueprint token as the agent application client assertion", async () => { - const configurations: Array> = []; - const requests: Array> = []; - const expiresOn = new Date("2030-01-01T00:00:00.000Z"); - let clientNumber = 0; - const factory: ConfidentialClientFactory = (configuration) => { - configurations.push(configuration as unknown as Record); - const currentClient = clientNumber++; - return { - acquireTokenByClientCredential: async (request) => { - requests.push(request as unknown as Record); - return currentClient === 0 - ? { accessToken: "blueprint-exchange-token", expiresOn } - : { accessToken: "agent-observability-token", expiresOn }; - }, - }; - }; - const config = parseSampleConfig(validConfigObject()); - const client = new MsalTokenExchangeClient(config, factory); - - const result = await client.exchange(); - - assert.deepEqual(requests[0], { - scopes: [TOKEN_EXCHANGE_SCOPE], - fmiPath: AGENT_ID, - }); - assert.deepEqual(requests[1], { scopes: [...OBSERVABILITY_SCOPES] }); - assert.deepEqual(configurations[0], { - auth: { - authority: `https://login.microsoftonline.com/${TENANT_ID}`, - clientId: BLUEPRINT_CLIENT_ID, - clientSecret: "sample-client-secret", - }, - }); - assert.deepEqual(configurations[1], { - auth: { - authority: `https://login.microsoftonline.com/${TENANT_ID}`, - clientId: AGENT_ID, - clientAssertion: "blueprint-exchange-token", - }, - }); - assert.deepEqual(result, { - accessToken: "agent-observability-token", - expiresOn, - }); - }); -}); - -describe("S2STokenProvider", () => { - it("reuses a cached token only outside the 60-second refresh window", async () => { - let now = Date.parse("2029-01-01T00:00:00.000Z"); - let exchangeCount = 0; - const client: TokenExchangeClient = { - exchange: async () => ({ - accessToken: `token-${++exchangeCount}`, - expiresOn: new Date(now + 120_000), - }), - }; - const config = parseSampleConfig(validConfigObject()); - const provider = new S2STokenProvider(config, client, () => now); - - assert.equal(await provider.resolve(AGENT_ID, TENANT_ID), "token-1"); - now += 59_999; - assert.equal(await provider.resolve(AGENT_ID, TENANT_ID), "token-1"); - now += 1; - assert.equal(await provider.resolve(AGENT_ID, TENANT_ID), "token-2"); - assert.equal(exchangeCount, 2); - }); - - it("uses one in-flight exchange for concurrent callers", async () => { - let completeExchange: ((value: { accessToken: string; expiresOn: Date }) => void) | undefined; - let exchangeCount = 0; - const client: TokenExchangeClient = { - exchange: () => { - exchangeCount++; - return new Promise((resolve) => { - completeExchange = resolve; - }); - }, - }; - const config = parseSampleConfig(validConfigObject()); - const provider = new S2STokenProvider(config, client); - - const resolutions = [ - provider.resolve(AGENT_ID, TENANT_ID), - provider.resolve(AGENT_ID.toUpperCase(), TENANT_ID.toUpperCase()), - provider.resolve(AGENT_ID, TENANT_ID, [...OBSERVABILITY_SCOPES]), - ]; - await Promise.resolve(); - assert.equal(exchangeCount, 1); - completeExchange?.({ - accessToken: "shared-token", - expiresOn: new Date(Date.now() + 120_000), - }); - - assert.deepEqual(await Promise.all(resolutions), [ - "shared-token", - "shared-token", - "shared-token", - ]); - }); - - it("clears a failed in-flight exchange so a later call retries", async () => { - let exchangeCount = 0; - const client: TokenExchangeClient = { - exchange: async () => { - exchangeCount++; - if (exchangeCount === 1) { - throw new Error("Blueprint token exchange failed (temporarily_unavailable)."); - } - return { - accessToken: "retry-token", - expiresOn: new Date(Date.now() + 120_000), - }; - }, - }; - const config = parseSampleConfig(validConfigObject()); - const provider = new S2STokenProvider(config, client); - - await assert.rejects(provider.resolve(AGENT_ID, TENANT_ID), /temporarily_unavailable/); - assert.equal(await provider.resolve(AGENT_ID, TENANT_ID), "retry-token"); - assert.equal(exchangeCount, 2); - }); - - it("does not let a stale rejected awaiter clear a newer refresh", async () => { - let firstAwaiterRejected: ((reason?: unknown) => void) | undefined; - let secondAwaiterRejected: ((reason?: unknown) => void) | undefined; - let resolveSecondExchange: - | (( - value: - | { accessToken: string; expiresOn: Date } - | PromiseLike<{ accessToken: string; expiresOn: Date }>, - ) => void) - | undefined; - let exchangeCount = 0; - const firstExchange = { - then: ( - _onFulfilled?: ((value: { accessToken: string; expiresOn: Date }) => unknown) | null, - onRejected?: ((reason: unknown) => unknown) | null, - ) => { - if (!onRejected) { - throw new Error("Missing rejection handler."); - } - if (!firstAwaiterRejected) { - firstAwaiterRejected = onRejected; - } else if (!secondAwaiterRejected) { - secondAwaiterRejected = onRejected; - } else { - throw new Error("Unexpected extra awaiter."); - } - }, - } as unknown as Promise<{ accessToken: string; expiresOn: Date }>; - const client: TokenExchangeClient = { - exchange: () => { - exchangeCount++; - if (exchangeCount === 1) { - return firstExchange; - } - if (exchangeCount === 2) { - return new Promise((resolve) => { - resolveSecondExchange = resolve; - }); - } - return Promise.resolve({ - accessToken: `unexpected-token-${exchangeCount}`, - expiresOn: new Date(Date.now() + 120_000), - }); - }, - }; - const config = parseSampleConfig(validConfigObject()); - const provider = new S2STokenProvider(config, client); - - const first = provider.resolve(AGENT_ID, TENANT_ID); - const second = provider.resolve(AGENT_ID, TENANT_ID); - const firstFailure = assert.rejects(first, /temporarily_unavailable/); - const secondFailure = assert.rejects(second, /temporarily_unavailable/); - await Promise.resolve(); - assert.equal(exchangeCount, 1); - assert.ok(firstAwaiterRejected); - assert.ok(secondAwaiterRejected); - - const staleFailure = new Error("Blueprint token exchange failed (temporarily_unavailable)."); - firstAwaiterRejected(staleFailure); - await Promise.resolve(); - - const refresh = provider.resolve(AGENT_ID, TENANT_ID); - await Promise.resolve(); - assert.equal(exchangeCount, 2); - assert.ok(resolveSecondExchange); - - secondAwaiterRejected(staleFailure); - await Promise.resolve(); - - const follower = provider.resolve(AGENT_ID, TENANT_ID); - await Promise.resolve(); - assert.equal(exchangeCount, 2); - - resolveSecondExchange({ - accessToken: "shared-refresh-token", - expiresOn: new Date(Date.now() + 120_000), - }); - - await firstFailure; - await secondFailure; - assert.equal(await refresh, "shared-refresh-token"); - assert.equal(await follower, "shared-refresh-token"); - assert.equal(exchangeCount, 2); - }); - - for (const [agentId, tenantId, expectedKey] of [ - ["44444444-4444-4444-8444-444444444444", TENANT_ID, "agentId"], - [AGENT_ID, "55555555-5555-4555-8555-555555555555", "tenantId"], - ["not-a-guid", TENANT_ID, "agentId"], - ]) { - it(`rejects ${expectedKey} identity mismatch before MSAL`, async () => { - let exchangeCount = 0; - const client: TokenExchangeClient = { - exchange: async () => { - exchangeCount++; - throw new Error("MSAL must not run"); - }, - }; - const provider = new S2STokenProvider(parseSampleConfig(validConfigObject()), client); - - await assert.rejects(provider.resolve(agentId, tenantId), new RegExp(expectedKey, "i")); - assert.equal(exchangeCount, 0); - }); - } -}); - -describe("safe logger and package exports", () => { - it("exposes the A365 logger API from the root package", () => { - const logger: ILogger = safeConsoleLogger; - assert.equal(logger, safeConsoleLogger); - assert.equal(typeof configureA365Logger, "function"); - }); - - it("writes only the formatted message and ignores unsafe additional arguments", () => { - const original = { - info: console.info, - warn: console.warn, - error: console.error, - }; - const output: string[] = []; - console.info = (message?: unknown) => output.push(String(message)); - console.warn = (message?: unknown) => output.push(String(message)); - console.error = (message?: unknown) => output.push(String(message)); - - try { - safeConsoleLogger.info("[S2S sample] Token cache refreshed.", { - accessToken: "unsafe-token", - }); - safeConsoleLogger.warn("[S2S sample] Retry scheduled.", "raw-secret-message"); - safeConsoleLogger.error("[S2S sample] Authentication failed (invalid_client).", { - clientSecret: "unsafe-secret", - stack: "unsafe-stack", - nested: new Error("nested-error-message"), - }); - } finally { - console.info = original.info; - console.warn = original.warn; - console.error = original.error; - } - - assert.deepEqual(output, [ - "[S2S sample] Token cache refreshed.", - "[S2S sample] Retry scheduled.", - "[S2S sample] Authentication failed (invalid_client).", - ]); - assert.doesNotMatch(output.join("\n"), /unsafe|raw-secret|nested-error/); - }); -}); - -describe("deterministic four-span scenario", () => { - it("configures the explicit S2S exporter and observability scope", () => { - const config = parseSampleConfig(validConfigObject()); - const tokenProvider = { - resolve: async () => "token", - }; - - const options = createTelemetryOptions(config, tokenProvider); - - assert.equal(options.a365?.enabled, true); - assert.equal(options.a365?.enableObservabilityExporter, true); - assert.equal(options.a365?.useS2SEndpoint, true); - assert.equal(options.a365?.clusterCategory, "prod"); - assert.deepEqual(options.a365?.authScopes, [...OBSERVABILITY_SCOPES]); - assert.equal(typeof options.a365?.tokenResolver, "function"); - }); - - it("emits exactly four deterministic spans in one trace with direct child parentage", async () => { - const exporter = new InMemorySpanExporter(); - const provider = new NodeTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const scenarioStart = Date.parse("2029-01-01T00:00:00.000Z"); - trace.disable(); - provider.register(); - - try { - await runScenario(parseSampleConfig(validConfigObject()), scenarioStart); - await provider.forceFlush(); - const spans = exporter - .getFinishedSpans() - .sort( - (left, right) => - left.startTime[0] - right.startTime[0] || left.startTime[1] - right.startTime[1], - ); - - assert.equal(spans.length, 4); - const [invoke, toolSelectingInference, executeTool, finalInference] = spans; - assert.deepEqual( - spans.map((span) => span.attributes["gen_ai.operation.name"]), - ["invoke_agent", "Chat", "execute_tool", "Chat"], - ); - assert.equal(new Set(spans.map((span) => span.spanContext().traceId)).size, 1); - assert.equal(invoke.parentSpanContext, undefined); - assert.equal(invoke.startTime[0] * 1_000 + invoke.startTime[1] / 1_000_000, scenarioStart); - for (const child of spans.slice(1)) { - assert.equal(child.parentSpanContext?.spanId, invoke.spanContext().spanId); - } - - assert.equal(invoke.attributes["gen_ai.agent.id"], AGENT_ID); - assert.equal(invoke.attributes["microsoft.tenant.id"], TENANT_ID); - assert.equal( - invoke.attributes["microsoft.a365.caller.agent.id"], - "44444444-4444-4444-8444-444444444444", - ); - assert.equal(invoke.attributes["user.id"], "synthetic-publisher-user"); - - assert.equal(toolSelectingInference.attributes["gen_ai.usage.input_tokens"], 48); - assert.equal(toolSelectingInference.attributes["gen_ai.usage.output_tokens"], 18); - assert.deepEqual(toolSelectingInference.attributes["gen_ai.response.finish_reasons"], [ - "tool_call", - ]); - assert.equal(finalInference.attributes["gen_ai.usage.input_tokens"], 32); - assert.equal(finalInference.attributes["gen_ai.usage.output_tokens"], 14); - assert.deepEqual(finalInference.attributes["gen_ai.response.finish_reasons"], ["stop"]); - - assert.equal(executeTool.attributes["gen_ai.tool.name"], "lookup_weather"); - assert.equal( - executeTool.attributes["gen_ai.tool.call.arguments"], - JSON.stringify({ city: "Seattle" }), - ); - assert.equal( - executeTool.attributes["gen_ai.tool.call.result"], - JSON.stringify({ condition: "sunny", temperatureFahrenheit: 72 }), - ); - - const durations = spans.map( - (span) => span.duration[0] * 1_000 + span.duration[1] / 1_000_000, - ); - assert.deepEqual(durations, [400, 100, 50, 100]); - } finally { - await provider.shutdown(); - trace.disable(); - } - }); -}); - -describe("sanitized MSAL failures", () => { - it("sanitizes client-construction failures at the correct stage", async () => { - let clientNumber = 0; - const factory: ConfidentialClientFactory = () => { - if (clientNumber++ === 1) { - throw { - errorCode: "invalid_client", - message: "constructor exposed sample-client-secret", - }; - } - return { - acquireTokenByClientCredential: async () => ({ - accessToken: "blueprint-exchange-token", - expiresOn: new Date("2030-01-01T00:00:00.000Z"), - }), - }; - }; - const client = new MsalTokenExchangeClient(parseSampleConfig(validConfigObject()), factory); - - await assert.rejects( - client.exchange(), - new Error("Agent token exchange failed (invalid_client)."), - ); - }); - - it("sanitizes blueprint-stage MSAL failures", async () => { - const factory: ConfidentialClientFactory = () => ({ - acquireTokenByClientCredential: async () => { - throw { - errorCode: "invalid_client", - message: "raw failure with sample-client-secret", - stack: "raw-stack-with-token", - nested: { accessToken: "nested-token" }, - }; - }, - }); - const client = new MsalTokenExchangeClient(parseSampleConfig(validConfigObject()), factory); - - await assert.rejects(client.exchange(), (error: unknown) => { - assert.ok(error instanceof Error); - assert.equal(error.message, "Blueprint token exchange failed (invalid_client)."); - assert.equal(error.cause, undefined); - assert.doesNotMatch(error.message, /sample-client-secret|raw|nested/i); - return true; - }); - }); - - it("sanitizes agent-stage MSAL failures", async () => { - let clientNumber = 0; - const factory: ConfidentialClientFactory = () => { - const currentClient = clientNumber++; - return { - acquireTokenByClientCredential: async () => { - if (currentClient === 0) { - return { - accessToken: "blueprint-exchange-token", - expiresOn: new Date("2030-01-01T00:00:00.000Z"), - }; - } - throw new Error("agent-stage-secret-and-stack"); - }, - }; - }; - const client = new MsalTokenExchangeClient(parseSampleConfig(validConfigObject()), factory); - - await assert.rejects(client.exchange(), (error: unknown) => { - assert.ok(error instanceof Error); - assert.equal(error.message, "Agent token exchange failed (unknown_error)."); - assert.equal(error.cause, undefined); - assert.doesNotMatch(error.message, /secret|stack/); - return true; - }); - }); - - it("treats missing MSAL results as sanitized stage failures", async () => { - const factory: ConfidentialClientFactory = () => ({ - acquireTokenByClientCredential: async () => null, - }); - const client = new MsalTokenExchangeClient(parseSampleConfig(validConfigObject()), factory); - - await assert.rejects( - client.exchange(), - new Error("Blueprint token exchange failed (empty_result)."), - ); - }); -}); diff --git a/samples/agent365-s2s/tsconfig.json b/samples/agent365-s2s/tsconfig.json index 37eeca6..110e4b8 100644 --- a/samples/agent365-s2s/tsconfig.json +++ b/samples/agent365-s2s/tsconfig.json @@ -10,5 +10,5 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts"] } diff --git a/src/a365/exporter/Agent365Exporter.ts b/src/a365/exporter/Agent365Exporter.ts index 4c2f73c..028b561 100644 --- a/src/a365/exporter/Agent365Exporter.ts +++ b/src/a365/exporter/Agent365Exporter.ts @@ -309,7 +309,7 @@ export class Agent365Exporter implements SpanExporter { headers["authorization"] = `Bearer ${token}`; // Send each chunk (all-or-nothing: fail on first chunk failure) - let lastCorrelationId = "N/A"; + let lastCorrelationId = "unknown"; for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; const payload = this.buildEnvelope(chunk, resourceAttrs); @@ -529,7 +529,7 @@ export class Agent365Exporter implements SpanExporter { const url = this.buildReplayUrl(record); const stats = createRequestStats(url); const requestStart = Date.now(); - let correlationId = "N/A"; + let correlationId = "unknown"; const headers: Record = { "content-type": "application/json", "x-ms-tenant-id": record.tenantId, @@ -550,14 +550,11 @@ export class Agent365Exporter implements SpanExporter { correlationId = response.headers.get("x-ms-correlation-id") ?? response.headers.get("x-correlation-id") ?? - "N/A"; + "unknown"; recordResponseStats(stats, response.status, requestStart); if (response.status >= 200 && response.status < 300) { - this.logger.info( - `[Agent365Exporter] HTTP ${response.status} success. Correlation ID: ${correlationId}.`, - ); return { kind: "success", correlationId }; } if ( @@ -589,7 +586,7 @@ export class Agent365Exporter implements SpanExporter { body: string, headers: Record, ): Promise<{ ok: boolean; correlationId: string }> { - let lastCorrelationId = "N/A"; + let lastCorrelationId = "unknown"; // Resolve the short host (and the SDKStats kill-switch) once per call // so each retry attempt records under the same key without re-parsing @@ -610,15 +607,12 @@ export class Agent365Exporter implements SpanExporter { const correlationId = response.headers.get("x-ms-correlation-id") ?? response.headers.get("x-correlation-id") ?? - "N/A"; + "unknown"; lastCorrelationId = correlationId; recordResponseStats(stats, response.status, requestStart); if (response.status >= 200 && response.status < 300) { - this.logger.info( - `[Agent365Exporter] HTTP ${response.status} success. Correlation ID: ${correlationId}.`, - ); return { ok: true, correlationId }; } diff --git a/src/index.ts b/src/index.ts index 8f97d19..e5f4564 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,9 +19,7 @@ export type { // ── Re-exports from A365 configuration ────────────────────────────────────── export { A365Configuration } from "./a365/index.js"; -export { configureA365Logger } from "./a365/index.js"; export type { ClusterCategory } from "./a365/index.js"; -export type { ILogger } from "./a365/index.js"; export { Agent365Exporter } from "./a365/index.js"; export type { Agent365ExporterOptions, diff --git a/test/internal/unit/a365/agent365Exporter.test.ts b/test/internal/unit/a365/agent365Exporter.test.ts index 8092e19..1712e74 100644 --- a/test/internal/unit/a365/agent365Exporter.test.ts +++ b/test/internal/unit/a365/agent365Exporter.test.ts @@ -216,46 +216,6 @@ describe("Agent365Exporter", () => { assert.strictEqual(options.headers["content-type"], "application/json"); }); - it("logs successful HTTP status and correlation ID without unsafe response data", async () => { - const customLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - configureA365Logger({ logger: customLogger, logLevel: "info|warn|error" }); - fetchSpy.mockResolvedValue({ - status: 202, - headers: new Headers({ "x-ms-correlation-id": "corr-safe-123" }), - json: () => ({ accessToken: "must-not-be-logged" }), - }); - - const exporter = createTestExporter({ tokenResolver: () => "secret-token" }); - const result = await exportResult(exporter, [makeSpan()]); - - assert.strictEqual(result, ExportResultCode.SUCCESS); - const output = customLogger.info.mock.calls.flat().map(String).join("\n"); - assert.include(output, "[Agent365Exporter] HTTP 202 success. Correlation ID: corr-safe-123."); - assert.notInclude(output, "secret-token"); - assert.notInclude(output, "must-not-be-logged"); - }); - - it("logs N/A when a successful HTTP response has no correlation ID", async () => { - const customLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - configureA365Logger({ logger: customLogger, logLevel: "info|warn|error" }); - fetchSpy.mockResolvedValue({ status: 204, headers: new Headers() }); - - const exporter = createTestExporter({ tokenResolver: () => "secret-token" }); - const result = await exportResult(exporter, [makeSpan()]); - - assert.strictEqual(result, ExportResultCode.SUCCESS); - const output = customLogger.info.mock.calls.flat().map(String).join("\n"); - assert.include(output, "[Agent365Exporter] HTTP 204 success. Correlation ID: N/A."); - }); - it("should use provided token resolver and set authorization header", async () => { const token = "abc123"; const exporter = createTestExporter({ @@ -1300,35 +1260,6 @@ describe("Agent365Exporter", () => { }); describe("durable delivery", () => { - it("logs successful durable HTTP status and correlation ID", async () => { - const directory = await createStorageDirectory(); - const customLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - configureA365Logger({ logger: customLogger, logLevel: "info|warn|error" }); - fetchSpy.mockResolvedValue({ - status: 201, - headers: new Headers({ "x-correlation-id": "durable-corr-123" }), - }); - const exporter = new Agent365Exporter({ - tokenResolver: () => "durable-secret-token", - durableDelivery: { enabled: true, storageDirectory: directory }, - }); - - const result = await exportResult(exporter, [makeSpan()]); - - assert.strictEqual(result, ExportResultCode.SUCCESS); - const output = customLogger.info.mock.calls.flat().map(String).join("\n"); - assert.include( - output, - "[Agent365Exporter] HTTP 201 success. Correlation ID: durable-corr-123.", - ); - assert.notInclude(output, "durable-secret-token"); - await exporter.shutdown(); - }); - it("hands a retryable failure to durable storage after one attempt", async () => { const directory = await createStorageDirectory(); fetchSpy.mockResolvedValue({ From 4e776115cd7997efc60353d26d168eec47c76539 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 1 Oct 2026 11:26:00 -0600 Subject: [PATCH 06/10] fix(samples): restore clean S2S build Export the logger API consumed by the standalone sample and use its validated routing configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f6edb2d-96f7-4c61-a5f2-ba0ed8da48a2 --- samples/agent365-s2s/src/index.ts | 2 +- src/index.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/samples/agent365-s2s/src/index.ts b/samples/agent365-s2s/src/index.ts index ffed939..3239310 100644 --- a/samples/agent365-s2s/src/index.ts +++ b/samples/agent365-s2s/src/index.ts @@ -30,7 +30,7 @@ export function createTelemetryOptions( tokenResolver: (agentId, tenantId, scopes) => tokenProvider.resolve(agentId, tenantId, scopes), authScopes: [...OBSERVABILITY_SCOPES], - clusterCategory: "prod", + clusterCategory: config.clusterCategory, useS2SEndpoint: true, }, }; diff --git a/src/index.ts b/src/index.ts index e5f4564..61e3705 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,8 @@ export type { // ── Re-exports from A365 configuration ────────────────────────────────────── export { A365Configuration } from "./a365/index.js"; -export type { ClusterCategory } from "./a365/index.js"; +export { configureA365Logger } from "./a365/index.js"; +export type { ClusterCategory, ILogger } from "./a365/index.js"; export { Agent365Exporter } from "./a365/index.js"; export type { Agent365ExporterOptions, From 25593192006870b08e96e337fae1b7408d9d165d Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 1 Oct 2026 12:38:40 -0600 Subject: [PATCH 07/10] refactor(samples): use environment configuration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f6edb2d-96f7-4c61-a5f2-ba0ed8da48a2 --- .github/workflows/pr-validation.yml | 12 ---------- samples/agent365-s2s/.gitignore | 2 +- samples/agent365-s2s/README.md | 24 +++++++++---------- samples/agent365-s2s/appsettings.example.json | 8 ------- samples/agent365-s2s/package-lock.json | 15 +++++++++++- samples/agent365-s2s/package.json | 3 ++- samples/agent365-s2s/sample.env | 7 ++++++ samples/agent365-s2s/src/config.ts | 19 +++++++-------- samples/agent365-s2s/src/index.ts | 10 ++++---- 9 files changed, 51 insertions(+), 49 deletions(-) delete mode 100644 samples/agent365-s2s/appsettings.example.json create mode 100644 samples/agent365-s2s/sample.env diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 4170450..32a41d5 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -27,18 +27,6 @@ jobs: - name: Build run: npm run build - - name: Install Agent365 S2S sample dependencies - run: npm ci --prefix samples/agent365-s2s - - - name: Format Agent365 S2S sample - run: npm run --prefix samples/agent365-s2s format - - - name: Lint Agent365 S2S sample - run: npm run --prefix samples/agent365-s2s lint - - - name: Build Agent365 S2S sample - run: npm run --prefix samples/agent365-s2s build - - name: Format check run: npm run format diff --git a/samples/agent365-s2s/.gitignore b/samples/agent365-s2s/.gitignore index bec292d..2fda730 100644 --- a/samples/agent365-s2s/.gitignore +++ b/samples/agent365-s2s/.gitignore @@ -1,4 +1,4 @@ -appsettings.json +.env dist/ node_modules/ .test-tmp/ diff --git a/samples/agent365-s2s/README.md b/samples/agent365-s2s/README.md index 5c0e818..6b5746c 100644 --- a/samples/agent365-s2s/README.md +++ b/samples/agent365-s2s/README.md @@ -16,7 +16,7 @@ required. - The blueprint and agent application must be configured for the Agent365 federated managed identity (FMI) token-exchange flow. -Never commit `appsettings.json`. The included `.gitignore` excludes it. +Never commit `.env`. The included `.gitignore` excludes it. ## Configure and run @@ -33,24 +33,24 @@ Set-Location samples\agent365-s2s Then configure and run the sample: ```powershell -Copy-Item appsettings.example.json appsettings.json +Copy-Item sample.env .env npm ci npm run build npm start ``` -Replace every placeholder in `appsettings.json`: +Set every required value in `.env`: -| Setting | Description | -| ----------------------- | ------------------------------------------------------------------------------------- | -| `authority` | HTTPS Microsoft Entra authority root, for example `https://login.microsoftonline.com` | -| `blueprintClientId` | Blueprint application client ID | -| `blueprintClientSecret` | Blueprint application client secret | -| `tenantId` | Microsoft Entra tenant ID | -| `agentId` | Agent365 agent application client ID and FMI path | -| `clusterCategory` | Must be `prod` | +| Variable | Description | +| ------------------------------ | ------------------------------------------------------------------------------------- | +| `A365_AUTHORITY` | HTTPS Microsoft Entra authority root, for example `https://login.microsoftonline.com` | +| `A365_BLUEPRINT_CLIENT_ID` | Blueprint application client ID | +| `A365_BLUEPRINT_CLIENT_SECRET` | Blueprint application client secret | +| `A365_TENANT_ID` | Microsoft Entra tenant ID | +| `A365_AGENT_ID` | Agent365 agent application client ID and FMI path | +| `A365_CLUSTER_CATEGORY` | Must be `prod` | -The sample rejects missing placeholders, malformed GUIDs, non-HTTPS +The sample rejects missing values, malformed GUIDs, non-HTTPS authorities, and authorities containing tenant paths, queries, or fragments. Configuration errors name only the invalid setting and never echo its value. diff --git a/samples/agent365-s2s/appsettings.example.json b/samples/agent365-s2s/appsettings.example.json deleted file mode 100644 index a81d5e2..0000000 --- a/samples/agent365-s2s/appsettings.example.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "authority": "https://login.microsoftonline.com", - "blueprintClientId": "", - "blueprintClientSecret": "", - "tenantId": "", - "agentId": "", - "clusterCategory": "prod" -} diff --git a/samples/agent365-s2s/package-lock.json b/samples/agent365-s2s/package-lock.json index d6d8343..91336d1 100644 --- a/samples/agent365-s2s/package-lock.json +++ b/samples/agent365-s2s/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@azure/msal-node": "^6.0.0", "@microsoft/opentelemetry": "file:../..", - "@opentelemetry/api": "^1.9.1" + "@opentelemetry/api": "^1.9.1", + "dotenv": "^17.4.2" }, "devDependencies": { "@types/node": "^22.0.0", @@ -134,6 +135,18 @@ "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=", "license": "BSD-3-Clause" }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha1-wH5Up0bhHroCHdnhBHztWv3BwDQ=", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", diff --git a/samples/agent365-s2s/package.json b/samples/agent365-s2s/package.json index 037f2a2..b580fb1 100644 --- a/samples/agent365-s2s/package.json +++ b/samples/agent365-s2s/package.json @@ -19,7 +19,8 @@ "dependencies": { "@azure/msal-node": "^6.0.0", "@microsoft/opentelemetry": "file:../..", - "@opentelemetry/api": "^1.9.1" + "@opentelemetry/api": "^1.9.1", + "dotenv": "^17.4.2" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/samples/agent365-s2s/sample.env b/samples/agent365-s2s/sample.env new file mode 100644 index 0000000..f24b16f --- /dev/null +++ b/samples/agent365-s2s/sample.env @@ -0,0 +1,7 @@ +# Agent365 service-to-service authentication +A365_AUTHORITY=https://login.microsoftonline.com +A365_BLUEPRINT_CLIENT_ID= +A365_BLUEPRINT_CLIENT_SECRET= +A365_TENANT_ID= +A365_AGENT_ID= +A365_CLUSTER_CATEGORY=prod diff --git a/samples/agent365-s2s/src/config.ts b/samples/agent365-s2s/src/config.ts index 33860ef..9bfea59 100644 --- a/samples/agent365-s2s/src/config.ts +++ b/samples/agent365-s2s/src/config.ts @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { readFile } from "node:fs/promises"; - export interface SampleConfig { authority: URL; blueprintClientId: string; @@ -81,12 +79,13 @@ export function parseSampleConfig(value: unknown): SampleConfig { }; } -export async function loadSampleConfig(path = "appsettings.json"): Promise { - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(path, "utf8")) as unknown; - } catch { - throw new Error("Unable to load sample configuration."); - } - return parseSampleConfig(parsed); +export function loadSampleConfig(env: NodeJS.ProcessEnv = process.env): SampleConfig { + return parseSampleConfig({ + authority: env.A365_AUTHORITY, + blueprintClientId: env.A365_BLUEPRINT_CLIENT_ID, + blueprintClientSecret: env.A365_BLUEPRINT_CLIENT_SECRET, + tenantId: env.A365_TENANT_ID, + agentId: env.A365_AGENT_ID, + clusterCategory: env.A365_CLUSTER_CATEGORY, + }); } diff --git a/samples/agent365-s2s/src/index.ts b/samples/agent365-s2s/src/index.ts index 3239310..0dcbeae 100644 --- a/samples/agent365-s2s/src/index.ts +++ b/samples/agent365-s2s/src/index.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import "dotenv/config"; + import { pathToFileURL } from "node:url"; import { configureA365Logger, @@ -41,7 +43,7 @@ function safeFailureMessage(error: unknown): string { return "Agent365 S2S sample failed."; } if ( - /^(?:Invalid sample configuration \([A-Za-z]+\)|Unable to load sample configuration|(?:Blueprint|Agent) token exchange failed \([A-Za-z0-9_.-]+\))\.$/.test( + /^(?:Invalid sample configuration \([A-Za-z]+\)|(?:Blueprint|Agent) token exchange failed \([A-Za-z0-9_.-]+\))\.$/.test( error.message, ) ) { @@ -50,7 +52,7 @@ function safeFailureMessage(error: unknown): string { return "Agent365 S2S sample failed."; } -export async function main(configPath = "appsettings.json"): Promise { +export async function main(): Promise { configureA365Logger({ logger: safeConsoleLogger, logLevel: "info|warn|error", @@ -58,7 +60,7 @@ export async function main(configPath = "appsettings.json"): Promise { let initialized = false; try { - const config = await loadSampleConfig(configPath); + const config = loadSampleConfig(); const tokenProvider = new S2STokenProvider(config, new MsalTokenExchangeClient(config)); useMicrosoftOpenTelemetry(createTelemetryOptions(config, tokenProvider)); initialized = true; @@ -75,7 +77,7 @@ export async function main(configPath = "appsettings.json"): Promise { const entryPoint = process.argv[1]; if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { - void main(process.argv[2]).catch(() => { + void main().catch(() => { process.exitCode = 1; }); } From df5c13ab9a8ee5059d9f6a0ea64996f537d39fe6 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 1 Oct 2026 12:52:42 -0600 Subject: [PATCH 08/10] fix(samples): export deterministic Agent365 trace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f6edb2d-96f7-4c61-a5f2-ba0ed8da48a2 --- samples/agent365-s2s/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/samples/agent365-s2s/src/index.ts b/samples/agent365-s2s/src/index.ts index 0dcbeae..1b34720 100644 --- a/samples/agent365-s2s/src/index.ts +++ b/samples/agent365-s2s/src/index.ts @@ -26,6 +26,8 @@ export function createTelemetryOptions( tokenProvider: TokenProvider, ): MicrosoftOpenTelemetryOptions { return { + samplingRatio: 1, + tracesPerSecond: 0, a365: { enabled: true, enableObservabilityExporter: true, From b16a6e3ab9694eea89918aecb2b2056ba413d74a Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 1 Oct 2026 13:13:56 -0600 Subject: [PATCH 09/10] fix(samples): pin Agent365 token audience Prevent ambient scope overrides from making the exporter request a different audience than the sample token exchange. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f6edb2d-96f7-4c61-a5f2-ba0ed8da48a2 --- samples/agent365-s2s/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/agent365-s2s/src/index.ts b/samples/agent365-s2s/src/index.ts index 1b34720..e46bb0e 100644 --- a/samples/agent365-s2s/src/index.ts +++ b/samples/agent365-s2s/src/index.ts @@ -33,7 +33,7 @@ export function createTelemetryOptions( enableObservabilityExporter: true, tokenResolver: (agentId, tenantId, scopes) => tokenProvider.resolve(agentId, tenantId, scopes), - authScopes: [...OBSERVABILITY_SCOPES], + observabilityScopeOverride: OBSERVABILITY_SCOPES[0], clusterCategory: config.clusterCategory, useS2SEndpoint: true, }, From 85b2d97b6245821e507e4288b24bab129157b9aa Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 1 Oct 2026 15:23:19 -0600 Subject: [PATCH 10/10] fix(samples): validate S2S credentials before tracing Resolve and cache the observability token before initializing telemetry so invalid sample credentials reach the existing error handler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f6edb2d-96f7-4c61-a5f2-ba0ed8da48a2 --- samples/agent365-s2s/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/samples/agent365-s2s/src/index.ts b/samples/agent365-s2s/src/index.ts index e46bb0e..30b4f27 100644 --- a/samples/agent365-s2s/src/index.ts +++ b/samples/agent365-s2s/src/index.ts @@ -64,6 +64,7 @@ export async function main(): Promise { try { const config = loadSampleConfig(); const tokenProvider = new S2STokenProvider(config, new MsalTokenExchangeClient(config)); + await tokenProvider.resolve(config.agentId, config.tenantId, [...OBSERVABILITY_SCOPES]); useMicrosoftOpenTelemetry(createTelemetryOptions(config, tokenProvider)); initialized = true; await runScenario(config);