From e16d43dea48b33980cf040e9887a779411e101b3 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 1/5] 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 + CHANGELOG.md | 4 + 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 +++ 19 files changed, 1706 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/CHANGELOG.md b/CHANGELOG.md index d61c5f7..409b5a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,11 @@ ## [Unreleased] +### Features Added +- Add a self-contained Agent365 S2S sample with two-stage MSAL app-only authentication and expiry-aware token caching. + ### Other Changes +- Include successful Agent365 exporter HTTP status and correlation ID in safe diagnostics. - Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. ## [1.4.0] - 2026-09-08 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 a286912..6075fe7 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 dd7435236756f9e5a148ebfbe6edca1cbb33c4a2 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 2/5] 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 a2a0003c603c24e87c8aea4689dcc7c254c38a22 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 3/5] 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 2af226fc1d91ca811b66a06739ffc86d9c778b13 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 15 Sep 2026 15:47:59 -0600 Subject: [PATCH 4/5] docs: link Agent365 S2S changelog entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 409b5a1..16d70c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,10 @@ ## [Unreleased] ### Features Added -- Add a self-contained Agent365 S2S sample with two-stage MSAL app-only authentication and expiry-aware token caching. +- Add a self-contained Agent365 S2S sample with two-stage MSAL app-only authentication and expiry-aware token caching. [#244](https://github.com/microsoft/opentelemetry-distro-javascript/pull/244) ### Other Changes -- Include successful Agent365 exporter HTTP status and correlation ID in safe diagnostics. +- Include successful Agent365 exporter HTTP status and correlation ID in safe diagnostics. [#244](https://github.com/microsoft/opentelemetry-distro-javascript/pull/244) - Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. ## [1.4.0] - 2026-09-08 From 802303dd483b4b4e792b2e07a6ccfc1a0f42ad5b 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 5/5] 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");