diff --git a/samples/README.md b/samples/README.md index 3ff4f90..87d5160 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 all five manual scope types. | ## 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..2fda730 --- /dev/null +++ b/samples/agent365-s2s/.gitignore @@ -0,0 +1,4 @@ +.env +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..6b5746c --- /dev/null +++ b/samples/agent365-s2s/README.md @@ -0,0 +1,107 @@ +# 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 `.env`. 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 sample.env .env +npm ci +npm run build +npm start +``` + +Set every required value in `.env`: + +| 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 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. + +## 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 six spans in one trace and demonstrates all five +concrete manual scope types: + +1. `invoke_agent` for the complete synthetic request. +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 +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. + +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-lock.json b/samples/agent365-s2s/package-lock.json new file mode 100644 index 0000000..91336d1 --- /dev/null +++ b/samples/agent365-s2s/package-lock.json @@ -0,0 +1,304 @@ +{ + "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", + "dotenv": "^17.4.2" + }, + "devDependencies": { + "@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/@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/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", + "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..b580fb1 --- /dev/null +++ b/samples/agent365-s2s/package.json @@ -0,0 +1,29 @@ +{ + "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 });\"", + "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", + "@microsoft/opentelemetry": "file:../..", + "@opentelemetry/api": "^1.9.1", + "dotenv": "^17.4.2" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.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 new file mode 100644 index 0000000..9bfea59 --- /dev/null +++ b/samples/agent365-s2s/src/config.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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 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 new file mode 100644 index 0000000..30b4f27 --- /dev/null +++ b/samples/agent365-s2s/src/index.ts @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import "dotenv/config"; + +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 { + samplingRatio: 1, + tracesPerSecond: 0, + a365: { + enabled: true, + enableObservabilityExporter: true, + tokenResolver: (agentId, tenantId, scopes) => + tokenProvider.resolve(agentId, tenantId, scopes), + observabilityScopeOverride: OBSERVABILITY_SCOPES[0], + clusterCategory: config.clusterCategory, + useS2SEndpoint: true, + }, + }; +} + +function safeFailureMessage(error: unknown): string { + if (!(error instanceof Error)) { + return "Agent365 S2S sample failed."; + } + if ( + /^(?:Invalid sample configuration \([A-Za-z]+\)|(?:Blueprint|Agent) token exchange failed \([A-Za-z0-9_.-]+\))\.$/.test( + error.message, + ) + ) { + return error.message; + } + return "Agent365 S2S sample failed."; +} + +export async function main(): Promise { + configureA365Logger({ + logger: safeConsoleLogger, + logLevel: "info|warn|error", + }); + + let initialized = false; + 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); + } catch (error) { + safeConsoleLogger.error(`[S2S sample] ${safeFailureMessage(error)}`); + throw new Error("Agent365 S2S sample failed.", { cause: error }); + } finally { + if (initialized) { + await shutdownMicrosoftOpenTelemetry(); + } + } +} + +const entryPoint = process.argv[1]; +if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) { + void main().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..2e862bd --- /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; + } + + const inFlight = entry.inFlight ?? (entry.inFlight = this.exchangeClient.exchange()); + + try { + const token = await inFlight; + entry.token = token; + return token.accessToken; + } finally { + if (entry.inFlight === inFlight) { + 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..0ab25cb --- /dev/null +++ b/samples/agent365-s2s/src/scenario.ts @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ApplyGuardrailScope, + ExecuteToolScope, + FinishReason, + GuardrailDecisionType, + GuardrailRiskSeverity, + GuardrailTargetType, + InferenceOperationType, + InferenceScope, + InvokeAgentScope, + MessageRole, + OutputScope, + 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 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, 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, + { + operationName: InferenceOperationType.CHAT, + model: "synthetic-tool-selector", + providerName: "sample", + endpoint: { host: "synthetic-model.invalid", port: 443 }, + }, + agentDetails, + callerDetails.userDetails, + { + startTime: at(startMilliseconds, 60), + endTime: at(startMilliseconds, 160), + }, + ); + 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, 180), + endTime: at(startMilliseconds, 230), + }, + ); + 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, 250), + endTime: at(startMilliseconds, 350), + }, + ); + 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: finalResponse, + }, + ], + }, + ], + }); + finalInference.recordInputTokens(32); + 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(finalResponse); + } 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/tsconfig.json b/samples/agent365-s2s/tsconfig.json new file mode 100644 index 0000000..110e4b8 --- /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"] +} 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,