diff --git a/dev/README.md b/dev/README.md deleted file mode 100644 index db45e4eb..00000000 --- a/dev/README.md +++ /dev/null @@ -1,3 +0,0 @@ -```bash -pip install -e ./microsoft-agents-testing/ --config-settings editable_mode=compat -``` \ No newline at end of file diff --git a/dev/testing/README.md b/dev/testing/README.md new file mode 100644 index 00000000..ff0ee91a --- /dev/null +++ b/dev/testing/README.md @@ -0,0 +1,13 @@ +## Testing + +This folder contains three test-related directories: + +`cross-sdk-tests`: End-to-end tests across all SDKs (Python, JavaScript, .NET). This is a work in progress. + +`microsoft-agents-testing`: This is the testing framework used to facilitate testing agents. This is only for internal development purposes. + +`python-sdk-tests`: These are integration tests related to the Python SDK. These are an extension of the Python SDK's unit tests. These tests are more specific that the ones in `cross-sdk-tests` because they look into the internals of Python SDK components for the running agents while the other test suite communicates purely over HTTP/HTTPS. + +## Running tests and installation + +The instructions to install the `microsoft-agents-testing` library are specificed in `microsoft-agents-testing/README.md`. To run the `python-sdk-tests`, `cd` into that directory and run `pytest` via Powershell. `cross-sdk-tests` still does not have an entry point for testing. \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/.gitignore b/dev/testing/cross-sdk-tests/.gitignore new file mode 100644 index 00000000..510e2f50 --- /dev/null +++ b/dev/testing/cross-sdk-tests/.gitignore @@ -0,0 +1,244 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates +target/ + +# Cake +/.cake +/version.txt +/PSRunCmds*.ps1 + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +/bin/ +/binSigned/ +/obj/ +Drop/ +target/ +Symbols/ +objd/ +.config/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +#nodeJS stuff +/node_modules/ + +#local development +appsettings.local.json +appsettings.Development.json +appsettings.Development* +appsettings.Production.json +**/[Aa]ppManifest/*.zip +.deployment + +# JetBrains Rider +*.sln.iml +.idea + +# Mac files +.DS_Store + +# VS Code files +.vscode +src/samples/ModelContextProtocol/GitHubMCPServer/Properties/ServiceDependencies/GitHubMCPServer20250311143114 - Web Deploy/profile.arm.json + +# Claude Code temporary files +tmpclaude* + + +node_modules/ +dist/ +*.env +*.key +*.pem +test-report.xml +tsconfig.tsbuildinfo +devTools/ \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/__init__.py b/dev/testing/cross-sdk-tests/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/__init__.py rename to dev/testing/cross-sdk-tests/__init__.py diff --git a/dev/testing/cross-sdk-tests/agents/core-agent/README.md b/dev/testing/cross-sdk-tests/agents/core-agent/README.md new file mode 100644 index 00000000..2c07f981 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/core-agent/README.md @@ -0,0 +1,3 @@ +# Core Agent + +An agent that has various routes to test diverse the Agents SDK core functionalities. \ No newline at end of file diff --git a/dev/tests/agents/basic_agent/python/README.md b/dev/testing/cross-sdk-tests/agents/core-agent/python/README.md similarity index 100% rename from dev/tests/agents/basic_agent/python/README.md rename to dev/testing/cross-sdk-tests/agents/core-agent/python/README.md diff --git a/dev/microsoft-agents-testing/tests/core/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/__init__.py diff --git a/dev/tests/agents/basic_agent/python/env.TEMPLATE b/dev/testing/cross-sdk-tests/agents/core-agent/python/env.TEMPLATE similarity index 100% rename from dev/tests/agents/basic_agent/python/env.TEMPLATE rename to dev/testing/cross-sdk-tests/agents/core-agent/python/env.TEMPLATE diff --git a/dev/tests/agents/basic_agent/python/pre_requirements.txt b/dev/testing/cross-sdk-tests/agents/core-agent/python/pre_requirements.txt similarity index 100% rename from dev/tests/agents/basic_agent/python/pre_requirements.txt rename to dev/testing/cross-sdk-tests/agents/core-agent/python/pre_requirements.txt diff --git a/dev/tests/agents/basic_agent/python/requirements.txt b/dev/testing/cross-sdk-tests/agents/core-agent/python/requirements.txt similarity index 100% rename from dev/tests/agents/basic_agent/python/requirements.txt rename to dev/testing/cross-sdk-tests/agents/core-agent/python/requirements.txt diff --git a/dev/microsoft-agents-testing/tests/core/fluent/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/__init__.py diff --git a/dev/tests/agents/basic_agent/python/src/agent.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/agent.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/agent.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/agent.py diff --git a/dev/tests/agents/basic_agent/python/src/app.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/app.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/app.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/app.py diff --git a/dev/tests/agents/basic_agent/python/src/config.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/config.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/config.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/config.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/__init__.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/weather_forecast_agent.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/agents/weather_forecast_agent.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/agents/weather_forecast_agent.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/__init__.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/__init__.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/__init__.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/adaptive_card_plugin.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/adaptive_card_plugin.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/adaptive_card_plugin.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/date_time_plugin.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/date_time_plugin.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/date_time_plugin.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/date_time_plugin.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py b/dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast_plugin.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/plugins/weather_forecast_plugin.py rename to dev/testing/cross-sdk-tests/agents/core-agent/python/src/weather/plugins/weather_forecast_plugin.py diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/README.md b/dev/testing/cross-sdk-tests/agents/quickstart/README.md new file mode 100644 index 00000000..e37735c2 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/README.md @@ -0,0 +1,3 @@ +# Quickstart Agent + +This agent echos responses back to the user. As presently configured, the agent enables JWT token validation. \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/_run_agent.ps1 b/dev/testing/cross-sdk-tests/agents/quickstart/js/_run_agent.ps1 new file mode 100644 index 00000000..0970ac91 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/_run_agent.ps1 @@ -0,0 +1,4 @@ +npm install + +npm run build +npm run start:anon \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/env.TEMPLATE b/dev/testing/cross-sdk-tests/agents/quickstart/js/env.TEMPLATE new file mode 100644 index 00000000..e170043b --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/env.TEMPLATE @@ -0,0 +1,9 @@ +# rename to .env +connections__serviceConnection__settings__clientId= # App ID of the App Registration used to log in. +connections__serviceConnection__settings__clientSecret= # Client secret of the App Registration used to log in +connections__serviceConnection__settings__tenantId= # Tenant ID of the App Registration used to log in + +connectionsMap__0__connection=serviceConnection +connectionsMap__0__serviceUrl=* + +DEBUG=agents:*:error \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/package-lock.json b/dev/testing/cross-sdk-tests/agents/quickstart/js/package-lock.json new file mode 100644 index 00000000..64fc102a --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/package-lock.json @@ -0,0 +1,3077 @@ +{ + "name": "node-empty-agent", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-empty-agent", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@microsoft/agents-hosting-express": "^1.1.0" + }, + "devDependencies": { + "@microsoft/m365agentsplayground": "^0.2.16", + "@types/node": "^22.15.18", + "npm-run-all": "^4.1.5", + "typescript": "^5.8.3" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.13.3", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.3.tgz", + "integrity": "sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.4.tgz", + "integrity": "sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.13.3", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@microsoft/agents-activity": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-activity/-/agents-activity-1.1.1.tgz", + "integrity": "sha512-L7PHEHKFge99aIxV9eA7uFY3n9goYKzxcWaqLXGmxq3wMsau8hdsPzZgpV77LOQWQynLO3M5cbD8AavcVZszlQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "uuid": "^11.1.0", + "zod": "3.25.75" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/agents-activity/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@microsoft/agents-hosting": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting/-/agents-hosting-1.1.1.tgz", + "integrity": "sha512-ZO/BU0d/NxSlbg/W4SvtHDvwS4GDYrMG5CpBh+m2vnqkl6tphM0kkfbSYZFef0BoftrinOdPZcSvdvmVqpbM2w==", + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.10.1", + "@azure/msal-node": "^3.8.2", + "@microsoft/agents-activity": "1.1.1", + "axios": "^1.13.2", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.2.0", + "object-path": "^0.11.8" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/agents-hosting-express": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting-express/-/agents-hosting-express-1.1.1.tgz", + "integrity": "sha512-CDStIx23U2zyS/4nZoeVgrVlVbQ+EasoqR2dLq7IfU4rUyuUrKGPdlO55rcfS6Z/spLkhCnX35jbD6EBqrTkJg==", + "license": "MIT", + "dependencies": { + "@microsoft/agents-hosting": "1.1.1", + "express": "^5.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/m365agentsplayground": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@microsoft/m365agentsplayground/-/m365agentsplayground-0.2.18.tgz", + "integrity": "sha512-8okNQ+fNQPPMBW/OSIudoCApBKqKxADNFIMivUGy/eaX9v8tFIG/gFo7DLwpaCzNuc1M8oOW9Sse1mX08Dxo0A==", + "dev": true, + "bin": { + "agentsplayground": "cli.js", + "teamsapptester": "cli.js" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.18.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz", + "integrity": "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", + "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/cross-spawn/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "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://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-path": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/object-path/-/object-path-0.11.8.tgz", + "integrity": "sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA==", + "license": "MIT", + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", + "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "dev": true + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.75", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.75.tgz", + "integrity": "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/package.json b/dev/testing/cross-sdk-tests/agents/quickstart/js/package.json new file mode 100644 index 00000000..e4cdab59 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/package.json @@ -0,0 +1,29 @@ +{ + "name": "node-empty-agent", + "version": "1.0.0", + "private": true, + "description": "Agents echo bot sample", + "author": "Microsoft", + "license": "MIT", + "main": "./dist/index.js", + "scripts": { + "prebuild": "npm ci", + "build": "tsc --build", + "prestart": "npm run build", + "prestart:anon": "npm run build", + "start:anon": "node ./dist/index.js", + "start": "node --env-file .env ./dist/index.js", + "test-tool": "agentsplayground", + "test": "npm-run-all -p -r start:anon test-tool" + }, + "dependencies": { + "@microsoft/agents-hosting-express": "^1.1.0" + }, + "devDependencies": { + "@microsoft/m365agentsplayground": "^0.2.16", + "@types/node": "^22.15.18", + "npm-run-all": "^4.1.5", + "typescript": "^5.8.3" + }, + "keywords": [] +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/src/index.ts b/dev/testing/cross-sdk-tests/agents/quickstart/js/src/index.ts new file mode 100644 index 00000000..5796e398 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/src/index.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { ActivityTypes } from '@microsoft/agents-activity'; +import { AgentApplication, AttachmentDownloader, MemoryStorage, TurnContext, TurnState } from '@microsoft/agents-hosting'; +import { startServer } from '@microsoft/agents-hosting-express'; + +// Create custom conversation state properties. This is +// used to store customer properties in conversation state. +interface ConversationState { + count: number; +} +type ApplicationTurnState = TurnState + +// Register IStorage. For development, MemoryStorage is suitable. +// For production Agents, persisted storage should be used so +// that state survives Agent restarts, and operates correctly +// in a cluster of Agent instances. +const storage = new MemoryStorage() + +const downloader = new AttachmentDownloader() + +const agentApp = new AgentApplication({ + storage, + fileDownloaders: [downloader] +}) + +// Display a welcome message when members are added +agentApp.onConversationUpdate('membersAdded', async (context: TurnContext, state: ApplicationTurnState) => { + await context.sendActivity('Hello and Welcome!') +}) + +// Listen for ANY message to be received. MUST BE AFTER ANY OTHER MESSAGE HANDLERS +agentApp.onActivity(ActivityTypes.Message, async (context: TurnContext, state: ApplicationTurnState) => { + // Increment count state + let count = state.conversation.count ?? 0 + state.conversation.count = ++count + + // Echo back users message + await context.sendActivity(`[${count}] You said: ${context.activity.text}`) +}) + +startServer(agentApp) \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/js/tsconfig.json b/dev/testing/cross-sdk-tests/agents/quickstart/js/tsconfig.json new file mode 100644 index 00000000..0e188450 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/js/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "incremental": true, + "lib": ["ES2021"], + "target": "es2019", + "module": "commonjs", + "declaration": true, + "sourceMap": true, + "composite": true, + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + } +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/net/AspNetExtensions.cs b/dev/testing/cross-sdk-tests/agents/quickstart/net/AspNetExtensions.cs new file mode 100644 index 00000000..944e6844 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/net/AspNetExtensions.cs @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Authentication; +using Microsoft.Agents.Core; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +public static class AspNetExtensions +{ + private static readonly ConcurrentDictionary> _openIdMetadataCache = new(); + + /// + /// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent using settings in configuration. + /// + /// + /// + /// Name of the config section to read. + /// Optional logger to use for authentication event logging. + /// + /// This extension reads settings from configuration. If configuration is missing JWT token + /// is not enabled. + ///

The minimum, but typical, configuration is:

+ /// + /// "TokenValidation": { + /// "Enabled": boolean, + /// "Audiences": [ + /// "{{ClientId}}" // this is the Client ID used for the Azure Bot + /// ], + /// "TenantId": "{{TenantId}}" + /// } + /// + /// The full options are: + /// + /// "TokenValidation": { + /// "Enabled": boolean, + /// "Audiences": [ + /// "{required:agent-appid}" + /// ], + /// "TenantId": "{recommended:tenant-id}", + /// "ValidIssuers": [ + /// "{default:Public-AzureBotService}" + /// ], + /// "IsGov": {optional:false}, + /// "AzureBotServiceOpenIdMetadataUrl": optional, + /// "OpenIdMetadataUrl": optional, + /// "AzureBotServiceTokenHandling": "{optional:true}" + /// "OpenIdMetadataRefresh": "optional-12:00:00" + /// } + /// + ///
+ public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation") + { + IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName); + + if (!tokenValidationSection.Exists() || !tokenValidationSection.GetValue("Enabled", true)) + { + // Noop if TokenValidation section missing or disabled. + System.Diagnostics.Trace.WriteLine("AddAgentAspNetAuthentication: Auth disabled"); + return; + } + + services.AddAgentAspNetAuthentication(tokenValidationSection.Get()!); + } + + /// + /// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent. + /// + public static void AddAgentAspNetAuthentication(this IServiceCollection services, TokenValidationOptions validationOptions) + { + AssertionHelpers.ThrowIfNull(validationOptions, nameof(validationOptions)); + + // Must have at least one Audience. + if (validationOptions.Audiences == null || validationOptions.Audiences.Count == 0) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences requires at least one ClientId"); + } + + // Audience values must be GUID's + foreach (var audience in validationOptions.Audiences) + { + if (!Guid.TryParse(audience, out _)) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences values must be a GUID"); + } + } + + // If ValidIssuers is empty, default for ABS Public Cloud + if (validationOptions.ValidIssuers == null || validationOptions.ValidIssuers.Count == 0) + { + validationOptions.ValidIssuers = + [ + "https://api.botframework.com", + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", + "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", + "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", + ]; + + if (!string.IsNullOrEmpty(validationOptions.TenantId) && Guid.TryParse(validationOptions.TenantId, out _)) + { + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, validationOptions.TenantId)); + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, validationOptions.TenantId)); + } + } + + // If the `AzureBotServiceOpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate ABS tokens. + if (string.IsNullOrEmpty(validationOptions.AzureBotServiceOpenIdMetadataUrl)) + { + validationOptions.AzureBotServiceOpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl; + } + + // If the `OpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate Entra ID tokens. + if (string.IsNullOrEmpty(validationOptions.OpenIdMetadataUrl)) + { + validationOptions.OpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl; + } + + var openIdMetadataRefresh = validationOptions.OpenIdMetadataRefresh ?? BaseConfigurationManager.DefaultAutomaticRefreshInterval; + + _ = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5), + ValidIssuers = validationOptions.ValidIssuers, + ValidAudiences = validationOptions.Audiences, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + }; + + // Using Microsoft.IdentityModel.Validators + options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + + options.Events = new JwtBearerEvents + { + // Create a ConfigurationManager based on the requestor. This is to handle ABS non-Entra tokens. + OnMessageReceived = async context => + { + string authorizationHeader = context.Request.Headers.Authorization.ToString(); + + if (string.IsNullOrEmpty(authorizationHeader)) + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + string[] parts = authorizationHeader?.Split(' ')!; + if (parts.Length != 2 || parts[0] != "Bearer") + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + JwtSecurityToken token = new(parts[1]); + string issuer = token.Claims.FirstOrDefault(claim => claim.Type == AuthenticationConstants.IssuerClaim)?.Value!; + + if (validationOptions.AzureBotServiceTokenHandling && AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer)) + { + // Use the Azure Bot authority for this configuration manager + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.AzureBotServiceOpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.AzureBotServiceOpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + else + { + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.OpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.OpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + + await Task.CompletedTask.ConfigureAwait(false); + }, + + OnTokenValidated = context => + { + return Task.CompletedTask; + }, + OnForbidden = context => + { + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + return Task.CompletedTask; + } + }; + }); + } + + public class TokenValidationOptions + { + public IList? Audiences { get; set; } + + /// + /// TenantId of the Azure Bot. Optional but recommended. + /// + public string? TenantId { get; set; } + + /// + /// Additional valid issuers. Optional, in which case the Public Azure Bot Service issuers are used. + /// + public IList? ValidIssuers { get; set; } + + /// + /// Can be omitted, in which case public Azure Bot Service and Azure Cloud metadata urls are used. + /// + public bool IsGov { get; set; } = false; + + /// + /// Azure Bot Service OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? AzureBotServiceOpenIdMetadataUrl { get; set; } + + /// + /// Entra OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? OpenIdMetadataUrl { get; set; } + + /// + /// Determines if Azure Bot Service tokens are handled. Defaults to true and should always be true until Azure Bot Service sends Entra ID token. + /// + public bool AzureBotServiceTokenHandling { get; set; } = true; + + /// + /// OpenIdMetadata refresh interval. Defaults to 12 hours. + /// + public TimeSpan? OpenIdMetadataRefresh { get; set; } + } +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/net/MyAgent.cs b/dev/testing/cross-sdk-tests/agents/quickstart/net/MyAgent.cs new file mode 100644 index 00000000..9e65e5f1 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/net/MyAgent.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Builder.State; +using Microsoft.Agents.Core.Models; +using System.Threading.Tasks; +using System.Threading; + +namespace QuickStart; + +public class MyAgent : AgentApplication +{ + public MyAgent(AgentApplicationOptions options) : base(options) + { + OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync); + OnActivity(ActivityTypes.Message, OnMessageAsync, rank: RouteRank.Last); + } + + private async Task WelcomeMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + foreach (ChannelAccount member in turnContext.Activity.MembersAdded) + { + if (member.Id != turnContext.Activity.Recipient.Id) + { + await turnContext.SendActivityAsync(MessageFactory.Text("Hello and Welcome!"), cancellationToken); + } + } + } + + private async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + await turnContext.SendActivityAsync($"You said: {turnContext.Activity.Text}", cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/net/Program.cs b/dev/testing/cross-sdk-tests/agents/quickstart/net/Program.cs new file mode 100644 index 00000000..24375d2b --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/net/Program.cs @@ -0,0 +1,51 @@ + +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using QuickStart; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.Agents.Storage; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddHttpClient(); + +// Add AgentApplicationOptions from appsettings section "AgentApplication". +builder.AddAgentApplicationOptions(); + +// Add the AgentApplication, which contains the logic for responding to +// user messages. +builder.AddAgent(); + +// Register IStorage. For development, MemoryStorage is suitable. +// For production Agents, persisted storage should be used so +// that state survives Agent restarts, and operates correctly +// in a cluster of Agent instances. +builder.Services.AddSingleton(); + +// Configure the HTTP request pipeline. + +// Add AspNet token validation for Azure Bot Service and Entra. Authentication is +// configured in the appsettings.json "TokenValidation" section. +builder.Services.AddControllers(); +builder.Services.AddAgentAspNetAuthentication(builder.Configuration); + +WebApplication app = builder.Build(); + +// Enable AspNet authentication and authorization +app.UseAuthentication(); +app.UseAuthorization(); + +// Map GET "/" +app.MapAgentRootEndpoint(); + +// Map the endpoints for all agents using the [AgentInterface] attribute. +// If there is a single IAgent/AgentApplication, the endpoints will be mapped to (e.g. "/api/message"). +app.MapAgentApplicationEndpoints(requireAuth: !app.Environment.IsDevelopment()); + +app.Urls.Add($"http://localhost:3978"); + +app.Run(); diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/net/Quickstart.csproj b/dev/testing/cross-sdk-tests/agents/quickstart/net/Quickstart.csproj new file mode 100644 index 00000000..14a818ca --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/net/Quickstart.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + latest + disable + enable + + + + + + + + \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/net/_run_agent.ps1 b/dev/testing/cross-sdk-tests/agents/quickstart/net/_run_agent.ps1 new file mode 100644 index 00000000..bca86d74 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/net/_run_agent.ps1 @@ -0,0 +1,2 @@ +dotnet build +dotnet run \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/python/_run_agent.ps1 b/dev/testing/cross-sdk-tests/agents/quickstart/python/_run_agent.ps1 new file mode 100644 index 00000000..3dace17f --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/python/_run_agent.ps1 @@ -0,0 +1,6 @@ +python -m venv venv +.\venv\Scripts\Activate.ps1 + +pip install -r requirements.txt + +python -m src.main \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/test_examples.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/env.TEMPLATE similarity index 100% rename from dev/microsoft-agents-testing/tests/test_examples.py rename to dev/testing/cross-sdk-tests/agents/quickstart/python/env.TEMPLATE diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/python/requirements.txt b/dev/testing/cross-sdk-tests/agents/quickstart/python/requirements.txt new file mode 100644 index 00000000..0ca6c392 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/python/requirements.txt @@ -0,0 +1,3 @@ +microsoft-agents-hosting-core +microsoft-agents-hosting-aiohttp +microsoft-agents-authentication-msal \ No newline at end of file diff --git a/dev/tests/__init__.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/__init__.py similarity index 100% rename from dev/tests/__init__.py rename to dev/testing/cross-sdk-tests/agents/quickstart/python/src/__init__.py diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/python/src/agent.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/agent.py new file mode 100644 index 00000000..33571cbf --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/agent.py @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re +import sys +import traceback +from dotenv import load_dotenv + +from os import environ +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + Authorization, + AgentApplication, + TurnState, + TurnContext, + MemoryStorage, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.activity import load_configuration_from_env + +load_dotenv() +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) + +@AGENT_APP.conversation_update("membersAdded") +async def on_members_added(context: TurnContext, _state: TurnState): + await context.send_activity( + "Welcome to the empty agent! " + "This agent is designed to be a starting point for your own agent development." + ) + return True + + +@AGENT_APP.message(re.compile(r"^hello$")) +async def on_hello(context: TurnContext, _state: TurnState): + await context.send_activity("Hello!") + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _state: TurnState): + await context.send_activity(f"you said: {context.activity.text}") + + +@AGENT_APP.error +async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/python/src/main.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/main.py new file mode 100644 index 00000000..9139fe33 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/main.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + +start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), +) diff --git a/dev/testing/cross-sdk-tests/agents/quickstart/python/src/start_server.py b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/start_server.py new file mode 100644 index 00000000..9a747e75 --- /dev/null +++ b/dev/testing/cross-sdk-tests/agents/quickstart/python/src/start_server.py @@ -0,0 +1,39 @@ +from os import environ +import logging + +from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.hosting.aiohttp import ( + start_agent_process, + jwt_authorization_middleware, + CloudAdapter, +) +from aiohttp.web import Request, Response, Application, run_app + +logger = logging.getLogger(__name__) + +def start_server( + agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration +): + async def entry_point(req: Request) -> Response: + + logger.info("Request received at /api/messages endpoint.") + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + + return await start_agent_process( + req, + agent, + adapter, + ) + + APP = Application(middlewares=[jwt_authorization_middleware]) + APP.router.add_post("/api/messages", entry_point) + + APP["agent_configuration"] = auth_configuration + APP["agent_app"] = agent_application + APP["adapter"] = agent_application.adapter + + try: + run_app(APP, host="localhost", port=environ.get("PORT", 3978)) + except Exception as error: + raise error diff --git a/dev/tests/env.TEMPLATE b/dev/testing/cross-sdk-tests/env.TEMPLATE similarity index 100% rename from dev/tests/env.TEMPLATE rename to dev/testing/cross-sdk-tests/env.TEMPLATE diff --git a/dev/tests/pytest.ini b/dev/testing/cross-sdk-tests/pytest.ini similarity index 97% rename from dev/tests/pytest.ini rename to dev/testing/cross-sdk-tests/pytest.ini index 2c3d00cb..9908f4bf 100644 --- a/dev/tests/pytest.ini +++ b/dev/testing/cross-sdk-tests/pytest.ini @@ -7,7 +7,7 @@ filterwarnings = ignore::aiohttp.web.NotAppKeyWarning # Test discovery configuration -testpaths = ./ +testpaths = tests python_files = test_*.py *_test.py python_classes = Test* python_functions = test_* diff --git a/dev/tests/agents/basic_agent/__init__.py b/dev/testing/cross-sdk-tests/tests/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/__init__.py rename to dev/testing/cross-sdk-tests/tests/__init__.py diff --git a/dev/testing/cross-sdk-tests/tests/_common/__init__.py b/dev/testing/cross-sdk-tests/tests/_common/__init__.py new file mode 100644 index 00000000..b04ac09c --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/__init__.py @@ -0,0 +1,12 @@ +from .types import SDKVersion + +from .utils import ( + create_agent_path, + create_scenario, +) + +__all__ = [ + "create_agent_path", + "create_scenario", + "SDKVersion", +] \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/constants.py b/dev/testing/cross-sdk-tests/tests/_common/constants.py new file mode 100644 index 00000000..49f5acda --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/constants.py @@ -0,0 +1,7 @@ +import pathlib + +_AGENTS_DIR_NAME = "agents" +AGENTS_PATH = pathlib.Path.cwd() / _AGENTS_DIR_NAME +ENTRY_POINT_NAME = "_run_agent.ps1" + +DEFAULT_LOCAL_AGENT_ENDPOINT = "http://localhost:3978/api/messages" diff --git a/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py b/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py new file mode 100644 index 00000000..c19cb8fd --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/source_scenario.py @@ -0,0 +1,78 @@ +import asyncio +import shutil +import subprocess + +from pathlib import Path + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from microsoft_agents.testing import ( + ActivityTemplate, + ClientConfig, + ExternalScenario, + ScenarioConfig, +) +from microsoft_agents.testing.core import ClientFactory + +from .constants import DEFAULT_LOCAL_AGENT_ENDPOINT + +_TEMPLATE = { + "channel_id": "webchat", + "locale": "en-US", + "conversation": {"id": "conv1"}, + "from": {"id": "user1", "name": "User"}, + "recipient": {"id": "bot", "name": "Bot"}, +} + +client_config=ClientConfig( + activity_template=ActivityTemplate(_TEMPLATE) +) + +class SourceScenario(ExternalScenario): + """Base class for script-based test scenarios.""" + + def __init__( + self, + script_path: str, + delay: float = 0.0, + config: ScenarioConfig | None = None + ) -> None: + super().__init__(DEFAULT_LOCAL_AGENT_ENDPOINT, config) + self._script_path = Path(script_path) + self._delay = delay + + @asynccontextmanager + async def _run_script(self) -> AsyncIterator[None]: + + script_path = self._script_path.resolve() + + runner = shutil.which("pwsh") or shutil.which("powershell") + if runner is None: + raise FileNotFoundError("Could not find pwsh or powershell in PATH") + + try: + process = subprocess.Popen( + [runner, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=script_path.parent, + shell=True, # Needed to ensure the process group is correctly set up for termination + ) + + # wait for the agent to start running + await asyncio.sleep(self._delay) + + yield + + process.terminate() + except Exception as ex: + process.kill() + raise ex + + @asynccontextmanager + async def run(self) -> AsyncIterator[ClientFactory]: + """Start callback server and yield a client factory.""" + async with self._run_script(): + async with super().run() as factory: + yield factory \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/types.py b/dev/testing/cross-sdk-tests/tests/_common/types.py new file mode 100644 index 00000000..305ef1a3 --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/types.py @@ -0,0 +1,7 @@ +from enum import Enum + +class SDKVersion(str, Enum): + + PYTHON = "python" + JS = "js" + NET = "net" \ No newline at end of file diff --git a/dev/testing/cross-sdk-tests/tests/_common/utils.py b/dev/testing/cross-sdk-tests/tests/_common/utils.py new file mode 100644 index 00000000..a545d3f6 --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/_common/utils.py @@ -0,0 +1,21 @@ +from microsoft_agents.testing import Scenario + +from . import constants +from .source_scenario import SourceScenario +from .types import SDKVersion + +def create_agent_path(agent_name: str, sdk_version: SDKVersion) -> str: + + agent_path = constants.AGENTS_PATH / agent_name / sdk_version.value / constants.ENTRY_POINT_NAME + if not agent_path.exists(): + raise FileNotFoundError(f"Agent path does not exist: {agent_path}") + + return str(agent_path.resolve()) + +def create_scenario(agent_name: str, sdk_version: SDKVersion, delay: float = 5.0) -> Scenario: + + agent_path = create_agent_path(agent_name, sdk_version) + return SourceScenario( + agent_path, + delay=delay, + ) \ No newline at end of file diff --git a/dev/tests/agents/basic_agent/python/__init__.py b/dev/testing/cross-sdk-tests/tests/basic/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/__init__.py rename to dev/testing/cross-sdk-tests/tests/basic/__init__.py diff --git a/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py b/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py new file mode 100644 index 00000000..4b43937e --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/basic/test_quickstart.py @@ -0,0 +1,71 @@ +import pytest + +from microsoft_agents.testing import AgentClient + +from tests._common import ( + create_scenario, + SDKVersion, +) + +AGENT_NAME = "quickstart" +PYTHON_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.PYTHON) +NET_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.NET) +JS_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.JS) + +class BaseTestQuickstart: + """Integration tests for the Quickstart scenario.""" + + @pytest.mark.asyncio + async def test_conversation_update(self, agent_client: AgentClient): + """Test sending a conversation update activity.""" + input_activity = agent_client.template.create({ + "type": "conversationUpdate", + "members_added": [ + {"id": "bot-id", "name": "Bot"}, + {"id": "user1", "name": "User"}, + ], + "textFormat": "plain", + "entities": [ + { + "type": "ClientCapabilities", + "requiresBotState": True, + "supportsTts": True + } + ], + "channel_data": {"clientActivityId": 123} + }) + + await agent_client.send(input_activity, wait=10) + agent_client.expect().that_for_one(type="message", text="~Welcome") + + @pytest.mark.asyncio + async def test_send_hello(self, agent_client: AgentClient): + """Test sending a 'hello' message and receiving a response.""" + await agent_client.send("hello", wait=10) + agent_client.expect().that_for_one(type="message", text="Hello!") + + @pytest.mark.asyncio + async def test_send_hi(self, agent_client: AgentClient): + """Test sending a 'hi' message and receiving a response.""" + + await agent_client.send("hi", wait=10) + responses = agent_client.recent() + + assert len(responses) == 2 + assert len(agent_client.history()) == 2 + + agent_client.expect().that_for_one(type="message", text="you said: hi") + agent_client.expect().that_for_one(type="typing") + + +@pytest.mark.agent_test(PYTHON_SCENARIO) +class TestQuickstartPython(BaseTestQuickstart): + pass + +@pytest.mark.agent_test(NET_SCENARIO) +class TestQuickstartNet(BaseTestQuickstart): + pass + +@pytest.mark.agent_test(JS_SCENARIO) +class TestQuickstartJS(BaseTestQuickstart): + pass \ No newline at end of file diff --git a/dev/tests/agents/basic_agent/python/src/__init__.py b/dev/testing/cross-sdk-tests/tests/core/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/__init__.py rename to dev/testing/cross-sdk-tests/tests/core/__init__.py diff --git a/dev/tests/integration/basic_agent/test_basic_agent_base.py b/dev/testing/cross-sdk-tests/tests/core/test_basic_agent_base.py similarity index 100% rename from dev/tests/integration/basic_agent/test_basic_agent_base.py rename to dev/testing/cross-sdk-tests/tests/core/test_basic_agent_base.py diff --git a/dev/tests/integration/basic_agent/test_directline.py b/dev/testing/cross-sdk-tests/tests/core/test_directline.py similarity index 100% rename from dev/tests/integration/basic_agent/test_directline.py rename to dev/testing/cross-sdk-tests/tests/core/test_directline.py diff --git a/dev/tests/integration/basic_agent/test_msteams.py b/dev/testing/cross-sdk-tests/tests/core/test_msteams.py similarity index 100% rename from dev/tests/integration/basic_agent/test_msteams.py rename to dev/testing/cross-sdk-tests/tests/core/test_msteams.py diff --git a/dev/tests/integration/basic_agent/test_webchat.py b/dev/testing/cross-sdk-tests/tests/core/test_webchat.py similarity index 100% rename from dev/tests/integration/basic_agent/test_webchat.py rename to dev/testing/cross-sdk-tests/tests/core/test_webchat.py diff --git a/dev/tests/agents/basic_agent/python/src/weather/__init__.py b/dev/testing/cross-sdk-tests/tests/telemetry/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/__init__.py rename to dev/testing/cross-sdk-tests/tests/telemetry/__init__.py diff --git a/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py b/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py new file mode 100644 index 00000000..f4f2b5ac --- /dev/null +++ b/dev/testing/cross-sdk-tests/tests/telemetry/test_basic_telemetry.py @@ -0,0 +1,28 @@ +import pytest + +from tests._common import ( + create_scenario, + SDKVersion, +) + +AGENT_NAME = "quickstart" +PYTHON_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.PYTHON) +NET_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.NET) +JS_SCENARIO = create_scenario(AGENT_NAME, SDKVersion.JS) + +class BaseTelemetryTests: + def test_telemetry(self, agent_client): + # This is a placeholder test. The actual telemetry tests will be implemented here. + assert True + +@pytest.mark.agent_test(PYTHON_SCENARIO) +class TestPythonTelemetry(BaseTelemetryTests): + pass + +@pytest.mark.agent_test(NET_SCENARIO) +class TestNetTelemetry(BaseTelemetryTests): + pass + +@pytest.mark.agent_test(JS_SCENARIO) +class TestJSTelemetry(BaseTelemetryTests): + pass \ No newline at end of file diff --git a/dev/testing/microsoft-agents-testing/README.md b/dev/testing/microsoft-agents-testing/README.md new file mode 100644 index 00000000..4acca0c5 --- /dev/null +++ b/dev/testing/microsoft-agents-testing/README.md @@ -0,0 +1,190 @@ +# Microsoft Agents Testing Framework + +A testing framework for M365 Agents that handles auth, callback servers, +activity construction, and response collection so your tests can focus on +what the agent actually does. + +```python +async with scenario.client() as client: + await client.send("Hello!", wait=0.2) + client.expect().that_for_any(text="Echo: Hello!") +``` + +## Installation + +```bash +pip install -e ./microsoft-agents-testing/ --config-settings editable_mode=compat +``` + +## Quick Start + +Define your agent, create a scenario, and write tests. The scenario takes +care of hosting, auth tokens, and response plumbing. + +### Pytest + +```python +import pytest +from microsoft_agents.testing import AiohttpScenario, AgentEnvironment + +async def init_echo(env: AgentEnvironment): + @env.agent_application.activity("message") + async def on_message(context, state): + await context.send_activity(f"Echo: {context.activity.text}") + +scenario = AiohttpScenario(init_agent=init_echo, use_jwt_middleware=False) + +@pytest.mark.agent_test(scenario) +class TestEcho: + async def test_responds(self, agent_client): + await agent_client.send("Hi!", wait=0.2) + agent_client.expect().that_for_any(text="Echo: Hi!") +``` + +```bash +pytest test_echo.py -v +``` + +### Without pytest + +The core has no pytest dependency — use `scenario.client()` as an async +context manager anywhere. + +```python +async with scenario.client() as client: + await client.send("Hi!", wait=0.2) + client.expect().that_for_any(text="Echo: Hi!") +``` + +### External agent + +To test an agent that's already running (locally or deployed), point +`ExternalScenario` at its endpoint. + +```python +from microsoft_agents.testing import ExternalScenario + +scenario = ExternalScenario("http://localhost:3978/api/messages") +async with scenario.client() as client: + await client.send("Hello!", wait=1.0) + client.expect().that_for_any(type="message") +``` + +## Scenarios + +A Scenario manages infrastructure (servers, auth, teardown) and gives you a +client to interact with the agent. Auth credentials and general SDK config settings come from a `.env` file. The path defaults to `.\.env` but this is configurable through `ScenarioConfig` and `ClientConfig`, which are passed in during `Scenario` and `AgentClient` constructions. + +| Scenario | Description | +|----------|-------------| +| `AiohttpScenario` | Hosts the agent in-process — fast, access to internals | +| `ExternalScenario` | Connects to a running agent at a URL | + +Swap one for the other and your assertions stay the same. + +## AgentClient + +The client you get from a scenario. Send messages, collect replies, make +assertions. Pass a string and it becomes a message `Activity` automatically. +Use `wait=` to pause for async callback responses, or use +`send_expect_replies()` when the agent replies inline. + +```python +await client.send("Hello!", wait=0.5) # send + wait for callbacks +replies = await client.send_expect_replies("Hi!") # inline replies +client.expect().that_for_any(text="~Hello") # assert +``` + +Every method has an `ex_` variant (`ex_send`, `ex_invoke`, etc.) that returns +the raw `Exchange` objects instead of just the response activities. + +## Expect & Select + +Fluent API for asserting on and filtering response collections. `Expect` +raises `AssertionError` with diagnostic context — it shows what was expected, +what was received, and which items were checked. Prefix a value with `~` for +substring matching, or pass a lambda for custom logic. The variable named `x` has a special meaning and is passed in dynamically during evaluation. + +```python +client.expect().that_for_any(text="~hello") # any reply contains "hello" +client.expect().that_for_none(text="~error") # no reply contains "error" +client.expect().that_for_exactly(2, type="message") # exactly 2 messages +client.expect().that_for_any(text=lambda x: len(x) > 10) # lambda predicate +``` + +`Select` filters and slices before you assert or extract: + +```python +from microsoft_agents.testing import Select +selected = Select(client.history()).where(type="message").last(3).get() +Select(client.history()).where(type="message").expect().that(text="~hello") +``` + +## Transcript + +Every request and response is recorded in a `Transcript`. When a test fails +you can print the conversation to see exactly what happened. + +`ConversationTranscriptFormatter` gives a chat-style view; +`ActivityTranscriptFormatter` shows all activities with selectable fields. +Both support `DetailLevel` (`MINIMAL`, `STANDARD`, `DETAILED`, `FULL`) and +`TimeFormat` (`CLOCK`, `RELATIVE`, `ELAPSED`). + +```python +from microsoft_agents.testing import ConversationTranscriptFormatter, DetailLevel + +ConversationTranscriptFormatter(detail=DetailLevel.FULL).print(client.transcript) +``` + +``` +[0.000s] You: Hello! + (253ms) +[0.253s] Agent: Echo: Hello! +``` + +## Pytest Plugin + +The plugin activates automatically on install. Decorate a class or function +with `@pytest.mark.agent_test(scenario)` — pass a `Scenario` instance, a URL +(creates `ExternalScenario`), or a registered scenario name — and request any +of these fixtures: + +| Fixture | Description | +|---------|-------------| +| `agent_client` | Send and assert | +| `agent_environment` | Agent internals (in-process only) | +| `agent_application` | `AgentApplication` instance | +| `storage` | `MemoryStorage` | +| `adapter` | `ChannelServiceAdapter` | +| `authorization` | `Authorization` handler | +| `connection_manager` | `Connections` manager | + +## Scenario Registry + +Register named scenarios so they can be shared across test files and +referenced by name in pytest markers. Use dot-notation for namespacing +(e.g., `"local.echo"`, `"staging.echo"`) and `discover()` with glob patterns +to find them. + +```python +from microsoft_agents.testing import scenario_registry + +scenario_registry.register("echo", echo_scenario) +scenario = scenario_registry.get("echo") + +# In a test — just pass the name +@pytest.mark.agent_test("echo") +class TestEcho: ... +``` + +## Documentation + +| Document | Contents | +|----------|----------| +| [MOTIVATION.md](MOTIVATION.md) | Before/after code comparison | +| [API.md](API.md) | Public API reference | +| [SAMPLES.md](SAMPLES.md) | Guide to the runnable samples | + +## License + +MIT License — Microsoft Corporation diff --git a/dev/microsoft-agents-testing/docs/API.md b/dev/testing/microsoft-agents-testing/docs/API.md similarity index 100% rename from dev/microsoft-agents-testing/docs/API.md rename to dev/testing/microsoft-agents-testing/docs/API.md diff --git a/dev/microsoft-agents-testing/docs/MOTIVATION.md b/dev/testing/microsoft-agents-testing/docs/MOTIVATION.md similarity index 100% rename from dev/microsoft-agents-testing/docs/MOTIVATION.md rename to dev/testing/microsoft-agents-testing/docs/MOTIVATION.md diff --git a/dev/microsoft-agents-testing/docs/README.md b/dev/testing/microsoft-agents-testing/docs/README.md similarity index 100% rename from dev/microsoft-agents-testing/docs/README.md rename to dev/testing/microsoft-agents-testing/docs/README.md diff --git a/dev/microsoft-agents-testing/docs/SAMPLES.md b/dev/testing/microsoft-agents-testing/docs/SAMPLES.md similarity index 100% rename from dev/microsoft-agents-testing/docs/SAMPLES.md rename to dev/testing/microsoft-agents-testing/docs/SAMPLES.md diff --git a/dev/microsoft-agents-testing/docs/samples/__init__.py b/dev/testing/microsoft-agents-testing/docs/samples/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/__init__.py rename to dev/testing/microsoft-agents-testing/docs/samples/__init__.py diff --git a/dev/microsoft-agents-testing/docs/samples/interactive.py b/dev/testing/microsoft-agents-testing/docs/samples/interactive.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/interactive.py rename to dev/testing/microsoft-agents-testing/docs/samples/interactive.py diff --git a/dev/microsoft-agents-testing/docs/samples/multi_client.py b/dev/testing/microsoft-agents-testing/docs/samples/multi_client.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/multi_client.py rename to dev/testing/microsoft-agents-testing/docs/samples/multi_client.py diff --git a/dev/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py b/dev/testing/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py rename to dev/testing/microsoft-agents-testing/docs/samples/pytest_plugin_usage.py diff --git a/dev/microsoft-agents-testing/docs/samples/quickstart.py b/dev/testing/microsoft-agents-testing/docs/samples/quickstart.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/quickstart.py rename to dev/testing/microsoft-agents-testing/docs/samples/quickstart.py diff --git a/dev/microsoft-agents-testing/docs/samples/scenario_registry_demo.py b/dev/testing/microsoft-agents-testing/docs/samples/scenario_registry_demo.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/scenario_registry_demo.py rename to dev/testing/microsoft-agents-testing/docs/samples/scenario_registry_demo.py diff --git a/dev/microsoft-agents-testing/docs/samples/test_motivation_assertions.py b/dev/testing/microsoft-agents-testing/docs/samples/test_motivation_assertions.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/test_motivation_assertions.py rename to dev/testing/microsoft-agents-testing/docs/samples/test_motivation_assertions.py diff --git a/dev/microsoft-agents-testing/docs/samples/transcript_formatting.py b/dev/testing/microsoft-agents-testing/docs/samples/transcript_formatting.py similarity index 100% rename from dev/microsoft-agents-testing/docs/samples/transcript_formatting.py rename to dev/testing/microsoft-agents-testing/docs/samples/transcript_formatting.py diff --git a/dev/testing/microsoft-agents-testing/env.TEMPLATE b/dev/testing/microsoft-agents-testing/env.TEMPLATE new file mode 100644 index 00000000..df82361b --- /dev/null +++ b/dev/testing/microsoft-agents-testing/env.TEMPLATE @@ -0,0 +1,5 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py similarity index 97% rename from dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py index aa148e34..8fb57d7c 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/aiohttp_scenario.py @@ -113,7 +113,7 @@ async def _init_agent_environment(self) -> dict: :return: The SDK configuration dictionary. """ - env_vars = dotenv_values(self._config.env_file_path) + env_vars = dotenv_values(self._config.env_file_path or ".env") sdk_config = load_configuration_from_env(env_vars) storage = MemoryStorage() @@ -179,10 +179,10 @@ async def run(self) -> AsyncIterator[ClientFactory]: async with callback_server.listen() as transcript: async with TestServer(app, port=3978) as server: - agent_url = f"http://{server.host}:{server.port}/" + agent_endpoint = f"http://127.0.0.1:{server.port}/api/messages" factory = _AiohttpClientFactory( - agent_url=agent_url, + agent_endpoint=agent_endpoint, response_endpoint=callback_server.service_endpoint, sdk_config=sdk_config, default_config=self._config.client_config, diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/env.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/commands/scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/cli_config.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/decorators.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/output.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/core/utils.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/main.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/main.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/main.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/main.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/auth_scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/cli/scenarios/basic_scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py similarity index 94% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py index 51a0a5e4..db2276cf 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/_aiohttp_client_factory.py @@ -32,14 +32,14 @@ class _AiohttpClientFactory: def __init__( self, - agent_url: str, + agent_endpoint: str, response_endpoint: str, sdk_config: dict, default_template: ActivityTemplate | None = None, default_config: ClientConfig | None = None, transcript: Transcript | None = None, ): - self._agent_url = agent_url + self._agent_endpoint = agent_endpoint self._response_endpoint = response_endpoint self._sdk_config = sdk_config self._default_template = default_template or ActivityTemplate() @@ -66,7 +66,7 @@ async def __call__(self, config: ClientConfig | None = None) -> AgentClient: pass # No auth available # Create session - session = ClientSession(base_url=self._agent_url, headers=headers) + session = ClientSession(headers=headers) self._sessions.append(session) # Build activity template with user identity @@ -76,7 +76,7 @@ async def __call__(self, config: ClientConfig | None = None) -> AgentClient: ) # Create sender and client - sender = AiohttpSender(session) + sender = AiohttpSender(self._agent_endpoint, session) return AgentClient(sender, self._transcript, template=template) async def cleanup(self): diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/agent_client.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/config.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/config.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/config.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/config.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py similarity index 93% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py index e384a393..a20a2fcc 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/external_scenario.py @@ -35,7 +35,7 @@ class ExternalScenario(Scenario): async with scenario.client() as client: replies = await client.send("Hello!") - :param endpoint: The URL of the agent's message endpoint. + :param agent_url: The URL of the agent's message endpoint. :param config: Optional scenario configuration. """ @@ -48,8 +48,8 @@ def __init__(self, endpoint: str, config: ScenarioConfig | None = None) -> None: @asynccontextmanager async def run(self) -> AsyncIterator[ClientFactory]: """Start callback server and yield a client factory.""" - - env_vars = dotenv_values(self._config.env_file_path) + + env_vars = dotenv_values(self._config.env_file_path or ".env") sdk_config = load_configuration_from_env(env_vars) callback_server = AiohttpCallbackServer(self._config.callback_server_port) @@ -58,7 +58,7 @@ async def run(self) -> AsyncIterator[ClientFactory]: # Create a factory that binds the agent URL, callback endpoint, # and SDK config so callers can create configured clients factory = _AiohttpClientFactory( - agent_url=self._endpoint, + agent_endpoint=self._endpoint, response_endpoint=callback_server.service_endpoint, sdk_config=sdk_config, default_config=self._config.client_config, diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/describe.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/model_predicate.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/quantifier.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/transform.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/readonly.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/safe_object.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/types/unset.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/backend/utils.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/expect.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py similarity index 88% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py index 581e7434..af22430b 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/model_template.py @@ -10,6 +10,7 @@ from __future__ import annotations from copy import deepcopy +from email.mime import base from typing import Generic, TypeVar, cast, Self from pydantic import BaseModel @@ -22,7 +23,7 @@ set_defaults, flatten, ) -from .utils import flatten_model_data +from .utils import flatten_model_data, rename_from_property ModelT = TypeVar("ModelT", bound=BaseModel | dict) @@ -82,15 +83,17 @@ def with_defaults(self, defaults: dict | None = None, **kwargs) -> ModelTemplate :return: A new ModelTemplate instance. """ new_template = deepcopy(self._defaults) - set_defaults(new_template, defaults, **kwargs) + defaults_copy = deepcopy(defaults) if defaults else {} + rename_from_property(defaults_copy) + set_defaults(new_template, defaults_copy, **kwargs) return ModelTemplate[ModelT](self._model_class, new_template) def with_updates(self, updates: dict | None = None, **kwargs) -> ModelTemplate[ModelT]: """Create a new ModelTemplate with updated default values.""" new_template = deepcopy(self._defaults) # Expand the updates first so they merge correctly with nested structure - flat_updates = flatten(updates or {}) - flat_kwargs = flatten(kwargs) + flat_updates = flatten_model_data(updates or {}) + flat_kwargs = flatten_model_data(kwargs) deep_update(new_template, flat_updates) deep_update(new_template, flat_kwargs) # Pass already-expanded data, avoid re-expansion @@ -128,6 +131,7 @@ def __init__(self, defaults: Activity | dict | None = None, **kwargs) -> None: :param kwargs: Additional default values as keyword arguments. """ super().__init__(Activity, defaults, **kwargs) + rename_from_property(self._defaults) def with_defaults(self, defaults: dict | None = None, **kwargs) -> ActivityTemplate: """Create a new ModelTemplate with additional default values. @@ -137,16 +141,18 @@ def with_defaults(self, defaults: dict | None = None, **kwargs) -> ActivityTempl :return: A new ModelTemplate instance. """ new_template = deepcopy(self._defaults) - set_defaults(new_template, defaults, **kwargs) + defaults_copy = deepcopy(defaults) if defaults else {} + rename_from_property(defaults_copy) + set_defaults(new_template, defaults_copy, **kwargs) return ActivityTemplate(new_template) def with_updates(self, updates: dict | None = None, **kwargs) -> ActivityTemplate: """Create a new ModelTemplate with updated default values.""" new_template = deepcopy(self._defaults) # Expand the updates first so they merge correctly with nested structure - flat_updates = flatten(updates or {}) - flat_kwargs = flatten(kwargs) + flat_updates = flatten_model_data(updates or {}) + flat_kwargs = flatten_model_data(kwargs) deep_update(new_template, flat_updates) deep_update(new_template, flat_kwargs) # Pass already-expanded data, avoid re-expansion - return ActivityTemplate(new_template) + return ActivityTemplate(new_template) \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/select.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py similarity index 68% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py index 91d19376..aab30872 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/fluent/utils.py @@ -11,6 +11,20 @@ from pydantic import BaseModel from .backend import expand, flatten +def rename_from_property(data: dict) -> None: + """Rename keys starting with 'from.' to 'from_property.' for compatibility.""" + mods = {} + for key in data.keys(): + if key.startswith("from."): + new_key = key.replace("from.", "from_property.") + mods[key] = new_key + elif key == "from": + new_key = "from_property" + mods[key] = new_key + + for old_key, new_key in mods.items(): + data[new_key] = data.pop(old_key) + def normalize_model_data(source: BaseModel | dict) -> dict: """Normalize a BaseModel or dictionary to an expanded dictionary. @@ -25,7 +39,9 @@ def normalize_model_data(source: BaseModel | dict) -> dict: source = cast(dict, source.model_dump(exclude_unset=True, mode="json")) return source - return expand(source) + expanded = expand(source) + rename_from_property(expanded) + return expanded def flatten_model_data(source: BaseModel | dict) -> dict: """Flatten model data to a single-level dictionary with dot-notation keys. @@ -41,4 +57,6 @@ def flatten_model_data(source: BaseModel | dict) -> dict: source = cast(dict, source.model_dump(exclude_unset=True, mode="json")) return flatten(source) - return flatten(source) \ No newline at end of file + flattened = flatten(source) + rename_from_property(flattened) + return flattened \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/scenario.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py similarity index 78% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py index 6e38efc7..f2527f2f 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_sender.py @@ -10,6 +10,7 @@ from typing import AsyncContextManager from aiohttp import ClientSession +from aiohttp.client_exceptions import ClientError from microsoft_agents.activity import Activity @@ -24,9 +25,14 @@ class AiohttpSender(Sender): the response in an Exchange object. """ - def __init__(self, session: ClientSession): + def __init__(self, endpoint, session: ClientSession): + self._endpoint = endpoint self._session = session + @property + def endpoint(self) -> str: + return self._endpoint + async def send(self, activity: Activity, transcript: Transcript | None = None, **kwargs) -> Exchange: """Send an activity and return the Exchange containing the response. @@ -41,7 +47,7 @@ async def send(self, activity: Activity, transcript: Transcript | None = None, * request_at = datetime.now(timezone.utc) try: async with self._session.post( - "api/messages", + self._endpoint, json=activity.model_dump( by_alias=True, exclude_unset=True, exclude_none=True, mode="json" ), @@ -49,17 +55,26 @@ async def send(self, activity: Activity, transcript: Transcript | None = None, * ) as response: response_at = datetime.now(timezone.utc) response_or_exception = response + + if response.status >= 300: + raise ClientError(f"Received non-success status code: {response.status}") + exchange = await Exchange.from_request( request_activity=activity, response_or_exception=response_or_exception, request_at=request_at, response_at=response_at, + status=response.status, **kwargs ) - except Exception as e: + except ClientError as e: + + if response_or_exception is not None: + raise # If we got a response but it was an error status, re-raise the exception + response_at = datetime.now(timezone.utc) - response_or_exception = e + response_or_exception = e exchange = await Exchange.from_request( request_activity=activity, diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/callback_server.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/sender.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/__init__.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py similarity index 95% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py index 3d98f197..6f12b7a3 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py +++ b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/exchange.py @@ -89,6 +89,7 @@ def is_allowed_exception(exception: Exception) -> bool: async def from_request( request_activity: Activity, response_or_exception: Exception | ResponseT, + status: int | None = None, **kwargs ) -> Exchange: """Create an Exchange from a request activity and its outcome. @@ -115,6 +116,13 @@ async def from_request( error=str(response_or_exception), **kwargs, ) + elif isinstance(status, int) and status >= 300: + text = await response_or_exception.text() + return Exchange( + request=request_activity, + error=text, + **kwargs + ) if isinstance(response_or_exception, aiohttp.ClientResponse): @@ -129,7 +137,7 @@ async def from_request( body = await response.text() activity_list = json.loads(body)["activities"] activities = [ Activity.model_validate(activity) for activity in activity_list ] - + elif request_activity.type == ActivityTypes.invoke: body = await response.text() body_json = json.loads(body) diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/transport/transcript/transcript.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/core/utils.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/pytest_plugin.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/scenario_registry.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/transcript_formatter.py diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/utils.py b/dev/testing/microsoft-agents-testing/microsoft_agents/testing/utils.py similarity index 100% rename from dev/microsoft-agents-testing/microsoft_agents/testing/utils.py rename to dev/testing/microsoft-agents-testing/microsoft_agents/testing/utils.py diff --git a/dev/microsoft-agents-testing/payload.json b/dev/testing/microsoft-agents-testing/payload.json similarity index 100% rename from dev/microsoft-agents-testing/payload.json rename to dev/testing/microsoft-agents-testing/payload.json diff --git a/dev/microsoft-agents-testing/pyproject.toml b/dev/testing/microsoft-agents-testing/pyproject.toml similarity index 100% rename from dev/microsoft-agents-testing/pyproject.toml rename to dev/testing/microsoft-agents-testing/pyproject.toml diff --git a/dev/microsoft-agents-testing/pytest.ini b/dev/testing/microsoft-agents-testing/pytest.ini similarity index 94% rename from dev/microsoft-agents-testing/pytest.ini rename to dev/testing/microsoft-agents-testing/pytest.ini index dae63b29..997500e6 100644 --- a/dev/microsoft-agents-testing/pytest.ini +++ b/dev/testing/microsoft-agents-testing/pytest.ini @@ -41,4 +41,5 @@ markers = slow: Slow tests that may take longer to run requires_network: Tests that require network access requires_auth: Tests that require authentication - failure_demo: Intentionally failing tests for assertion/transcript formatting review \ No newline at end of file + failure_demo: Intentionally failing tests for assertion/transcript formatting review + agent_test: Tests that involve agent interactions \ No newline at end of file diff --git a/dev/tests/agents/basic_agent/python/src/weather/agents/__init__.py b/dev/testing/microsoft-agents-testing/tests/__init__.py similarity index 100% rename from dev/tests/agents/basic_agent/python/src/weather/agents/__init__.py rename to dev/testing/microsoft-agents-testing/tests/__init__.py diff --git a/dev/microsoft-agents-testing/tests/cli/test_cli_integration.py b/dev/testing/microsoft-agents-testing/tests/cli/test_cli_integration.py similarity index 100% rename from dev/microsoft-agents-testing/tests/cli/test_cli_integration.py rename to dev/testing/microsoft-agents-testing/tests/cli/test_cli_integration.py diff --git a/dev/microsoft-agents-testing/tests/cli/test_output.py b/dev/testing/microsoft-agents-testing/tests/cli/test_output.py similarity index 100% rename from dev/microsoft-agents-testing/tests/cli/test_output.py rename to dev/testing/microsoft-agents-testing/tests/cli/test_output.py diff --git a/dev/tests/integration/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/__init__.py similarity index 100% rename from dev/tests/integration/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/__init__.py diff --git a/dev/tests/integration/basic_agent/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/__init__.py similarity index 100% rename from dev/tests/integration/basic_agent/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/__init__.py diff --git a/dev/tests/sdk/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/__init__.py similarity index 100% rename from dev/tests/sdk/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_describe.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_model_predicate.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_quantifier.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_transform.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/test_utils.py diff --git a/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_readonly.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/backend/types/test_unset.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_expect.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_expect.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/test_expect.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_expect.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_model_template.py similarity index 86% rename from dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_model_template.py index a002475e..a378987c 100644 --- a/dev/microsoft-agents-testing/tests/core/fluent/test_model_template.py +++ b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_model_template.py @@ -421,6 +421,88 @@ def test_dot_notation_for_conversation(self): assert activity.conversation.name == "Test Conv" +class TestActivityTemplateFromAliases: + """Tests for ActivityTemplate alias behavior between from and from_property.""" + + def test_from_dot_notation_defaults_are_normalized(self): + """Defaults using from.* are normalized to from_property.* internally.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from.id": "user123", "from.name": "Alias User"} + ) + + assert "from.id" not in template._defaults + assert "from.name" not in template._defaults + assert template._defaults["from_property.id"] == "user123" + assert template._defaults["from_property.name"] == "Alias User" + + def test_create_accepts_top_level_from_alias_in_defaults(self): + """Top-level from alias in defaults maps to Activity.from_property.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from": {"id": "user123", "name": "Alias User"}} + ) + + activity = template.create() + assert activity.from_property is not None + assert activity.from_property.id == "user123" + assert activity.from_property.name == "Alias User" + + def test_create_original_from_alias_overrides_from_property_default(self): + """create() accepts from alias and overrides from_property defaults.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from_property.id": "default-id", "from_property.name": "Default User"} + ) + + activity = template.create({"from": {"id": "override-id", "name": "Override User"}}) + assert activity.from_property is not None + assert activity.from_property.id == "override-id" + assert activity.from_property.name == "Override User" + + def test_create_original_from_property_overrides_from_dot_default(self): + """create() accepts from_property and overrides defaults authored with from.* alias.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from.id": "default-id", "from.name": "Default User"} + ) + + activity = template.create( + { + "from_property": { + "id": "override-id", + "name": "Override User", + } + } + ) + assert activity.from_property is not None + assert activity.from_property.id == "override-id" + assert activity.from_property.name == "Override User" + + def test_with_defaults_accepts_from_alias(self): + """with_defaults() supports from alias and produces from_property on create.""" + template = ActivityTemplate(type=ActivityTypes.message).with_defaults( + **{"from.id": "user123", "from.name": "Alias User"} + ) + + activity = template.create() + assert activity.from_property is not None + assert activity.from_property.id == "user123" + assert activity.from_property.name == "Alias User" + + def test_with_updates_accepts_from_alias(self): + """with_updates() supports from alias and updates existing from_property values.""" + template = ActivityTemplate( + type=ActivityTypes.message, + **{"from_property.id": "default-id", "from_property.name": "Default User"} + ).with_updates(**{"from.id": "updated-id", "from.name": "Updated User"}) + + activity = template.create() + assert activity.from_property is not None + assert activity.from_property.id == "updated-id" + assert activity.from_property.name == "Updated User" + + class TestActivityTemplateEquality: """Tests for ActivityTemplate equality comparison.""" diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_select.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_select.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/test_select.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_select.py diff --git a/dev/microsoft-agents-testing/tests/core/fluent/test_utils.py b/dev/testing/microsoft-agents-testing/tests/core/fluent/test_utils.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/fluent/test_utils.py rename to dev/testing/microsoft-agents-testing/tests/core/fluent/test_utils.py diff --git a/dev/microsoft-agents-testing/tests/core/test_agent_client.py b/dev/testing/microsoft-agents-testing/tests/core/test_agent_client.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/test_agent_client.py rename to dev/testing/microsoft-agents-testing/tests/core/test_agent_client.py diff --git a/dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py b/dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py similarity index 94% rename from dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py rename to dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py index 80833d3c..dd31e0a1 100644 --- a/dev/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py +++ b/dev/testing/microsoft-agents-testing/tests/core/test_aiohttp_client_factory.py @@ -30,7 +30,7 @@ def test_initialization_stores_all_parameters(self): sdk_config = {"CONNECTIONS": {}} factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config=sdk_config, default_template=template, @@ -38,7 +38,7 @@ def test_initialization_stores_all_parameters(self): transcript=transcript, ) - assert factory._agent_url == "http://localhost:3978" + assert factory._agent_endpoint == "http://localhost:3978" assert factory._response_endpoint == "http://localhost:9378/api/callback" assert factory._sdk_config is sdk_config assert factory._default_template is template @@ -48,7 +48,7 @@ def test_initialization_stores_all_parameters(self): def test_initialization_creates_empty_sessions_list(self): """Factory initializes with empty sessions list.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -60,17 +60,17 @@ def test_initialization_creates_empty_sessions_list(self): # ============================================================================ -# _AiohttpClientfactory Tests +# _AiohttpClientFactory Tests # ============================================================================ class TestAiohttpClientFactoryCreateClient: - """Tests for _AiohttpClientfactory method.""" + """Tests for _AiohttpClientFactory method.""" @pytest.fixture def factory(self): """Create a factory with default configuration.""" return _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(type="message"), @@ -210,7 +210,7 @@ class TestAiohttpClientFactoryAuthorization: async def test_explicit_authorization_header_preserved(self): """Explicit Authorization header is preserved.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -232,7 +232,7 @@ async def test_explicit_authorization_header_preserved(self): async def test_auth_token_overrides_when_no_explicit_authorization(self): """auth_token is used when no explicit Authorization header.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -254,7 +254,7 @@ async def test_auth_token_overrides_when_no_explicit_authorization(self): async def test_no_auth_when_no_token_and_no_sdk_config(self): """No Authorization header when no token and sdk_config fails.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, # Empty, will cause generate_token_from_config to fail default_template=ActivityTemplate(), @@ -278,7 +278,7 @@ async def test_sdk_config_token_generation_on_failure(self): invalid_sdk_config = {"CONNECTIONS": {"SERVICE_CONNECTION": {"SETTINGS": {}}}} factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config=invalid_sdk_config, default_template=ActivityTemplate(), @@ -306,7 +306,7 @@ class TestAiohttpClientFactoryCleanup: async def test_cleanup_closes_all_sessions(self): """cleanup closes all created sessions.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -331,7 +331,7 @@ async def test_cleanup_closes_all_sessions(self): async def test_cleanup_clears_sessions_list(self): """cleanup clears the sessions list.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -352,7 +352,7 @@ async def test_cleanup_clears_sessions_list(self): async def test_cleanup_on_empty_sessions_list(self): """cleanup handles empty sessions list gracefully.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -369,7 +369,7 @@ async def test_cleanup_on_empty_sessions_list(self): async def test_cleanup_can_be_called_multiple_times(self): """cleanup can be called multiple times safely.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -398,7 +398,7 @@ async def test_default_template_used_when_config_has_none(self): default_template = ActivityTemplate(type="message", text="Default") factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=default_template, @@ -421,7 +421,7 @@ async def test_config_template_used_when_provided(self): config = ClientConfig(activity_template=custom_template) factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=default_template, @@ -448,7 +448,7 @@ class TestAiohttpClientFactoryIntegration: async def test_full_workflow_create_and_cleanup(self): """Full workflow: create multiple clients, then cleanup.""" factory = _AiohttpClientFactory( - agent_url="http://localhost:3978", + agent_endpoint="http://localhost:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -481,7 +481,7 @@ async def test_full_workflow_create_and_cleanup(self): async def test_session_base_url_is_set_correctly(self): """Sessions are created with correct base_url.""" factory = _AiohttpClientFactory( - agent_url="http://my-agent:3978", + agent_endpoint="http://my-agent:3978", response_endpoint="http://localhost:9378/api/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -493,7 +493,6 @@ async def test_session_base_url_is_set_correctly(self): try: session = factory._sessions[0] - # aiohttp stores base_url as a URL object - assert str(session._base_url) == "http://my-agent:3978" + assert session._base_url is None finally: await factory.cleanup() diff --git a/dev/microsoft-agents-testing/tests/core/test_config.py b/dev/testing/microsoft-agents-testing/tests/core/test_config.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/test_config.py rename to dev/testing/microsoft-agents-testing/tests/core/test_config.py diff --git a/dev/microsoft-agents-testing/tests/core/test_external_scenario.py b/dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py similarity index 99% rename from dev/microsoft-agents-testing/tests/core/test_external_scenario.py rename to dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py index 85d21649..5fd29869 100644 --- a/dev/microsoft-agents-testing/tests/core/test_external_scenario.py +++ b/dev/testing/microsoft-agents-testing/tests/core/test_external_scenario.py @@ -238,7 +238,7 @@ async def test_run_passes_endpoint_to_factory(self): mock_server_class.return_value = mock_server async with scenario.run() as factory: - assert factory._agent_url == "http://my-agent:3978/api/messages" + assert factory._agent_endpoint == "http://my-agent:3978/api/messages" @pytest.mark.asyncio async def test_run_passes_service_endpoint_to_factory(self): @@ -539,7 +539,7 @@ async def test_run_with_none_env_file_path(self): mock_server_class.return_value = mock_server async with scenario.run() as factory: - mock_dotenv.assert_called_once_with(None) + mock_dotenv.assert_called_once_with(".env") # ============================================================================ diff --git a/dev/microsoft-agents-testing/tests/core/test_integration.py b/dev/testing/microsoft-agents-testing/tests/core/test_integration.py similarity index 92% rename from dev/microsoft-agents-testing/tests/core/test_integration.py rename to dev/testing/microsoft-agents-testing/tests/core/test_integration.py index b9facb30..f8133628 100644 --- a/dev/microsoft-agents-testing/tests/core/test_integration.py +++ b/dev/testing/microsoft-agents-testing/tests/core/test_integration.py @@ -169,10 +169,12 @@ async def test_sender_posts_to_real_server(self): """AiohttpSender posts to a real HTTP server.""" mock_server = MockAgentServer(port=9901) mock_server.default_response(Activity(type=ActivityTypes.message, text="Reply")) + + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -189,10 +191,11 @@ async def test_sender_with_expect_replies(self): Activity(type=ActivityTypes.message, text="Reply 1"), Activity(type=ActivityTypes.message, text="Reply 2") ) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) activity = Activity( type=ActivityTypes.message, text="Hello", @@ -210,10 +213,11 @@ async def test_sender_with_invoke(self): """AiohttpSender handles invoke activities.""" mock_server = MockAgentServer(port=9903) mock_server.on_invoke("action/test", 200, {"result": "success"}) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) activity = Activity(type=ActivityTypes.invoke, name="action/test") exchange = await sender.send(activity) @@ -226,10 +230,11 @@ async def test_sender_with_invoke(self): async def test_sender_records_to_transcript(self): """AiohttpSender records exchanges to transcript.""" mock_server = MockAgentServer(port=9904) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) transcript = Transcript() activity1 = Activity(type=ActivityTypes.message, text="First") @@ -255,10 +260,11 @@ async def test_client_sends_via_http(self): """AgentClient sends activities via real HTTP.""" mock_server = MockAgentServer(port=9905) mock_server.default_response(Activity(type=ActivityTypes.message, text="OK")) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + sender = AiohttpSender(agent_endpoint, session) template = ActivityTemplate( channel_id="test", **{"conversation.id": "conv-1", "from.id": "user-1"} @@ -285,8 +291,9 @@ async def test_client_full_conversation_flow(self): mock_server.default_response(Activity(type=ActivityTypes.message, text="I don't understand")) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) # Greeting @@ -311,8 +318,9 @@ async def test_client_invoke_via_http(self): mock_server.on_invoke("submit/form", 200, {"submitted": True, "id": "form-123"}) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) invoke_response = await client.invoke( @@ -406,11 +414,12 @@ async def test_factory_creates_working_client(self): """Factory creates clients that can communicate with agent.""" mock_server = MockAgentServer(port=9911) mock_server.default_response(Activity(type=ActivityTypes.message, text="Factory test OK")) - + agent_endpoint = f"{mock_server.endpoint}/api/messages" + async with mock_server.run(): transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(channel_id="test"), @@ -440,9 +449,10 @@ async def test_factory_applies_default_template(self): ) async with mock_server.run(): + agent_endpoint = f"{mock_server.endpoint}/api/messages" transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=default_template, @@ -467,9 +477,10 @@ async def test_factory_creates_multiple_clients(self): mock_server.default_response(Activity(type=ActivityTypes.message, text="OK")) async with mock_server.run(): + agent_endpoint = f"{mock_server.endpoint}/api/messages" transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -500,8 +511,9 @@ async def test_factory_cleanup_closes_sessions(self): mock_server = MockAgentServer(port=9914) async with mock_server.run(): + agent_endpoint = f"{mock_server.endpoint}/api/messages" factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(), @@ -581,8 +593,9 @@ async def test_complete_http_conversation_flow(self): async with mock_server.run(): # Setup infrastructure transcript = Transcript() + agent_endpoint = f"{mock_server.endpoint}/api/messages" factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate( @@ -633,11 +646,12 @@ async def test_multi_user_http_conversation(self): """Multiple users in same conversation via HTTP.""" mock_server = MockAgentServer(port=9921) mock_server.default_response(Activity(type=ActivityTypes.message, text="Received")) + agent_endpoint = f"{mock_server.endpoint}/api/messages" async with mock_server.run(): transcript = Transcript() factory = _AiohttpClientFactory( - agent_url=mock_server.endpoint, + agent_endpoint=agent_endpoint, response_endpoint="http://localhost:9999/callback", sdk_config={}, default_template=ActivityTemplate(**{"conversation.id": "multi-user-conv"}), @@ -683,8 +697,9 @@ async def test_invoke_and_message_mixed_flow(self): mock_server.default_response(Activity(type=ActivityTypes.message, text="Message received")) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) # Regular message @@ -729,8 +744,9 @@ async def test_select_and_expect_with_http_responses(self): ) async with mock_server.run(): - async with ClientSession(base_url=mock_server.endpoint) as session: - sender = AiohttpSender(session) + async with ClientSession() as session: + agent_endpoint = f"{mock_server.endpoint}/api/messages" + sender = AiohttpSender(agent_endpoint, session) client = AgentClient(sender=sender) responses = await client.send_expect_replies("report") diff --git a/dev/microsoft-agents-testing/tests/core/transport/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/transport/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_callback_server.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py similarity index 85% rename from dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py index c040696a..b90af39b 100644 --- a/dev/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py +++ b/dev/testing/microsoft-agents-testing/tests/core/transport/test_aiohttp_sender.py @@ -5,12 +5,13 @@ import json from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from contextlib import asynccontextmanager import pytest import aiohttp from aiohttp import ClientSession, ClientResponse +from aiohttp.client_exceptions import ClientError from microsoft_agents.activity import Activity, ActivityTypes, DeliveryModes from microsoft_agents.testing.core.transport import AiohttpSender @@ -36,6 +37,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post return mock_session +ENDPOINT = "http://localhost:9999/api/messages" class TestAiohttpSenderInitialization: """Tests for AiohttpSender initialization.""" @@ -44,7 +46,7 @@ def test_aiohttp_sender_stores_session(self): """AiohttpSender should store the provided session.""" mock_session = MagicMock(spec=ClientSession) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) assert sender._session is mock_session @@ -67,13 +69,13 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") await sender.send(activity) assert len(post_calls) == 1 - assert post_calls[0][0][0] == "api/messages" + assert post_calls[0][0][0] == "http://localhost:9999/api/messages" @pytest.mark.asyncio async def test_send_serializes_activity_correctly(self): @@ -90,7 +92,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") await sender.send(activity) @@ -107,7 +109,7 @@ async def test_send_returns_exchange(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -122,7 +124,7 @@ async def test_send_records_timestamps(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -138,7 +140,7 @@ async def test_send_records_to_transcript(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") transcript = Transcript() @@ -153,7 +155,7 @@ async def test_send_without_transcript_does_not_record(self): mock_response = create_mock_response(200, "OK") mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") # Should not raise @@ -175,7 +177,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") await sender.send(activity, timeout=30) @@ -198,7 +200,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -218,7 +220,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") exchange = await sender.send(activity) @@ -237,7 +239,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") transcript = Transcript() @@ -258,7 +260,7 @@ async def mock_post(*args, **kwargs): mock_session.post = mock_post - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity(type=ActivityTypes.message, text="Hello") with pytest.raises(ValueError, match="Unexpected error"): @@ -279,7 +281,7 @@ async def test_send_expect_replies_parses_responses(self): mock_response = create_mock_response(200, responses_json) mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity( type=ActivityTypes.message, text="Hello", @@ -304,7 +306,7 @@ async def test_send_invoke_parses_invoke_response(self): mock_response = create_mock_response(200, invoke_response_json) mock_session = create_mock_session(mock_response) - sender = AiohttpSender(session=mock_session) + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) activity = Activity( type=ActivityTypes.invoke, name="testAction" @@ -315,3 +317,15 @@ async def test_send_invoke_parses_invoke_response(self): assert exchange.invoke_response is not None assert exchange.invoke_response.status == 200 assert exchange.invoke_response.body == {"result": "success"} + + @pytest.mark.asyncio + async def test_send_raises_on_non_success_status(self): + """send should raise ClientError when response status >= 300.""" + mock_response = create_mock_response(status=404, text="Not Found") + mock_session = create_mock_session(mock_response) + + sender = AiohttpSender(endpoint=ENDPOINT, session=mock_session) + activity = Activity(type=ActivityTypes.message, text="Hello") + + with pytest.raises(ClientError, match="404"): + await sender.send(activity) \ No newline at end of file diff --git a/dev/microsoft-agents-testing/tests/core/transport/transcript/__init__.py b/dev/testing/microsoft-agents-testing/tests/core/transport/transcript/__init__.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/transcript/__init__.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/transcript/__init__.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py b/dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_exchange.py diff --git a/dev/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py b/dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py similarity index 100% rename from dev/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py rename to dev/testing/microsoft-agents-testing/tests/core/transport/transcript/test_transcript.py diff --git a/dev/microsoft-agents-testing/tests/manual.py b/dev/testing/microsoft-agents-testing/tests/manual.py similarity index 100% rename from dev/microsoft-agents-testing/tests/manual.py rename to dev/testing/microsoft-agents-testing/tests/manual.py diff --git a/dev/microsoft-agents-testing/tests/test_aiohttp_scenario.py b/dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_aiohttp_scenario.py rename to dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario.py diff --git a/dev/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py b/dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py rename to dev/testing/microsoft-agents-testing/tests/test_aiohttp_scenario_integration.py diff --git a/dev/testing/microsoft-agents-testing/tests/test_examples.py b/dev/testing/microsoft-agents-testing/tests/test_examples.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/microsoft-agents-testing/tests/test_pytest_plugin.py b/dev/testing/microsoft-agents-testing/tests/test_pytest_plugin.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_pytest_plugin.py rename to dev/testing/microsoft-agents-testing/tests/test_pytest_plugin.py diff --git a/dev/microsoft-agents-testing/tests/test_scenario_registry.py b/dev/testing/microsoft-agents-testing/tests/test_scenario_registry.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_scenario_registry.py rename to dev/testing/microsoft-agents-testing/tests/test_scenario_registry.py diff --git a/dev/microsoft-agents-testing/tests/test_scenario_registry_plugin.py b/dev/testing/microsoft-agents-testing/tests/test_scenario_registry_plugin.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_scenario_registry_plugin.py rename to dev/testing/microsoft-agents-testing/tests/test_scenario_registry_plugin.py diff --git a/dev/microsoft-agents-testing/tests/test_transcript_formatter.py b/dev/testing/microsoft-agents-testing/tests/test_transcript_formatter.py similarity index 100% rename from dev/microsoft-agents-testing/tests/test_transcript_formatter.py rename to dev/testing/microsoft-agents-testing/tests/test_transcript_formatter.py diff --git a/dev/testing/python-sdk-tests/README.md b/dev/testing/python-sdk-tests/README.md new file mode 100644 index 00000000..3a3a5f2f --- /dev/null +++ b/dev/testing/python-sdk-tests/README.md @@ -0,0 +1,11 @@ +# Python SDK (Integration) Tests + +## Description + +This directory contains integration tests for the Python SDK. + +## Directory structure + +`integration`: general-purpose tests that run with automated, locally-running agents +`scenarios`: agent scenarios used for testing +`sdk`: integration for specific SDK components. This directory mirrors the structure of the Python SDK libraries. \ No newline at end of file diff --git a/dev/testing/python-sdk-tests/__init__.py b/dev/testing/python-sdk-tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/python-sdk-tests/env.TEMPLATE b/dev/testing/python-sdk-tests/env.TEMPLATE new file mode 100644 index 00000000..df82361b --- /dev/null +++ b/dev/testing/python-sdk-tests/env.TEMPLATE @@ -0,0 +1,5 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO \ No newline at end of file diff --git a/dev/testing/python-sdk-tests/pytest.ini b/dev/testing/python-sdk-tests/pytest.ini new file mode 100644 index 00000000..9908f4bf --- /dev/null +++ b/dev/testing/python-sdk-tests/pytest.ini @@ -0,0 +1,33 @@ +[pytest] +# Pytest configuration for Microsoft Agents for Python + +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + ignore::aiohttp.web.NotAppKeyWarning + +# Test discovery configuration +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* +asyncio_mode=auto + +# Output configuration +addopts = + --strict-markers + --strict-config + --verbose + --tb=short + --durations=10 + +# Minimum version requirement +minversion = 6.0 + +# Markers for test categorization +markers = + unit: Unit tests + integration: Integration tests + slow: Slow tests that may take longer to run + requires_network: Tests that require network access + requires_auth: Tests that require authentication \ No newline at end of file diff --git a/dev/testing/python-sdk-tests/run_tests.ps1 b/dev/testing/python-sdk-tests/run_tests.ps1 new file mode 100644 index 00000000..e35a2531 --- /dev/null +++ b/dev/testing/python-sdk-tests/run_tests.ps1 @@ -0,0 +1,3 @@ +Get-ChildItem -Path tests -Filter test_*.py -Recurse | ForEach-Object { + pytest $_.FullName +} \ No newline at end of file diff --git a/dev/testing/python-sdk-tests/tests/__init__.py b/dev/testing/python-sdk-tests/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/testing/python-sdk-tests/tests/integration/__init__.py b/dev/testing/python-sdk-tests/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/tests/sdk/test_expect_replies.py b/dev/testing/python-sdk-tests/tests/integration/test_expect_replies.py similarity index 95% rename from dev/tests/sdk/test_expect_replies.py rename to dev/testing/python-sdk-tests/tests/integration/test_expect_replies.py index 3a338f32..993e56c0 100644 --- a/dev/tests/sdk/test_expect_replies.py +++ b/dev/testing/python-sdk-tests/tests/integration/test_expect_replies.py @@ -1,7 +1,7 @@ import pytest from microsoft_agents.activity import Activity from microsoft_agents.testing import AgentClient -from ..scenarios import load_scenario +from tests.scenarios import load_scenario @pytest.mark.agent_test(load_scenario("quickstart")) diff --git a/dev/tests/integration/test_quickstart.py b/dev/testing/python-sdk-tests/tests/integration/test_quickstart.py similarity index 98% rename from dev/tests/integration/test_quickstart.py rename to dev/testing/python-sdk-tests/tests/integration/test_quickstart.py index a1053775..58e07e5d 100644 --- a/dev/tests/integration/test_quickstart.py +++ b/dev/testing/python-sdk-tests/tests/integration/test_quickstart.py @@ -1,5 +1,4 @@ import pytest -from ..scenarios import load_scenario from microsoft_agents.testing import ( ActivityTemplate, @@ -8,6 +7,8 @@ ScenarioConfig, ) +from tests.scenarios import load_scenario + _TEMPLATE = { "channel_id": "webchat", "locale": "en-US", @@ -29,6 +30,7 @@ class TestQuickstart: @pytest.mark.asyncio async def test_conversation_update(self, agent_client: AgentClient): """Test sending a conversation update activity.""" + input_activity = agent_client.template.create({ "type": "conversationUpdate", "members_added": [ diff --git a/dev/tests/sdk/test_streaming_response.py b/dev/testing/python-sdk-tests/tests/integration/test_streaming_response.py similarity index 100% rename from dev/tests/sdk/test_streaming_response.py rename to dev/testing/python-sdk-tests/tests/integration/test_streaming_response.py diff --git a/dev/testing/python-sdk-tests/tests/integration/test_telemetry.py b/dev/testing/python-sdk-tests/tests/integration/test_telemetry.py new file mode 100644 index 00000000..9d233d44 --- /dev/null +++ b/dev/testing/python-sdk-tests/tests/integration/test_telemetry.py @@ -0,0 +1,148 @@ +import pytest + +from opentelemetry import trace, metrics +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from microsoft_agents.activity import DeliveryModes +from microsoft_agents.hosting.core.telemetry import ( + attributes, + SERVICE_NAME, + SERVICE_VERSION +) +from microsoft_agents.hosting.core.telemetry.adapter import constants as adapter_constants +from microsoft_agents.hosting.core.telemetry.turn_context import constants as turn_context_constants +from microsoft_agents.hosting.core.app.telemetry import constants as app_constants +from microsoft_agents.hosting.core.app.oauth.telemetry import constants as oauth_constants +from microsoft_agents.hosting.core.authorization.telemetry import constants as auth_constants +from microsoft_agents.hosting.core.connector.telemetry import constants as connector_constants +from microsoft_agents.hosting.core.storage.telemetry import constants as storage_constants + +from tests.scenarios import load_scenario + +from tests.utils.telemetry_fixtures import ( # unused imports are needed for fixtures + test_telemetry, + test_exporter, + test_metric_reader, +) +from tests.utils.telemetry_utils import ( + sum_counter, + sum_hist_count, + find_metric +) + +_SCENARIO = load_scenario("quickstart", use_jwt_middleware=False) + +def get_span(spans, name): + for span in spans: + if span.name == name: + return span + return None + +def assert_span(spans, name, expected_attributes: dict | None = None): + if not expected_attributes: + expected_attributes = {} + + for span in spans: + if span.name == name: + match = True + for key, value in expected_attributes.items(): + if key not in span.attributes or span.attributes[key] != value: + match = False + break + if match: + return + assert False, f"Span '{name}' with attributes {expected_attributes} not found" + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_basic(test_exporter, test_metric_reader, agent_client): + """Test that spans are created for a simple scenario.""" + + activity_id = "test-activity-id" + activity = agent_client.template.create( + { + "type": "message", + "id": activity_id + } + ) + + await agent_client.send_expect_replies(activity) + + spans = test_exporter.get_finished_spans() + + # We should have a span for the overall turn + assert_span(spans, app_constants.SPAN_ON_TURN, { + attributes.ROUTE_AUTHORIZED: True, + attributes.ROUTE_MATCHED: True, + attributes.ACTIVITY_ID: activity_id, + attributes.ACTIVITY_TYPE: "message" + }) + + assert_span(spans, app_constants.SPAN_BEFORE_TURN) + assert_span(spans, app_constants.SPAN_AFTER_TURN) + + assert_span(spans, app_constants.SPAN_ROUTE_HANDLER, { + attributes.ROUTE_IS_INVOKE: False, + attributes.ROUTE_IS_AGENTIC: False, + }) + + assert_span(spans, adapter_constants.SPAN_PROCESS, { + attributes.ACTIVITY_TYPE: "message", + attributes.ACTIVITY_CHANNEL_ID: activity.channel_id, + attributes.ACTIVITY_DELIVERY_MODE: DeliveryModes.expect_replies, + attributes.CONVERSATION_ID: activity.conversation.id, + attributes.IS_AGENTIC: False, + }) + + assert get_span(spans, adapter_constants.SPAN_CREATE_CONNECTOR_CLIENT) is None + assert_span(spans, adapter_constants.SPAN_CREATE_USER_TOKEN_CLIENT) + + metrics_data = test_metric_reader.get_metrics_data() + + received_activities = sum_counter(find_metric(metrics_data, adapter_constants.METRIC_ACTIVITIES_RECEIVED)) + assert received_activities >= 1 + + sent_activities = sum_counter(find_metric(metrics_data, adapter_constants.METRIC_ACTIVITIES_SENT)) + assert sent_activities >= 1 + + process_duration_count = sum_hist_count(find_metric(metrics_data, adapter_constants.METRIC_ADAPTER_PROCESS_DURATION)) + assert process_duration_count == 1 + + connector_request_count = sum_counter(find_metric(metrics_data, connector_constants.METRIC_CONNECTOR_REQUEST_COUNT)) + assert connector_request_count == 0 + + user_token_client_request_count = sum_counter(find_metric(metrics_data, connector_constants.METRIC_USER_TOKEN_CLIENT_REQUEST_COUNT)) + assert user_token_client_request_count == 0 + + turn_count = sum_counter(find_metric(metrics_data, app_constants.METRIC_TURN_COUNT)) + assert turn_count >= 1 + + turn_errors = sum_counter(find_metric(metrics_data, app_constants.METRIC_TURN_ERROR_COUNT)) + assert turn_errors == 0 + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_multiple_users(test_exporter, agent_client): + """Test that spans are created correctly for multiple users.""" + + activity1 = agent_client.template.create({ + "from.id": "user1", + "text": "Hello from user 1" + }) + + activity2 = agent_client.template.create({ + "from.id": "user2", + "text": "Hello from user 2" + }) + + await agent_client.send_expect_replies(activity1) + await agent_client.send_expect_replies(activity2) + + spans = test_exporter.get_finished_spans() + + assert len(list(filter(lambda span: span.name == app_constants.SPAN_ON_TURN, spans))) == 2 + assert len(list(filter(lambda span: span.name == adapter_constants.SPAN_PROCESS, spans))) == 2 \ No newline at end of file diff --git a/dev/tests/scenarios/__init__.py b/dev/testing/python-sdk-tests/tests/scenarios/__init__.py similarity index 90% rename from dev/tests/scenarios/__init__.py rename to dev/testing/python-sdk-tests/tests/scenarios/__init__.py index bfd4ee47..dd9b85a0 100644 --- a/dev/tests/scenarios/__init__.py +++ b/dev/testing/python-sdk-tests/tests/scenarios/__init__.py @@ -4,7 +4,7 @@ Scenario, ) -from .quickstart import init_app as init_quickstart +from .quickstart import init_agent as init_quickstart _SCENARIO_INITS = { "quickstart": init_quickstart, diff --git a/dev/tests/scenarios/quickstart.py b/dev/testing/python-sdk-tests/tests/scenarios/quickstart.py similarity index 93% rename from dev/tests/scenarios/quickstart.py rename to dev/testing/python-sdk-tests/tests/scenarios/quickstart.py index 2f8e0a7a..a0f0375c 100644 --- a/dev/tests/scenarios/quickstart.py +++ b/dev/testing/python-sdk-tests/tests/scenarios/quickstart.py @@ -10,9 +10,11 @@ TurnState ) -from microsoft_agents.testing import AgentEnvironment +from microsoft_agents.testing import ( + AgentEnvironment, +) -async def init_app(env: AgentEnvironment): +async def init_agent(env: AgentEnvironment): """Initialize the application for the quickstart sample.""" app: AgentApplication[TurnState] = env.agent_application diff --git a/dev/testing/python-sdk-tests/tests/utils/telemetry_fixtures.py b/dev/testing/python-sdk-tests/tests/utils/telemetry_fixtures.py new file mode 100644 index 00000000..ae2fcf95 --- /dev/null +++ b/dev/testing/python-sdk-tests/tests/utils/telemetry_fixtures.py @@ -0,0 +1,166 @@ +import pytest +from types import SimpleNamespace + +from opentelemetry import trace, metrics +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + + +class DeltaMetricReader: + """Wraps an InMemoryMetricReader so each test only sees metrics + accrued *after* the wrapper was created (or last reset). + + InMemoryMetricReader uses cumulative aggregation by default and has + no ``clear()`` method, so counters and histograms accumulate across + the whole session. This wrapper snapshots the cumulative values at + construction time and subtracts them from every subsequent + ``get_metrics_data()`` call, producing a delta view that is + compatible with the ``find_metric`` / ``sum_counter`` / + ``sum_hist_count`` helpers. + """ + + def __init__(self, inner: InMemoryMetricReader): + self._inner = inner + self._baseline: dict[tuple, tuple] = {} + self.reset() + + def reset(self): + """Capture the current cumulative values as the new zero-line.""" + data = self._inner.get_metrics_data() + self._baseline = self._snapshot(data) + + def force_flush(self): + self._inner.force_flush() + + def get_metrics_data(self): + """Return a metrics-data object containing only the delta + since the last ``reset()``.""" + raw = self._inner.get_metrics_data() + return self._subtract(raw, self._baseline) + + # -- internals -------------------------------------------------- + + @staticmethod + def _dp_key(metric_name, dp): + attrs = dp.attributes or {} + return (metric_name, tuple(sorted(attrs.items()))) + + @staticmethod + def _snapshot(data): + snap: dict[tuple, tuple] = {} + if data is None: + return snap + for rm in data.resource_metrics: + for sm in rm.scope_metrics: + for m in sm.metrics: + for dp in m.data.data_points: + k = DeltaMetricReader._dp_key(m.name, dp) + if hasattr(dp, "bucket_counts"): + snap[k] = ("hist", dp.count) + else: + snap[k] = ("counter", dp.value) + return snap + + @staticmethod + def _empty_data(): + return SimpleNamespace( + resource_metrics=[ + SimpleNamespace(scope_metrics=[SimpleNamespace(metrics=[])]) + ] + ) + + @staticmethod + def _subtract(data, baseline): + if data is None: + return DeltaMetricReader._empty_data() + all_metrics: list = [] + for rm in data.resource_metrics: + for sm in rm.scope_metrics: + for m in sm.metrics: + points: list = [] + for dp in m.data.data_points: + k = DeltaMetricReader._dp_key(m.name, dp) + base = baseline.get(k) + if hasattr(dp, "bucket_counts"): + base_count = base[1] if base else 0 + points.append( + SimpleNamespace( + attributes=dp.attributes, + count=dp.count - base_count, + ) + ) + else: + base_val = base[1] if base else 0 + points.append( + SimpleNamespace( + attributes=dp.attributes, + value=dp.value - base_val, + ) + ) + if points: + all_metrics.append( + SimpleNamespace( + name=m.name, + data=SimpleNamespace(data_points=points), + ) + ) + return SimpleNamespace( + resource_metrics=[ + SimpleNamespace(scope_metrics=[SimpleNamespace(metrics=all_metrics)]) + ] + ) + + +_metric_reader = None +_exporter = None + + +@pytest.fixture(scope="session") +def test_telemetry(): + """Set up fresh in-memory exporter for testing.""" + global _exporter, _metric_reader + + if _exporter is None: + exporter = InMemorySpanExporter() + metric_reader = InMemoryMetricReader() + + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + trace.set_tracer_provider(tracer_provider) + + meter_provider = MeterProvider([metric_reader]) + + metrics.set_meter_provider(meter_provider) + + _exporter = exporter + _metric_reader = metric_reader + else: + meter_provider = metrics.get_meter_provider() + tracer_provider = trace.get_tracer_provider() + + exporter = _exporter + metric_reader = _metric_reader + + yield _exporter, metric_reader + + exporter.clear() + + +@pytest.fixture(scope="function") +def test_exporter(test_telemetry): + """Provide the in-memory span exporter for each test.""" + exporter, _ = test_telemetry + exporter.clear() + return exporter + + +@pytest.fixture(scope="function") +def test_metric_reader(test_telemetry): + """Provide a delta view of the metric reader for each test. + Only metrics recorded *during* the test are visible.""" + _, metric_reader = test_telemetry + metric_reader.force_flush() + return DeltaMetricReader(metric_reader) diff --git a/dev/testing/python-sdk-tests/tests/utils/telemetry_utils.py b/dev/testing/python-sdk-tests/tests/utils/telemetry_utils.py new file mode 100644 index 00000000..3acb4008 --- /dev/null +++ b/dev/testing/python-sdk-tests/tests/utils/telemetry_utils.py @@ -0,0 +1,38 @@ +def find_metric(metrics_data, metric_name): + """Helper function to find a metric by name in the collected metrics data. + + Usage: + metric = find_metric(metrics_data, "my_metric_name") + """ + for resource_metric in metrics_data.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + if metric.name == metric_name: + return metric + return None + + +def sum_counter(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.value + return total + + +def sum_hist_count(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.count + return total diff --git a/dev/tests/agents/__init__.py b/dev/tests/agents/__init__.py deleted file mode 100644 index 8b5f94fa..00000000 --- a/dev/tests/agents/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .basic_agent \ No newline at end of file diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 50d97e7b..83eeadf8 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -25,6 +25,7 @@ AccessTokenProviderBase, AgentAuthConfiguration, ) +from microsoft_agents.hosting.core.authorization.telemetry import spans from microsoft_agents.authentication.msal.errors import authentication_errors logger = logging.getLogger(__name__) @@ -65,40 +66,48 @@ def __init__(self, msal_configuration: AgentAuthConfiguration): async def get_access_token( self, resource_url: str, scopes: list[str], force_refresh: bool = False ) -> str: - logger.debug( - f"Requesting access token for resource: {resource_url}, scopes: {scopes}" - ) - valid_uri, instance_uri = self._uri_validator(resource_url) - if not valid_uri: - raise ValueError(str(authentication_errors.InvalidInstanceUrl)) - assert instance_uri is not None # for mypy - - local_scopes = self._resolve_scopes_list(instance_uri, scopes) - msal_auth_client = self._get_client() - - if isinstance(msal_auth_client, ManagedIdentityClient): - logger.info("Acquiring token using Managed Identity Client.") - auth_result_payload = await _async_acquire_token_for_client( - msal_auth_client, resource=resource_url - ) - elif isinstance(msal_auth_client, ConfidentialClientApplication): - logger.info("Acquiring token using Confidential Client Application.") - auth_result_payload = await _async_acquire_token_for_client( - msal_auth_client, scopes=local_scopes + with spans.GetAccessToken( + scopes, + self._msal_configuration.AUTH_TYPE, + ): + logger.debug( + f"Requesting access token for resource: {resource_url}, scopes: {scopes}" ) - else: - auth_result_payload = None - - res = auth_result_payload.get("access_token") if auth_result_payload else None - if not res: - logger.error("Failed to acquire token for resource %s", auth_result_payload) - raise ValueError( - authentication_errors.FailedToAcquireToken.format( - str(auth_result_payload) + valid_uri, instance_uri = self._uri_validator(resource_url) + if not valid_uri: + raise ValueError(str(authentication_errors.InvalidInstanceUrl)) + assert instance_uri is not None # for mypy + + local_scopes = self._resolve_scopes_list(instance_uri, scopes) + msal_auth_client = self._get_client() + + if isinstance(msal_auth_client, ManagedIdentityClient): + logger.info("Acquiring token using Managed Identity Client.") + auth_result_payload = await _async_acquire_token_for_client( + msal_auth_client, resource=resource_url + ) + elif isinstance(msal_auth_client, ConfidentialClientApplication): + logger.info("Acquiring token using Confidential Client Application.") + auth_result_payload = await _async_acquire_token_for_client( + msal_auth_client, scopes=local_scopes ) + else: + auth_result_payload = None + + res = ( + auth_result_payload.get("access_token") if auth_result_payload else None ) + if not res: + logger.error( + "Failed to acquire token for resource %s", auth_result_payload + ) + raise ValueError( + authentication_errors.FailedToAcquireToken.format( + str(auth_result_payload) + ) + ) - return res + return res async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str @@ -109,44 +118,44 @@ async def acquire_token_on_behalf_of( :param user_assertion: The user assertion token. :return: The access token as a string. """ - - msal_auth_client = self._get_client() - if isinstance(msal_auth_client, ManagedIdentityClient): - logger.error( - "Attempted on-behalf-of flow with Managed Identity authentication." - ) - raise NotImplementedError( - str(authentication_errors.OnBehalfOfFlowNotSupportedManagedIdentity) - ) - elif isinstance(msal_auth_client, ConfidentialClientApplication): - # TODO: Handling token error / acquisition failed - - # MSAL in Python does not support async, so we use asyncio.to_thread to run it in - # a separate thread and avoid blocking the event loop - token = await asyncio.to_thread( - lambda: msal_auth_client.acquire_token_on_behalf_of( - scopes=scopes, user_assertion=user_assertion - ) - ) - - if "access_token" not in token: + with spans.AcquireTokenOnBehalfOf(scopes): + msal_auth_client = self._get_client() + if isinstance(msal_auth_client, ManagedIdentityClient): logger.error( - f"Failed to acquire token on behalf of user: {user_assertion}" + "Attempted on-behalf-of flow with Managed Identity authentication." ) - raise ValueError( - authentication_errors.FailedToAcquireToken.format(str(token)) + raise NotImplementedError( + str(authentication_errors.OnBehalfOfFlowNotSupportedManagedIdentity) + ) + elif isinstance(msal_auth_client, ConfidentialClientApplication): + # TODO: Handling token error / acquisition failed + + # MSAL in Python does not support async, so we use asyncio.to_thread to run it in + # a separate thread and avoid blocking the event loop + token = await asyncio.to_thread( + lambda: msal_auth_client.acquire_token_on_behalf_of( + scopes=scopes, user_assertion=user_assertion + ) ) - return token["access_token"] + if "access_token" not in token: + logger.error( + f"Failed to acquire token on behalf of user: {user_assertion}" + ) + raise ValueError( + authentication_errors.FailedToAcquireToken.format(str(token)) + ) - logger.error( - f"On-behalf-of flow is not supported with the current authentication type: {msal_auth_client.__class__.__name__}" - ) - raise NotImplementedError( - authentication_errors.OnBehalfOfFlowNotSupportedAuthType.format( - msal_auth_client.__class__.__name__ + return token["access_token"] + + logger.error( + f"On-behalf-of flow is not supported with the current authentication type: {msal_auth_client.__class__.__name__}" + ) + raise NotImplementedError( + authentication_errors.OnBehalfOfFlowNotSupportedAuthType.format( + msal_auth_client.__class__.__name__ + ) ) - ) @staticmethod def _resolve_authority( @@ -318,78 +327,80 @@ async def get_agentic_instance_token( :return: A tuple containing the agentic instance token and the agent application token. :rtype: tuple[str, str] """ - if not agent_app_instance_id: raise ValueError( str(authentication_errors.AgentApplicationInstanceIdRequired) ) - logger.info( - "Attempting to get agentic instance token from agent_app_instance_id %s", - agent_app_instance_id, - ) - agent_token_result = await self.get_agentic_application_token( - tenant_id, agent_app_instance_id - ) + with spans.GetAgenticInstanceToken(agent_app_instance_id): - if not agent_token_result: - logger.error( - "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", + logger.info( + "Attempting to get agentic instance token from agent_app_instance_id %s", agent_app_instance_id, ) - raise Exception( - authentication_errors.FailedToAcquireAgenticInstanceToken.format( - agent_app_instance_id - ) + agent_token_result = await self.get_agentic_application_token( + tenant_id, agent_app_instance_id ) - authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) + if not agent_token_result: + logger.error( + "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", + agent_app_instance_id, + ) + raise Exception( + authentication_errors.FailedToAcquireAgenticInstanceToken.format( + agent_app_instance_id + ) + ) - instance_app = ConfidentialClientApplication( - client_id=agent_app_instance_id, - authority=authority, - client_credential={"client_assertion": agent_token_result}, - # token_cache=self._token_cache, - ) + authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) - agentic_instance_token = await _async_acquire_token_for_client( - instance_app, ["api://AzureAdTokenExchange/.default"] - ) + instance_app = ConfidentialClientApplication( + client_id=agent_app_instance_id, + authority=authority, + client_credential={"client_assertion": agent_token_result}, + # token_cache=self._token_cache, + ) - if not agentic_instance_token: - logger.error( - "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", - agent_app_instance_id, + agentic_instance_token = await _async_acquire_token_for_client( + instance_app, ["api://AzureAdTokenExchange/.default"] ) - raise Exception( - authentication_errors.FailedToAcquireAgenticInstanceToken.format( - agent_app_instance_id + + if not agentic_instance_token: + logger.error( + "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", + agent_app_instance_id, + ) + raise Exception( + authentication_errors.FailedToAcquireAgenticInstanceToken.format( + agent_app_instance_id + ) ) - ) - # future scenario where we don't know the blueprint id upfront + # future scenario where we don't know the blueprint id upfront - token = agentic_instance_token.get("access_token") - if not token: - logger.error( - "Failed to acquire agentic instance token, %s", agentic_instance_token - ) - raise ValueError( - authentication_errors.FailedToAcquireToken.format( - str(agentic_instance_token) + token = agentic_instance_token.get("access_token") + if not token: + logger.error( + "Failed to acquire agentic instance token, %s", + agentic_instance_token, ) - ) - - logger.debug( - "Agentic blueprint id: %s", - _DeferredString( - lambda: jwt.decode(token, options={"verify_signature": False}).get( - "xms_par_app_azp" + raise ValueError( + authentication_errors.FailedToAcquireToken.format( + str(agentic_instance_token) + ) ) - ), - ) - return agentic_instance_token["access_token"], agent_token_result + logger.debug( + "Agentic blueprint id: %s", + _DeferredString( + lambda: jwt.decode(token, options={"verify_signature": False}).get( + "xms_par_app_azp" + ) + ), + ) + + return agentic_instance_token["access_token"], agent_token_result async def get_agentic_user_token( self, @@ -414,71 +425,73 @@ async def get_agentic_user_token( str(authentication_errors.AgentApplicationInstanceIdAndUserIdRequired) ) - logger.info( - "Attempting to get agentic user token from agent_app_instance_id %s and agentic_user_id %s", - agent_app_instance_id, - agentic_user_id, - ) - instance_token, agent_token = await self.get_agentic_instance_token( - tenant_id, agent_app_instance_id - ) + with spans.GetAgenticUserToken(agent_app_instance_id, agentic_user_id, scopes): - if not instance_token or not agent_token: - logger.error( - "Failed to acquire instance token or agent token for agent_app_instance_id %s and agentic_user_id %s", + logger.info( + "Attempting to get agentic user token from agent_app_instance_id %s and agentic_user_id %s", agent_app_instance_id, agentic_user_id, ) - raise Exception( - authentication_errors.FailedToAcquireInstanceOrAgentToken.format( - agent_app_instance_id, agentic_user_id - ) + instance_token, agent_token = await self.get_agentic_instance_token( + tenant_id, agent_app_instance_id ) - authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) - - instance_app = ConfidentialClientApplication( - client_id=agent_app_instance_id, - authority=authority, - client_credential={"client_assertion": agent_token}, - # token_cache=self._token_cache, - ) + if not instance_token or not agent_token: + logger.error( + "Failed to acquire instance token or agent token for agent_app_instance_id %s and agentic_user_id %s", + agent_app_instance_id, + agentic_user_id, + ) + raise Exception( + authentication_errors.FailedToAcquireInstanceOrAgentToken.format( + agent_app_instance_id, agentic_user_id + ) + ) - logger.info( - "Acquiring agentic user token for agent_app_instance_id %s and agentic_user_id %s", - agent_app_instance_id, - agentic_user_id, - ) - # MSAL in Python does not support async, so we use asyncio.to_thread to run it in - # a separate thread and avoid blocking the event loop - auth_result_payload = await _async_acquire_token_for_client( - instance_app, - scopes, - data={ - "user_id": agentic_user_id, - "user_federated_identity_credential": instance_token, - "grant_type": "user_fic", - }, - ) + authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) - if not auth_result_payload: - logger.error( - "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", - agent_app_instance_id, - agentic_user_id, - auth_result_payload, + instance_app = ConfidentialClientApplication( + client_id=agent_app_instance_id, + authority=authority, + client_credential={"client_assertion": agent_token}, + # token_cache=self._token_cache, ) - return None - access_token = auth_result_payload.get("access_token") - if not access_token: - logger.error( - "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", + logger.info( + "Acquiring agentic user token for agent_app_instance_id %s and agentic_user_id %s", agent_app_instance_id, agentic_user_id, - auth_result_payload, ) - return None + # MSAL in Python does not support async, so we use asyncio.to_thread to run it in + # a separate thread and avoid blocking the event loop + auth_result_payload = await _async_acquire_token_for_client( + instance_app, + scopes, + data={ + "user_id": agentic_user_id, + "user_federated_identity_credential": instance_token, + "grant_type": "user_fic", + }, + ) + + if not auth_result_payload: + logger.error( + "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", + agent_app_instance_id, + agentic_user_id, + auth_result_payload, + ) + return None + + access_token = auth_result_payload.get("access_token") + if not access_token: + logger.error( + "Failed to acquire agentic user token for agent_app_instance_id %s and agentic_user_id %s, %s", + agent_app_instance_id, + agentic_user_id, + auth_result_payload, + ) + return None - logger.info("Acquired agentic user token response.") - return access_token + logger.info("Acquired agentic user token response.") + return access_token diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py index c384dd95..88373953 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py @@ -69,6 +69,7 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: Returns: aiohttp Response object. """ + # Adapt request to protocol adapted_request = AiohttpRequestAdapter(request) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index d0eb6c1e..c8633ecf 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -30,7 +30,8 @@ InvokeResponse, ) -from ..turn_context import TurnContext +from microsoft_agents.hosting.core.turn_context import TurnContext + from ..agent import Agent from ..authorization import Connections from .app_error import ApplicationError @@ -40,6 +41,7 @@ from ..channel_service_adapter import ChannelServiceAdapter from .oauth import Authorization from .typing_indicator import TypingIndicator +from .telemetry import spans from ._type_defs import RouteHandler, RouteSelector from ._routes import _RouteList, _Route, RouteRank, _agentic_selector @@ -669,50 +671,51 @@ async def on_turn(self, context: TurnContext): async def _on_turn(self, context: TurnContext): typing = None try: - if context.activity.type != ActivityTypes.typing: - if self._options.start_typing_timer: - typing = TypingIndicator(context) - typing.start() + with spans.AppOnTurn(context) as on_turn_span: + if context.activity.type != ActivityTypes.typing: + if self._options.start_typing_timer: + typing = TypingIndicator(context) + typing.start() - self._remove_mentions(context) + self._remove_mentions(context) - logger.debug("Initializing turn state") - turn_state = await self._initialize_state(context) - if ( - context.activity.type == ActivityTypes.message - or context.activity.type == ActivityTypes.invoke - ): + logger.debug("Initializing turn state") + turn_state = await self._initialize_state(context) + if ( + context.activity.type == ActivityTypes.message + or context.activity.type == ActivityTypes.invoke + ): - ( - auth_intercepts, - continuation_activity, - ) = await self._auth._on_turn_auth_intercept(context, turn_state) - if auth_intercepts: - if continuation_activity: - new_context = copy(context) - new_context.activity = continuation_activity - logger.info( - "Resending continuation activity %s", - continuation_activity.text, - ) - await self.on_turn(new_context) - await turn_state.save(context) - return + ( + auth_intercepts, + continuation_activity, + ) = await self._auth._on_turn_auth_intercept(context, turn_state) + if auth_intercepts: + if continuation_activity: + new_context = copy(context) + new_context.activity = continuation_activity + logger.info( + "Resending continuation activity %s", + continuation_activity.text, + ) + await self.on_turn(new_context) + await turn_state.save(context) + return - logger.debug("Running before turn middleware") - if not await self._run_before_turn_middleware(context, turn_state): - return + logger.debug("Running before turn middleware") + if not await self._run_before_turn_middleware(context, turn_state): + return - logger.debug("Running file downloads") - await self._handle_file_downloads(context, turn_state) + logger.debug("Running file downloads") + await self._handle_file_downloads(context, turn_state) - logger.debug("Running activity handlers") - await self._on_activity(context, turn_state) + logger.debug("Running activity handlers") + await self._on_activity(context, turn_state, on_turn_span) - logger.debug("Running after turn middleware") - if await self._run_after_turn_middleware(context, turn_state): - await turn_state.save(context) - return + logger.debug("Running after turn middleware") + if await self._run_after_turn_middleware(context, turn_state): + await turn_state.save(context) + return except ApplicationError as err: logger.error( f"An application error occurred in the AgentApplication: {err}", @@ -777,23 +780,28 @@ async def _initialize_state(self, context: TurnContext) -> StateT: return turn_state async def _run_before_turn_middleware(self, context: TurnContext, state: StateT): - for before_turn in self._internal_before_turn: - is_ok = await before_turn(context, state) - if not is_ok: - await state.save(context) - return False - return True + with spans.AppBeforeTurn(): + for before_turn in self._internal_before_turn: + is_ok = await before_turn(context, state) + if not is_ok: + await state.save(context) + return False + return True async def _handle_file_downloads(self, context: TurnContext, state: StateT): - if self._options.file_downloaders and len(self._options.file_downloaders) > 0: - input_files = state.temp.input_files if state.temp.input_files else [] - for file_downloader in self._options.file_downloaders: - logger.info( - f"Using file downloader: {file_downloader.__class__.__name__}" - ) - files = await file_downloader.download_files(context) - input_files.extend(files) - state.temp.input_files = input_files + with spans.AppDownloadFiles(context): + if ( + self._options.file_downloaders + and len(self._options.file_downloaders) > 0 + ): + input_files = state.temp.input_files if state.temp.input_files else [] + for file_downloader in self._options.file_downloaders: + logger.info( + f"Using file downloader: {file_downloader.__class__.__name__}" + ) + files = await file_downloader.download_files(context) + input_files.extend(files) + state.temp.input_files = input_files def _contains_non_text_attachments(self, context: TurnContext): non_text_attachments = filter( @@ -803,18 +811,31 @@ def _contains_non_text_attachments(self, context: TurnContext): return len(list(non_text_attachments)) > 0 async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): - for after_turn in self._internal_after_turn: - is_ok = await after_turn(context, state) - if not is_ok: - await state.save(context) - return False - return True + with spans.AppAfterTurn(): + for after_turn in self._internal_after_turn: + is_ok = await after_turn(context, state) + if not is_ok: + await state.save(context) + return False + return True + + async def _on_activity( + self, + context: TurnContext, + state: StateT, + on_turn_span: spans.AppOnTurn | None = None, + ): + + route_matched: bool = False + route_authorized: bool = False - async def _on_activity(self, context: TurnContext, state: StateT): for route in self._route_list: if route.selector(context): + route_matched = True if not route.auth_handlers: - await route.handler(context, state) + route_authorized = True + with spans.AppRouteHandler(route.is_invoke, route.is_agentic): + await route.handler(context, state) else: sign_in_complete = True for auth_handler_id in route.auth_handlers: @@ -827,11 +848,19 @@ async def _on_activity(self, context: TurnContext, state: StateT): break if sign_in_complete: - await route.handler(context, state) - return - logger.warning( - f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" - ) + route_authorized = True + with spans.AppRouteHandler(route.is_invoke, route.is_agentic): + await route.handler(context, state) + break + + if not route_matched: + logger.warning( + f"No route found for activity type: {context.activity.type} with text: {context.activity.text}" + ) + if on_turn_span is not None: + on_turn_span.share( + route_authorized=route_authorized, route_matched=route_matched + ) async def _start_long_running_call( self, context: TurnContext, func: Callable[[TurnContext], Awaitable] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py index 902b0dd4..81a02b83 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py @@ -29,6 +29,7 @@ ) from .._sign_in_response import _SignInResponse from ._authorization_handler import _AuthorizationHandler +from ..telemetry import spans logger = logging.getLogger(__name__) @@ -119,21 +120,26 @@ async def _handle_obo( connection_name = exchange_connection or self._handler.obo_connection_name exchange_scopes = exchange_scopes or self._handler.scopes - if not connection_name or not exchange_scopes: - return input_token_response + with spans.AzureBotToken( + auth_handler_id=self._id, + connection_name=connection_name, + scopes=exchange_scopes, + ): + if not connection_name or not exchange_scopes: + return input_token_response - if not input_token_response.is_exchangeable(): - return input_token_response + if not input_token_response.is_exchangeable(): + return input_token_response - token_provider = self._connection_manager.get_connection(connection_name) - if not token_provider: - raise ValueError(f"Connection '{connection_name}' not found") + token_provider = self._connection_manager.get_connection(connection_name) + if not token_provider: + raise ValueError(f"Connection '{connection_name}' not found") - token = await token_provider.acquire_token_on_behalf_of( - scopes=exchange_scopes, - user_assertion=input_token_response.token, - ) - return TokenResponse(token=token) if token else TokenResponse() + token = await token_provider.acquire_token_on_behalf_of( + scopes=exchange_scopes, + user_assertion=input_token_response.token, + ) + return TokenResponse(token=token) if token else TokenResponse() async def _sign_out( self, @@ -147,10 +153,11 @@ async def _sign_out( :param auth_handler_id: Optional ID of the auth handler to use for sign out. If None, signs out from all the handlers. """ - flow, flow_storage_client = await self._load_flow(context) - logger.info("Signing out from handler: %s", self._id) - await flow.sign_out() - await flow_storage_client.delete(self._id) + with spans.AzureBotSignOut(auth_handler_id=self._id): + flow, flow_storage_client = await self._load_flow(context) + logger.info("Signing out from handler: %s", self._id) + await flow.sign_out() + await flow_storage_client.delete(self._id) async def _handle_flow_response( self, context: TurnContext, flow_response: _FlowResponse @@ -212,31 +219,40 @@ async def _sign_in( :return: The _SignInResponse containing the token response and flow state tag. :rtype: _SignInResponse """ - flow, flow_storage_client = await self._load_flow(context) - flow_response: _FlowResponse = await flow.begin_or_continue_flow( - context.activity - ) - - logger.info("Saving OAuth flow state to storage") - await flow_storage_client.write(flow_response.flow_state) - await self._handle_flow_response(context, flow_response) - - if flow_response.token_response: - # attempt exchange if needed - # if not needed, returns the same token - token_response = await self._handle_obo( - context, - flow_response.token_response, - exchange_connection, - exchange_scopes, + with spans.AzureBotSignIn( + auth_handler_id=self._id, + connection_name=exchange_connection, + scopes=exchange_scopes, + ): + flow, flow_storage_client = await self._load_flow(context) + flow_response: _FlowResponse = await flow.begin_or_continue_flow( + context.activity ) - return _SignInResponse( - token_response=token_response, - tag=_FlowStateTag.COMPLETE if token_response else _FlowStateTag.FAILURE, - ) + logger.info("Saving OAuth flow state to storage") + await flow_storage_client.write(flow_response.flow_state) + await self._handle_flow_response(context, flow_response) + + if flow_response.token_response: + # attempt exchange if needed + # if not needed, returns the same token + token_response = await self._handle_obo( + context, + flow_response.token_response, + exchange_connection, + exchange_scopes, + ) + + return _SignInResponse( + token_response=token_response, + tag=( + _FlowStateTag.COMPLETE + if token_response + else _FlowStateTag.FAILURE + ), + ) - return _SignInResponse(tag=flow_response.flow_state.tag) + return _SignInResponse(tag=flow_response.flow_state.tag) async def get_refreshed_token( self, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index 85fdc9a4..548edaab 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -16,6 +16,7 @@ from ....storage import Storage from ....authorization import Connections from ..auth_handler import AuthHandler +from ..telemetry import spans logger = logging.getLogger(__name__) @@ -179,9 +180,14 @@ async def get_refreshed_token( :param exchange_scopes: Optional list of scopes to request during token exchange. If None, default scopes will be used. :type exchange_scopes: Optional[list[str]], Optional """ - if not exchange_scopes: - exchange_scopes = self._handler.scopes or [] - return await self.get_agentic_user_token(context, exchange_scopes) + with spans.AgenticToken( + auth_handler_id=self._id, + connection_name=exchange_connection, + scopes=exchange_scopes, + ): + if not exchange_scopes: + exchange_scopes = self._handler.scopes or [] + return await self.get_agentic_user_token(context, exchange_scopes) async def sign_out( self, context: TurnContext, auth_handler_id: Optional[str] = None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/constants.py new file mode 100644 index 00000000..0897443b --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/constants.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +AGENTIC_TOKEN = "agents.authorization.agentic_token" +AZURE_BOT_TOKEN = "agents.authorization.azure_bot_token" +AZURE_BOT_SIGN_OUT = "agents.authorization.azure_bot_sign_out" +AZURE_BOT_SIGN_IN = "agents.authorization.azure_bot_sign_in" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/spans.py new file mode 100644 index 00000000..b7f3df5e --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/telemetry/spans.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span + +from microsoft_agents.hosting.core.telemetry import ( + attributes, + AttributeMap, + SimpleSpanWrapper, + format_scopes, +) +from . import constants + + +class _AuthorizationSpanWrapper(SimpleSpanWrapper): + """Base SpanWrapper for spans related to authorization operations. + + This is meant to be a base class for spans related to authorization operations, + and can be used to share common functionality and attributes + """ + + def __init__( + self, + span_name: str, + auth_handler_id: str, + connection_name: str | None = None, + scopes: list[str] | None = None, + ): + """Initializes the _StorageSpanWrapper span.""" + super().__init__(span_name) + self._auth_handler_id = auth_handler_id + self._connection_name = connection_name + self._scopes = scopes + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span ends.""" + + def _get_attributes(self) -> dict[str, str]: + """Gets the attributes to be added to the span.""" + attr_dict = { + attributes.AUTH_HANDLER_ID: self._auth_handler_id, + attributes.CONNECTION_NAME: self._connection_name or attributes.UNKNOWN, + } + if self._scopes is not None: + attr_dict[attributes.AUTH_SCOPES] = format_scopes(self._scopes) + return attr_dict + + +class AgenticToken(_AuthorizationSpanWrapper): + """Span wrapper for agentic token operations.""" + + def __init__( + self, + auth_handler_id: str, + connection_name: str | None, + scopes: list[str] | None, + ): + """Initializes the AgenticToken span.""" + super().__init__( + constants.AGENTIC_TOKEN, + auth_handler_id, + connection_name, + scopes, + ) + + +class AzureBotToken(_AuthorizationSpanWrapper): + """Span wrapper for azure bot token operations.""" + + def __init__( + self, + auth_handler_id: str, + connection_name: str | None, + scopes: list[str] | None, + ): + """Initializes the AzureBotToken span.""" + super().__init__( + constants.AZURE_BOT_TOKEN, + auth_handler_id, + connection_name, + scopes, + ) + + +class AzureBotSignIn(_AuthorizationSpanWrapper): + """Span wrapper for azure bot sign in operations.""" + + def __init__( + self, + auth_handler_id: str, + connection_name: str | None, + scopes: list[str] | None, + ): + """Initializes the AzureBotSignIn span.""" + super().__init__( + constants.AZURE_BOT_SIGN_IN, + auth_handler_id, + connection_name, + scopes, + ) + + +class AzureBotSignOut(_AuthorizationSpanWrapper): + """Span wrapper for azure bot sign out operations.""" + + def __init__(self, auth_handler_id: str): + """Initializes the AzureBotSignOut span.""" + super().__init__( + constants.AZURE_BOT_SIGN_OUT, + auth_handler_id, + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py new file mode 100644 index 00000000..a1dbfccf --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/constants.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_ON_TURN = "agents.app.run" +SPAN_ROUTE_HANDLER = "agents.app.route_handler" +SPAN_BEFORE_TURN = "agents.app.before_turn" +SPAN_AFTER_TURN = "agents.app.after_turn" +SPAN_DOWNLOAD_FILES = "agents.app.download_files" + +METRIC_TURN_COUNT = "agents.turn.count" +METRIC_TURN_ERROR_COUNT = "agents.turn.error.count" +METRIC_TURN_DURATION = "agents.turn.duration" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py new file mode 100644 index 00000000..8681358f --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/metrics.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +turn_count = agents_telemetry.meter.create_counter( + constants.METRIC_TURN_COUNT, + "turn", + description="Total number of turns processed by the agent", +) + +turn_error_count = agents_telemetry.meter.create_counter( + constants.METRIC_TURN_ERROR_COUNT, + "turn", + description="Number of turns that resulted in an error", +) + +turn_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_TURN_DURATION, + "ms", + description="Duration of agent turns in milliseconds", +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py new file mode 100644 index 00000000..f48be961 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/telemetry/spans.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span +from microsoft_agents.activity import TurnContextProtocol +from microsoft_agents.hosting.core.telemetry import ( + AttributeMap, + attributes, + SimpleSpanWrapper, + get_conversation_id, +) +from microsoft_agents.hosting.core.app._routes import _Route +from . import constants, metrics + + +class AppOnTurn(SimpleSpanWrapper): + """Span for the entire app run, starting from when an activity is received in the adapter, until a response is sent back (if applicable). This span is meant to be a parent span for all other spans created during the processing of the activity, and can be used to correlate all telemetry for a given app run.""" + + def __init__(self, turn_context: TurnContextProtocol): + """Initializes the AppOnTurn SpanWrapper. + + :param turn_context: The TurnContext for the app run, used to extract attributes for the span + """ + super().__init__(constants.SPAN_ON_TURN) + self._turn_context = turn_context + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the app run based on the outcome of the span.""" + attrs = { + attributes.ACTIVITY_TYPE: self._turn_context.activity.type, + attributes.ACTIVITY_CHANNEL_ID: self._turn_context.activity.channel_id + or attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id( + self._turn_context.activity + ), + } + if error is None: + metrics.turn_count.add(1, attributes=attrs) + metrics.turn_duration.record(duration, attributes=attrs) + else: + metrics.turn_error_count.add(1, attributes=attrs) + + def _get_attributes(self) -> AttributeMap: + return { + attributes.ACTIVITY_TYPE: self._turn_context.activity.type, + attributes.ACTIVITY_ID: self._turn_context.activity.id + or attributes.UNKNOWN, + } + + def share(self, route_authorized: bool, route_matched: bool) -> None: + """Shares the span context for this app run with downstream spans, and adds attributes related to routing decisions + + :param route_authorized: Whether the route for this app run was authorized + :param route_matched: Whether the route for this app run was matched + """ + if self._span is not None: + self._span.set_attribute(attributes.ROUTE_AUTHORIZED, route_authorized) + self._span.set_attribute(attributes.ROUTE_MATCHED, route_matched) + + +class AppRouteHandler(SimpleSpanWrapper): + """Span for handling the routing logic. From selection, through authorization, and through the invocation of the route handler.""" + + def __init__(self, is_invoke: bool, is_agentic: bool): + """Initializes the AppRouteHandler SpanWrapper.""" + super().__init__(constants.SPAN_ROUTE_HANDLER) + self._is_invoke = is_invoke + self._is_agentic = is_agentic + + def _get_attributes(self) -> AttributeMap: + """Gets attributes for the AppRouteHandler span, based on the activity being processed.""" + return { + attributes.ROUTE_IS_INVOKE: self._is_invoke, + attributes.ROUTE_IS_AGENTIC: self._is_agentic, + } + + +class AppBeforeTurn(SimpleSpanWrapper): + """Span for the logic that happens before the main turn processing. This is meant to capture telemetry for the pre-processing logic of the app run, and can be used to identify issues in the early stages of the app run before the main processing logic is invoked.""" + + def __init__(self): + """Initializes the AppBeforeTurn SpanWrapper.""" + super().__init__(constants.SPAN_BEFORE_TURN) + + +class AppAfterTurn(SimpleSpanWrapper): + """Span for the logic that happens after the main turn processing. This is meant to capture telemetry for the post-processing logic of the app run, and can be used to identify issues in the later stages of the app run after the main processing logic is invoked.""" + + def __init__(self): + """Initializes the AppAfterTurn SpanWrapper.""" + super().__init__(constants.SPAN_AFTER_TURN) + + +class AppDownloadFiles(SimpleSpanWrapper): + """Span for the logic related to downloading files in the app. This can be used to capture telemetry for file download operations, and to identify issues related to file downloads in the app.""" + + def __init__(self, turn_context: TurnContextProtocol): + """Initializes the AppDownloadFiles SpanWrapper.""" + super().__init__(constants.SPAN_DOWNLOAD_FILES) + self._turn_context = turn_context + + def _get_attributes(self) -> AttributeMap: + return { + attributes.ATTACHMENT_COUNT: len( + self._turn_context.activity.attachments or [] + ), + } diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py new file mode 100644 index 00000000..0bb23802 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/constants.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# Spans + +SPAN_GET_ACCESS_TOKEN = "agents.auth.getAccessToken" +SPAN_ACQUIRE_TOKEN_ON_BEHALF_OF = "agents.auth.acquireTokenOnBehalfOf" +SPAN_GET_AGENTIC_INSTANCE_TOKEN = "agents.auth.getAgenticInstanceToken" +SPAN_GET_AGENTIC_USER_TOKEN = "agents.auth.getAgenticUserToken" + +# Metrics + +METRIC_AUTH_TOKEN_REQUEST_DURATION = "agents.auth.token.request.duration" +METRIC_AUTH_TOKEN_REQUEST_COUNT = "agents.auth.token.request.count" + +AUTH_METHOD_OBO = "obo" +AUTH_METHOD_AGENTIC_INSTANCE = "agentic_instance" +AUTH_METHOD_AGENTIC_USER = "agentic_user" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py new file mode 100644 index 00000000..fd5d0156 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/metrics.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +auth_token_request_count = agents_telemetry.meter.create_counter( + constants.METRIC_AUTH_TOKEN_REQUEST_COUNT, + "request", + description="Total number of auth token requests made by the AuthTokenClient", +) + +auth_token_request_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_AUTH_TOKEN_REQUEST_DURATION, + "ms", + description="Duration of auth token requests in milliseconds", +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py new file mode 100644 index 00000000..f0abfd4a --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/telemetry/spans.py @@ -0,0 +1,110 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span + +from microsoft_agents.hosting.core.telemetry import ( + attributes, + AttributeMap, + SimpleSpanWrapper, + format_scopes, +) +from . import constants, metrics + + +class _AuthenticationSpanWrapper(SimpleSpanWrapper): + """Base SpanWrapper for spans related to authentication operations. + + This is meant to be a base class for spans related to authentication operations, such as retrieving or validating tokens, + and can be used to share common functionality and attributes + """ + + def __init__(self, span_name: str, auth_method: str): + """Initializes the _StorageSpanWrapper span.""" + super().__init__(span_name) + self._auth_method = auth_method + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span ends. This function can be used to set additional attributes or record exceptions based on the outcome of the operation being traced.""" + attrs = { + attributes.AUTH_METHOD: self._auth_method, + attributes.AUTH_SUCCESS: error is None, + } + metrics.auth_token_request_count.add(1, attributes=attrs) + metrics.auth_token_request_duration.record(duration, attributes=attrs) + + +class GetAccessToken(_AuthenticationSpanWrapper): + """Span wrapper for the operation of retrieving an access token.""" + + def __init__(self, scopes: list[str], auth_method: str): + """Initializes the GetAccessToken span with the specified authentication scope and type.""" + super().__init__(constants.SPAN_GET_ACCESS_TOKEN, auth_method) + self._scopes = scopes + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to be set on the span. This includes the authentication scope and type.""" + return { + attributes.AUTH_SCOPES: format_scopes(self._scopes), + attributes.AUTH_METHOD: self._auth_method, + } + + +class AcquireTokenOnBehalfOf(_AuthenticationSpanWrapper): + """Span wrapper for the operation of acquiring a token on behalf of a user.""" + + def __init__(self, scopes: list[str]): + """Initializes the AcquireTokenOnBehalfOf span with the specified authentication scope.""" + super().__init__( + constants.SPAN_ACQUIRE_TOKEN_ON_BEHALF_OF, constants.AUTH_METHOD_OBO + ) + self._scopes = scopes + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to be set on the span. This includes the authentication scope.""" + return { + attributes.AUTH_SCOPES: format_scopes(self._scopes), + } + + +class GetAgenticInstanceToken(_AuthenticationSpanWrapper): + """Span wrapper for the operation of retrieving an agentic instance token.""" + + def __init__(self, agentic_instance_id: str): + """Initializes the GetAgenticInstanceToken span with the specified agentic instance ID.""" + super().__init__( + constants.SPAN_GET_AGENTIC_INSTANCE_TOKEN, + constants.AUTH_METHOD_AGENTIC_INSTANCE, + ) + self._agentic_instance_id = agentic_instance_id + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to be set on the span. This includes the agentic instance ID.""" + return { + attributes.AGENTIC_INSTANCE_ID: self._agentic_instance_id, + } + + +class GetAgenticUserToken(_AuthenticationSpanWrapper): + """Span wrapper for the operation of retrieving an agentic user token.""" + + def __init__( + self, agentic_instance_id: str, agentic_user_id: str, scopes: list[str] + ): + """Initializes the GetAgenticUserToken span with the specified agentic instance ID, user ID, and authentication scopes.""" + super().__init__( + constants.SPAN_GET_AGENTIC_USER_TOKEN, constants.AUTH_METHOD_AGENTIC_USER + ) + self._agentic_instance_id = agentic_instance_id + self._agentic_user_id = agentic_user_id + self._scopes = scopes + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to be set on the span. This includes the agentic instance ID, user ID, and authentication scopes.""" + return { + attributes.AGENTIC_INSTANCE_ID: self._agentic_instance_id, + attributes.AGENTIC_USER_ID: self._agentic_user_id, + attributes.AUTH_SCOPES: format_scopes(self._scopes), + } diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 20539ee6..c81457c5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -35,6 +35,7 @@ AuthenticationConstants, ClaimsIdentity, ) +from microsoft_agents.hosting.core.telemetry.adapter import spans from .channel_service_client_factory_base import ChannelServiceClientFactoryBase from .channel_adapter import ChannelAdapter from .turn_context import TurnContext @@ -99,19 +100,22 @@ async def send_activities( if not connector_client: raise Error("Unable to extract ConnectorClient from turn context.") - if activity.reply_to_id: - response = await connector_client.conversations.reply_to_activity( - activity.conversation.id, - activity.reply_to_id, - activity, - ) - else: - response = ( - await connector_client.conversations.send_to_conversation( - activity.conversation.id, - activity, + with spans.AdapterSendActivities([activity]): + if activity.reply_to_id: + response = ( + await connector_client.conversations.reply_to_activity( + activity.conversation.id, + activity.reply_to_id, + activity, + ) + ) + else: + response = ( + await connector_client.conversations.send_to_conversation( + activity.conversation.id, + activity, + ) ) - ) response = response or ResourceResponse(id=activity.id or "") responses.append(response) @@ -136,16 +140,18 @@ async def update_activity(self, context: TurnContext, activity: Activity): if activity is None: raise TypeError("Expected Activity but got None instead") - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), - ) - if not connector_client: - raise Error("Unable to extract ConnectorClient from turn context.") + with spans.AdapterUpdateActivity(activity): - return await connector_client.conversations.update_activity( - activity.conversation.id, activity.id, activity - ) + connector_client = cast( + ConnectorClientBase, + context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), + ) + if not connector_client: + raise Error("Unable to extract ConnectorClient from turn context.") + + return await connector_client.conversations.update_activity( + activity.conversation.id, activity.id, activity + ) async def delete_activity( self, context: TurnContext, reference: ConversationReference @@ -165,16 +171,18 @@ async def delete_activity( if not reference: raise TypeError("Expected ConversationReference but got None instead") - connector_client = cast( - ConnectorClientBase, - context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), - ) - if not connector_client: - raise Error("Unable to extract ConnectorClient from turn context.") + with spans.AdapterDeleteActivity(context.activity): - await connector_client.conversations.delete_activity( - reference.conversation.id, reference.activity_id - ) + connector_client = cast( + ConnectorClientBase, + context.turn_state.get(self._AGENT_CONNECTOR_CLIENT_KEY), + ) + if not connector_client: + raise Error("Unable to extract ConnectorClient from turn context.") + + await connector_client.conversations.delete_activity( + reference.conversation.id, reference.activity_id + ) async def continue_conversation( # pylint: disable=arguments-differ self, @@ -196,21 +204,23 @@ async def continue_conversation( # pylint: disable=arguments-differ :param callback: The method to call for the resulting agent turn. :type callback: Callable[[:class:`microsoft_agents.hosting.core.turn_context.TurnContext`], Awaitable] """ - if not callable: + if not callable(callback): raise TypeError( "Expected Callback (Callable[[TurnContext], Awaitable]) but got None instead" ) - self._validate_continuation_activity(continuation_activity) + with spans.AdapterContinueConversation(continuation_activity): - claims_identity = self.create_claims_identity(agent_app_id) + self._validate_continuation_activity(continuation_activity) - return await self.process_proactive( - claims_identity, - continuation_activity, - claims_identity.get_token_audience(), - callback, - ) + claims_identity = self.create_claims_identity(agent_app_id) + + return await self.process_proactive( + claims_identity, + continuation_activity, + claims_identity.get_token_audience(), + callback, + ) async def continue_conversation_with_claims( self, @@ -231,12 +241,13 @@ async def continue_conversation_with_claims( :param audience: The audience for the conversation. :type audience: Optional[str] """ - return await self.process_proactive( - claims_identity, - continuation_activity, - audience or claims_identity.get_token_audience(), - callback, - ) + with spans.AdapterContinueConversation(continuation_activity): + return await self.process_proactive( + claims_identity, + continuation_activity, + audience or claims_identity.get_token_audience(), + callback, + ) async def create_conversation( # pylint: disable=arguments-differ self, @@ -520,22 +531,25 @@ def _process_turn_results(self, context: TurnContext) -> Optional[InvokeResponse # Handle ExpectedReplies scenarios where all activities have been # buffered and sent back at once in an invoke response. if context.activity.delivery_mode == DeliveryModes.expect_replies: - return InvokeResponse( - status=HTTPStatus.OK, - body=ExpectedReplies( - activities=context.buffered_reply_activities - ).model_dump(mode="json", by_alias=True, exclude_unset=True), - ) + with spans.AdapterSendActivities([context.activity]): + return InvokeResponse( + status=HTTPStatus.OK, + body=ExpectedReplies( + activities=context.buffered_reply_activities + ).model_dump(mode="json", by_alias=True, exclude_unset=True), + ) # Handle Invoke scenarios where the agent will return a specific body and return code. if context.activity.type == ActivityTypes.invoke: - activity_invoke_response: Activity = context.turn_state.get( - self.INVOKE_RESPONSE_KEY - ) - if not activity_invoke_response: - return InvokeResponse(status=HTTPStatus.NOT_IMPLEMENTED) - return InvokeResponse.model_validate(activity_invoke_response.value) + with spans.AdapterSendActivities([context.activity]): + activity_invoke_response: Activity = context.turn_state.get( + self.INVOKE_RESPONSE_KEY + ) + if not activity_invoke_response: + return InvokeResponse(status=HTTPStatus.NOT_IMPLEMENTED) + + return InvokeResponse.model_validate(activity_invoke_response.value) # No body to return return None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 865e084c..4230b4a1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -21,6 +21,7 @@ from ..attachments_base import AttachmentsBase from ..conversations_base import ConversationsBase from ..get_product_info import get_product_info +from ..telemetry import connector_spans as spans logger = logging.getLogger(__name__) @@ -71,20 +72,24 @@ async def get_attachment_info(self, attachment_id: str) -> AttachmentInfo: if attachment_id is None: raise ValueError("attachmentId is required") - url = f"v3/attachments/{attachment_id}" + with spans.ConnectorGetAttachmentInfo(attachment_id=attachment_id) as span: - logger.info("Getting attachment info for ID: %s", attachment_id) - async with self.client.get(url) as response: - if response.status >= 300: - logger.error( - "Error getting attachment info: %s", - response.status, - stack_info=True, - ) - response.raise_for_status() + url = f"v3/attachments/{attachment_id}" - data = await response.json() - return AttachmentInfo(**data) + logger.info("Getting attachment info for ID: %s", attachment_id) + async with self.client.get(url) as response: + span.share(http_method="GET", status_code=response.status) + + if response.status >= 300: + logger.error( + "Error getting attachment info: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return AttachmentInfo(**data) async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: """ @@ -107,20 +112,24 @@ async def get_attachment(self, attachment_id: str, view_id: str) -> BytesIO: ) raise ValueError("viewId is required") - url = f"v3/attachments/{attachment_id}/views/{view_id}" + with spans.ConnectorGetAttachment(attachment_id, view_id) as span: - logger.info( - "Getting attachment for ID: %s, View ID: %s", attachment_id, view_id - ) - async with self.client.get(url) as response: - if response.status >= 300: - logger.error( - "Error getting attachment: %s", response.status, stack_info=True - ) - response.raise_for_status() + url = f"v3/attachments/{attachment_id}/views/{view_id}" + + logger.info( + "Getting attachment for ID: %s, View ID: %s", attachment_id, view_id + ) + async with self.client.get(url) as response: + span.share(http_method="GET", status_code=response.status) + + if response.status >= 300: + logger.error( + "Error getting attachment: %s", response.status, stack_info=True + ) + response.raise_for_status() - data = await response.read() - return BytesIO(data) + data = await response.read() + return BytesIO(data) class ConversationsOperations(ConversationsBase): @@ -141,22 +150,29 @@ async def get_conversations( :param continuation_token: The continuation token for pagination. :return: A list of conversations. """ - params = ( - {"continuationToken": continuation_token} if continuation_token else None - ) + with spans.ConnectorGetConversations() as span: + params = ( + {"continuationToken": continuation_token} + if continuation_token + else None + ) - logger.info( - "Getting conversations with continuation token: %s", continuation_token - ) - async with self.client.get("v3/conversations", params=params) as response: - if response.status >= 300: - logger.error( - "Error getting conversations: %s", response.status, stack_info=True - ) - response.raise_for_status() + logger.info( + "Getting conversations with continuation token: %s", continuation_token + ) + async with self.client.get("v3/conversations", params=params) as response: + span.share(http_method="GET", status_code=response.status) - data = await response.json() - return ConversationsResult.model_validate(data) + if response.status >= 300: + logger.error( + "Error getting conversations: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return ConversationsResult.model_validate(data) async def create_conversation( self, body: ConversationParameters @@ -167,20 +183,23 @@ async def create_conversation( :param body: The conversation parameters. :return: The conversation resource response. """ - - logger.info("Creating a new conversation") - async with self.client.post( - "v3/conversations", - json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), - ) as response: - if response.status >= 300: - logger.error( - "Error creating conversation: %s", response.status, stack_info=True - ) - response.raise_for_status() - - data = await response.json() - return ConversationResourceResponse.model_validate(data) + with spans.ConnectorCreateConversation() as span: + logger.info("Creating a new conversation") + async with self.client.post( + "v3/conversations", + json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), + ) as response: + span.share(http_method="POST", status_code=response.status) + if response.status >= 300: + logger.error( + "Error creating conversation: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return ConversationResourceResponse.model_validate(data) async def reply_to_activity( self, conversation_id: str, activity_id: str, body: Activity @@ -200,45 +219,50 @@ async def reply_to_activity( ) raise ValueError("conversationId and activityId are required") - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities/{activity_id}" - - logger.info( - "Replying to activity: %s in conversation: %s. Activity type is %s", - activity_id, - conversation_id, - body.type, - ) - - async with self.client.post( - url, - json=body.model_dump( - by_alias=True, exclude_unset=True, exclude_none=True, mode="json" - ), - ) as response: + with spans.ConnectorReplyToActivity(conversation_id, activity_id) as span: - response_text = await response.text("utf-8") - - if response.status >= 300: - logger.error( - "Error replying to activity: %s", - response_text or response.status, - stack_info=True, - ) - response.raise_for_status() - - if not response_text: - resource_response = ResourceResponse() - else: - resource_response = ResourceResponse.model_validate_json(response_text) + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities/{activity_id}" logger.info( - "Reply to conversation/activity: %s, %s", - resource_response.id, + "Replying to activity: %s in conversation: %s. Activity type is %s", activity_id, + conversation_id, + body.type, ) - return resource_response + async with self.client.post( + url, + json=body.model_dump( + by_alias=True, exclude_unset=True, exclude_none=True, mode="json" + ), + ) as response: + span.share(http_method="POST", status_code=response.status) + + response_text = await response.text("utf-8") + + if response.status >= 300: + logger.error( + "Error replying to activity: %s", + response_text or response.status, + stack_info=True, + ) + response.raise_for_status() + + if not response_text: + resource_response = ResourceResponse() + else: + resource_response = ResourceResponse.model_validate_json( + response_text + ) + + logger.info( + "Reply to conversation/activity: %s, %s", + resource_response.id, + activity_id, + ) + + return resource_response async def send_to_conversation( self, conversation_id: str, body: Activity @@ -252,35 +276,39 @@ async def send_to_conversation( """ if not conversation_id: logger.error( - "ConversationsOperations.sent_to_conversation(): conversationId is required", + "ConversationsOperations.send_to_conversation(): conversationId is required", stack_info=True, ) raise ValueError("conversationId is required") - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities" + with spans.ConnectorSendToConversation(conversation_id, body.id) as span: - logger.info( - "Sending to conversation: %s. Activity type is %s", - conversation_id, - body.type, - ) - async with self.client.post( - url, - json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), - ) as response: - if response.status >= 300: - logger.error( - "Error sending to conversation: %s", - response.status, - stack_info=True, - ) - response.raise_for_status() + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities" - response_text = await response.text("utf-8") - if not response_text: - return ResourceResponse() - return ResourceResponse.model_validate_json(response_text) + logger.info( + "Sending to conversation: %s. Activity type is %s", + conversation_id, + body.type, + ) + async with self.client.post( + url, + json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), + ) as response: + span.share(http_method="POST", status_code=response.status) + + if response.status >= 300: + logger.error( + "Error sending to conversation: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + response_text = await response.text("utf-8") + if not response_text: + return ResourceResponse() + return ResourceResponse.model_validate_json(response_text) async def update_activity( self, conversation_id: str, activity_id: str, body: Activity @@ -300,8 +328,10 @@ async def update_activity( ) raise ValueError("conversationId and activityId are required") - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities/{activity_id}" + with spans.ConnectorUpdateActivity(conversation_id, activity_id) as span: + + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities/{activity_id}" logger.info( "Updating activity: %s in conversation: %s. Activity type is %s", @@ -311,7 +341,7 @@ async def update_activity( ) async with self.client.put( url, - json=body.model_dump(exclude_unset=True, by_alias=True), + json=body.model_dump(by_alias=True, exclude_unset=True), ) as response: if response.status >= 300: logger.error( @@ -319,8 +349,8 @@ async def update_activity( ) response.raise_for_status() - data = await response.json() - return ResourceResponse.model_validate(data) + data = await response.json() + return ResourceResponse.model_validate(data) async def delete_activity(self, conversation_id: str, activity_id: str) -> None: """ @@ -336,20 +366,24 @@ async def delete_activity(self, conversation_id: str, activity_id: str) -> None: ) raise ValueError("conversationId and activityId are required") - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/activities/{activity_id}" + with spans.ConnectorDeleteActivity(conversation_id, activity_id) as span: - logger.info( - "Deleting activity: %s from conversation: %s", - activity_id, - conversation_id, - ) - async with self.client.delete(url) as response: - if response.status >= 300: - logger.error( - "Error deleting activity: %s", response.status, stack_info=True - ) - response.raise_for_status() + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/activities/{activity_id}" + + logger.info( + "Deleting activity: %s from conversation: %s", + activity_id, + conversation_id, + ) + async with self.client.delete(url) as response: + span.share(http_method="DELETE", status_code=response.status) + + if response.status >= 300: + logger.error( + "Error deleting activity: %s", response.status, stack_info=True + ) + response.raise_for_status() async def upload_attachment( self, conversation_id: str, body: AttachmentData @@ -368,31 +402,37 @@ async def upload_attachment( ) raise ValueError("conversationId is required") - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/attachments" - - # Convert the AttachmentData to a dictionary - attachment_dict = { - "name": body.name, - "originalBase64": body.original_base64, - "type": body.type, - "thumbnailBase64": body.thumbnail_base64, - } + with spans.ConnectorUploadAttachment(conversation_id) as span: - logger.info( - "Uploading attachment to conversation: %s, Attachment name: %s", - conversation_id, - body.name, - ) - async with self.client.post(url, json=attachment_dict) as response: - if response.status >= 300: - logger.error( - "Error uploading attachment: %s", response.status, stack_info=True - ) - response.raise_for_status() + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/attachments" - data = await response.json() - return ResourceResponse.model_validate(data) + # Convert the AttachmentData to a dictionary + attachment_dict = { + "name": body.name, + "originalBase64": body.original_base64, + "type": body.type, + "thumbnailBase64": body.thumbnail_base64, + } + + logger.info( + "Uploading attachment to conversation: %s, Attachment name: %s", + conversation_id, + body.name, + ) + async with self.client.post(url, json=attachment_dict) as response: + span.share(http_method="POST", status_code=response.status) + + if response.status >= 300: + logger.error( + "Error uploading attachment: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return ResourceResponse.model_validate(data) async def get_conversation_members( self, conversation_id: str @@ -410,23 +450,27 @@ async def get_conversation_members( ) raise ValueError("conversationId is required") - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/members" + with spans.ConnectorGetConversationMembers() as span: - logger.info( - "Getting conversation members for conversation: %s", conversation_id - ) - async with self.client.get(url) as response: - if response.status >= 300: - logger.error( - "Error getting conversation members: %s", - response.status, - stack_info=True, - ) - response.raise_for_status() + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/members" - data = await response.json() - return [ChannelAccount.model_validate(member) for member in data] + logger.info( + "Getting conversation members for conversation: %s", conversation_id + ) + async with self.client.get(url) as response: + span.share(http_method="GET", status_code=response.status) + + if response.status >= 300: + logger.error( + "Error getting conversation members: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return [ChannelAccount.model_validate(member) for member in data] async def get_conversation_member( self, conversation_id: str, member_id: str @@ -445,25 +489,29 @@ async def get_conversation_member( ) raise ValueError("conversationId and memberId are required") - conversation_id = self._normalize_conversation_id(conversation_id) - url = f"v3/conversations/{conversation_id}/members/{member_id}" + with spans.ConnectorGetConversationMembers() as span: - logger.info( - "Getting conversation member: %s from conversation: %s", - member_id, - conversation_id, - ) - async with self.client.get(url) as response: - if response.status >= 300: - logger.error( - "Error getting conversation member: %s", - response.status, - stack_info=True, - ) - response.raise_for_status() + conversation_id = self._normalize_conversation_id(conversation_id) + url = f"v3/conversations/{conversation_id}/members/{member_id}" - data = await response.json() - return ChannelAccount.model_validate(data) + logger.info( + "Getting conversation member: %s from conversation: %s", + member_id, + conversation_id, + ) + async with self.client.get(url) as response: + span.share(http_method="GET", status_code=response.status) + + if response.status >= 300: + logger.error( + "Error getting conversation member: %s", + response.status, + stack_info=True, + ) + response.raise_for_status() + + data = await response.json() + return ChannelAccount.model_validate(data) async def delete_conversation_member( self, conversation_id: str, member_id: str diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py index 2a380acf..22759895 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py @@ -15,6 +15,7 @@ SignInResource, ) from ..get_product_info import get_product_info +from ..telemetry import user_token_client_spans as spans from ..user_token_base import UserTokenBase from ..agent_sign_in_base import AgentSignInBase @@ -80,27 +81,29 @@ async def get_sign_in_resource( :param final_redirect: Final redirect URL. :return: The sign-in resource. """ - params = {"state": state} - if code_challenge: - params["codeChallenge"] = code_challenge - if emulator_url: - params["emulatorUrl"] = emulator_url - if final_redirect: - params["finalRedirect"] = final_redirect - - logger.info( - "AgentSignIn.get_sign_in_resource(): Getting sign-in resource with params: %s", - params, - ) - async with self.client.get( - "api/botsignin/getSignInResource", params=params - ) as response: - if response.status >= 300: - logger.error("Error getting sign-in resource: %s", response.status) - response.raise_for_status() - - data = await response.json() - return SignInResource.model_validate(data) + with spans.GetSignInResource() as span: + params = {"state": state} + if code_challenge: + params["codeChallenge"] = code_challenge + if emulator_url: + params["emulatorUrl"] = emulator_url + if final_redirect: + params["finalRedirect"] = final_redirect + + logger.info( + "AgentSignIn.get_sign_in_resource(): Getting sign-in resource with params: %s", + params, + ) + async with self.client.get( + "api/botsignin/getSignInResource", params=params + ) as response: + span.share(http_method="GET", status_code=response.status) + if response.status >= 300: + logger.error("Error getting sign-in resource: %s", response.status) + response.raise_for_status() + + data = await response.json() + return SignInResource.model_validate(data) class UserToken(UserTokenBase): @@ -116,21 +119,29 @@ async def get_token( channel_id: Optional[str] = None, code: Optional[str] = None, ) -> TokenResponse: - params = {"userId": user_id, "connectionName": connection_name} - if channel_id: - params["channelId"] = channel_id - if code: - params["code"] = code + with spans.GetUserToken( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ) as span: + params = {"userId": user_id, "connectionName": connection_name} - logger.info("User_token.get_token(): Getting token with params: %s", params) - async with self.client.get("api/usertoken/GetToken", params=params) as response: - if response.status >= 300: - logger.error("Error getting token: %s", response.status) - response.raise_for_status() + if channel_id: + params["channelId"] = channel_id + if code: + params["code"] = code + + logger.info("User_token.get_token(): Getting token with params: %s", params) + async with self.client.get( + "api/usertoken/GetToken", params=params + ) as response: + span.share(http_method="GET", status_code=response.status) - data = await response.json() - return TokenResponse.model_validate(data) + if response.status >= 300: + logger.error("Error getting token: %s", response.status) + response.raise_for_status() + + data = await response.json() + return TokenResponse.model_validate(data) async def _get_token_or_sign_in_resource( self, @@ -142,29 +153,35 @@ async def _get_token_or_sign_in_resource( final_redirect: str = "", fwd_url: str = "", ) -> TokenOrSignInResourceResponse: - - params = { - "userId": user_id, - "connectionName": connection_name, - "channelId": channel_id, - "state": state, - "code": code, - "finalRedirect": final_redirect, - "fwdUrl": fwd_url, - } - - logger.info("Getting token or sign-in resource with params: %s", params) - async with self.client.get( - "/api/usertoken/GetTokenOrSignInResource", params=params - ) as response: - if response.status != 200: - logger.error( - "Error getting token or sign-in resource: %s", response.status - ) - response.raise_for_status() - - data = await response.json() - return TokenOrSignInResourceResponse.model_validate(data) + """Get token or sign-in resource for a user.""" + + with spans.GetTokenOrSignInResource( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ) as span: + params = { + "userId": user_id, + "connectionName": connection_name, + "channelId": channel_id, + "state": state, + "code": code, + "finalRedirect": final_redirect, + "fwdUrl": fwd_url, + } + + logger.info("Getting token or sign-in resource with params: %s", params) + async with self.client.get( + "/api/usertoken/GetTokenOrSignInResource", params=params + ) as response: + span.share(http_method="GET", status_code=response.status) + + if response.status != 200: + logger.error( + "Error getting token or sign-in resource: %s", response.status + ) + response.raise_for_status() + + data = await response.json() + return TokenOrSignInResourceResponse.model_validate(data) async def get_aad_tokens( self, @@ -173,21 +190,28 @@ async def get_aad_tokens( channel_id: Optional[str] = None, body: Optional[dict] = None, ) -> dict[str, TokenResponse]: - params = {"userId": user_id, "connectionName": connection_name} + """Get AAD tokens for a user.""" - if channel_id: - params["channelId"] = channel_id + with spans.GetAadTokens( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ) as span: + params = {"userId": user_id, "connectionName": connection_name} - logger.info("Getting AAD tokens with params: %s and body: %s", params, body) - async with self.client.post( - "api/usertoken/GetAadTokens", params=params, json=body - ) as response: - if response.status >= 300: - logger.error("Error getting AAD tokens: %s", response.status) - response.raise_for_status() + if channel_id: + params["channelId"] = channel_id + + logger.info("Getting AAD tokens with params: %s and body: %s", params, body) + async with self.client.post( + "api/usertoken/GetAadTokens", params=params, json=body + ) as response: + span.share(http_method="POST", status_code=response.status) - data = await response.json() - return {k: TokenResponse.model_validate(v) for k, v in data.items()} + if response.status >= 300: + logger.error("Error getting AAD tokens: %s", response.status) + response.raise_for_status() + + data = await response.json() + return {k: TokenResponse.model_validate(v) for k, v in data.items()} async def sign_out( self, @@ -195,20 +219,27 @@ async def sign_out( connection_name: Optional[str] = None, channel_id: Optional[str] = None, ) -> None: - params = {"userId": user_id} + """Sign out user from a connection.""" - if connection_name: - params["connectionName"] = connection_name - if channel_id: - params["channelId"] = channel_id + with spans.SignOut( + user_id=user_id, connection_name=connection_name, channel_id=channel_id + ) as span: + params = {"userId": user_id} - logger.info("Signing out user %s with params: %s", user_id, params) - async with self.client.delete( - "api/usertoken/SignOut", params=params - ) as response: - if response.status >= 300: - logger.error("Error signing out: %s", response.status) - response.raise_for_status() + if connection_name: + params["connectionName"] = connection_name + if channel_id: + params["channelId"] = channel_id + + logger.info("Signing out user %s with params: %s", user_id, params) + async with self.client.delete( + "api/usertoken/SignOut", params=params + ) as response: + span.share(http_method="DELETE", status_code=response.status) + + if response.status >= 300: + logger.error("Error signing out: %s", response.status) + response.raise_for_status() async def get_token_status( self, @@ -216,23 +247,30 @@ async def get_token_status( channel_id: Optional[str] = None, include: Optional[str] = None, ) -> list[TokenStatus]: - params = {"userId": user_id} + """Get token status for a user.""" - if channel_id: - params["channelId"] = channel_id - if include: - params["include"] = include + with spans.GetTokenStatus(user_id=user_id, channel_id=channel_id) as span: + params = {"userId": user_id} - logger.info("Getting token status for user %s with params: %s", user_id, params) - async with self.client.get( - "api/usertoken/GetTokenStatus", params=params - ) as response: - if response.status >= 300: - logger.error("Error getting token status: %s", response.status) - response.raise_for_status() + if channel_id: + params["channelId"] = channel_id + if include: + params["include"] = include + + logger.info( + "Getting token status for user %s with params: %s", user_id, params + ) + async with self.client.get( + "api/usertoken/GetTokenStatus", params=params + ) as response: + span.share(http_method="GET", status_code=response.status) + + if response.status >= 300: + logger.error("Error getting token status: %s", response.status) + response.raise_for_status() - data = await response.json() - return [TokenStatus.model_validate(status) for status in data] + data = await response.json() + return [TokenStatus.model_validate(status) for status in data] async def exchange_token( self, @@ -241,22 +279,29 @@ async def exchange_token( channel_id: str, body: Optional[dict] = None, ) -> TokenResponse: - params = { - "userId": user_id, - "connectionName": connection_name, - "channelId": channel_id, - } - - logger.info("Exchanging token with params: %s and body: %s", params, body) - async with self.client.post( - "api/usertoken/exchange", params=params, json=body - ) as response: - if response.status >= 300: - logger.error("Error exchanging token: %s", response.status) - response.raise_for_status() - - data = await response.json() - return TokenResponse.model_validate(data) + """Exchange token for a user.""" + + with spans.ExchangeToken( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ) as span: + params = { + "userId": user_id, + "connectionName": connection_name, + "channelId": channel_id, + } + + logger.info("Exchanging token with params: %s and body: %s", params, body) + async with self.client.post( + "api/usertoken/exchange", params=params, json=body + ) as response: + span.share(http_method="POST", status_code=response.status) + + if response.status >= 300: + logger.error("Error exchanging token: %s", response.status) + response.raise_for_status() + + data = await response.json() + return TokenResponse.model_validate(data) class UserTokenClient(UserTokenClientBase): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py new file mode 100644 index 00000000..fa5e108b --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/_request_span_wrapper.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import ( + attributes, + SimpleSpanWrapper, +) + + +class _RequestSpanWrapper(SimpleSpanWrapper): + + def __init__(self, span_name: str): + """Initializes the RequestSpanWrapper.""" + super().__init__(span_name) + self._http_method: str | None = None + self._status_code: int | None = None + + def _get_request_attributes(self) -> dict[str, str]: + """Returns a dictionary of attributes related to the request to set on the span.""" + attr_dict = {} + if self._http_method is not None: + attr_dict[attributes.HTTP_METHOD] = self._http_method + if self._status_code is not None: + attr_dict[attributes.HTTP_STATUS_CODE] = self._status_code + attr_dict[attributes.OPERATION] = self._span_name + return attr_dict + + def share( + self, *, http_method: str | None = None, status_code: int | None = None + ) -> None: + """Shares the span by setting the request and response attributes and ending the span. This should be called when the client operation is complete and a response is being sent back to the caller.""" + self._http_method = http_method + self._status_code = status_code diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_spans.py new file mode 100644 index 00000000..a14a4cbe --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/connector_spans.py @@ -0,0 +1,159 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span + +from microsoft_agents.hosting.core.telemetry import ( + attributes, + AttributeMap, +) +from ._request_span_wrapper import _RequestSpanWrapper +from . import metrics, constants + + +class _ConnectorSpanWrapper(_RequestSpanWrapper): + """Base SpanWrapper for spans related to connector operations in the adapter. This is meant to be a base class for spans related to connector operations, such as creating a connector client or creating a user token, and can be used to share common functionality and attributes related to connector operations.""" + + def __init__( + self, + span_name: str, + *, + conversation_id: str | None = None, + activity_id: str | None = None, + ): + """Initializes the _ConnectorSpanWrapper span.""" + super().__init__(span_name) + self._conversation_id = conversation_id + self._activity_id = activity_id + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the connector operation based on the outcome of the span.""" + attrs = self._get_request_attributes() + metrics.connector_request_duration.record(duration, attributes=attrs) + metrics.connector_request_count.add(1, attributes=attrs) + + def _get_attributes(self) -> dict[str, str]: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the connector operation being performed. + + NOTE: a dict is the annotated return type to allow child classes to add additional attributes. + """ + attr_dict = {} + if self._conversation_id is not None: + attr_dict[attributes.CONVERSATION_ID] = self._conversation_id + if self._activity_id is not None: + attr_dict[attributes.ACTIVITY_ID] = self._activity_id + return attr_dict + + +class ConnectorReplyToActivity(_ConnectorSpanWrapper): + """Span for replying to an activity using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str | None): + """Initializes the ConnectorReplyToActivity span.""" + super().__init__( + constants.SPAN_REPLY_TO_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id, + ) + + +class ConnectorSendToConversation(_ConnectorSpanWrapper): + """Span for sending to a conversation using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str | None): + """Initializes the ConnectorSendToConversation span.""" + super().__init__( + constants.SPAN_SEND_TO_CONVERSATION, + conversation_id=conversation_id, + activity_id=activity_id, + ) + + +class ConnectorUpdateActivity(_ConnectorSpanWrapper): + """Span for updating an activity using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str | None): + """Initializes the ConnectorUpdateActivity span.""" + super().__init__( + constants.SPAN_UPDATE_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id, + ) + + +class ConnectorDeleteActivity(_ConnectorSpanWrapper): + """Span for deleting an activity using the connector client in the adapter.""" + + def __init__(self, conversation_id: str, activity_id: str | None): + """Initializes the ConnectorDeleteActivity span.""" + super().__init__( + constants.SPAN_DELETE_ACTIVITY, + conversation_id=conversation_id, + activity_id=activity_id, + ) + + +class ConnectorCreateConversation(_ConnectorSpanWrapper): + """Span for creating a conversation using the connector client in the adapter.""" + + def __init__(self): + """Initializes the ConnectorCreateConversation span.""" + super().__init__(constants.SPAN_CREATE_CONVERSATION) + + +class ConnectorGetConversations(_ConnectorSpanWrapper): + """Span for getting conversations using the connector client in the adapter.""" + + def __init__(self): + """Initializes the ConnectorGetConversations span.""" + super().__init__(constants.SPAN_GET_CONVERSATIONS) + + +class ConnectorGetConversationMembers(_ConnectorSpanWrapper): + """Span for getting conversation members using the connector client in the adapter.""" + + def __init__(self): + """Initializes the ConnectorGetConversationMembers span.""" + super().__init__(constants.SPAN_GET_CONVERSATION_MEMBERS) + + +class ConnectorUploadAttachment(_ConnectorSpanWrapper): + """Span for uploading an attachment using the connector client in the adapter.""" + + def __init__(self, conversation_id: str): + """Initializes the ConnectorUploadAttachment span.""" + super().__init__( + constants.SPAN_UPLOAD_ATTACHMENT, conversation_id=conversation_id + ) + + +class ConnectorGetAttachmentInfo(_ConnectorSpanWrapper): + """Span for getting attachment info using the connector client in the adapter.""" + + def __init__(self, attachment_id: str): + """Initializes the ConnectorGetAttachmentInfo span.""" + super().__init__(constants.SPAN_GET_ATTACHMENT_INFO) + self._attachment_id = attachment_id + + def _get_attributes(self) -> AttributeMap: + attr_dict = super()._get_attributes() + attr_dict[attributes.ATTACHMENT_ID] = self._attachment_id + return attr_dict + + +class ConnectorGetAttachment(_ConnectorSpanWrapper): + """Span for getting an attachment using the connector client in the adapter.""" + + def __init__(self, attachment_id: str, view_id: str): + """Initializes the ConnectorGetAttachment span.""" + super().__init__(constants.SPAN_GET_ATTACHMENT) + self._attachment_id = attachment_id + self._view_id = view_id + + def _get_attributes(self) -> AttributeMap: + attr_dict = super()._get_attributes() + attr_dict[attributes.ATTACHMENT_ID] = self._attachment_id + attr_dict[attributes.VIEW_ID] = self._view_id + return attr_dict diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py new file mode 100644 index 00000000..3b3a9027 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/constants.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_REPLY_TO_ACTIVITY = "agents.connector.replyToActivity" +SPAN_SEND_TO_CONVERSATION = "agents.connector.sendToConversation" +SPAN_UPDATE_ACTIVITY = "agents.connector.updateActivity" +SPAN_DELETE_ACTIVITY = "agents.connector.deleteActivity" +SPAN_CREATE_CONVERSATION = "agents.connector.createConversation" +SPAN_GET_CONVERSATIONS = "agents.connector.getConversations" +SPAN_GET_CONVERSATION_MEMBERS = "agents.connector.getConversationMembers" +SPAN_UPLOAD_ATTACHMENT = "agents.connector.uploadAttachment" +SPAN_GET_ATTACHMENT = "agents.connector.getAttachment" +SPAN_GET_ATTACHMENT_INFO = "agents.connector.getAttachmentInfo" + +SPAN_GET_USER_TOKEN = "agents.user_token_client.get_user_token" +SPAN_SIGN_OUT = "agents.user_token_client.sign_out" +SPAN_GET_SIGN_IN_RESOURCE = "agents.user_token_client.get_sign_in_resource" +SPAN_EXCHANGE_TOKEN = "agents.user_token_client.exchange_token" +SPAN_GET_TOKEN_OR_SIGN_IN_RESOURCE = ( + "agents.user_token_client.get_token_or_sign_in_resource" +) +SPAN_GET_TOKEN_STATUS = "agents.user_token_client.get_token_status" +SPAN_GET_AAD_TOKENS = "agents.user_token_client.get_aad_tokens" + +METRIC_CONNECTOR_REQUEST_COUNT = "agents.connector.request.count" +METRIC_CONNECTOR_REQUEST_DURATION = "agents.connector.request.duration" + +METRIC_USER_TOKEN_CLIENT_REQUEST_COUNT = "agents.user_token_client.request.count" +METRIC_USER_TOKEN_CLIENT_REQUEST_DURATION = "agents.user_token_client.request.duration" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py new file mode 100644 index 00000000..47d61881 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/metrics.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +connector_request_count = agents_telemetry.meter.create_counter( + constants.METRIC_CONNECTOR_REQUEST_COUNT, + "request", + description="Total number of connector requests made by the ConnectorClient", +) + +connector_request_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_CONNECTOR_REQUEST_DURATION, + "ms", + description="Duration of connector requests in milliseconds", +) + +user_token_client_request_count = agents_telemetry.meter.create_counter( + constants.METRIC_USER_TOKEN_CLIENT_REQUEST_COUNT, + "request", + description="Total number of user token client requests made by the UserTokenClient", +) + +user_token_client_request_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_USER_TOKEN_CLIENT_REQUEST_DURATION, + "ms", + description="Duration of user token client requests in milliseconds", +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py new file mode 100644 index 00000000..9a6bdb62 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/telemetry/user_token_client_spans.py @@ -0,0 +1,141 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span + +from microsoft_agents.hosting.core.telemetry import attributes +from ._request_span_wrapper import _RequestSpanWrapper +from . import metrics, constants + + +class _UserTokenClientSpanWrapper(_RequestSpanWrapper): + """Base SpanWrapper for spans related to user token client operations in the adapter. This is meant to be a base class for spans related to user token client operations, such as creating a user token, and can be used to share common functionality and attributes related to user token client operations.""" + + def __init__( + self, + span_name: str, + *, + connection_name: str | None = None, + user_id: str | None = None, + channel_id: str | None = None, + ): + """Initializes the _UserTokenClientSpanWrapper span.""" + super().__init__(span_name) + self._connection_name = connection_name or attributes.UNKNOWN + self._user_id = user_id or attributes.UNKNOWN + self._channel_id = channel_id or attributes.UNKNOWN + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the user token client operation based on the outcome of the span.""" + attrs = self._get_request_attributes() + metrics.user_token_client_request_duration.record(duration, attributes=attrs) + metrics.user_token_client_request_count.add(1, attributes=attrs) + + def _get_attributes(self) -> dict[str, str]: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the user token client operation being performed. + + NOTE: a dict is the annotated return type to allow child classes to add additional attributes. + """ + attr_dict = {} + if self._connection_name is not None: + attr_dict[attributes.CONNECTION_NAME] = self._connection_name + if self._user_id is not None: + attr_dict[attributes.USER_ID] = self._user_id + if self._channel_id is not None: + attr_dict[attributes.ACTIVITY_CHANNEL_ID] = self._channel_id + return attr_dict + + +class GetUserToken(_UserTokenClientSpanWrapper): + """Span for getting a user token using the user token client in the adapter.""" + + def __init__( + self, connection_name: str, user_id: str, channel_id: str | None = None + ): + """Initializes the GetUserToken span.""" + super().__init__( + constants.SPAN_GET_USER_TOKEN, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + ) + + +class SignOut(_UserTokenClientSpanWrapper): + """Span for signing out a user using the user token client in the adapter.""" + + def __init__( + self, connection_name: str | None, user_id: str, channel_id: str | None = None + ): + """Initializes the SignOut span.""" + super().__init__( + constants.SPAN_SIGN_OUT, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + ) + + +class GetSignInResource(_UserTokenClientSpanWrapper): + """Span for getting a sign-in resource using the user token client in the adapter.""" + + def __init__(self): + """Initializes the GetSignInResource span.""" + super().__init__(constants.SPAN_GET_SIGN_IN_RESOURCE) + + +class ExchangeToken(_UserTokenClientSpanWrapper): + """Span for exchanging a token using the user token client in the adapter.""" + + def __init__( + self, connection_name: str, user_id: str, channel_id: str | None = None + ): + """Initializes the ExchangeToken span.""" + super().__init__( + constants.SPAN_EXCHANGE_TOKEN, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + ) + + +class GetTokenOrSignInResource(_UserTokenClientSpanWrapper): + """Span for getting a token or sign-in resource using the user token client in the adapter.""" + + def __init__( + self, connection_name: str, user_id: str, channel_id: str | None = None + ): + """Initializes the GetTokenOrSignInResource span.""" + super().__init__( + constants.SPAN_GET_TOKEN_OR_SIGN_IN_RESOURCE, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + ) + + +class GetTokenStatus(_UserTokenClientSpanWrapper): + """Span for getting token status using the user token client in the adapter.""" + + def __init__(self, user_id: str, channel_id: str | None = None): + """Initializes the GetTokenStatus span.""" + super().__init__( + constants.SPAN_GET_TOKEN_STATUS, user_id=user_id, channel_id=channel_id + ) + + +class GetAadTokens(_UserTokenClientSpanWrapper): + """Span for getting AAD tokens using the user token client in the adapter.""" + + def __init__( + self, connection_name: str, user_id: str, channel_id: str | None = None + ): + """Initializes the GetAadTokens span.""" + super().__init__( + constants.SPAN_GET_AAD_TOKENS, + connection_name=connection_name, + user_id=user_id, + channel_id=channel_id, + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py index e86142a2..6de63fb1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py @@ -16,6 +16,7 @@ RestChannelServiceClientFactory, TurnContext, ) +from microsoft_agents.hosting.core.telemetry.adapter import spans from ._http_request_protocol import HttpRequestProtocol from ._http_response import HttpResponse, HttpResponseFactory @@ -84,50 +85,53 @@ async def process_request( if not agent: raise TypeError("HttpAdapterBase.process_request: agent can't be None") - if request.method != "POST": - return HttpResponseFactory.method_not_allowed() + with spans.AdapterProcess() as span: - try: - body = await request.json() - except Exception: - return HttpResponseFactory.bad_request( - "Invalid JSON or unsupported Content-Type" - ) - - activity: Activity = Activity.model_validate(body) + if request.method != "POST": + return HttpResponseFactory.method_not_allowed() - # Get claims identity (default to anonymous if not set by middleware) - claims_identity: ClaimsIdentity = ( - request.get_claims_identity() or ClaimsIdentity({}, False) - ) + try: + body = await request.json() + except Exception: + return HttpResponseFactory.bad_request( + "Invalid JSON or unsupported Content-Type" + ) - # Validate required activity fields - if ( - not activity.type - or not activity.conversation - or not activity.conversation.id - ): - return HttpResponseFactory.bad_request( - "Activity must have type and conversation.id" - ) + activity: Activity = Activity.model_validate(body) + span.share(activity=activity) - try: - # Process the inbound activity with the agent - invoke_response = await self.process_activity( - claims_identity, activity, agent.on_turn + # Get claims identity (default to anonymous if not set by middleware) + claims_identity: ClaimsIdentity = ( + request.get_claims_identity() or ClaimsIdentity({}, False) ) - # Check if we need to return a synchronous response + # Validate required activity fields if ( - activity.type == "invoke" - or activity.delivery_mode == DeliveryModes.expect_replies + not activity.type + or not activity.conversation + or not activity.conversation.id ): - # Invoke and ExpectReplies cannot be performed async - return HttpResponseFactory.json( - invoke_response.body, invoke_response.status + return HttpResponseFactory.bad_request( + "Activity must have type and conversation.id" + ) + + try: + # Process the inbound activity with the agent + invoke_response = await self.process_activity( + claims_identity, activity, agent.on_turn ) - return HttpResponseFactory.accepted() + # Check if we need to return a synchronous response + if ( + activity.type == "invoke" + or activity.delivery_mode == DeliveryModes.expect_replies + ): + # Invoke and ExpectReplies cannot be performed async + return HttpResponseFactory.json( + invoke_response.body, invoke_response.status + ) + + return HttpResponseFactory.accepted() - except PermissionError: - return HttpResponseFactory.unauthorized() + except PermissionError: + return HttpResponseFactory.unauthorized() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index 4855b3ea..3497ef47 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -16,6 +16,7 @@ from microsoft_agents.hosting.core.connector.client import UserTokenClient from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient from microsoft_agents.hosting.core.connector.mcs import MCSConnectorClient +from microsoft_agents.hosting.core.telemetry.adapter import spans from .channel_service_client_factory_base import ChannelServiceClientFactoryBase from .turn_context import TurnContext @@ -105,36 +106,44 @@ async def create_connector_client( "RestChannelServiceClientFactory.create_connector_client: audience can't be None or Empty" ) - if context and context.activity.is_agentic_request(): - token = await self._get_agentic_token(context, service_url) - else: - token_provider: AccessTokenProviderBase = ( - self._connection_manager.get_token_provider( - claims_identity, service_url + is_agentic_request = context.activity.is_agentic_request() if context else False + + with spans.AdapterCreateConnectorClient( + service_url=service_url, + scopes=scopes, + is_agentic_request=is_agentic_request, + ): + + if context and is_agentic_request: + token = await self._get_agentic_token(context, service_url) + else: + token_provider: AccessTokenProviderBase = ( + self._connection_manager.get_token_provider( + claims_identity, service_url + ) + if not use_anonymous + else self._ANONYMOUS_TOKEN_PROVIDER ) - if not use_anonymous - else self._ANONYMOUS_TOKEN_PROVIDER - ) - token = await token_provider.get_access_token( - audience, scopes or claims_identity.get_token_scope() - ) + token = await token_provider.get_access_token( + audience, scopes or claims_identity.get_token_scope() + ) - # Check if this is a connector request (e.g., from Copilot Studio) - if ( - context - and context.activity.recipient - and context.activity.recipient.role == RoleTypes.connector_user - ) or service_url.startswith("https://pvaruntime"): - return MCSConnectorClient( + # Check if this is a connector request (e.g., from Copilot Studio) + if ( + context + and context.activity.recipient + and context.activity.recipient.role == RoleTypes.connector_user + ) or service_url.startswith("https://pvaruntime"): + return MCSConnectorClient( + endpoint=service_url, + ) + + return TeamsConnectorClient( endpoint=service_url, + token=token, ) - return TeamsConnectorClient( - endpoint=service_url, - token=token, - ) - async def create_user_token_client( self, context: TurnContext, @@ -150,27 +159,34 @@ async def create_user_token_client( if not context or not claims_identity: raise ValueError("context and claims_identity are required") - if use_anonymous: - return UserTokenClient(endpoint=self._token_service_endpoint, token="") + scopes = claims_identity.get_token_scope() if claims_identity else None - if context.activity.is_agentic_request(): - token = await self._get_agentic_token(context, self._token_service_endpoint) - else: - scopes = claims_identity.get_token_scope() + with spans.AdapterCreateUserTokenClient( + token_service_endpoint=self._token_service_endpoint, + scopes=scopes, + ): - token_provider = self._connection_manager.get_token_provider( - claims_identity, self._token_service_endpoint - ) + if use_anonymous: + return UserTokenClient(endpoint=self._token_service_endpoint, token="") - token = await token_provider.get_access_token( - self._token_service_audience, scopes - ) + if context.activity.is_agentic_request(): + token = await self._get_agentic_token( + context, self._token_service_endpoint + ) + else: + token_provider = self._connection_manager.get_token_provider( + claims_identity, self._token_service_endpoint + ) - if not token: - logger.error("Failed to obtain token for user token client") - raise ValueError("Failed to obtain token for user token client") + token = await token_provider.get_access_token( + self._token_service_audience, scopes + ) - return UserTokenClient( - endpoint=self._token_service_endpoint, - token=token, - ) + if not token: + logger.error("Failed to obtain token for user token client") + raise ValueError("Failed to obtain token for user token client") + + return UserTokenClient( + endpoint=self._token_service_endpoint, + token=token, + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py index 9c66ac31..1e9ddd86 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py @@ -2,11 +2,11 @@ # Licensed under the MIT License. from typing import Protocol, TypeVar, Type, Union -from abc import ABC, abstractmethod +from abc import abstractmethod from asyncio import gather -from ._type_aliases import JSON from .store_item import StoreItem +from .telemetry import spans StoreItemT = TypeVar("StoreItemT", bound=StoreItem) @@ -69,12 +69,18 @@ async def read( if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - await self.initialize() + with spans.StorageRead(len(keys)): + await self.initialize() - items: list[tuple[Union[str, None], Union[StoreItemT, None]]] = await gather( - *[self._read_item(key, target_cls=target_cls, **kwargs) for key in keys] - ) - return {key: value for key, value in items if key is not None} + items: list[tuple[Union[str, None], Union[StoreItemT, None]]] = ( + await gather( + *[ + self._read_item(key, target_cls=target_cls, **kwargs) + for key in keys + ] + ) + ) + return {key: value for key, value in items if key is not None} @abstractmethod async def _write_item(self, key: str, value: StoreItemT) -> None: @@ -85,9 +91,12 @@ async def write(self, changes: dict[str, StoreItemT]) -> None: if not changes: raise ValueError("Storage.write(): Changes are required when writing.") - await self.initialize() + with spans.StorageWrite(len(changes)): + await self.initialize() - await gather(*[self._write_item(key, value) for key, value in changes.items()]) + await gather( + *[self._write_item(key, value) for key, value in changes.items()] + ) @abstractmethod async def _delete_item(self, key: str) -> None: @@ -98,6 +107,7 @@ async def delete(self, keys: list[str]) -> None: if not keys: raise ValueError("Storage.delete(): Keys are required when deleting.") - await self.initialize() + with spans.StorageDelete(len(keys)): + await self.initialize() - await gather(*[self._delete_item(key) for key in keys]) + await gather(*[self._delete_item(key) for key in keys]) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py new file mode 100644 index 00000000..d8e3af85 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/constants.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_STORAGE_READ = "agents.storage.read" +SPAN_STORAGE_WRITE = "agents.storage.write" +SPAN_STORAGE_DELETE = "agents.storage.delete" + +METRIC_STORAGE_OPERATION_TOTAL = "agents.storage.operation.total" +METRIC_STORAGE_OPERATION_DURATION = "agents.storage.operation.duration" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py new file mode 100644 index 00000000..0e9c2e68 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/metrics.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +storage_operation_total = agents_telemetry.meter.create_counter( + constants.METRIC_STORAGE_OPERATION_TOTAL, + "operation", + description="Number of storage operations performed by the agent", +) +storage_operation_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_STORAGE_OPERATION_DURATION, + "ms", + description="Duration of storage operations in milliseconds", +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py new file mode 100644 index 00000000..86ae7e90 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/telemetry/spans.py @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span + +from microsoft_agents.hosting.core.telemetry import ( + attributes, + SimpleSpanWrapper, +) +from . import metrics, constants + + +class _StorageSpanWrapper(SimpleSpanWrapper): + """Base SpanWrapper for spans related to storage operations. This is meant to be a base class for spans related to storage operations, such as retrieving or saving state, and can be used to share common functionality and attributes related to storage operations.""" + + def __init__(self, span_name: str, *, key_count: int): + """Initializes the _StorageSpanWrapper span.""" + super().__init__(span_name) + self._key_count = key_count + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the storage operation based on the outcome of the span.""" + metrics.storage_operation_duration.record( + duration, + attributes={ + attributes.STORAGE_OPERATION: self._span_name, + }, + ) + metrics.storage_operation_total.add( + 1, + attributes={ + attributes.STORAGE_OPERATION: self._span_name, + }, + ) + + def _get_attributes(self) -> dict[str, str | int]: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the storage operation being performed. + + NOTE: a dict is the annotated return type to allow child classes to add additional attributes. + """ + return { + attributes.KEY_COUNT: self._key_count, + } + + +class StorageRead(_StorageSpanWrapper): + """Span for reading from storage.""" + + def __init__(self, key_count: int): + """Initializes the StorageRead span.""" + super().__init__(constants.SPAN_STORAGE_READ, key_count=key_count) + + +class StorageWrite(_StorageSpanWrapper): + """Span for writing to storage.""" + + def __init__(self, key_count: int): + """Initializes the StorageWrite span.""" + super().__init__(constants.SPAN_STORAGE_WRITE, key_count=key_count) + + +class StorageDelete(_StorageSpanWrapper): + """Span for deleting from storage.""" + + def __init__(self, key_count: int): + """Initializes the StorageDelete span.""" + super().__init__(constants.SPAN_STORAGE_DELETE, key_count=key_count) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py new file mode 100644 index 00000000..ddd936f6 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/__init__.py @@ -0,0 +1,41 @@ +## DESIGN +# This design is similar to how error codes are implemented and maintained. +# The alternative was to inject all of this telemetry logic inline with the code it instruments. +# While some spans are simple, others require more involved mapping of attributes or +# even emitting metrics. +# +# This design hides the "mess" of telemetry to one location rather than throughout the codebase. +# +# NOTE: this module should not be auto-loaded from __init__.py in order to avoid creating +# of the telemetry providers too early for the user to configure them. + +from . import attributes +from .core import ( + agents_telemetry, + SERVICE_NAME, + SERVICE_VERSION, + RESOURCE, + AttributeMap, + BaseSpanWrapper, + SimpleSpanWrapper, +) + +from .utils import ( + format_scopes, + get_conversation_id, + get_delivery_mode, +) + +__all__ = [ + "attributes", + "agents_telemetry", + "format_scopes", + "get_conversation_id", + "get_delivery_mode", + "AttributeMap", + "BaseSpanWrapper", + "SimpleSpanWrapper", + "SERVICE_NAME", + "SERVICE_VERSION", + "RESOURCE", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py new file mode 100644 index 00000000..7ff5cfcd --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/constants.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_PROCESS = "agents.adapter.process" +SPAN_SEND_ACTIVITIES = "agents.adapter.send_activities" +SPAN_UPDATE_ACTIVITY = "agents.adapter.update_activity" +SPAN_DELETE_ACTIVITY = "agents.adapter.delete_activity" +SPAN_CONTINUE_CONVERSATION = "agents.adapter.continue_conversation" +SPAN_CREATE_CONNECTOR_CLIENT = "agents.adapter.create_connector_client" +SPAN_CREATE_USER_TOKEN_CLIENT = "agents.adapter.create_user_token_client" + +METRIC_ADAPTER_PROCESS_DURATION = "agents.adapter.process.duration" + +METRIC_ACTIVITIES_RECEIVED = "agents.activities.received" +METRIC_ACTIVITIES_SENT = "agents.activities.sent" +METRIC_ACTIVITIES_UPDATED = "agents.activities.updated" +METRIC_ACTIVITIES_DELETED = "agents.activities.deleted" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py new file mode 100644 index 00000000..0659a8d5 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/metrics.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core.telemetry import agents_telemetry +from . import constants + +adapter_process_duration = agents_telemetry.meter.create_histogram( + constants.METRIC_ADAPTER_PROCESS_DURATION, + "ms", + description="Duration of adapter processing in milliseconds", +) + +activities_received = agents_telemetry.meter.create_counter( + constants.METRIC_ACTIVITIES_RECEIVED, + description="Number of activities received by the adapter", +) + +activities_sent = agents_telemetry.meter.create_counter( + constants.METRIC_ACTIVITIES_SENT, + description="Number of activities sent by the adapter", +) + +activities_updated = agents_telemetry.meter.create_counter( + constants.METRIC_ACTIVITIES_UPDATED, + description="Number of activities updated by the adapter", +) + +activities_deleted = agents_telemetry.meter.create_counter( + constants.METRIC_ACTIVITIES_DELETED, + description="Number of activities deleted by the adapter", +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py new file mode 100644 index 00000000..44c08aa8 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/adapter/spans.py @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from opentelemetry.trace import Span +from microsoft_agents.activity import Activity + +from microsoft_agents.hosting.core.telemetry import ( + AttributeMap, + SimpleSpanWrapper, + get_conversation_id, + get_delivery_mode, + format_scopes, + attributes, +) +from . import constants, metrics + + +class AdapterProcess(SimpleSpanWrapper): + """Span for processing an incoming activity in the adapter.""" + + def __init__(self, activity: Activity | None = None): + """Initializes the AdapterProcess SpanWrapper.""" + super().__init__(constants.SPAN_PROCESS) + self._activity: Activity | None = activity + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This is used to record metrics for the adapter processing based on the outcome of the span.""" + if self._activity is None: + attrs = { + attributes.ACTIVITY_TYPE: attributes.UNKNOWN, + attributes.ACTIVITY_CHANNEL_ID: attributes.UNKNOWN, + } + else: + attrs = { + attributes.ACTIVITY_TYPE: self._activity.type, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id + or attributes.UNKNOWN, + } + metrics.adapter_process_duration.record(duration, attributes=attrs) + metrics.activities_received.add(1, attributes=attrs) + + def _get_attributes(self) -> AttributeMap: + if self._activity is None: + return {} + return { + attributes.ACTIVITY_TYPE: self._activity.type, + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id + or attributes.UNKNOWN, + attributes.ACTIVITY_DELIVERY_MODE: get_delivery_mode(self._activity), + attributes.CONVERSATION_ID: get_conversation_id(self._activity), + attributes.IS_AGENTIC: self._activity.is_agentic_request(), + } + + def share(self, *, activity: Activity) -> None: + """Shares the activity being processed with the span, so that it can be used in the callback to record metrics.""" + self._activity = activity + + +class AdapterSendActivities(SimpleSpanWrapper): + """Span for sending activities in the adapter.""" + + def __init__(self, activities: list[Activity]): + """Initializes the AdapterSendActivities span.""" + super().__init__(constants.SPAN_SEND_ACTIVITIES) + self._activities = activities + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + for act in self._activities: + metrics.activities_sent.add( + 1, + attributes={ + attributes.ACTIVITY_TYPE: act.type, + attributes.ACTIVITY_CHANNEL_ID: act.channel_id + or attributes.UNKNOWN, + }, + ) + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activities being sent.""" + return { + attributes.ACTIVITY_COUNT: len(self._activities), + attributes.CONVERSATION_ID: ( + get_conversation_id(self._activities[0]) + if self._activities + else attributes.UNKNOWN + ), + } + + +class AdapterUpdateActivity(SimpleSpanWrapper): + """Span for updating an activity in the adapter.""" + + def __init__(self, activity: Activity): + """Initializes the AdapterUpdateActivity span.""" + super().__init__(constants.SPAN_UPDATE_ACTIVITY) + self._activity = activity + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + metrics.activities_updated.add( + 1, + attributes={ + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id + or attributes.UNKNOWN, + }, + ) + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being updated.""" + return { + attributes.ACTIVITY_ID: self._activity.id or attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id(self._activity), + } + + +class AdapterDeleteActivity(SimpleSpanWrapper): + """Span for deleting an activity in the adapter.""" + + def __init__(self, activity: Activity): + """Initializes the AdapterDeleteActivity span.""" + super().__init__(constants.SPAN_DELETE_ACTIVITY) + self._activity = activity + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + metrics.activities_deleted.add( + 1, + attributes={ + attributes.ACTIVITY_CHANNEL_ID: self._activity.channel_id + or attributes.UNKNOWN, + }, + ) + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the activity being deleted.""" + return { + attributes.ACTIVITY_ID: self._activity.id or attributes.UNKNOWN, + attributes.CONVERSATION_ID: get_conversation_id(self._activity), + } + + +class AdapterContinueConversation(SimpleSpanWrapper): + """Span for continuing a conversation in the adapter.""" + + def __init__(self, activity: Activity): + """Initializes the AdapterContinueConversation span.""" + super().__init__(constants.SPAN_CONTINUE_CONVERSATION) + self._activity = activity + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This includes attributes related to the conversation being continued.""" + return { + attributes.APP_ID: ( + self._activity.recipient.id + if self._activity.recipient + else attributes.UNKNOWN + ), + attributes.CONVERSATION_ID: get_conversation_id(self._activity), + attributes.IS_AGENTIC: self._activity.is_agentic_request(), + } + + +class AdapterCreateUserTokenClient(SimpleSpanWrapper): + """Span for creating a user token in the adapter.""" + + def __init__(self, token_service_endpoint: str, scopes: list[str] | None): + """Initializes the AdapterCreateUserToken span.""" + super().__init__(constants.SPAN_CREATE_USER_TOKEN_CLIENT) + self._token_service_endpoint = token_service_endpoint + self._scopes = scopes + + def _get_attributes(self) -> AttributeMap: + """Starts the AdapterCreateUserToken span, and sets attributes related to the user token being created.""" + return { + attributes.TOKEN_SERVICE_ENDPOINT: self._token_service_endpoint, + attributes.AUTH_SCOPES: format_scopes(self._scopes), + } + + +class AdapterCreateConnectorClient(SimpleSpanWrapper): + """Span for creating a connector client in the adapter.""" + + def __init__( + self, service_url: str, scopes: list[str] | None, is_agentic_request: bool + ): + """Initializes the AdapterCreateConnectorClient span.""" + super().__init__(constants.SPAN_CREATE_CONNECTOR_CLIENT) + self._service_url = service_url + self._scopes = scopes + self._is_agentic_request = is_agentic_request + + def _get_attributes(self) -> AttributeMap: + """Starts the AdapterCreateConnectorClient span, and sets attributes related to the connector client being created.""" + return { + attributes.SERVICE_URL: self._service_url, + attributes.AUTH_SCOPES: format_scopes(self._scopes), + attributes.IS_AGENTIC: self._is_agentic_request, + } diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py new file mode 100644 index 00000000..379a443a --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/attributes.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +ACTIVITY_DELIVERY_MODE = "activity.delivery_mode" +ACTIVITY_CHANNEL_ID = "activity.channel_id" +ACTIVITY_ID = "activity.id" +ACTIVITY_COUNT = "activities.count" +ACTIVITY_TYPE = "activity.type" + +AGENTIC_USER_ID = "agentic.user_id" +AGENTIC_INSTANCE_ID = "agentic.instance_id" + +APP_ID = "agent.app_id" + +ATTACHMENT_ID = "activity.attachment.id" +ATTACHMENT_COUNT = "activity.attachments.count" + +AUTH_HANDLER_ID = "auth.handler.id" +AUTH_METHOD = "auth.method" +AUTH_SCOPES = "auth.scopes" +AUTH_SUCCESS = "auth.success" + +CONNECTION_NAME = "auth.connection.name" +CONVERSATION_ID = "activity.conversation.id" + +HTTP_METHOD = "http.method" +HTTP_STATUS_CODE = "http.status_code" + +IS_AGENTIC = "is_agentic_request" + +KEY_COUNT = "storage.keys.count" + +OPERATION = "operation" + +ROUTE_AUTHORIZED = "route.authorized" +ROUTE_IS_INVOKE = "route.is_invoke" +ROUTE_IS_AGENTIC = "route.is_agentic" +ROUTE_MATCHED = "route.matched" + +SERVICE_URL = "service_url" +STORAGE_OPERATION = "storage.operation" + +TOKEN_SERVICE_ENDPOINT = "agents.token_service.endpoint" + +USER_ID = "user.id" + +VIEW_ID = "view.id" + +# for missing values +UNKNOWN = "unknown" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py new file mode 100644 index 00000000..71b29dcd --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from . import resource +from ._agents_telemetry import agents_telemetry +from .type_defs import AttributeMap, SpanCallback +from .simple_span_wrapper import SimpleSpanWrapper +from .base_span_wrapper import BaseSpanWrapper +from .resource import SERVICE_NAME, SERVICE_VERSION, RESOURCE + +__all__ = [ + "agents_telemetry", + "resource", + "AttributeMap", + "SpanCallback", + "SimpleSpanWrapper", + "BaseSpanWrapper", + "SERVICE_NAME", + "SERVICE_VERSION", + "RESOURCE", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py new file mode 100644 index 00000000..34c13ec4 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/_agents_telemetry.py @@ -0,0 +1,106 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import time +import logging +from collections.abc import Iterator + +from contextlib import contextmanager + +from opentelemetry.metrics import Meter +from opentelemetry import metrics, trace +from opentelemetry.trace import Tracer, Span + +from microsoft_agents.activity import TurnContextProtocol + +from .resource import SERVICE_NAME, SERVICE_VERSION +from .type_defs import SpanCallback + +logger = logging.getLogger(__name__) + + +class _AgentsTelemetry: + + def __init__(self): + """Initializes the AgentsTelemetry instance with the given tracer and meter, or creates new ones if not provided + + :param tracer: Optional OpenTelemetry Tracer instance to use for creating spans. If not provided, a new tracer will be created with the service name and version from constants. + :param meter: Optional OpenTelemetry Meter instance to use for recording metrics. If not provided, a new meter will be created with the service name and version from constants. + """ + self._tracer = trace.get_tracer(SERVICE_NAME, SERVICE_VERSION) + self._meter = metrics.get_meter(SERVICE_NAME, SERVICE_VERSION) + + @property + def tracer(self) -> Tracer: + """Returns the OpenTelemetry tracer instance for creating spans""" + return self._tracer + + @property + def meter(self) -> Meter: + """Returns the OpenTelemetry meter instance for recording metrics""" + return self._meter + + def _extract_attributes_from_context( + self, turn_context: TurnContextProtocol + ) -> dict: + """Helper method to extract common attributes from the TurnContext for span and metric recording""" + + # This can be expanded to extract common attributes for spans and metrics from the context + attributes = {} + attributes["activity.type"] = turn_context.activity.type + attributes["agent.is_agentic"] = turn_context.activity.is_agentic_request() + if turn_context.activity.from_property: + attributes["from.id"] = turn_context.activity.from_property.id + if turn_context.activity.recipient: + attributes["recipient.id"] = turn_context.activity.recipient.id + if turn_context.activity.conversation: + attributes["conversation.id"] = turn_context.activity.conversation.id + attributes["channel_id"] = turn_context.activity.channel_id + attributes["message.text.length"] = ( + len(turn_context.activity.text) if turn_context.activity.text else 0 + ) + return attributes + + @contextmanager + def start_as_current_span( + self, + span_name: str, + callback: SpanCallback | None = None, + ) -> Iterator[Span]: + """Context manager for starting a timed span that records duration and success/failure status, and invokes a callback with the results + + :param span_name: The name of the span to start + :param callback: Optional callback function that will be called with the span, duration in milliseconds, and any exception that was raised (or None if successful) when the span is ended + :return: An iterator that yields the started span, which will be ended when the context manager exits + """ + + with self._tracer.start_as_current_span(span_name) as span: + + start = time.time() + exception: Exception | None = None + + try: + yield span # execute the operation in the with block + except Exception as e: + exception = e + raise + finally: + + success = exception is None + + end = time.time() + duration = (end - start) * 1000 # milliseconds + + if success: + span.add_event(f"{span_name} completed", {"duration_ms": duration}) + span.set_status(trace.Status(trace.StatusCode.OK)) + if callback: + callback(span, duration, None) + else: + if callback: + callback(span, duration, exception) + + span.set_status(trace.Status(trace.StatusCode.ERROR)) + + +agents_telemetry = _AgentsTelemetry() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py new file mode 100644 index 00000000..841275e9 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/base_span_wrapper.py @@ -0,0 +1,79 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +import logging + +from abc import ABC, abstractmethod +from contextlib import ExitStack +from typing import ContextManager + +from opentelemetry.trace import Span + +logger = logging.getLogger(__name__) + + +class BaseSpanWrapper(ABC): + """Wrapper around OTEL spans for SDK-specific telemetry""" + + def __init__(self): + self._span: Span | None = None + self._active: bool = False + + self._exit_stack = ExitStack() + + @property + def otel_span(self) -> Span | None: + """Returns the underlying OTEL span if it is active, or None if the span has not been started or has already ended. This can be used to access OTEL-specific functionality or attributes of the span when needed, while still providing a higher-level abstraction through the BaseSpanWrapper class.""" + return self._span + + @property + def active(self) -> bool: + """Indicates whether the BaseSpanWrapper is currently active. This can be used to prevent operations on an inactive BaseSpanWrapper, and to check the BaseSpanWrapper's lifecycle state.""" + return self._active + + @abstractmethod + def _start_span(self) -> ContextManager[Span]: + """Abstract method that must be implemented by subclasses to define how the BaseSpanWrapper is started and what attributes are set on the BaseSpanWrapper. This method should return a context manager that yields the started BaseSpanWrapper, allowing the base BaseSpanWrapper class to manage the BaseSpanWrapper's lifecycle and ensure proper cleanup when the BaseSpanWrapper is ended.""" + raise NotImplementedError + + @staticmethod + def _log_lifespan_error(desc: str) -> None: + """Helper method to log a warning when an operation is attempted on an inactive BaseSpanWrapper. This can be used in methods that require an active BaseSpanWrapper to indicate potential misuse of the BaseSpanWrapper lifecycle.""" + logger.warning( + "Attempting to perform an operation on an inactive BaseSpanWrapper. This may indicate a bug in the telemetry implementation or misuse of the BaseSpanWrapper lifecycle." + ) + logger.warning("Description: %s", desc) + + # TODO -> Add Self annotation once 3.11 is the minimum supported version + def __enter__(self): + """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining. This method should check if the BaseSpanWrapper is already active and log a warning if an attempt is made to start an already active BaseSpanWrapper, to help identify potential issues with BaseSpanWrapper lifecycle management.""" + if self._active: + BaseSpanWrapper._log_lifespan_error( + "Attempting to start a BaseSpanWrapper that is already active." + ) + + self._span = self._exit_stack.enter_context(self._start_span()) + self._active = True + + return self + + def start(self) -> BaseSpanWrapper: + """Starts the BaseSpanWrapper and returns the BaseSpanWrapper instance for chaining""" + return self.__enter__() + + def __exit__(self, exc_type, exc_val, exc_tb): + """Stops the BaseSpanWrapper if it is active, and logs a warning if an attempt is made to stop a BaseSpanWrapper that is not active. This ensures that BaseSpanWrappers are properly cleaned up and that potential issues with BaseSpanWrapper lifecycle management are logged for debugging purposes.""" + if self._active: + self._exit_stack.__exit__(exc_type, exc_val, exc_tb) + self._span = None + self._active = False + else: + BaseSpanWrapper._log_lifespan_error( + "BaseSpanWrapper is not active and cannot be exited" + ) + + def end(self) -> None: + """Stops the BaseSpanWrapper if it is active""" + self.__exit__(None, None, None) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py new file mode 100644 index 00000000..e93fd464 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/resource.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +from opentelemetry.sdk.resources import Resource + +SERVICE_NAME = "microsoft_agents" +SERVICE_VERSION = "1.0.0" + +RESOURCE = Resource.create( + { + "service.name": SERVICE_NAME, + "service.version": SERVICE_VERSION, + "service.instance.id": os.getenv("HOSTNAME", "unknown"), + "telemetry.sdk.language": "python", + } +) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py new file mode 100644 index 00000000..bb2e6349 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/simple_span_wrapper.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC +from collections.abc import Iterator +from contextlib import contextmanager + +from opentelemetry.trace import Span + +from ._agents_telemetry import agents_telemetry +from .base_span_wrapper import BaseSpanWrapper +from .type_defs import AttributeMap + + +class SimpleSpanWrapper(BaseSpanWrapper, ABC): + """Simple implementation of the BaseSpanWrapper that can be used when no additional attributes or functionality are needed on the span beyond what is provided by the base BaseSpanWrapper class. This can be used as a simple wrapper around an OTEL span for cases where no SDK-specific telemetry is needed, while still providing the benefits of the BaseSpanWrapper abstraction and lifecycle management.""" + + def __init__(self, span_name: str): + super().__init__() + self._span_name = span_name + + def _get_attributes(self) -> AttributeMap: + """Returns a dictionary of attributes to set on the span when it is started. This can be overridden by subclasses to provide custom attributes for the span based on the context in which it is being used.""" + return {} + + def _callback(self, span: Span, duration: float, error: Exception | None) -> None: + """Callback function that is called when the span is ended. This can be overridden by subclasses to provide custom logic for recording metrics or handling errors based on the outcome of the span.""" + pass + + @contextmanager + def _start_span(self) -> Iterator[Span]: + """Starts a basic OTEL span with the given name and no additional attributes.""" + with agents_telemetry.start_as_current_span( + self._span_name, callback=self._callback + ) as span: + try: + yield span + except Exception: + raise + finally: + if span is not None: + attributes = self._get_attributes() + if attributes: + span.set_attributes(attributes) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py new file mode 100644 index 00000000..0169e73f --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/core/type_defs.py @@ -0,0 +1,7 @@ +from typing import Mapping, Callable + +from opentelemetry.util.types import AttributeValue +from opentelemetry.trace import Span + +AttributeMap = Mapping[str, AttributeValue] +SpanCallback = Callable[[Span, float, Exception | None], None] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py new file mode 100644 index 00000000..b8155e78 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/constants.py @@ -0,0 +1,4 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +SPAN_TURN_SEND_ACTIVITY = "agents.turn.send_activity" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py new file mode 100644 index 00000000..8655731e --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/turn_context/spans.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from microsoft_agents.activity import TurnContextProtocol +from microsoft_agents.hosting.core.telemetry import ( + AttributeMap, + SimpleSpanWrapper, + attributes, + get_conversation_id, +) +from . import constants + + +class TurnContextSendActivity(SimpleSpanWrapper): + """Span wrapper for sending an activity within a turn context.""" + + def __init__(self, turn_context: TurnContextProtocol): + super().__init__(constants.SPAN_TURN_SEND_ACTIVITY) + self._turn_context = turn_context + + def _get_attributes(self) -> AttributeMap: + activity = self._turn_context.activity + return { + attributes.CONVERSATION_ID: get_conversation_id(activity), + } diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py new file mode 100644 index 00000000..693cb7bc --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/telemetry/utils.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.activity import Activity, DeliveryModes + +from .attributes import UNKNOWN + + +def format_scopes(scopes: list[str] | None) -> str: + """Formats a list of scopes into a string for telemetry recording. If the list is None or empty, returns a constant value indicating unknown scopes.""" + if not scopes: + return UNKNOWN + return ",".join(scopes) + + +def get_conversation_id(activity: Activity) -> str: + """Extracts the conversation ID from the given activity. If the conversation ID cannot be found, returns a constant value indicating unknown conversation ID.""" + return activity.conversation.id if activity.conversation else UNKNOWN + + +def get_delivery_mode(activity: Activity) -> str: + """Extracts the delivery mode from the given activity. If the delivery mode cannot be found, returns a constant value indicating unknown delivery mode.""" + if activity.delivery_mode: + if isinstance(activity.delivery_mode, DeliveryModes): + return activity.delivery_mode.value + else: + return activity.delivery_mode + return UNKNOWN diff --git a/libraries/microsoft-agents-hosting-core/setup.py b/libraries/microsoft-agents-hosting-core/setup.py index b1da90cf..bcec11f3 100644 --- a/libraries/microsoft-agents-hosting-core/setup.py +++ b/libraries/microsoft-agents-hosting-core/setup.py @@ -17,5 +17,7 @@ "isodate>=0.6.1", "azure-core>=1.30.0", "python-dotenv>=1.1.1", + "opentelemetry-api>=1.27.0", + "opentelemetry-sdk>=1.27.0", ], ) diff --git a/test_samples/otel/env.TEMPLATE b/test_samples/otel/env.TEMPLATE new file mode 100644 index 00000000..bf65df42 --- /dev/null +++ b/test_samples/otel/env.TEMPLATE @@ -0,0 +1,16 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=tenant-id + +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=connection-name + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO + +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_EXPORTER_OTLP_INSECURE=true + +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST=".*" +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE=".*" + +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST=".*" +OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE=".*" \ No newline at end of file diff --git a/test_samples/otel/requirements.txt b/test_samples/otel/requirements.txt new file mode 100644 index 00000000..879687ff --- /dev/null +++ b/test_samples/otel/requirements.txt @@ -0,0 +1,14 @@ +python-dotenv +aiohttp +microsoft-agents-hosting-aiohttp +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +opentelemetry-instrumentation-aiohttp-server +opentelemetry-instrumentation-aiohttp-client +opentelemetry-instrumentation-requests +opentelemetry-exporter-otlp +opentelemetry-sdk +opentelemetry-api +opentelemetry-instrumentation-logging +opentelemetry-instrumentation \ No newline at end of file diff --git a/test_samples/otel/src/__init__.py b/test_samples/otel/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/otel/src/agent.py b/test_samples/otel/src/agent.py new file mode 100644 index 00000000..048abf04 --- /dev/null +++ b/test_samples/otel/src/agent.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re +import sys +import traceback +from dotenv import load_dotenv + +from os import environ +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + Authorization, + AgentApplication, + TurnState, + TurnContext, + MemoryStorage, + MessageFactory, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.activity import load_configuration_from_env + +from .get_user_info import get_user_info +from .card import create_profile_card + +load_dotenv() +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) + + +@AGENT_APP.conversation_update("membersAdded") +async def on_members_added(context: TurnContext, _state: TurnState): + await context.send_activity( + "Welcome to the empty agent! " + "This agent is designed to be a starting point for your own agent development." + ) + return True + + +@AGENT_APP.message("/logout") +async def logout(context: TurnContext, state: TurnState) -> None: + await AGENT_APP.auth.sign_out(context, "GRAPH") + await context.send_activity(MessageFactory.text("You have been logged out.")) + + +@AGENT_APP.message( + re.compile(r"^/(me|profile)$", re.IGNORECASE), auth_handlers=["GRAPH"] +) +async def profile_request(context: TurnContext, state: TurnState) -> None: + user_token_response = await AGENT_APP.auth.get_token(context, "GRAPH") + if user_token_response and user_token_response is not None: + user_info = await get_user_info(user_token_response.token) + activity = MessageFactory.attachment(create_profile_card(user_info)) + await context.send_activity(activity) + else: + await context.send_activity( + 'Token not available. Enter "login" to sign in.' + ) + +@AGENT_APP.message(re.compile(r"^hello$")) +async def on_hello(context: TurnContext, _state: TurnState): + await context.send_activity("Hello!") + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _state: TurnState): + await context.send_activity(f"you said: {context.activity.text}") + +@AGENT_APP.error +async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") diff --git a/test_samples/otel/src/card.py b/test_samples/otel/src/card.py new file mode 100644 index 00000000..f2e44a28 --- /dev/null +++ b/test_samples/otel/src/card.py @@ -0,0 +1,73 @@ +from microsoft_agents.hosting.core import CardFactory + +def create_profile_card(profile): + return CardFactory.adaptive_card( + { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "type": "AdaptiveCard", + "body": [ + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "auto", + "items": ( + [ + { + "type": "Image", + "altText": "", + "url": profile.get("imageUri", ""), + "style": "Person", + "size": "Small", + } + ] + if profile.get("imageUri") + else [] + ), + }, + { + "type": "Column", + "width": "auto", + "items": [ + { + "type": "TextBlock", + "weight": "Bolder", + "text": profile["displayName"], + }, + { + "type": "Container", + "spacing": "Small", + "items": [ + { + "type": "TextBlock", + "text": profile["jobTitle"], + "spacing": "Small", + }, + { + "type": "TextBlock", + "text": profile["mail"], + "spacing": "None", + }, + { + "type": "TextBlock", + "text": profile["givenName"], + "spacing": "None", + }, + { + "type": "TextBlock", + "text": profile["surname"], + "spacing": "None", + }, + ], + }, + ], + }, + ], + } + ], + } + ) + + diff --git a/test_samples/otel/src/get_user_info.py b/test_samples/otel/src/get_user_info.py new file mode 100644 index 00000000..4fded9b1 --- /dev/null +++ b/test_samples/otel/src/get_user_info.py @@ -0,0 +1,18 @@ +import aiohttp + +async def get_user_info(token): + """ + Get information about the current user from Microsoft Graph API. + """ + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + async with session.get( + "https://graph.microsoft.com/v1.0/me", headers=headers + ) as response: + if response.status == 200: + return await response.json() + error_text = await response.text() + raise Exception(f"Error from Graph API: {response.status} - {error_text}") diff --git a/test_samples/otel/src/main.py b/test_samples/otel/src/main.py new file mode 100644 index 00000000..bfd1ce41 --- /dev/null +++ b/test_samples/otel/src/main.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .telemetry import configure_otel_providers + +configure_otel_providers(service_name="quickstart_agent") + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + +start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), +) diff --git a/test_samples/otel/src/start_server.py b/test_samples/otel/src/start_server.py new file mode 100644 index 00000000..96e79f9b --- /dev/null +++ b/test_samples/otel/src/start_server.py @@ -0,0 +1,37 @@ +from os import environ +import logging + +from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.hosting.aiohttp import ( + start_agent_process, + CloudAdapter, + jwt_authorization_middleware, +) +from aiohttp.web import Request, Response, Application, run_app + +logger = logging.getLogger(__name__) + + +def start_server( + agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration +): + async def entry_point(req: Request) -> Response: + + logger.info("Request received at /api/messages endpoint.") + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + + return await start_agent_process( + req, + agent, + adapter, + ) + + APP = Application(middlewares=[jwt_authorization_middleware]) + APP.router.add_post("/api/messages", entry_point) + + APP["agent_configuration"] = auth_configuration + APP["agent_app"] = agent_application + APP["adapter"] = agent_application.adapter + + run_app(APP, host="localhost", port=int(environ.get("PORT", 3978))) diff --git a/test_samples/otel/src/telemetry.py b/test_samples/otel/src/telemetry.py new file mode 100644 index 00000000..ddd81109 --- /dev/null +++ b/test_samples/otel/src/telemetry.py @@ -0,0 +1,107 @@ +import logging +import os +import requests + +import aiohttp +from opentelemetry import metrics, trace +from opentelemetry.trace import Span +from opentelemetry._logs import set_logger_provider +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor + +from opentelemetry.instrumentation.aiohttp_server import AioHttpServerInstrumentor +from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor +from opentelemetry.instrumentation.requests import RequestsInstrumentor + +def instrument_libraries(): + """Instrument libraries for OpenTelemetry.""" + + # ## + # # instrument aiohttp client + # ## + def aiohttp_client_request_hook( + span: Span, params: aiohttp.TraceRequestStartParams + ): + if span and span.is_recording(): + span.set_attribute("http.url", str(params.url)) + + def aiohttp_client_response_hook( + span: Span, + params: aiohttp.TraceRequestEndParams | aiohttp.TraceRequestExceptionParams, + ): + if span and span.is_recording(): + span.set_attribute("http.url", str(params.url)) + + AioHttpClientInstrumentor().instrument( + request_hook=aiohttp_client_request_hook, + response_hook=aiohttp_client_response_hook, + ) + + # + # instrument requests library + ## + def requests_request_hook(span: Span, request: requests.Request): + if span and span.is_recording(): + span.set_attribute("http.url", request.url) + + def requests_response_hook( + span: Span, request: requests.Request, response: requests.Response + ): + if span and span.is_recording(): + span.set_attribute("http.url", response.url) + + RequestsInstrumentor().instrument( + request_hook=requests_request_hook, response_hook=requests_response_hook + ) + +def configure_otel_providers(service_name: str = "app"): + """Configure OpenTelemetry for FastAPI application.""" + + # Create resource with service name + resource = Resource.create( + { + "service.name": service_name, + "service.version": "1.0.0", + "service.instance.id": os.getenv("HOSTNAME", "unknown"), + "telemetry.sdk.language": "python", + } + ) + + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317/") + + # Configure Tracing + tracer_provider = TracerProvider(resource=resource) + tracer_provider.add_span_processor( + SimpleSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) + ) + trace.set_tracer_provider(tracer_provider) + + # Configure Metrics + metric_reader = PeriodicExportingMetricReader( + OTLPMetricExporter(endpoint=endpoint) + ) + meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader]) + metrics.set_meter_provider(meter_provider) + + # Configure Logging + logger_provider = LoggerProvider(resource=resource) + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint)) + ) + set_logger_provider(logger_provider) + + # Add logging handler + handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider) + logging.getLogger().addHandler(handler) + + logging.getLogger().info("OpenTelemetry providers configured with endpoint: %s", endpoint) + + instrument_libraries() \ No newline at end of file diff --git a/test_samples/otel/start_dashboard.ps1 b/test_samples/otel/start_dashboard.ps1 new file mode 100644 index 00000000..de2dd386 --- /dev/null +++ b/test_samples/otel/start_dashboard.ps1 @@ -0,0 +1 @@ +docker run --rm -it -p 18888:18888 -p 4317:18889 --name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard:latest \ No newline at end of file diff --git a/tests/_common/_tests/test_delta_metric_reader.py b/tests/_common/_tests/test_delta_metric_reader.py new file mode 100644 index 00000000..67be5eed --- /dev/null +++ b/tests/_common/_tests/test_delta_metric_reader.py @@ -0,0 +1,212 @@ +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from tests._common.fixtures.telemetry import DeltaMetricReader +from tests._common.telemetry_utils import find_metric, sum_counter, sum_hist_count + + +def _make_reader(): + """Create a standalone MeterProvider + InMemoryMetricReader pair.""" + inner = InMemoryMetricReader() + provider = MeterProvider([inner]) + meter = provider.get_meter("test") + return inner, provider, meter + + +# ---- basic counter delta ---- + + +def test_counter_delta_excludes_baseline(): + inner, provider, meter = _make_reader() + counter = meter.create_counter("my_counter") + + counter.add(5) + delta = DeltaMetricReader(inner) # baseline captures 5 + + counter.add(3) + data = delta.get_metrics_data() + + assert sum_counter(find_metric(data, "my_counter")) == 3 + provider.shutdown() + + +def test_counter_delta_is_zero_when_nothing_new(): + inner, provider, meter = _make_reader() + counter = meter.create_counter("my_counter") + + counter.add(10) + delta = DeltaMetricReader(inner) + + data = delta.get_metrics_data() + metric = find_metric(data, "my_counter") + # No new increments → metric either absent or zero + assert metric is None or sum_counter(metric) == 0 + provider.shutdown() + + +def test_counter_accumulates_across_calls(): + inner, provider, meter = _make_reader() + counter = meter.create_counter("my_counter") + + delta = DeltaMetricReader(inner) + + counter.add(2) + counter.add(3) + + data = delta.get_metrics_data() + assert sum_counter(find_metric(data, "my_counter")) == 5 + provider.shutdown() + + +# ---- reset ---- + + +def test_reset_clears_delta(): + inner, provider, meter = _make_reader() + counter = meter.create_counter("my_counter") + + delta = DeltaMetricReader(inner) + counter.add(7) + + delta.reset() # new baseline includes the 7 + + data = delta.get_metrics_data() + metric = find_metric(data, "my_counter") + assert metric is None or sum_counter(metric) == 0 + + counter.add(2) + data = delta.get_metrics_data() + assert sum_counter(find_metric(data, "my_counter")) == 2 + provider.shutdown() + + +# ---- histogram delta ---- + + +def test_histogram_delta_excludes_baseline(): + inner, provider, meter = _make_reader() + hist = meter.create_histogram("my_hist") + + hist.record(100) + hist.record(200) + delta = DeltaMetricReader(inner) # baseline count=2 + + hist.record(50) + data = delta.get_metrics_data() + + assert sum_hist_count(find_metric(data, "my_hist")) == 1 + provider.shutdown() + + +def test_histogram_delta_is_zero_when_nothing_new(): + inner, provider, meter = _make_reader() + hist = meter.create_histogram("my_hist") + + hist.record(42) + delta = DeltaMetricReader(inner) + + data = delta.get_metrics_data() + metric = find_metric(data, "my_hist") + assert metric is None or sum_hist_count(metric) == 0 + provider.shutdown() + + +# ---- attribute-keyed counters ---- + + +def test_counter_delta_respects_attributes(): + inner, provider, meter = _make_reader() + counter = meter.create_counter("tagged") + + counter.add(10, attributes={"ch": "teams"}) + counter.add(20, attributes={"ch": "webchat"}) + + delta = DeltaMetricReader(inner) + + counter.add(1, attributes={"ch": "teams"}) + counter.add(2, attributes={"ch": "webchat"}) + + data = delta.get_metrics_data() + metric = find_metric(data, "tagged") + + assert sum_counter(metric, {"ch": "teams"}) == 1 + assert sum_counter(metric, {"ch": "webchat"}) == 2 + provider.shutdown() + + +# ---- multiple metrics ---- + + +def test_multiple_metrics_tracked_independently(): + inner, provider, meter = _make_reader() + c1 = meter.create_counter("counter_a") + c2 = meter.create_counter("counter_b") + + c1.add(100) + delta = DeltaMetricReader(inner) + + c1.add(1) + c2.add(5) + + data = delta.get_metrics_data() + assert sum_counter(find_metric(data, "counter_a")) == 1 + assert sum_counter(find_metric(data, "counter_b")) == 5 + provider.shutdown() + + +# ---- new metric after baseline ---- + + +def test_new_metric_after_baseline(): + inner, provider, meter = _make_reader() + + delta = DeltaMetricReader(inner) + + counter = meter.create_counter("late_counter") + counter.add(3) + + data = delta.get_metrics_data() + assert sum_counter(find_metric(data, "late_counter")) == 3 + provider.shutdown() + + +# ---- force_flush delegates ---- + + +def test_force_flush_delegates(): + inner, provider, meter = _make_reader() + delta = DeltaMetricReader(inner) + + counter = meter.create_counter("flushed") + counter.add(1) + delta.force_flush() + + data = delta.get_metrics_data() + assert sum_counter(find_metric(data, "flushed")) == 1 + provider.shutdown() + + +# ---- output structure is compatible with find_metric ---- + + +def test_output_structure_compatible_with_helpers(): + inner, provider, meter = _make_reader() + delta = DeltaMetricReader(inner) + + counter = meter.create_counter("compat") + counter.add(1) + + data = delta.get_metrics_data() + + assert hasattr(data, "resource_metrics") + rm = data.resource_metrics[0] + assert hasattr(rm, "scope_metrics") + sm = rm.scope_metrics[0] + assert hasattr(sm, "metrics") + m = sm.metrics[0] + assert m.name == "compat" + assert hasattr(m.data, "data_points") + dp = m.data.data_points[0] + assert hasattr(dp, "value") + assert hasattr(dp, "attributes") + provider.shutdown() diff --git a/tests/_common/fixtures/telemetry.py b/tests/_common/fixtures/telemetry.py new file mode 100644 index 00000000..5de63784 --- /dev/null +++ b/tests/_common/fixtures/telemetry.py @@ -0,0 +1,169 @@ +import pytest +from types import SimpleNamespace + +from opentelemetry import trace, metrics +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + + +class DeltaMetricReader: + """Wraps an InMemoryMetricReader so each test only sees metrics + accrued *after* the wrapper was created (or last reset). + + InMemoryMetricReader uses cumulative aggregation by default and has + no ``clear()`` method, so counters and histograms accumulate across + the whole session. This wrapper snapshots the cumulative values at + construction time and subtracts them from every subsequent + ``get_metrics_data()`` call, producing a delta view that is + compatible with the ``find_metric`` / ``sum_counter`` / + ``sum_hist_count`` helpers. + """ + + def __init__(self, inner: InMemoryMetricReader): + self._inner = inner + self._baseline: dict[tuple, tuple] = {} + self.reset() + + def reset(self): + """Capture the current cumulative values as the new zero-line.""" + data = self._inner.get_metrics_data() + self._baseline = self._snapshot(data) + + def force_flush(self): + self._inner.force_flush() + + def get_metrics_data(self): + """Return a metrics-data object containing only the delta + since the last ``reset()``.""" + raw = self._inner.get_metrics_data() + return self._subtract(raw, self._baseline) + + # -- internals -------------------------------------------------- + + @staticmethod + def _dp_key(metric_name, dp): + attrs = dp.attributes or {} + return (metric_name, tuple(sorted(attrs.items()))) + + @staticmethod + def _snapshot(data): + snap: dict[tuple, tuple] = {} + if data is None: + return snap + for rm in data.resource_metrics: + for sm in rm.scope_metrics: + for m in sm.metrics: + for dp in m.data.data_points: + k = DeltaMetricReader._dp_key(m.name, dp) + if hasattr(dp, "bucket_counts"): + snap[k] = ("hist", dp.count) + else: + snap[k] = ("counter", dp.value) + return snap + + @staticmethod + def _empty_data(): + return SimpleNamespace( + resource_metrics=[ + SimpleNamespace(scope_metrics=[SimpleNamespace(metrics=[])]) + ] + ) + + @staticmethod + def _subtract(data, baseline): + if data is None: + return DeltaMetricReader._empty_data() + all_metrics: list = [] + for rm in data.resource_metrics: + for sm in rm.scope_metrics: + for m in sm.metrics: + points: list = [] + for dp in m.data.data_points: + k = DeltaMetricReader._dp_key(m.name, dp) + base = baseline.get(k) + if hasattr(dp, "bucket_counts"): + base_count = base[1] if base else 0 + points.append( + SimpleNamespace( + attributes=dp.attributes, + count=dp.count - base_count, + ) + ) + else: + base_val = base[1] if base else 0 + points.append( + SimpleNamespace( + attributes=dp.attributes, + value=dp.value - base_val, + ) + ) + if points: + all_metrics.append( + SimpleNamespace( + name=m.name, + data=SimpleNamespace(data_points=points), + ) + ) + return SimpleNamespace( + resource_metrics=[ + SimpleNamespace(scope_metrics=[SimpleNamespace(metrics=all_metrics)]) + ] + ) + + +_metric_reader = None +_exporter = None + + +@pytest.fixture(scope="session") +def test_telemetry(): + """Set up fresh in-memory exporter for testing.""" + global _exporter, _metric_reader + + if _exporter is None: + exporter = InMemorySpanExporter() + metric_reader = InMemoryMetricReader() + + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + trace.set_tracer_provider(tracer_provider) + + meter_provider = MeterProvider([metric_reader]) + + metrics.set_meter_provider(meter_provider) + + _exporter = exporter + _metric_reader = metric_reader + else: + meter_provider = metrics.get_meter_provider() + tracer_provider = trace.get_tracer_provider() + + exporter = _exporter + metric_reader = _metric_reader + + yield _exporter, metric_reader + + exporter.clear() + meter_provider.force_flush() + # tracer_provider.shutdown() + # meter_provider.shutdown() + + +@pytest.fixture(scope="function") +def test_exporter(test_telemetry): + """Provide the in-memory span exporter for each test.""" + exporter, _ = test_telemetry + exporter.clear() + return exporter + + +@pytest.fixture(scope="function") +def test_metric_reader(test_telemetry): + """Provide a delta view of the metric reader for each test. + Only metrics recorded *during* the test are visible.""" + _, metric_reader = test_telemetry + metric_reader.force_flush() + return DeltaMetricReader(metric_reader) diff --git a/tests/_common/telemetry_utils.py b/tests/_common/telemetry_utils.py new file mode 100644 index 00000000..3acb4008 --- /dev/null +++ b/tests/_common/telemetry_utils.py @@ -0,0 +1,38 @@ +def find_metric(metrics_data, metric_name): + """Helper function to find a metric by name in the collected metrics data. + + Usage: + metric = find_metric(metrics_data, "my_metric_name") + """ + for resource_metric in metrics_data.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + if metric.name == metric_name: + return metric + return None + + +def sum_counter(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.value + return total + + +def sum_hist_count(metric, attribute_filter=None): + if metric is None: + return 0 + total = 0 + for point in metric.data.data_points: + if attribute_filter is None or all( + point.attributes.get(key) == value + for key, value in attribute_filter.items() + ): + total += point.count + return total diff --git a/tests/hosting_core/telemetry/__init__.py b/tests/hosting_core/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/telemetry/test_adapter_spans.py b/tests/hosting_core/telemetry/test_adapter_spans.py new file mode 100644 index 00000000..d8262851 --- /dev/null +++ b/tests/hosting_core/telemetry/test_adapter_spans.py @@ -0,0 +1,336 @@ +from microsoft_agents.hosting.core.telemetry import ( + attributes, +) +from microsoft_agents.hosting.core.telemetry.adapter.spans import ( + AdapterProcess, + AdapterSendActivities, + AdapterUpdateActivity, + AdapterDeleteActivity, + AdapterContinueConversation, + AdapterCreateUserTokenClient, + AdapterCreateConnectorClient, +) +from microsoft_agents.hosting.core.telemetry.adapter import constants +from microsoft_agents.activity import Activity, ConversationAccount, ChannelAccount + +from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures + test_telemetry, + test_exporter, + test_metric_reader, +) +from tests._common.telemetry_utils import ( + find_metric, + sum_counter, + sum_hist_count, +) + + +def _make_activity(**overrides) -> Activity: + defaults = dict( + type="message", + id="activity-1", + channel_id="msteams", + text="Hello!", + conversation=ConversationAccount(id="conversation-1"), + from_property=ChannelAccount(id="user-1", name="User"), + recipient=ChannelAccount(id="bot-1", name="Bot"), + ) + defaults.update(overrides) + return Activity(**defaults) + + +# ---- AdapterProcess ---- + + +def test_adapter_process_creates_span(test_exporter): + activity = _make_activity() + + with AdapterProcess(activity): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_PROCESS + + +def test_adapter_process_span_attributes(test_exporter): + activity = _make_activity(type="invoke", channel_id="webchat") + + with AdapterProcess(activity): + pass + + span = test_exporter.get_finished_spans()[0] + span_attrs = dict(span.attributes) + assert span_attrs[attributes.ACTIVITY_TYPE] == "invoke" + assert span_attrs[attributes.ACTIVITY_CHANNEL_ID] == "webchat" + assert attributes.CONVERSATION_ID in span_attrs + assert attributes.ACTIVITY_DELIVERY_MODE in span_attrs + assert attributes.IS_AGENTIC in span_attrs + + +def test_adapter_process_span_attributes_shared_activity(test_exporter): + activity = _make_activity(type="invoke", channel_id="webchat") + + with AdapterProcess() as span: + span.share(activity=activity) + + span = test_exporter.get_finished_spans()[0] + span_attrs = dict(span.attributes) + assert span_attrs[attributes.ACTIVITY_TYPE] == "invoke" + assert span_attrs[attributes.ACTIVITY_CHANNEL_ID] == "webchat" + assert attributes.CONVERSATION_ID in span_attrs + assert attributes.ACTIVITY_DELIVERY_MODE in span_attrs + assert attributes.IS_AGENTIC in span_attrs + + +def test_adapter_process_records_metrics(test_exporter, test_metric_reader): + activity = _make_activity() + + with AdapterProcess(activity): + pass + + metric_data = test_metric_reader.get_metrics_data() + + received = sum_counter( + find_metric(metric_data, constants.METRIC_ACTIVITIES_RECEIVED) + ) + + assert received == 1 + + duration_count = sum_hist_count( + find_metric(metric_data, constants.METRIC_ADAPTER_PROCESS_DURATION) + ) + assert duration_count == 1 + + +# ---- AdapterSendActivities ---- + + +def test_adapter_send_activities_creates_span(test_exporter): + activities = [_make_activity(), _make_activity(type="typing")] + + with AdapterSendActivities(activities): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_SEND_ACTIVITIES + + +def test_adapter_send_activities_span_attributes(test_exporter): + activities = [_make_activity(), _make_activity()] + + with AdapterSendActivities(activities): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_COUNT] == 2 + assert span.attributes[attributes.CONVERSATION_ID] == "conversation-1" + + +def test_adapter_send_activities_empty_list(test_exporter): + with AdapterSendActivities([]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_COUNT] == 0 + assert span.attributes[attributes.CONVERSATION_ID] == attributes.UNKNOWN + + +def test_adapter_send_activities_records_metrics(test_exporter, test_metric_reader): + activities = [ + _make_activity(channel_id="msteams"), + _make_activity(channel_id="webchat"), + ] + + with AdapterSendActivities(activities): + pass + + metric_data = test_metric_reader.get_metrics_data() + sent = sum_counter(find_metric(metric_data, constants.METRIC_ACTIVITIES_SENT)) + assert sent == 2 + + +# ---- AdapterUpdateActivity ---- + + +def test_adapter_update_activity_creates_span(test_exporter): + activity = _make_activity(id="act-42") + + with AdapterUpdateActivity(activity): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_UPDATE_ACTIVITY + + +def test_adapter_update_activity_span_attributes(test_exporter): + activity = _make_activity(id="act-42") + + with AdapterUpdateActivity(activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_ID] == "act-42" + assert span.attributes[attributes.CONVERSATION_ID] == "conversation-1" + + +def test_adapter_update_activity_records_metrics(test_exporter, test_metric_reader): + activity = _make_activity() + + with AdapterUpdateActivity(activity): + pass + + metric_data = test_metric_reader.get_metrics_data() + updated = sum_counter(find_metric(metric_data, constants.METRIC_ACTIVITIES_UPDATED)) + assert updated == 1 + + +def test_adapter_update_activity_missing_id(test_exporter): + activity = _make_activity(id=None) + + with AdapterUpdateActivity(activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_ID] == attributes.UNKNOWN + + +# ---- AdapterDeleteActivity ---- + + +def test_adapter_delete_activity_creates_span(test_exporter): + activity = _make_activity(id="act-99") + + with AdapterDeleteActivity(activity): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_DELETE_ACTIVITY + + +def test_adapter_delete_activity_span_attributes(test_exporter): + activity = _make_activity(id="act-99") + + with AdapterDeleteActivity(activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_ID] == "act-99" + assert span.attributes[attributes.CONVERSATION_ID] == "conversation-1" + + +def test_adapter_delete_activity_records_metrics(test_exporter, test_metric_reader): + activity = _make_activity() + + with AdapterDeleteActivity(activity): + pass + + metric_data = test_metric_reader.get_metrics_data() + deleted = sum_counter(find_metric(metric_data, constants.METRIC_ACTIVITIES_DELETED)) + assert deleted == 1 + + +# ---- AdapterContinueConversation ---- + + +def test_adapter_continue_conversation_creates_span(test_exporter): + activity = _make_activity() + + with AdapterContinueConversation(activity): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_CONTINUE_CONVERSATION + + +def test_adapter_continue_conversation_span_attributes(test_exporter): + activity = _make_activity() + + with AdapterContinueConversation(activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.APP_ID] == "bot-1" + assert span.attributes[attributes.CONVERSATION_ID] == "conversation-1" + + +def test_adapter_continue_conversation_no_recipient(test_exporter): + activity = _make_activity() + activity.recipient = None + + with AdapterContinueConversation(activity): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.APP_ID] == attributes.UNKNOWN + + +# ---- AdapterCreateUserTokenClient ---- + + +def test_adapter_create_user_token_client_creates_span(test_exporter): + with AdapterCreateUserTokenClient("https://token.example.com", ["scope1"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_CREATE_USER_TOKEN_CLIENT + + +def test_adapter_create_user_token_client_span_attributes(test_exporter): + with AdapterCreateUserTokenClient( + "https://token.example.com", ["User.Read", "Mail.Read"] + ): + pass + + span = test_exporter.get_finished_spans()[0] + assert ( + span.attributes[attributes.TOKEN_SERVICE_ENDPOINT] + == "https://token.example.com" + ) + assert span.attributes[attributes.AUTH_SCOPES] == "User.Read,Mail.Read" + + +def test_adapter_create_user_token_client_no_scopes(test_exporter): + with AdapterCreateUserTokenClient("https://token.example.com", None): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_SCOPES] == attributes.UNKNOWN + + +# ---- AdapterCreateConnectorClient ---- + + +def test_adapter_create_connector_client_creates_span(test_exporter): + with AdapterCreateConnectorClient("https://service.example.com", ["scope1"], False): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_CREATE_CONNECTOR_CLIENT + + +def test_adapter_create_connector_client_span_attributes(test_exporter): + with AdapterCreateConnectorClient( + "https://service.example.com", ["Bot.Read"], True + ): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.SERVICE_URL] == "https://service.example.com" + assert span.attributes[attributes.AUTH_SCOPES] == "Bot.Read" + assert span.attributes[attributes.IS_AGENTIC] is True + + +def test_adapter_create_connector_client_not_agentic(test_exporter): + with AdapterCreateConnectorClient("https://svc.example.com", None, False): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.IS_AGENTIC] is False + assert span.attributes[attributes.AUTH_SCOPES] == attributes.UNKNOWN diff --git a/tests/hosting_core/telemetry/test_agents_telemetry.py b/tests/hosting_core/telemetry/test_agents_telemetry.py new file mode 100644 index 00000000..954daa9a --- /dev/null +++ b/tests/hosting_core/telemetry/test_agents_telemetry.py @@ -0,0 +1,109 @@ +from opentelemetry import trace + +from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures + test_telemetry, + test_exporter, + test_metric_reader, +) + +from tests._common.telemetry_utils import find_metric, sum_counter + +from microsoft_agents.hosting.core.telemetry import ( + agents_telemetry, + SERVICE_NAME, + SERVICE_VERSION, +) + + +def test_tracer(test_exporter): + """Test that the tracer is initialized with the correct service name and version.""" + + with agents_telemetry.tracer.start_as_current_span("test_span"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "test_span" + assert spans[0].instrumentation_scope.name == SERVICE_NAME + assert spans[0].instrumentation_scope.version == SERVICE_VERSION + + +def test_meter(test_metric_reader): + """Test that the meter is initialized with the correct service name and version.""" + counter = agents_telemetry.meter.create_counter("test_counter") + counter.add(1) + + metrics_data = test_metric_reader.get_metrics_data() + metric = find_metric(metrics_data, "test_counter") + assert len(metric.data.data_points) == 1 + assert sum_counter(metric) == 1 + assert metric.name == "test_counter" + + +def test_start_as_current_span(test_exporter): + """Test start_as_current_span creates a span with context attributes.""" + + with agents_telemetry.start_as_current_span("test_span"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "test_span" + assert spans[0].instrumentation_scope.name == SERVICE_NAME + assert spans[0].instrumentation_scope.version == SERVICE_VERSION + + +def test_start_as_current_span_with_callback(mocker, test_exporter): + """Test start_as_current_span records success status and callback payload.""" + callback = mocker.Mock() + + with agents_telemetry.start_as_current_span( + "test_span", + callback=callback, + ): + pass + + finished_spans = test_exporter.get_finished_spans() + assert len(finished_spans) == 1 + + finished_span = finished_spans[0] + assert finished_span.name == "test_span" + assert finished_span.status.status_code == trace.StatusCode.OK + + callback.assert_called_once() + callback_span, duration_ms, callback_exception = callback.call_args.args + assert callback_span.name == "test_span" + assert duration_ms >= 0 + assert callback_exception is None + + +def test_start_as_current_span_with_callback_with_failure(mocker, test_exporter): + """Test start_as_current_span records failure status and callback payload.""" + callback = mocker.Mock() + + exception_raised = False + try: + with agents_telemetry.start_as_current_span( + "test_span", + callback=callback, + ): + raise ValueError("Test exception") + except ValueError as ex: + exception_raised = True + assert str(ex) == "Test exception" + + assert exception_raised + + finished_spans = test_exporter.get_finished_spans() + assert len(finished_spans) == 1 + + finished_span = finished_spans[0] + assert finished_span.name == "test_span" + assert finished_span.status.status_code == trace.StatusCode.ERROR + + callback.assert_called_once() + callback_span, duration_ms, callback_exception = callback.call_args.args + assert callback_span.name == "test_span" + assert duration_ms >= 0 + assert callback_exception is not None + assert str(callback_exception) == "Test exception" diff --git a/tests/hosting_core/telemetry/test_app_spans.py b/tests/hosting_core/telemetry/test_app_spans.py new file mode 100644 index 00000000..8fe8ea71 --- /dev/null +++ b/tests/hosting_core/telemetry/test_app_spans.py @@ -0,0 +1,203 @@ +from types import SimpleNamespace + +from microsoft_agents.activity import Activity, ConversationAccount, ChannelAccount +from microsoft_agents.hosting.core.telemetry import attributes +from microsoft_agents.hosting.core.app.telemetry.spans import ( + AppOnTurn, + AppRouteHandler, + AppBeforeTurn, + AppAfterTurn, + AppDownloadFiles, +) +from microsoft_agents.hosting.core.app.telemetry import constants + +from tests._common.fixtures.telemetry import ( + test_telemetry, + test_exporter, + test_metric_reader, +) +from tests._common.telemetry_utils import find_metric, sum_counter, sum_hist_count + + +def _make_context(**activity_overrides): + defaults = dict( + type="message", + channel_id="msteams", + service_url="https://smba.trafficmanager.net/teams/", + conversation=ConversationAccount(id="conv-1"), + from_property=ChannelAccount(id="user-1"), + recipient=ChannelAccount(id="bot-1"), + ) + defaults.update(activity_overrides) + activity = Activity(**defaults) + return SimpleNamespace(activity=activity) + + +# ---- AppOnTurn ---- + + +def test_app_on_turn_creates_span(test_exporter): + ctx = _make_context() + + with AppOnTurn(ctx): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_ON_TURN + + +def test_app_on_turn_span_attributes(test_exporter): + ctx = _make_context(id="act-1") + + with AppOnTurn(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_TYPE] == "message" + assert span.attributes[attributes.ACTIVITY_ID] == "act-1" + + +def test_app_on_turn_span_attributes_missing_id(test_exporter): + ctx = _make_context() + + with AppOnTurn(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ACTIVITY_TYPE] == "message" + assert span.attributes[attributes.ACTIVITY_ID] == attributes.UNKNOWN + + +def test_app_on_turn_records_turn_metrics(test_exporter, test_metric_reader): + ctx = _make_context() + + with AppOnTurn(ctx): + pass + + data = test_metric_reader.get_metrics_data() + count = sum_counter(find_metric(data, constants.METRIC_TURN_COUNT)) + assert count == 1 + duration = sum_hist_count(find_metric(data, constants.METRIC_TURN_DURATION)) + assert duration == 1 + + +def test_app_on_turn_records_error_metric_on_exception( + test_exporter, test_metric_reader +): + ctx = _make_context() + + try: + with AppOnTurn(ctx): + raise ValueError("boom") + except ValueError: + pass + + data = test_metric_reader.get_metrics_data() + error_count = sum_counter(find_metric(data, constants.METRIC_TURN_ERROR_COUNT)) + assert error_count == 1 + # success counter should NOT be incremented + success_count = sum_counter(find_metric(data, constants.METRIC_TURN_COUNT)) + assert success_count == 0 + + +def test_app_on_turn_share(test_exporter): + ctx = _make_context() + + with AppOnTurn(ctx) as wrapper: + wrapper.share(route_authorized=True, route_matched=False) + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ROUTE_AUTHORIZED] is True + assert span.attributes[attributes.ROUTE_MATCHED] is False + + +# ---- AppRouteHandler ---- + + +def test_app_route_handler_creates_span(test_exporter): + with AppRouteHandler(is_invoke=False, is_agentic=False): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_ROUTE_HANDLER + + +def test_app_route_handler_span_attributes(test_exporter): + with AppRouteHandler(is_invoke=True, is_agentic=True): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ROUTE_IS_INVOKE] is True + assert span.attributes[attributes.ROUTE_IS_AGENTIC] is True + + +def test_app_route_handler_not_invoke_not_agentic(test_exporter): + with AppRouteHandler(is_invoke=False, is_agentic=False): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ROUTE_IS_INVOKE] is False + assert span.attributes[attributes.ROUTE_IS_AGENTIC] is False + + +# ---- AppBeforeTurn ---- + + +def test_app_before_turn_creates_span(test_exporter): + with AppBeforeTurn(): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_BEFORE_TURN + + +# ---- AppAfterTurn ---- + + +def test_app_after_turn_creates_span(test_exporter): + with AppAfterTurn(): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_AFTER_TURN + + +# ---- AppDownloadFiles ---- + + +def test_app_download_files_creates_span(test_exporter): + ctx = _make_context() + ctx.activity.attachments = [] + + with AppDownloadFiles(ctx): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_DOWNLOAD_FILES + + +def test_app_download_files_attachment_count(test_exporter): + ctx = _make_context() + ctx.activity.attachments = [SimpleNamespace(), SimpleNamespace(), SimpleNamespace()] + + with AppDownloadFiles(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ATTACHMENT_COUNT] == 3 + + +def test_app_download_files_no_attachments(test_exporter): + ctx = _make_context() + ctx.activity.attachments = None + + with AppDownloadFiles(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.ATTACHMENT_COUNT] == 0 diff --git a/tests/hosting_core/telemetry/test_auth_spans.py b/tests/hosting_core/telemetry/test_auth_spans.py new file mode 100644 index 00000000..8c61376f --- /dev/null +++ b/tests/hosting_core/telemetry/test_auth_spans.py @@ -0,0 +1,146 @@ +from microsoft_agents.hosting.core.telemetry import attributes +from microsoft_agents.hosting.core.authorization.telemetry.spans import ( + GetAccessToken, + AcquireTokenOnBehalfOf, + GetAgenticInstanceToken, + GetAgenticUserToken, +) +from microsoft_agents.hosting.core.authorization.telemetry import constants + +from tests._common.fixtures.telemetry import ( + test_telemetry, + test_exporter, + test_metric_reader, +) +from tests._common.telemetry_utils import find_metric, sum_counter, sum_hist_count + +# ---- GetAccessToken ---- + + +def test_get_access_token_creates_span(test_exporter): + with GetAccessToken(["User.Read"], "client_credentials"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_GET_ACCESS_TOKEN + + +def test_get_access_token_span_attributes(test_exporter): + with GetAccessToken(["User.Read", "Mail.Read"], "client_credentials"): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_SCOPES] == "User.Read,Mail.Read" + assert span.attributes[attributes.AUTH_METHOD] == "client_credentials" + + +def test_get_access_token_records_metrics(test_exporter, test_metric_reader): + with GetAccessToken(["scope"], "client_credentials"): + pass + + data = test_metric_reader.get_metrics_data() + count = sum_counter(find_metric(data, constants.METRIC_AUTH_TOKEN_REQUEST_COUNT)) + assert count == 1 + duration = sum_hist_count( + find_metric(data, constants.METRIC_AUTH_TOKEN_REQUEST_DURATION) + ) + assert duration == 1 + + +# ---- AcquireTokenOnBehalfOf ---- + + +def test_acquire_token_obo_creates_span(test_exporter): + with AcquireTokenOnBehalfOf(["User.Read"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_ACQUIRE_TOKEN_ON_BEHALF_OF + + +def test_acquire_token_obo_span_attributes(test_exporter): + with AcquireTokenOnBehalfOf(["User.Read"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_SCOPES] == "User.Read" + + +def test_acquire_token_obo_records_metrics(test_exporter, test_metric_reader): + with AcquireTokenOnBehalfOf(["scope"]): + pass + + data = test_metric_reader.get_metrics_data() + count = sum_counter( + find_metric(data, constants.METRIC_AUTH_TOKEN_REQUEST_COUNT), + {attributes.AUTH_METHOD: constants.AUTH_METHOD_OBO}, + ) + assert count == 1 + + +# ---- GetAgenticInstanceToken ---- + + +def test_get_agentic_instance_token_creates_span(test_exporter): + with GetAgenticInstanceToken("instance-42"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_GET_AGENTIC_INSTANCE_TOKEN + + +def test_get_agentic_instance_token_span_attributes(test_exporter): + with GetAgenticInstanceToken("instance-42"): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AGENTIC_INSTANCE_ID] == "instance-42" + + +def test_get_agentic_instance_token_records_metrics(test_exporter, test_metric_reader): + with GetAgenticInstanceToken("instance-42"): + pass + + data = test_metric_reader.get_metrics_data() + count = sum_counter( + find_metric(data, constants.METRIC_AUTH_TOKEN_REQUEST_COUNT), + {attributes.AUTH_METHOD: constants.AUTH_METHOD_AGENTIC_INSTANCE}, + ) + assert count == 1 + + +# ---- GetAgenticUserToken ---- + + +def test_get_agentic_user_token_creates_span(test_exporter): + with GetAgenticUserToken("instance-1", "user-1", ["scope"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_GET_AGENTIC_USER_TOKEN + + +def test_get_agentic_user_token_span_attributes(test_exporter): + with GetAgenticUserToken("instance-1", "user-1", ["Scope.A", "Scope.B"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AGENTIC_INSTANCE_ID] == "instance-1" + assert span.attributes[attributes.AGENTIC_USER_ID] == "user-1" + assert span.attributes[attributes.AUTH_SCOPES] == "Scope.A,Scope.B" + + +def test_get_agentic_user_token_records_metrics(test_exporter, test_metric_reader): + with GetAgenticUserToken("instance-1", "user-1", ["scope"]): + pass + + data = test_metric_reader.get_metrics_data() + count = sum_counter( + find_metric(data, constants.METRIC_AUTH_TOKEN_REQUEST_COUNT), + {attributes.AUTH_METHOD: constants.AUTH_METHOD_AGENTIC_USER}, + ) + assert count == 1 diff --git a/tests/hosting_core/telemetry/test_oauth_spans.py b/tests/hosting_core/telemetry/test_oauth_spans.py new file mode 100644 index 00000000..eb63935d --- /dev/null +++ b/tests/hosting_core/telemetry/test_oauth_spans.py @@ -0,0 +1,124 @@ +from microsoft_agents.hosting.core.telemetry import attributes +from microsoft_agents.hosting.core.app.oauth.telemetry.spans import ( + AgenticToken, + AzureBotToken, + AzureBotSignIn, + AzureBotSignOut, +) +from microsoft_agents.hosting.core.app.oauth.telemetry import constants + +from tests._common.fixtures.telemetry import ( + test_telemetry, + test_exporter, + test_metric_reader, +) + +# ---- AgenticToken ---- + + +def test_agentic_token_creates_span(test_exporter): + with AgenticToken("handler-1", "conn-1", ["scope"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.AGENTIC_TOKEN + + +def test_agentic_token_span_attributes(test_exporter): + with AgenticToken("handler-1", "conn-1", ["Scope.A", "Scope.B"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_HANDLER_ID] == "handler-1" + assert span.attributes[attributes.CONNECTION_NAME] == "conn-1" + assert span.attributes[attributes.AUTH_SCOPES] == "Scope.A,Scope.B" + + +def test_agentic_token_no_connection(test_exporter): + with AgenticToken("handler-1", None, ["scope"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONNECTION_NAME] == attributes.UNKNOWN + + +def test_agentic_token_no_scopes(test_exporter): + with AgenticToken("handler-1", "conn-1", None): + pass + + span = test_exporter.get_finished_spans()[0] + assert attributes.AUTH_SCOPES not in span.attributes + + +# ---- AzureBotToken ---- + + +def test_azure_bot_token_creates_span(test_exporter): + with AzureBotToken("handler-2", "conn-2", ["scope"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.AZURE_BOT_TOKEN + + +def test_azure_bot_token_span_attributes(test_exporter): + with AzureBotToken("handler-2", "conn-2", ["Mail.Read"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_HANDLER_ID] == "handler-2" + assert span.attributes[attributes.CONNECTION_NAME] == "conn-2" + assert span.attributes[attributes.AUTH_SCOPES] == "Mail.Read" + + +# ---- AzureBotSignIn ---- + + +def test_azure_bot_sign_in_creates_span(test_exporter): + with AzureBotSignIn("handler-3", "conn-3", ["scope"]): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.AZURE_BOT_SIGN_IN + + +def test_azure_bot_sign_in_span_attributes(test_exporter): + with AzureBotSignIn("handler-3", "conn-3", ["User.Read"]): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_HANDLER_ID] == "handler-3" + assert span.attributes[attributes.CONNECTION_NAME] == "conn-3" + assert span.attributes[attributes.AUTH_SCOPES] == "User.Read" + + +# ---- AzureBotSignOut ---- + + +def test_azure_bot_sign_out_creates_span(test_exporter): + with AzureBotSignOut("handler-4"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.AZURE_BOT_SIGN_OUT + + +def test_azure_bot_sign_out_span_attributes(test_exporter): + with AzureBotSignOut("handler-4"): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.AUTH_HANDLER_ID] == "handler-4" + assert span.attributes[attributes.CONNECTION_NAME] == attributes.UNKNOWN + + +def test_azure_bot_sign_out_no_scopes_in_attributes(test_exporter): + with AzureBotSignOut("handler-4"): + pass + + span = test_exporter.get_finished_spans()[0] + assert attributes.AUTH_SCOPES not in span.attributes diff --git a/tests/hosting_core/telemetry/test_simple_span_wrapper.py b/tests/hosting_core/telemetry/test_simple_span_wrapper.py new file mode 100644 index 00000000..e0e44c5b --- /dev/null +++ b/tests/hosting_core/telemetry/test_simple_span_wrapper.py @@ -0,0 +1,252 @@ +import time + +import pytest +from types import SimpleNamespace + +from opentelemetry.trace import StatusCode + +from tests._common.fixtures.telemetry import ( # unused imports are needed for fixtures + test_telemetry, + test_exporter, + test_metric_reader, +) +from tests._common.telemetry_utils import ( + find_metric, + sum_counter, + sum_hist_count, +) + +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.telemetry import ( + agents_telemetry, + SimpleSpanWrapper, +) + + +class MySpanWrapper(SimpleSpanWrapper): + """Subclass with custom attributes and a callback that records info on the span.""" + + def __init__(self, span_name): + super().__init__(span_name) + + def _callback(self, span, duration_ms, exception): + span.set_attribute("callback_called", True) + span.set_attribute("duration_ms", duration_ms) + if exception: + span.set_attribute("exception_message", str(exception)) + + def _get_attributes(self): + return {"custom_attribute": "custom_value"} + + +class MinimalSpanWrapper(SimpleSpanWrapper): + """Subclass that uses default (no-op) _callback and empty _get_attributes.""" + + def __init__(self, span_name): + super().__init__(span_name) + + +class TestSimpleSpanWrapper: + def test_simple_span_wrapper(self, test_exporter): + """Test that MySpanWrapper creates a span with the correct attributes and callback.""" + with MySpanWrapper("test_simple_span"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.name == "test_simple_span" + assert span.attributes["custom_attribute"] == "custom_value" + assert span.attributes["callback_called"] is True + assert span.attributes["duration_ms"] >= 0 + + def test_minimal_span_wrapper_creates_span(self, test_exporter): + """A subclass with no overrides still creates a valid span.""" + with MinimalSpanWrapper("minimal_span"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "minimal_span" + + def test_minimal_span_no_custom_attributes(self, test_exporter): + """Default _get_attributes returns empty dict, so no custom attributes are set.""" + with MinimalSpanWrapper("no_attrs"): + pass + + span = test_exporter.get_finished_spans()[0] + # The span should not have the custom_attribute key + assert "custom_attribute" not in (span.attributes or {}) + + def test_span_status_ok_on_success(self, test_exporter): + """Span status is OK when the body completes without error.""" + with MySpanWrapper("ok_span"): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.status.status_code == StatusCode.OK + + def test_span_status_error_on_exception(self, test_exporter): + """Span status is ERROR and exception is re-raised when body raises.""" + with pytest.raises(ValueError, match="boom"): + with MySpanWrapper("err_span"): + raise ValueError("boom") + + span = test_exporter.get_finished_spans()[0] + assert span.status.status_code == StatusCode.ERROR + + def test_callback_receives_exception(self, test_exporter): + """The callback receives the exception object when the body raises.""" + with pytest.raises(RuntimeError): + with MySpanWrapper("cb_err"): + raise RuntimeError("fail") + + span = test_exporter.get_finished_spans()[0] + assert span.attributes["callback_called"] is True + assert span.attributes["exception_message"] == "fail" + + def test_exception_is_recorded_on_span(self, test_exporter): + """record_exception is called, so the span events contain the exception.""" + with pytest.raises(TypeError): + with MySpanWrapper("rec_exc"): + raise TypeError("type error") + + span = test_exporter.get_finished_spans()[0] + exception_events = [e for e in span.events if e.name == "exception"] + assert len(exception_events) == 1 + assert "type error" in exception_events[0].attributes["exception.message"] + + def test_span_completion_event_on_success(self, test_exporter): + """A completion event is added on successful span execution.""" + with MinimalSpanWrapper("evt_span"): + pass + + span = test_exporter.get_finished_spans()[0] + completion_events = [e for e in span.events if "completed" in e.name] + assert len(completion_events) == 1 + assert completion_events[0].attributes["duration_ms"] >= 0 + + def test_no_completion_event_on_failure(self, test_exporter): + """No completion event is added when the span body raises.""" + with pytest.raises(Exception): + with MinimalSpanWrapper("no_evt"): + raise Exception("oops") + + span = test_exporter.get_finished_spans()[0] + completion_events = [e for e in span.events if "completed" in e.name] + assert len(completion_events) == 0 + + def test_duration_is_positive(self, test_exporter): + """The callback's duration_ms reflects actual elapsed time.""" + with MySpanWrapper("dur_span"): + time.sleep(0.05) + + span = test_exporter.get_finished_spans()[0] + assert span.attributes["duration_ms"] >= 40 # at least ~40ms + + def test_active_property_inside_context(self, test_exporter): + """The active property is True while the context manager is open.""" + wrapper = MySpanWrapper("active_test") + assert wrapper.active is False + + with wrapper: + assert wrapper.active is True + + assert wrapper.active is False + + def test_otel_span_accessible_inside_context(self, test_exporter): + """otel_span returns the underlying span while active.""" + wrapper = MinimalSpanWrapper("otel_access") + with wrapper: + otel_span = wrapper.otel_span + assert otel_span is not None + + def test_otel_span_raises_when_not_started(self): + """Accessing otel_span before start raises RuntimeError.""" + wrapper = MinimalSpanWrapper("not_started") + assert wrapper.otel_span is None + + def test_start_end_manual_lifecycle(self, test_exporter): + """start() and end() can be used instead of the context manager.""" + wrapper = MySpanWrapper("manual_lifecycle") + wrapper.start() + assert wrapper.active is True + wrapper.end() + assert wrapper.active is False + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "manual_lifecycle" + + def test_multiple_sequential_spans(self, test_exporter): + """Multiple span wrappers used sequentially each create their own span.""" + with MySpanWrapper("seq_1"): + pass + with MySpanWrapper("seq_2"): + pass + with MinimalSpanWrapper("seq_3"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 3 + names = [s.name for s in spans] + assert "seq_1" in names + assert "seq_2" in names + assert "seq_3" in names + + def test_nested_span_wrappers(self, test_exporter): + """Nested span wrappers create parent-child span relationships.""" + with MySpanWrapper("parent"): + with MinimalSpanWrapper("child"): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 2 + child_span = next(s for s in spans if s.name == "child") + parent_span = next(s for s in spans if s.name == "parent") + assert child_span.parent.span_id == parent_span.context.span_id + + def test_wrapper_reuse_after_end(self, test_exporter): + """A wrapper can be reused after it has been ended.""" + wrapper = MySpanWrapper("reuse") + + with wrapper: + pass + assert wrapper.active is False + + # Re-enter + with wrapper: + assert wrapper.active is True + assert wrapper.active is False + + spans = test_exporter.get_finished_spans() + assert len(spans) == 2 + assert all(s.name == "reuse" for s in spans) + + def test_custom_attributes_set_on_span(self, test_exporter): + """Custom attributes from _get_attributes appear on the finished span.""" + + class MultiAttrWrapper(SimpleSpanWrapper): + def __init__(self): + super().__init__("multi_attr") + + def _get_attributes(self): + return {"key_a": "val_a", "key_b": 42, "key_c": True} + + with MultiAttrWrapper(): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes["key_a"] == "val_a" + assert span.attributes["key_b"] == 42 + assert span.attributes["key_c"] is True + + def test_exception_propagates_unchanged(self, test_exporter): + """The original exception type and message are preserved after re-raise.""" + + class CustomError(Exception): + pass + + with pytest.raises(CustomError, match="custom msg"): + with MinimalSpanWrapper("propagate"): + raise CustomError("custom msg") diff --git a/tests/hosting_core/telemetry/test_storage_spans.py b/tests/hosting_core/telemetry/test_storage_spans.py new file mode 100644 index 00000000..25ac7566 --- /dev/null +++ b/tests/hosting_core/telemetry/test_storage_spans.py @@ -0,0 +1,104 @@ +from microsoft_agents.hosting.core.telemetry import attributes +from microsoft_agents.hosting.core.storage.telemetry.spans import ( + StorageRead, + StorageWrite, + StorageDelete, +) +from microsoft_agents.hosting.core.storage.telemetry import constants + +from tests._common.fixtures.telemetry import ( + test_telemetry, + test_exporter, + test_metric_reader, +) +from tests._common.telemetry_utils import find_metric, sum_counter, sum_hist_count + +# ---- StorageRead ---- + + +def test_storage_read_creates_span(test_exporter): + with StorageRead(key_count=3): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_STORAGE_READ + + +def test_storage_read_span_attributes(test_exporter): + with StorageRead(key_count=5): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.KEY_COUNT] == 5 + + +def test_storage_read_records_metrics(test_exporter, test_metric_reader): + with StorageRead(key_count=1): + pass + + data = test_metric_reader.get_metrics_data() + total = sum_counter(find_metric(data, constants.METRIC_STORAGE_OPERATION_TOTAL)) + assert total == 1 + duration = sum_hist_count( + find_metric(data, constants.METRIC_STORAGE_OPERATION_DURATION) + ) + assert duration == 1 + + +# ---- StorageWrite ---- + + +def test_storage_write_creates_span(test_exporter): + with StorageWrite(key_count=2): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_STORAGE_WRITE + + +def test_storage_write_span_attributes(test_exporter): + with StorageWrite(key_count=7): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.KEY_COUNT] == 7 + + +def test_storage_write_records_metrics(test_exporter, test_metric_reader): + with StorageWrite(key_count=1): + pass + + data = test_metric_reader.get_metrics_data() + total = sum_counter(find_metric(data, constants.METRIC_STORAGE_OPERATION_TOTAL)) + assert total == 1 + + +# ---- StorageDelete ---- + + +def test_storage_delete_creates_span(test_exporter): + with StorageDelete(key_count=1): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_STORAGE_DELETE + + +def test_storage_delete_span_attributes(test_exporter): + with StorageDelete(key_count=4): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.KEY_COUNT] == 4 + + +def test_storage_delete_records_metrics(test_exporter, test_metric_reader): + with StorageDelete(key_count=1): + pass + + data = test_metric_reader.get_metrics_data() + total = sum_counter(find_metric(data, constants.METRIC_STORAGE_OPERATION_TOTAL)) + assert total == 1 diff --git a/tests/hosting_core/telemetry/test_turn_context_spans.py b/tests/hosting_core/telemetry/test_turn_context_spans.py new file mode 100644 index 00000000..ca9c3db9 --- /dev/null +++ b/tests/hosting_core/telemetry/test_turn_context_spans.py @@ -0,0 +1,62 @@ +from types import SimpleNamespace + +from microsoft_agents.activity import Activity, ConversationAccount, ChannelAccount +from microsoft_agents.hosting.core.telemetry import attributes +from microsoft_agents.hosting.core.telemetry.turn_context.spans import ( + TurnContextSendActivity, +) +from microsoft_agents.hosting.core.telemetry.turn_context import constants + +from tests._common.fixtures.telemetry import ( + test_telemetry, + test_exporter, + test_metric_reader, +) + + +def _make_context(**activity_overrides): + defaults = dict( + type="message", + channel_id="msteams", + conversation=ConversationAccount(id="conv-1"), + from_property=ChannelAccount(id="user-1"), + recipient=ChannelAccount(id="bot-1"), + ) + defaults.update(activity_overrides) + activity = Activity(**defaults) + return SimpleNamespace(activity=activity) + + +# ---- TurnContextSendActivity ---- + + +def test_send_activity_creates_span(test_exporter): + ctx = _make_context() + + with TurnContextSendActivity(ctx): + pass + + spans = test_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == constants.SPAN_TURN_SEND_ACTIVITY + + +def test_send_activity_span_attributes(test_exporter): + ctx = _make_context() + + with TurnContextSendActivity(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == "conv-1" + + +def test_send_activity_no_conversation(test_exporter): + ctx = _make_context() + ctx.activity.conversation = None + + with TurnContextSendActivity(ctx): + pass + + span = test_exporter.get_finished_spans()[0] + assert span.attributes[attributes.CONVERSATION_ID] == attributes.UNKNOWN diff --git a/tests/hosting_core/telemetry/test_utils.py b/tests/hosting_core/telemetry/test_utils.py new file mode 100644 index 00000000..8f4bd044 --- /dev/null +++ b/tests/hosting_core/telemetry/test_utils.py @@ -0,0 +1,72 @@ +from microsoft_agents.activity import ( + Activity, + ConversationAccount, + DeliveryModes, +) +from microsoft_agents.hosting.core.telemetry.attributes import UNKNOWN +from microsoft_agents.hosting.core.telemetry.utils import ( + format_scopes, + get_conversation_id, + get_delivery_mode, +) + +# ---- format_scopes ---- + + +def test_format_scopes_single(): + assert format_scopes(["User.Read"]) == "User.Read" + + +def test_format_scopes_multiple(): + assert format_scopes(["User.Read", "Mail.Read"]) == "User.Read,Mail.Read" + + +def test_format_scopes_none(): + assert format_scopes(None) == UNKNOWN + + +def test_format_scopes_empty_list(): + assert format_scopes([]) == UNKNOWN + + +# ---- get_conversation_id ---- + + +def test_get_conversation_id_present(): + activity = Activity( + type="message", + conversation=ConversationAccount(id="conv-123"), + ) + assert get_conversation_id(activity) == "conv-123" + + +def test_get_conversation_id_no_conversation(): + activity = Activity(type="message") + assert get_conversation_id(activity) == UNKNOWN + + +# ---- get_delivery_mode ---- + + +def test_get_delivery_mode_enum(): + activity = Activity( + type="message", + delivery_mode=DeliveryModes.expect_replies, + ) + assert get_delivery_mode(activity) == "expectReplies" + + +def test_get_delivery_mode_string(): + activity = Activity(type="message", delivery_mode="custom_mode") + assert get_delivery_mode(activity) == "custom_mode" + + +def test_get_delivery_mode_none(): + activity = Activity(type="message") + assert get_delivery_mode(activity) == UNKNOWN + + +def test_get_delivery_mode_all_enum_values(): + for mode in DeliveryModes: + activity = Activity(type="message", delivery_mode=mode) + assert get_delivery_mode(activity) == mode.value