diff --git a/.github/workflows/samples-integration-test.yml b/.github/workflows/samples-integration-test.yml index 8874bc592..5613f3b67 100644 --- a/.github/workflows/samples-integration-test.yml +++ b/.github/workflows/samples-integration-test.yml @@ -16,6 +16,9 @@ on: permissions: contents: read +env: + ORT_TELEMETRY_DISABLED: '1' + jobs: # ── Python Samples ────────────────────────────────────────────────── python-samples: diff --git a/.pipelines/foundry-local-packaging.yml b/.pipelines/foundry-local-packaging.yml index bedd8a3d1..4cf3cc36e 100644 --- a/.pipelines/foundry-local-packaging.yml +++ b/.pipelines/foundry-local-packaging.yml @@ -53,6 +53,9 @@ parameters: variables: - group: FoundryLocal-ESRP-Signing +# Keep all CI jobs telemetry-free through the shared ORT/1DS runtime opt-out. +- name: ORT_TELEMETRY_DISABLED + value: '1' # C++ SDK (sdk_v2/cpp) native dependency versions. Must match cmake defaults # in sdk_v2/deps_versions.json. - name: cppOrtVersion diff --git a/README.md b/README.md index b86e183cd..af8a1a6e9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Foundry Local is an **end-to-end local AI solution** for building applications t User data never leaves the device, responses start immediately with zero network latency, and your app works offline. No per-token costs, no API keys, no backend infrastructure to maintain, and no Azure subscription required. +Foundry Local may collect usage data and send it to Microsoft to help improve our products and services. See the [privacy statement](sdk_v2/cpp/docs/Privacy.md) for more details. + ### Key Features - **Lightweight runtime** — The runtime handles model acquisition, hardware acceleration, model management, and inference (via [ONNX Runtime](https://onnxruntime.ai/)). diff --git a/sdk_v2/cpp/CMakeLists.txt b/sdk_v2/cpp/CMakeLists.txt index fd1c86b51..43fb436db 100644 --- a/sdk_v2/cpp/CMakeLists.txt +++ b/sdk_v2/cpp/CMakeLists.txt @@ -13,6 +13,12 @@ endif() if(NOT DEFINED FOUNDRY_LOCAL_BUILD_SERVICE OR FOUNDRY_LOCAL_BUILD_SERVICE) list(APPEND VCPKG_MANIFEST_FEATURES "service") endif() +# Telemetry must be selected before project() so vcpkg resolves the feature. Foundry Local Core always builds with +# telemetry; platforms that cannot link the 1DS transport are unsupported for this target. +if(CMAKE_SYSTEM_NAME STREQUAL "WindowsStore" OR VCPKG_TARGET_TRIPLET MATCHES "uwp") + message(FATAL_ERROR "Foundry Local Core always builds with telemetry; UWP/WindowsStore is not supported.") +endif() +list(APPEND VCPKG_MANIFEST_FEATURES "telemetry") project(foundry_local VERSION 0.1.0 LANGUAGES CXX C) @@ -45,6 +51,30 @@ option(FOUNDRY_LOCAL_BUILD_EXAMPLES "Build example programs" ON) option(FOUNDRY_LOCAL_BUILD_SERVICE "Build web service support (requires oat++)" ON) option(FOUNDRY_LOCAL_ENABLE_ASAN "Enable AddressSanitizer + UndefinedBehaviorSanitizer (Linux only)" OFF) +# Optional 1DS ingestion token override. Override only via environment so it does +# not appear in CMake cache files. +if(DEFINED CACHE{FOUNDRY_LOCAL_TELEMETRY_TOKEN} + AND NOT "$CACHE{FOUNDRY_LOCAL_TELEMETRY_TOKEN}" STREQUAL "") + message(FATAL_ERROR + "Do not pass FOUNDRY_LOCAL_TELEMETRY_TOKEN via -D or CMake cache. " + "Use the FOUNDRY_LOCAL_TELEMETRY_TOKEN environment variable for temporary test-tenant overrides.") +endif() +unset(FOUNDRY_LOCAL_TELEMETRY_TOKEN CACHE) + +set(FOUNDRY_LOCAL_TELEMETRY_TOKEN "") +if(DEFINED ENV{FOUNDRY_LOCAL_TELEMETRY_TOKEN}) + set(FOUNDRY_LOCAL_TELEMETRY_TOKEN "$ENV{FOUNDRY_LOCAL_TELEMETRY_TOKEN}") +endif() +if(FOUNDRY_LOCAL_TELEMETRY_TOKEN MATCHES "^\\$\\(") + set(FOUNDRY_LOCAL_TELEMETRY_TOKEN "") +endif() +if(FOUNDRY_LOCAL_TELEMETRY_TOKEN) + set(FOUNDRY_LOCAL_TELEMETRY_TOKEN_DEFINE + "#define FOUNDRY_LOCAL_TELEMETRY_TOKEN \"${FOUNDRY_LOCAL_TELEMETRY_TOKEN}\"") +else() + set(FOUNDRY_LOCAL_TELEMETRY_TOKEN_DEFINE "") +endif() + # Android: interactive examples and host tools don't run on device if(ANDROID) set(FOUNDRY_LOCAL_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) @@ -87,6 +117,13 @@ if(WIN32) find_package(WinMLEpCatalog) endif() +# 1DS C++ client telemetry — provided by the cpp-client-telemetry vcpkg port. +find_package(MSTelemetry CONFIG REQUIRED) +if(NOT WIN32) + find_package(OpenSSL REQUIRED) +endif() +message(STATUS "1DS telemetry: enabled (cpp-client-telemetry found)") + # -------------------------------------------------------------------------- # Library target # -------------------------------------------------------------------------- @@ -196,7 +233,13 @@ set(FOUNDRY_LOCAL_SOURCES src/service/web_service.cc src/telemetry/telemetry.cc src/telemetry/telemetry_action_tracker.cc + src/telemetry/device_id.cc + src/telemetry/invocation_context.cc + src/telemetry/telemetry_environment.cc src/telemetry/telemetry_logger.cc + src/telemetry/telemetry_metadata.cc + src/telemetry/ep_download_tracker.cc + src/telemetry/download_tracker.cc src/utils.cc src/util/file_lock.cc src/http/http_download.cc @@ -208,6 +251,12 @@ set(FOUNDRY_LOCAL_SOURCES ${FOUNDRY_LOCAL_INTERNAL_HEADERS} ) +# 1DS bridge — always compiled for Foundry Local Core. +list(APPEND FOUNDRY_LOCAL_SOURCES src/telemetry/one_ds_telemetry.cc) +if(ANDROID) + list(APPEND FOUNDRY_LOCAL_SOURCES src/telemetry/android_telemetry_bridge.cc) +endif() + # Organize headers into filters matching the directory structure in Visual Studio source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" PREFIX "Source" FILES ${FOUNDRY_LOCAL_SOURCES}) source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}/include" PREFIX "Public Headers" FILES ${FOUNDRY_LOCAL_PUBLIC_HEADERS}) @@ -250,12 +299,18 @@ function(foundry_local_configure_target TARGET LINK_SCOPE) endif() if(WIN32) - target_link_libraries(${TARGET} ${LINK_SCOPE} dbghelp bcrypt) + target_link_libraries(${TARGET} ${LINK_SCOPE} dbghelp bcrypt version) # UWP builds have CMAKE_SYSTEM_NAME = "WindowsStore"; desktop = "Windows". # WinHTTP is not available in UWP, so only define this on desktop Windows. if(NOT CMAKE_SYSTEM_NAME STREQUAL "WindowsStore") target_compile_definitions(${TARGET} PRIVATE FOUNDRY_LOCAL_USE_WINHTTP_TRANSPORT=1) endif() + else() + target_link_libraries(${TARGET} ${LINK_SCOPE} OpenSSL::Crypto) + endif() + + if(APPLE) + target_link_libraries(${TARGET} ${LINK_SCOPE} "-framework CoreFoundation") endif() if(ANDROID) @@ -273,6 +328,9 @@ function(foundry_local_configure_target TARGET LINK_SCOPE) else() target_compile_definitions(${TARGET} ${LINK_SCOPE} FOUNDRY_LOCAL_HAS_EP_CATALOG=0) endif() + + target_link_libraries(${TARGET} ${LINK_SCOPE} MSTelemetry::mat) + endfunction() # -------------------------------------------------------------------------- @@ -291,6 +349,13 @@ configure_file( @ONLY ) +# Generate the 1DS tenant-token header. +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/src/telemetry/one_ds_tenant_token.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/generated/one_ds_tenant_token.h" + @ONLY +) + # -------------------------------------------------------------------------- # Object library — compiles all sources once. Both the shared (DLL) and # static library targets re-use these object files, avoiding a double build. diff --git a/sdk_v2/cpp/build.py b/sdk_v2/cpp/build.py index b1274cd60..b3094ff32 100644 --- a/sdk_v2/cpp/build.py +++ b/sdk_v2/cpp/build.py @@ -165,7 +165,6 @@ class HelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescript help="Override the Microsoft.Windows.AI.MachineLearning NuGet version for the WinML EP " "catalog (Windows only). Defaults to the version pinned in deps_versions.json.", ) - # Cross-compilation (mutually exclusive targets) cross_group = parser.add_mutually_exclusive_group() cross_group.add_argument( @@ -439,6 +438,10 @@ def configure(args: argparse.Namespace) -> None: if triplets_dir.is_dir(): command += [f"-DVCPKG_OVERLAY_TRIPLETS={triplets_dir}"] + ports_dir = SCRIPT_DIR / "ports" + if ports_dir.is_dir(): + command += [f"-DVCPKG_OVERLAY_PORTS={ports_dir}"] + # Project options build_tests = "ON" @@ -451,9 +454,13 @@ def configure(args: argparse.Namespace) -> None: f"-DFOUNDRY_LOCAL_BUILD_SERVICE={build_service}", ] - # Enable vcpkg manifest features for tests + # Enable vcpkg manifest features as needed. Multiple features are passed as a + # semicolon-separated list in a single -D flag. + manifest_features = [] if build_tests == "ON": - command += ["-DVCPKG_MANIFEST_FEATURES=tests"] + manifest_features.append("tests") + if manifest_features: + command += [f"-DVCPKG_MANIFEST_FEATURES={';'.join(manifest_features)}"] # WinML EP catalog is enabled automatically on Windows by CMake. Allow an # optional version override for the Microsoft.Windows.AI.MachineLearning NuGet. diff --git a/sdk_v2/cpp/docs/Privacy.md b/sdk_v2/cpp/docs/Privacy.md new file mode 100644 index 000000000..e774d745c --- /dev/null +++ b/sdk_v2/cpp/docs/Privacy.md @@ -0,0 +1,24 @@ +# Privacy + +## Data Collection + +The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may disable non-essential telemetry as described below. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft's privacy statement. Our privacy statement is located at https://go.microsoft.com/fwlink/?LinkID=824704. You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. + +*** + +Foundry Local collects a small number of trace events with the goal of improving product quality. Official packages on supported platforms include the cross-platform 1DS telemetry SDK. Collection is subject to user consent and handled following Microsoft's privacy practices. + +Telemetry is turned **ON** by default. + +#### Technical Details + +Foundry Local uses the cross-platform 1DS SDK (cpp_client_telemetry) to send trace events to Microsoft's telemetry backend over HTTPS. Based on user consent, this data is handled following GDPR and privacy regulations for anonymity and data access controls. + +Non-essential telemetry can be disabled as follows. Foundry Local may still send a minimal ProcessInfo event. + +- **Disable via manager config.** Set the disable-nonessential-telemetry option before creating the manager: + - C++: `Configuration::SetDisableNonessentialTelemetry(true)` + - C#: `Configuration.DisableNonessentialTelemetry = true` + - JavaScript/TypeScript: `disableNonessentialTelemetry: true` + - Python: `disable_nonessential_telemetry=True` + - Native additional option: `DisableNonessentialTelemetry=true` diff --git a/sdk_v2/cpp/docs/telemetry/grafana/README.md b/sdk_v2/cpp/docs/telemetry/grafana/README.md new file mode 100644 index 000000000..6be4f115d --- /dev/null +++ b/sdk_v2/cpp/docs/telemetry/grafana/README.md @@ -0,0 +1,53 @@ +# Foundry Local Grafana dashboard + +`foundry-local-telemetry-dashboard.json` is an importable Grafana dashboard for Foundry Local SDK v2 client +telemetry in Aria. + +## Import + +1. Install the Grafana Azure Data Explorer datasource plugin + (`grafana-azure-data-explorer-datasource`) version `7.2.8`. The dashboard targets Grafana `11.6.11`; newer + Grafana versions must also satisfy the plugin's compatibility requirements. +2. Configure a datasource that can query: + - Cluster: `https://kusto.aria.microsoft.com` + - Database: `9d5ddaec61e24567b788a20aea324631` +3. In Grafana, select **Dashboards > New > Import**, upload + `foundry-local-telemetry-dashboard.json`, and map **Aria Azure Data Explorer** to that datasource. + +The dashboard defaults to UTC, the last seven days, and a five-minute refresh. Aria retention is approximately +12–14 days. Every query requires a populated `FoundryLocalVersion`, excluding legacy/unversioned telemetry from this +SDK v2 dashboard. + +## Filters + +All dashboard filters are time-aware and multi-select: + +- App +- OS +- Foundry Local version +- User agent +- Action +- Status +- Model +- Execution provider + +The filters use the ADX plugin's `$__contains` macro. Their **All** value must remain exactly `all`. + +## Privacy + +The dashboard does not project device identifiers, client IP addresses, or user identity fields. Device IDs are used +only inside an aggregate `count_distinct` calculation. Recent-error panels show the telemetry error text already +subject to Foundry Local's upload redaction rules. + +## Regenerate + +The JSON is generated from `generate-dashboard.py`: + +```powershell +python sdk_v2\cpp\docs\telemetry\grafana\generate-dashboard.py +``` + +The queries were authored against the live Aria schemas for: + +`action`, `audiomodel`, `catalogfetch`, `download`, `epdownloadandregister`, `epdownloadattempt`, `error`, +`hardwareinfo`, `model`, `modelid`, `processinfo`, and `session`. diff --git a/sdk_v2/cpp/docs/telemetry/grafana/foundry-local-telemetry-dashboard.json b/sdk_v2/cpp/docs/telemetry/grafana/foundry-local-telemetry-dashboard.json new file mode 100644 index 000000000..3d6e760df --- /dev/null +++ b/sdk_v2/cpp/docs/telemetry/grafana/foundry-local-telemetry-dashboard.json @@ -0,0 +1,2825 @@ +{ + "__inputs": [ + { + "description": "Azure Data Explorer datasource configured for the Foundry Local Aria cluster.", + "label": "Aria Azure Data Explorer", + "name": "DS_ARIA_ADX", + "pluginId": "grafana-azure-data-explorer-datasource", + "pluginName": "Azure Data Explorer", + "type": "datasource" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "11.6.11" + }, + { + "type": "datasource", + "id": "grafana-azure-data-explorer-datasource", + "name": "Azure Data Explorer", + "version": "7.2.8" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + }, + { + "type": "panel", + "id": "barchart", + "name": "Bar chart", + "version": "" + }, + { + "type": "panel", + "id": "text", + "name": "Text", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "panels": [], + "title": "Overview", + "type": "row" + }, + { + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 1 + }, + "id": 2, + "options": { + "content": "Foundry Local SDK v2 client telemetry from Aria. Filters are multi-select and time-aware. Device IDs are used only for aggregate distinct counts; this dashboard never displays device, client-IP, or user-identity values. Aria retention is approximately 12\u201314 days.", + "mode": "markdown" + }, + "title": "Dashboard scope", + "type": "text" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "Exact distinct process-wide application sessions represented by ProcessInfo.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 4 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "processinfo\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=coalesce(OsName, osName, os),\n FLVersion=coalesce(FoundryLocalVersion, Version, libraryVersion)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where isnotempty(AppSessionGuid)\n| summarize Value=count_distinct(AppSessionGuid)", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Process sessions", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "Aggregate exact distinct device count; device identifiers are never projected.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 4 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "processinfo\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=coalesce(OsName, osName, os),\n FLVersion=coalesce(FoundryLocalVersion, Version, libraryVersion)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where isnotempty(DeviceInfo_Id)\n| summarize Value=count_distinct(DeviceInfo_Id)", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Distinct devices", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 95 + }, + { + "color": "green", + "value": 99 + } + ] + }, + "unit": "percent", + "decimals": 1 + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 4 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLAction, $fl_action)\n| where $__contains(FLStatus, $fl_status)\n| summarize Total=count(), Healthy=countif(FLStatus in (\"Success\", \"Skipped\"))\n| where Total > 0\n| project Value=100.0 * todouble(Healthy) / todouble(Total)", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Action success rate", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 4 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "error\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=OsName,\n FLVersion=coalesce(FoundryLocalVersion, AppVersion),\n FLUserAgent=UserAgent,\n FLAction=coalesce(Action, action)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLAction, $fl_action)\n| summarize Value=count()", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Errors", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 4 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "model\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=OsName,\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLEP=coalesce(ExecutionProvider, executionProvider)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLModel, $fl_model)\n| where $__contains(FLEP, $fl_ep)\n| summarize Value=count()", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Inference calls", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "Bytes transferred by successful downloads, excluding bytes already present in cache.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 4 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "download\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=coalesce(OsName, hostOS, os_type),\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLStatus=coalesce(Status, status)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLModel, $fl_model)\n| where $__contains(FLStatus, $fl_status)\n| where FLStatus == \"Success\"\n| extend TransferredBytes=iff(TotalSizeBytes > AlreadyCachedBytes,\n TotalSizeBytes - AlreadyCachedBytes,\n long(0))\n| summarize Value=sum(TransferredBytes)", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Transferred bytes", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 9, + "panels": [], + "title": "Adoption and environment", + "type": "row" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "processinfo\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=coalesce(OsName, osName, os),\n FLVersion=coalesce(FoundryLocalVersion, Version, libraryVersion)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where isnotempty(AppSessionGuid)\n| summarize Value=count_distinct(AppSessionGuid) by Time=bin(EventInfo_Time, $__interval), Series=FLOS\n| order by Time asc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Process sessions by OS", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 9 + }, + "id": 11, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "processinfo\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=coalesce(OsName, osName, os),\n FLVersion=coalesce(FoundryLocalVersion, Version, libraryVersion)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| summarize Sessions=count_distinct(AppSessionGuid) by App=FLApp, Version=FLVersion, OS=FLOS\n| top 50 by Sessions desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "App / SDK version adoption", + "type": "table" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 17 + }, + "id": 12, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLAction, $fl_action)\n| where $__contains(FLStatus, $fl_status)\n| summarize Events=count(), Sessions=count_distinct(AppSessionGuid)\n by App=FLApp, UserAgent=FLUserAgent\n| top 50 by Events desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "User-agent mix", + "type": "table" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 17 + }, + "id": 13, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "hardwareinfo\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| summarize Sessions=count_distinct(AppSessionGuid)\n by HasGPU, HasNPU, DeviceTypes, ExecutionProviders\n| order by Sessions desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Hardware capability mix", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 25 + }, + "id": 14, + "panels": [], + "title": "Reliability and latency", + "type": "row" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 26 + }, + "id": 15, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLAction, $fl_action)\n| where $__contains(FLStatus, $fl_status)\n| summarize Value=count() by Time=bin(EventInfo_Time, $__interval), Series=FLStatus\n| order by Time asc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Action status over time", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent", + "decimals": 1 + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 26 + }, + "id": 16, + "options": { + "barRadius": 0, + "barWidth": 0.8, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLAction, $fl_action)\n| where $__contains(FLStatus, $fl_status)\n| summarize Total=count(),\n Failures=countif(FLStatus !in (\"Success\", \"Skipped\"))\n by Action=FLAction\n| extend FailureRate=100.0 * todouble(Failures) / todouble(Total)\n| top 15 by FailureRate desc\n| project Action, FailureRate", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Failure rate by action", + "type": "barchart" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 34 + }, + "id": 17, + "options": { + "barRadius": 0, + "barWidth": 0.8, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLAction, $fl_action)\n| where $__contains(FLStatus, $fl_status)\n| where TimeMs >= 0\n| summarize P95=percentile(TimeMs, 95) by Action=FLAction\n| top 15 by P95 desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "P95 action latency", + "type": "barchart" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 34 + }, + "id": 18, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "error\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=OsName,\n FLVersion=coalesce(FoundryLocalVersion, AppVersion),\n FLUserAgent=UserAgent,\n FLAction=coalesce(Action, action)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLAction, $fl_action)\n| project Time=EventInfo_Time, Action=FLAction, ExceptionType, ExceptionMessage,\n App=FLApp, Version=FLVersion, UserAgent=FLUserAgent, CorrelationId\n| top 100 by Time desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Recent errors", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 42 + }, + "id": 19, + "panels": [], + "title": "Inference and model usage", + "type": "row" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 43 + }, + "id": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "model\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=OsName,\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLEP=coalesce(ExecutionProvider, executionProvider)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLModel, $fl_model)\n| where $__contains(FLEP, $fl_ep)\n| summarize Value=count() by Time=bin(EventInfo_Time, $__interval), Series=FLEP\n| order by Time asc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Inference volume by execution provider", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 43 + }, + "id": 21, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "model\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=OsName,\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLEP=coalesce(ExecutionProvider, executionProvider)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLModel, $fl_model)\n| where $__contains(FLEP, $fl_ep)\n| summarize Calls=count(), P50TotalMs=percentile(TotalTimeMs, 50),\n P95TotalMs=percentile(TotalTimeMs, 95), Tokens=sum(TotalTokens)\n by Model=FLModel, EP=FLEP, Stream\n| top 100 by Calls desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Model performance", + "type": "table" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 51 + }, + "id": 22, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "model\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=OsName,\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLEP=coalesce(ExecutionProvider, executionProvider)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLModel, $fl_model)\n| where $__contains(FLEP, $fl_ep)\n| where TotalTimeMs >= 0\n| summarize Value=percentile(TotalTimeMs, 95)\n by Time=bin(EventInfo_Time, $__interval), Series=FLEP\n| order by Time asc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "P95 inference latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 51 + }, + "id": 23, + "options": { + "barRadius": 0, + "barWidth": 0.8, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "model\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=OsName,\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLEP=coalesce(ExecutionProvider, executionProvider)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLModel, $fl_model)\n| where $__contains(FLEP, $fl_ep)\n| summarize Tokens=sum(TotalTokens) by Model=FLModel\n| top 15 by Tokens desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Token volume by model", + "type": "barchart" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 59 + }, + "id": 24, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "audiomodel\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion,\n FLUserAgent=UserAgent, FLModel=ModelId, FLEP=ExecutionProvider\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLModel, $fl_model)\n| where $__contains(FLEP, $fl_ep)\n| summarize Calls=count(),\n P95LatencyMs=percentile(TotalTimeMs, 95),\n TotalTokens=sum(TotalTokens),\n AvgAudioDurationMs=avgif(AudioDurationMs, AudioDurationMs >= 0)\n by Model=FLModel, EP=FLEP, AudioSource, Language, Stream\n| top 100 by Calls desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Audio usage", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 66 + }, + "id": 25, + "panels": [], + "title": "Downloads, catalog, and execution providers", + "type": "row" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 67 + }, + "id": 26, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "download\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=coalesce(OsName, hostOS, os_type),\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLStatus=coalesce(Status, status)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLModel, $fl_model)\n| where $__contains(FLStatus, $fl_status)\n| summarize Value=count() by Time=bin(EventInfo_Time, $__interval), Series=FLStatus\n| order by Time asc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Download outcomes", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 67 + }, + "id": 27, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "download\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=coalesce(OsName, hostOS, os_type),\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLStatus=coalesce(Status, status)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLModel, $fl_model)\n| where $__contains(FLStatus, $fl_status)\n| summarize Attempts=count(),\n P50DownloadMs=percentile(DownloadTimeMs, 50),\n P95DownloadMs=percentile(DownloadTimeMs, 95),\n P95LockWaitMs=percentile(LockWaitTimeMs, 95),\n TransferredBytes=sum(iff(FLStatus == \"Success\" and TotalSizeBytes > AlreadyCachedBytes,\n TotalSizeBytes - AlreadyCachedBytes,\n long(0))),\n CachedBytes=sum(AlreadyCachedBytes)\n by Model=FLModel, Status=FLStatus, WaitResult=DownloadWaitResult\n| top 100 by Attempts desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Download performance", + "type": "table" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 75 + }, + "id": 28, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "catalogfetch\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion,\n FLUserAgent=UserAgent, FLStatus=Status\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLStatus, $fl_status)\n| summarize Calls=count(),\n Failures=countif(FLStatus !in (\"Success\", \"Skipped\")),\n P95LatencyMs=percentile(TimeMs, 95),\n AvgModels=avg(ModelCount)\n by Operation, Endpoint, Region, Status=FLStatus\n| top 100 by Calls desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Catalog fetch health", + "type": "table" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 75 + }, + "id": 29, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "catalogfetch\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion,\n FLUserAgent=UserAgent, FLStatus=Status\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLStatus, $fl_status)\n| summarize Value=percentile(TimeMs, 95)\n by Time=bin(EventInfo_Time, $__interval), Series=Operation\n| order by Time asc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "Catalog P95 latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 83 + }, + "id": 30, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "epdownloadattempt\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion,\n FLUserAgent=UserAgent, FLStatus=Status\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLStatus, $fl_status)\n| summarize Value=count() by Time=bin(EventInfo_Time, $__interval), Series=FLStatus\n| order by Time asc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "time_series" + } + ], + "title": "EP attempt outcomes", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 83 + }, + "id": 31, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "epdownloadandregister\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion, FLUserAgent=UserAgent\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| summarize Attempts=count(),\n DownloadFailures=countif(DownloadStatus !in (\"Success\", \"Skipped\")),\n RegisterFailures=countif(RegisterStatus !in (\"Success\", \"Skipped\")),\n P95DownloadMs=percentile(DownloadTimeMs, 95),\n P95RegisterMs=percentile(RegisterTimeMs, 95)\n by ProviderName, InitReadyState, DownloadReadyState, RegisterReadyState\n| top 100 by Attempts desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "EP provider phase performance", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 91 + }, + "id": 32, + "panels": [], + "title": "Failure drilldown", + "type": "row" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 92 + }, + "id": 33, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLAction, $fl_action)\n| where $__contains(FLStatus, $fl_status)\n| where FLStatus !in (\"Success\", \"Skipped\")\n| project Time=EventInfo_Time, Action=FLAction, Status=FLStatus, TimeMs,\n App=FLApp, Version=FLVersion, OS=FLOS, UserAgent=FLUserAgent, CorrelationId\n| top 100 by Time desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Recent failed actions", + "type": "table" + }, + { + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 92 + }, + "id": 34, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "targets": [ + { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "catalogfetch\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion,\n FLUserAgent=UserAgent, FLStatus=Status\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where $__contains(FLStatus, $fl_status)\n| where FLStatus !in (\"Success\", \"Skipped\")\n| project Time=EventInfo_Time, Operation, Endpoint, Region, Status=FLStatus,\n TimeMs, ErrorMessage, CorrelationId\n| top 100 by Time desc", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "A", + "resultFormat": "table" + } + ], + "title": "Recent catalog failures", + "type": "table" + } + ], + "refresh": "5m", + "schemaVersion": 39, + "tags": [ + "foundry-local", + "sdk-v2", + "telemetry", + "aria", + "adx" + ], + "templating": { + "list": [ + { + "allValue": "all", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "all" + ] + }, + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "definition": "processinfo\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend Value=coalesce(AppName, appName)\n| where isnotempty(Value)\n| distinct Value\n| order by Value asc\n| project __text=Value, __value=Value", + "description": "", + "hide": 0, + "includeAll": true, + "label": "App", + "multi": true, + "name": "fl_app", + "options": [], + "query": { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "processinfo\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend Value=coalesce(AppName, appName)\n| where isnotempty(Value)\n| distinct Value\n| order by Value asc\n| project __text=Value, __value=Value", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "StandardVariableQuery", + "resultFormat": "table" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "allValue": "all", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "all" + ] + }, + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "definition": "processinfo\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName), Value=coalesce(OsName, osName, os)\n| where $__contains(FLApp, $fl_app)\n| where isnotempty(Value)\n| distinct Value\n| order by Value asc\n| project __text=Value, __value=Value", + "description": "", + "hide": 0, + "includeAll": true, + "label": "OS", + "multi": true, + "name": "fl_os", + "options": [], + "query": { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "processinfo\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName), Value=coalesce(OsName, osName, os)\n| where $__contains(FLApp, $fl_app)\n| where isnotempty(Value)\n| distinct Value\n| order by Value asc\n| project __text=Value, __value=Value", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "StandardVariableQuery", + "resultFormat": "table" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "allValue": "all", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "all" + ] + }, + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "definition": "processinfo\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName), FLOS=coalesce(OsName, osName, os),\n Value=FoundryLocalVersion\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where isnotempty(Value)\n| distinct Value\n| order by Value desc\n| project __text=Value, __value=Value", + "description": "", + "hide": 0, + "includeAll": true, + "label": "Foundry Local version", + "multi": true, + "name": "fl_version", + "options": [], + "query": { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "processinfo\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName), FLOS=coalesce(OsName, osName, os),\n Value=FoundryLocalVersion\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where isnotempty(Value)\n| distinct Value\n| order by Value desc\n| project __text=Value, __value=Value", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "StandardVariableQuery", + "resultFormat": "table" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "allValue": "all", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "all" + ] + }, + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "definition": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where isnotempty(FLUserAgent)\n| distinct FLUserAgent\n| order by FLUserAgent asc\n| project __text=FLUserAgent, __value=FLUserAgent", + "description": "", + "hide": 0, + "includeAll": true, + "label": "User agent", + "multi": true, + "name": "fl_user_agent", + "options": [], + "query": { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where isnotempty(FLUserAgent)\n| distinct FLUserAgent\n| order by FLUserAgent asc\n| project __text=FLUserAgent, __value=FLUserAgent", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "StandardVariableQuery", + "resultFormat": "table" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "allValue": "all", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "all" + ] + }, + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "definition": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where isnotempty(FLAction)\n| distinct FLAction\n| order by FLAction asc\n| project __text=FLAction, __value=FLAction", + "description": "", + "hide": 0, + "includeAll": true, + "label": "Action", + "multi": true, + "name": "fl_action", + "options": [], + "query": { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where isnotempty(FLAction)\n| distinct FLAction\n| order by FLAction asc\n| project __text=FLAction, __value=FLAction", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "StandardVariableQuery", + "resultFormat": "table" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "allValue": "all", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "all" + ] + }, + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "definition": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where isnotempty(FLStatus)\n| distinct FLStatus\n| order by FLStatus asc\n| project __text=FLStatus, __value=FLStatus", + "description": "", + "hide": 0, + "includeAll": true, + "label": "Status", + "multi": true, + "name": "fl_status", + "options": [], + "query": { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "action\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLAction=coalesce(tostring(column_ifexists(\"Action\", \"\")),\n tostring(column_ifexists(\"action\", \"\"))),\n FLStatus=coalesce(tostring(column_ifexists(\"Status\", \"\")),\n tostring(column_ifexists(\"status\", \"\")))\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where isnotempty(FLStatus)\n| distinct FLStatus\n| order by FLStatus asc\n| project __text=FLStatus, __value=FLStatus", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "StandardVariableQuery", + "resultFormat": "table" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "allValue": "all", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "all" + ] + }, + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "definition": "let InferenceModels = model\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=OsName,\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLEP=coalesce(ExecutionProvider, executionProvider)\n| project FLApp, FLOS, FLVersion, FLUserAgent, FLModel;\nlet DownloadModels = download\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=coalesce(OsName, hostOS, os_type),\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLStatus=coalesce(Status, status)\n| project FLApp, FLOS, FLVersion, FLUserAgent, FLModel;\nlet ActionModels = modelid\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLModel=coalesce(tostring(column_ifexists(\"ModelId\", \"\")),\n tostring(column_ifexists(\"modelId\", \"\")))\n| project FLApp, FLOS, FLVersion, FLUserAgent, FLModel;\nunion InferenceModels, DownloadModels, ActionModels\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where isnotempty(FLModel)\n| distinct FLModel\n| order by FLModel asc\n| project __text=FLModel, __value=FLModel", + "description": "", + "hide": 0, + "includeAll": true, + "label": "Model", + "multi": true, + "name": "fl_model", + "options": [], + "query": { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "let InferenceModels = model\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=OsName,\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLEP=coalesce(ExecutionProvider, executionProvider)\n| project FLApp, FLOS, FLVersion, FLUserAgent, FLModel;\nlet DownloadModels = download\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=coalesce(OsName, hostOS, os_type),\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLStatus=coalesce(Status, status)\n| project FLApp, FLOS, FLVersion, FLUserAgent, FLModel;\nlet ActionModels = modelid\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(tostring(column_ifexists(\"AppName\", \"\")),\n tostring(column_ifexists(\"appName\", \"\"))),\n FLOS=coalesce(tostring(column_ifexists(\"OsName\", \"\")),\n tostring(column_ifexists(\"hostOS\", \"\")),\n tostring(column_ifexists(\"os_type\", \"\"))),\n FLVersion=coalesce(tostring(column_ifexists(\"FoundryLocalVersion\", \"\")),\n tostring(column_ifexists(\"Version\", \"\")),\n tostring(column_ifexists(\"AppVersion\", \"\"))),\n FLUserAgent=coalesce(tostring(column_ifexists(\"UserAgent\", \"\")),\n tostring(column_ifexists(\"userAgent\", \"\"))),\n FLModel=coalesce(tostring(column_ifexists(\"ModelId\", \"\")),\n tostring(column_ifexists(\"modelId\", \"\")))\n| project FLApp, FLOS, FLVersion, FLUserAgent, FLModel;\nunion InferenceModels, DownloadModels, ActionModels\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where isnotempty(FLModel)\n| distinct FLModel\n| order by FLModel asc\n| project __text=FLModel, __value=FLModel", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "StandardVariableQuery", + "resultFormat": "table" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "allValue": "all", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "all" + ] + }, + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "definition": "model\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=OsName,\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLEP=coalesce(ExecutionProvider, executionProvider)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where isnotempty(FLEP)\n| distinct FLEP\n| order by FLEP asc\n| project __text=FLEP, __value=FLEP", + "description": "", + "hide": 0, + "includeAll": true, + "label": "Execution provider", + "multi": true, + "name": "fl_ep", + "options": [], + "query": { + "clusterUri": "https://kusto.aria.microsoft.com", + "database": "9d5ddaec61e24567b788a20aea324631", + "datasource": { + "type": "grafana-azure-data-explorer-datasource", + "uid": "${DS_ARIA_ADX}" + }, + "expression": { + "where": { + "type": "and", + "expressions": [] + }, + "groupBy": { + "type": "and", + "expressions": [] + }, + "reduce": { + "type": "and", + "expressions": [] + } + }, + "pluginVersion": "7.2.8", + "query": "model\n| where $__timeFilter(EventInfo_Time)\n| where isnotempty(FoundryLocalVersion)\n| extend FLApp=coalesce(AppName, appName),\n FLOS=OsName,\n FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion),\n FLUserAgent=coalesce(UserAgent, userAgent),\n FLModel=coalesce(ModelId, modelId),\n FLEP=coalesce(ExecutionProvider, executionProvider)\n| where $__contains(FLApp, $fl_app)\n| where $__contains(FLOS, $fl_os)\n| where $__contains(FLVersion, $fl_version)\n| where $__contains(FLUserAgent, $fl_user_agent)\n| where isnotempty(FLEP)\n| distinct FLEP\n| order by FLEP asc\n| project __text=FLEP, __value=FLEP", + "querySource": "raw", + "queryType": "KQL", + "rawMode": true, + "refId": "StandardVariableQuery", + "resultFormat": "table" + }, + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-7d", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "Foundry Local SDK v2 Telemetry", + "uid": "foundry-local-sdk-v2-telemetry", + "version": 1, + "weekStart": "" +} diff --git a/sdk_v2/cpp/docs/telemetry/grafana/generate-dashboard.py b/sdk_v2/cpp/docs/telemetry/grafana/generate-dashboard.py new file mode 100644 index 000000000..635bddda7 --- /dev/null +++ b/sdk_v2/cpp/docs/telemetry/grafana/generate-dashboard.py @@ -0,0 +1,1106 @@ +#!/usr/bin/env python3 +"""Generate the importable Foundry Local Aria telemetry Grafana dashboard.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +PLUGIN_ID = "grafana-azure-data-explorer-datasource" +PLUGIN_VERSION = "7.2.8" +DATASOURCE_UID = "${DS_ARIA_ADX}" +CLUSTER_URI = "https://kusto.aria.microsoft.com" +DATABASE = "9d5ddaec61e24567b788a20aea324631" +OUTPUT = Path(__file__).with_name("foundry-local-telemetry-dashboard.json") + +EMPTY_EXPRESSION = { + "where": {"type": "and", "expressions": []}, + "groupBy": {"type": "and", "expressions": []}, + "reduce": {"type": "and", "expressions": []}, +} + +PROCESS_DIMENSIONS = """ +| where isnotempty(FoundryLocalVersion) +| extend FLApp=coalesce(AppName, appName), + FLOS=coalesce(OsName, osName, os), + FLVersion=coalesce(FoundryLocalVersion, Version, libraryVersion) +""".strip() + +ACTION_DIMENSIONS = """ +| where isnotempty(FoundryLocalVersion) +| extend FLApp=coalesce(tostring(column_ifexists("AppName", "")), + tostring(column_ifexists("appName", ""))), + FLOS=coalesce(tostring(column_ifexists("OsName", "")), + tostring(column_ifexists("hostOS", "")), + tostring(column_ifexists("os_type", ""))), + FLVersion=coalesce(tostring(column_ifexists("FoundryLocalVersion", "")), + tostring(column_ifexists("Version", "")), + tostring(column_ifexists("AppVersion", ""))), + FLUserAgent=coalesce(tostring(column_ifexists("UserAgent", "")), + tostring(column_ifexists("userAgent", ""))), + FLAction=coalesce(tostring(column_ifexists("Action", "")), + tostring(column_ifexists("action", ""))), + FLStatus=coalesce(tostring(column_ifexists("Status", "")), + tostring(column_ifexists("status", ""))) +""".strip() + +MODEL_DIMENSIONS = """ +| where isnotempty(FoundryLocalVersion) +| extend FLApp=coalesce(AppName, appName), + FLOS=OsName, + FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion), + FLUserAgent=coalesce(UserAgent, userAgent), + FLModel=coalesce(ModelId, modelId), + FLEP=coalesce(ExecutionProvider, executionProvider) +""".strip() + +DOWNLOAD_DIMENSIONS = """ +| where isnotempty(FoundryLocalVersion) +| extend FLApp=coalesce(AppName, appName), + FLOS=coalesce(OsName, hostOS, os_type), + FLVersion=coalesce(FoundryLocalVersion, Version, AppVersion), + FLUserAgent=coalesce(UserAgent, userAgent), + FLModel=coalesce(ModelId, modelId), + FLStatus=coalesce(Status, status) +""".strip() + +ACTION_FILTERS = """ +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLAction, $fl_action) +| where $__contains(FLStatus, $fl_status) +""".strip() + +MODEL_FILTERS = """ +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLModel, $fl_model) +| where $__contains(FLEP, $fl_ep) +""".strip() + +PROCESS_FILTERS = """ +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +""".strip() + + +def datasource() -> dict[str, str]: + return {"type": PLUGIN_ID, "uid": DATASOURCE_UID} + + +def target(query: str, result_format: str = "table", ref_id: str = "A") -> dict[str, object]: + return { + "clusterUri": CLUSTER_URI, + "database": DATABASE, + "datasource": datasource(), + "expression": EMPTY_EXPRESSION, + "pluginVersion": PLUGIN_VERSION, + "query": query.strip(), + "querySource": "raw", + "queryType": "KQL", + "rawMode": True, + "refId": ref_id, + "resultFormat": result_format, + } + + +def thresholds(red_at: float | None = None, green_at: float | None = None) -> dict[str, object]: + steps: list[dict[str, object]] = [{"color": "green", "value": None}] + if red_at is not None: + steps.append({"color": "red", "value": red_at}) + if green_at is not None: + steps.append({"color": "green", "value": green_at}) + return {"mode": "absolute", "steps": steps} + + +def panel( + panel_id: int, + title: str, + panel_type: str, + x: int, + y: int, + w: int, + h: int, + query: str, + *, + result_format: str = "table", + description: str = "", + unit: str = "short", + decimals: int | None = None, + panel_options: dict[str, object] | None = None, + panel_thresholds: dict[str, object] | None = None, +) -> dict[str, object]: + defaults: dict[str, object] = { + "color": {"mode": "thresholds" if panel_type == "stat" else "palette-classic"}, + "mappings": [], + "thresholds": panel_thresholds or thresholds(), + "unit": unit, + } + if decimals is not None: + defaults["decimals"] = decimals + + options: dict[str, object] + if panel_type == "stat": + options = { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False}, + "textMode": "auto", + "wideLayout": True, + } + elif panel_type == "timeseries": + options = { + "legend": {"calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": True}, + "tooltip": {"mode": "multi", "sort": "desc"}, + } + elif panel_type == "barchart": + options = { + "barRadius": 0, + "barWidth": 0.8, + "fullHighlight": False, + "groupWidth": 0.7, + "legend": {"calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": True}, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": {"mode": "single", "sort": "none"}, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0, + } + else: + options = { + "cellHeight": "sm", + "footer": {"countRows": False, "fields": "", "reducer": ["sum"], "show": False}, + "showHeader": True, + } + if panel_options: + options.update(panel_options) + + return { + "datasource": datasource(), + "description": description, + "fieldConfig": {"defaults": defaults, "overrides": []}, + "gridPos": {"h": h, "w": w, "x": x, "y": y}, + "id": panel_id, + "options": options, + "targets": [target(query, result_format)], + "title": title, + "type": panel_type, + } + + +def row(panel_id: int, title: str, y: int) -> dict[str, object]: + return { + "collapsed": False, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": y}, + "id": panel_id, + "panels": [], + "title": title, + "type": "row", + } + + +def variable(name: str, label: str, query: str, *, description: str = "") -> dict[str, object]: + query_target = target(query, "table", "StandardVariableQuery") + return { + "allValue": "all", + "current": {"selected": True, "text": ["All"], "value": ["all"]}, + "datasource": datasource(), + "definition": query.strip(), + "description": description, + "hide": 0, + "includeAll": True, + "label": label, + "multi": True, + "name": name, + "options": [], + "query": query_target, + "refresh": 2, + "regex": "", + "skipUrlSync": False, + "sort": 1, + "type": "query", + } + + +def build_panels() -> list[dict[str, object]]: + panels: list[dict[str, object]] = [] + panels.append(row(1, "Overview", 0)) + panels.append( + { + "gridPos": {"h": 3, "w": 24, "x": 0, "y": 1}, + "id": 2, + "options": { + "content": ( + "Foundry Local SDK v2 client telemetry from Aria. Filters are multi-select and time-aware. " + "Device IDs are used only for aggregate distinct counts; this dashboard never displays device, " + "client-IP, or user-identity values. Aria retention is approximately 12–14 days." + ), + "mode": "markdown", + }, + "title": "Dashboard scope", + "type": "text", + } + ) + panels.extend( + [ + panel( + 3, + "Process sessions", + "stat", + 0, + 4, + 4, + 4, + f""" +processinfo +| where $__timeFilter(EventInfo_Time) +{PROCESS_DIMENSIONS} +{PROCESS_FILTERS} +| where isnotempty(AppSessionGuid) +| summarize Value=count_distinct(AppSessionGuid) +""", + description="Exact distinct process-wide application sessions represented by ProcessInfo.", + ), + panel( + 4, + "Distinct devices", + "stat", + 4, + 4, + 4, + 4, + f""" +processinfo +| where $__timeFilter(EventInfo_Time) +{PROCESS_DIMENSIONS} +{PROCESS_FILTERS} +| where isnotempty(DeviceInfo_Id) +| summarize Value=count_distinct(DeviceInfo_Id) +""", + description="Aggregate exact distinct device count; device identifiers are never projected.", + ), + panel( + 5, + "Action success rate", + "stat", + 8, + 4, + 4, + 4, + f""" +action +| where $__timeFilter(EventInfo_Time) +{ACTION_DIMENSIONS} +{ACTION_FILTERS} +| summarize Total=count(), Healthy=countif(FLStatus in ("Success", "Skipped")) +| where Total > 0 +| project Value=100.0 * todouble(Healthy) / todouble(Total) +""", + unit="percent", + decimals=1, + panel_thresholds={ + "mode": "absolute", + "steps": [ + {"color": "red", "value": None}, + {"color": "yellow", "value": 95}, + {"color": "green", "value": 99}, + ], + }, + ), + panel( + 6, + "Errors", + "stat", + 12, + 4, + 4, + 4, + """ +error +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=coalesce(AppName, appName), + FLOS=OsName, + FLVersion=coalesce(FoundryLocalVersion, AppVersion), + FLUserAgent=UserAgent, + FLAction=coalesce(Action, action) +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLAction, $fl_action) +| summarize Value=count() +""", + panel_thresholds=thresholds(red_at=1), + ), + panel( + 7, + "Inference calls", + "stat", + 16, + 4, + 4, + 4, + f""" +model +| where $__timeFilter(EventInfo_Time) +{MODEL_DIMENSIONS} +{MODEL_FILTERS} +| summarize Value=count() +""", + ), + panel( + 8, + "Transferred bytes", + "stat", + 20, + 4, + 4, + 4, + f""" +download +| where $__timeFilter(EventInfo_Time) +{DOWNLOAD_DIMENSIONS} +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLModel, $fl_model) +| where $__contains(FLStatus, $fl_status) +| where FLStatus == "Success" +| extend TransferredBytes=iff(TotalSizeBytes > AlreadyCachedBytes, + TotalSizeBytes - AlreadyCachedBytes, + long(0)) +| summarize Value=sum(TransferredBytes) +""", + description="Bytes transferred by successful downloads, excluding bytes already present in cache.", + unit="bytes", + ), + ] + ) + + panels.append(row(9, "Adoption and environment", 8)) + panels.extend( + [ + panel( + 10, + "Process sessions by OS", + "timeseries", + 0, + 9, + 12, + 8, + f""" +processinfo +| where $__timeFilter(EventInfo_Time) +{PROCESS_DIMENSIONS} +{PROCESS_FILTERS} +| where isnotempty(AppSessionGuid) +| summarize Value=count_distinct(AppSessionGuid) by Time=bin(EventInfo_Time, $__interval), Series=FLOS +| order by Time asc +""", + result_format="time_series", + ), + panel( + 11, + "App / SDK version adoption", + "table", + 12, + 9, + 12, + 8, + f""" +processinfo +| where $__timeFilter(EventInfo_Time) +{PROCESS_DIMENSIONS} +{PROCESS_FILTERS} +| summarize Sessions=count_distinct(AppSessionGuid) by App=FLApp, Version=FLVersion, OS=FLOS +| top 50 by Sessions desc +""", + ), + panel( + 12, + "User-agent mix", + "table", + 0, + 17, + 12, + 8, + f""" +action +| where $__timeFilter(EventInfo_Time) +{ACTION_DIMENSIONS} +{ACTION_FILTERS} +| summarize Events=count(), Sessions=count_distinct(AppSessionGuid) + by App=FLApp, UserAgent=FLUserAgent +| top 50 by Events desc +""", + ), + panel( + 13, + "Hardware capability mix", + "table", + 12, + 17, + 12, + 8, + """ +hardwareinfo +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| summarize Sessions=count_distinct(AppSessionGuid) + by HasGPU, HasNPU, DeviceTypes, ExecutionProviders +| order by Sessions desc +""", + ), + ] + ) + + panels.append(row(14, "Reliability and latency", 25)) + panels.extend( + [ + panel( + 15, + "Action status over time", + "timeseries", + 0, + 26, + 12, + 8, + f""" +action +| where $__timeFilter(EventInfo_Time) +{ACTION_DIMENSIONS} +{ACTION_FILTERS} +| summarize Value=count() by Time=bin(EventInfo_Time, $__interval), Series=FLStatus +| order by Time asc +""", + result_format="time_series", + ), + panel( + 16, + "Failure rate by action", + "barchart", + 12, + 26, + 12, + 8, + f""" +action +| where $__timeFilter(EventInfo_Time) +{ACTION_DIMENSIONS} +{ACTION_FILTERS} +| summarize Total=count(), + Failures=countif(FLStatus !in ("Success", "Skipped")) + by Action=FLAction +| extend FailureRate=100.0 * todouble(Failures) / todouble(Total) +| top 15 by FailureRate desc +| project Action, FailureRate +""", + unit="percent", + decimals=1, + ), + panel( + 17, + "P95 action latency", + "barchart", + 0, + 34, + 12, + 8, + f""" +action +| where $__timeFilter(EventInfo_Time) +{ACTION_DIMENSIONS} +{ACTION_FILTERS} +| where TimeMs >= 0 +| summarize P95=percentile(TimeMs, 95) by Action=FLAction +| top 15 by P95 desc +""", + unit="ms", + ), + panel( + 18, + "Recent errors", + "table", + 12, + 34, + 12, + 8, + """ +error +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=coalesce(AppName, appName), + FLOS=OsName, + FLVersion=coalesce(FoundryLocalVersion, AppVersion), + FLUserAgent=UserAgent, + FLAction=coalesce(Action, action) +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLAction, $fl_action) +| project Time=EventInfo_Time, Action=FLAction, ExceptionType, ExceptionMessage, + App=FLApp, Version=FLVersion, UserAgent=FLUserAgent, CorrelationId +| top 100 by Time desc +""", + ), + ] + ) + + panels.append(row(19, "Inference and model usage", 42)) + panels.extend( + [ + panel( + 20, + "Inference volume by execution provider", + "timeseries", + 0, + 43, + 12, + 8, + f""" +model +| where $__timeFilter(EventInfo_Time) +{MODEL_DIMENSIONS} +{MODEL_FILTERS} +| summarize Value=count() by Time=bin(EventInfo_Time, $__interval), Series=FLEP +| order by Time asc +""", + result_format="time_series", + ), + panel( + 21, + "Model performance", + "table", + 12, + 43, + 12, + 8, + f""" +model +| where $__timeFilter(EventInfo_Time) +{MODEL_DIMENSIONS} +{MODEL_FILTERS} +| summarize Calls=count(), P50TotalMs=percentile(TotalTimeMs, 50), + P95TotalMs=percentile(TotalTimeMs, 95), Tokens=sum(TotalTokens) + by Model=FLModel, EP=FLEP, Stream +| top 100 by Calls desc +""", + ), + panel( + 22, + "P95 inference latency", + "timeseries", + 0, + 51, + 12, + 8, + f""" +model +| where $__timeFilter(EventInfo_Time) +{MODEL_DIMENSIONS} +{MODEL_FILTERS} +| where TotalTimeMs >= 0 +| summarize Value=percentile(TotalTimeMs, 95) + by Time=bin(EventInfo_Time, $__interval), Series=FLEP +| order by Time asc +""", + result_format="time_series", + unit="ms", + ), + panel( + 23, + "Token volume by model", + "barchart", + 12, + 51, + 12, + 8, + f""" +model +| where $__timeFilter(EventInfo_Time) +{MODEL_DIMENSIONS} +{MODEL_FILTERS} +| summarize Tokens=sum(TotalTokens) by Model=FLModel +| top 15 by Tokens desc +""", + ), + panel( + 24, + "Audio usage", + "table", + 0, + 59, + 24, + 7, + """ +audiomodel +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion, + FLUserAgent=UserAgent, FLModel=ModelId, FLEP=ExecutionProvider +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLModel, $fl_model) +| where $__contains(FLEP, $fl_ep) +| summarize Calls=count(), + P95LatencyMs=percentile(TotalTimeMs, 95), + TotalTokens=sum(TotalTokens), + AvgAudioDurationMs=avgif(AudioDurationMs, AudioDurationMs >= 0) + by Model=FLModel, EP=FLEP, AudioSource, Language, Stream +| top 100 by Calls desc +""", + ), + ] + ) + + panels.append(row(25, "Downloads, catalog, and execution providers", 66)) + panels.extend( + [ + panel( + 26, + "Download outcomes", + "timeseries", + 0, + 67, + 12, + 8, + f""" +download +| where $__timeFilter(EventInfo_Time) +{DOWNLOAD_DIMENSIONS} +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLModel, $fl_model) +| where $__contains(FLStatus, $fl_status) +| summarize Value=count() by Time=bin(EventInfo_Time, $__interval), Series=FLStatus +| order by Time asc +""", + result_format="time_series", + ), + panel( + 27, + "Download performance", + "table", + 12, + 67, + 12, + 8, + f""" +download +| where $__timeFilter(EventInfo_Time) +{DOWNLOAD_DIMENSIONS} +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLModel, $fl_model) +| where $__contains(FLStatus, $fl_status) +| summarize Attempts=count(), + P50DownloadMs=percentile(DownloadTimeMs, 50), + P95DownloadMs=percentile(DownloadTimeMs, 95), + P95LockWaitMs=percentile(LockWaitTimeMs, 95), + TransferredBytes=sum(iff(FLStatus == "Success" and TotalSizeBytes > AlreadyCachedBytes, + TotalSizeBytes - AlreadyCachedBytes, + long(0))), + CachedBytes=sum(AlreadyCachedBytes) + by Model=FLModel, Status=FLStatus, WaitResult=DownloadWaitResult +| top 100 by Attempts desc +""", + ), + panel( + 28, + "Catalog fetch health", + "table", + 0, + 75, + 12, + 8, + """ +catalogfetch +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion, + FLUserAgent=UserAgent, FLStatus=Status +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLStatus, $fl_status) +| summarize Calls=count(), + Failures=countif(FLStatus !in ("Success", "Skipped")), + P95LatencyMs=percentile(TimeMs, 95), + AvgModels=avg(ModelCount) + by Operation, Endpoint, Region, Status=FLStatus +| top 100 by Calls desc +""", + ), + panel( + 29, + "Catalog P95 latency", + "timeseries", + 12, + 75, + 12, + 8, + """ +catalogfetch +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion, + FLUserAgent=UserAgent, FLStatus=Status +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLStatus, $fl_status) +| summarize Value=percentile(TimeMs, 95) + by Time=bin(EventInfo_Time, $__interval), Series=Operation +| order by Time asc +""", + result_format="time_series", + unit="ms", + ), + panel( + 30, + "EP attempt outcomes", + "timeseries", + 0, + 83, + 12, + 8, + """ +epdownloadattempt +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion, + FLUserAgent=UserAgent, FLStatus=Status +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLStatus, $fl_status) +| summarize Value=count() by Time=bin(EventInfo_Time, $__interval), Series=FLStatus +| order by Time asc +""", + result_format="time_series", + ), + panel( + 31, + "EP provider phase performance", + "table", + 12, + 83, + 12, + 8, + """ +epdownloadandregister +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion, FLUserAgent=UserAgent +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| summarize Attempts=count(), + DownloadFailures=countif(DownloadStatus !in ("Success", "Skipped")), + RegisterFailures=countif(RegisterStatus !in ("Success", "Skipped")), + P95DownloadMs=percentile(DownloadTimeMs, 95), + P95RegisterMs=percentile(RegisterTimeMs, 95) + by ProviderName, InitReadyState, DownloadReadyState, RegisterReadyState +| top 100 by Attempts desc +""", + ), + ] + ) + + panels.append(row(32, "Failure drilldown", 91)) + panels.extend( + [ + panel( + 33, + "Recent failed actions", + "table", + 0, + 92, + 12, + 8, + f""" +action +| where $__timeFilter(EventInfo_Time) +{ACTION_DIMENSIONS} +{ACTION_FILTERS} +| where FLStatus !in ("Success", "Skipped") +| project Time=EventInfo_Time, Action=FLAction, Status=FLStatus, TimeMs, + App=FLApp, Version=FLVersion, OS=FLOS, UserAgent=FLUserAgent, CorrelationId +| top 100 by Time desc +""", + ), + panel( + 34, + "Recent catalog failures", + "table", + 12, + 92, + 12, + 8, + """ +catalogfetch +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=AppName, FLOS=OsName, FLVersion=FoundryLocalVersion, + FLUserAgent=UserAgent, FLStatus=Status +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where $__contains(FLStatus, $fl_status) +| where FLStatus !in ("Success", "Skipped") +| project Time=EventInfo_Time, Operation, Endpoint, Region, Status=FLStatus, + TimeMs, ErrorMessage, CorrelationId +| top 100 by Time desc +""", + ), + ] + ) + return panels + + +def build_variables() -> list[dict[str, object]]: + return [ + variable( + "fl_app", + "App", + """ +processinfo +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend Value=coalesce(AppName, appName) +| where isnotempty(Value) +| distinct Value +| order by Value asc +| project __text=Value, __value=Value +""", + ), + variable( + "fl_os", + "OS", + """ +processinfo +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=coalesce(AppName, appName), Value=coalesce(OsName, osName, os) +| where $__contains(FLApp, $fl_app) +| where isnotempty(Value) +| distinct Value +| order by Value asc +| project __text=Value, __value=Value +""", + ), + variable( + "fl_version", + "Foundry Local version", + """ +processinfo +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=coalesce(AppName, appName), FLOS=coalesce(OsName, osName, os), + Value=FoundryLocalVersion +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where isnotempty(Value) +| distinct Value +| order by Value desc +| project __text=Value, __value=Value +""", + ), + variable( + "fl_user_agent", + "User agent", + f""" +action +| where $__timeFilter(EventInfo_Time) +{ACTION_DIMENSIONS} +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where isnotempty(FLUserAgent) +| distinct FLUserAgent +| order by FLUserAgent asc +| project __text=FLUserAgent, __value=FLUserAgent +""", + ), + variable( + "fl_action", + "Action", + f""" +action +| where $__timeFilter(EventInfo_Time) +{ACTION_DIMENSIONS} +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where isnotempty(FLAction) +| distinct FLAction +| order by FLAction asc +| project __text=FLAction, __value=FLAction +""", + ), + variable( + "fl_status", + "Status", + f""" +action +| where $__timeFilter(EventInfo_Time) +{ACTION_DIMENSIONS} +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where isnotempty(FLStatus) +| distinct FLStatus +| order by FLStatus asc +| project __text=FLStatus, __value=FLStatus +""", + ), + variable( + "fl_model", + "Model", + f""" +let InferenceModels = model +| where $__timeFilter(EventInfo_Time) +{MODEL_DIMENSIONS} +| project FLApp, FLOS, FLVersion, FLUserAgent, FLModel; +let DownloadModels = download +| where $__timeFilter(EventInfo_Time) +{DOWNLOAD_DIMENSIONS} +| project FLApp, FLOS, FLVersion, FLUserAgent, FLModel; +let ActionModels = modelid +| where $__timeFilter(EventInfo_Time) +| where isnotempty(FoundryLocalVersion) +| extend FLApp=coalesce(tostring(column_ifexists("AppName", "")), + tostring(column_ifexists("appName", ""))), + FLOS=coalesce(tostring(column_ifexists("OsName", "")), + tostring(column_ifexists("hostOS", "")), + tostring(column_ifexists("os_type", ""))), + FLVersion=coalesce(tostring(column_ifexists("FoundryLocalVersion", "")), + tostring(column_ifexists("Version", "")), + tostring(column_ifexists("AppVersion", ""))), + FLUserAgent=coalesce(tostring(column_ifexists("UserAgent", "")), + tostring(column_ifexists("userAgent", ""))), + FLModel=coalesce(tostring(column_ifexists("ModelId", "")), + tostring(column_ifexists("modelId", ""))) +| project FLApp, FLOS, FLVersion, FLUserAgent, FLModel; +union InferenceModels, DownloadModels, ActionModels +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where isnotempty(FLModel) +| distinct FLModel +| order by FLModel asc +| project __text=FLModel, __value=FLModel +""", + ), + variable( + "fl_ep", + "Execution provider", + f""" +model +| where $__timeFilter(EventInfo_Time) +{MODEL_DIMENSIONS} +| where $__contains(FLApp, $fl_app) +| where $__contains(FLOS, $fl_os) +| where $__contains(FLVersion, $fl_version) +| where $__contains(FLUserAgent, $fl_user_agent) +| where isnotempty(FLEP) +| distinct FLEP +| order by FLEP asc +| project __text=FLEP, __value=FLEP +""", + ), + ] + + +def build_dashboard() -> dict[str, object]: + return { + "__inputs": [ + { + "description": "Azure Data Explorer datasource configured for the Foundry Local Aria cluster.", + "label": "Aria Azure Data Explorer", + "name": "DS_ARIA_ADX", + "pluginId": PLUGIN_ID, + "pluginName": "Azure Data Explorer", + "type": "datasource", + } + ], + "__requires": [ + {"type": "grafana", "id": "grafana", "name": "Grafana", "version": "11.6.11"}, + {"type": "datasource", "id": PLUGIN_ID, "name": "Azure Data Explorer", "version": PLUGIN_VERSION}, + {"type": "panel", "id": "stat", "name": "Stat", "version": ""}, + {"type": "panel", "id": "timeseries", "name": "Time series", "version": ""}, + {"type": "panel", "id": "table", "name": "Table", "version": ""}, + {"type": "panel", "id": "barchart", "name": "Bar chart", "version": ""}, + {"type": "panel", "id": "text", "name": "Text", "version": ""}, + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": {"type": "grafana", "uid": "-- Grafana --"}, + "enable": True, + "hide": True, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard", + } + ] + }, + "editable": True, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": None, + "links": [], + "liveNow": False, + "panels": build_panels(), + "refresh": "5m", + "schemaVersion": 39, + "tags": ["foundry-local", "sdk-v2", "telemetry", "aria", "adx"], + "templating": {"list": build_variables()}, + "time": {"from": "now-7d", "to": "now"}, + "timepicker": {}, + "timezone": "utc", + "title": "Foundry Local SDK v2 Telemetry", + "uid": "foundry-local-sdk-v2-telemetry", + "version": 1, + "weekStart": "", + } + + +def main() -> None: + OUTPUT.write_text(json.dumps(build_dashboard(), indent=2) + "\n", encoding="utf-8") + print(f"Wrote {OUTPUT}") + + +if __name__ == "__main__": + main() diff --git a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h index 034348e99..be58cb886 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_c.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_c.h @@ -362,13 +362,11 @@ typedef struct flUsage { } flUsage; /// Information about a discoverable execution provider. -/// Returned by Manager_GetDiscoverableEps. Storage is owned by the Manager; the -/// returned pointers and `name` strings are stable for the Manager's lifetime. -/// `is_registered` may be updated by a concurrent Manager_DownloadAndRegisterEps; -/// readers see a recent snapshot. +/// Returned by Manager_GetDiscoverableEps as a snapshot. Storage is owned by the implementation +/// and remains valid until Manager_GetDiscoverableEps is next called on the same thread. typedef struct flEpInfo { uint32_t version; ///< Set by impl to FOUNDRY_LOCAL_API_VERSION. - const char* name; ///< UTF-8 EP name. Stable for Manager lifetime. + const char* name; ///< UTF-8 EP name. Stable for the snapshot lifetime. bool is_registered; ///< Whether the EP is currently registered with ORT. /* V2 fields go here. */ } flEpInfo; @@ -671,11 +669,9 @@ typedef struct flApi { /* EP detection */ - /// Get discoverable execution providers. Returns a pointer to an internal - /// array of versioned flEpInfo structs. The array, the structs, and the - /// `name` strings are owned by the Manager and remain valid for its lifetime. - /// `is_registered` values reflect a recent snapshot; concurrent calls to - /// Manager_DownloadAndRegisterEps may update them in place. + /// Get discoverable execution providers. Returns a pointer to a snapshot array + /// of versioned flEpInfo structs. The array, structs, and `name` strings remain + /// valid until Manager_GetDiscoverableEps is next called on the same thread. FL_API_STATUS(Manager_GetDiscoverableEps, _In_ const flManager* manager, _Outptr_result_buffer_(*out_count) const flEpInfo** out_eps, _Out_ size_t* out_count); diff --git a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h index fe6281302..30b69a529 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.h @@ -269,6 +269,10 @@ class Configuration { /// Defaults to "centralus" when not set. Configuration& SetCatalogRegion(const std::string& region); + /// Optional. Disable non-essential telemetry. Foundry Local may still send a minimal ProcessInfo event. + /// Defaults to false (telemetry enabled). + Configuration& SetDisableNonessentialTelemetry(bool disable); + const flConfiguration* native_handle() const noexcept { return handle_.get(); } private: diff --git a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.inline.h b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.inline.h index 08c8f594f..15c34b4d1 100644 --- a/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.inline.h +++ b/sdk_v2/cpp/include/foundry_local/foundry_local_cpp.inline.h @@ -180,6 +180,12 @@ inline Configuration& Configuration::SetCatalogRegion(const std::string& region) return *this; } +inline Configuration& Configuration::SetDisableNonessentialTelemetry(bool disable) { + KeyValuePairs options; + options.Set("DisableNonessentialTelemetry", disable ? "true" : "false"); + return SetAdditionalOptions(options); +} + inline flConfiguration* detail::CreateConfiguration(const std::string& app_name) { flConfiguration* config = nullptr; Check(detail::config_api()->Create(app_name.c_str(), &config)); diff --git a/sdk_v2/cpp/ports/cpp-client-telemetry/portfile.cmake b/sdk_v2/cpp/ports/cpp-client-telemetry/portfile.cmake new file mode 100644 index 000000000..2b4bac558 --- /dev/null +++ b/sdk_v2/cpp/ports/cpp-client-telemetry/portfile.cmake @@ -0,0 +1,50 @@ +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO microsoft/cpp_client_telemetry + REF 5152cb4067c3c0f46ffd79672702ffcffcade9c8 + SHA512 d46e929f1724333f41574829da2521d0c76cb07273b00baf978f459b38416a8cb7eeba9b0898364540b9a6ad1f2319f70c5e89f92e15c6f80ef59921e7ee0325 + HEAD_REF main +) + +set(MATSDK_BUILD_APPLE_HTTP OFF) +if(VCPKG_TARGET_IS_OSX OR VCPKG_TARGET_IS_IOS) + set(MATSDK_BUILD_APPLE_HTTP ON) +endif() + +set(MATSDK_BUILD_IOS OFF) +if(VCPKG_TARGET_IS_IOS) + set(MATSDK_BUILD_IOS ON) +endif() + +vcpkg_check_features( + OUT_FEATURE_OPTIONS FEATURE_OPTIONS + FEATURES + minimal-sqlite MATSDK_MINIMAL_SQLITE +) + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + OPTIONS + ${FEATURE_OPTIONS} + -DMATSDK_USE_VCPKG_DEPS=ON + -DBUILD_HEADERS=ON + -DBUILD_LIBRARY=ON + -DBUILD_TEST_TOOL=OFF + -DBUILD_UNIT_TESTS=OFF + -DBUILD_FUNC_TESTS=OFF + -DBUILD_JNI_WRAPPER=OFF + -DBUILD_OBJC_WRAPPER=OFF + -DBUILD_SWIFT_WRAPPER=OFF + -DBUILD_PACKAGE=OFF + -DBUILD_VERSION=${VERSION} + -DBUILD_APPLE_HTTP=${MATSDK_BUILD_APPLE_HTTP} + -DBUILD_IOS=${MATSDK_BUILD_IOS} +) + +vcpkg_cmake_install() +vcpkg_cmake_config_fixup(PACKAGE_NAME MSTelemetry CONFIG_PATH lib/cmake/MSTelemetry) + +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/share") + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") diff --git a/sdk_v2/cpp/ports/cpp-client-telemetry/vcpkg.json b/sdk_v2/cpp/ports/cpp-client-telemetry/vcpkg.json new file mode 100644 index 000000000..8287af308 --- /dev/null +++ b/sdk_v2/cpp/ports/cpp-client-telemetry/vcpkg.json @@ -0,0 +1,54 @@ +{ + "name": "cpp-client-telemetry", + "version": "3.10.173.1", + "port-version": 1, + "description": "Microsoft 1DS C/C++ Client Telemetry Library", + "homepage": "https://github.com/microsoft/cpp_client_telemetry", + "license": "Apache-2.0", + "supports": "((windows & !mingw) | linux | osx | ios | android) & !uwp", + "dependencies": [ + "nlohmann-json", + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + }, + { + "name": "zlib", + "platform": "!osx & !ios" + } + ], + "default-features": [ + "system-sqlite", + "curl-openssl" + ], + "features": { + "system-sqlite": { + "description": "Use the external vcpkg sqlite3 package for offline storage.", + "dependencies": [ + { + "name": "sqlite3", + "default-features": false, + "platform": "!osx & !ios" + } + ] + }, + "minimal-sqlite": { + "description": "Build the SDK's private feature-stripped SQLite." + }, + "curl-openssl": { + "description": "Use the built-in libcurl client with OpenSSL on Linux and Android.", + "dependencies": [ + { + "name": "curl", + "default-features": false, + "features": ["openssl"], + "platform": "linux | android" + } + ] + } + } +} diff --git a/sdk_v2/cpp/src/c_api.cc b/sdk_v2/cpp/src/c_api.cc index dbf49cc03..0aea12341 100644 --- a/sdk_v2/cpp/src/c_api.cc +++ b/sdk_v2/cpp/src/c_api.cc @@ -21,6 +21,7 @@ #include "items/tool_result_item.h" #include "manager.h" #include "ep_detection/ep_bootstrapper.h" +#include "inferencing/session/session_registration.h" #include #include @@ -72,6 +73,7 @@ struct flCatalog { struct flManager { fl::Manager& impl; std::unique_ptr catalog; // stores the flCatalog wrapper around impl.GetCatalog() + mutable std::vector urls_storage; mutable std::vector urls_cache; }; @@ -327,7 +329,7 @@ FL_API_STATUS_IMPL(Manager_CreateImpl, const flConfiguration* config, flManager* } auto& mgr = fl::Manager::Create(*cfg); - auto wrapper = std::make_unique(flManager{mgr, nullptr, {}}); + auto wrapper = std::make_unique(flManager{mgr, nullptr, {}, {}}); wrapper->catalog = std::make_unique(flCatalog{mgr.GetCatalog()}); *out_manager = wrapper.release(); return nullptr; @@ -372,10 +374,10 @@ FL_API_STATUS_IMPL(Manager_WebServiceUrlsImpl, const flManager* manager, return MakeStatus(FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT, "null argument"); } - const auto& urls = manager->impl.GetWebServiceUrls(); + manager->urls_storage = manager->impl.GetWebServiceUrls(); manager->urls_cache.clear(); - manager->urls_cache.reserve(urls.size()); - for (const auto& u : urls) { + manager->urls_cache.reserve(manager->urls_storage.size()); + for (const auto& u : manager->urls_storage) { manager->urls_cache.push_back(u.c_str()); } @@ -1766,6 +1768,7 @@ FL_API_STATUS_IMPL(Session_ProcessRequestImpl, flSession* session, const flReque } // ProcessRequest handles session option overlay and streaming callback wiring. + fl::SessionRegistration reg(fl::Manager::Instance().GetSessionManager(), *AsImpl(session)); AsImpl(session)->ProcessRequest(*AsImpl(request), *target); if (owned) { diff --git a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc index 39afcae37..45c73842c 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.cc +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.cc @@ -6,6 +6,9 @@ #include "catalog/local_model_scanner.h" #include "model.h" #include "model_info.h" +#include "telemetry/invocation_context.h" +#include "telemetry/telemetry.h" +#include "telemetry/telemetry_redaction.h" #include "utils.h" #include @@ -16,6 +19,81 @@ namespace fl { +namespace { + +/// Split a catalog URL into structured telemetry dimensions. The Azure Foundry +/// catalog URL looks like "https://ai.azure.com/api//", e.g. +/// "https://ai.azure.com/api/eastus/ux/v1.0" -> {ai.azure.com, eastus, ux/v1.0}. +/// Custom URLs that don't follow the "/api//" convention keep an empty +/// region and put the whole path in `format`. The embedded snapshot is "static". +struct ParsedCatalogUrl { + std::string endpoint; + std::string region; + std::string format; +}; + +ParsedCatalogUrl ParseCatalogUrl(const std::string& url) { + if (url == "static") { + return {"static", "", ""}; + } + + std::string rest = url; + if (auto scheme = rest.find("://"); scheme != std::string::npos) { + rest = rest.substr(scheme + 3); + } + if (auto q = rest.find_first_of("?#"); q != std::string::npos) { + rest = rest.substr(0, q); + } + + ParsedCatalogUrl out; + std::string path; + if (auto slash = rest.find('/'); slash == std::string::npos) { + out.endpoint = rest; + } else { + out.endpoint = rest.substr(0, slash); + path = rest.substr(slash + 1); + } + if (auto at = out.endpoint.rfind('@'); at != std::string::npos) { + out.endpoint = out.endpoint.substr(at + 1); + } + if (out.endpoint != "ai.azure.com") { + return {"custom", "", ""}; + } + + if (auto q = path.find_first_of("?#"); q != std::string::npos) { + path = path.substr(0, q); + } + + std::vector segments; + size_t pos = 0; + while (pos < path.size()) { + auto next = path.find('/', pos); + if (next == std::string::npos) { + next = path.size(); + } + if (next > pos) { + segments.push_back(path.substr(pos, next - pos)); + } + pos = next + 1; + } + + size_t format_start = 0; + if (segments.size() >= 2 && segments[0] == "api") { + out.region = segments[1]; + format_start = 2; + } + for (size_t i = format_start; i < segments.size(); ++i) { + if (!out.format.empty()) { + out.format += '/'; + } + out.format += segments[i]; + } + + return out; +} + +} // namespace + AzureModelCatalog::AzureModelCatalog(std::vector>> catalog_urls, std::string cache_dir, ModelFactory model_factory, @@ -23,7 +101,8 @@ AzureModelCatalog::AzureModelCatalog(std::vector(kDefaultCatalogFilter)); } @@ -77,6 +157,9 @@ std::vector AzureModelCatalog::FetchModels() const { std::vector fetched_infos; const std::string& cache_dir = cache_dir_; + // One correlation id groups every catalog access made by this refresh. + const std::string correlation_id = GenerateGuidV4(); + logger_.Log(LogLevel::Information, "Getting latest info from the Azure catalog and for locally cached models."); @@ -96,7 +179,16 @@ std::vector AzureModelCatalog::FetchModels() const { // while letting callers explicitly request "" as a real filter override. auto client = MakeCatalogClient(url, filter.value_or(""), ep_detector_, logger_, cache_dir, catalog_region_, disable_region_fallback_); - auto model_infos = FetchAllModelInfosWithCachedModels(*client, cached_model_ids, logger_); + + auto parsed = ParseCatalogUrl(url); + CatalogFetchInfo base_info; + base_info.endpoint = parsed.endpoint; + base_info.region = parsed.region; + base_info.format = parsed.format; + base_info.correlation_id = correlation_id; + base_info.user_agent = DefaultUserAgent(); + + auto model_infos = FetchAllModelInfosWithCachedModels(*client, cached_model_ids, logger_, telemetry_, base_info); for (const auto& info : model_infos) { // Check if the model is locally cached and pass the path if so. diff --git a/sdk_v2/cpp/src/catalog/azure_model_catalog.h b/sdk_v2/cpp/src/catalog/azure_model_catalog.h index 5769a3ef7..ad61c847f 100644 --- a/sdk_v2/cpp/src/catalog/azure_model_catalog.h +++ b/sdk_v2/cpp/src/catalog/azure_model_catalog.h @@ -14,6 +14,8 @@ namespace fl { +class ITelemetry; // forward declaration + /// Azure-specific catalog. Fetches from Azure Foundry catalog API, /// scans local cache, merges results. /// Maps to C# AzureModelCatalog. @@ -26,9 +28,10 @@ class AzureModelCatalog : public BaseModelCatalog { ModelFactory model_factory, const IEpDetector& ep_detector, ILogger& logger, - bool cache_only = false, - std::string catalog_region = "", - bool disable_region_fallback = false); + bool cache_only, + std::string catalog_region, + bool disable_region_fallback, + ITelemetry& telemetry); ~AzureModelCatalog() override; protected: @@ -50,6 +53,7 @@ class AzureModelCatalog : public BaseModelCatalog { // Configured Azure region: empty/"auto" → auto-detect, explicit → hard override. std::string catalog_region_; bool disable_region_fallback_; + ITelemetry& telemetry_; }; } // namespace fl diff --git a/sdk_v2/cpp/src/catalog/catalog_client.cc b/sdk_v2/cpp/src/catalog/catalog_client.cc index 6f0a51e38..7cab4f4f1 100644 --- a/sdk_v2/cpp/src/catalog/catalog_client.cc +++ b/sdk_v2/cpp/src/catalog/catalog_client.cc @@ -1,22 +1,64 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. #include "catalog/catalog_client.h" +#include "ep_detection/ep_detector.h" +#include "telemetry/telemetry.h" #include "utils.h" #include #include +#include +#include #include namespace fl { +namespace { + +constexpr const char* kCatalogFetchFailure = "catalog request failed"; + +} // namespace + std::vector FetchAllModelInfosWithCachedModels( ICatalogClient& client, const std::vector& cached_model_ids, - ILogger& logger) { - // Step 1: Fetch latest catalog models (existing flow). - auto result = client.FetchAllModelInfos(); + ILogger& logger, + ITelemetry& telemetry, + const CatalogFetchInfo& base_info) { + auto now = [] { return std::chrono::steady_clock::now(); }; + auto elapsed_ms = [](std::chrono::steady_clock::time_point start) { + return std::chrono::duration_cast(std::chrono::steady_clock::now() - start) + .count(); + }; + auto emit = [&](const std::string& operation, ActionStatus status, int64_t duration_ms, + int32_t model_count, const std::string& error) { + CatalogFetchInfo info = base_info; + info.operation = operation; + info.status = status; + info.duration_ms = duration_ms; + info.model_count = model_count; + info.error_message = error; + try { + telemetry.RecordCatalogFetch(info); + } catch (...) { + // Telemetry is best-effort and must not affect catalog results or mask catalog errors. + } + }; + + // Step 1: Fetch latest catalog models (the primary catalog access). + std::vector result; + { + const auto start = now(); + try { + result = client.FetchAllModelInfos(); + } catch (const std::exception&) { + emit("FetchAll", ActionStatus::kDependencyFailure, elapsed_ms(start), 0, kCatalogFetchFailure); + throw; + } + emit("FetchAll", ActionStatus::kSuccess, elapsed_ms(start), static_cast(result.size()), ""); + } if (cached_model_ids.empty()) { return result; @@ -37,17 +79,22 @@ std::vector FetchAllModelInfosWithCachedModels( // Step 3: Look up unresolved IDs from the catalog (older versions, etc.) if (!unresolved_ids.empty()) { + const auto start = now(); try { auto additional = client.FetchModelsByIds(unresolved_ids); + const auto additional_count = static_cast(additional.size()); for (auto& info : additional) { resolved_ids.insert(info.model_id); result.push_back(std::move(info)); } + emit("FetchByIds", ActionStatus::kSuccess, elapsed_ms(start), additional_count, ""); } catch (const std::exception& ex) { logger.Log(LogLevel::Warning, fmt::format("catalog: failed to fetch cached model IDs — {}", ex.what())); + emit("FetchByIds", ActionStatus::kDependencyFailure, elapsed_ms(start), 0, kCatalogFetchFailure); } catch (...) { logger.Log(LogLevel::Warning, "catalog: failed to fetch cached model IDs — unknown error"); + emit("FetchByIds", ActionStatus::kDependencyFailure, elapsed_ms(start), 0, "unknown error"); } // Step 4: Create basic entries for any IDs still unresolved (BYO models). diff --git a/sdk_v2/cpp/src/catalog/catalog_client.h b/sdk_v2/cpp/src/catalog/catalog_client.h index e3afcfe78..4438ad812 100644 --- a/sdk_v2/cpp/src/catalog/catalog_client.h +++ b/sdk_v2/cpp/src/catalog/catalog_client.h @@ -12,6 +12,9 @@ namespace fl { +class ITelemetry; // forward declaration +struct CatalogFetchInfo; // forward declaration + /// Abstract catalog client. Implemented by the live Azure catalog client, /// which queries the Azure Foundry catalog REST API. class ICatalogClient { @@ -50,11 +53,15 @@ class ICatalogClient { }; /// Production helper that combines a catalog fetch with locally cached model -/// resolution and BYO synthesis. +/// resolution and BYO synthesis. Emits a CatalogFetch event for the primary fetch +/// and (if it runs) the cached-id lookup, copying the endpoint/region/format/correlation +/// fields from `base_info` and filling in operation/status/duration/model_count/error. std::vector FetchAllModelInfosWithCachedModels( ICatalogClient& client, const std::vector& cached_model_ids, - ILogger& logger); + ILogger& logger, + ITelemetry& telemetry, + const CatalogFetchInfo& base_info); /// Construct a client for the live Azure Foundry catalog. /// - `ep_detector` limits results to models supported by this machine. diff --git a/sdk_v2/cpp/src/configuration.h b/sdk_v2/cpp/src/configuration.h index 761fe4065..91c421dfa 100644 --- a/sdk_v2/cpp/src/configuration.h +++ b/sdk_v2/cpp/src/configuration.h @@ -44,6 +44,10 @@ struct Configuration { /// inference) via the external service's HTTP endpoints. std::optional external_service_url; + /// Disable non-essential telemetry. Foundry Local may still send a minimal ProcessInfo event. + /// Defaults to false. + bool disable_nonessential_telemetry = false; + /// Additional/undocumented options passed through to the core. std::map additional_options; diff --git a/sdk_v2/cpp/src/download/blob_downloader.cc b/sdk_v2/cpp/src/download/blob_downloader.cc index c6a5e701a..8719334d9 100644 --- a/sdk_v2/cpp/src/download/blob_downloader.cc +++ b/sdk_v2/cpp/src/download/blob_downloader.cc @@ -443,7 +443,11 @@ bool IsDownloadNeeded(const BlobItemInfo& blob, const std::string& local_path) { void DownloadBlobsToDirectory(IBlobDownloader& downloader, const std::string& sas_uri, const std::string& output_directory, - const BlobDownloadOptions& options) { + const BlobDownloadOptions& options, + BlobDownloadStats& stats) { + using clock = std::chrono::steady_clock; + auto enum_start = clock::now(); + // Step 1: Enumerate all blobs auto all_blobs = downloader.ListBlobs(sas_uri); @@ -486,6 +490,9 @@ void DownloadBlobsToDirectory(IBlobDownloader& downloader, blobs_to_download.end()); if (blobs_to_download.empty()) { + stats.enumeration_ms = std::chrono::duration_cast( + clock::now() - enum_start) + .count(); return; } @@ -502,27 +509,42 @@ void DownloadBlobsToDirectory(IBlobDownloader& downloader, total_size += blob.content_length; } + stats.file_count = static_cast(blobs_to_download.size()); + stats.total_size_bytes = total_size; + stats.enumeration_ms = std::chrono::duration_cast( + clock::now() - enum_start) + .count(); + auto download_start = clock::now(); + // Step 5: Skip blobs already present at the expected size. Their bytes // count toward "downloaded" so the percentage stays accurate when this is a // resume of a partially-completed download. int64_t skipped_bytes = 0; + int32_t skipped_file_count = 0; blobs_to_download.erase( std::remove_if(blobs_to_download.begin(), blobs_to_download.end(), - [&skipped_bytes](const auto& pair) { + [&skipped_bytes, &skipped_file_count](const auto& pair) { if (IsDownloadNeeded(pair.first, pair.second)) { return false; } skipped_bytes += pair.first.content_length; + ++skipped_file_count; return true; }), blobs_to_download.end()); + stats.already_cached_bytes = skipped_bytes; + stats.skipped_file_count = skipped_file_count; + // Step 6: Emit initial progress reflecting any already-on-disk bytes. // If everything was skipped, emit 100% directly and return. if (blobs_to_download.empty()) { if (options.progress) { options.progress(100.0f); } + stats.download_ms = std::chrono::duration_cast( + clock::now() - download_start) + .count(); return; } @@ -596,6 +618,10 @@ void DownloadBlobsToDirectory(IBlobDownloader& downloader, if (options.progress) { options.progress(100.0f); } + + stats.download_ms = std::chrono::duration_cast( + clock::now() - download_start) + .count(); } } // namespace fl diff --git a/sdk_v2/cpp/src/download/blob_downloader.h b/sdk_v2/cpp/src/download/blob_downloader.h index 5d6849a6e..ed2c60228 100644 --- a/sdk_v2/cpp/src/download/blob_downloader.h +++ b/sdk_v2/cpp/src/download/blob_downloader.h @@ -118,12 +118,26 @@ class AzureBlobDownloader : public IBlobDownloader { ILogger& logger_; }; +/// Aggregate statistics from one DownloadBlobsToDirectory call. Used by callers to +/// stamp telemetry events with download-shape data (file count, byte volume, timing). +struct BlobDownloadStats { + int64_t total_size_bytes = 0; + int64_t already_cached_bytes = 0; + int32_t file_count = 0; + int32_t skipped_file_count = 0; + int64_t enumeration_ms = 0; // Time spent listing blobs from the SAS URI. + int64_t download_ms = 0; // Time spent transferring blob bytes (excludes enumeration). +}; + /// High-level download function: enumerate, filter, and download all blobs from a SAS URI. /// Handles safetensors optimization, path prefix filtering, and progress reporting. /// Throws fl::Exception on failure. +/// @param stats Populated with byte/file counts and per-phase timings useful for telemetry. +/// Always populated when the function returns normally; partially populated when it throws. void DownloadBlobsToDirectory(IBlobDownloader& downloader, const std::string& sas_uri, const std::string& output_directory, - const BlobDownloadOptions& options); + const BlobDownloadOptions& options, + BlobDownloadStats& stats); } // namespace fl diff --git a/sdk_v2/cpp/src/download/download_manager.cc b/sdk_v2/cpp/src/download/download_manager.cc index f7219e7e8..e0512a450 100644 --- a/sdk_v2/cpp/src/download/download_manager.cc +++ b/sdk_v2/cpp/src/download/download_manager.cc @@ -6,6 +6,8 @@ #include "exception.h" #include "log_level.h" #include "logger.h" +#include "telemetry/download_tracker.h" +#include "telemetry/telemetry.h" #include "util/path_safety.h" #include "util/region_fallback.h" #include "utils.h" @@ -13,6 +15,7 @@ #include #include +#include #include #include #include @@ -177,25 +180,22 @@ std::string ResolveRegion(const std::string& config_region, const ModelInfo& inf } // anonymous namespace DownloadManager::DownloadManager(std::string cache_directory, std::string_view catalog_region, int max_concurrency, - ILogger& logger, bool disable_region_fallback) + ILogger& logger, ITelemetry& telemetry, bool disable_region_fallback, + std::unique_ptr registry_client, + std::unique_ptr blob_downloader) : cache_directory_(std::move(cache_directory)), config_region_(NormalizeConfiguredRegion(catalog_region)), max_concurrency_(max_concurrency), logger_(logger), - registry_client_(std::make_unique( - kDefaultRegistryRegion, logger, std::make_unique(logger, !disable_region_fallback))), - blob_downloader_(std::make_unique(logger)) {} + registry_client_(registry_client ? std::move(registry_client) + : std::make_unique( + kDefaultRegistryRegion, logger, + std::make_unique(logger, !disable_region_fallback))), + blob_downloader_(blob_downloader ? std::move(blob_downloader) : std::make_unique(logger)), + telemetry_(telemetry) {} DownloadManager::~DownloadManager() = default; -void DownloadManager::SetModelRegistryClient(std::unique_ptr client) { - registry_client_ = std::move(client); -} - -void DownloadManager::SetBlobDownloader(std::unique_ptr downloader) { - blob_downloader_ = std::move(downloader); -} - std::string DownloadManager::ComputeModelPath(const ModelInfo& info) const { // Get publisher from string properties std::string publisher; @@ -238,12 +238,42 @@ std::string DownloadManager::ComputeModelPath(const ModelInfo& info) const { } std::string DownloadManager::DownloadModel(const ModelInfo& info, - std::function progress_cb) { + std::function progress_cb, + const std::string& user_agent) { + using clock = std::chrono::steady_clock; + auto lock_wait_start = clock::now(); + // Serialize all model downloads in this process: only one runs at a time, so it // gets the full network and disk instead of competing with another download. // The cross-process file lock taken below extends the guarantee across every // process and app that shares this cache directory. std::unique_lock download_guard(download_mutex_); + + // RAII telemetry tracker — emits a "Download" event on destruction with whatever + // fields have been populated. Default status is kFailure so abrupt exits (exceptions) + // are recorded as failures; the happy path explicitly sets kSuccess / kSkipped. + DownloadTracker tracker(info.model_id, user_agent, telemetry_); + tracker.SetMaxConcurrency(static_cast(max_concurrency_)); + auto download_start = clock::now(); + auto record_lock_wait = [&]() { + tracker.SetLockWaitMs(std::chrono::duration_cast( + clock::now() - lock_wait_start) + .count()); + }; + auto record_download_elapsed = [&]() { + tracker.SetDownloadMs(std::chrono::duration_cast( + clock::now() - download_start) + .count()); + }; + auto record_stats = [&](const BlobDownloadStats& stats) { + tracker.SetEnumerationMs(stats.enumeration_ms); + tracker.SetDownloadMs(stats.download_ms); + tracker.SetFileCount(stats.file_count); + tracker.SetTotalSizeBytes(stats.total_size_bytes); + tracker.SetAlreadyCachedBytes(stats.already_cached_bytes); + tracker.SetSkippedFileCount(stats.skipped_file_count); + }; + auto model_path = ComputeModelPath(info); // Fast path: serve the cache without taking the cross-process lock. @@ -258,10 +288,14 @@ std::string DownloadManager::DownloadModel(const ModelInfo& info, progress_cb(100.0f); } + record_lock_wait(); + tracker.SetStatus(ActionStatus::kSkipped); return ResolveEffectiveModelPath(model_path); } if (info.uri.empty()) { + auto ex = std::runtime_error("cannot download model: empty URI (asset_id)"); + tracker.RecordException(ex); FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "cannot download model: empty URI (asset_id)"); } @@ -301,9 +335,17 @@ std::string DownloadManager::DownloadModel(const ModelInfo& info, // (WaitForDirectoryLock) never runs while the mutex is held — the in-line acquire // at the top is the non-blocking TryAcquireForDirectory. Keep it that way. download_guard.unlock(); - lock = CrossProcessFileLock::WaitForDirectoryLock(model_path, cancel_pred, logger_); + try { + lock = CrossProcessFileLock::WaitForDirectoryLock(model_path, cancel_pred, logger_); + } catch (const std::exception& ex) { + record_lock_wait(); + tracker.SetDownloadWaitResult("Failed"); + tracker.RecordException(ex); + throw; + } download_guard.lock(); } + record_lock_wait(); // Another process may have just completed the download we were waiting on. // Re-check the cache now that we hold the lock. @@ -312,6 +354,8 @@ std::string DownloadManager::DownloadModel(const ModelInfo& info, if (progress_cb) { progress_cb(100.0f); } + tracker.SetStatus(ActionStatus::kSkipped); + tracker.SetDownloadWaitResult("CompletedByOtherProcess"); return ResolveEffectiveModelPath(model_path); } @@ -321,13 +365,14 @@ std::string DownloadManager::DownloadModel(const ModelInfo& info, // Empty file — its presence indicates download is in progress } - // Emit 0% immediately so callers know the download process has started. - // This provides a heartbeat during the silent container resolution phase. - if (progress_cb && progress_cb(0.0f) != 0) { - FL_THROW(FOUNDRY_LOCAL_ERROR_OPERATION_CANCELLED, "download cancelled by user progress callback"); - } - + BlobDownloadStats stats; try { + // Emit 0% immediately so callers know the download process has started. + // This provides a heartbeat during the silent container resolution phase. + if (progress_cb && progress_cb(0.0f) != 0) { + FL_THROW(FOUNDRY_LOCAL_ERROR_OPERATION_CANCELLED, "download cancelled by user progress callback"); + } + // Step 1: Resolve SAS URI from the region that served this model's catalog entry // (or the explicit override / default registry-region fallback). auto container = registry_client_->ResolveModelContainer(info.uri, ResolveRegion(config_region_, info)); @@ -350,7 +395,10 @@ std::string DownloadManager::DownloadModel(const ModelInfo& info, } DownloadBlobsToDirectory(*blob_downloader_, container.blob_sas_uri, - model_path, download_opts); + model_path, download_opts, stats); + + record_stats(stats); + tracker.SetDownloadWaitResult("Completed"); // Step 3: Write inference_model.json — use model_id (includes version) so the // local model scanner can match it back to catalog entries during startup. @@ -362,8 +410,13 @@ std::string DownloadManager::DownloadModel(const ModelInfo& info, // Step 5: Remove download signal — marks download as complete std::filesystem::remove(signal_path); + tracker.SetStatus(ActionStatus::kSuccess); return ResolveEffectiveModelPath(model_path); - } catch (...) { + } catch (const std::exception& e) { + record_stats(stats); + record_download_elapsed(); + tracker.SetDownloadWaitResult("Failed"); + tracker.RecordException(e); // Leave the signal file in place so the incomplete download is detected throw; } diff --git a/sdk_v2/cpp/src/download/download_manager.h b/sdk_v2/cpp/src/download/download_manager.h index 7099dcb8a..80605adef 100644 --- a/sdk_v2/cpp/src/download/download_manager.h +++ b/sdk_v2/cpp/src/download/download_manager.h @@ -5,6 +5,7 @@ #include "download/blob_downloader.h" #include "download/model_registry_client.h" #include "model_info.h" +#include "util/region_fallback.h" #include #include @@ -15,6 +16,7 @@ namespace fl { class ILogger; +class ITelemetry; /// Orchestrates the full model download flow: /// 1. Compute local cache path @@ -32,24 +34,26 @@ class DownloadManager { /// @param logger Logger forwarded to the registry client for retry diagnostics. /// @param disable_region_fallback When true, the registry uses a single region attempt /// with no cross-region fallback. + /// @param telemetry Telemetry sink. A Download event is emitted per DownloadModel call. DownloadManager(std::string cache_directory, std::string_view catalog_region, int max_concurrency, ILogger& logger, - bool disable_region_fallback = false); + ITelemetry& telemetry, + bool disable_region_fallback = false, + std::unique_ptr registry_client = nullptr, + std::unique_ptr blob_downloader = nullptr); ~DownloadManager(); - /// Override the model registry client (for testing). - void SetModelRegistryClient(std::unique_ptr client); - - /// Override the blob downloader (for testing). - void SetBlobDownloader(std::unique_ptr downloader); - /// Download a model to the local cache. /// progress_cb reports 0.0 to 100.0 percentage. + /// user_agent identifies the calling API surface for telemetry attribution; pass an + /// empty string when called outside of an HTTP request context. /// Returns the local path where the model was downloaded. /// Throws fl::Exception on failure. - std::string DownloadModel(const ModelInfo& info, std::function progress_cb = nullptr); + std::string DownloadModel(const ModelInfo& info, + std::function progress_cb = nullptr, + const std::string& user_agent = ""); /// Check if a model is cached locally (directory exists and download is complete). bool IsModelCached(const ModelInfo& info) const; @@ -77,6 +81,7 @@ class DownloadManager { ILogger& logger_; std::unique_ptr registry_client_; std::unique_ptr blob_downloader_; + ITelemetry& telemetry_; /// Serializes all model downloads in this process: only one runs at a time, so /// each gets the full network/disk instead of competing with another download. diff --git a/sdk_v2/cpp/src/ep_detection/ep_detector.cc b/sdk_v2/cpp/src/ep_detection/ep_detector.cc index f60e6adf9..3b5273c9b 100644 --- a/sdk_v2/cpp/src/ep_detection/ep_detector.cc +++ b/sdk_v2/cpp/src/ep_detection/ep_detector.cc @@ -4,35 +4,32 @@ #include "ep_detection/ep_bootstrapper.h" #include "logger.h" +#include "telemetry/ep_download_tracker.h" +#include "telemetry/telemetry.h" #include #include +#include #include namespace fl { EpDetector::EpDetector(const OrtApi& ort_api, OrtEnv& ort_env, std::vector> bootstrappers, - ILogger& logger) + ILogger& logger, + ITelemetry& telemetry) : ort_api_(ort_api), ort_env_(ort_env), bootstrappers_(std::move(bootstrappers)), - logger_(logger) { - // Populate both cache vectors exact-sized from bootstrappers_. After this point - // size and element addresses (including the EpInfo::name string storage backing - // flEpInfo::name) are immutable for the detector's lifetime — only is_registered - // is ever updated, in place, under cache_mutex_. + logger_(logger), + telemetry_(telemetry) { + // Populate the cache from bootstrappers_. Reads return snapshots so callers do + // not observe concurrent writes to is_registered. cached_eps_.reserve(bootstrappers_.size()); - cached_eps_c_.reserve(bootstrappers_.size()); for (const auto& bs : bootstrappers_) { cached_eps_.push_back(EpInfo{bs->Name(), bs->IsRegistered()}); - cached_eps_c_.push_back(flEpInfo{ - FOUNDRY_LOCAL_API_VERSION, - cached_eps_.back().name.c_str(), - bs->IsRegistered(), - }); } } @@ -105,19 +102,27 @@ std::map> EpDetector::GetAvailableDevicesT } const std::vector& EpDetector::GetDiscoverableEps() const { - // Take the cache lock for strict correctness of the is_registered field reads. - // Vector size and element addresses are immutable after construction; only - // is_registered fields can be mutated (by DownloadAndRegisterEps under the same - // mutex). The lock is released when this function returns, so the snapshot may - // be stale by the time the caller reads individual fields — that is documented - // and acceptable. + thread_local std::vector snapshot; std::lock_guard lock(cache_mutex_); - return cached_eps_; + snapshot = cached_eps_; + return snapshot; } std::span EpDetector::GetDiscoverableEpsCApi() const { + thread_local std::vector snapshot_eps; + thread_local std::vector snapshot_c; std::lock_guard lock(cache_mutex_); - return cached_eps_c_; + snapshot_eps = cached_eps_; + snapshot_c.clear(); + snapshot_c.reserve(snapshot_eps.size()); + for (const auto& ep : snapshot_eps) { + snapshot_c.push_back(flEpInfo{ + FOUNDRY_LOCAL_API_VERSION, + ep.name.c_str(), + ep.is_registered, + }); + } + return snapshot_c; } EpDownloadResult EpDetector::DownloadAndRegisterEps(const std::vector* names, @@ -134,6 +139,47 @@ EpDownloadResult EpDetector::DownloadAndRegisterEps(const std::vector(names->size()) + : static_cast(bootstrappers_.size()); + int telemetry_attempts = 0; + int telemetry_succeeded = 0; + int telemetry_failed = 0; + ActionStatus telemetry_status = ActionStatus::kSuccess; + // Some bootstrappers may already be Registered before this call; if so, the + // EPDownloadAttempt is considered "resolved" even when no work was done. + bool telemetry_resolved = false; + bool telemetry_attempt_recorded = false; + auto record_attempt = [&]() { + if (telemetry_attempt_recorded) { + return; + } + + telemetry_attempt_recorded = true; + EpDownloadAttemptInfo attempt_info; + attempt_info.user_agent = DefaultUserAgent(); + attempt_info.correlation_id = telemetry_correlation_id; + attempt_info.attempts = telemetry_attempts; + attempt_info.num_providers = telemetry_num_providers; + attempt_info.succeeded = telemetry_succeeded; + attempt_info.failed = telemetry_failed; + attempt_info.resolved = telemetry_resolved; + attempt_info.status = telemetry_status; + attempt_info.duration_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - attempt_start) + .count(); + try { + telemetry_.RecordEpDownloadAttempt(attempt_info); + } catch (...) { + // Telemetry is best-effort and must not change EP registration results. + } + }; + // Track cancellation from the progress callback bool cancelled = false; IEpBootstrapper::ProgressCallback wrapped_cb; @@ -170,20 +216,63 @@ EpDownloadResult EpDetector::DownloadAndRegisterEps(const std::vectorName()); + // Per-provider EPDownloadAndRegister event via EpDownloadTracker. + const bool was_registered_before = bs->IsRegistered(); + EpDownloadTracker tracker(bs->Name(), /*user_agent=*/std::string{}, telemetry_correlation_id, telemetry_); + const auto initial_ready_state = was_registered_before ? EpReadyState::kRegistered : EpReadyState::kNotPresent; + const auto unresolved_ready_state = was_registered_before ? EpReadyState::kRegistered : EpReadyState::kUnknown; + tracker.RecordInitialState(initial_ready_state); + + ++telemetry_attempts; // Reuse previously downloaded EP packages unless the caller explicitly asks // for a forced refresh. Downloading every time made the bootstrapper // re-fetch and re-register EPs on every invocation. - if (bs->DownloadAndRegister(/*force=*/false, wrapped_cb, logger_)) { + bool ok = false; + try { + ok = bs->DownloadAndRegister(/*force=*/false, wrapped_cb, logger_); + } catch (const std::exception& ex) { + ++telemetry_failed; + result.failed_eps.push_back(bs->Name()); + result.success = false; + telemetry_status = ActionStatusFromException(ex); + if (telemetry_status == ActionStatus::kCanceled) { + cancelled = true; + result.cancelled = true; + } + result.status = "Some EPs failed to register"; + tracker.RecordException(ex); + record_attempt(); + // Re-throw to preserve existing semantics — the wrapper RAII guard above + // resets download_in_progress_; the tracker dtor records the EP event. + throw; + } + + if (cancelled) { + result.success = false; + telemetry_status = ActionStatus::kCanceled; + tracker.RecordRegisterComplete(ActionStatus::kCanceled, unresolved_ready_state); + } else if (ok) { + ++telemetry_succeeded; + telemetry_resolved = true; result.registered_eps.push_back(bs->Name()); // Update cached registration state in place under the cache lock so // GetDiscoverableEps[C] readers see the new value. std::lock_guard cache_lock(cache_mutex_); cached_eps_[i].is_registered = true; - cached_eps_c_[i].is_registered = true; + + tracker.RecordDownloadComplete(was_registered_before ? ActionStatus::kSkipped : ActionStatus::kSuccess, + unresolved_ready_state); + tracker.RecordRegisterComplete(ActionStatus::kSuccess, EpReadyState::kRegistered); } else { + ++telemetry_failed; result.failed_eps.push_back(bs->Name()); result.success = false; + telemetry_status = ActionStatus::kFailure; + // The bootstrapper conflated download + register and returned false. + // Record the combined operation as the register phase rather than fabricating a download/register split. + tracker.RecordDownloadComplete(ActionStatus::kSkipped, unresolved_ready_state); + tracker.RecordRegisterComplete(ActionStatus::kFailure, unresolved_ready_state); } } @@ -191,12 +280,19 @@ EpDownloadResult EpDetector::DownloadAndRegisterEps(const std::vector> GetAvailableDevicesToEPs() const = 0; - /// Returns metadata for all discoverable EPs (those with bootstrappers). + /// Returns a recent snapshot of all discoverable EPs (those with bootstrappers). + /// The returned reference is valid until this method is next called on the same thread. /// Default: empty — no discoverable EPs beyond what's already registered. virtual const std::vector& GetDiscoverableEps() const { static const std::vector empty; return empty; } - /// C ABI view of the discoverable EPs. Pointers, struct addresses, and name - /// strings are stable for the detector's lifetime. is_registered values - /// reflect a recent snapshot — concurrent DownloadAndRegisterEps may have - /// updated them since the call returned. + /// C ABI view of a recent discoverable EPs snapshot. Pointers are valid until + /// this method is next called on the same thread. /// Default: empty span. virtual std::span GetDiscoverableEpsCApi() const { return {}; @@ -71,9 +71,12 @@ class EpDetector : public IEpDetector { /// @param ort_env The ORT environment singleton. /// @param bootstrappers EP bootstrappers for download/registration. /// @param logger Logger instance. + /// @param telemetry Telemetry sink. DownloadAndRegisterEps emits one EPDownloadAndRegister event per provider via + /// EpDownloadTracker, plus one aggregate EPDownloadAttempt event for the whole call. EpDetector(const OrtApi& ort_api, OrtEnv& ort_env, std::vector> bootstrappers, - ILogger& logger); + ILogger& logger, + ITelemetry& telemetry); ~EpDetector() override = default; // Non-copyable, non-movable (owns bootstrappers and mutex state) @@ -92,14 +95,13 @@ class EpDetector : public IEpDetector { OrtEnv& ort_env_; std::vector> bootstrappers_; ILogger& logger_; + ITelemetry& telemetry_; std::mutex download_mutex_; std::atomic download_in_progress_{false}; mutable std::mutex cache_mutex_; - // Populated once in the constructor; size and element addresses (including name strings) - // are stable for the detector's lifetime. Only is_registered fields are mutated, under - // cache_mutex_. cached_eps_c_ mirrors cached_eps_ for the C ABI. + // Populated once in the constructor; mutated only under cache_mutex_ and copied + // into per-thread snapshots for callers. std::vector cached_eps_; - std::vector cached_eps_c_; }; } // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/generative/audio/audio_session.cc b/sdk_v2/cpp/src/inferencing/generative/audio/audio_session.cc index 06a597ea9..288481382 100644 --- a/sdk_v2/cpp/src/inferencing/generative/audio/audio_session.cc +++ b/sdk_v2/cpp/src/inferencing/generative/audio/audio_session.cc @@ -4,6 +4,7 @@ #include "inferencing/generative/audio/audio_session.h" #include "contracts/audio_transcriptions.h" +#include "inferencing/execution_provider.h" #include "inferencing/generative/audio/onnx_audio_generator.h" #include "inferencing/generative/audio/pcm_utils.h" #include "inferencing/generative/genai_model_instance.h" @@ -15,9 +16,11 @@ #include "items/speech_segment_item.h" #include "items/text_item.h" #include "model.h" +#include "telemetry/telemetry.h" #include "util/file_uri.h" #include "utils.h" +#include #include #include #include @@ -56,6 +59,8 @@ std::unique_ptr BuildSpeechResult( // (~10s on Whisper, ~5s on Nemotron streaming) produces under 256 tokens, so most short-form // transcriptions avoid any reallocation. Longer transcriptions still grow geometrically. constexpr size_t kInitialTokenCapacity = 256; +constexpr int32_t kDefaultStreamingSampleRate = 16000; +constexpr int32_t kDefaultStreamingChannels = 1; // Concatenate the per-token strings into a single buffer with one allocation. std::string JoinTokens(const std::vector& token_texts) { @@ -71,6 +76,16 @@ std::string JoinTokens(const std::vector& token_texts) { return out; } +int64_t AudioDurationMsFromPcmBytes(int64_t bytes, int32_t sample_rate, int32_t channels) { + if (bytes <= 0 || sample_rate <= 0 || channels <= 0) { + return -1; + } + + constexpr int64_t kBytesPerSample = 2; // s16le PCM + const int64_t samples_per_channel = bytes / (kBytesPerSample * channels); + return samples_per_channel * 1000 / sample_rate; +} + } // namespace AudioSession::AudioSession(const fl::Model& catalog_model, GenAIModelInstance& model, @@ -100,11 +115,17 @@ SessionType AudioSession::Type() const { return SessionType::kAudio; } +std::string AudioSession::ExecutionProvider() const { + return std::string(EPUtils::EPtoRegistrationName(model_.EP())); +} + void AudioSession::SetSessionOptionsImpl(const KeyValuePairs& options) { session_options_ = SearchOptions::FromParameters(options); } void AudioSession::ProcessRequestImpl(const Request& request, Response& response) { + audio_telemetry_details_.reset(); + // OpenAI audio transcription JSON pass-through: a TEXT item tagged OPENAI_JSON. for (const auto* item : request.items) { if (item->type == FOUNDRY_LOCAL_ITEM_TEXT) { @@ -189,6 +210,10 @@ void AudioSession::ProcessRequestImpl(const Request& request, Response& response std::string audio_path = PathFromFileUri(audio_item.uri); auto generator = OnnxAudioGenerator::Create(audio_path, temperature, Model(), language); + audio_telemetry_details_ = AudioTelemetryDetails{ + .source = "file", + .language = language, + }; int prompt_tokens = generator->PromptTokenCount(); // Token-by-token generation with optional streaming. @@ -294,9 +319,13 @@ void AudioSession::ProcessStreamingAudio(const AudioItem& format_item, ItemQueue // Streaming ASR has no text prompt (input is audio), so prompt_tokens stays 0. // We track every decoded token (whether it produced visible text or not) as completion_tokens. int completion_tokens = 0; + int64_t audio_bytes = 0; + const int32_t sample_rate = format_item.sample_rate == 0 ? kDefaultStreamingSampleRate : format_item.sample_rate; + const int32_t channels = format_item.channels == 0 ? kDefaultStreamingChannels : format_item.channels; // 3. If the AudioItem itself has initial data, process it first if (format_item.data && format_item.data_size > 0) { + audio_bytes += static_cast(format_item.data_size); auto float_samples = ConvertS16LEToFloat( static_cast(format_item.data), format_item.data_size); ProcessChunk(*processor, *generator, *tokenizer_stream, @@ -321,6 +350,7 @@ void AudioSession::ProcessStreamingAudio(const AudioItem& format_item, ItemQueue } auto& bytes = static_cast(*item); + audio_bytes += static_cast(bytes.data_size); auto float_samples = ConvertS16LEToFloat( static_cast(bytes.data), bytes.data_size); @@ -353,6 +383,13 @@ void AudioSession::ProcessStreamingAudio(const AudioItem& format_item, ItemQueue response.usage.prompt_tokens = 0; response.usage.completion_tokens = completion_tokens; response.usage.total_tokens = completion_tokens; + audio_telemetry_details_ = AudioTelemetryDetails{ + .source = "streaming_pcm", + .language = {}, + .duration_ms = AudioDurationMsFromPcmBytes(audio_bytes, sample_rate, channels), + .sample_rate = sample_rate, + .channels = channels, + }; logger_.Log(LogLevel::Debug, fmt::format("Streaming audio transcription complete, text length: {}", response.items.empty() ? 0 : full_text_size)); @@ -419,7 +456,7 @@ void AudioSession::ProcessAudioTranscriptionJson(const std::string& request_json // Validate file exists namespace fs = std::filesystem; if (!fs::exists(req.filename)) { - FL_LOG_AND_THROW(logger_, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, fmt::format("Audio file not found: '{}'", req.filename)); + FL_LOG_AND_THROW(logger_, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "Audio file not found"); } // Build generation options from session defaults @@ -445,6 +482,10 @@ void AudioSession::ProcessAudioTranscriptionJson(const std::string& request_json // Create the audio generator auto generator = OnnxAudioGenerator::Create(req.filename, temperature, Model(), language); + audio_telemetry_details_ = AudioTelemetryDetails{ + .source = "openai_json_file", + .language = language, + }; int prompt_tokens = generator->PromptTokenCount(); auto streaming_callback = CreateCallbackHandler(original_request); @@ -502,4 +543,33 @@ void AudioSession::ProcessAudioTranscriptionJson(const std::string& request_json total_tokens, prompt_tokens, completion_tokens)); } +void AudioSession::RecordAdditionalModelUsage(const Request& /*request*/, const Response& response, + const InvocationContext& context, int64_t total_time_ms, + bool streaming) { + if (!audio_telemetry_details_.has_value()) { + return; + } + + AudioUsageInfo info; + info.model_id = CatalogModel().Id(); + info.execution_provider = ExecutionProvider(); + if (info.execution_provider.empty()) { + info.execution_provider = CatalogModel().Info().execution_provider; + } + info.user_agent = context.user_agent; + info.correlation_id = context.correlation_id; + info.indirect = context.indirect; + info.stream = streaming; + info.total_time_ms = total_time_ms; + info.total_tokens = static_cast(response.usage.total_tokens); + info.input_token_count = static_cast(response.usage.prompt_tokens); + info.completion_token_count = static_cast(response.usage.completion_tokens); + info.audio_source = audio_telemetry_details_->source; + info.language = audio_telemetry_details_->language; + info.audio_duration_ms = audio_telemetry_details_->duration_ms; + info.sample_rate = audio_telemetry_details_->sample_rate; + info.channels = audio_telemetry_details_->channels; + Telemetry().RecordAudioUsage(info); +} + } // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/generative/audio/audio_session.h b/sdk_v2/cpp/src/inferencing/generative/audio/audio_session.h index 1a35d096a..e0378ba37 100644 --- a/sdk_v2/cpp/src/inferencing/generative/audio/audio_session.h +++ b/sdk_v2/cpp/src/inferencing/generative/audio/audio_session.h @@ -7,6 +7,7 @@ #include "logger.h" #include +#include #include #include @@ -79,15 +80,30 @@ class AudioSession : public Session { const Request& request, int& completion_tokens); + void RecordAdditionalModelUsage(const Request& request, const Response& response, + const InvocationContext& context, int64_t total_time_ms, + bool streaming) override; + GenAIModelInstance& Model() { return model_; } const GenAIModelInstance& Model() const { return model_; } + std::string ExecutionProvider() const override; + ILogger& logger_; GenAIModelInstance& model_; // Tracks who is responsible for calling model_.ReleaseSession(). Set to false on the // moved-from instance so the refcount transfers cleanly across moves. bool owns_session_ = true; SearchOptions session_options_; + + struct AudioTelemetryDetails { + std::string source; + std::string language; + int64_t duration_ms = -1; + int32_t sample_rate = 0; + int32_t channels = 0; + }; + std::optional audio_telemetry_details_; }; } // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc b/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc index eb76ef94d..f46ef5b71 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc +++ b/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc @@ -5,6 +5,7 @@ #include "contracts/chat_completions.h" #include "contracts/chat_completions_converter.h" +#include "inferencing/execution_provider.h" #include "inferencing/generative/chat/onnx_chat_generator.h" #include "inferencing/generative/chat/reasoning_stream_splitter.h" #include "inferencing/generative/genai_model_instance.h" @@ -77,6 +78,10 @@ SessionType ChatSession::Type() const { return SessionType::kChat; } +std::string ChatSession::ExecutionProvider() const { + return std::string(EPUtils::EPtoRegistrationName(model_.EP())); +} + void ChatSession::SetSessionOptionsImpl(const KeyValuePairs& options) { session_options_ = SearchOptions::FromParameters(options); } diff --git a/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.h b/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.h index 4bd761bc3..b6c38f63f 100644 --- a/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.h +++ b/sdk_v2/cpp/src/inferencing/generative/chat/chat_session.h @@ -101,6 +101,8 @@ class ChatSession : public Session { void ProcessChatCompletionsJson(const std::string& request_json, const Request& original_request, Response& response); + std::string ExecutionProvider() const override; + /// Commit input messages and assistant reply to history after a successful turn. void CommitTurn(std::vector&& new_messages, const Response& response, int pre_turn_token_count, int post_turn_token_count); diff --git a/sdk_v2/cpp/src/inferencing/generative/embeddings/embeddings_session.cc b/sdk_v2/cpp/src/inferencing/generative/embeddings/embeddings_session.cc index ae571dbf4..131c83032 100644 --- a/sdk_v2/cpp/src/inferencing/generative/embeddings/embeddings_session.cc +++ b/sdk_v2/cpp/src/inferencing/generative/embeddings/embeddings_session.cc @@ -4,6 +4,7 @@ #include "contracts/embeddings.h" #include "exception.h" +#include "inferencing/execution_provider.h" #include "inferencing/generative/embeddings/fp16.h" #include "inferencing/generative/genai_model_instance.h" #include "items/tensor_item.h" @@ -39,6 +40,10 @@ SessionType EmbeddingsSession::Type() const { return SessionType::kEmbeddings; } +std::string EmbeddingsSession::ExecutionProvider() const { + return std::string(EPUtils::EPtoRegistrationName(model_.EP())); +} + void EmbeddingsSession::ProcessRequestImpl(const Request& request, Response& response) { // OpenAI embeddings JSON pass-through: a TEXT item tagged OPENAI_JSON. Routes to a // separate handler that produces an OPENAI_JSON response, parity with chat and audio. @@ -73,7 +78,7 @@ void EmbeddingsSession::ProcessRequestImpl(const Request& request, Response& res } // Single batched forward pass for all inputs. - auto embeddings = GenerateEmbeddingsBatch(inputs); + auto embeddings = GenerateEmbeddingsBatch(inputs, request); // Wrap each embedding as a TensorItem in the response. The vector is heap-allocated // and ownership is transferred to the TensorItem deleter via deleter_user_data_, so @@ -95,7 +100,7 @@ void EmbeddingsSession::ProcessRequestImpl(const Request& request, Response& res } void EmbeddingsSession::ProcessEmbeddingsJson(const std::string& request_json, - const Request& /*original_request*/, + const Request& original_request, Response& response) { // Parse the OpenAI embeddings request. Let nlohmann::json::parse_error propagate — // matches AudioSession::ProcessAudioTranscriptionJson behavior. @@ -117,7 +122,7 @@ void EmbeddingsSession::ProcessEmbeddingsJson(const std::string& request_json, // Reuse GenerateEmbeddingsBatch so the typed and JSON paths stay bit-for-bit equal // under future refactors (parity test relies on this). if (!inputs.empty()) { - auto embeddings = GenerateEmbeddingsBatch(inputs); + auto embeddings = GenerateEmbeddingsBatch(inputs, original_request); output.data.reserve(embeddings.size()); for (size_t i = 0; i < embeddings.size(); ++i) { @@ -142,7 +147,7 @@ void EmbeddingsSession::ProcessEmbeddingsJson(const std::string& request_json, } std::vector> EmbeddingsSession::GenerateEmbeddingsBatch( - const std::vector& inputs) { + const std::vector& inputs, const Request& request) { // Process each input independently (batch_size=1 per forward pass). // // Embedding models like Qwen3-Embedding use bidirectional attention — @@ -157,7 +162,10 @@ std::vector> EmbeddingsSession::GenerateEmbeddingsBatch( results.reserve(inputs.size()); for (const auto& input : inputs) { - results.push_back(GenerateSingleEmbedding(input)); + if (request.canceled.load(std::memory_order_relaxed)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_OPERATION_CANCELLED, "embeddings request canceled"); + } + results.push_back(GenerateSingleEmbedding(input, request)); } logger_.Log(LogLevel::Verbose, @@ -166,7 +174,11 @@ std::vector> EmbeddingsSession::GenerateEmbeddingsBatch( return results; } -std::vector EmbeddingsSession::GenerateSingleEmbedding(const std::string& input) { +std::vector EmbeddingsSession::GenerateSingleEmbedding(const std::string& input, const Request& request) { + if (request.canceled.load(std::memory_order_relaxed)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_OPERATION_CANCELLED, "embeddings request canceled"); + } + auto& oga_model = model_.GetOgaModel(); auto& tokenizer = model_.GetOgaTokenizer(); @@ -189,7 +201,13 @@ std::vector EmbeddingsSession::GenerateSingleEmbedding(const std::string& generator->AppendTokenSequences(*sequences); // 3. Single forward pass. + if (request.canceled.load(std::memory_order_relaxed)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_OPERATION_CANCELLED, "embeddings request canceled"); + } generator->GenerateNextToken(); + if (request.canceled.load(std::memory_order_relaxed)) { + FL_THROW(FOUNDRY_LOCAL_ERROR_OPERATION_CANCELLED, "embeddings request canceled"); + } // 4. Extract hidden_states. Shape: [1, token_count, hidden_size] auto hidden_states = generator->GetOutput("hidden_states"); diff --git a/sdk_v2/cpp/src/inferencing/generative/embeddings/embeddings_session.h b/sdk_v2/cpp/src/inferencing/generative/embeddings/embeddings_session.h index 26f9a317d..4dfe29a4a 100644 --- a/sdk_v2/cpp/src/inferencing/generative/embeddings/embeddings_session.h +++ b/sdk_v2/cpp/src/inferencing/generative/embeddings/embeddings_session.h @@ -37,10 +37,11 @@ class EmbeddingsSession : public Session { /// Generate L2-normalized embedding vectors for a list of inputs. /// Each input is processed independently (batch_size=1) to avoid /// padding artifacts with bidirectional-attention embedding models. - std::vector> GenerateEmbeddingsBatch(const std::vector& inputs); + std::vector> GenerateEmbeddingsBatch(const std::vector& inputs, + const Request& request); /// Generate a single L2-normalized embedding vector for one input string. - std::vector GenerateSingleEmbedding(const std::string& input); + std::vector GenerateSingleEmbedding(const std::string& input, const Request& request); /// Process a request whose first item is a TEXT item tagged OPENAI_JSON containing an /// OpenAI EmbeddingCreateRequest payload. Parses the JSON, runs generation via the @@ -49,6 +50,8 @@ class EmbeddingsSession : public Session { void ProcessEmbeddingsJson(const std::string& request_json, const Request& original_request, Response& response); + std::string ExecutionProvider() const override; + ILogger& logger_; GenAIModelInstance& model_; }; diff --git a/sdk_v2/cpp/src/inferencing/session/session.cc b/sdk_v2/cpp/src/inferencing/session/session.cc index e8f90ee69..da667506c 100644 --- a/sdk_v2/cpp/src/inferencing/session/session.cc +++ b/sdk_v2/cpp/src/inferencing/session/session.cc @@ -8,6 +8,9 @@ #include "inferencing/generative/embeddings/embeddings_session.h" #include "inferencing/model_load_manager.h" #include "inferencing/session/session_manager.h" +#include "items/message_item.h" +#include "items/text_item.h" +#include "items/tool_result_item.h" #include "manager.h" #include "model.h" #include "telemetry/telemetry.h" @@ -16,10 +19,54 @@ #include +#include +#include #include +#include namespace fl { +namespace { + +uint64_t CountOpenAIJsonMessages(std::string_view text) { + auto json = nlohmann::json::parse(text.begin(), text.end(), nullptr, false); + if (json.is_discarded() || !json.is_object()) { + return 0; + } + + uint64_t count = 0; + const auto messages = json.find("messages"); + if (messages != json.end() && messages->is_array()) { + count += messages->size(); + } + + return count; +} + +uint64_t CountRequestMessages(const Request& request) { + uint64_t count = 0; + for (const auto* item : request.items) { + if (item == nullptr) { + continue; + } + + if (item->type == FOUNDRY_LOCAL_ITEM_MESSAGE || item->type == FOUNDRY_LOCAL_ITEM_TOOL_RESULT) { + ++count; + continue; + } + + if (item->type == FOUNDRY_LOCAL_ITEM_TEXT) { + const auto& text_item = static_cast(*item); + if (text_item.text_type == FOUNDRY_LOCAL_TEXT_ITEM_TYPE_OPENAI_JSON) { + count += CountOpenAIJsonMessages(text_item.text); + } + } + } + return count; +} + +} // namespace + Session::Session(const fl::Model& catalog_model, ILogger& logger, ITelemetry& telemetry, bool allow_concurrent_requests) : catalog_model_(catalog_model), @@ -30,6 +77,20 @@ Session::Session(const fl::Model& catalog_model, ILogger& logger, ITelemetry& te Session::~Session() = default; +Session::Session(Session&& other) noexcept + : catalog_model_(other.catalog_model_), + logger_(other.logger_), + telemetry_(other.telemetry_), + tool_definitions_(std::move(other.tool_definitions_)), + session_options_(std::move(other.session_options_)), + callback_fn_(std::move(other.callback_fn_)), + callback_user_data_(other.callback_user_data_), + allow_concurrent_requests_(other.allow_concurrent_requests_) { + std::lock_guard lock(other.request_context_mutex_); + request_context_ = std::move(other.request_context_); + other.request_context_.reset(); +} + std::unique_ptr Session::Create(const fl::Model& model) { auto& mgr = Manager::Instance(); auto& telemetry = mgr.GetTelemetry(); @@ -101,17 +162,81 @@ void Session::ProcessRequest(const Request& request, Response& response) { lock.lock(); } - ActionTracker tracker(Action::kSessionProcessRequest, telemetry_); + struct ActiveRequestGuard { + Session& session; + const Request& request; + ActiveRequestGuard(Session& session, const Request& request) : session(session), request(request) { + std::lock_guard lock(session.active_requests_mutex_); + session.active_requests_.insert(&request); + if (session.session_canceled_) { + request.canceled.store(true, std::memory_order_relaxed); + } + } + ~ActiveRequestGuard() { + std::lock_guard lock(session.active_requests_mutex_); + session.active_requests_.erase(&request); + } + } active_request_guard(*this, request); + + // Use the context the caller staged (an HTTP route stages an indirect child + // with the route's correlation id); otherwise mint a direct context per call + // for direct SDK use. + InvocationContext context; + { + std::lock_guard context_lock(request_context_mutex_); + context = request_context_ ? *request_context_ : InvocationContext::Direct(); + request_context_.reset(); + } + context.EnsureCorrelationId(); + const bool streaming = static_cast(callback_fn_); + + ActionTracker tracker(Action::kSessionProcessRequest, telemetry_, context); tracker.SetModelId(CatalogModel().Id()); + const auto start = std::chrono::steady_clock::now(); try { ProcessRequestImpl(request, response); - tracker.SetStatus(ActionStatus::kSuccess); + tracker.SetStatus(request.canceled.load(std::memory_order_relaxed) ? ActionStatus::kCanceled + : ActionStatus::kSuccess); } catch (const std::exception& ex) { tracker.RecordException(ex); throw; } + + const auto inference_end = std::chrono::steady_clock::now(); + + // Per-inference Model event — emitted on success with whatever metrics this run + // produced, sharing the action's correlation id and indirect flag. TTFT and + // memory are not surfaced by the generators yet and stay at their unset values. + ModelUsageInfo usage; + usage.model_id = CatalogModel().Id(); + usage.execution_provider = ExecutionProvider(); + if (usage.execution_provider.empty()) { + usage.execution_provider = CatalogModel().Info().execution_provider; + } + usage.user_agent = context.user_agent; + usage.correlation_id = context.correlation_id; + usage.indirect = context.indirect; + usage.stream = streaming; + usage.num_messages = CountRequestMessages(request); + usage.total_time_ms = std::chrono::duration_cast(inference_end - start).count(); + usage.total_tokens = static_cast(response.usage.total_tokens); + usage.input_token_count = static_cast(response.usage.prompt_tokens); + try { + telemetry_.RecordModelUsage(usage); + RecordAdditionalModelUsage(request, response, context, usage.total_time_ms, streaming); + } catch (...) { + // Telemetry is best-effort and must not turn successful inference into an API failure. + } +} + +void Session::Cancel() { + std::lock_guard lock(active_requests_mutex_); + session_canceled_ = true; + for (const Request* request : active_requests_) { + request->canceled.store(true, std::memory_order_relaxed); + } } } // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/session/session.h b/sdk_v2/cpp/src/inferencing/session/session.h index f3ec0e5f1..5358e8b73 100644 --- a/sdk_v2/cpp/src/inferencing/session/session.h +++ b/sdk_v2/cpp/src/inferencing/session/session.h @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include #include #include @@ -15,6 +17,7 @@ #include "inferencing/session/request.h" #include "inferencing/session/response.h" #include "inferencing/session/types.h" +#include "telemetry/invocation_context.h" #include "util/key_value_pairs.h" namespace fl { @@ -34,7 +37,7 @@ class Session { public: virtual ~Session(); - Session(Session&&) = default; + Session(Session&& other) noexcept; Session& operator=(Session&&) = delete; Session(const Session&) = delete; @@ -52,6 +55,9 @@ class Session { /// in-flight callbacks and ensures the Response is fully populated on return. void ProcessRequest(const Request& request, Response& response); + /// Signal all active requests in this session to stop. + void Cancel(); + /// Add a tool definition to this session. /// @throws fl::Exception if tool_def.json_schema is not valid JSON. void AddToolDefinition(ToolDefinition tool_def); @@ -100,6 +106,16 @@ class Session { callback_user_data_ = user_data; } + /// Telemetry context for the next ProcessRequest. HTTP handlers set this to an + /// indirect child of the route's context so the kSessionProcessRequest action + /// and the per-inference Model event are marked indirect and share the route's + /// correlation id. When unset (direct SDK use), ProcessRequest mints its own + /// direct context per call. + void SetRequestContext(InvocationContext context) { + std::lock_guard lock(request_context_mutex_); + request_context_ = std::move(context); + } + protected: Session(const fl::Model& catalog_model, ILogger& logger, ITelemetry& telemetry, bool allow_concurrent_requests = false); @@ -131,6 +147,17 @@ class Session { /// Requests are serialized if the derived class does not opt into concurrency via allow_concurrent_requests_. virtual void ProcessRequestImpl(const Request& request, Response& response) = 0; + /// Execution provider the loaded model runs on (e.g. "CPU", "CUDA"). Surfaced + /// on the per-inference Model telemetry event. Empty when not known. + virtual std::string ExecutionProvider() const { return {}; } + + /// Derived sessions can emit modality-specific inference telemetry after the generic Model event. + virtual void RecordAdditionalModelUsage(const Request& /*request*/, const Response& /*response*/, + const InvocationContext& /*context*/, int64_t /*total_time_ms*/, + bool /*streaming*/) {} + + ITelemetry& Telemetry() { return telemetry_; } + /// Create a per-request callback handler. Returns nullptr if no callback is set. /// The handler is owned by the caller (unique_ptr) and drains+joins on destruction. std::unique_ptr CreateCallbackHandler(const Request& request) { @@ -151,8 +178,13 @@ class Session { KeyValuePairs session_options_; StreamingCallbackFn callback_fn_; void* callback_user_data_ = nullptr; + mutable std::mutex request_context_mutex_; + std::optional request_context_; const bool allow_concurrent_requests_; mutable std::unique_ptr request_mutex_ = std::make_unique(); + mutable std::mutex active_requests_mutex_; + bool session_canceled_ = false; + std::unordered_set active_requests_; }; } // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/session/session_manager.cc b/sdk_v2/cpp/src/inferencing/session/session_manager.cc index a48e81bb6..e1d7e368e 100644 --- a/sdk_v2/cpp/src/inferencing/session/session_manager.cc +++ b/sdk_v2/cpp/src/inferencing/session/session_manager.cc @@ -22,19 +22,19 @@ SessionManager::~SessionManager() { } void SessionManager::Register(Session& session) { - if (shutting_down_.load()) { + std::lock_guard lock(mutex_); + if (shutting_down_) { FL_THROW(FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "cannot create session during shutdown"); } - std::lock_guard lock(mutex_); - sessions_.insert(&session); + ++sessions_[&session]; } void SessionManager::Deregister(Session& session) { std::lock_guard lock(mutex_); - auto erased = sessions_.erase(&session); + auto it = sessions_.find(&session); - if (erased == 0) { + if (it == sessions_.end()) { // Bug: session was not registered. Log loudly but don't throw — this may be // called from a destructor where throwing would call std::terminate(). logger_.Log(LogLevel::Error, "SessionManager::Deregister called for unregistered session"); @@ -42,22 +42,29 @@ void SessionManager::Deregister(Session& session) { return; } + if (--it->second == 0) { + sessions_.erase(it); + } + if (sessions_.empty()) { drain_cv_.notify_all(); } } void SessionManager::CancelAll() { - shutting_down_.store(true); + std::vector> cached_sessions; - // Clear cache — frees idle cached sessions so they don't block drain. - ClearCache(); - - std::lock_guard lock(mutex_); - logger_.Log(LogLevel::Information, - fmt::format("SessionManager: cancelling all sessions ({} active)", sessions_.size())); + { + std::lock_guard lock(mutex_); + shutting_down_ = true; + cached_sessions = MoveCacheToDestroyLocked(); + logger_.Log(LogLevel::Information, + fmt::format("SessionManager: cancelling all sessions ({} active)", sessions_.size())); - // Future (Phase 3): iterate sessions_ and call Cancel() on each + for (const auto& session_entry : sessions_) { + session_entry.first->Cancel(); + } + } } void SessionManager::WaitForDrain(std::chrono::milliseconds timeout) { @@ -93,9 +100,7 @@ std::unique_ptr SessionManager::CheckOut(const std::string& key) { return nullptr; } - auto session = std::move(it->second.session); - lru_order_.erase(it->second.lru_iter); - cache_.erase(it); + auto session = RemoveCachedLocked(it); logger_.Log(LogLevel::Debug, fmt::format("SessionManager: checked out cached session for '{}'", key)); return session; @@ -107,25 +112,22 @@ void SessionManager::CheckIn(const std::string& key, std::unique_ptr lock(mutex_); + if (shutting_down_ || cache_capacity_ == 0) { + evicted.push_back(std::move(session)); + return; + } - // Replace existing entry for this key (if any) auto existing = cache_.find(key); if (existing != cache_.end()) { - evicted.push_back(std::move(existing->second.session)); - lru_order_.erase(existing->second.lru_iter); - cache_.erase(existing); + evicted.push_back(RemoveCachedLocked(existing)); } - // Evict LRU if at capacity while (cache_.size() >= cache_capacity_) { const auto& lru_key = lru_order_.back(); auto lru_it = cache_.find(lru_key); - evicted.push_back(std::move(lru_it->second.session)); - cache_.erase(lru_it); - lru_order_.pop_back(); + evicted.push_back(RemoveCachedLocked(lru_it)); } - // Insert new entry lru_order_.push_front(key); cache_[key] = CacheEntry{std::move(session), lru_order_.begin()}; @@ -157,30 +159,39 @@ bool SessionManager::EvictCached(const std::string& key) { return false; } - evicted = std::move(it->second.session); - lru_order_.erase(it->second.lru_iter); - cache_.erase(it); + evicted = RemoveCachedLocked(it); } logger_.Log(LogLevel::Debug, fmt::format("SessionManager: evicted cached session for '{}'", key)); return true; } +std::unique_ptr SessionManager::RemoveCachedLocked(CacheMap::iterator it) { + auto session = std::move(it->second.session); + lru_order_.erase(it->second.lru_iter); + cache_.erase(it); + return session; +} + +std::vector> SessionManager::MoveCacheToDestroyLocked() { + std::vector> to_destroy; + + for (auto& cache_entry : cache_) { + to_destroy.push_back(std::move(cache_entry.second.session)); + } + + cache_.clear(); + lru_order_.clear(); + return to_destroy; +} + void SessionManager::ClearCache() { std::vector> to_destroy; { std::lock_guard lock(mutex_); - - for (auto& [key, entry] : cache_) { - to_destroy.push_back(std::move(entry.session)); - } - - cache_.clear(); - lru_order_.clear(); + to_destroy = MoveCacheToDestroyLocked(); } - - // Destroy outside lock } } // namespace fl diff --git a/sdk_v2/cpp/src/inferencing/session/session_manager.h b/sdk_v2/cpp/src/inferencing/session/session_manager.h index 121a24323..c0384bed7 100644 --- a/sdk_v2/cpp/src/inferencing/session/session_manager.h +++ b/sdk_v2/cpp/src/inferencing/session/session_manager.h @@ -4,7 +4,6 @@ #include "logger.h" -#include #include #include #include @@ -12,7 +11,7 @@ #include #include #include -#include +#include namespace fl { @@ -40,8 +39,8 @@ class ISessionManager { /// - CheckIn(key, session) inserts the session into the cache under the given key. /// May evict the least-recently-used entry if at capacity. /// -/// Cached sessions are NOT registered — they are idle. Only sessions actively -/// processing requests should be registered via SessionRegistration. +/// Cached sessions are NOT registered — they are idle. Only sessions actively processing requests should be registered +/// via SessionRegistration. class SessionManager : public ISessionManager { public: static constexpr size_t kDefaultCacheCapacity = 5; @@ -67,18 +66,17 @@ class SessionManager : public ISessionManager { /// Block until all sessions have deregistered, with timeout. void WaitForDrain(std::chrono::milliseconds timeout = std::chrono::seconds(10)); - /// Number of currently tracked sessions (active + cached). + /// Number of currently active sessions. size_t ActiveCount() const; // --- Session cache (Responses API) --- /// Remove a session from the cache by key and return it. - /// Returns nullptr on cache miss. The session is still tracked (registered). + /// Returns nullptr on cache miss. std::unique_ptr CheckOut(const std::string& key); /// Insert a session into the cache under the given key. /// May evict the least-recently-used entry if at capacity. - /// The session remains tracked (registered) while cached. void CheckIn(const std::string& key, std::unique_ptr session); /// Remove and destroy a cached session by key. Returns true if an entry was removed. @@ -96,21 +94,22 @@ class SessionManager : public ISessionManager { std::unique_ptr session; std::list::iterator lru_iter; }; + using CacheMap = std::unordered_map; - /// Clear all cache entries. Acquires mutex_ to move entries out, then destroys - /// them outside the lock (destroyed sessions call Deregister, which acquires mutex_). + std::unique_ptr RemoveCachedLocked(CacheMap::iterator it); + std::vector> MoveCacheToDestroyLocked(); void ClearCache(); ILogger& logger_; size_t cache_capacity_; - std::atomic shutting_down_{false}; + bool shutting_down_ = false; mutable std::mutex mutex_; std::condition_variable drain_cv_; - std::unordered_set sessions_; + std::unordered_map sessions_; // LRU cache: front of lru_order_ = most recently used std::list lru_order_; - std::unordered_map cache_; + CacheMap cache_; }; } // namespace fl diff --git a/sdk_v2/cpp/src/manager.cc b/sdk_v2/cpp/src/manager.cc index 23c6ccb3f..11a265c0e 100644 --- a/sdk_v2/cpp/src/manager.cc +++ b/sdk_v2/cpp/src/manager.cc @@ -7,9 +7,23 @@ #include #include +#include #include +#include #include +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#endif + #include "catalog.h" #include "catalog/azure_model_catalog.h" #include "download/download_manager.h" @@ -23,7 +37,11 @@ #include "inferencing/session/session_manager.h" #include "spdlog_logger.h" #include "telemetry/telemetry_action_tracker.h" +#include "telemetry/telemetry_environment.h" +#include "telemetry/invocation_context.h" #include "telemetry/telemetry_logger.h" +#include "telemetry/telemetry_metadata.h" +#include "telemetry/one_ds_telemetry.h" #include "util/string_utils.h" #include "utils.h" @@ -65,6 +83,51 @@ bool IsAdditionalOptionEnabled(const Configuration& config, const std::string& o return it != config.additional_options.cend() && IsTruthyConfigValue(it->second); } +std::string GetAdditionalOption(const Configuration& config, const std::string& option_name) { + const auto it = config.additional_options.find(option_name); + return it == config.additional_options.cend() ? std::string{} : it->second; +} + +std::string JoinTelemetryValues(const std::set& values) { + std::ostringstream joined; + bool first = true; + for (const auto& value : values) { + if (!first) { + joined << ","; + } + first = false; + joined << value; + } + return joined.str(); +} + +HardwareInfo BuildHardwareInfo(const std::map>& devices_to_eps) { + HardwareInfo info; + std::set device_types; + std::set execution_providers; + + for (const auto& [device_type, providers] : devices_to_eps) { + device_types.insert(device_type); + if (device_type == "CPU") { + info.has_cpu = true; + } else if (device_type == "GPU") { + info.has_gpu = true; + } else if (device_type == "NPU") { + info.has_npu = true; + } + + for (const auto& provider : providers) { + execution_providers.insert(provider); + } + } + + info.device_type_count = static_cast(device_types.size()); + info.execution_provider_count = static_cast(execution_providers.size()); + info.device_types = JoinTelemetryValues(device_types); + info.execution_providers = JoinTelemetryValues(execution_providers); + return info; +} + OrtLoggingLevel GetDefaultOrtLoggingLevel(bool genai_verbose_logging_enabled) { // If someone explicitly enables ORTGENAI_ORT_VERBOSE_LOGGING, treat this as // a debug scenario and default ORT logging to verbose as well. @@ -171,6 +234,29 @@ void SetOgaLogCallback(ILogger* logger) { } } +using OgaSetTelemetryEnabledFn = void(OGA_API_CALL*)(bool enabled); + +OgaSetTelemetryEnabledFn ResolveOgaSetTelemetryEnabled() { +#ifdef _WIN32 + HMODULE genai = ::GetModuleHandleW(L"onnxruntime-genai.dll"); + if (genai == nullptr) { + return nullptr; + } + return reinterpret_cast(::GetProcAddress(genai, "OgaSetTelemetryEnabled")); +#else + return reinterpret_cast(dlsym(RTLD_DEFAULT, "OgaSetTelemetryEnabled")); +#endif +} + +void DisableOgaTelemetryIfAvailable() noexcept { + try { + if (auto* set_telemetry_enabled = ResolveOgaSetTelemetryEnabled(); set_telemetry_enabled != nullptr) { + set_telemetry_enabled(false); + } + } catch (...) { + } +} + } // namespace std::mutex Manager::s_mutex_; @@ -179,6 +265,9 @@ std::unique_ptr Manager::s_instance_; Manager::Manager(const Configuration& config) : config_(config) { config_.Validate(); + config_.disable_nonessential_telemetry = + config_.disable_nonessential_telemetry || IsAdditionalOptionEnabled(config_, "DisableNonessentialTelemetry"); + SetDefaultUserAgent(GetAdditionalOption(config_, "UserAgent")); const bool genai_verbose_logging = IsGenAIVerboseLoggingEnabled(); const auto logger_level = genai_verbose_logging ? LogLevel::Verbose : config_.log_level; @@ -216,6 +305,18 @@ Manager::Manager(const Configuration& config) } } + if (config_.disable_nonessential_telemetry) { + OrtStatus* status = ort_api_->DisableTelemetryEvents(ort_env_); + if (status != nullptr) { + const char* msg = ort_api_->GetErrorMessage(status); + logger_->Log(LogLevel::Warning, + std::string("Failed to disable ONNX Runtime telemetry: ") + (msg ? msg : "unknown")); + ort_api_->ReleaseStatus(status); + } + + DisableOgaTelemetryIfAvailable(); + } + LogRuntimeVersions(*logger_); EpRegistrationCallback register_ep = [this, &log = *logger_]( @@ -295,7 +396,28 @@ Manager::Manager(const Configuration& config) const auto webgpu_ep_dir = cache_dir / "webgpu-ep"; bootstrappers.push_back(std::make_unique(webgpu_ep_dir.string(), register_ep)); - ep_detector_ = std::make_unique(*ort_api_, *ort_env_, std::move(bootstrappers), *logger_); + telemetry_ = std::make_unique(config_.app_name, *logger_, config_.disable_nonessential_telemetry); + + const bool is_ci_environment = TelemetryEnvironment::IsCiEnvironment(); + if (!is_ci_environment) { + try { + telemetry_->RecordProcessInfo(BuildProcessInfo(BuildTelemetryMetadata(config_.app_name), + !config_.disable_nonessential_telemetry)); + } catch (...) { + // Telemetry is best-effort and must not block Manager startup. + } + } + + ep_detector_ = std::make_unique(*ort_api_, *ort_env_, std::move(bootstrappers), *logger_, + *telemetry_); + + if (!config_.disable_nonessential_telemetry && !is_ci_environment) { + try { + telemetry_->RecordHardwareInfo(BuildHardwareInfo(ep_detector_->GetAvailableDevicesToEPs())); + } catch (...) { + // Telemetry is best-effort and must not block Manager startup. + } + } // Read configurable download concurrency (default 64) int download_concurrency = 64; @@ -320,10 +442,10 @@ Manager::Manager(const Configuration& config) config_.catalog_region.value_or("auto"), download_concurrency, *logger_, + *telemetry_, disable_region_fallback); model_load_manager_ = std::make_unique(*ep_detector_, *logger_); session_manager_ = std::make_unique(*logger_); - telemetry_ = std::make_unique(config_.app_name, *logger_); catalog_ = std::make_unique( config_.catalog_urls, download_manager_->GetCacheDirectory(), @@ -333,7 +455,8 @@ Manager::Manager(const Configuration& config) *ep_detector_, *logger_, config_.external_service_url.has_value(), config_.catalog_region.value_or("auto"), - disable_region_fallback); + disable_region_fallback, + *telemetry_); } Manager::~Manager() { @@ -348,6 +471,13 @@ Manager::~Manager() { logger_->Log(LogLevel::Error, "Unknown exception while shutting down Manager subsystems during destruction."); } + { + std::lock_guard lock(shutdown_worker_mutex_); + if (shutdown_worker_.joinable() && shutdown_worker_.get_id() != std::this_thread::get_id()) { + shutdown_worker_.join(); + } + } + // Tear down members that hold OrtEnv references / live ORT sessions before // we unregister EPs and release the env. C++ would destroy these in reverse // declaration order after this function returns, but the env release below @@ -359,8 +489,9 @@ Manager::~Manager() { model_load_manager_.reset(); download_manager_.reset(); catalog_.reset(); - telemetry_.reset(); + // ep_detector_ holds an ITelemetry& — destroy it before telemetry_. ep_detector_.reset(); + telemetry_.reset(); // Unregister EPs we registered, then drop our OrtEnv refcount. Best-effort: // log failures but don't throw from a destructor. @@ -409,7 +540,8 @@ Manager& Manager::Create(const Configuration& config) { // state: catch and log, then proceed. The Manager itself is fully constructed at this // point — only the post-construction signaling can fail, and it's not load-bearing. try { - created->telemetry_->RecordAction(Action::kCoreInitialize, ActionStatus::kSuccess, "", false, 0); + created->telemetry_->RecordAction(Action::kCoreInitialize, ActionStatus::kSuccess, + InvocationContext::Direct(), 0); } catch (const std::exception& ex) { created->GetLogger().Log(LogLevel::Error, fmt::format("telemetry RecordAction failed during Create: {}", ex.what())); @@ -442,7 +574,8 @@ ICatalog& Manager::GetCatalog() { } void Manager::StartWebService() { - if (web_service_running_) { + std::lock_guard lock(web_service_mutex_); + if (web_service_running_.load(std::memory_order_acquire)) { FL_LOG_AND_THROW(*logger_, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, "web service is already running"); } @@ -456,15 +589,34 @@ void Manager::StartWebService() { #ifdef FOUNDRY_LOCAL_HAS_WEB_SERVICE web_service_ = std::make_unique(*catalog_, *logger_, *config_.model_cache_dir, *model_load_manager_, *session_manager_, *telemetry_, - [this]() { Shutdown(); }); + [this]() { RequestShutdownAsync(); }); auto endpoints = config_.web_service_endpoints; if (endpoints.empty()) { endpoints.push_back("http://127.0.0.1:0"); } - bound_urls_ = web_service_->Start(endpoints); - web_service_running_ = true; + // Open an app-usage session for the lifetime of the running service so events + // carry ext.app.sesId and the backend gets session duration. + try { + telemetry_->StartSession(); + } catch (const std::exception& ex) { + logger_->Log(LogLevel::Warning, std::string("telemetry StartSession failed: ") + ex.what()); + } catch (...) { + logger_->Log(LogLevel::Warning, "telemetry StartSession failed with unknown error"); + } + try { + bound_urls_ = web_service_->Start(endpoints); + web_service_running_.store(true, std::memory_order_release); + } catch (...) { + try { + telemetry_->EndSession(); + } catch (...) { + } + web_service_.reset(); + bound_urls_.clear(); + throw; + } tracker.SetStatus(ActionStatus::kSuccess); #else FL_LOG_AND_THROW(*logger_, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, @@ -472,28 +624,37 @@ void Manager::StartWebService() { #endif } -const std::vector& Manager::GetWebServiceUrls() const { +std::vector Manager::GetWebServiceUrls() const { // No "not running" check: bound_urls_ is cleared in StopWebService() and is empty before // StartWebService(), so the empty vector is the documented "service is not running" signal // (see GetWebServiceEndpoints() docstring in foundry_local_cpp.h). + std::lock_guard lock(web_service_mutex_); return bound_urls_; } void Manager::StopWebService() { - if (!web_service_running_) { - // No-op rather than throw: the public-API contract treats StopWebService() as idempotent so - // callers can shut down unconditionally without first probing service state. - logger_->Log(LogLevel::Information, "StopWebService called but web service is not running; ignoring"); - return; +#ifdef FOUNDRY_LOCAL_HAS_WEB_SERVICE + std::unique_ptr service; + { + std::lock_guard lock(web_service_mutex_); + if (!web_service_running_.load(std::memory_order_acquire)) { + logger_->Log(LogLevel::Information, "StopWebService called but web service is not running; ignoring"); + return; + } + service = std::move(web_service_); + web_service_running_.store(false, std::memory_order_release); + bound_urls_.clear(); } ActionTracker tracker(Action::kCoreServiceStop, *telemetry_); - -#ifdef FOUNDRY_LOCAL_HAS_WEB_SERVICE - web_service_->Stop(); - web_service_.reset(); - web_service_running_ = false; - bound_urls_.clear(); + service->Stop(); + try { + telemetry_->EndSession(); + } catch (const std::exception& ex) { + logger_->Log(LogLevel::Warning, std::string("telemetry EndSession failed: ") + ex.what()); + } catch (...) { + logger_->Log(LogLevel::Warning, "telemetry EndSession failed with unknown error"); + } tracker.SetStatus(ActionStatus::kSuccess); #else FL_LOG_AND_THROW(*logger_, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, @@ -509,10 +670,6 @@ void Manager::Shutdown() { logger_->Log(LogLevel::Information, "Shutdown requested"); - if (web_service_running_) { - StopWebService(); - } - // Order matters: // 1. Reject new loads so callers gated on IsShutdownRequested can stop early. // 2. Cancel + drain HTTP-tracked sessions (web service path). @@ -522,9 +679,20 @@ void Manager::Shutdown() { model_load_manager_->RejectNewLoads(); session_manager_->CancelAll(); session_manager_->WaitForDrain(); + if (web_service_running_.load(std::memory_order_acquire)) { + StopWebService(); + } model_load_manager_->UnloadAll(); } +void Manager::RequestShutdownAsync() { + std::lock_guard lock(shutdown_worker_mutex_); + if (shutdown_worker_.joinable()) { + return; + } + shutdown_worker_ = std::thread([this]() { Shutdown(); }); +} + bool Manager::IsShutdownRequested() const { return shutdown_requested_.load(); } diff --git a/sdk_v2/cpp/src/manager.h b/sdk_v2/cpp/src/manager.h index 4b5440db7..59afcf4b0 100644 --- a/sdk_v2/cpp/src/manager.h +++ b/sdk_v2/cpp/src/manager.h @@ -10,6 +10,7 @@ #include #include #include +#include #include struct OrtApi; @@ -81,16 +82,18 @@ class Manager { /// Get the bound service URLs. The returned reference is valid as long as /// the web service is running. Throws if web service is not running. - const std::vector& GetWebServiceUrls() const; + std::vector GetWebServiceUrls() const; /// Stop the embedded web service. void StopWebService(); - /// Begin graceful shutdown. Sets the shutdown flag and signals subsystems to drain. - /// Idempotent and non-blocking — safe to call from any thread including web service handlers. + /// Begin graceful shutdown. Idempotent and safe to call from any thread. /// Destroy() calls this internally, so direct SDK users don't need to call it explicitly. void Shutdown(); + /// Request shutdown from a web-service handler without running teardown on the handler thread. + void RequestShutdownAsync(); + /// Check if Shutdown() has been called (from any source — web endpoint, signal, user code). bool IsShutdownRequested() const; @@ -120,10 +123,14 @@ class Manager { // released manually in ~Manager() after all // consumers (sessions, ep_detector_) are gone. // logger_ — everything logs through this, destroyed last + // telemetry_ — used by ep_detector_ and throughout; must + // outlive ep_detector_ because EpDetector holds + // an ITelemetry& for emitting EP events // ep_detector_ — detects HW acceleration; holds OrtEnv& (must - // outlive ort_env_ release in ~Manager()) - // telemetry_ — used throughout - // catalog_ — owns all Model instances. used by download_manager, model_load_manager, and web service + // outlive ort_env_ release in ~Manager()) and + // an ITelemetry& + // catalog_ — owns all Model instances. used by download_manager, model_load_manager, + // and web service // download_manager_ — uses ModelInfo owned by catalog // model_load_manager_ — holds loaded model state referencing catalog models // session_manager_ — tracks all active sessions. destroyed after web service, before models @@ -135,15 +142,18 @@ class Manager { OrtEnv* ort_env_ = nullptr; std::vector registered_ep_libraries_; std::unique_ptr logger_; - std::unique_ptr ep_detector_; std::unique_ptr telemetry_; + std::unique_ptr ep_detector_; std::unique_ptr catalog_; std::unique_ptr download_manager_; std::unique_ptr model_load_manager_; std::unique_ptr session_manager_; std::atomic shutdown_requested_{false}; std::atomic web_service_running_{false}; + mutable std::mutex web_service_mutex_; std::vector bound_urls_; + std::mutex shutdown_worker_mutex_; + std::thread shutdown_worker_; #ifdef FOUNDRY_LOCAL_HAS_WEB_SERVICE std::unique_ptr web_service_; diff --git a/sdk_v2/cpp/src/service/audio_transcriptions_handler.cc b/sdk_v2/cpp/src/service/audio_transcriptions_handler.cc index e7b2ac642..a084a016f 100644 --- a/sdk_v2/cpp/src/service/audio_transcriptions_handler.cc +++ b/sdk_v2/cpp/src/service/audio_transcriptions_handler.cc @@ -87,16 +87,19 @@ void AudioTranscriptionsHandler::BuildOpenAIJsonRequest(const std::string& body, std::shared_ptr AudioTranscriptionsHandler::handle( const std::shared_ptr& request) { - ActionTracker tracker(Action::kOpenAIAudioTranscribe, ctx_.telemetry); + auto route_ctx = InvocationContext::Direct(GetUserAgent(request)); + auto tracker = std::make_unique(Action::kOpenAIAudioTranscribe, ctx_.telemetry, route_ctx); auto body_str = request->readBodyToString(); if (!body_str || body_str->empty()) { + tracker->SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Empty request body"); } // 1. Parse & validate AudioTranscriptionRequest req; if (auto err = ParseAndValidateRequest(body_str->c_str(), req)) { + tracker->SetStatus(ActionStatus::kClientError); return err; } @@ -108,9 +111,11 @@ std::shared_ptr AudioTranscriptionsHandler // 2. Validate file path try { if (!std::filesystem::exists(req.filename)) { + tracker->SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Audio file not found", "'" + req.filename + "'"); } } catch (const std::filesystem::filesystem_error& ex) { + tracker->SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Invalid file path", ex.what()); } @@ -119,30 +124,44 @@ std::shared_ptr AudioTranscriptionsHandler Model* model = nullptr; GenAIModelInstance* loaded = nullptr; if (auto err = ResolveModel(model_name, model, loaded)) { + tracker->SetStatus(ActionStatus::kClientError); return err; } - tracker.SetModelId(model_name); + tracker->SetModelId(model_name); // 4. Build an OPENAI_JSON-tagged TEXT request item — pass original body directly Request session_request; BuildOpenAIJsonRequest(body_str->c_str(), session_request); + // The session and the inference it drives are indirect children of this route. + auto session_ctx = route_ctx.AsIndirect(); + // 5. Dispatch to streaming or non-streaming try { - AudioSession session(*model, *loaded, ctx_.logger, ctx_.telemetry); + std::unique_ptr session; + { + ActionTracker create_tracker(Action::kSessionCreate, ctx_.telemetry, session_ctx); + create_tracker.SetModelId(model_name); + session = std::make_unique(*model, *loaded, ctx_.logger, ctx_.telemetry); + create_tracker.SetStatus(ActionStatus::kSuccess); + } + AudioSession& session_ref = *session; + session_ref.SetRequestContext(session_ctx); if (stream) { - tracker.SetStatus(ActionStatus::kSuccess); - return HandleStreaming(std::move(session), std::move(session_request)); + // The route action is recorded by the streaming thread on completion. + return HandleStreaming(std::move(*session), std::move(session_request), std::move(tracker)); } else { - SessionRegistration reg(ctx_.session_manager, session); - auto response = HandleNonStreaming(session, session_request); - tracker.SetStatus(ActionStatus::kSuccess); + SessionRegistration reg(ctx_.session_manager, session_ref); + auto response = HandleNonStreaming(session_ref, session_request); + tracker->SetStatus(ResponseToActionStatus(response)); return response; } } catch (const std::exception& ex) { - tracker.RecordException(ex); + if (tracker) { + tracker->RecordException(ex); + } ctx_.logger.Log(LogLevel::Error, fmt::format("Audio transcription inference failed: {}", ex.what())); return ErrorResponse(Status::CODE_500, "Inference failed", ex.what()); } catch (...) { @@ -177,18 +196,18 @@ std::shared_ptr AudioTranscriptionsHandler } std::shared_ptr AudioTranscriptionsHandler::HandleStreaming( - AudioSession&& session, Request session_request) { + AudioSession&& session, Request session_request, std::unique_ptr route_tracker) { auto body = std::make_shared(); auto body_ptr = body; auto& logger = ctx_.logger; - auto& tracker = ctx_.thread_tracker; + auto& thread_tracker = ctx_.thread_tracker; std::thread streaming_thread([bg_session = std::move(session), body_ptr, &logger, - req = std::move(session_request), &tracker, + req = std::move(session_request), &thread_tracker, + route_tracker = std::move(route_tracker), &session_manager = ctx_.session_manager]() mutable { - SessionRegistration reg(session_manager, bg_session); - try { + SessionRegistration reg(session_manager, bg_session); fl::Response bg_response; // Callback receives OPENAI_JSON-tagged TextItem chunks from AudioSession — just wrap in SSE framing. @@ -201,7 +220,10 @@ std::shared_ptr AudioTranscriptionsHandler if (item->type == FOUNDRY_LOCAL_ITEM_TEXT) { auto& text_item = static_cast(*item); - body_ptr->Push("data: " + text_item.text + "\n\n"); + if (!body_ptr->Push("data: " + text_item.text + "\n\n")) { + req.canceled.store(true, std::memory_order_relaxed); + return 1; + } } else { logger.Log(LogLevel::Error, fmt::format("Unexpected item type {} in audio streaming callback", @@ -214,21 +236,30 @@ std::shared_ptr AudioTranscriptionsHandler bg_session.SetStreamingCallback(callback_fn); bg_session.ProcessRequest(req, bg_response); - // Send terminal event body_ptr->Push("data: [DONE]\n\n"); + + if (route_tracker) { + route_tracker->SetStatus(req.canceled.load(std::memory_order_relaxed) ? ActionStatus::kCanceled + : ActionStatus::kSuccess); + } } catch (const std::exception& ex) { logger.Log(LogLevel::Error, fmt::format("Audio streaming transcription failed: {}", ex.what())); // Push error to stream so client doesn't hang nlohmann::json error = {{"error", {{"message", ex.what()}}}}; body_ptr->Push("data: " + error.dump() + "\n\n"); + + if (route_tracker) { + route_tracker->RecordException(ex); + } } body_ptr->Finish(); - tracker.Remove(std::this_thread::get_id()); + route_tracker.reset(); + thread_tracker.Remove(std::this_thread::get_id()); }); - tracker.Track(std::move(streaming_thread)); + thread_tracker.Track(std::move(streaming_thread)); auto response = oatpp::web::protocol::http::outgoing::Response::createShared(Status::CODE_200, body); response->putHeader("Content-Type", "text/event-stream"); diff --git a/sdk_v2/cpp/src/service/audio_transcriptions_handler.h b/sdk_v2/cpp/src/service/audio_transcriptions_handler.h index 5dffeaa90..1f68c058c 100644 --- a/sdk_v2/cpp/src/service/audio_transcriptions_handler.h +++ b/sdk_v2/cpp/src/service/audio_transcriptions_handler.h @@ -16,6 +16,7 @@ struct AudioTranscriptionRequest; class AudioSession; class Model; class GenAIModelInstance; +class ActionTracker; struct Request; // ======================================================================== @@ -43,8 +44,11 @@ class AudioTranscriptionsHandler : public HttpRequestHandler { /// Non-streaming inference — processes request, extracts the OPENAI_JSON-tagged TEXT response. std::shared_ptr HandleNonStreaming(AudioSession& session, Request& session_request); - /// Streaming inference — runs inference in background thread with SSE output. - std::shared_ptr HandleStreaming(AudioSession&& session, Request session_request); + /// Streaming inference — runs inference in a background thread with SSE output. + /// Takes ownership of the route ActionTracker so the route action records when + /// the stream finishes (real duration + terminal status). + std::shared_ptr HandleStreaming(AudioSession&& session, Request session_request, + std::unique_ptr route_tracker); ServiceContext& ctx_; }; diff --git a/sdk_v2/cpp/src/service/chat_completions_handler.cc b/sdk_v2/cpp/src/service/chat_completions_handler.cc index fcf13fb29..a351eaf7f 100644 --- a/sdk_v2/cpp/src/service/chat_completions_handler.cc +++ b/sdk_v2/cpp/src/service/chat_completions_handler.cc @@ -104,10 +104,12 @@ void ChatCompletionsHandler::BuildOpenAIJsonRequest(const std::string& body, con std::shared_ptr ChatCompletionsHandler::handle( const std::shared_ptr& request) { - ActionTracker tracker(Action::kOpenAIChatCompletions, ctx_.telemetry); + auto route_ctx = InvocationContext::Direct(GetUserAgent(request)); + auto tracker = std::make_unique(Action::kOpenAIChatCompletions, ctx_.telemetry, route_ctx); auto body_str = request->readBodyToString(); if (!body_str || body_str->empty()) { + tracker->SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Empty request body"); } @@ -117,6 +119,7 @@ std::shared_ptr ChatCompletionsHandler::ha // telemetry. How much do we care about that? Is it worth the double parsing? ChatCompletionRequest req; if (auto err = ParseAndValidateRequest(body_str->c_str(), req)) { + tracker->SetStatus(ActionStatus::kClientError); return err; } @@ -130,10 +133,11 @@ std::shared_ptr ChatCompletionsHandler::ha Model* model = nullptr; GenAIModelInstance* loaded = nullptr; if (auto err = ResolveModel(model_name, model, loaded)) { + tracker->SetStatus(ActionStatus::kClientError); return err; } - tracker.SetModelId(model_name); + tracker->SetModelId(model_name); // 3. Build an OPENAI_JSON-tagged TEXT request item. Request session_request; @@ -145,21 +149,37 @@ std::shared_ptr ChatCompletionsHandler::ha include_usage_in_stream = req.stream_options->include_usage; } + // The session and the inference it drives happen as a consequence of this + // route, so they are indirect and reuse the route's correlation id. + auto session_ctx = route_ctx.AsIndirect(); + // 6. Run inference via ChatSession try { - ChatSession session(*model, *loaded, ctx_.logger, ctx_.telemetry); + std::unique_ptr session; + { + ActionTracker create_tracker(Action::kSessionCreate, ctx_.telemetry, session_ctx); + create_tracker.SetModelId(model_name); + session = std::make_unique(*model, *loaded, ctx_.logger, ctx_.telemetry); + create_tracker.SetStatus(ActionStatus::kSuccess); + } + ChatSession& session_ref = *session; + session_ref.SetRequestContext(session_ctx); if (stream) { - tracker.SetStatus(ActionStatus::kSuccess); - return HandleStreaming(std::move(session), std::move(session_request), include_usage_in_stream); + // The route action is recorded by the streaming thread when the stream + // finishes, so don't set its status here — move the tracker into the thread. + return HandleStreaming(std::move(*session), std::move(session_request), include_usage_in_stream, + std::move(tracker)); } else { - SessionRegistration reg(ctx_.session_manager, session); - auto response = HandleNonStreaming(session, session_request); - tracker.SetStatus(ActionStatus::kSuccess); + SessionRegistration reg(ctx_.session_manager, session_ref); + auto response = HandleNonStreaming(session_ref, session_request); + tracker->SetStatus(ResponseToActionStatus(response)); return response; } } catch (const std::exception& ex) { - tracker.RecordException(ex); + if (tracker) { + tracker->RecordException(ex); + } ctx_.logger.Log(LogLevel::Error, fmt::format("Chat completion inference failed: {}", ex.what())); return ErrorResponse(Status::CODE_500, "Inference failed", ex.what()); } @@ -189,19 +209,20 @@ std::shared_ptr ChatCompletionsHandler::Ha } std::shared_ptr ChatCompletionsHandler::HandleStreaming( - ChatSession&& session, Request session_request, bool include_usage) { + ChatSession&& session, Request session_request, bool include_usage, + std::unique_ptr route_tracker) { auto body = std::make_shared(); auto body_ptr = body; auto& logger = ctx_.logger; - auto& tracker = ctx_.thread_tracker; + auto& thread_tracker = ctx_.thread_tracker; std::thread streaming_thread([bg_session = std::move(session), body_ptr, &logger, req = std::move(session_request), - include_usage, &tracker, + include_usage, &thread_tracker, + route_tracker = std::move(route_tracker), &session_manager = ctx_.session_manager]() mutable { - SessionRegistration reg(session_manager, bg_session); - try { + SessionRegistration reg(session_manager, bg_session); fl::Response bg_response; // Callback receives OPENAI_JSON-tagged TextItem chunks from ChatSession — just wrap in SSE framing. @@ -214,7 +235,10 @@ std::shared_ptr ChatCompletionsHandler::Ha if (item->type == FOUNDRY_LOCAL_ITEM_TEXT) { auto& text_item = static_cast(*item); - body_ptr->Push("data: " + text_item.text + "\n\n"); + if (!body_ptr->Push("data: " + text_item.text + "\n\n")) { + req.canceled.store(true, std::memory_order_relaxed); + return 1; + } } else { logger.Log(LogLevel::Error, fmt::format("Unexpected item type {} in chat streaming callback", static_cast(item->type))); @@ -244,22 +268,38 @@ std::shared_ptr ChatCompletionsHandler::Ha usage.total_tokens = static_cast(bg_response.usage.total_tokens); usage_chunk.usage = std::move(usage); - body_ptr->Push("data: " + nlohmann::json(usage_chunk).dump() + "\n\n"); + if (!body_ptr->Push("data: " + nlohmann::json(usage_chunk).dump() + "\n\n")) { + req.canceled.store(true, std::memory_order_relaxed); + } } body_ptr->Push("data: [DONE]\n\n"); + + // Record final route status after streaming completes. + if (route_tracker) { + route_tracker->SetStatus(req.canceled.load(std::memory_order_relaxed) ? ActionStatus::kCanceled + : ActionStatus::kSuccess); + } } catch (const std::exception& ex) { nlohmann::json err = { {"error", {{"message", ex.what()}, {"type", "server_error"}, {"param", nullptr}, {"code", nullptr}}}, }; body_ptr->Push("data: " + err.dump() + "\n\n"); + + // Mid-stream failure: record the exception; the route action keeps kFailure. + if (route_tracker) { + route_tracker->RecordException(ex); + } } body_ptr->Finish(); - tracker.Remove(std::this_thread::get_id()); + // route_tracker is destroyed with this closure once the thread completes, + // recording the route action with the full streaming duration and final status. + route_tracker.reset(); + thread_tracker.Remove(std::this_thread::get_id()); }); - tracker.Track(std::move(streaming_thread)); + thread_tracker.Track(std::move(streaming_thread)); auto response = oatpp::web::protocol::http::outgoing::Response::createShared( Status::CODE_200, body); diff --git a/sdk_v2/cpp/src/service/chat_completions_handler.h b/sdk_v2/cpp/src/service/chat_completions_handler.h index ed483c4d0..c7225bf77 100644 --- a/sdk_v2/cpp/src/service/chat_completions_handler.h +++ b/sdk_v2/cpp/src/service/chat_completions_handler.h @@ -16,6 +16,7 @@ struct ChatCompletionRequest; class ChatSession; class Model; class GenAIModelInstance; +class ActionTracker; struct Request; // ======================================================================== @@ -47,9 +48,13 @@ class ChatCompletionsHandler : public HttpRequestHandler { /// Non-streaming inference — processes request, extracts the OPENAI_JSON-tagged TEXT response. std::shared_ptr HandleNonStreaming(ChatSession& session, Request& session_request); - /// Streaming inference — runs inference in background thread with SSE output. + /// Streaming inference — runs inference in a background thread with SSE output. + /// Takes ownership of the route ActionTracker so the route action is recorded + /// when the stream actually finishes (real duration + terminal status), + /// instead of when this handler returns. std::shared_ptr HandleStreaming(ChatSession&& session, Request session_request, - bool include_usage); + bool include_usage, + std::unique_ptr route_tracker); ServiceContext& ctx_; }; diff --git a/sdk_v2/cpp/src/service/embeddings_handler.cc b/sdk_v2/cpp/src/service/embeddings_handler.cc index b5f734b07..b70c46f5c 100644 --- a/sdk_v2/cpp/src/service/embeddings_handler.cc +++ b/sdk_v2/cpp/src/service/embeddings_handler.cc @@ -28,10 +28,12 @@ class EmbeddingsHandler : public HttpRequestHandler { explicit EmbeddingsHandler(ServiceContext& ctx) : ctx_(ctx) {} std::shared_ptr handle(const std::shared_ptr& request) override { - ActionTracker tracker(Action::kOpenAIEmbeddings, ctx_.telemetry); + auto route_ctx = InvocationContext::Direct(GetUserAgent(request)); + ActionTracker tracker(Action::kOpenAIEmbeddings, ctx_.telemetry, route_ctx); auto body_str = request->readBodyToString(); if (!body_str || body_str->empty()) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Empty request body"); } @@ -41,6 +43,7 @@ class EmbeddingsHandler : public HttpRequestHandler { auto j = nlohmann::json::parse(*body_str); req = j.get(); } catch (const nlohmann::json::exception& ex) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Invalid JSON", ex.what()); } @@ -53,6 +56,7 @@ class EmbeddingsHandler : public HttpRequestHandler { } if (inputs.empty()) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "\"input\" must not be empty"); } @@ -60,20 +64,32 @@ class EmbeddingsHandler : public HttpRequestHandler { std::string model_name = req.model; auto* model = ctx_.catalog.GetModelVariant(model_name); if (!model) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_404, "Model not found", model_name); } auto* loaded = ctx_.model_load_manager.GetLoadedModel(model->Id()); if (!loaded) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Model not loaded", model_name); } tracker.SetModelId(model_name); + // The session and the inference it drives are indirect children of this route. + auto session_ctx = route_ctx.AsIndirect(); + // 4. Create session and process each input try { - EmbeddingsSession session(*model, *loaded, ctx_.logger, ctx_.telemetry); - SessionRegistration reg(ctx_.session_manager, session); + std::unique_ptr session; + { + ActionTracker create_tracker(Action::kSessionCreate, ctx_.telemetry, session_ctx); + create_tracker.SetModelId(model_name); + session = std::make_unique(*model, *loaded, ctx_.logger, ctx_.telemetry); + create_tracker.SetStatus(ActionStatus::kSuccess); + } + session->SetRequestContext(session_ctx); + SessionRegistration reg(ctx_.session_manager, *session); Request session_request; for (const auto& text : inputs) { @@ -81,7 +97,7 @@ class EmbeddingsHandler : public HttpRequestHandler { } fl::Response session_response; - session.ProcessRequest(session_request, session_response); + session->ProcessRequest(session_request, session_response); // 5. Build response EmbeddingCreateResponse output; diff --git a/sdk_v2/cpp/src/service/handler_utils.h b/sdk_v2/cpp/src/service/handler_utils.h index e25528fa3..b7f3a01d1 100644 --- a/sdk_v2/cpp/src/service/handler_utils.h +++ b/sdk_v2/cpp/src/service/handler_utils.h @@ -10,6 +10,8 @@ #include #include +#include "telemetry/telemetry.h" + #include #include #include @@ -52,6 +54,23 @@ inline std::shared_ptr ErrorResponse(const return JsonResponse(status, body); } +inline ActionStatus HttpStatusToActionStatus(const Status& status) { + if (status.code == 408 || status.code == 504) { + return ActionStatus::kTimeout; + } + if (status.code >= 500) { + return ActionStatus::kFailure; + } + if (status.code >= 400) { + return ActionStatus::kClientError; + } + return ActionStatus::kSuccess; +} + +inline ActionStatus ResponseToActionStatus(const std::shared_ptr& response) { + return response ? HttpStatusToActionStatus(response->getStatus()) : ActionStatus::kFailure; +} + /// Generate a random ID with the given prefix (e.g. "chatcmpl"). inline std::string GenerateCompletionId(const std::string& prefix) { static thread_local std::mt19937_64 rng(std::random_device{}()); @@ -62,6 +81,16 @@ inline std::string GenerateCompletionId(const std::string& prefix) { return ss.str(); } +/// Extract the User-Agent header from an incoming request ("" if absent), for +/// attribution on the telemetry events the request drives. +inline std::string GetUserAgent(const std::shared_ptr& request) { + if (!request) { + return {}; + } + auto ua = request->getHeader("User-Agent"); + return ua ? *ua : std::string{}; +} + // ======================================================================== // SSE stream body — feeds token-by-token SSE events to oatpp's chunked // transfer encoding. A producer thread pushes formatted SSE strings into @@ -73,10 +102,16 @@ class SseStreamBody : public oatpp::web::protocol::http::outgoing::Body { SseStreamBody() : done_(false) {} /// Push a formatted SSE event (e.g. "data: {...}\n\n") into the queue. - void Push(std::string chunk) { + bool Push(std::string chunk) { std::lock_guard lock(mutex_); + if (done_ || queue_.size() >= kMaxQueuedChunks) { + done_ = true; + cv_.notify_one(); + return false; + } queue_.push(std::move(chunk)); cv_.notify_one(); + return true; } /// Signal that no more data will be pushed. @@ -137,6 +172,7 @@ class SseStreamBody : public oatpp::web::protocol::http::outgoing::Body { std::condition_variable cv_; std::queue queue_; bool done_; + static constexpr size_t kMaxQueuedChunks = 1024; }; } // namespace fl diff --git a/sdk_v2/cpp/src/service/models_handlers.cc b/sdk_v2/cpp/src/service/models_handlers.cc index 93988d3b6..b2bb65f51 100644 --- a/sdk_v2/cpp/src/service/models_handlers.cc +++ b/sdk_v2/cpp/src/service/models_handlers.cc @@ -24,7 +24,10 @@ class ListLoadedModelsHandler : public HttpRequestHandler { public: explicit ListLoadedModelsHandler(ServiceContext& ctx) : ctx_(ctx) {} - std::shared_ptr handle(const std::shared_ptr&) override { + std::shared_ptr handle(const std::shared_ptr& request) override { + ActionTracker tracker(Action::kModelList, ctx_.telemetry, + InvocationContext::Direct(GetUserAgent(request))); + auto loaded = ctx_.catalog.GetLoadedModels(); nlohmann::json names = nlohmann::json::array(); @@ -32,6 +35,7 @@ class ListLoadedModelsHandler : public HttpRequestHandler { names.push_back(model->Id()); } + tracker.SetStatus(ActionStatus::kSuccess); return JsonResponse(Status::CODE_200, names); } @@ -48,10 +52,12 @@ class LoadModelHandler : public HttpRequestHandler { explicit LoadModelHandler(ServiceContext& ctx) : ctx_(ctx) {} std::shared_ptr handle(const std::shared_ptr& request) override { - ActionTracker tracker(Action::kModelLoad, ctx_.telemetry); + ActionTracker tracker(Action::kModelLoad, ctx_.telemetry, + InvocationContext::Direct(GetUserAgent(request))); auto name_raw = request->getPathVariable("name"); if (!name_raw) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing model name"); } @@ -59,6 +65,7 @@ class LoadModelHandler : public HttpRequestHandler { auto* model = ctx_.catalog.GetModel(name); if (!model) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_404, "Model not found", "No model matching '" + name + "'"); } @@ -69,6 +76,7 @@ class LoadModelHandler : public HttpRequestHandler { } if (!model->IsCached()) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Model not cached", "Model must be downloaded before loading"); } @@ -101,10 +109,12 @@ class UnloadModelHandler : public HttpRequestHandler { explicit UnloadModelHandler(ServiceContext& ctx) : ctx_(ctx) {} std::shared_ptr handle(const std::shared_ptr& request) override { - ActionTracker tracker(Action::kModelUnload, ctx_.telemetry); + ActionTracker tracker(Action::kModelUnload, ctx_.telemetry, + InvocationContext::Direct(GetUserAgent(request))); auto name_raw = request->getPathVariable("name"); if (!name_raw) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing model name"); } @@ -112,6 +122,7 @@ class UnloadModelHandler : public HttpRequestHandler { auto* model = ctx_.catalog.GetModel(name); if (!model) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_404, "Model not found", "No model matching '" + name + "'"); } @@ -149,8 +160,9 @@ class OpenAIListModelsHandler : public HttpRequestHandler { public: explicit OpenAIListModelsHandler(ServiceContext& ctx) : ctx_(ctx) {} - std::shared_ptr handle(const std::shared_ptr&) override { - ActionTracker tracker(Action::kOpenAIModelList, ctx_.telemetry); + std::shared_ptr handle(const std::shared_ptr& request) override { + ActionTracker tracker(Action::kOpenAIModelList, ctx_.telemetry, + InvocationContext::Direct(GetUserAgent(request))); auto models = ctx_.catalog.ListModels(); nlohmann::json data = nlohmann::json::array(); @@ -203,10 +215,12 @@ class OpenAIRetrieveModelHandler : public HttpRequestHandler { explicit OpenAIRetrieveModelHandler(ServiceContext& ctx) : ctx_(ctx) {} std::shared_ptr handle(const std::shared_ptr& request) override { - ActionTracker tracker(Action::kOpenAIModelRetrieve, ctx_.telemetry); + ActionTracker tracker(Action::kOpenAIModelRetrieve, ctx_.telemetry, + InvocationContext::Direct(GetUserAgent(request))); auto name_raw = request->getPathVariable("name"); if (!name_raw) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing model name"); } @@ -214,6 +228,7 @@ class OpenAIRetrieveModelHandler : public HttpRequestHandler { auto* model = ctx_.catalog.GetModelVariant(name); if (!model) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_404, "Model not found", "No model matching '" + name + "'"); } diff --git a/sdk_v2/cpp/src/service/responses_handler.cc b/sdk_v2/cpp/src/service/responses_handler.cc index 0fdf6d8f7..1ced57750 100644 --- a/sdk_v2/cpp/src/service/responses_handler.cc +++ b/sdk_v2/cpp/src/service/responses_handler.cc @@ -130,10 +130,12 @@ void ResponsesHandler::LoadPreviousContext(const ResponseCreateParams& params, std::shared_ptr ResponsesHandler::handle( const std::shared_ptr& request) { - ActionTracker tracker(Action::kOpenAIResponsesCreate, ctx_.telemetry); + auto route_ctx = InvocationContext::Direct(GetUserAgent(request)); + auto tracker = std::make_unique(Action::kOpenAIResponsesCreate, ctx_.telemetry, route_ctx); auto body_str = request->readBodyToString(); if (!body_str || body_str->empty()) { + tracker->SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Empty request body"); } @@ -141,6 +143,7 @@ std::shared_ptr ResponsesHandler::handle( nlohmann::json req_json; ResponseCreateParams params; if (auto err = ParseAndValidateRequest(body_str->c_str(), req_json, params)) { + tracker->SetStatus(ActionStatus::kClientError); return err; } @@ -155,10 +158,11 @@ std::shared_ptr ResponsesHandler::handle( Model* model = nullptr; GenAIModelInstance* loaded = nullptr; if (auto err = ResolveModel(model_name, model, loaded)) { + tracker->SetStatus(ActionStatus::kClientError); return err; } - tracker.SetModelId(model_name); + tracker->SetModelId(model_name); // 3. Load previous context if chaining via previous_response_id const nlohmann::json* previous_input = nullptr; @@ -193,10 +197,18 @@ std::shared_ptr ResponsesHandler::handle( // happens here in the handler that owns the session lifetime. std::string tools_json = ResponseConverter::ExtractResponsesToolDefinitions(params, session_request); + // The session and the inference it drives happen as a consequence of this + // route, so they are indirect and reuse the route's correlation id. + auto session_ctx = route_ctx.AsIndirect(); + try { if (!session) { + ActionTracker create_tracker(Action::kSessionCreate, ctx_.telemetry, session_ctx); + create_tracker.SetModelId(model_name); session = std::make_unique(*model, *loaded, ctx_.logger, ctx_.telemetry); + create_tracker.SetStatus(ActionStatus::kSuccess); } + session->SetRequestContext(session_ctx); // Sessions can be reused via previous_response_id; clear any stale tool defs from the prior // turn before applying this request's tools so the request stays self-contained. @@ -208,22 +220,25 @@ std::shared_ptr ResponsesHandler::handle( if (params.stream) { ctx_.logger.Log(LogLevel::Debug, fmt::format("Creating streaming response {} for model {}", response_id, model_name)); - tracker.SetStatus(ActionStatus::kSuccess); + // The route action is recorded by the streaming thread when the stream + // finishes — move the tracker in rather than marking success now. return HandleStreaming(std::move(session), std::move(session_request), model_name, - response_id, created_at, params, req_json); + response_id, created_at, params, req_json, std::move(tracker)); } else { ctx_.logger.Log(LogLevel::Debug, fmt::format("Creating response {} for model {}", response_id, model_name)); auto response = HandleNonStreaming(std::move(session), session_request, model_name, response_id, created_at, params, req_json); - tracker.SetStatus(ActionStatus::kSuccess); + tracker->SetStatus(ActionStatus::kSuccess); return response; } } catch (const std::exception& ex) { - tracker.RecordException(ex); + if (tracker) { + tracker->RecordException(ex); + } ctx_.logger.Log(LogLevel::Error, fmt::format("Response {} failed: {}", response_id, ex.what())); @@ -280,7 +295,7 @@ std::shared_ptr ResponsesHandler::HandleSt std::unique_ptr session, Request session_request, const std::string& model_name, const std::string& response_id, int64_t created_at, const ResponseCreateParams& params, - const nlohmann::json& req_json) { + const nlohmann::json& req_json, std::unique_ptr route_tracker) { auto body = std::make_shared(); auto initial_response = ResponseConverter::BuildInitialResponseObject(response_id, created_at, model_name, params); @@ -326,46 +341,50 @@ std::shared_ptr ResponsesHandler::HandleSt should_store, &store, req_copy = std::move(req_copy), params_copy = std::move(params_copy), + route_tracker = std::move(route_tracker), &tracker]() mutable { - SessionRegistration reg(session_manager, *session); - int seq = 2; - std::string full_text; // concatenation of all visible runs, used for output_text in completed_response - - // Per-item state for the currently-open item. `current_kind == nullopt` means no item is open. On every type - // transition we close the open item (emitting its done events) and open a new one with a fresh id at the next - // output_index. Adjacent same-typed segments accumulate into the same item naturally because we don't close - // until the type changes. - enum class ItemKind { Reasoning, - Message }; - std::optional current_kind; - std::string current_id; - std::string current_text; - int current_output_index = -1; - int next_output_index = 0; - - // Items that have been *closed* (or, for the currently-open item at end-of-stream, finalized in place). - // Used to construct the final `output[]` array for the response.completed event. - std::vector closed_items; - - auto push_event = [&](const std::string& event_name, const StreamEvent& ev) { - body_ptr->Push("event: " + event_name + "\ndata: " + nlohmann::json(ev).dump() + "\n\n"); - }; + try { + SessionRegistration reg(session_manager, *session); + + std::string full_text; // concatenation of all visible runs, used for output_text in completed_response + + // Per-item state for the currently-open item. `current_kind == nullopt` means no item is open. On every type + // transition we close the open item (emitting its done events) and open a new one with a fresh id at the next + // output_index. Adjacent same-typed segments accumulate into the same item naturally because we don't close + // until the type changes. + enum class ItemKind { Reasoning, + Message }; + std::optional current_kind; + std::string current_id; + std::string current_text; + int current_output_index = -1; + int next_output_index = 0; + + // Items that have been *closed* (or, for the currently-open item at end-of-stream, finalized in place). + // Used to construct the final `output[]` array for the response.completed event. + std::vector closed_items; + + auto push_event = [&](const std::string& event_name, const StreamEvent& ev) { + if (!body_ptr->Push("event: " + event_name + "\ndata: " + nlohmann::json(ev).dump() + "\n\n")) { + req.canceled.store(true, std::memory_order_relaxed); + } + }; - auto close_current = [&]() { - if (!current_kind.has_value()) { - return; - } + auto close_current = [&]() { + if (!current_kind.has_value()) { + return; + } - if (*current_kind == ItemKind::Reasoning) { - // Emit: response.reasoning.done - StreamEvent done_ev; - done_ev.type = StreamEventType::kReasoningDone; - done_ev.sequence_number = seq++; - done_ev.output_index = current_output_index; - done_ev.item_id = current_id; - done_ev.text = current_text; - push_event("response.reasoning.done", done_ev); + if (*current_kind == ItemKind::Reasoning) { + // Emit: response.reasoning.done + StreamEvent done_ev; + done_ev.type = StreamEventType::kReasoningDone; + done_ev.sequence_number = seq++; + done_ev.output_index = current_output_index; + done_ev.item_id = current_id; + done_ev.text = current_text; + push_event("response.reasoning.done", done_ev); // Emit: response.output_item.done ReasoningOutputItem rs; @@ -473,7 +492,6 @@ std::shared_ptr ResponsesHandler::HandleSt push_event("response.content_part.added", part_added); }; - try { fl::Response bg_response; fl::Session::StreamingCallbackFn callback_fn = [&](flStreamingCallbackData event, void* /*user_data*/) -> int { fl::ItemQueue* queue = reinterpret_cast(event.item_queue); @@ -535,6 +553,25 @@ std::shared_ptr ResponsesHandler::HandleSt // Close whatever item is still open at end-of-generation so the SSE stream is well-formed. close_current(); + if (req.canceled.load(std::memory_order_relaxed)) { + auto canceled_response = ResponseConverter::BuildFailedResponseObject( + response_id, created_at, model_name, params_copy, "canceled", "Response generation was canceled"); + + StreamEvent failed; + failed.type = StreamEventType::kResponseFailed; + failed.sequence_number = seq++; + failed.response = canceled_response; + push_event("response.failed", failed); + + if (route_tracker) { + route_tracker->SetStatus(ActionStatus::kCanceled); + } + body_ptr->Finish(); + route_tracker.reset(); + tracker.Remove(std::this_thread::get_id()); + return; + } + auto completed_response = ResponseConverter::BuildResponseObject( response_id, created_at, model_name, params_copy, std::move(closed_items), full_text, bg_response.usage); @@ -560,6 +597,12 @@ std::shared_ptr ResponsesHandler::HandleSt session_manager.CheckIn(response_id, std::move(session)); } + // Record final route status after streaming completes. + if (route_tracker) { + route_tracker->SetStatus(req.canceled.load(std::memory_order_relaxed) ? ActionStatus::kCanceled + : ActionStatus::kSuccess); + } + } catch (const std::exception& ex) { logger.Log(LogLevel::Error, fmt::format("Response {} failed during streaming: {}", response_id, ex.what())); @@ -572,12 +615,18 @@ std::shared_ptr ResponsesHandler::HandleSt failed.sequence_number = seq++; failed.response = error_response; body_ptr->Push("event: response.failed\ndata: " + nlohmann::json(failed).dump() + "\n\n"); + + // Mid-stream failure: record the exception; the route action keeps kFailure. + if (route_tracker) { + route_tracker->RecordException(ex); + } } // Terminal event per spec body_ptr->Push("data: [DONE]\n\n"); body_ptr->Finish(); + route_tracker.reset(); tracker.Remove(std::this_thread::get_id()); }); @@ -598,10 +647,12 @@ GetResponseHandler::GetResponseHandler(ServiceContext& ctx) : ctx_(ctx) {} std::shared_ptr GetResponseHandler::handle( const std::shared_ptr& request) { - ActionTracker tracker(Action::kOpenAIResponsesGet, ctx_.telemetry); + ActionTracker tracker(Action::kOpenAIResponsesGet, ctx_.telemetry, + InvocationContext::Direct(GetUserAgent(request))); auto id = request->getPathVariable("id"); if (!id) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing response ID"); } @@ -609,6 +660,7 @@ std::shared_ptr GetResponseHandler::handle auto response = ctx_.response_store.Get(id->c_str()); if (!response) { + tracker.SetStatus(ActionStatus::kClientError); nlohmann::json error_body = { {"error", { {"message", "The response '" + std::string(id->c_str()) + "' does not exist."}, @@ -633,7 +685,8 @@ ListResponsesHandler::ListResponsesHandler(ServiceContext& ctx) : ctx_(ctx) {} std::shared_ptr ListResponsesHandler::handle( const std::shared_ptr& request) { - ActionTracker tracker(Action::kOpenAIResponsesList, ctx_.telemetry); + ActionTracker tracker(Action::kOpenAIResponsesList, ctx_.telemetry, + InvocationContext::Direct(GetUserAgent(request))); // Parse query parameters auto limit_str = request->getQueryParameter("limit", "20"); @@ -688,10 +741,12 @@ DeleteResponseHandler::DeleteResponseHandler(ServiceContext& ctx) : ctx_(ctx) {} std::shared_ptr DeleteResponseHandler::handle( const std::shared_ptr& request) { - ActionTracker tracker(Action::kOpenAIResponsesDelete, ctx_.telemetry); + ActionTracker tracker(Action::kOpenAIResponsesDelete, ctx_.telemetry, + InvocationContext::Direct(GetUserAgent(request))); auto id = request->getPathVariable("id"); if (!id) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing response ID"); } @@ -699,6 +754,7 @@ std::shared_ptr DeleteResponseHandler::han bool deleted = ctx_.response_store.Delete(id->c_str()); if (!deleted) { + tracker.SetStatus(ActionStatus::kClientError); nlohmann::json error_body = { {"error", { {"message", "The response '" + std::string(id->c_str()) + "' does not exist."}, @@ -733,10 +789,12 @@ GetInputItemsHandler::GetInputItemsHandler(ServiceContext& ctx) : ctx_(ctx) {} std::shared_ptr GetInputItemsHandler::handle( const std::shared_ptr& request) { - ActionTracker tracker(Action::kOpenAIResponsesGetInputItems, ctx_.telemetry); + ActionTracker tracker(Action::kOpenAIResponsesGetInputItems, ctx_.telemetry, + InvocationContext::Direct(GetUserAgent(request))); auto id = request->getPathVariable("id"); if (!id) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing response ID"); } @@ -745,6 +803,7 @@ std::shared_ptr GetInputItemsHandler::hand // Check that response exists auto response = ctx_.response_store.Get(id->c_str()); if (!response) { + tracker.SetStatus(ActionStatus::kClientError); nlohmann::json error_body = { {"error", { {"message", "The response '" + std::string(id->c_str()) + "' does not exist."}, diff --git a/sdk_v2/cpp/src/service/responses_handler.h b/sdk_v2/cpp/src/service/responses_handler.h index da9f5f691..e455d6203 100644 --- a/sdk_v2/cpp/src/service/responses_handler.h +++ b/sdk_v2/cpp/src/service/responses_handler.h @@ -16,6 +16,7 @@ struct Request; class ChatSession; class Model; class GenAIModelInstance; +class ActionTracker; namespace responses { struct ResponseCreateParams; @@ -65,7 +66,8 @@ class ResponsesHandler : public HttpRequestHandler { const std::string& model_name, const std::string& response_id, int64_t created_at, const responses::ResponseCreateParams& params, - const nlohmann::json& req_json); + const nlohmann::json& req_json, + std::unique_ptr route_tracker); ServiceContext& ctx_; }; diff --git a/sdk_v2/cpp/src/service/web_service.cc b/sdk_v2/cpp/src/service/web_service.cc index 683ff7817..65f39d3f7 100644 --- a/sdk_v2/cpp/src/service/web_service.cc +++ b/sdk_v2/cpp/src/service/web_service.cc @@ -23,6 +23,7 @@ #include #include +#include #include #include @@ -357,9 +358,6 @@ void WebService::Stop() { return; } - // Join streaming threads first — they may still be pushing to SSE bodies. - impl_->thread_tracker.JoinAll(); - // Stop accepting new connections first, then stop server loops. for (auto& provider : impl_->providers) { provider->stop(); @@ -386,6 +384,10 @@ void WebService::Stop() { } } + // Streaming threads hold request/session state; Manager::Shutdown cancels active sessions + // and waits for drain before StopWebService reaches here. + impl_->thread_tracker.JoinAll(); + impl_->servers.clear(); impl_->listener_threads.clear(); impl_->providers.clear(); diff --git a/sdk_v2/cpp/src/telemetry/android_telemetry_bridge.cc b/sdk_v2/cpp/src/telemetry/android_telemetry_bridge.cc new file mode 100644 index 000000000..9a4d7c79b --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/android_telemetry_bridge.cc @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#include "http/HttpClient_Android.hpp" + +extern "C" __attribute__((visibility("default"))) bool FoundryLocalIsAndroidTelemetryReady() noexcept { + try { + return Microsoft::Applications::Events::HttpClient_Android::GetClientInstance() != nullptr; + } catch (...) { + return false; + } +} diff --git a/sdk_v2/cpp/src/telemetry/device_id.cc b/sdk_v2/cpp/src/telemetry/device_id.cc new file mode 100644 index 000000000..40bdaba3a --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/device_id.cc @@ -0,0 +1,606 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "telemetry/device_id.h" + +#include "telemetry/invocation_context.h" +#include "telemetry/telemetry_environment.h" +#include "util/sha256.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#include +#endif + +#ifdef __APPLE__ +#include +#endif + +namespace fl { + +namespace { + +constexpr size_t kMaxDeviceIdFileSize = 256; +constexpr const char* kDeviceIdFileName = "deviceid"; +#ifdef _WIN32 +constexpr const char* kRegistryPath = "SOFTWARE\\Microsoft\\DeveloperTools\\.onnxruntime"; +constexpr const char* kRegistryValueName = "deviceid"; +#endif + +#if defined(__ANDROID__) || (defined(__APPLE__) && TARGET_OS_IPHONE) +constexpr bool kUsePlatformDeviceId = true; +#else +constexpr bool kUsePlatformDeviceId = false; +#endif + +std::filesystem::path HomeDirectory() { + auto home = TelemetryEnvironment::GetEnv("HOME"); + return home.empty() ? std::filesystem::path{} : std::filesystem::path(home); +} + +std::string TrimDeviceId(std::string value) { + while (!value.empty() && (value.back() == '\n' || value.back() == '\r' || value.back() == ' ')) { + value.pop_back(); + } + return value; +} + +#ifdef _WIN32 +class ScopedWinHandle { + public: + explicit ScopedWinHandle(HANDLE handle = nullptr) : handle_(handle) {} + ~ScopedWinHandle() { + if (handle_ != nullptr && handle_ != INVALID_HANDLE_VALUE) { + ::CloseHandle(handle_); + } + } + + ScopedWinHandle(const ScopedWinHandle&) = delete; + ScopedWinHandle& operator=(const ScopedWinHandle&) = delete; + + HANDLE Get() const { return handle_; } + + private: + HANDLE handle_ = nullptr; +}; + +class ScopedDeviceIdMutex { + public: + ScopedDeviceIdMutex() { + HANDLE token = nullptr; + if (!::OpenProcessToken(::GetCurrentProcess(), TOKEN_QUERY, &token)) { + return; + } + ScopedWinHandle token_handle(token); + + DWORD size = 0; + ::GetTokenInformation(token_handle.Get(), TokenUser, nullptr, 0, &size); + if (size == 0) { + return; + } + + std::vector token_info(size); + if (!::GetTokenInformation(token_handle.Get(), TokenUser, token_info.data(), size, &size)) { + return; + } + + const auto* token_user = reinterpret_cast(token_info.data()); + if (!::IsValidSid(token_user->User.Sid)) { + return; + } + + uint64_t sid_hash = 14695981039346656037ULL; + const auto* sid_bytes = static_cast(token_user->User.Sid); + const DWORD sid_size = ::GetLengthSid(token_user->User.Sid); + for (DWORD i = 0; i < sid_size; ++i) { + sid_hash ^= sid_bytes[i]; + sid_hash *= 1099511628211ULL; + } + + std::array mutex_name{}; + _snwprintf_s(mutex_name.data(), mutex_name.size(), _TRUNCATE, + L"Global\\Microsoft.DeveloperTools.OnnxRuntime.DeviceId.%016llx", + static_cast(sid_hash)); + + handle_ = ::CreateMutexW(nullptr, FALSE, mutex_name.data()); + if (handle_ == nullptr) { + return; + } + + const DWORD wait_result = ::WaitForSingleObject(handle_, 1000); + acquired_ = wait_result == WAIT_OBJECT_0 || wait_result == WAIT_ABANDONED; + } + + ~ScopedDeviceIdMutex() { + if (acquired_) { + ::ReleaseMutex(handle_); + } + if (handle_ != nullptr) { + ::CloseHandle(handle_); + } + } + + ScopedDeviceIdMutex(const ScopedDeviceIdMutex&) = delete; + ScopedDeviceIdMutex& operator=(const ScopedDeviceIdMutex&) = delete; + + explicit operator bool() const { return acquired_; } + + private: + HANDLE handle_ = nullptr; + bool acquired_ = false; +}; +#else +bool CreateDirectoryTreeOwnerOnly(const std::filesystem::path& dir, bool leaf = true) { + if (dir.empty()) { + return false; + } + + std::error_code ec; + if (leaf && std::filesystem::is_symlink(dir, ec)) { + return false; + } + + ec.clear(); + if (std::filesystem::exists(dir, ec)) { + if (!std::filesystem::is_directory(dir, ec)) { + return false; + } + if (leaf) { + ec.clear(); + std::filesystem::permissions(dir, std::filesystem::perms::owner_all, + std::filesystem::perm_options::replace, ec); + if (ec) { + return false; + } + } + return true; + } + + const auto parent = dir.parent_path(); + if (!parent.empty() && parent != dir && !CreateDirectoryTreeOwnerOnly(parent, false)) { + return false; + } + + const auto dir_path = dir.string(); + if (::mkdir(dir_path.c_str(), S_IRWXU) != 0 && errno != EEXIST) { + return false; + } + + ec.clear(); + if (leaf && std::filesystem::is_symlink(dir, ec)) { + return false; + } + ec.clear(); + if (!std::filesystem::is_directory(dir, ec)) { + return false; + } + if (leaf) { + std::filesystem::permissions(dir, std::filesystem::perms::owner_all, + std::filesystem::perm_options::replace, ec); + if (ec) { + return false; + } + } + return true; +} + +enum class DeviceIdPublishResult { + kCreated, + kAlreadyExists, + kFailed, +}; + +DeviceIdPublishResult PublishDeviceIdFileNoFollow(const std::filesystem::path& file, + std::string_view value, + bool replace_existing) { + std::filesystem::path temp = file; + temp += ".tmp." + GenerateGuidV4(); + + int flags = O_WRONLY | O_CREAT | O_EXCL; +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif + const auto temp_path = temp.string(); + const int fd = ::open(temp_path.c_str(), flags, S_IRUSR | S_IWUSR); + if (fd < 0) { + return DeviceIdPublishResult::kFailed; + } + + bool wrote = true; + const char* data = value.data(); + size_t remaining = value.size(); + while (remaining > 0) { + const ssize_t n = ::write(fd, data, remaining); + if (n <= 0) { + wrote = false; + break; + } + data += n; + remaining -= static_cast(n); + } + if (::close(fd) != 0) { + wrote = false; + } + + std::error_code ec; + if (!wrote) { + std::filesystem::remove(temp, ec); + return DeviceIdPublishResult::kFailed; + } + + if (replace_existing) { + std::filesystem::rename(temp, file, ec); + } else { + std::filesystem::create_hard_link(temp, file, ec); + } + if (ec) { + const bool already_exists = !replace_existing && ec == std::errc::file_exists; + std::filesystem::remove(temp, ec); + return already_exists ? DeviceIdPublishResult::kAlreadyExists : DeviceIdPublishResult::kFailed; + } + std::filesystem::remove(temp, ec); + + ec.clear(); + std::filesystem::permissions(file, + std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, + std::filesystem::perm_options::replace, ec); + return DeviceIdPublishResult::kCreated; +} +#endif + +} // namespace + +TelemetryDeviceId& TelemetryDeviceId::Instance() { + static TelemetryDeviceId instance; + return instance; +} + +std::string TelemetryDeviceId::GetValue() { + std::lock_guard lock(mutex_); + InitializeLocked(); + return device_id_; +} + +TelemetryDeviceIdStatus TelemetryDeviceId::GetStatus() { + std::lock_guard lock(mutex_); + InitializeLocked(); + return status_; +} + +std::string TelemetryDeviceId::GetStatusString() { + return StatusToString(GetStatus()); +} + +std::filesystem::path TelemetryDeviceId::GetStorageDirectory() { +#ifdef _WIN32 + return {}; +#elif defined(__APPLE__) + auto home = HomeDirectory(); + return home.empty() ? std::filesystem::path{} : + home / "Library" / "Application Support" / "Microsoft" / "DeveloperTools" / ".onnxruntime"; +#else + auto cache_base = TelemetryEnvironment::GetEnv("XDG_CACHE_HOME"); + std::filesystem::path base; + if (!cache_base.empty()) { + base = cache_base; + } else { + auto home = HomeDirectory(); + if (home.empty()) { + return {}; + } + base = home / ".cache"; + } + return base / "Microsoft" / "DeveloperTools" / ".onnxruntime"; +#endif +} + +std::filesystem::path TelemetryDeviceId::EnsureStorageDirectory() { +#ifdef _WIN32 + return {}; +#else + auto dir = GetStorageDirectory(); + if (dir.empty()) { + return {}; + } + + if (!CreateDirectoryTreeOwnerOnly(dir)) { + return {}; + } + return dir; +#endif +} + +std::filesystem::path TelemetryDeviceId::GetCacheDirectory() { +#ifdef _WIN32 + auto base = TelemetryEnvironment::GetEnv("LOCALAPPDATA"); + if (base.empty()) { + auto user_profile = TelemetryEnvironment::GetEnv("USERPROFILE"); + if (!user_profile.empty()) { + base = (std::filesystem::path(user_profile) / "AppData" / "Local").string(); + } + } + return base.empty() ? std::filesystem::path{} : + std::filesystem::path(base) / "Microsoft" / "DeveloperTools" / ".onnxruntime"; +#else + return GetStorageDirectory(); +#endif +} + +std::filesystem::path TelemetryDeviceId::EnsureCacheDirectory() { + auto dir = GetCacheDirectory(); + if (dir.empty()) { + return {}; + } + + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) { + return {}; + } +#ifndef _WIN32 + ::chmod(dir.string().c_str(), S_IRWXU); +#endif + return dir; +} + +std::string TelemetryDeviceId::HashForTelemetry(std::string_view raw_device_id) { + if (raw_device_id.empty()) { + return {}; + } + return "c:" + Sha256String(raw_device_id); +} + +bool TelemetryDeviceId::IsValidGuid(std::string_view value) { + if (value.size() != 36) { + return false; + } + for (size_t i = 0; i < value.size(); ++i) { + const char ch = value[i]; + if (i == 8 || i == 13 || i == 18 || i == 23) { + if (ch != '-') { + return false; + } + continue; + } + const bool hex = (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F'); + if (!hex) { + return false; + } + } + return true; +} + +void TelemetryDeviceId::InitializeLocked() { + if (initialized_) { + return; + } + initialized_ = true; + + if constexpr (kUsePlatformDeviceId) { + status_ = TelemetryDeviceIdStatus::kPlatform; + device_id_.clear(); + return; + } + +#ifdef _WIN32 + std::string registry_value; + bool found = false; + const auto read_existing = [&]() -> bool { + if (!ReadWindowsRegistryDeviceId(registry_value, found)) { + status_ = TelemetryDeviceIdStatus::kFailed; + return false; + } + + if (!found) { + return false; + } + + registry_value = TrimDeviceId(std::move(registry_value)); + if (registry_value.size() <= kMaxDeviceIdFileSize && IsValidGuid(registry_value)) { + device_id_ = std::move(registry_value); + status_ = TelemetryDeviceIdStatus::kExisting; + return true; + } + status_ = TelemetryDeviceIdStatus::kCorrupted; + return false; + }; + + if (read_existing()) { + return; + } + + const bool saw_corruption_before_mutex = status_ == TelemetryDeviceIdStatus::kCorrupted; + ScopedDeviceIdMutex mutex; + if (!mutex) { + if (read_existing()) { + return; + } + status_ = TelemetryDeviceIdStatus::kFailed; + return; + } + + status_ = TelemetryDeviceIdStatus::kNew; + registry_value.clear(); + found = false; + if (read_existing()) { + return; + } + if (status_ == TelemetryDeviceIdStatus::kFailed) { + return; + } + + const bool regenerated_from_corruption = + saw_corruption_before_mutex || status_ == TelemetryDeviceIdStatus::kCorrupted; + device_id_ = GenerateGuidV4(); + if (WriteWindowsRegistryDeviceId(device_id_)) { + status_ = regenerated_from_corruption ? TelemetryDeviceIdStatus::kCorrupted : TelemetryDeviceIdStatus::kNew; + } else { + status_ = TelemetryDeviceIdStatus::kFailed; + } + return; +#else + auto dir = GetStorageDirectory(); + if (dir.empty()) { + status_ = TelemetryDeviceIdStatus::kFailed; + return; + } + + const auto file_path = dir / kDeviceIdFileName; + std::error_code ec; + if (std::filesystem::is_symlink(file_path, ec)) { + status_ = TelemetryDeviceIdStatus::kFailed; + return; + } + ec.clear(); + if (std::filesystem::exists(file_path, ec) && !ec) { + const auto file_size = std::filesystem::file_size(file_path, ec); + if (!ec && file_size <= kMaxDeviceIdFileSize) { + std::ifstream input(file_path); + std::string content; + std::getline(input, content); + content = TrimDeviceId(std::move(content)); + if (IsValidGuid(content)) { + device_id_ = std::move(content); + status_ = TelemetryDeviceIdStatus::kExisting; + return; + } + } + status_ = TelemetryDeviceIdStatus::kCorrupted; + } + + const bool file_existed = status_ == TelemetryDeviceIdStatus::kCorrupted; + auto storage_dir = EnsureStorageDirectory(); + if (storage_dir.empty()) { + status_ = TelemetryDeviceIdStatus::kFailed; + return; + } + + ec.clear(); + if (std::filesystem::is_symlink(file_path, ec)) { + status_ = TelemetryDeviceIdStatus::kFailed; + return; + } + + device_id_ = GenerateGuidV4(); + const auto publish_result = PublishDeviceIdFileNoFollow(file_path, device_id_, file_existed); + if (publish_result == DeviceIdPublishResult::kAlreadyExists) { + ec.clear(); + if (!std::filesystem::is_symlink(file_path, ec)) { + std::ifstream winner(file_path); + std::string winner_id; + if (std::getline(winner, winner_id)) { + winner_id = TrimDeviceId(std::move(winner_id)); + if (IsValidGuid(winner_id)) { + device_id_ = std::move(winner_id); + status_ = TelemetryDeviceIdStatus::kExisting; + return; + } + } + } + status_ = TelemetryDeviceIdStatus::kFailed; + return; + } + + if (publish_result == DeviceIdPublishResult::kCreated) { + status_ = file_existed ? TelemetryDeviceIdStatus::kCorrupted : TelemetryDeviceIdStatus::kNew; + } else { + status_ = TelemetryDeviceIdStatus::kFailed; + } +#endif +} + +std::string TelemetryDeviceId::StatusToString(TelemetryDeviceIdStatus status) { + switch (status) { + case TelemetryDeviceIdStatus::kNew: + return "New"; + case TelemetryDeviceIdStatus::kExisting: + return "Existing"; + case TelemetryDeviceIdStatus::kCorrupted: + return "Corrupted"; + case TelemetryDeviceIdStatus::kFailed: + return "Failed"; + case TelemetryDeviceIdStatus::kPlatform: + return "Platform"; + default: + return "Unknown"; + } +} + +bool TelemetryDeviceId::WriteDeviceIdFile(const std::filesystem::path& path, std::string_view value) { +#ifdef _WIN32 + (void)path; + (void)value; + return false; +#else + const int fd = ::open(path.string().c_str(), O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR); + if (fd < 0) { + return false; + } + ::fchmod(fd, S_IRUSR | S_IWUSR); + const ssize_t written = ::write(fd, value.data(), value.size()); + ::close(fd); + return written == static_cast(value.size()); +#endif +} + +#ifdef _WIN32 +bool TelemetryDeviceId::ReadWindowsRegistryDeviceId(std::string& value, bool& found) { + found = false; + DWORD size = 0; + LSTATUS status = ::RegGetValueA(HKEY_CURRENT_USER, kRegistryPath, kRegistryValueName, + RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY, nullptr, nullptr, &size); + if (status == ERROR_FILE_NOT_FOUND) { + return true; + } + if (status != ERROR_SUCCESS) { + return false; + } + + std::string buffer(size, '\0'); + status = ::RegGetValueA(HKEY_CURRENT_USER, kRegistryPath, kRegistryValueName, + RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY, nullptr, buffer.data(), &size); + if (status != ERROR_SUCCESS) { + return false; + } + + if (!buffer.empty() && buffer.back() == '\0') { + buffer.pop_back(); + } + value = std::move(buffer); + found = true; + return true; +} + +bool TelemetryDeviceId::WriteWindowsRegistryDeviceId(std::string_view value) { + HKEY key = nullptr; + LSTATUS status = ::RegCreateKeyExA(HKEY_CURRENT_USER, kRegistryPath, 0, nullptr, 0, + KEY_SET_VALUE | KEY_WOW64_64KEY, nullptr, &key, nullptr); + if (status != ERROR_SUCCESS) { + return false; + } + + const auto close_key = [&]() { ::RegCloseKey(key); }; + std::string value_z(value); + status = ::RegSetValueExA(key, kRegistryValueName, 0, REG_SZ, + reinterpret_cast(value_z.c_str()), + static_cast(value_z.size() + 1)); + close_key(); + return status == ERROR_SUCCESS; +} +#endif + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/device_id.h b/sdk_v2/cpp/src/telemetry/device_id.h new file mode 100644 index 000000000..b522c013f --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/device_id.h @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include +#include +#include +#include + +namespace fl { + +enum class TelemetryDeviceIdStatus { + kNew, + kExisting, + kCorrupted, + kFailed, + kPlatform, +}; + +class TelemetryDeviceId { + public: + static TelemetryDeviceId& Instance(); + + std::string GetValue(); + TelemetryDeviceIdStatus GetStatus(); + std::string GetStatusString(); + + static std::filesystem::path GetStorageDirectory(); + static std::filesystem::path EnsureStorageDirectory(); + static std::filesystem::path GetCacheDirectory(); + static std::filesystem::path EnsureCacheDirectory(); + static std::string HashForTelemetry(std::string_view raw_device_id); + static bool IsValidGuid(std::string_view value); + + private: + TelemetryDeviceId() = default; + + void InitializeLocked(); + static std::string StatusToString(TelemetryDeviceIdStatus status); + static bool WriteDeviceIdFile(const std::filesystem::path& path, std::string_view value); +#ifdef _WIN32 + static bool ReadWindowsRegistryDeviceId(std::string& value, bool& found); + static bool WriteWindowsRegistryDeviceId(std::string_view value); +#endif + + std::mutex mutex_; + std::string device_id_; + TelemetryDeviceIdStatus status_ = TelemetryDeviceIdStatus::kNew; + bool initialized_ = false; +}; + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/download_tracker.cc b/sdk_v2/cpp/src/telemetry/download_tracker.cc new file mode 100644 index 000000000..c39556eac --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/download_tracker.cc @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "telemetry/download_tracker.h" + +#include "telemetry/invocation_context.h" + +#include + +namespace fl { + +DownloadTracker::DownloadTracker(std::string model_id, + std::string user_agent, + ITelemetry& telemetry) + : telemetry_(telemetry) { + info_.model_id = std::move(model_id); + info_.user_agent = user_agent.empty() ? DefaultUserAgent() : std::move(user_agent); + info_.correlation_id = GenerateGuidV4(); + info_.status = ActionStatus::kFailure; + download_phase_start_ = std::chrono::steady_clock::now(); +} + +DownloadTracker::~DownloadTracker() { + // Emit the Download event regardless of outcome. The default status is + // kFailure so abrupt exits (exceptions) are recorded as failures. + try { + telemetry_.RecordDownload(info_); + } catch (...) { + // Telemetry is best-effort and must not throw from RAII cleanup. + } +} + +void DownloadTracker::RecordException(const std::exception& exception) { + info_.status = ActionStatusFromException(exception); + try { + telemetry_.RecordException(Action::kModelFileDownload, exception, + InvocationContext{info_.user_agent, info_.correlation_id, /*indirect=*/false}); + } catch (...) { + // Telemetry is best-effort and must not mask the original error path. + } +} + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/download_tracker.h b/sdk_v2/cpp/src/telemetry/download_tracker.h new file mode 100644 index 000000000..c1c93cb88 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/download_tracker.h @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include "telemetry/telemetry.h" + +#include +#include + +namespace fl { + +/// RAII tracker for the per-model "Download" event. +/// Caller updates fields as the download progresses; the event is emitted on +/// destruction. Status defaults to kFailure so that abrupt exits (exceptions) +/// are recorded as failures unless the caller calls SetStatus(kSuccess) on the +/// happy path or SetStatus(kSkipped) when the model was already cached. +class DownloadTracker { + public: + DownloadTracker(std::string model_id, + std::string user_agent, + ITelemetry& telemetry); + ~DownloadTracker(); + + // Non-copyable, non-movable + DownloadTracker(const DownloadTracker&) = delete; + DownloadTracker& operator=(const DownloadTracker&) = delete; + + void SetStatus(ActionStatus status) { info_.status = status; } + void SetLockWaitMs(int64_t v) { info_.lock_wait_ms = v; } + void SetEnumerationMs(int64_t v) { info_.enumeration_ms = v; } + void SetTotalSizeBytes(int64_t v) { info_.total_size_bytes = v; } + void SetAlreadyCachedBytes(int64_t v) { info_.already_cached_bytes = v; } + void SetFileCount(int32_t v) { info_.file_count = v; } + void SetSkippedFileCount(int32_t v) { info_.skipped_file_count = v; } + void SetDownloadMs(int64_t v) { info_.download_ms = v; } + void SetDownloadWaitResult(std::string v) { info_.download_wait_result = std::move(v); } + void SetMaxConcurrency(int32_t v) { info_.max_concurrency = v; } + + /// Start the timer for the download phase. Kept for callers that do not have + /// lower-level transfer stats. + void BeginDownloadPhase() { download_phase_start_ = std::chrono::steady_clock::now(); } + + /// Stop the timer for the download phase. The duration is captured into the + /// emitted event. Safe to call multiple times — the last call wins. + void EndDownloadPhase() { + info_.download_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - download_phase_start_) + .count(); + } + + void RecordException(const std::exception& exception); + + private: + ITelemetry& telemetry_; + DownloadInfo info_; + std::chrono::steady_clock::time_point download_phase_start_; +}; + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/ep_download_tracker.cc b/sdk_v2/cpp/src/telemetry/ep_download_tracker.cc new file mode 100644 index 000000000..f35738a8c --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/ep_download_tracker.cc @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "telemetry/ep_download_tracker.h" + +#include "telemetry/invocation_context.h" + +#include + +namespace fl { + +namespace { + +int64_t ElapsedMs(std::chrono::steady_clock::time_point start) { + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); +} + +} // namespace + +EpDownloadTracker::EpDownloadTracker(std::string provider_name, + std::string user_agent, + std::string correlation_id, + ITelemetry& telemetry) + : telemetry_(telemetry), + provider_name_(std::move(provider_name)), + user_agent_(user_agent.empty() ? DefaultUserAgent() : std::move(user_agent)), + correlation_id_(std::move(correlation_id)), + stage_start_(std::chrono::steady_clock::now()) { +} + +EpDownloadTracker::~EpDownloadTracker() { + // Mirror neutron-server: if the caller didn't reach Done() or + // RecordRegisterComplete, assume the abrupt exit was an exception path and + // record any unfinished stage as kFailure. + try { + RecordEvent(ActionStatus::kFailure); + } catch (...) { + // Telemetry is best-effort and must not throw from RAII cleanup. + } +} + +void EpDownloadTracker::RecordInitialState(EpReadyState ready_state) { + init_ready_state_ = ready_state; + stage_ = Stage::Download; + stage_start_ = std::chrono::steady_clock::now(); +} + +void EpDownloadTracker::RecordDownloadComplete(ActionStatus status, EpReadyState ready_state) { + download_duration_ms_ = ElapsedMs(stage_start_); + download_ready_state_ = ready_state; + download_status_ = status; + stage_ = Stage::Register; + stage_start_ = std::chrono::steady_clock::now(); +} + +void EpDownloadTracker::RecordRegisterComplete(ActionStatus status, EpReadyState ready_state) { + register_duration_ms_ = ElapsedMs(stage_start_); + register_ready_state_ = ready_state; + register_status_ = status; + stage_ = Stage::Final; +} + +void EpDownloadTracker::Done() { + RecordEvent(ActionStatus::kSkipped); +} + +void EpDownloadTracker::RecordException(const std::exception& ex) { + const auto status = ActionStatusFromException(ex); + // The per-provider attempt happens as a consequence of the overall + // DownloadAndRegisterEps call, so it is indirect and shares its correlation id. + try { + telemetry_.RecordException(Action::kEpDownloadAndRegister, ex, + InvocationContext{user_agent_, correlation_id_, /*indirect=*/true}); + } catch (...) { + // Telemetry is best-effort and must not mask the original error path. + } + RecordEvent(status); +} + +void EpDownloadTracker::RecordEvent(ActionStatus incomplete_stage_status) { + if (recorded_event_) { + return; + } + recorded_event_ = true; + + if (stage_ == Stage::Download) { + download_duration_ms_ = ElapsedMs(stage_start_); + download_status_ = incomplete_stage_status; + } else if (stage_ == Stage::Register) { + register_duration_ms_ = ElapsedMs(stage_start_); + register_status_ = incomplete_stage_status; + } + + EpDownloadAndRegisterInfo info; + info.user_agent = user_agent_; + info.correlation_id = correlation_id_; + info.provider_name = provider_name_; + info.init_ready_state = ReadyStateToString(init_ready_state_); + info.download_ready_state = ReadyStateToString(download_ready_state_); + info.download_status = download_status_; + info.download_duration_ms = download_duration_ms_; + info.register_ready_state = ReadyStateToString(register_ready_state_); + info.register_status = register_status_; + info.register_duration_ms = register_duration_ms_; + telemetry_.RecordEpDownloadAndRegister(info); +} + +const char* EpDownloadTracker::ReadyStateToString(EpReadyState state) { + switch (state) { + case EpReadyState::kNotApplicable: + return "N/A"; + case EpReadyState::kNotPresent: + return "NotPresent"; + case EpReadyState::kInstalled: + return "Installed"; + case EpReadyState::kRegistered: + return "Registered"; + case EpReadyState::kUnknown: + return "Unknown"; + default: + return "Unknown"; + } +} + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/ep_download_tracker.h b/sdk_v2/cpp/src/telemetry/ep_download_tracker.h new file mode 100644 index 000000000..6eb67c35a --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/ep_download_tracker.h @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include "telemetry/telemetry.h" + +#include +#include +#include + +namespace fl { + +enum class EpReadyState { + kNotApplicable, + kNotPresent, + kInstalled, + kRegistered, + kUnknown, +}; + +/// RAII tracker for the per-provider "EPDownloadAndRegister" event. Mirrors the +/// neutron-server EPDownloadTracker. Stages advance Initial -> Download -> +/// Register -> Final. Each stage that the caller doesn't explicitly complete +/// is recorded with one of: +/// * kFailure on destruction (assumed exception path) +/// * kSkipped if Done() is called before destruction (early-exit without +/// exception, e.g. "EP already registered, nothing to download") +class EpDownloadTracker { + public: + EpDownloadTracker(std::string provider_name, + std::string user_agent, + std::string correlation_id, + ITelemetry& telemetry); + ~EpDownloadTracker(); + + // Non-copyable, non-movable + EpDownloadTracker(const EpDownloadTracker&) = delete; + EpDownloadTracker& operator=(const EpDownloadTracker&) = delete; + + /// Captures the EP's ready state before the download phase begins. Restarts + /// the stopwatch so subsequent timings measure the download phase only. + void RecordInitialState(EpReadyState ready_state = EpReadyState::kNotApplicable); + + /// Captures the download phase outcome and ready state, restarts the + /// stopwatch for the register phase. + void RecordDownloadComplete(ActionStatus status, EpReadyState ready_state = EpReadyState::kNotApplicable); + + /// Captures the register phase outcome and ready state. Stops the stopwatch. + void RecordRegisterComplete(ActionStatus status, EpReadyState ready_state = EpReadyState::kNotApplicable); + + /// Mark tracking as complete, filling any remaining stages with kSkipped + /// instead of the default kFailure (exception-assumed) status. Call this on + /// happy-path early exits (e.g. "EP already registered"). + void Done(); + + /// Record an exception associated with the bootstrap operation and finalize + /// the EPDownloadAndRegister event with the classified failure status. + void RecordException(const std::exception& ex); + + private: + enum class Stage { + Initial = 0, + Download = 1, + Register = 2, + Final = 3, + }; + + void RecordEvent(ActionStatus incomplete_stage_status); + + static const char* ReadyStateToString(EpReadyState state); + + ITelemetry& telemetry_; + std::string provider_name_; + std::string user_agent_; + std::string correlation_id_; + EpReadyState init_ready_state_ = EpReadyState::kNotApplicable; + EpReadyState download_ready_state_ = EpReadyState::kNotApplicable; + EpReadyState register_ready_state_ = EpReadyState::kNotApplicable; + ActionStatus download_status_ = ActionStatus::kSkipped; + ActionStatus register_status_ = ActionStatus::kSkipped; + int64_t download_duration_ms_ = 0; + int64_t register_duration_ms_ = 0; + std::chrono::steady_clock::time_point stage_start_; + Stage stage_ = Stage::Initial; + bool recorded_event_ = false; +}; + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/invocation_context.cc b/sdk_v2/cpp/src/telemetry/invocation_context.cc new file mode 100644 index 000000000..fdad654b5 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/invocation_context.cc @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "telemetry/invocation_context.h" + +#include "version.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fl { + +namespace { + +uint64_t MakeNonThrowingSeed() { + try { + std::random_device rd; + return (static_cast(rd()) << 32) ^ rd(); + } catch (...) { + static std::atomic counter{0}; + const auto now = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto sequence = counter.fetch_add(1, std::memory_order_relaxed) + 1; + const auto thread_id = std::hash{}(std::this_thread::get_id()); + return static_cast(now) ^ (sequence * 0x9E3779B97F4A7C15ULL) ^ + static_cast(thread_id); + } +} + +std::string& MutableDefaultUserAgent() { + static std::string user_agent = std::string("foundry-local-core/") + FOUNDRY_LOCAL_VERSION; + return user_agent; +} + +} // namespace + +std::string DefaultUserAgent() { + return MutableDefaultUserAgent(); +} + +void SetDefaultUserAgent(std::string user_agent) { + if (user_agent.empty()) { + user_agent = std::string("foundry-local-core/") + FOUNDRY_LOCAL_VERSION; + } + MutableDefaultUserAgent() = std::move(user_agent); +} + +std::string GenerateGuidV4() { + // This is a correlation / session id, not a cryptographic identifier; use + // random_device when available and a process-local fallback when it is not. + std::mt19937_64 gen{MakeNonThrowingSeed()}; + uint64_t hi = gen(); + uint64_t lo = gen(); + + // Set version (4) and variant (10xx) bits. + hi = (hi & 0xFFFFFFFFFFFF0FFFULL) | 0x0000000000004000ULL; + lo = (lo & 0x3FFFFFFFFFFFFFFFULL) | 0x8000000000000000ULL; + + char buf[37]; + std::snprintf(buf, sizeof(buf), + "%08x-%04x-%04x-%04x-%012llx", + static_cast((hi >> 32) & 0xFFFFFFFFu), + static_cast((hi >> 16) & 0xFFFFu), + static_cast(hi & 0xFFFFu), + static_cast((lo >> 48) & 0xFFFFu), + static_cast(lo & 0x0000FFFFFFFFFFFFULL)); + return std::string(buf); +} + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/invocation_context.h b/sdk_v2/cpp/src/telemetry/invocation_context.h new file mode 100644 index 000000000..b6e8d3660 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/invocation_context.h @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include +#include + +namespace fl { + +/// Generate an RFC 4122 v4 UUID, hex-encoded with hyphens. Not a cryptographic +/// identifier — used for per-operation correlation and per-process session ids. +std::string GenerateGuidV4(); + +std::string DefaultUserAgent(); +void SetDefaultUserAgent(std::string user_agent); + +/// Per-operation telemetry context, threaded from an entry point through every +/// action it triggers. +/// +/// - `user_agent` identifies the calling client (SDK, CLI, Node, browser…). +/// - `correlation_id` groups every event emitted while servicing one logical +/// operation, so the route action, the inference it drives, +/// the Model metrics event and any error can be joined. +/// - `indirect` is true when this action happened as a consequence of +/// another action rather than a direct user/API call (e.g. a +/// session driven by an HTTP route, or a per-provider EP +/// download under an overall attempt). +struct InvocationContext { + std::string user_agent; + std::string correlation_id; + bool indirect = false; + + /// A direct, top-level context with a freshly generated correlation id. + static InvocationContext Direct(std::string user_agent = "") { + InvocationContext ctx; + ctx.user_agent = user_agent.empty() ? DefaultUserAgent() : std::move(user_agent); + ctx.correlation_id = GenerateGuidV4(); + ctx.indirect = false; + return ctx; + } + + /// Derive a context for an action triggered by this one: same correlation id + /// and user agent, but marked indirect. + InvocationContext AsIndirect() const { + InvocationContext ctx = *this; + ctx.indirect = true; + return ctx; + } + + /// Guarantee a correlation id is present, generating one when empty. Lets an + /// entry point that received a default-constructed context still group its + /// events (e.g. an SDK caller that didn't build a Direct() context). + InvocationContext& EnsureCorrelationId() { + if (correlation_id.empty()) { + correlation_id = GenerateGuidV4(); + } + return *this; + } +}; + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/one_ds_telemetry.cc b/sdk_v2/cpp/src/telemetry/one_ds_telemetry.cc new file mode 100644 index 000000000..9807cb123 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/one_ds_telemetry.cc @@ -0,0 +1,550 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#include "telemetry/one_ds_telemetry.h" + +#include "telemetry/device_id.h" +#include "telemetry/telemetry_context.h" +#include "telemetry/telemetry_environment.h" +#include "telemetry/telemetry_redaction.h" +#include "telemetry/telemetry_sampling.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include "one_ds_tenant_token.h" + +#if defined(__ANDROID__) +extern "C" bool FoundryLocalIsAndroidTelemetryReady() noexcept; +#endif + +namespace fl { + +namespace { + +using MatILogManager = ::Microsoft::Applications::Events::ILogManager; +using MatILogger = ::Microsoft::Applications::Events::ILogger; +using ::Microsoft::Applications::Events::EventProperties; +using ::Microsoft::Applications::Events::EventPriority; +using ::Microsoft::Applications::Events::PiiKind_None; +using ::Microsoft::Applications::Events::SessionState; +using ::Microsoft::Applications::Events::CFG_BOOL_SESSION_RESET_ENABLED; +using ::Microsoft::Applications::Events::CFG_INT_MAX_TEARDOWN_TIME; +using ::Microsoft::Applications::Events::CFG_INT_SDK_MODE; +using ::Microsoft::Applications::Events::CFG_INT_TRACE_LEVEL_MASK; +using ::Microsoft::Applications::Events::CFG_STR_PRIMARY_TOKEN; +using ::Microsoft::Applications::Events::CFG_STR_CACHE_FILE_PATH; +using ::Microsoft::Applications::Events::ILogConfiguration; +using ::Microsoft::Applications::Events::LogManagerProvider; +using ::Microsoft::Applications::Events::SdkModeTypes_CS; +using ::Microsoft::Applications::Events::STATUS_SUCCESS; +using ::Microsoft::Applications::Events::status_t; + +constexpr uint64_t kCriticalData = MICROSOFT_KEYWORD_CRITICAL_DATA; +constexpr int kMaxTeardownUploadTimeSec = 1; + +std::string DecodeBase64(std::string_view encoded) { + auto DecodeChar = [](char c) -> int { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; + }; + + std::string result; + result.reserve(encoded.size() * 3 / 4); + uint32_t accum = 0; + int bits = 0; + for (char c : encoded) { + int val = DecodeChar(c); + if (val < 0) continue; + accum = (accum << 6) | static_cast(val); + bits += 6; + if (bits >= 8) { + bits -= 8; + result.push_back(static_cast((accum >> bits) & 0xFF)); + } + } + return result; +} + +std::string GetToken() { +#if defined(FOUNDRY_LOCAL_TELEMETRY_TOKEN) + return FOUNDRY_LOCAL_TELEMETRY_TOKEN; +#else + static constexpr char kXorKey[] = "FoundryLocal"; + static constexpr char kEncodedToken[] = + "fwtACgATHC9ZUgReclpDWQZFQXQOUVENIw5GXFBESn1CAQJbc1dEDVJfH3QLBUxYJw5EQ1wXGnpCUgNZJAtNVl0UQHkLTlZfd1o="; + constexpr size_t klen = sizeof(kXorKey) - 1; + std::string decoded = DecodeBase64(kEncodedToken); + for (size_t i = 0; i < decoded.size(); ++i) { + decoded[i] = static_cast(decoded[i] ^ kXorKey[i % klen]); + } + return decoded; +#endif +} + +void SetCommonContext(MatILogger* mat_logger, const TelemetryMetadata& m) { + mat_logger->SetContext("AppName", m.app_name); + mat_logger->SetContext("AppVersion", m.app_version); + mat_logger->SetContext("FoundryLocalVersion", m.version); + mat_logger->SetContext("AppSessionGuid", m.app_session_guid); + mat_logger->SetContext("OsName", m.os_name); + mat_logger->SetContext("OsVersion", m.os_version); + mat_logger->SetContext("CpuArch", m.cpu_arch); +} + +EventProperties MakeEvent(const char* name) { + EventProperties ev(name); + ev.SetPriority(EventPriority::EventPriority_Normal); + ev.SetPolicyBitFlags(kCriticalData); + ev.SetPopsample(TelemetryInternal::kTelemetrySampleRatePercent); + return ev; +} + +bool ShouldSampleEvent(std::string_view app_session_guid, std::string_view correlation_id) { + return TelemetryInternal::ShouldSampleTelemetryEvent( + app_session_guid, correlation_id.empty() ? app_session_guid : correlation_id); +} + +void SafeLog(MatILogger* mat_logger, EventProperties& ev) { + if (mat_logger != nullptr) { + mat_logger->LogEvent(ev); + } +} + +std::string SanitizeTelemetryText(std::string_view value) { + return ScrubStringForTelemetry(value); +} + +} // namespace + +struct OneDsTelemetry::Impl { + ILogConfiguration config; + MatILogManager* log_manager = nullptr; + MatILogger* logger = nullptr; +}; + +std::shared_lock OneDsTelemetry::LockForLogging(bool require_upload) const { + std::shared_lock lock(mutex_); + if (!initialized_.load(std::memory_order_acquire) || !impl_ || impl_->logger == nullptr || + (require_upload && !upload_enabled_.load(std::memory_order_acquire))) { + return {}; + } + return lock; +} + +OneDsTelemetry::OneDsTelemetry(const std::string& app_name, + ILogger& logger, + bool disable_nonessential_telemetry) + : local_log_(app_name, logger), + metadata_(BuildTelemetryMetadata(app_name)), + logger_(logger) { + if (TelemetryEnvironment::IsCiEnvironment()) { + logger_.Log(LogLevel::Information, + "[Telemetry] CI environment detected; 1DS upload disabled (events still logged locally)"); + return; + } + if (disable_nonessential_telemetry) { + upload_enabled_.store(false, std::memory_order_release); + logger_.Log(LogLevel::Information, + "[Telemetry] Disabled via configuration; non-essential 1DS upload disabled " + "(ProcessInfo still uploads)"); + } +#if defined(__ANDROID__) + if (!FoundryLocalIsAndroidTelemetryReady()) { + logger_.Log(LogLevel::Information, + "[Telemetry] Android 1DS Java HTTP bridge is not initialized; 1DS upload disabled " + "(events still logged locally)"); + return; + } +#endif + const auto token = GetToken(); + if (token.empty()) { + logger_.Log(LogLevel::Information, + "[Telemetry] Token is empty; 1DS upload disabled (events still logged locally)"); + return; + } + + bool log_manager_initialized = false; + try { + impl_ = std::make_unique(); + auto& config = impl_->config; + config[CFG_STR_PRIMARY_TOKEN] = token; + config[CFG_BOOL_SESSION_RESET_ENABLED] = true; + config[CFG_INT_TRACE_LEVEL_MASK] = 0; + config[CFG_INT_SDK_MODE] = SdkModeTypes_CS; + config[CFG_INT_MAX_TEARDOWN_TIME] = kMaxTeardownUploadTimeSec; + if (const auto cache_dir = TelemetryDeviceId::EnsureCacheDirectory(); !cache_dir.empty()) { + config[CFG_STR_CACHE_FILE_PATH] = (cache_dir / "foundry-local.db").string(); + } + + status_t status = STATUS_SUCCESS; + impl_->log_manager = LogManagerProvider::CreateLogManager("FoundryLocal", true, config, status); + if (status != STATUS_SUCCESS || impl_->log_manager == nullptr) { + impl_.reset(); + logger_.Log(LogLevel::Warning, + "[Telemetry] LogManagerProvider::CreateLogManager failed; 1DS upload disabled"); + return; + } + impl_->logger = impl_->log_manager->GetLogger(token); + if (impl_->logger == nullptr) { + try { + impl_->log_manager->FlushAndTeardown(); + LogManagerProvider::Release(impl_->config); + } catch (...) { + } + impl_.reset(); + logger_.Log(LogLevel::Warning, + "[Telemetry] ILogManager::GetLogger returned null; 1DS upload disabled"); + return; + } + log_manager_initialized = true; + if (auto* semantic_context = impl_->logger->GetSemanticContext(); semantic_context != nullptr) { + (void)TelemetryInternal::TrySuppressContext( + [&] { TelemetryInternal::SuppressUnneededCommonContext(*semantic_context); }); + if (!disable_nonessential_telemetry) { + const auto hashed_device_id = TelemetryDeviceId::HashForTelemetry(TelemetryDeviceId::Instance().GetValue()); + if (!hashed_device_id.empty()) { + semantic_context->SetDeviceId(hashed_device_id); + } + } + } + SetCommonContext(impl_->logger, metadata_); + logger_.Log(LogLevel::Information, + fmt::format("[Telemetry] 1DS initialized; AppName={} AppVersion={} Version={} Os={} {} Arch={}", + metadata_.app_name, metadata_.app_version, metadata_.version, metadata_.os_name, + metadata_.os_version, metadata_.cpu_arch)); + initialized_.store(true, std::memory_order_release); + } catch (const std::exception& ex) { + if (log_manager_initialized) { + try { + if (impl_ && impl_->log_manager != nullptr) { + impl_->log_manager->Flush(); + impl_->log_manager->FlushAndTeardown(); + LogManagerProvider::Release(impl_->config); + } + } catch (...) { + } + impl_.reset(); + } + logger_.Log(LogLevel::Warning, + fmt::format("[Telemetry] LogManagerProvider initialization threw: {}; " + "1DS upload disabled", ex.what())); + } catch (...) { + if (log_manager_initialized) { + try { + if (impl_ && impl_->log_manager != nullptr) { + impl_->log_manager->Flush(); + impl_->log_manager->FlushAndTeardown(); + LogManagerProvider::Release(impl_->config); + } + } catch (...) { + } + impl_.reset(); + } + logger_.Log(LogLevel::Warning, + "[Telemetry] LogManagerProvider initialization threw unknown exception; 1DS upload disabled"); + } +} + +OneDsTelemetry::~OneDsTelemetry() { + std::unique_lock lock(mutex_); + if (!initialized_.load(std::memory_order_acquire) || !impl_ || impl_->log_manager == nullptr) { + return; + } + initialized_.store(false, std::memory_order_release); + try { + impl_->log_manager->Flush(); + impl_->log_manager->FlushAndTeardown(); + LogManagerProvider::Release(impl_->config); + } catch (...) { + // Best-effort: never throw from a destructor. + } + impl_.reset(); +} + +void OneDsTelemetry::RecordAction(Action action, ActionStatus status, const InvocationContext& context, + int64_t duration_ms) { + local_log_.RecordAction(action, status, context, duration_ms); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + if (!ShouldSampleEvent(metadata_.app_session_guid, context.correlation_id)) { + return; + } + auto ev = MakeEvent("Action"); + ev.SetProperty("Action", std::string(ActionToString(action))); + ev.SetProperty("Status", std::string(ActionStatusToString(status))); + ev.SetProperty("UserAgent", context.user_agent); + ev.SetProperty("CorrelationId", context.correlation_id); + ev.SetProperty("Direct", !context.indirect); + ev.SetProperty("TimeMs", duration_ms); + SafeLog(impl_->logger, ev); +} + +void OneDsTelemetry::RecordException(Action action, const std::exception& exception, + const InvocationContext& context) { + local_log_.RecordException(action, exception, context); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + if (!ShouldSampleEvent(metadata_.app_session_guid, context.correlation_id)) { + return; + } + auto ev = MakeEvent("Error"); + ev.SetProperty("Action", std::string(ActionToString(action))); + ev.SetProperty("UserAgent", context.user_agent); + ev.SetProperty("CorrelationId", context.correlation_id); + ev.SetProperty("ExceptionType", "std::exception"); + ev.SetProperty("ExceptionMessage", SanitizeTelemetryText(exception.what())); + ev.SetProperty("InnerExceptionType", ""); + ev.SetProperty("InnerExceptionMessage", ""); + ev.SetProperty("StackTrace", ""); + ev.SetProperty("InnerStackTrace", ""); + SafeLog(impl_->logger, ev); +} + +void OneDsTelemetry::RecordModelUsage(const ModelUsageInfo& info) { + local_log_.RecordModelUsage(info); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + if (!ShouldSampleEvent(metadata_.app_session_guid, info.correlation_id)) { + return; + } + auto ev = MakeEvent("Model"); + ev.SetProperty("ModelId", info.model_id); + ev.SetProperty("ExecutionProvider", info.execution_provider); + ev.SetProperty("UserAgent", info.user_agent); + ev.SetProperty("CorrelationId", info.correlation_id); + ev.SetProperty("Stream", info.stream); + ev.SetProperty("Direct", !info.indirect); + ev.SetProperty("TimeToFirstTokenMs", info.time_to_first_token_ms); + ev.SetProperty("TotalTimeMs", info.total_time_ms); + ev.SetProperty("TotalTokens", static_cast(info.total_tokens)); + ev.SetProperty("InputTokenCount", static_cast(info.input_token_count)); + ev.SetProperty("NumMessages", static_cast(info.num_messages)); + ev.SetProperty("MemoryUsedMB", info.memory_used_mb); + ev.SetProperty("CpuTimeMs", info.cpu_time_ms); + ev.SetProperty("GpuMemoryUsedMB", info.gpu_memory_used_mb); + SafeLog(impl_->logger, ev); +} + +void OneDsTelemetry::RecordAudioUsage(const AudioUsageInfo& info) { + local_log_.RecordAudioUsage(info); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + if (!ShouldSampleEvent(metadata_.app_session_guid, info.correlation_id)) { + return; + } + auto ev = MakeEvent("AudioModel"); + ev.SetProperty("ModelId", info.model_id); + ev.SetProperty("ExecutionProvider", info.execution_provider); + ev.SetProperty("UserAgent", info.user_agent); + ev.SetProperty("CorrelationId", info.correlation_id); + ev.SetProperty("AudioSource", info.audio_source); + ev.SetProperty("Language", info.language); + ev.SetProperty("Stream", info.stream); + ev.SetProperty("Direct", !info.indirect); + ev.SetProperty("TotalTimeMs", info.total_time_ms); + ev.SetProperty("TotalTokens", static_cast(info.total_tokens)); + ev.SetProperty("InputTokenCount", static_cast(info.input_token_count)); + ev.SetProperty("CompletionTokenCount", static_cast(info.completion_token_count)); + ev.SetProperty("AudioDurationMs", info.audio_duration_ms); + ev.SetProperty("SampleRate", static_cast(info.sample_rate)); + ev.SetProperty("Channels", static_cast(info.channels)); + SafeLog(impl_->logger, ev); +} + +void OneDsTelemetry::RecordModelId(Action action, const std::string& model_id, + ActionStatus status, const InvocationContext& context) { + local_log_.RecordModelId(action, model_id, status, context); + auto lock = LockForLogging(); + if (!lock.owns_lock() || model_id.empty()) { + return; + } + if (!ShouldSampleEvent(metadata_.app_session_guid, context.correlation_id)) { + return; + } + auto ev = MakeEvent("ModelId"); + ev.SetProperty("Action", std::string(ActionToString(action))); + ev.SetProperty("ModelId", model_id); + ev.SetProperty("Status", std::string(ActionStatusToString(status))); + ev.SetProperty("UserAgent", context.user_agent); + ev.SetProperty("CorrelationId", context.correlation_id); + SafeLog(impl_->logger, ev); +} + +void OneDsTelemetry::RecordEpDownloadAttempt(const EpDownloadAttemptInfo& info) { + local_log_.RecordEpDownloadAttempt(info); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + if (!ShouldSampleEvent(metadata_.app_session_guid, info.correlation_id)) { + return; + } + auto ev = MakeEvent("EPDownloadAttempt"); + ev.SetProperty("UserAgent", info.user_agent); + ev.SetProperty("CorrelationId", info.correlation_id); + ev.SetProperty("Attempts", static_cast(info.attempts)); + ev.SetProperty("NumProviders", static_cast(info.num_providers)); + ev.SetProperty("Succeeded", static_cast(info.succeeded)); + ev.SetProperty("Failed", static_cast(info.failed)); + ev.SetProperty("Resolved", info.resolved); + ev.SetProperty("Status", std::string(ActionStatusToString(info.status))); + ev.SetProperty("TimeMs", info.duration_ms); + SafeLog(impl_->logger, ev); +} + +void OneDsTelemetry::RecordEpDownloadAndRegister(const EpDownloadAndRegisterInfo& info) { + local_log_.RecordEpDownloadAndRegister(info); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + if (!ShouldSampleEvent(metadata_.app_session_guid, info.correlation_id)) { + return; + } + auto ev = MakeEvent("EPDownloadAndRegister"); + ev.SetProperty("UserAgent", info.user_agent); + ev.SetProperty("CorrelationId", info.correlation_id); + ev.SetProperty("ProviderName", info.provider_name); + ev.SetProperty("InitReadyState", info.init_ready_state); + ev.SetProperty("DownloadReadyState", info.download_ready_state); + ev.SetProperty("DownloadStatus", std::string(ActionStatusToString(info.download_status))); + ev.SetProperty("DownloadTimeMs", info.download_duration_ms); + ev.SetProperty("RegisterReadyState", info.register_ready_state); + ev.SetProperty("RegisterStatus", std::string(ActionStatusToString(info.register_status))); + ev.SetProperty("RegisterTimeMs", info.register_duration_ms); + SafeLog(impl_->logger, ev); +} + +void OneDsTelemetry::RecordDownload(const DownloadInfo& info) { + local_log_.RecordDownload(info); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + if (!ShouldSampleEvent(metadata_.app_session_guid, info.correlation_id)) { + return; + } + auto ev = MakeEvent("Download"); + ev.SetProperty("UserAgent", info.user_agent); + ev.SetProperty("CorrelationId", info.correlation_id); + ev.SetProperty("ModelId", info.model_id); + ev.SetProperty("Status", std::string(ActionStatusToString(info.status))); + ev.SetProperty("LockWaitTimeMs", info.lock_wait_ms); + ev.SetProperty("EnumerationTimeMs", info.enumeration_ms); + ev.SetProperty("DownloadTimeMs", info.download_ms); + ev.SetProperty("TotalSizeBytes", info.total_size_bytes); + ev.SetProperty("AlreadyCachedBytes", info.already_cached_bytes); + ev.SetProperty("FileCount", static_cast(info.file_count)); + ev.SetProperty("SkippedFileCount", static_cast(info.skipped_file_count)); + ev.SetProperty("DownloadWaitResult", info.download_wait_result); + ev.SetProperty("MaxConcurrency", static_cast(info.max_concurrency)); + SafeLog(impl_->logger, ev); +} + +void OneDsTelemetry::RecordCatalogFetch(const CatalogFetchInfo& info) { + local_log_.RecordCatalogFetch(info); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + if (!ShouldSampleEvent(metadata_.app_session_guid, info.correlation_id)) { + return; + } + auto ev = MakeEvent("CatalogFetch"); + ev.SetProperty("Operation", info.operation); + ev.SetProperty("Endpoint", info.endpoint); + ev.SetProperty("Region", info.region); + ev.SetProperty("Format", info.format); + ev.SetProperty("Status", std::string(ActionStatusToString(info.status))); + ev.SetProperty("TimeMs", info.duration_ms); + ev.SetProperty("ModelCount", static_cast(info.model_count)); + ev.SetProperty("ErrorMessage", SanitizeTelemetryText(info.error_message)); + ev.SetProperty("UserAgent", info.user_agent); + ev.SetProperty("CorrelationId", info.correlation_id); + SafeLog(impl_->logger, ev); +} + +void OneDsTelemetry::RecordProcessInfo(const ProcessInfo& info) { + local_log_.RecordProcessInfo(info); + auto lock = LockForLogging(/*require_upload=*/false); + if (!lock.owns_lock()) { + return; + } + auto ev = MakeEvent("ProcessInfo"); + ev.SetProperty("appVersion", info.app_version); + ev.SetProperty("appName", info.app_name); + ev.SetProperty("osName", info.os_name); + ev.SetProperty("osVersion", info.os_version); + ev.SetProperty("architecture", info.cpu_arch); + ev.SetProperty("processName", info.process_name); + ev.SetProperty("DeviceInfo.Status", info.device_id_status); + ev.SetProperty("cpuCount", static_cast(info.cpu_count)); + ev.SetProperty("totalMemoryMB", info.total_memory_mb); + ev.SetProperty("locale", info.locale); + SafeLog(impl_->logger, ev); +} + +void OneDsTelemetry::RecordHardwareInfo(const HardwareInfo& info) { + local_log_.RecordHardwareInfo(info); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + auto ev = MakeEvent("HardwareInfo"); + ev.SetProperty("DeviceTypes", info.device_types); + ev.SetProperty("ExecutionProviders", info.execution_providers); + ev.SetProperty("DeviceTypeCount", static_cast(info.device_type_count)); + ev.SetProperty("ExecutionProviderCount", static_cast(info.execution_provider_count)); + ev.SetProperty("HasCPU", info.has_cpu); + ev.SetProperty("HasGPU", info.has_gpu); + ev.SetProperty("HasNPU", info.has_npu); + SafeLog(impl_->logger, ev); +} + +void OneDsTelemetry::StartSession() { + local_log_.StartSession(); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + // LogSession(Started) opens an app-usage session; the SDK stamps ext.app.sesId + // on subsequent events and records session duration on End. + auto ev = MakeEvent("Session"); + impl_->logger->LogSession(SessionState::Session_Started, ev); +} + +void OneDsTelemetry::EndSession() { + local_log_.EndSession(); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + auto ev = MakeEvent("Session"); + impl_->logger->LogSession(SessionState::Session_Ended, ev); +} + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/one_ds_telemetry.h b/sdk_v2/cpp/src/telemetry/one_ds_telemetry.h new file mode 100644 index 000000000..34cf55820 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/one_ds_telemetry.h @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include "telemetry/telemetry.h" +#include "telemetry/telemetry_logger.h" +#include "telemetry/telemetry_metadata.h" +#include "logger.h" + +#include +#include +#include +#include + +namespace fl { + +/// 1DS-backed ITelemetry implementation. Manager config can suppress non-essential uploads while still allowing +/// ProcessInfo; CI and unit-test processes suppress upload entirely. +class OneDsTelemetry : public ITelemetry { + public: + /// @param app_name Configuration::app_name; stamped as AppName on every event. + /// @param logger Diagnostic logger; used by the embedded TelemetryLogger mirror. + /// @param disable_nonessential_telemetry When true, non-essential uploads are suppressed; ProcessInfo still + /// uploads and events are still written to the local diagnostic logger. + OneDsTelemetry(const std::string& app_name, + ILogger& logger, + bool disable_nonessential_telemetry = false); + ~OneDsTelemetry() override; + + // Non-copyable, non-movable. + OneDsTelemetry(const OneDsTelemetry&) = delete; + OneDsTelemetry& operator=(const OneDsTelemetry&) = delete; + + void RecordAction(Action action, ActionStatus status, const InvocationContext& context, + int64_t duration_ms) override; + + void RecordException(Action action, const std::exception& exception, + const InvocationContext& context) override; + + void RecordModelUsage(const ModelUsageInfo& info) override; + void RecordAudioUsage(const AudioUsageInfo& info) override; + void RecordModelId(Action action, const std::string& model_id, + ActionStatus status, const InvocationContext& context) override; + void RecordEpDownloadAttempt(const EpDownloadAttemptInfo& info) override; + void RecordEpDownloadAndRegister(const EpDownloadAndRegisterInfo& info) override; + void RecordDownload(const DownloadInfo& info) override; + void RecordCatalogFetch(const CatalogFetchInfo& info) override; + void RecordProcessInfo(const ProcessInfo& info) override; + void RecordHardwareInfo(const HardwareInfo& info) override; + void StartSession() override; + void EndSession() override; + + /// True if 1DS Initialize succeeded and non-essential uploads are enabled. + /// ProcessInfo may still upload when disable_nonessential_telemetry suppresses usage events. + bool IsUploadEnabled() const { + return initialized_.load(std::memory_order_acquire) && upload_enabled_.load(std::memory_order_acquire); + } + + private: + struct Impl; + + std::shared_lock LockForLogging(bool require_upload = true) const; + + TelemetryLogger local_log_; + TelemetryMetadata metadata_; // Cached at construction. + std::unique_ptr impl_; + std::atomic initialized_{false}; + std::atomic upload_enabled_{true}; // False when non-essential uploads are suppressed. + mutable std::shared_mutex mutex_; // Serializes logging calls with teardown. + ILogger& logger_; +}; + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/one_ds_tenant_token.h.in b/sdk_v2/cpp/src/telemetry/one_ds_tenant_token.h.in new file mode 100644 index 000000000..46d2e74e5 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/one_ds_tenant_token.h.in @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft. All rights reserved. +// Auto-generated by CMake from one_ds_tenant_token.h.in — do not edit manually. +#pragma once + +@FOUNDRY_LOCAL_TELEMETRY_TOKEN_DEFINE@ diff --git a/sdk_v2/cpp/src/telemetry/telemetry.cc b/sdk_v2/cpp/src/telemetry/telemetry.cc index 6d8fc06f6..274473b1b 100644 --- a/sdk_v2/cpp/src/telemetry/telemetry.cc +++ b/sdk_v2/cpp/src/telemetry/telemetry.cc @@ -2,6 +2,8 @@ // Licensed under the MIT License. #include "telemetry/telemetry.h" +#include "exception.h" + namespace fl { std::string_view ActionToString(Action action) { @@ -22,10 +24,6 @@ std::string_view ActionToString(Action action) { return "ModelLoad"; case Action::kModelUnload: return "ModelUnload"; - case Action::kModelDownload: - return "ModelDownload"; - case Action::kModelDelete: - return "ModelDelete"; case Action::kModelList: return "ModelList"; case Action::kOpenAIChatCompletions: @@ -48,8 +46,14 @@ std::string_view ActionToString(Action action) { return "OpenAIResponsesDelete"; case Action::kOpenAIResponsesGetInputItems: return "OpenAIResponsesGetInputItems"; - case Action::kCoreAudioTranscribe: - return "CoreAudioTranscribe"; + case Action::kEpDownloadAttempt: + return "EPDownloadAttempt"; + case Action::kEpDownloadAndRegister: + return "EPDownloadAndRegister"; + case Action::kModelFileDownload: + return "ModelFileDownload"; + case Action::kModelInference: + return "ModelInference"; default: return "Unknown"; } @@ -65,9 +69,36 @@ std::string_view ActionStatusToString(ActionStatus status) { return "Invalid"; case ActionStatus::kSkipped: return "Skipped"; + case ActionStatus::kClientError: + return "ClientError"; + case ActionStatus::kCanceled: + return "Canceled"; + case ActionStatus::kDependencyFailure: + return "DependencyFailure"; + case ActionStatus::kTimeout: + return "Timeout"; default: return "Unknown"; } } +ActionStatus ActionStatusFromException(const std::exception& exception) { + const auto* foundry_exception = dynamic_cast(&exception); + if (foundry_exception == nullptr) { + return ActionStatus::kFailure; + } + + switch (foundry_exception->code()) { + case FOUNDRY_LOCAL_ERROR_INVALID_ARGUMENT: + case FOUNDRY_LOCAL_ERROR_INVALID_USAGE: + return ActionStatus::kClientError; + case FOUNDRY_LOCAL_ERROR_OPERATION_CANCELLED: + return ActionStatus::kCanceled; + case FOUNDRY_LOCAL_ERROR_NETWORK: + return ActionStatus::kDependencyFailure; + default: + return ActionStatus::kFailure; + } +} + } // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/telemetry.h b/sdk_v2/cpp/src/telemetry/telemetry.h index 680dfcd9a..d4f01403c 100644 --- a/sdk_v2/cpp/src/telemetry/telemetry.h +++ b/sdk_v2/cpp/src/telemetry/telemetry.h @@ -2,6 +2,8 @@ // Licensed under the MIT License. #pragma once +#include "telemetry/invocation_context.h" + #include #include #include @@ -9,8 +11,9 @@ namespace fl { -/// Telemetry action identifiers matching the C# ITelemetry.Action enum. -// TODO: This is a lie. The enum values don't match. Do they need to? +/// Telemetry action identifiers. The values are stable IDs for log greppability; +/// the over-the-wire field is the human-readable name produced by ActionToString. +/// The 1DS implementation emits the string, so changing numeric values is safe. enum class Action { kInvalid = 0, @@ -26,8 +29,6 @@ enum class Action { // Model management kModelLoad = 100, kModelUnload = 101, - kModelDownload = 102, - kModelDelete = 103, kModelList = 104, // OpenAI Chat/Audio/Embeddings APIs @@ -44,16 +45,28 @@ enum class Action { kOpenAIResponsesDelete = 303, kOpenAIResponsesGetInputItems = 304, - // Audio - kCoreAudioTranscribe = 400, + // EP catalog operations + kEpDownloadAttempt = 500, // Wraps the entire DownloadAndRegisterEps call + kEpDownloadAndRegister = 501, // One per-provider attempt within DownloadAndRegisterEps + + // Model file download + kModelFileDownload = 600, // Wraps the per-model DownloadManager flow + + // EP runtime usage (TimeToFirstToken / total tokens / memory) + kModelInference = 700, // The "Model" event in the C# implementation + }; /// Status of a tracked telemetry action. enum class ActionStatus { - kFailure = 0, + kFailure = 0, // Internal failure while executing valid work kSuccess, kInvalid, kSkipped, + kClientError, // Rejected due to invalid client input (maps to HTTP 4xx) — not a service fault + kCanceled, + kDependencyFailure, + kTimeout, }; /// Convert Action to human-readable string. @@ -62,29 +75,184 @@ std::string_view ActionToString(Action action); /// Convert ActionStatus to human-readable string. std::string_view ActionStatusToString(ActionStatus status); +/// Classify a thrown exception into an action status. +ActionStatus ActionStatusFromException(const std::exception& exception); + +/// Payload for the EPDownloadAttempt event — emitted once per DownloadAndRegisterEps call. +struct EpDownloadAttemptInfo { + std::string user_agent; + std::string correlation_id; + int attempts = 0; // Total per-provider attempts made + int num_providers = 0; // Number of providers requested + int succeeded = 0; // Number of providers that registered successfully + int failed = 0; // Number of providers that failed + bool resolved = false; // True if at least one provider became Registered + ActionStatus status = ActionStatus::kInvalid; + int64_t duration_ms = 0; +}; + +/// Payload for the EPDownloadAndRegister event — emitted once per provider attempt. +struct EpDownloadAndRegisterInfo { + std::string user_agent; + std::string correlation_id; + std::string provider_name; + std::string init_ready_state; // EP state before this call (e.g. "NotPresent") + std::string download_ready_state; // EP state after the download phase (e.g. "Installed") + ActionStatus download_status = ActionStatus::kInvalid; + int64_t download_duration_ms = 0; + std::string register_ready_state; // EP state after the register phase (e.g. "Registered") + ActionStatus register_status = ActionStatus::kInvalid; + int64_t register_duration_ms = 0; +}; + +/// Payload for the Model event — emitted once per inference completion with token / memory metrics. +struct ModelUsageInfo { + std::string model_id; + std::string execution_provider; + std::string user_agent; + std::string correlation_id; + bool stream = false; // True if the inference was streamed (SSE) vs a single response + bool indirect = false; // True if the inference was driven by another action (e.g. an HTTP route) + int64_t time_to_first_token_ms = -1; // -1 if not measured + int64_t total_time_ms = 0; + int32_t total_tokens = 0; + int32_t input_token_count = 0; + uint64_t num_messages = 0; + int64_t memory_used_mb = -1; // -1 if not measured + int64_t cpu_time_ms = -1; // -1 if not measured + int64_t gpu_memory_used_mb = -1; // -1 if not measured +}; + +/// Payload for the AudioModel event — emitted once per successful audio inference with audio-specific metrics. +struct AudioUsageInfo { + std::string model_id; + std::string execution_provider; + std::string user_agent; + std::string correlation_id; + std::string audio_source; // "file", "openai_json_file", or "streaming_pcm"; never a path/name + std::string language; // Request/session language hint when provided; empty if unset + bool stream = false; + bool indirect = false; + int64_t total_time_ms = 0; + int32_t total_tokens = 0; + int32_t input_token_count = 0; + int32_t completion_token_count = 0; + int64_t audio_duration_ms = -1; // -1 if not measured + int32_t sample_rate = 0; // 0 if not known + int32_t channels = 0; // 0 if not known +}; + +/// Payload for the Download event — emitted once per DownloadManager::DownloadModel call. +struct DownloadInfo { + std::string model_id; + std::string user_agent; + std::string correlation_id; + ActionStatus status = ActionStatus::kInvalid; + int64_t lock_wait_ms = 0; + int64_t enumeration_ms = 0; + int64_t download_ms = 0; + int64_t total_size_bytes = 0; + int64_t already_cached_bytes = 0; + int32_t file_count = 0; + int32_t skipped_file_count = 0; + std::string download_wait_result; // e.g. "Completed", "TimedOut", "AlreadyHeld" + int32_t max_concurrency = 0; +}; + +/// Payload for the CatalogFetch event — emitted for primary catalog refreshes +/// and cache-miss/cached-id lookups against a model catalog source. +struct CatalogFetchInfo { + std::string operation; // "FetchAll" (full catalog) or "FetchByIds" (cached-id lookup) + std::string endpoint; // catalog host (e.g. "ai.azure.com"), or "static" for the embedded snapshot + std::string region; // region parsed from the catalog URL (e.g. "eastus"); empty if not present + std::string format; // catalog API path/version after the region (e.g. "ux/v1.0") + ActionStatus status = ActionStatus::kInvalid; + int64_t duration_ms = 0; + int32_t model_count = 0; // models returned by this access + std::string error_message; // populated on failure + std::string user_agent; + std::string correlation_id; // shared across the accesses of one catalog refresh +}; + +/// Payload for coarse hardware/accelerator availability at startup. +struct HardwareInfo { + bool has_cpu = false; + bool has_gpu = false; + bool has_npu = false; + int32_t device_type_count = 0; + int32_t execution_provider_count = 0; + std::string device_types; // comma-separated coarse device classes, e.g. "CPU,GPU" + std::string execution_providers; // comma-separated provider names, e.g. "CPUExecutionProvider,CUDAExecutionProvider" +}; + +/// Payload for the ProcessInfo event — emitted once during process startup when telemetry is not CI-suppressed. +struct ProcessInfo { + std::string app_name; + std::string app_version; + std::string os_name; + std::string os_version; + std::string cpu_arch; + std::string process_name; + std::string locale; + std::string device_id_status; + int32_t cpu_count = 0; + int64_t total_memory_mb = -1; +}; + /// Abstract telemetry interface. -/// Implementations may send events to a telemetry backend (ETW, OpenTelemetry, etc.) -/// or simply log them. The stub TelemetryLogger logs via ILogger. +/// Implementations may send events to a telemetry backend (1DS, ETW, OpenTelemetry, …) +/// or simply log them. The OneDsTelemetry implementation sends to 1DS; the +/// TelemetryLogger stub formats them to the ILogger sink. class ITelemetry { public: virtual ~ITelemetry() = default; - /// Record a completed action with timing and status. + /// Record a completed action with timing and status. The context carries the + /// user agent, the correlation id grouping this operation's events, and whether + /// the action was indirect (triggered by another action). virtual void RecordAction(Action action, ActionStatus status, - const std::string& user_agent, - bool indirect, int64_t duration_ms) = 0; + const InvocationContext& context, int64_t duration_ms) = 0; /// Record an exception associated with an action. - virtual void RecordException(Action action, const std::exception& exception) = 0; + virtual void RecordException(Action action, const std::exception& exception, + const InvocationContext& context) = 0; + + /// Record model usage metrics after inference (Model event). + virtual void RecordModelUsage(const ModelUsageInfo& info) = 0; + + /// Record audio-specific inference metrics after audio inference (AudioModel event). + virtual void RecordAudioUsage(const AudioUsageInfo& /*info*/) {} + + /// Record which model was used for an action (ModelId event). + virtual void RecordModelId(Action action, const std::string& model_id, + ActionStatus status, const InvocationContext& context) = 0; + + /// Record the result of a DownloadAndRegisterEps call (EPDownloadAttempt event). + virtual void RecordEpDownloadAttempt(const EpDownloadAttemptInfo& info) = 0; + + /// Record one EP provider's download+register attempt (EPDownloadAndRegister event). + virtual void RecordEpDownloadAndRegister(const EpDownloadAndRegisterInfo& info) = 0; + + /// Record one model file download (Download event). + virtual void RecordDownload(const DownloadInfo& info) = 0; + + /// Record one access to a model catalog source (CatalogFetch event). + virtual void RecordCatalogFetch(const CatalogFetchInfo& info) = 0; + + /// Record one process/system metadata snapshot. Default no-op for test fakes + /// and embedders that do not care about startup telemetry. + virtual void RecordProcessInfo(const ProcessInfo& /*info*/) {} - /// Record model usage metrics after inference. - virtual void RecordModelUsage(const std::string& model_id, - int64_t prompt_tokens, - int64_t completion_tokens, - int64_t duration_ms) = 0; + /// Record coarse hardware/accelerator availability. Default no-op for test fakes + /// and embedders that do not care about startup telemetry. + virtual void RecordHardwareInfo(const HardwareInfo& /*info*/) {} - /// Record which model was used for an action. - virtual void RecordModelId(Action action, const std::string& model_id) = 0; + /// Mark the start / end of an app-usage session via 1DS LogSession. Between + /// these the SDK stamps a session id (ext.app.sesId) on events and emits a + /// session start/end with duration — standard, cross-platform engagement + /// sessions. Default no-op for the non-1DS implementations. + virtual void StartSession() {} + virtual void EndSession() {} }; } // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/telemetry_action_tracker.cc b/sdk_v2/cpp/src/telemetry/telemetry_action_tracker.cc index 7672cb1f4..e512e4544 100644 --- a/sdk_v2/cpp/src/telemetry/telemetry_action_tracker.cc +++ b/sdk_v2/cpp/src/telemetry/telemetry_action_tracker.cc @@ -2,24 +2,35 @@ // Licensed under the MIT License. #include "telemetry/telemetry_action_tracker.h" +#include + namespace fl { -ActionTracker::ActionTracker(Action action, ITelemetry& telemetry, const std::string& user_agent, bool indirect) +ActionTracker::ActionTracker(Action action, ITelemetry& telemetry, InvocationContext context) : action_(action), telemetry_(telemetry), - user_agent_(user_agent), - indirect_(indirect), + context_(std::move(context)), start_(std::chrono::steady_clock::now()) { + // Guarantee a correlation id so this action and anything it triggers can be + // grouped, even when the caller passed a default-constructed context. + if (context_.user_agent.empty()) { + context_.user_agent = DefaultUserAgent(); + } + context_.EnsureCorrelationId(); } ActionTracker::~ActionTracker() { - auto end = std::chrono::steady_clock::now(); - auto duration_ms = std::chrono::duration_cast(end - start_).count(); + try { + auto end = std::chrono::steady_clock::now(); + auto duration_ms = std::chrono::duration_cast(end - start_).count(); - telemetry_.RecordAction(action_, status_, user_agent_, indirect_, duration_ms); + telemetry_.RecordAction(action_, status_, context_, duration_ms); - if (!model_id_.empty()) { - telemetry_.RecordModelId(action_, model_id_); + if (!model_id_.empty()) { + telemetry_.RecordModelId(action_, model_id_, status_, context_); + } + } catch (...) { + // Telemetry is best-effort and must not throw from RAII cleanup. } } @@ -28,7 +39,12 @@ void ActionTracker::SetStatus(ActionStatus status) { } void ActionTracker::RecordException(const std::exception& exception) { - telemetry_.RecordException(action_, exception); + status_ = ActionStatusFromException(exception); + try { + telemetry_.RecordException(action_, exception, context_); + } catch (...) { + // Telemetry is best-effort and must not mask the original error path. + } } void ActionTracker::SetModelId(const std::string& model_id) { diff --git a/sdk_v2/cpp/src/telemetry/telemetry_action_tracker.h b/sdk_v2/cpp/src/telemetry/telemetry_action_tracker.h index e06da6130..e19971eb2 100644 --- a/sdk_v2/cpp/src/telemetry/telemetry_action_tracker.h +++ b/sdk_v2/cpp/src/telemetry/telemetry_action_tracker.h @@ -14,9 +14,7 @@ namespace fl { /// Matches the C# ActionTracker (IDisposable) pattern. class ActionTracker { public: - ActionTracker(Action action, ITelemetry& telemetry, - const std::string& user_agent = "", - bool indirect = false); + ActionTracker(Action action, ITelemetry& telemetry, InvocationContext context = {}); ~ActionTracker(); // Non-copyable, non-movable @@ -32,11 +30,15 @@ class ActionTracker { /// Associate a model ID with this action. void SetModelId(const std::string& model_id); + /// This action's context, with its correlation id resolved. Use to derive a + /// child context (Context().AsIndirect()) for any caused-by action so all + /// events from one operation share a correlation id. + const InvocationContext& Context() const { return context_; } + private: Action action_; ITelemetry& telemetry_; - std::string user_agent_; - bool indirect_; + InvocationContext context_; ActionStatus status_ = ActionStatus::kFailure; std::string model_id_; std::chrono::steady_clock::time_point start_; diff --git a/sdk_v2/cpp/src/telemetry/telemetry_context.h b/sdk_v2/cpp/src/telemetry/telemetry_context.h new file mode 100644 index 000000000..cbff98668 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_context.h @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include +#include +#include + +namespace fl::TelemetryInternal { + +inline constexpr std::array kSuppressedCommonContextFields{ + "AppInfo.Language", + "AppInfo.Name", + "UserInfo.Language", + "UserInfo.TimeZone", + "M365aInfo.EnrolledTenantId", +}; + +template +bool TrySuppressContext(Suppression&& suppression) noexcept { + try { + std::forward(suppression)(); + return true; + } catch (...) { + return false; + } +} + +template +void SuppressUnneededCommonContext(SemanticContext& context) { + for (const char* field : kSuppressedCommonContextFields) { + context.SetCommonField(field, std::string{}); + } +} + +} // namespace fl::TelemetryInternal diff --git a/sdk_v2/cpp/src/telemetry/telemetry_environment.cc b/sdk_v2/cpp/src/telemetry/telemetry_environment.cc new file mode 100644 index 000000000..e8f8761b2 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_environment.cc @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "telemetry/telemetry_environment.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#endif + +namespace fl { + +namespace { + +// Mirrors neutron-server's CiEnvironmentVariableNames. Keep this in sync if the +// list there changes — telemetry behavior in CI must match across stacks. +constexpr std::array kCiEnvironmentVariableNames = { + "CI", // Generic CI flag used by many providers + "TF_BUILD", // Azure Pipelines + "GITHUB_ACTIONS", // GitHub Actions + "GITLAB_CI", // GitLab CI + "CIRCLECI", // CircleCI + "TRAVIS", // Travis CI + "JENKINS_URL", // Jenkins + "CODEBUILD_BUILD_ID", // AWS CodeBuild + "BUILDKITE", // Buildkite + "TEAMCITY_VERSION", // TeamCity + "APPVEYOR", // AppVeyor + "BITBUCKET_BUILD_NUMBER", // Bitbucket Pipelines + "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI", // Azure DevOps +}; + +bool EqualsIgnoreCase(std::string_view a, std::string_view b) { + if (a.size() != b.size()) { + return false; + } + for (size_t i = 0; i < a.size(); ++i) { + if (std::tolower(static_cast(a[i])) != + std::tolower(static_cast(b[i]))) { + return false; + } + } + return true; +} + +std::string_view Trim(std::string_view s) { + auto is_ws = [](unsigned char c) { return std::isspace(c) != 0; }; + while (!s.empty() && is_ws(static_cast(s.front()))) { + s.remove_prefix(1); + } + while (!s.empty() && is_ws(static_cast(s.back()))) { + s.remove_suffix(1); + } + return s; +} + +} // namespace + +std::string TelemetryEnvironment::GetEnv(const char* name) { +#ifdef _WIN32 + // Env-var values are ASCII for the CI flags we care about; use the Win32 A API + // to avoid depending on CRT getenv behavior. + DWORD needed = ::GetEnvironmentVariableA(name, nullptr, 0); + if (needed == 0) { + return {}; + } + std::vector buf(needed); + DWORD written = ::GetEnvironmentVariableA(name, buf.data(), needed); + if (written == 0 || written >= needed) { + return {}; + } + return std::string(buf.data(), written); +#else + const char* value = std::getenv(name); + return value ? std::string(value) : std::string{}; +#endif +} + +bool TelemetryEnvironment::IsTruthyValue(std::string_view value) { + auto trimmed = Trim(value); + if (trimmed.empty()) { + return false; + } + return !EqualsIgnoreCase(trimmed, "0") && + !EqualsIgnoreCase(trimmed, "false") && + !EqualsIgnoreCase(trimmed, "no") && + !EqualsIgnoreCase(trimmed, "off"); +} + +bool TelemetryEnvironment::IsCiEnvironment() { + for (const char* name : kCiEnvironmentVariableNames) { + if (IsTruthyValue(GetEnv(name))) { + return true; + } + } + return false; +} + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/telemetry_environment.h b/sdk_v2/cpp/src/telemetry/telemetry_environment.h new file mode 100644 index 000000000..3fafbe8b5 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_environment.h @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include +#include + +namespace fl { + +/// Static helpers for telemetry runtime gating. Ported from neutron-server's +/// TelemetryEnvironment.cs so the CI suppression behavior matches across stacks. +class TelemetryEnvironment { + public: + /// Returns true if any well-known CI environment variable is set to a truthy + /// value. The set matches neutron-server's TelemetryEnvironment.cs. + /// In CI, OneDsTelemetry skips Initialize entirely — no 1DS events emitted. + static bool IsCiEnvironment(); + + /// Truthy-value semantics: a non-empty, non-whitespace string whose trimmed + /// value is not "0", "false", "no", or "off" (case-insensitive). + static bool IsTruthyValue(std::string_view value); + + /// Read an env var (cross-platform). Returns empty string if unset. + static std::string GetEnv(const char* name); +}; + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/telemetry_logger.cc b/sdk_v2/cpp/src/telemetry/telemetry_logger.cc index 68d250513..415caca88 100644 --- a/sdk_v2/cpp/src/telemetry/telemetry_logger.cc +++ b/sdk_v2/cpp/src/telemetry/telemetry_logger.cc @@ -2,6 +2,8 @@ // Licensed under the MIT License. #include "telemetry/telemetry_logger.h" +#include "telemetry/telemetry_redaction.h" + #include namespace fl { @@ -10,34 +12,123 @@ TelemetryLogger::TelemetryLogger(const std::string& app_name, ILogger& logger) : app_name_(app_name), logger_(logger) { } -void TelemetryLogger::RecordAction(Action action, ActionStatus status, const std::string& user_agent, - bool indirect, int64_t duration_ms) { +void TelemetryLogger::RecordAction(Action action, ActionStatus status, const InvocationContext& context, + int64_t duration_ms) { + logger_.Log(LogLevel::Debug, + fmt::format("[Telemetry] Action AppName={} UserAgent={} CorrelationId={} Action={} Status={} " + "Direct={} TimeMs={}", + app_name_, context.user_agent, context.correlation_id, ActionToString(action), + ActionStatusToString(status), !context.indirect, duration_ms)); +} + +void TelemetryLogger::RecordException(Action action, const std::exception& exception, + const InvocationContext& context) { + logger_.Log(LogLevel::Debug, + fmt::format("[Telemetry] Error AppName={} UserAgent={} CorrelationId={} Action={} Exception={}", + app_name_, context.user_agent, context.correlation_id, ActionToString(action), + ScrubStringForTelemetry(exception.what()))); +} + +void TelemetryLogger::RecordModelUsage(const ModelUsageInfo& info) { + logger_.Log(LogLevel::Debug, + fmt::format("[Telemetry] Model AppName={} UserAgent={} CorrelationId={} ModelId={} EP={} " + "Stream={} Direct={} TimeToFirstTokenMs={} " + "TotalTimeMs={} TotalTokens={} InputTokenCount={} NumMessages={} MemoryUsedMB={} " + "CpuTimeMs={} GpuMemoryUsedMB={}", + app_name_, info.user_agent, info.correlation_id, info.model_id, + info.execution_provider, info.stream, !info.indirect, + info.time_to_first_token_ms, info.total_time_ms, info.total_tokens, + info.input_token_count, info.num_messages, info.memory_used_mb, + info.cpu_time_ms, info.gpu_memory_used_mb)); +} + +void TelemetryLogger::RecordAudioUsage(const AudioUsageInfo& info) { + logger_.Log(LogLevel::Debug, + fmt::format("[Telemetry] AudioModel AppName={} UserAgent={} CorrelationId={} ModelId={} EP={} " + "AudioSource={} Language={} Stream={} Direct={} TotalTimeMs={} TotalTokens={} " + "InputTokenCount={} CompletionTokenCount={} AudioDurationMs={} SampleRate={} Channels={}", + app_name_, info.user_agent, info.correlation_id, info.model_id, info.execution_provider, + info.audio_source, info.language, info.stream, !info.indirect, info.total_time_ms, + info.total_tokens, info.input_token_count, info.completion_token_count, + info.audio_duration_ms, info.sample_rate, info.channels)); +} + +void TelemetryLogger::RecordModelId(Action action, const std::string& model_id, + ActionStatus status, const InvocationContext& context) { logger_.Log(LogLevel::Debug, - fmt::format("[Telemetry] AppName:{} UserAgent:{} Command:{} Status:{} Direct:{} Time:{}ms", - app_name_, user_agent, ActionToString(action), - ActionStatusToString(status), !indirect, duration_ms)); + fmt::format("[Telemetry] ModelId AppName={} UserAgent={} CorrelationId={} Action={} ModelId={} " + "Status={}", + app_name_, context.user_agent, context.correlation_id, ActionToString(action), + model_id, ActionStatusToString(status))); } -void TelemetryLogger::RecordException(Action action, const std::exception& exception) { +void TelemetryLogger::RecordEpDownloadAttempt(const EpDownloadAttemptInfo& info) { logger_.Log(LogLevel::Debug, - fmt::format("[Telemetry] AppName:{} Command:{} Exception:{}", - app_name_, ActionToString(action), exception.what())); + fmt::format("[Telemetry] EPDownloadAttempt AppName={} UserAgent={} CorrelationId={} Attempts={} " + "NumProviders={} Succeeded={} Failed={} Resolved={} Status={} TimeMs={}", + app_name_, info.user_agent, info.correlation_id, info.attempts, info.num_providers, + info.succeeded, info.failed, info.resolved, + ActionStatusToString(info.status), info.duration_ms)); } -void TelemetryLogger::RecordModelUsage(const std::string& model_id, - int64_t prompt_tokens, - int64_t completion_tokens, - int64_t duration_ms) { +void TelemetryLogger::RecordEpDownloadAndRegister(const EpDownloadAndRegisterInfo& info) { logger_.Log(LogLevel::Debug, - fmt::format("[Telemetry] AppName:{} ModelUsage: model={} prompt_tokens={} " - "completion_tokens={} duration={}ms", - app_name_, model_id, prompt_tokens, completion_tokens, duration_ms)); + fmt::format("[Telemetry] EPDownloadAndRegister AppName={} UserAgent={} CorrelationId={} Provider={} " + "InitReadyState={} DownloadReadyState={} DownloadStatus={} DownloadTimeMs={} " + "RegisterReadyState={} RegisterStatus={} RegisterTimeMs={}", + app_name_, info.user_agent, info.correlation_id, info.provider_name, + info.init_ready_state, info.download_ready_state, + ActionStatusToString(info.download_status), info.download_duration_ms, + info.register_ready_state, ActionStatusToString(info.register_status), + info.register_duration_ms)); } -void TelemetryLogger::RecordModelId(Action action, const std::string& model_id) { +void TelemetryLogger::RecordDownload(const DownloadInfo& info) { logger_.Log(LogLevel::Debug, - fmt::format("[Telemetry] AppName:{} Command:{} ModelId:{}", - app_name_, ActionToString(action), model_id)); + fmt::format("[Telemetry] Download AppName={} UserAgent={} CorrelationId={} ModelId={} Status={} " + "LockWaitMs={} EnumerationMs={} DownloadMs={} TotalSizeBytes={} " + "AlreadyCachedBytes={} FileCount={} SkippedFileCount={} " + "DownloadWaitResult={} MaxConcurrency={}", + app_name_, info.user_agent, info.correlation_id, info.model_id, + ActionStatusToString(info.status), info.lock_wait_ms, + info.enumeration_ms, info.download_ms, info.total_size_bytes, + info.already_cached_bytes, info.file_count, info.skipped_file_count, + info.download_wait_result, info.max_concurrency)); +} + +void TelemetryLogger::RecordCatalogFetch(const CatalogFetchInfo& info) { + logger_.Log(LogLevel::Debug, + fmt::format("[Telemetry] CatalogFetch AppName={} Operation={} Endpoint={} Region={} Format={} " + "Status={} TimeMs={} ModelCount={} Error={} UserAgent={} CorrelationId={}", + app_name_, info.operation, info.endpoint, info.region, info.format, + ActionStatusToString(info.status), info.duration_ms, info.model_count, + ScrubStringForTelemetry(info.error_message), info.user_agent, info.correlation_id)); +} + +void TelemetryLogger::RecordProcessInfo(const ProcessInfo& info) { + logger_.Log(LogLevel::Debug, + fmt::format("[Telemetry] ProcessInfo AppName={} AppVersion={} OsName={} " + "OsVersion={} CpuArch={} " + "ProcessName={} DeviceIdStatus={} CpuCount={} TotalMemoryMB={} Locale={}", + app_name_, info.app_version, info.os_name, info.os_version, + info.cpu_arch, info.process_name, info.device_id_status, info.cpu_count, + info.total_memory_mb, info.locale)); +} + +void TelemetryLogger::RecordHardwareInfo(const HardwareInfo& info) { + logger_.Log(LogLevel::Debug, + fmt::format("[Telemetry] HardwareInfo AppName={} DeviceTypes={} ExecutionProviders={} " + "DeviceTypeCount={} ExecutionProviderCount={} HasCPU={} HasGPU={} HasNPU={}", + app_name_, info.device_types, info.execution_providers, info.device_type_count, + info.execution_provider_count, info.has_cpu, info.has_gpu, info.has_npu)); +} + +void TelemetryLogger::StartSession() { + logger_.Log(LogLevel::Debug, fmt::format("[Telemetry] SessionStart AppName={}", app_name_)); +} + +void TelemetryLogger::EndSession() { + logger_.Log(LogLevel::Debug, fmt::format("[Telemetry] SessionEnd AppName={}", app_name_)); } } // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/telemetry_logger.h b/sdk_v2/cpp/src/telemetry/telemetry_logger.h index 056838846..8e13b12c5 100644 --- a/sdk_v2/cpp/src/telemetry/telemetry_logger.h +++ b/sdk_v2/cpp/src/telemetry/telemetry_logger.h @@ -9,23 +9,31 @@ namespace fl { -/// Stub ITelemetry implementation that logs telemetry events via ILogger. -/// Used as a fallback when no platform-specific telemetry backend is available. +/// ITelemetry implementation that formats telemetry events to ILogger. class TelemetryLogger : public ITelemetry { public: TelemetryLogger(const std::string& app_name, ILogger& logger); - void RecordAction(Action action, ActionStatus status, const std::string& user_agent, - bool indirect, int64_t duration_ms) override; + void RecordAction(Action action, ActionStatus status, const InvocationContext& context, + int64_t duration_ms) override; - void RecordException(Action action, const std::exception& exception) override; + void RecordException(Action action, const std::exception& exception, + const InvocationContext& context) override; - void RecordModelUsage(const std::string& model_id, - int64_t prompt_tokens, - int64_t completion_tokens, - int64_t duration_ms) override; + void RecordModelUsage(const ModelUsageInfo& info) override; + void RecordAudioUsage(const AudioUsageInfo& info) override; - void RecordModelId(Action action, const std::string& model_id) override; + void RecordModelId(Action action, const std::string& model_id, + ActionStatus status, const InvocationContext& context) override; + + void RecordEpDownloadAttempt(const EpDownloadAttemptInfo& info) override; + void RecordEpDownloadAndRegister(const EpDownloadAndRegisterInfo& info) override; + void RecordDownload(const DownloadInfo& info) override; + void RecordCatalogFetch(const CatalogFetchInfo& info) override; + void RecordProcessInfo(const ProcessInfo& info) override; + void RecordHardwareInfo(const HardwareInfo& info) override; + void StartSession() override; + void EndSession() override; private: std::string app_name_; diff --git a/sdk_v2/cpp/src/telemetry/telemetry_metadata.cc b/sdk_v2/cpp/src/telemetry/telemetry_metadata.cc new file mode 100644 index 000000000..7cfe5a496 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_metadata.cc @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#include "telemetry/telemetry_metadata.h" + +#include "telemetry/device_id.h" +#include "telemetry/invocation_context.h" +#include "telemetry/telemetry_environment.h" +#include "version.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include +#else +#include +#include +#include +#if defined(__APPLE__) +#include +#include +#endif +#endif + +namespace fl { + +namespace { + +#ifdef _WIN32 +std::string GetProcessPath() { + std::array path{}; + DWORD length = ::GetModuleFileNameA(nullptr, path.data(), static_cast(path.size())); + if (length == 0) { + return {}; + } + return std::string(path.data(), length); +} + +std::string GetProcessName() { + auto path = GetProcessPath(); + if (path.empty()) { + return "unknown"; + } + return std::filesystem::path(path).filename().string(); +} + +std::string TrimVersionString(std::string value) { + while (!value.empty() && (value.back() == '\0' || value.back() == '\n' || + value.back() == '\r' || value.back() == ' ')) { + value.pop_back(); + } + return value; +} + +std::string QueryVersionString(const std::vector& data, uint16_t language, uint16_t code_page, + const wchar_t* name) { + wchar_t sub_block[128]; + std::swprintf(sub_block, sizeof(sub_block) / sizeof(sub_block[0]), L"\\StringFileInfo\\%04x%04x\\%ls", + language, code_page, name); + + void* value = nullptr; + UINT value_len = 0; + if (!::VerQueryValueW(data.data(), sub_block, &value, &value_len) || value == nullptr || value_len == 0) { + return {}; + } + + auto* wide_value = static_cast(value); + const int needed = ::WideCharToMultiByte(CP_UTF8, 0, wide_value, -1, nullptr, 0, nullptr, nullptr); + if (needed <= 1) { + return {}; + } + + std::string out(static_cast(needed), '\0'); + const int written = ::WideCharToMultiByte(CP_UTF8, 0, wide_value, -1, out.data(), needed, nullptr, nullptr); + return written > 0 ? TrimVersionString(std::move(out)) : std::string{}; +} + +std::string FormatFixedFileVersion(const VS_FIXEDFILEINFO& info) { + if (info.dwSignature != VS_FFI_SIGNATURE) { + return {}; + } + + char buf[64]; + std::snprintf(buf, sizeof(buf), "%hu.%hu.%hu.%hu", + HIWORD(info.dwFileVersionMS), LOWORD(info.dwFileVersionMS), + HIWORD(info.dwFileVersionLS), LOWORD(info.dwFileVersionLS)); + return std::string(buf); +} + +std::string GetHostAppVersion() { + const auto path = GetProcessPath(); + if (path.empty()) { + return {}; + } + + DWORD handle = 0; + const DWORD size = ::GetFileVersionInfoSizeA(path.c_str(), &handle); + if (size == 0) { + return {}; + } + + std::vector data(size); + if (!::GetFileVersionInfoA(path.c_str(), handle, size, data.data())) { + return {}; + } + + struct LangAndCodePage { + WORD language; + WORD code_page; + }; + + void* translations = nullptr; + UINT translations_len = 0; + if (::VerQueryValueW(data.data(), L"\\VarFileInfo\\Translation", &translations, &translations_len) && + translations != nullptr && translations_len >= sizeof(LangAndCodePage)) { + const auto* entries = static_cast(translations); + const size_t count = translations_len / sizeof(LangAndCodePage); + for (size_t i = 0; i < count; ++i) { + auto version = QueryVersionString(data, entries[i].language, entries[i].code_page, L"ProductVersion"); + if (!version.empty()) { + return version; + } + version = QueryVersionString(data, entries[i].language, entries[i].code_page, L"FileVersion"); + if (!version.empty()) { + return version; + } + } + } + + void* fixed_info = nullptr; + UINT fixed_info_len = 0; + if (::VerQueryValueW(data.data(), L"\\", &fixed_info, &fixed_info_len) && + fixed_info != nullptr && fixed_info_len >= sizeof(VS_FIXEDFILEINFO)) { + return FormatFixedFileVersion(*static_cast(fixed_info)); + } + + return {}; +} + +int64_t GetTotalMemoryMB() { + MEMORYSTATUSEX status{}; + status.dwLength = sizeof(status); + if (!::GlobalMemoryStatusEx(&status)) { + return -1; + } + return static_cast(status.ullTotalPhys / (1024ULL * 1024ULL)); +} + +std::string GetWindowsVersion() { + using RtlGetVersionFn = LONG(WINAPI*)(PRTL_OSVERSIONINFOW); + auto* ntdll = ::GetModuleHandleW(L"ntdll.dll"); + auto* proc = ntdll ? ::GetProcAddress(ntdll, "RtlGetVersion") : nullptr; + auto* rtl_get_version = reinterpret_cast(proc); + if (!rtl_get_version) { + return "unknown"; + } + + RTL_OSVERSIONINFOW info{}; + info.dwOSVersionInfoSize = sizeof(info); + if (rtl_get_version(&info) != 0) { + return "unknown"; + } + + char buf[64]; + std::snprintf(buf, sizeof(buf), "%lu.%lu.%lu", + info.dwMajorVersion, info.dwMinorVersion, info.dwBuildNumber); + return std::string(buf); +} + +std::string GetCpuArch() { + SYSTEM_INFO si{}; + ::GetNativeSystemInfo(&si); + switch (si.wProcessorArchitecture) { + case PROCESSOR_ARCHITECTURE_AMD64: return "amd64"; + case PROCESSOR_ARCHITECTURE_ARM: return "arm"; + case PROCESSOR_ARCHITECTURE_ARM64: return "arm64"; + case PROCESSOR_ARCHITECTURE_IA64: return "ia64"; + case PROCESSOR_ARCHITECTURE_INTEL: return "x86"; + default: return "unknown"; + } +} +#else +std::string GetProcessName() { +#if defined(__linux__) + std::array path{}; + ssize_t length = ::readlink("/proc/self/exe", path.data(), path.size() - 1); + if (length <= 0) { + return "unknown"; + } + return std::filesystem::path(std::string(path.data(), static_cast(length))).filename().string(); +#elif defined(__APPLE__) + const char* name = ::getprogname(); + return (name != nullptr && name[0] != '\0') ? std::string(name) : std::string{"unknown"}; +#else + return "unknown"; +#endif +} + +int64_t GetTotalMemoryMB() { + long pages = ::sysconf(_SC_PHYS_PAGES); + long page_size = ::sysconf(_SC_PAGE_SIZE); + if (pages <= 0 || page_size <= 0) { + return -1; + } + return (static_cast(pages) * static_cast(page_size)) / (1024LL * 1024LL); +} + +struct PosixOsInfo { + std::string name; + std::string version; + std::string arch; +}; + +PosixOsInfo GetPosixOsInfo() { + PosixOsInfo out{"unknown", "unknown", "unknown"}; + ::utsname u{}; + if (::uname(&u) == 0) { + out.name = u.sysname; + out.version = u.release; + out.arch = u.machine; + } + return out; +} + +#if defined(__APPLE__) +std::string CfStringToUtf8(CFStringRef value) { + if (value == nullptr) { + return {}; + } + + if (const char* c_str = CFStringGetCStringPtr(value, kCFStringEncodingUTF8); c_str != nullptr) { + return std::string(c_str); + } + + const CFIndex length = CFStringGetLength(value); + const CFIndex max_size = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1; + if (max_size <= 1) { + return {}; + } + + std::string out(static_cast(max_size), '\0'); + if (!CFStringGetCString(value, out.data(), max_size, kCFStringEncodingUTF8)) { + return {}; + } + out.resize(std::strlen(out.c_str())); + return out; +} + +std::string GetBundleString(CFStringRef key) { + CFBundleRef bundle = CFBundleGetMainBundle(); + if (bundle == nullptr) { + return {}; + } + + CFTypeRef value = CFBundleGetValueForInfoDictionaryKey(bundle, key); + if (value == nullptr || CFGetTypeID(value) != CFStringGetTypeID()) { + return {}; + } + + return CfStringToUtf8(static_cast(value)); +} + +std::string GetHostAppVersion() { + auto version = GetBundleString(CFSTR("CFBundleShortVersionString")); + if (!version.empty()) { + return version; + } + return GetBundleString(kCFBundleVersionKey); +} +#else +std::string GetHostAppVersion() { + return {}; +} +#endif +#endif + +std::string GetLocaleName() { + const char* locale = std::setlocale(LC_ALL, nullptr); + return (locale != nullptr && locale[0] != '\0') ? std::string(locale) : std::string{"unknown"}; +} + +} // namespace + +TelemetryMetadata BuildTelemetryMetadata(std::string app_name) { + TelemetryMetadata m; + m.app_session_guid = GenerateGuidV4(); + m.version = FOUNDRY_LOCAL_VERSION; + m.app_version = GetHostAppVersion(); + if (m.app_version.empty()) { + m.app_version = m.version; + } + m.app_name = std::move(app_name); + +#ifdef _WIN32 + m.os_name = "Windows"; + m.os_version = GetWindowsVersion(); + m.cpu_arch = GetCpuArch(); +#else + auto info = GetPosixOsInfo(); + m.os_name = info.name; + m.os_version = info.version; + m.cpu_arch = info.arch; +#endif + + return m; +} + +ProcessInfo BuildProcessInfo(const TelemetryMetadata& metadata, bool include_device_id_status) { + ProcessInfo info; + info.app_name = metadata.app_name; + info.app_version = metadata.app_version; + info.os_name = metadata.os_name; + info.os_version = metadata.os_version; + info.cpu_arch = metadata.cpu_arch; + info.process_name = GetProcessName(); + info.locale = GetLocaleName(); + info.device_id_status = include_device_id_status ? TelemetryDeviceId::Instance().GetStatusString() : "Disabled"; + info.cpu_count = static_cast(std::thread::hardware_concurrency()); + info.total_memory_mb = GetTotalMemoryMB(); + return info; +} + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/telemetry_metadata.h b/sdk_v2/cpp/src/telemetry/telemetry_metadata.h new file mode 100644 index 000000000..5dabebfab --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_metadata.h @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include "telemetry/telemetry.h" + +#include + +namespace fl { + +/// Process-wide metadata stamped onto every 1DS event as common context. +/// Computed once at startup and cached. Cheap to copy. +struct TelemetryMetadata { + /// Hex-encoded random 128-bit GUID, generated once at startup. Stamped on + /// every event as `AppSessionGuid` so the backend can group all events from a + /// single FL process run. This is a stable per-process correlation id and is + /// distinct from the SDK's rotating usage-session id (ext.app.sesId), which is + /// driven separately via LogSession(Started/Ended). + std::string app_session_guid; + + /// Foundry Local SDK version. + std::string version; + + /// Best-effort host application version from platform metadata. Falls back to `version`. + std::string app_version; + + /// Configured app name (from Configuration::app_name). + std::string app_name; + + /// Free-form "Windows 11 10.0.26100 amd64" / "Linux 6.5.0 x86_64" / "macOS 14.4 arm64". + std::string os_name; // "Windows" / "Linux" / "Darwin" + std::string os_version; // "10.0.26100" / "6.5.0-azure" / "14.4" + std::string cpu_arch; // "amd64" / "arm64" / "x86" / ... +}; + +/// Build the metadata for this process. Reads env vars and OS APIs once. +/// app_name comes from Configuration. +TelemetryMetadata BuildTelemetryMetadata(std::string app_name); + +/// Build the one-shot ProcessInfo event payload from metadata and system APIs. +ProcessInfo BuildProcessInfo(const TelemetryMetadata& metadata, bool include_device_id_status = true); + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/telemetry_redaction.h b/sdk_v2/cpp/src/telemetry/telemetry_redaction.h new file mode 100644 index 000000000..d88a40008 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_redaction.h @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include +#include +#include +#include + +namespace fl { + +inline constexpr size_t kMaxTelemetryStringLength = 40'960; + +namespace telemetry_detail { + +inline size_t FindPathAnchor(std::string_view s) { + for (size_t i = 0; i < s.size(); ++i) { + const char c = s[i]; + if (c == '\\' && i + 1 < s.size() && s[i + 1] == '\\') { + return i; + } + if (c == '~' && i + 1 < s.size() && (s[i + 1] == '/' || s[i + 1] == '\\')) { + return i; + } + if (std::isalpha(static_cast(c)) && i + 2 < s.size() && s[i + 1] == ':' && + (s[i + 2] == '\\' || s[i + 2] == '/')) { + return i; + } + if (c == '\\') { + size_t start = i; + while (start > 0) { + const unsigned char prev = static_cast(s[start - 1]); + if (std::isspace(prev) || s[start - 1] == '"' || s[start - 1] == '\'') { + break; + } + --start; + } + + size_t separators = 0; + for (size_t j = i; j < s.size() && s[j] != '\r' && s[j] != '\n'; ++j) { + if (s[j] == '\\' && ++separators >= 2) { + return start; + } + } + } + if (c == '/') { + if (i == 0) { + return i; + } + + size_t segments = 0; + size_t j = i; + while (j < s.size() && s[j] == '/') { + const size_t seg_start = ++j; + while (j < s.size() && s[j] != '/' && s[j] != '\r' && s[j] != '\n' && s[j] != ' ' && + s[j] != '\t') { + ++j; + } + if (j > seg_start) { + ++segments; + } else { + break; + } + } + + if (segments >= 2) { + size_t start = i; + while (start > 0) { + const unsigned char prev = static_cast(s[start - 1]); + if (std::isspace(prev) || s[start - 1] == '"' || s[start - 1] == '\'') { + break; + } + --start; + } + return start; + } + } + } + return std::string_view::npos; +} + +inline void TruncateUtf8AtBoundary(std::string& s, size_t max_length) { + if (s.size() <= max_length) { + return; + } + + size_t end = max_length; + while (end > 0 && (static_cast(s[end]) & 0xC0) == 0x80) { + --end; + } + s.resize(end); +} + +} // namespace telemetry_detail + +inline std::string ScrubStringForTelemetry(std::string_view msg) { + const size_t anchor = telemetry_detail::FindPathAnchor(msg); + std::string out; + if (anchor == std::string_view::npos) { + out.assign(msg); + } else { + out.assign(msg.substr(0, anchor)); + out += "[path]"; + } + if (out.size() > kMaxTelemetryStringLength) { + telemetry_detail::TruncateUtf8AtBoundary(out, kMaxTelemetryStringLength); + } + return out; +} + +} // namespace fl diff --git a/sdk_v2/cpp/src/telemetry/telemetry_sampling.h b/sdk_v2/cpp/src/telemetry/telemetry_sampling.h new file mode 100644 index 000000000..ef3ccd980 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_sampling.h @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +#pragma once + +#include +#include + +namespace fl::TelemetryInternal { + +// Percentage of non-process telemetry events retained. Keep at 100% until we intentionally reduce volume. +// 1DS popSample is metadata only; ShouldSampleTelemetryEvent performs the actual client-side sampling. +inline constexpr double kTelemetrySampleRatePercent = 100.0; + +static_assert(kTelemetrySampleRatePercent >= 0.0 && kTelemetrySampleRatePercent <= 100.0); + +inline uint64_t HashSamplingKey(std::string_view app_session_guid, std::string_view event_key) { + uint64_t hash = 14695981039346656037ULL; + for (const unsigned char c : app_session_guid) { + hash ^= c; + hash *= 1099511628211ULL; + } + for (const unsigned char c : event_key) { + hash ^= c; + hash *= 1099511628211ULL; + } + return hash; +} + +inline bool ShouldSampleTelemetryEvent(std::string_view app_session_guid, std::string_view event_key, + double sample_rate_percent = kTelemetrySampleRatePercent) { + if (!(sample_rate_percent > 0.0)) { + return false; + } + if (sample_rate_percent >= 100.0) { + return true; + } + + // One million buckets support rates down to 0.0001% while keeping the decision stable for every event + // sharing the same correlation key. The random process GUID prevents sequential keys from biasing samples. + constexpr uint64_t kBucketCount = 1'000'000; + const auto threshold = static_cast( + static_cast(sample_rate_percent) * kBucketCount / 100.0L); + return HashSamplingKey(app_session_guid, event_key) % kBucketCount < threshold; +} + +} // namespace fl::TelemetryInternal diff --git a/sdk_v2/cpp/src/util/sha256.cc b/sdk_v2/cpp/src/util/sha256.cc index 0d60d40bd..ff60c2285 100644 --- a/sdk_v2/cpp/src/util/sha256.cc +++ b/sdk_v2/cpp/src/util/sha256.cc @@ -2,8 +2,10 @@ // Licensed under the MIT License. #include "util/sha256.h" +#include #include #include +#include #include #include "exception.h" @@ -18,6 +20,15 @@ namespace fl { namespace { +std::string HexEncode(const unsigned char* digest, size_t digest_len) { + std::ostringstream hex; + hex << std::hex << std::uppercase << std::setfill('0'); + for (size_t i = 0; i < digest_len; ++i) { + hex << std::setw(2) << static_cast(digest[i]); + } + return hex.str(); +} + void ThrowBCryptError(const char* call, NTSTATUS status) { std::ostringstream oss; oss << call << " failed (NTSTATUS=0x" << std::hex << std::uppercase << std::setfill('0') @@ -70,14 +81,47 @@ std::string Sha256File(const std::filesystem::path& file_path) { BCryptDestroyHash(hash); BCryptCloseAlgorithmProvider(alg, 0); - // Convert to uppercase hex - std::ostringstream hex; - hex << std::hex << std::uppercase << std::setfill('0'); - for (auto b : digest) { - hex << std::setw(2) << static_cast(b); + return HexEncode(digest, sizeof(digest)); +} + +std::string Sha256String(std::string_view value) { + BCRYPT_ALG_HANDLE alg = nullptr; + NTSTATUS status = BCryptOpenAlgorithmProvider(&alg, BCRYPT_SHA256_ALGORITHM, nullptr, 0); + if (!BCRYPT_SUCCESS(status)) { + ThrowBCryptError("BCryptOpenAlgorithmProvider", status); } - return hex.str(); + BCRYPT_HASH_HANDLE hash = nullptr; + status = BCryptCreateHash(alg, &hash, nullptr, 0, nullptr, 0, 0); + if (!BCRYPT_SUCCESS(status)) { + BCryptCloseAlgorithmProvider(alg, 0); + ThrowBCryptError("BCryptCreateHash", status); + } + + for (size_t offset = 0; offset < value.size();) { + const size_t chunk_size = std::min( + value.size() - offset, static_cast((std::numeric_limits::max)())); + status = BCryptHashData(hash, reinterpret_cast(const_cast(value.data() + offset)), + static_cast(chunk_size), 0); + if (!BCRYPT_SUCCESS(status)) { + BCryptDestroyHash(hash); + BCryptCloseAlgorithmProvider(alg, 0); + ThrowBCryptError("BCryptHashData", status); + } + offset += chunk_size; + } + + UCHAR digest[32]; + status = BCryptFinishHash(hash, digest, sizeof(digest), 0); + if (!BCRYPT_SUCCESS(status)) { + BCryptDestroyHash(hash); + BCryptCloseAlgorithmProvider(alg, 0); + ThrowBCryptError("BCryptFinishHash", status); + } + + BCryptDestroyHash(hash); + BCryptCloseAlgorithmProvider(alg, 0); + return HexEncode(digest, sizeof(digest)); } } // namespace fl @@ -88,6 +132,19 @@ std::string Sha256File(const std::filesystem::path& file_path) { namespace fl { +namespace { + +std::string HexEncode(const unsigned char* digest, size_t digest_len) { + std::ostringstream hex; + hex << std::hex << std::uppercase << std::setfill('0'); + for (size_t i = 0; i < digest_len; ++i) { + hex << std::setw(2) << static_cast(digest[i]); + } + return hex.str(); +} + +} // namespace + std::string Sha256File(const std::filesystem::path& file_path) { std::ifstream file(file_path, std::ios::binary); if (!file) { @@ -120,13 +177,34 @@ std::string Sha256File(const std::filesystem::path& file_path) { } EVP_MD_CTX_free(ctx); - std::ostringstream hex; - hex << std::hex << std::uppercase << std::setfill('0'); - for (unsigned int i = 0; i < digest_len; ++i) { - hex << std::setw(2) << static_cast(digest[i]); + return HexEncode(digest, digest_len); +} + +std::string Sha256String(std::string_view value) { + auto* ctx = EVP_MD_CTX_new(); + if (!ctx) { + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "EVP_MD_CTX_new failed (out of memory)"); } - return hex.str(); + if (EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr) != 1) { + EVP_MD_CTX_free(ctx); + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "EVP_DigestInit_ex failed"); + } + + if (EVP_DigestUpdate(ctx, value.data(), value.size()) != 1) { + EVP_MD_CTX_free(ctx); + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "EVP_DigestUpdate failed"); + } + + unsigned char digest[EVP_MAX_MD_SIZE]; + unsigned int digest_len = 0; + if (EVP_DigestFinal_ex(ctx, digest, &digest_len) != 1) { + EVP_MD_CTX_free(ctx); + FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL, "EVP_DigestFinal_ex failed"); + } + EVP_MD_CTX_free(ctx); + + return HexEncode(digest, digest_len); } } // namespace fl diff --git a/sdk_v2/cpp/src/util/sha256.h b/sdk_v2/cpp/src/util/sha256.h index 59f8d35a4..c6e4604f9 100644 --- a/sdk_v2/cpp/src/util/sha256.h +++ b/sdk_v2/cpp/src/util/sha256.h @@ -4,6 +4,7 @@ #include #include +#include namespace fl { @@ -11,4 +12,7 @@ namespace fl { /// Returns empty string on error. std::string Sha256File(const std::filesystem::path& file_path); +/// Compute SHA256 hash of an in-memory string and return it as uppercase hex string. +std::string Sha256String(std::string_view value); + } // namespace fl diff --git a/sdk_v2/cpp/test/internal_api/audio/audio_session_test.cc b/sdk_v2/cpp/test/internal_api/audio/audio_session_test.cc index f6f7b4479..35efe2b2a 100644 --- a/sdk_v2/cpp/test/internal_api/audio/audio_session_test.cc +++ b/sdk_v2/cpp/test/internal_api/audio/audio_session_test.cc @@ -18,7 +18,7 @@ #include "logger.h" #include "model.h" #include "internal_api/null_session_manager.h" -#include "internal_api/null_telemetry.h" +#include "telemetry/telemetry_logger.h" #include "internal_api/test_helpers.h" #include "internal_api/test_model_cache.h" @@ -107,7 +107,7 @@ class AudioSessionTest : public ::testing::Test { static inline fl::test::FakeServiceBindings svc_; static inline Model catalog_model_ = Model::FromModelInfo( ModelInfo{}, "", svc_.download_manager, svc_.model_load_manager); - fl::test::NullTelemetry null_telemetry_; + TelemetryLogger telemetry_{"foundry-local-test", fl::test::NullLog()}; fl::test::NullSessionManager null_session_manager_; }; @@ -159,7 +159,7 @@ class AudioSessionInferenceTest : public ::testing::Test { static inline fl::test::FakeServiceBindings svc_; static inline Model catalog_model_ = Model::FromModelInfo( ModelInfo{}, "", svc_.download_manager, svc_.model_load_manager); - fl::test::NullTelemetry null_telemetry_; + TelemetryLogger telemetry_{"foundry-local-test", fl::test::NullLog()}; fl::test::NullSessionManager null_session_manager_; }; @@ -168,7 +168,7 @@ class AudioSessionInferenceTest : public ::testing::Test { // =========================================================================== TEST_F(AudioSessionTest, TypeReturnsAudio) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); EXPECT_EQ(session.Type(), SessionType::kAudio); } @@ -177,7 +177,7 @@ TEST_F(AudioSessionTest, TypeReturnsAudio) { // =========================================================================== TEST_F(AudioSessionTest, ThrowsWhenFirstItemIsNotAudio) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); // Request with a text item instead of an audio item Request request; @@ -199,7 +199,7 @@ TEST_F(AudioSessionTest, ThrowsWhenFirstItemIsNotAudio) { } TEST_F(AudioSessionTest, ThrowsWhenAudioItemHasNoUriOrData) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); // Audio item with no uri and no data Request request; @@ -221,7 +221,7 @@ TEST_F(AudioSessionTest, ThrowsWhenAudioItemHasNoUriOrData) { } TEST_F(AudioSessionTest, ThrowsWhenRequestHasNoItems) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); Request request; Response response; @@ -239,7 +239,7 @@ TEST_F(AudioSessionTest, ThrowsWhenRequestHasNoItems) { } TEST_F(AudioSessionTest, ThrowsWhenTooManyItems) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); Request request; auto audio = std::make_unique(); @@ -264,7 +264,7 @@ TEST_F(AudioSessionTest, ThrowsWhenTooManyItems) { } TEST_F(AudioSessionTest, ThrowsWhenSecondItemIsNotQueue) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); Request request; auto audio = std::make_unique(); @@ -291,7 +291,7 @@ TEST_F(AudioSessionTest, ThrowsWhenSecondItemIsNotQueue) { // =========================================================================== TEST_F(AudioSessionTest, StreamingThrowsWhenFormatIsNotPcm) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); Request request; auto audio = std::make_unique(); @@ -315,7 +315,7 @@ TEST_F(AudioSessionTest, StreamingThrowsWhenFormatIsNotPcm) { } TEST_F(AudioSessionTest, StreamingThrowsWhenSampleRateIsWrong) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); Request request; auto audio = std::make_unique(); @@ -340,7 +340,7 @@ TEST_F(AudioSessionTest, StreamingThrowsWhenSampleRateIsWrong) { } TEST_F(AudioSessionTest, StreamingThrowsWhenChannelsIsWrong) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); Request request; auto audio = std::make_unique(); @@ -369,7 +369,7 @@ TEST_F(AudioSessionTest, StreamingThrowsWhenChannelsIsWrong) { // =========================================================================== TEST_F(AudioSessionTest, OpenAIJsonWithEmptyFileFieldThrows) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); nlohmann::json req_json = { {"model", "openai-whisper-tiny-generic-cpu-4"}, @@ -395,7 +395,7 @@ TEST_F(AudioSessionTest, OpenAIJsonWithEmptyFileFieldThrows) { } TEST_F(AudioSessionTest, OpenAIJsonWithNonexistentFileThrows) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); nlohmann::json req_json = { {"model", "openai-whisper-tiny-generic-cpu-4"}, @@ -421,7 +421,7 @@ TEST_F(AudioSessionTest, OpenAIJsonWithNonexistentFileThrows) { } TEST_F(AudioSessionTest, OpenAIJsonWithInvalidJsonThrows) { - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); Request request; request.AddOwnedItem(std::make_unique("not valid json {{{", @@ -444,7 +444,7 @@ TEST_F(AudioSessionInferenceTest, TranscribeFromFilePath) { auto audio_path = fl::test::GetTestDataPath("Recording.mp3"); ASSERT_TRUE(fs::exists(audio_path)) << "Test audio file not found: " << audio_path; - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); Request request; auto audio_item = std::make_unique(audio_path.string()); @@ -479,7 +479,7 @@ TEST_F(AudioSessionInferenceTest, TranscribeViaOpenAIJson) { auto audio_path = fl::test::GetTestDataPath("Recording.mp3"); ASSERT_TRUE(fs::exists(audio_path)) << "Test audio file not found: " << audio_path; - AudioSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + AudioSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); nlohmann::json req_json = { {"model", "openai-whisper-tiny-generic-cpu-2"}, diff --git a/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc b/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc index 96df77366..49a05f096 100644 --- a/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc +++ b/sdk_v2/cpp/test/internal_api/azure_catalog_test.cc @@ -13,6 +13,7 @@ #include "exception.h" #include "logger.h" #include "model_info.h" +#include "test_helpers.h" #include #include @@ -508,7 +509,8 @@ TEST(AzureCatalogClientTest, WithCachedModels_NoCachedIds_BehavesLikeRegularFetc return MakeOkResponse(MakeMockCatalogResponse({{"phi-4-mini", 3}})); }); - auto result = FetchAllModelInfosWithCachedModels(client, {}, logger); + CatalogFetchInfo telemetry_info; + auto result = FetchAllModelInfosWithCachedModels(client, {}, logger, fl::test::TestTelemetrySink(), telemetry_info); // Only the primary FetchAllModelInfos call — no extra fetch for cached models. EXPECT_EQ(http_call_count, 1); @@ -528,7 +530,9 @@ TEST(AzureCatalogClientTest, WithCachedModels_AlreadyInCatalog_NoExtraFetch) { }); // The cached ID matches what's already in the catalog — no extra fetch needed. - auto result = FetchAllModelInfosWithCachedModels(client, {"phi-4-mini:3"}, logger); + CatalogFetchInfo telemetry_info; + auto result = FetchAllModelInfosWithCachedModels(client, {"phi-4-mini:3"}, logger, + fl::test::TestTelemetrySink(), telemetry_info); EXPECT_EQ(http_call_count, 1); ASSERT_EQ(result.size(), 1u); @@ -566,7 +570,9 @@ TEST(AzureCatalogClientTest, WithCachedModels_UnresolvedId_TriggersSecondFetch) } }); - auto result = FetchAllModelInfosWithCachedModels(client, {"old-model:1"}, logger); + CatalogFetchInfo telemetry_info; + auto result = FetchAllModelInfosWithCachedModels(client, {"old-model:1"}, logger, + fl::test::TestTelemetrySink(), telemetry_info); EXPECT_EQ(http_call_count, 2); ASSERT_EQ(result.size(), 2u); @@ -606,7 +612,9 @@ TEST(AzureCatalogClientTest, WithCachedModels_FullyUnresolved_CreatesBYOEntry) { } }); - auto result = FetchAllModelInfosWithCachedModels(client, {"custom-model:0"}, logger); + CatalogFetchInfo telemetry_info; + auto result = FetchAllModelInfosWithCachedModels(client, {"custom-model:0"}, logger, + fl::test::TestTelemetrySink(), telemetry_info); EXPECT_EQ(http_call_count, 2); diff --git a/sdk_v2/cpp/test/internal_api/chat/chat_session_test.cc b/sdk_v2/cpp/test/internal_api/chat/chat_session_test.cc index 891aa9d6a..780cc186e 100644 --- a/sdk_v2/cpp/test/internal_api/chat/chat_session_test.cc +++ b/sdk_v2/cpp/test/internal_api/chat/chat_session_test.cc @@ -13,7 +13,7 @@ #include "logger.h" #include "model.h" #include "internal_api/null_session_manager.h" -#include "internal_api/null_telemetry.h" +#include "telemetry/telemetry_logger.h" #include "internal_api/test_helpers.h" #include "internal_api/test_model_cache.h" #include "utils/string_utils.h" @@ -68,7 +68,7 @@ class ChatSessionTest : public ::testing::Test { static inline fl::test::FakeServiceBindings svc_; static inline Model catalog_model_ = Model::FromModelInfo( ModelInfo{}, "", svc_.download_manager, svc_.model_load_manager); - fl::test::NullTelemetry null_telemetry_; + TelemetryLogger telemetry_{"foundry-local-test", fl::test::NullLog()}; fl::test::NullSessionManager null_session_manager_; }; @@ -77,7 +77,7 @@ class ChatSessionTest : public ::testing::Test { // =========================================================================== TEST_F(ChatSessionTest, ConstructWithModelOnly) { - ChatSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + ChatSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); EXPECT_EQ(session.MessageCount(), 0u); EXPECT_TRUE(session.GetHistory().empty()); EXPECT_EQ(session.TurnCount(), 0u); @@ -113,7 +113,7 @@ std::string GetAssistantText(const Response& response) { // =========================================================================== TEST_F(ChatSessionTest, RunBasic) { - ChatSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + ChatSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); Request request; request.AddOwnedItem(MakeMessage(FOUNDRY_LOCAL_ROLE_USER, "What is 2+2? Answer with just the number.")); @@ -141,7 +141,7 @@ TEST_F(ChatSessionTest, RunBasic) { } TEST_F(ChatSessionTest, RunWithStreaming) { - ChatSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + ChatSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); // Use a multi-token prompt with deterministic substrings so we can validate: // 1. Streaming actually delivers multiple deltas (callback_count >= 2), @@ -242,7 +242,7 @@ TEST_F(ChatSessionTest, RunWithStreaming) { } TEST_F(ChatSessionTest, RunMultiTurn) { - ChatSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + ChatSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); // Turn 1 Request req1; @@ -273,7 +273,7 @@ TEST_F(ChatSessionTest, RunMultiTurn) { } TEST_F(ChatSessionTest, RunStreamingCancellation) { - ChatSession session(GetCatalogModel(), GetModel(), *logger_, null_telemetry_); + ChatSession session(GetCatalogModel(), GetModel(), *logger_, telemetry_); Request request; request.AddOwnedItem(MakeMessage(FOUNDRY_LOCAL_ROLE_USER, "Count from 1 to 100.")); diff --git a/sdk_v2/cpp/test/internal_api/configuration_test.cc b/sdk_v2/cpp/test/internal_api/configuration_test.cc index eae4bb28c..d79c34014 100644 --- a/sdk_v2/cpp/test/internal_api/configuration_test.cc +++ b/sdk_v2/cpp/test/internal_api/configuration_test.cc @@ -27,6 +27,7 @@ TEST(ConfigurationTest, DefaultValues) { EXPECT_FALSE(config.app_data_dir.has_value()); EXPECT_FALSE(config.model_cache_dir.has_value()); EXPECT_FALSE(config.logs_dir.has_value()); + EXPECT_FALSE(config.disable_nonessential_telemetry); } TEST(ConfigurationTest, ValidateRejectsEmptyCatalogUrl) { diff --git a/sdk_v2/cpp/test/internal_api/download_test.cc b/sdk_v2/cpp/test/internal_api/download_test.cc index d8a5f9b5c..208826333 100644 --- a/sdk_v2/cpp/test/internal_api/download_test.cc +++ b/sdk_v2/cpp/test/internal_api/download_test.cc @@ -57,6 +57,14 @@ std::string ReadFile(const fs::path& path) { return ss.str(); } +void DownloadBlobsToDirectoryForTest(IBlobDownloader& downloader, + const std::string& sas_uri, + const std::string& output_directory, + const BlobDownloadOptions& options) { + BlobDownloadStats stats; + DownloadBlobsToDirectory(downloader, sas_uri, output_directory, options, stats); +} + http::HttpResponse MakeRegistryResponse(std::string body, int status = 200) { http::HttpResponse response; response.status = status; @@ -427,7 +435,7 @@ TEST(BlobDownloadTest, DownloadsAllBlobs) { BlobDownloadOptions opts; opts.path_prefix = "model"; - DownloadBlobsToDirectory(mock, "https://test.blob/container?sig=x", tmpdir.string(), opts); + DownloadBlobsToDirectoryForTest(mock, "https://test.blob/container?sig=x", tmpdir.string(), opts); EXPECT_EQ(mock.downloaded_blobs.size(), 2u); } @@ -443,7 +451,7 @@ TEST(BlobDownloadTest, FiltersByPathPrefix) { BlobDownloadOptions opts; opts.path_prefix = "variant-a"; - DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); + DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); ASSERT_EQ(mock.downloaded_blobs.size(), 1u); EXPECT_EQ(mock.downloaded_blobs[0], "variant-a/weights.safetensors"); @@ -459,7 +467,7 @@ TEST(BlobDownloadTest, FiltersOutInferenceModelJson) { }; BlobDownloadOptions opts; - DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); + DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); ASSERT_EQ(mock.downloaded_blobs.size(), 2u); for (const auto& name : mock.downloaded_blobs) { @@ -482,7 +490,7 @@ TEST(BlobDownloadTest, ReportsProgress) { return 0; }; - DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); + DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); // Should get progress reports, ending at 100 ASSERT_FALSE(progress_values.empty()); @@ -501,7 +509,7 @@ TEST(BlobDownloadTest, HandlesEmptyBlobList) { // No blobs BlobDownloadOptions opts; - DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); + DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); EXPECT_TRUE(mock.downloaded_blobs.empty()); } @@ -522,7 +530,7 @@ TEST(BlobDownloadTest, SkipsExistingFilesWithCorrectSize) { }; BlobDownloadOptions opts; - DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); + DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); // Only the missing blob should be downloaded. ASSERT_EQ(mock.downloaded_blobs.size(), 1u); @@ -540,7 +548,7 @@ TEST(BlobDownloadTest, RedownloadsFilesWithWrongSize) { }; BlobDownloadOptions opts; - DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); + DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); // Wrong-size files should be redownloaded (the mock overwrites them). ASSERT_EQ(mock.downloaded_blobs.size(), 1u); @@ -565,7 +573,7 @@ TEST(BlobDownloadTest, ReportsSkippedBytesInInitialProgress) { return 0; }; - DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); + DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); ASSERT_FALSE(progress_values.empty()); // First emitted progress reflects the already-on-disk bytes (500/2000 = 25%). @@ -592,7 +600,7 @@ TEST(BlobDownloadTest, EmitsHundredPercentWhenEverythingIsCached) { return 0; }; - DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); + DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); EXPECT_TRUE(mock.downloaded_blobs.empty()); ASSERT_FALSE(progress_values.empty()); @@ -639,7 +647,7 @@ TEST(BlobDownloadTest, RejectsPathTraversalBlobName) { }; BlobDownloadOptions opts; - EXPECT_THROW(DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts), + EXPECT_THROW(DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts), fl::Exception); EXPECT_TRUE(mock.downloaded_blobs.empty()); } @@ -652,7 +660,7 @@ TEST(BlobDownloadTest, RejectsBackslashPathTraversalBlobName) { }; BlobDownloadOptions opts; - EXPECT_THROW(DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts), + EXPECT_THROW(DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts), fl::Exception); EXPECT_TRUE(mock.downloaded_blobs.empty()); } @@ -665,7 +673,7 @@ TEST(BlobDownloadTest, RejectsNestedPathTraversalBlobName) { }; BlobDownloadOptions opts; - EXPECT_THROW(DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts), + EXPECT_THROW(DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts), fl::Exception); EXPECT_TRUE(mock.downloaded_blobs.empty()); } @@ -685,7 +693,7 @@ TEST(BlobDownloadTest, CancellationStopsRemainingBlobs) { }; try { - DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); + DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); FAIL() << "Expected fl::Exception to be thrown"; } catch (const fl::Exception& e) { EXPECT_EQ(e.code(), FOUNDRY_LOCAL_ERROR_OPERATION_CANCELLED); @@ -712,7 +720,7 @@ TEST(BlobDownloadTest, CancelledFlagAbortsInFlightDownload) { }; try { - DownloadBlobsToDirectory(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); + DownloadBlobsToDirectoryForTest(mock, "https://test.blob/c?sig=x", tmpdir.string(), opts); FAIL() << "Expected fl::Exception to be thrown"; } catch (const fl::Exception& e) { EXPECT_EQ(e.code(), FOUNDRY_LOCAL_ERROR_OPERATION_CANCELLED); @@ -847,18 +855,13 @@ TEST(VariantFixupTest, PreservesRootFileWhenNoSubdirs) { TEST(DownloadManagerTest, FullDownloadFlow) { auto tmpdir = TempPath::CreateTempDir(); - auto manager = std::make_unique(tmpdir.string(), "eastus", 64, fl::test::NullLog()); - - // Mock the registry client auto registry = std::make_unique( "eastus", fl::test::NullLog(), std::make_unique(fl::test::NullLog(), false), [](const std::string&) { return MakeRegistryResponse( R"({"blobSasUri": "https://storage.blob.core.windows.net/container?sig=test"})"); }); - manager->SetModelRegistryClient(std::move(registry)); - // Mock the blob downloader auto mock_downloader = std::make_unique(); mock_downloader->expected_sas_uri = "https://storage.blob.core.windows.net/container?sig=test"; @@ -866,7 +869,10 @@ TEST(DownloadManagerTest, FullDownloadFlow) { {"weights.safetensors", 1024}, {"config.json", 100}, }; - manager->SetBlobDownloader(std::move(mock_downloader)); + + auto manager = std::make_unique( + tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink(), + /*disable_region_fallback=*/false, std::move(registry), std::move(mock_downloader)); ModelInfo info; info.model_id = "test-model:1"; @@ -898,8 +904,6 @@ TEST(DownloadManagerTest, FullDownloadFlow) { static std::string CaptureRegistryUrlForDownload(const std::string& config_region, const std::string& detected_region) { auto tmpdir = TempPath::CreateTempDir(); - auto manager = - std::make_unique(tmpdir.string(), config_region, 64, fl::test::NullLog()); std::string captured_url; auto registry = std::make_unique( @@ -909,12 +913,14 @@ static std::string CaptureRegistryUrlForDownload(const std::string& config_regio return MakeRegistryResponse( R"({"blobSasUri": "https://storage.blob.core.windows.net/container?sig=test"})"); }); - manager->SetModelRegistryClient(std::move(registry)); auto mock_downloader = std::make_unique(); mock_downloader->expected_sas_uri = "https://storage.blob.core.windows.net/container?sig=test"; mock_downloader->blobs_to_return = {{"config.json", 100}}; - manager->SetBlobDownloader(std::move(mock_downloader)); + + auto manager = std::make_unique( + tmpdir.string(), config_region, 64, fl::test::NullLog(), fl::test::TestTelemetrySink(), + /*disable_region_fallback=*/false, std::move(registry), std::move(mock_downloader)); ModelInfo info; info.model_id = "test-model:1"; @@ -952,7 +958,8 @@ TEST(DownloadManagerTest, Region_FallsBackToDefaultRegistryRegionWhenNoConfigAnd TEST(DownloadManagerTest, SkipsAlreadyCachedModel) { auto tmpdir = TempPath::CreateTempDir(); - auto manager = std::make_unique(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + auto manager = std::make_unique(tmpdir.string(), "eastus", 64, fl::test::NullLog(), + fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "cached-model:1"; @@ -978,7 +985,7 @@ TEST(DownloadManagerTest, SkipsAlreadyCachedModel) { TEST(DownloadManagerTest, IsModelCachedReturnsFalseForMissing) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "nonexistent:1"; @@ -989,7 +996,7 @@ TEST(DownloadManagerTest, IsModelCachedReturnsFalseForMissing) { TEST(DownloadManagerTest, IsModelCachedReturnsFalseForIncomplete) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "incomplete:1"; @@ -1007,7 +1014,7 @@ TEST(DownloadManagerTest, IsModelCachedReturnsFalseForIncomplete) { TEST(DownloadManagerTest, IsModelCachedReturnsTrueForComplete) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "complete:2"; @@ -1026,7 +1033,7 @@ TEST(DownloadManagerTest, IsModelCachedReturnsTrueForComplete) { TEST(DownloadManagerTest, IsModelCachedReturnsFalseForEmptyDir) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "empty:1"; @@ -1042,7 +1049,7 @@ TEST(DownloadManagerTest, IsModelCachedReturnsFalseForEmptyDir) { TEST(DownloadManagerTest, VersionSuffixConversion) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "mymodel:42"; @@ -1062,7 +1069,7 @@ TEST(DownloadManagerTest, VersionSuffixConversion) { TEST(DownloadManagerTest, ThrowsOnEmptyUri) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "test:1"; @@ -1076,14 +1083,12 @@ TEST(DownloadManagerTest, ThrowsOnEmptyUri) { // proceed in parallel — covered by the unrelated-model test below. TEST(DownloadManagerTest, ConcurrentDownloadsOfSameModelSerialize) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); auto registry = std::make_unique( "eastus", fl::test::NullLog(), std::make_unique(fl::test::NullLog(), false), [](const std::string&) { return MakeRegistryResponse(R"({"blobSasUri": "https://storage.blob.core.windows.net/c?sig=test"})"); }); - manager.SetModelRegistryClient(std::move(registry)); // Counting mock — increments an atomic on every DownloadBlob call. class CountingDownloader : public IBlobDownloader { @@ -1116,7 +1121,8 @@ TEST(DownloadManagerTest, ConcurrentDownloadsOfSameModelSerialize) { auto counting = std::make_unique(); auto* counting_raw = counting.get(); - manager.SetBlobDownloader(std::move(counting)); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink(), + /*disable_region_fallback=*/false, std::move(registry), std::move(counting)); ModelInfo info; info.model_id = "concurrent-model:1"; @@ -1160,7 +1166,6 @@ TEST(DownloadManagerTest, ConcurrentDownloadsOfSameModelSerialize) { // second download can't enter until the first releases the mutex). TEST(DownloadManagerTest, ModelDownloadsSerializeUnderGlobalLock) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); auto registry = std::make_unique( "eastus", fl::test::NullLog(), std::make_unique(fl::test::NullLog(), false), @@ -1168,7 +1173,6 @@ TEST(DownloadManagerTest, ModelDownloadsSerializeUnderGlobalLock) { return MakeRegistryResponse( R"({"blobSasUri": "https://storage.blob.core.windows.net/c?sig=test"})"); }); - manager.SetModelRegistryClient(std::move(registry)); // Tracks the peak number of downloads running at once. The global download // mutex must keep this at 1 even for different models. @@ -1207,7 +1211,8 @@ TEST(DownloadManagerTest, ModelDownloadsSerializeUnderGlobalLock) { auto probe = std::make_unique(); auto* probe_raw = probe.get(); - manager.SetBlobDownloader(std::move(probe)); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink(), + /*disable_region_fallback=*/false, std::move(registry), std::move(probe)); auto make_info = [](const char* id, const char* publisher) { ModelInfo info; @@ -1252,7 +1257,6 @@ TEST(DownloadManagerTest, ModelDownloadsSerializeUnderGlobalLock) { // recheck WITHOUT re-downloading anything. TEST(DownloadManagerTest, WaitsForCrossProcessLockThenServesCachedResult) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); // Registry + downloader that must stay untouched if the post-lock recheck works. auto registry = std::make_unique( @@ -1261,12 +1265,12 @@ TEST(DownloadManagerTest, WaitsForCrossProcessLockThenServesCachedResult) { return MakeRegistryResponse( R"({"blobSasUri": "https://storage.blob.core.windows.net/c?sig=test"})"); }); - manager.SetModelRegistryClient(std::move(registry)); auto mock = std::make_unique(); mock->blobs_to_return = {{"weights.bin", 100}}; // non-empty: a stray download would be visible auto* mock_raw = mock.get(); - manager.SetBlobDownloader(std::move(mock)); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink(), + /*disable_region_fallback=*/false, std::move(registry), std::move(mock)); ModelInfo info; info.model_id = "wait-model:1"; @@ -1307,7 +1311,7 @@ TEST(DownloadManagerTest, WaitsForCrossProcessLockThenServesCachedResult) { // underlying directory_iterator would throw filesystem_error. TEST(DownloadManagerTest, IsModelCachedReturnsFalseWhenPathIsRegularFile) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "filemodel:1"; @@ -1375,7 +1379,7 @@ TEST(EndToEndTest, DISABLED_LiveCatalogAndDownload) { // 3. Download the model — use build output dir so reruns skip the download auto cache_path = fs::path(__FILE__).parent_path().parent_path() / "build" / "test_cache"; fs::create_directories(cache_path); - DownloadManager dm(cache_path.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager dm(cache_path.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); std::vector progress_values; std::string local_path = dm.DownloadModel(*smallest, [&](float pct) { @@ -1448,7 +1452,7 @@ TEST(EndToEndTest, DISABLED_LiveCatalogAndDownload) { TEST(DownloadManagerTest, RejectsParentEscapeInModelId) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "../evil:1"; @@ -1460,7 +1464,7 @@ TEST(DownloadManagerTest, RejectsParentEscapeInModelId) { TEST(DownloadManagerTest, RejectsBackslashInPublisher) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "test:1"; @@ -1471,7 +1475,7 @@ TEST(DownloadManagerTest, RejectsBackslashInPublisher) { TEST(DownloadManagerTest, RejectsForwardSlashInPublisher) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "test:1"; @@ -1484,7 +1488,7 @@ TEST(DownloadManagerTest, RejectsColonInBareModelId) { // model_id "drive:c:1" splits as bare="drive:c", version="1"; the bare half then // contains a stray ':' that would let a Windows drive letter slip through. auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "drive:c:1"; @@ -1495,7 +1499,7 @@ TEST(DownloadManagerTest, RejectsColonInBareModelId) { TEST(DownloadManagerTest, RejectsTrailingDotInPublisher) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "test:1"; @@ -1506,7 +1510,7 @@ TEST(DownloadManagerTest, RejectsTrailingDotInPublisher) { TEST(DownloadManagerTest, RejectsEmptyModelId) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = ""; @@ -1517,7 +1521,7 @@ TEST(DownloadManagerTest, RejectsEmptyModelId) { TEST(DownloadManagerTest, AcceptsNormalModelIdAndPublisher) { auto tmpdir = TempPath::CreateTempDir(); - DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog()); + DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog(), fl::test::TestTelemetrySink()); ModelInfo info; info.model_id = "phi-3-mini:1"; diff --git a/sdk_v2/cpp/test/internal_api/ep_detector_test.cc b/sdk_v2/cpp/test/internal_api/ep_detector_test.cc index 8feaef302..eb45e64e3 100644 --- a/sdk_v2/cpp/test/internal_api/ep_detector_test.cc +++ b/sdk_v2/cpp/test/internal_api/ep_detector_test.cc @@ -14,7 +14,9 @@ #include "ep_detection/ep_bootstrapper.h" #include "ep_detection/ep_detector.h" #include "ep_detection/ep_types.h" +#include "internal_api/test_helpers.h" #include "logger.h" +#include "telemetry/telemetry.h" #include @@ -46,8 +48,12 @@ class MockEpBootstrapper : public IEpBootstrapper { ILogger& /*logger*/) override { download_called_ = true; if (progress_cb) { - progress_cb(name_, 50.0f); - progress_cb(name_, 100.0f); + if (!progress_cb(name_, 50.0f)) { + return false; + } + if (!progress_cb(name_, 100.0f)) { + return false; + } } if (succeed_) { registered_ = true; @@ -63,6 +69,28 @@ class MockEpBootstrapper : public IEpBootstrapper { bool registered_ = false; }; +class RecordingTelemetry : public ITelemetry { + public: + void RecordAction(Action, ActionStatus, const InvocationContext&, int64_t) override {} + void RecordException(Action, const std::exception&, const InvocationContext&) override {} + void RecordModelUsage(const ModelUsageInfo&) override {} + void RecordModelId(Action, const std::string&, ActionStatus, const InvocationContext&) override {} + void RecordEpDownloadAndRegister(const EpDownloadAndRegisterInfo& info) override { + ep_register_calls.push_back(info); + } + void RecordDownload(const DownloadInfo&) override {} + void RecordCatalogFetch(const CatalogFetchInfo&) override {} + void StartSession() override {} + void EndSession() override {} + + void RecordEpDownloadAttempt(const EpDownloadAttemptInfo& info) override { + ep_attempt_calls.push_back(info); + } + + std::vector ep_attempt_calls; + std::vector ep_register_calls; +}; + // ======================================================================== // Fixture // ======================================================================== @@ -81,14 +109,15 @@ class EpDetectorTest : public ::testing::Test { /// Build an EpDetector from a list of mock bootstrappers, retaining raw pointers. std::unique_ptr MakeDetector(std::vector& raw_ptrs, - std::vector> specs) { + std::vector> specs, + ITelemetry& telemetry = fl::test::TestTelemetrySink()) { std::vector> bootstrappers; for (auto& [name, succeed] : specs) { auto mock = std::make_unique(name, succeed); raw_ptrs.push_back(mock.get()); bootstrappers.push_back(std::move(mock)); } - return std::make_unique(*ort_api_, *ort_env_, std::move(bootstrappers), logger_); + return std::make_unique(*ort_api_, *ort_env_, std::move(bootstrappers), logger_, telemetry); } }; @@ -207,6 +236,49 @@ TEST_F(EpDetectorTest, DownloadFiltered_UnknownNamesSkipped) { EXPECT_TRUE(mocks[0]->download_called_); } +TEST_F(EpDetectorTest, DownloadFiltered_TelemetryCountsRequestedNamesIncludingUnknown) { + RecordingTelemetry telemetry; + std::vector mocks; + auto detector = MakeDetector(mocks, {{"CUDAExecutionProvider", true}}, telemetry); + + std::vector names = {"CUDAExecutionProvider", "NonExistentProvider"}; + auto result = detector->DownloadAndRegisterEps(&names, nullptr); + + EXPECT_TRUE(result.success); + ASSERT_EQ(telemetry.ep_attempt_calls.size(), 1u); + EXPECT_EQ(telemetry.ep_attempt_calls[0].num_providers, 2); + EXPECT_EQ(telemetry.ep_attempt_calls[0].attempts, 1); + EXPECT_EQ(telemetry.ep_attempt_calls[0].succeeded, 1); + ASSERT_EQ(telemetry.ep_register_calls.size(), 1u); + EXPECT_EQ(telemetry.ep_register_calls[0].download_status, ActionStatus::kSuccess); + EXPECT_EQ(telemetry.ep_register_calls[0].register_status, ActionStatus::kSuccess); +} + +TEST_F(EpDetectorTest, DownloadAll_CancelledProgressRecordsSkippedTelemetry) { + RecordingTelemetry telemetry; + std::vector mocks; + auto detector = MakeDetector(mocks, {{"CUDAExecutionProvider", true}, + {"QNNExecutionProvider", true}}, + telemetry); + + auto result = detector->DownloadAndRegisterEps(nullptr, [](const std::string&, float) { return false; }); + + EXPECT_FALSE(result.success); + EXPECT_TRUE(result.cancelled); + EXPECT_TRUE(result.registered_eps.empty()); + EXPECT_TRUE(result.failed_eps.empty()); + ASSERT_EQ(telemetry.ep_attempt_calls.size(), 1u); + EXPECT_EQ(telemetry.ep_attempt_calls[0].status, ActionStatus::kCanceled); + EXPECT_FALSE(telemetry.ep_attempt_calls[0].user_agent.empty()); + EXPECT_EQ(telemetry.ep_attempt_calls[0].attempts, 1); + EXPECT_EQ(telemetry.ep_attempt_calls[0].succeeded, 0); + EXPECT_EQ(telemetry.ep_attempt_calls[0].failed, 0); + ASSERT_EQ(telemetry.ep_register_calls.size(), 1u); + EXPECT_FALSE(telemetry.ep_register_calls[0].user_agent.empty()); + EXPECT_EQ(telemetry.ep_register_calls[0].download_status, ActionStatus::kSkipped); + EXPECT_EQ(telemetry.ep_register_calls[0].register_status, ActionStatus::kCanceled); +} + TEST_F(EpDetectorTest, DownloadFiltered_AllNamesUnknown_SucceedsWithNothing) { std::vector mocks; auto detector = MakeDetector(mocks, {{"CUDAExecutionProvider", true}}); diff --git a/sdk_v2/cpp/test/internal_api/null_telemetry.h b/sdk_v2/cpp/test/internal_api/null_telemetry.h deleted file mode 100644 index 6d2c11c16..000000000 --- a/sdk_v2/cpp/test/internal_api/null_telemetry.h +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -// No-op telemetry implementation for tests that don't care about telemetry events. -#pragma once - -#include "telemetry/telemetry.h" - -namespace fl::test { - -class NullTelemetry : public ITelemetry { - public: - void RecordAction(Action /*action*/, ActionStatus /*status*/, - const std::string& /*user_agent*/, - bool /*indirect*/, int64_t /*duration_ms*/) override {} - - void RecordException(Action /*action*/, const std::exception& /*exception*/) override {} - - void RecordModelUsage(const std::string& /*model_id*/, - int64_t /*prompt_tokens*/, - int64_t /*completion_tokens*/, - int64_t /*duration_ms*/) override {} - - void RecordModelId(Action /*action*/, const std::string& /*model_id*/) override {} -}; - -} // namespace fl::test diff --git a/sdk_v2/cpp/test/internal_api/session_manager_test.cc b/sdk_v2/cpp/test/internal_api/session_manager_test.cc index 9e485781a..f0ae41251 100644 --- a/sdk_v2/cpp/test/internal_api/session_manager_test.cc +++ b/sdk_v2/cpp/test/internal_api/session_manager_test.cc @@ -10,7 +10,7 @@ #include "exception.h" #include "logger.h" #include "model.h" -#include "internal_api/null_telemetry.h" +#include "telemetry/telemetry_logger.h" #include "internal_api/test_helpers.h" #include "internal_api/test_model_cache.h" @@ -59,7 +59,7 @@ class SessionManagerTest : public ::testing::Test { /// Create an unregistered ChatSession (for cache tests that only test cache mechanics). std::unique_ptr MakeSession() { - return std::make_unique(GetCatalogModel(), GetModel(), GetLogger(), null_telemetry_); + return std::make_unique(GetCatalogModel(), GetModel(), GetLogger(), telemetry_); } /// Tracked session: a session + its registration guard. @@ -84,7 +84,7 @@ class SessionManagerTest : public ::testing::Test { static inline fl::test::FakeServiceBindings svc_; static inline Model catalog_model_ = Model::FromModelInfo( ModelInfo{}, "", svc_.download_manager, svc_.model_load_manager); - fl::test::NullTelemetry null_telemetry_; + TelemetryLogger telemetry_{"foundry-local-test", fl::test::NullLog()}; }; // =========================================================================== diff --git a/sdk_v2/cpp/test/internal_api/telemetry_test.cc b/sdk_v2/cpp/test/internal_api/telemetry_test.cc index 56683cd68..3db68a416 100644 --- a/sdk_v2/cpp/test/internal_api/telemetry_test.cc +++ b/sdk_v2/cpp/test/internal_api/telemetry_test.cc @@ -2,21 +2,69 @@ // Licensed under the MIT License. #include "logger.h" #include "telemetry/telemetry_action_tracker.h" +#include "telemetry/telemetry_context.h" +#include "telemetry/device_id.h" +#include "telemetry/telemetry_environment.h" #include "telemetry/telemetry_logger.h" +#include "telemetry/telemetry_metadata.h" +#include "telemetry/one_ds_telemetry.h" +#include "telemetry/telemetry_redaction.h" +#include "telemetry/telemetry_sampling.h" +#include "test_helpers.h" #include #include +#include +#include #include #include #include #include #include +#ifdef _WIN32 +#include +#endif + using namespace fl; namespace { +class ScopedEnvVar { + public: + ScopedEnvVar(const char* name, const char* value) : name_(name) { + original_ = TelemetryEnvironment::GetEnv(name); + had_original_ = !original_.empty(); +#ifdef _WIN32 + ::SetEnvironmentVariableA(name, value); +#else + setenv(name, value, 1); +#endif + } + + ~ScopedEnvVar() { +#ifdef _WIN32 + if (had_original_) { + ::SetEnvironmentVariableA(name_.c_str(), original_.c_str()); + } else { + ::SetEnvironmentVariableA(name_.c_str(), nullptr); + } +#else + if (had_original_) { + setenv(name_.c_str(), original_.c_str(), 1); + } else { + unsetenv(name_.c_str()); + } +#endif + } + + private: + std::string name_; + std::string original_; + bool had_original_ = false; +}; + struct LogEntry { LogLevel level; std::string message; @@ -31,6 +79,15 @@ class RecordingLogger : public ILogger { std::vector entries; }; +class RecordingSemanticContext { + public: + void SetCommonField(const std::string& name, const std::string& value) { + fields[name] = value; + } + + std::map fields; +}; + struct ActionCall { Action action; ActionStatus status; @@ -39,104 +96,293 @@ struct ActionCall { int64_t duration_ms; }; -class RecordingTelemetry : public ITelemetry { +class CapturingTelemetry : public ITelemetry { public: void RecordAction(Action action, ActionStatus status, - const std::string& user_agent, - bool indirect, int64_t duration_ms) override { - action_calls.push_back(ActionCall{action, status, user_agent, indirect, duration_ms}); + const InvocationContext& context, int64_t duration_ms) override { + action_calls.push_back( + ActionCall{action, status, context.user_agent, context.indirect, duration_ms}); } - void RecordException(Action action, const std::exception& exception) override { + void RecordException(Action action, const std::exception& exception, + const InvocationContext& /*context*/) override { exception_calls.emplace_back(action, exception.what()); } - void RecordModelUsage(const std::string& model_id, - int64_t prompt_tokens, - int64_t completion_tokens, - int64_t duration_ms) override { - model_usage_calls.push_back( - ModelUsageCall{model_id, prompt_tokens, completion_tokens, duration_ms}); - } + void RecordModelUsage(const ModelUsageInfo&) override {} - void RecordModelId(Action action, const std::string& model_id) override { + void RecordModelId(Action action, const std::string& model_id, + ActionStatus /*status*/, const InvocationContext& /*context*/) override { model_id_calls.emplace_back(action, model_id); } - struct ModelUsageCall { - std::string model_id; - int64_t prompt_tokens; - int64_t completion_tokens; - int64_t duration_ms; - }; + void RecordEpDownloadAttempt(const EpDownloadAttemptInfo&) override {} + + void RecordEpDownloadAndRegister(const EpDownloadAndRegisterInfo&) override {} + + void RecordDownload(const DownloadInfo&) override {} + + void RecordCatalogFetch(const CatalogFetchInfo&) override {} std::vector action_calls; std::vector> exception_calls; - std::vector model_usage_calls; std::vector> model_id_calls; }; } // namespace +TEST(TelemetryEnvironmentTest, TruthyValueParsing) { + EXPECT_FALSE(TelemetryEnvironment::IsTruthyValue("")); + EXPECT_FALSE(TelemetryEnvironment::IsTruthyValue(" ")); + EXPECT_FALSE(TelemetryEnvironment::IsTruthyValue("0")); + EXPECT_FALSE(TelemetryEnvironment::IsTruthyValue(" false ")); + EXPECT_FALSE(TelemetryEnvironment::IsTruthyValue("NO")); + EXPECT_FALSE(TelemetryEnvironment::IsTruthyValue("off")); + EXPECT_TRUE(TelemetryEnvironment::IsTruthyValue("1")); + EXPECT_TRUE(TelemetryEnvironment::IsTruthyValue("true")); + EXPECT_TRUE(TelemetryEnvironment::IsTruthyValue("yes")); + EXPECT_TRUE(TelemetryEnvironment::IsTruthyValue("anything")); +} + +TEST(TelemetryEnvironmentTest, DetectsCiEnvironmentFlag) { + ScopedEnvVar ci("CI", "true"); + EXPECT_TRUE(TelemetryEnvironment::IsCiEnvironment()); +} + +TEST(TelemetryContextTest, SuppressesUnneededCommonContext) { + RecordingSemanticContext context; + + TelemetryInternal::SuppressUnneededCommonContext(context); + + ASSERT_EQ(context.fields.size(), TelemetryInternal::kSuppressedCommonContextFields.size()); + for (const char* field : TelemetryInternal::kSuppressedCommonContextFields) { + EXPECT_EQ(context.fields.at(field), ""); + } +} + +TEST(OneDsTelemetryTest, DisableNonessentialTelemetrySuppressesUpload) { + // In test processes, hard suppression prevents 1DS upload entirely. Outside tests/CI, + // manager disable_nonessential_telemetry initializes 1DS but suppresses non-ProcessInfo uploads. + OneDsTelemetry telemetry("TestApp", fl::test::NullLog(), /*disable_nonessential_telemetry=*/true); + EXPECT_FALSE(telemetry.IsUploadEnabled()); +} + +TEST(TelemetryActionTest, EpActionNamesMatchEventNames) { + EXPECT_EQ(ActionToString(Action::kEpDownloadAttempt), "EPDownloadAttempt"); + EXPECT_EQ(ActionToString(Action::kEpDownloadAndRegister), "EPDownloadAndRegister"); +} + +TEST(TelemetryActionTest, StatusNamesIncludeDetailedFailures) { + EXPECT_EQ(ActionStatusToString(ActionStatus::kClientError), "ClientError"); + EXPECT_EQ(ActionStatusToString(ActionStatus::kCanceled), "Canceled"); + EXPECT_EQ(ActionStatusToString(ActionStatus::kDependencyFailure), "DependencyFailure"); + EXPECT_EQ(ActionStatusToString(ActionStatus::kTimeout), "Timeout"); +} + +TEST(TelemetryActionTest, DirectContextUsesDefaultUserAgent) { + SetDefaultUserAgent("foundry-local-test/1.0"); + auto context = InvocationContext::Direct(); + EXPECT_EQ(context.user_agent, "foundry-local-test/1.0"); + EXPECT_FALSE(context.correlation_id.empty()); + EXPECT_FALSE(context.indirect); + SetDefaultUserAgent({}); +} + TEST(TelemetryLoggerTest, RecordActionIncludesConcreteFields) { RecordingLogger logger; TelemetryLogger telemetry("foundry-local", logger); - telemetry.RecordAction(Action::kModelDownload, ActionStatus::kSuccess, - "cli/1.0", false, 1234); + telemetry.RecordAction(Action::kModelFileDownload, ActionStatus::kSuccess, + InvocationContext{"cli/1.0", "corr-1", false}, 1234); ASSERT_EQ(logger.entries.size(), 1u); EXPECT_EQ(logger.entries[0].level, LogLevel::Debug); - EXPECT_NE(logger.entries[0].message.find("AppName:foundry-local"), std::string::npos); - EXPECT_NE(logger.entries[0].message.find("UserAgent:cli/1.0"), std::string::npos); - EXPECT_NE(logger.entries[0].message.find("Command:ModelDownload"), std::string::npos); - EXPECT_NE(logger.entries[0].message.find("Status:Success"), std::string::npos); - EXPECT_NE(logger.entries[0].message.find("Direct:true"), std::string::npos); - EXPECT_NE(logger.entries[0].message.find("Time:1234ms"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("AppName=foundry-local"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("UserAgent=cli/1.0"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("CorrelationId=corr-1"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("Action=ModelFileDownload"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("Status=Success"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("Direct=true"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("TimeMs=1234"), std::string::npos); } TEST(TelemetryLoggerTest, RecordExceptionAndModelEventsIncludeSpecificValues) { RecordingLogger logger; TelemetryLogger telemetry("foundry-local", logger); - telemetry.RecordException(Action::kModelLoad, std::runtime_error("config missing")); - telemetry.RecordModelUsage("phi-3-mini", 17, 31, 250); - telemetry.RecordModelId(Action::kModelLoad, "phi-3-mini"); + telemetry.RecordException(Action::kModelLoad, std::runtime_error("config missing"), InvocationContext{}); + + ModelUsageInfo usage; + usage.model_id = "phi-3-mini"; + usage.execution_provider = "CPU"; + usage.user_agent = "cli/1.0"; + usage.total_tokens = 31; + usage.input_token_count = 17; + usage.total_time_ms = 250; + telemetry.RecordModelUsage(usage); + + telemetry.RecordModelId(Action::kModelLoad, "phi-3-mini", ActionStatus::kSuccess, + InvocationContext{"cli/1.0", "", false}); ASSERT_EQ(logger.entries.size(), 3u); - EXPECT_NE(logger.entries[0].message.find("Command:ModelLoad"), std::string::npos); - EXPECT_NE(logger.entries[0].message.find("Exception:config missing"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("Action=ModelLoad"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("Exception=config missing"), std::string::npos); - EXPECT_NE(logger.entries[1].message.find("ModelUsage: model=phi-3-mini"), std::string::npos); - EXPECT_NE(logger.entries[1].message.find("prompt_tokens=17"), std::string::npos); - EXPECT_NE(logger.entries[1].message.find("completion_tokens=31"), std::string::npos); - EXPECT_NE(logger.entries[1].message.find("duration=250ms"), std::string::npos); + EXPECT_NE(logger.entries[1].message.find("Model "), std::string::npos); + EXPECT_NE(logger.entries[1].message.find("ModelId=phi-3-mini"), std::string::npos); + EXPECT_NE(logger.entries[1].message.find("InputTokenCount=17"), std::string::npos); + EXPECT_NE(logger.entries[1].message.find("TotalTokens=31"), std::string::npos); + EXPECT_NE(logger.entries[1].message.find("TotalTimeMs=250"), std::string::npos); - EXPECT_NE(logger.entries[2].message.find("Command:ModelLoad"), std::string::npos); - EXPECT_NE(logger.entries[2].message.find("ModelId:phi-3-mini"), std::string::npos); + EXPECT_NE(logger.entries[2].message.find("Action=ModelLoad"), std::string::npos); + EXPECT_NE(logger.entries[2].message.find("ModelId=phi-3-mini"), std::string::npos); +} + +TEST(TelemetryLoggerTest, RecordAudioUsageIncludesAudioSpecificFields) { + RecordingLogger logger; + TelemetryLogger telemetry("foundry-local", logger); + + AudioUsageInfo info; + info.model_id = "whisper-tiny"; + info.execution_provider = "CPUExecutionProvider"; + info.user_agent = "cli/1.0"; + info.correlation_id = "corr-audio"; + info.audio_source = "streaming_pcm"; + info.language = "en"; + info.stream = true; + info.indirect = true; + info.total_time_ms = 1234; + info.total_tokens = 42; + info.input_token_count = 0; + info.completion_token_count = 42; + info.audio_duration_ms = 5000; + info.sample_rate = 16000; + info.channels = 1; + + telemetry.RecordAudioUsage(info); + + ASSERT_EQ(logger.entries.size(), 1u); + EXPECT_NE(logger.entries[0].message.find("AudioModel"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("ModelId=whisper-tiny"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("AudioSource=streaming_pcm"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("Language=en"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("AudioDurationMs=5000"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("SampleRate=16000"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("Channels=1"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("Direct=false"), std::string::npos); +} + +TEST(TelemetryLoggerTest, RecordProcessInfoIncludesStartupMetadata) { + RecordingLogger logger; + TelemetryLogger telemetry("foundry-local", logger); + + ProcessInfo info; + info.app_name = "foundry-local"; + info.app_version = "4.5.6"; + info.os_name = "Windows"; + info.os_version = "10.0.26100"; + info.cpu_arch = "amd64"; + info.process_name = "foundry_local_test.exe"; + info.device_id_status = "Existing"; + info.cpu_count = 8; + info.total_memory_mb = 32768; + info.locale = "en-US"; + + telemetry.RecordProcessInfo(info); + + ASSERT_EQ(logger.entries.size(), 1u); + EXPECT_NE(logger.entries[0].message.find("ProcessInfo"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("AppVersion=4.5.6"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("ProcessName=foundry_local_test.exe"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("DeviceIdStatus=Existing"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("CpuCount=8"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("TotalMemoryMB=32768"), std::string::npos); +} + +TEST(TelemetryLoggerTest, RecordHardwareInfoIncludesCoarseAcceleratorInventory) { + RecordingLogger logger; + TelemetryLogger telemetry("foundry-local", logger); + + HardwareInfo info; + info.has_cpu = true; + info.has_gpu = true; + info.device_type_count = 2; + info.execution_provider_count = 3; + info.device_types = "CPU,GPU"; + info.execution_providers = "CPUExecutionProvider,CUDAExecutionProvider,WebGpuExecutionProvider"; + + telemetry.RecordHardwareInfo(info); + + ASSERT_EQ(logger.entries.size(), 1u); + EXPECT_NE(logger.entries[0].message.find("HardwareInfo"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("DeviceTypes=CPU,GPU"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("ExecutionProviderCount=3"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("HasGPU=true"), std::string::npos); +} + +TEST(TelemetryMetadataTest, HostAppVersionIsAlwaysPopulated) { + auto metadata = BuildTelemetryMetadata("foundry-local-test"); + + EXPECT_FALSE(metadata.app_version.empty()); + EXPECT_FALSE(metadata.version.empty()); +} + +TEST(TelemetryDeviceIdTest, ValidatesGuidShapeAndHashesForUpload) { + EXPECT_TRUE(TelemetryDeviceId::IsValidGuid("01234567-89ab-4def-8123-456789abcdef")); + EXPECT_FALSE(TelemetryDeviceId::IsValidGuid("0123456789ab4def8123456789abcdef")); + EXPECT_FALSE(TelemetryDeviceId::IsValidGuid("zzzzzzzz-89ab-4def-8123-456789abcdef")); + + auto hashed = TelemetryDeviceId::HashForTelemetry("01234567-89ab-4def-8123-456789abcdef"); + EXPECT_EQ(hashed, "c:6225BD190D6CCF87766A49C9986D174DEF3391FE175A61525E49A1D2334D6A43"); +} + +TEST(TelemetryRedactionTest, ScrubsPathsKeepsNonPathTextAndCapsLength) { + EXPECT_EQ(ScrubStringForTelemetry("config missing"), "config missing"); + EXPECT_EQ(ScrubStringForTelemetry("/secret"), "[path]"); + EXPECT_EQ(ScrubStringForTelemetry("Load C:\\Users\\First Last\\model.onnx failed"), "Load [path]"); + EXPECT_EQ(ScrubStringForTelemetry("open /home/alice/model.onnx failed"), "open [path]"); + EXPECT_EQ(ScrubStringForTelemetry("ratio 3/4 and and/or"), "ratio 3/4 and and/or"); + + const std::string long_msg(kMaxTelemetryStringLength + 100, 'x'); + EXPECT_EQ(ScrubStringForTelemetry(long_msg).size(), kMaxTelemetryStringLength); + + const std::string euro = "\xE2\x82\xAC"; + const std::string partial_tail = std::string(kMaxTelemetryStringLength - 1, 'x') + euro; + EXPECT_EQ(ScrubStringForTelemetry(partial_tail), std::string(kMaxTelemetryStringLength - 1, 'x')); +} + +TEST(TelemetrySamplingTest, SamplesAllEventsAtCurrentDefaultRate) { + EXPECT_TRUE(TelemetryInternal::ShouldSampleTelemetryEvent("app-session", "corr-1")); +} + +TEST(TelemetrySamplingTest, HonorsZeroAndHundredPercentRates) { + EXPECT_FALSE(TelemetryInternal::ShouldSampleTelemetryEvent("app-session", "corr-1", 0.0)); + EXPECT_TRUE(TelemetryInternal::ShouldSampleTelemetryEvent("app-session", "corr-1", 100.0)); } TEST(ActionTrackerTest, DestructorRecordsFailureByDefaultWithoutModelId) { - RecordingTelemetry telemetry; + CapturingTelemetry telemetry; + SetDefaultUserAgent("foundry-local-test/2.0"); { - ActionTracker tracker(Action::kModelDelete, telemetry, "cli/2.0", true); + ActionTracker tracker(Action::kModelFileDownload, telemetry); } ASSERT_EQ(telemetry.action_calls.size(), 1u); - EXPECT_EQ(telemetry.action_calls[0].action, Action::kModelDelete); + EXPECT_EQ(telemetry.action_calls[0].action, Action::kModelFileDownload); EXPECT_EQ(telemetry.action_calls[0].status, ActionStatus::kFailure); - EXPECT_EQ(telemetry.action_calls[0].user_agent, "cli/2.0"); - EXPECT_TRUE(telemetry.action_calls[0].indirect); + EXPECT_EQ(telemetry.action_calls[0].user_agent, "foundry-local-test/2.0"); + EXPECT_FALSE(telemetry.action_calls[0].indirect); EXPECT_GE(telemetry.action_calls[0].duration_ms, 0); EXPECT_TRUE(telemetry.model_id_calls.empty()); + SetDefaultUserAgent({}); } TEST(ActionTrackerTest, RecordsExceptionSuccessAndModelId) { - RecordingTelemetry telemetry; + CapturingTelemetry telemetry; { - ActionTracker tracker(Action::kModelLoad, telemetry, "cli/3.0", false); + ActionTracker tracker(Action::kModelLoad, telemetry, InvocationContext{"cli/3.0", "", false}); tracker.RecordException(std::runtime_error("failed to load")); tracker.SetModelId("phi-3-mini"); tracker.SetStatus(ActionStatus::kSuccess); diff --git a/sdk_v2/cpp/test/internal_api/test_helpers.h b/sdk_v2/cpp/test/internal_api/test_helpers.h index e5ef61ea0..72b824805 100644 --- a/sdk_v2/cpp/test/internal_api/test_helpers.h +++ b/sdk_v2/cpp/test/internal_api/test_helpers.h @@ -7,6 +7,7 @@ #include "ep_detection/ep_detector.h" #include "inferencing/model_load_manager.h" #include "logger.h" +#include "telemetry/telemetry_logger.h" #include "utils/temp_path.h" @@ -41,6 +42,11 @@ inline ILogger& NullLog() { return instance; } +inline ITelemetry& TestTelemetrySink() { + static TelemetryLogger instance("foundry-local-test", NullLog()); + return instance; +} + /// One-stop bag of cheap fakes for tests that need to construct a leaf `Model` via /// `FromModelInfo` but don't exercise Download/Load. Public fields by design — no invariants /// to protect, and the field names match the matching `FromModelInfo` parameter names so the @@ -48,7 +54,9 @@ inline ILogger& NullLog() { struct FakeServiceBindings { CpuOnlyEpDetector ep_detector; NullLogger logger; - DownloadManager download_manager{/*cache_directory=*/"", /*catalog_region=*/"", /*max_concurrency=*/1, logger}; + TelemetryLogger telemetry{"foundry-local-test", logger}; + DownloadManager download_manager{/*cache_directory=*/"", /*catalog_region=*/"", /*max_concurrency=*/1, logger, + telemetry}; ModelLoadManager model_load_manager{ep_detector, logger}; }; diff --git a/sdk_v2/cpp/test/internal_api/web_service_test.cc b/sdk_v2/cpp/test/internal_api/web_service_test.cc index 4b33be0a3..9cceca717 100644 --- a/sdk_v2/cpp/test/internal_api/web_service_test.cc +++ b/sdk_v2/cpp/test/internal_api/web_service_test.cc @@ -15,8 +15,9 @@ #include "logger.h" #include "model.h" #include "model_info.h" -#include "null_telemetry.h" +#include "telemetry/telemetry_logger.h" #include "service/web_service.h" +#include "utils/temp_path.h" #include @@ -25,6 +26,7 @@ #include #include +#include #include #include #include @@ -58,6 +60,47 @@ std::string TestHttpDelete(const std::string& url, const std::string& user_agent return http::HttpDelete(url, options); } +// Telemetry sink that records every event for assertions. +class CapturingTelemetry : public ITelemetry { + public: + struct ActionCall { + Action action; + ActionStatus status; + std::string user_agent; + std::string correlation_id; + bool indirect; + }; + + void RecordAction(Action action, ActionStatus status, const InvocationContext& context, + int64_t /*duration_ms*/) override { + std::lock_guard lock(mutex_); + actions.push_back({action, status, context.user_agent, context.correlation_id, context.indirect}); + } + void RecordException(Action, const std::exception&, const InvocationContext&) override {} + void RecordModelUsage(const ModelUsageInfo& info) override { + std::lock_guard lock(mutex_); + model_usages.push_back(info); + } + void RecordModelId(Action, const std::string&, ActionStatus, const InvocationContext&) override {} + void RecordEpDownloadAttempt(const EpDownloadAttemptInfo&) override {} + void RecordEpDownloadAndRegister(const EpDownloadAndRegisterInfo&) override {} + void RecordDownload(const DownloadInfo&) override {} + void RecordCatalogFetch(const CatalogFetchInfo&) override {} + + std::optional Find(Action action) { + std::lock_guard lock(mutex_); + for (const auto& c : actions) { + if (c.action == action) { + return c; + } + } + return std::nullopt; + } + std::vector actions; + std::vector model_usages; + std::mutex mutex_; +}; + } // namespace // ======================================================================== @@ -71,7 +114,7 @@ class WebServiceTest : public ::testing::Test { ep_detector_ = std::make_unique(); model_load_manager_ = std::make_unique(*ep_detector_, *logger_); session_manager_ = std::make_unique(*logger_); - null_telemetry_ = std::make_unique(); + telemetry_ = std::make_unique("foundry-local-test", fl::test::NullLog()); catalog_ = std::make_unique(); // Populate with test models @@ -92,7 +135,7 @@ class WebServiceTest : public ::testing::Test { *model_load_manager_)); service_ = std::make_unique(*catalog_, *logger_, "/tmp/test-cache", - *model_load_manager_, *session_manager_, *null_telemetry_, []() {}); + *model_load_manager_, *session_manager_, *telemetry_, []() {}); auto urls = service_->Start({"http://127.0.0.1:0"}); ASSERT_EQ(urls.size(), 1u); base_url_ = urls[0]; @@ -105,7 +148,7 @@ class WebServiceTest : public ::testing::Test { service_.reset(); catalog_.reset(); session_manager_.reset(); - null_telemetry_.reset(); + telemetry_.reset(); model_load_manager_.reset(); ep_detector_.reset(); logger_.reset(); @@ -122,7 +165,7 @@ class WebServiceTest : public ::testing::Test { static std::unique_ptr logger_; static std::unique_ptr model_load_manager_; static std::unique_ptr session_manager_; - static std::unique_ptr null_telemetry_; + static std::unique_ptr telemetry_; static std::unique_ptr service_; static std::string base_url_; static inline fl::test::FakeServiceBindings svc_; @@ -134,7 +177,7 @@ std::unique_ptr WebServiceTest::ep_detector_; std::unique_ptr WebServiceTest::logger_; std::unique_ptr WebServiceTest::model_load_manager_; std::unique_ptr WebServiceTest::session_manager_; -std::unique_ptr WebServiceTest::null_telemetry_; +std::unique_ptr WebServiceTest::telemetry_; std::unique_ptr WebServiceTest::service_; std::string WebServiceTest::base_url_; @@ -374,9 +417,11 @@ TEST(WebServiceLifecycleTest, StartAndStopOnEphemeralPort) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger telemetry{"foundry-local-test", fl::test::NullLog()}; - WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); + auto model_cache_dir = fl::test::TempPath::CreateTempDir("fl_web_service_test_"); + WebService service(catalog, logger, model_cache_dir.string(), model_load_manager, session_manager, telemetry, + []() {}); auto urls = service.Start({"http://127.0.0.1:0"}); ASSERT_EQ(urls.size(), 1u); @@ -396,9 +441,11 @@ TEST(WebServiceLifecycleTest, DoubleStartThrows) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger telemetry{"foundry-local-test", fl::test::NullLog()}; - WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); + auto model_cache_dir = fl::test::TempPath::CreateTempDir("fl_web_service_test_"); + WebService service(catalog, logger, model_cache_dir.string(), model_load_manager, session_manager, telemetry, + []() {}); service.Start({"http://127.0.0.1:0"}); EXPECT_THROW(service.Start({"http://127.0.0.1:0"}), std::runtime_error); @@ -412,9 +459,11 @@ TEST(WebServiceLifecycleTest, StopWithoutStartIsNoop) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger telemetry{"foundry-local-test", fl::test::NullLog()}; - WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); + auto model_cache_dir = fl::test::TempPath::CreateTempDir("fl_web_service_test_"); + WebService service(catalog, logger, model_cache_dir.string(), model_load_manager, session_manager, telemetry, + []() {}); // Should not crash service.Stop(); } @@ -425,9 +474,11 @@ TEST(WebServiceLifecycleTest, MultipleEndpoints) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger telemetry{"foundry-local-test", fl::test::NullLog()}; - WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); + auto model_cache_dir = fl::test::TempPath::CreateTempDir("fl_web_service_test_"); + WebService service(catalog, logger, model_cache_dir.string(), model_load_manager, session_manager, telemetry, + []() {}); auto urls = service.Start({"http://127.0.0.1:0", "http://127.0.0.1:0"}); EXPECT_EQ(urls.size(), 2u) << "Expected 2 bound URLs"; @@ -451,9 +502,11 @@ TEST(WebServiceEmptyCatalogTest, ListModelsReturnsEmptyData) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger telemetry{"foundry-local-test", fl::test::NullLog()}; - WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); + auto model_cache_dir = fl::test::TempPath::CreateTempDir("fl_web_service_test_"); + WebService service(catalog, logger, model_cache_dir.string(), model_load_manager, session_manager, telemetry, + []() {}); auto urls = service.Start({"http://127.0.0.1:0"}); auto body = TestHttpGet(urls[0] + "/v1/models"); @@ -471,9 +524,11 @@ TEST(WebServiceEmptyCatalogTest, LoadedModelsReturnsEmptyArray) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger telemetry{"foundry-local-test", fl::test::NullLog()}; - WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); + auto model_cache_dir = fl::test::TempPath::CreateTempDir("fl_web_service_test_"); + WebService service(catalog, logger, model_cache_dir.string(), model_load_manager, session_manager, telemetry, + []() {}); auto urls = service.Start({"http://127.0.0.1:0"}); auto body = TestHttpGet(urls[0] + "/models/loaded"); @@ -485,6 +540,39 @@ TEST(WebServiceEmptyCatalogTest, LoadedModelsReturnsEmptyArray) { service.Stop(); } +// ======================================================================== +// Telemetry behaviors — capture events and assert coverage / classification +// ======================================================================== + +TEST(WebServiceTelemetryTest, EmptyChatBodyRecordsClientErrorNotFailure) { + test::MockCatalog catalog; + StderrLogger logger; + test::CpuOnlyEpDetector ep_detector; + ModelLoadManager model_load_manager(ep_detector, logger); + SessionManager session_manager(logger); + CapturingTelemetry telemetry; + + auto model_cache_dir = fl::test::TempPath::CreateTempDir("fl_web_service_telemetry_test_"); + WebService service(catalog, logger, model_cache_dir.string(), model_load_manager, session_manager, telemetry, + []() {}); + auto urls = service.Start({"http://127.0.0.1:0"}); + + // Empty body is rejected (400) before any model resolution. + try { + TestHttpPost(urls[0] + "/v1/chat/completions", ""); + } catch (...) { + } + + service.Stop(); + + auto call = telemetry.Find(Action::kOpenAIChatCompletions); + ASSERT_TRUE(call.has_value()) << "chat completions route action was not recorded"; + EXPECT_EQ(call->status, ActionStatus::kClientError); + EXPECT_FALSE(call->indirect); + // A 4xx reject performs no inference, so there is no Model event. + EXPECT_TRUE(telemetry.model_usages.empty()); +} + // ======================================================================== // Streaming validation tests — same errors apply with stream=true // ======================================================================== @@ -775,10 +863,10 @@ TEST(WebServiceShutdownTest, StopReturnsQuicklyWithKeepAliveClient) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger telemetry{"foundry-local-test", fl::test::NullLog()}; test::MockCatalog catalog; - WebService service(catalog, logger, "/tmp/test-cache", model_load_manager, session_manager, null_telemetry, + WebService service(catalog, logger, "/tmp/test-cache", model_load_manager, session_manager, telemetry, []() {}); auto urls = service.Start({"http://127.0.0.1:0"}); diff --git a/sdk_v2/cpp/test/sdk_api/manager_web_service_test.cc b/sdk_v2/cpp/test/sdk_api/manager_web_service_test.cc index 286e5fd26..002de0c19 100644 --- a/sdk_v2/cpp/test/sdk_api/manager_web_service_test.cc +++ b/sdk_v2/cpp/test/sdk_api/manager_web_service_test.cc @@ -112,7 +112,9 @@ TEST_F(ManagerWebServiceTest, StopWebServiceIsIdempotentAfterSuccessfulStart) { GTEST_SKIP() << "StartWebService unavailable in this build: " << what; } - EXPECT_FALSE(manager.GetWebServiceEndpoints().empty()); + const auto endpoints = manager.GetWebServiceEndpoints(); + ASSERT_EQ(endpoints.size(), 1u); + EXPECT_EQ(endpoints[0].find("http://127.0.0.1:"), 0u); EXPECT_NO_THROW(manager.StopWebService()); EXPECT_TRUE(manager.GetWebServiceEndpoints().empty()); diff --git a/sdk_v2/cpp/vcpkg.json b/sdk_v2/cpp/vcpkg.json index 8a3d1037e..e5c3412eb 100644 --- a/sdk_v2/cpp/vcpkg.json +++ b/sdk_v2/cpp/vcpkg.json @@ -32,6 +32,17 @@ "oatpp" ] }, + "telemetry": { + "description": "Build with 1DS (cpp-client-telemetry) telemetry uploads", + "dependencies": [ + { + "name": "cpp-client-telemetry", + "default-features": false, + "features": ["minimal-sqlite", "curl-openssl"], + "platform": "!uwp" + } + ] + }, "tests": { "description": "Build unit tests", "dependencies": [ diff --git a/sdk_v2/cs/src/Configuration.cs b/sdk_v2/cs/src/Configuration.cs index 83f8cac28..b3ff04aad 100644 --- a/sdk_v2/cs/src/Configuration.cs +++ b/sdk_v2/cs/src/Configuration.cs @@ -57,6 +57,12 @@ public class Configuration /// public string? CatalogRegion { get; init; } + /// + /// Optional. Disable non-essential telemetry. Foundry Local may still send a minimal ProcessInfo event. + /// Defaults to false (telemetry enabled). + /// + public bool DisableNonessentialTelemetry { get; init; } + /// /// Catalog URLs with optional per-catalog filter overrides. /// Each entry is a (url, filter) pair where filter may be null to use the default. diff --git a/sdk_v2/cs/src/FoundryLocalManager.cs b/sdk_v2/cs/src/FoundryLocalManager.cs index 54863556f..bc8a05929 100644 --- a/sdk_v2/cs/src/FoundryLocalManager.cs +++ b/sdk_v2/cs/src/FoundryLocalManager.cs @@ -243,6 +243,11 @@ await Task.Run(() => // Merge AdditionalSettings with user-supplied entries. // Done as a local dict so we don't mutate the user-supplied AdditionalSettings. var additionalSettings = new Dictionary(StringComparer.Ordinal); + additionalSettings["UserAgent"] = $"foundry-local-csharp/{AssemblyVersion}"; + if (_config.DisableNonessentialTelemetry) + { + additionalSettings["DisableNonessentialTelemetry"] = "true"; + } if (_config.AdditionalSettings != null) { foreach (var kvp in _config.AdditionalSettings) diff --git a/sdk_v2/js/native/src/manager.cc b/sdk_v2/js/native/src/manager.cc index 6d359b588..319974e80 100644 --- a/sdk_v2/js/native/src/manager.cc +++ b/sdk_v2/js/native/src/manager.cc @@ -88,6 +88,21 @@ Manager::Manager(const Napi::CallbackInfo& info) : Napi::ObjectWrap(inf return true; }; + auto read_optional_bool = [&](const char* key, bool& out, bool& has) -> bool { + if (opts.Has(key) && !opts.Get(key).IsUndefined() && !opts.Get(key).IsNull()) { + if (!opts.Get(key).IsBoolean()) { + std::string msg = "options."; + msg += key; + msg += " must be a boolean"; + Napi::TypeError::New(env, msg).ThrowAsJavaScriptException(); + return false; + } + out = opts.Get(key).As(); + has = true; + } + return true; + }; + std::string model_cache_dir; bool has_model_cache_dir = false; if (!read_optional_string("modelCacheDir", model_cache_dir, has_model_cache_dir)) return; @@ -96,6 +111,13 @@ Manager::Manager(const Napi::CallbackInfo& info) : Napi::ObjectWrap(inf bool has_external_service_url = false; if (!read_optional_string("serviceEndpoint", external_service_url, has_external_service_url)) return; + bool disable_nonessential_telemetry = false; + bool has_disable_nonessential_telemetry = false; + if (!read_optional_bool("disableNonessentialTelemetry", disable_nonessential_telemetry, + has_disable_nonessential_telemetry)) { + return; + } + std::string app_data_dir; bool has_app_data_dir = false; if (!read_optional_string("appDataDir", app_data_dir, has_app_data_dir)) return; @@ -191,6 +213,13 @@ Manager::Manager(const Napi::CallbackInfo& info) : Napi::ObjectWrap(inf for (const auto& entry : additional_settings) { kvp.Set(entry.first.c_str(), entry.second.c_str()); } + if (has_disable_nonessential_telemetry) { + kvp.Set("DisableNonessentialTelemetry", disable_nonessential_telemetry ? "true" : "false"); + } + config.SetAdditionalOptions(kvp); + } else if (has_disable_nonessential_telemetry) { + foundry_local::KeyValuePairs kvp; + kvp.Set("DisableNonessentialTelemetry", disable_nonessential_telemetry ? "true" : "false"); config.SetAdditionalOptions(kvp); } impl_ = std::make_unique(std::move(config)); diff --git a/sdk_v2/js/src/configuration.ts b/sdk_v2/js/src/configuration.ts index d592de12b..d7b821f87 100644 --- a/sdk_v2/js/src/configuration.ts +++ b/sdk_v2/js/src/configuration.ts @@ -22,6 +22,9 @@ export interface FoundryLocalConfig { /** External service URL (when the web service runs in a separate process). */ serviceEndpoint?: string; + /** Disable non-essential telemetry. Foundry Local may still send a minimal ProcessInfo event. Defaults to false. */ + disableNonessentialTelemetry?: boolean; + /** * Directory containing the native Foundry Local library (`foundry_local.dll` on Windows, `libfoundry_local.so` * on Linux, `libfoundry_local.dylib` on macOS). When set, the SDK pre-loads the library from this directory @@ -48,6 +51,7 @@ export const FOUNDRY_LOCAL_CONFIG_KEYS: ReadonlySet = "logLevel", "webServiceUrls", "serviceEndpoint", + "disableNonessentialTelemetry", "libraryPath", "additionalSettings", ]); diff --git a/sdk_v2/js/src/detail/native.ts b/sdk_v2/js/src/detail/native.ts index 0f5c2c758..b47f35f55 100644 --- a/sdk_v2/js/src/detail/native.ts +++ b/sdk_v2/js/src/detail/native.ts @@ -14,6 +14,7 @@ export interface NativeManagerCtor { appName: string; modelCacheDir?: string; serviceEndpoint?: string; + disableNonessentialTelemetry?: boolean; appDataDir?: string; logsDir?: string; logLevel?: "trace" | "debug" | "info" | "warn" | "error" | "fatal"; diff --git a/sdk_v2/js/src/foundryLocalManager.ts b/sdk_v2/js/src/foundryLocalManager.ts index 9cf9946cf..05aeeff65 100644 --- a/sdk_v2/js/src/foundryLocalManager.ts +++ b/sdk_v2/js/src/foundryLocalManager.ts @@ -3,6 +3,8 @@ // `FoundryLocalError` from the native addon. `create()` and `createAsync()` are factory wrappers around the // constructor — the native layer is the source of truth for instance identity. +import { readFileSync } from "node:fs"; + import { type Catalog, wrapNativeCatalog } from "./catalog.js"; import { FOUNDRY_LOCAL_CONFIG_KEYS, type FoundryLocalConfig } from "./configuration.js"; import { @@ -13,6 +15,18 @@ import { } from "./detail/native.js"; import type { EpDownloadResult, EpInfo } from "./types.js"; +function readSdkVersion(): string { + const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { + version?: unknown; + }; + if (typeof packageJson.version !== "string" || packageJson.version.length === 0) { + throw new Error("package.json version is missing."); + } + return packageJson.version; +} + +const SDK_VERSION = readSdkVersion(); + // A native Manager holds process-global native resources. Dispose the live // Manager on process exit so native teardown happens at a deterministic point // rather than being left entirely to environment finalizers. `beforeExit` @@ -91,7 +105,20 @@ export class FoundryLocalManager { } } } - this.#native = new (getAddon().Manager)(config); + const rawAdditionalSettings: unknown = config.additionalSettings; + if ( + rawAdditionalSettings !== undefined && + (typeof rawAdditionalSettings !== "object" || + rawAdditionalSettings === null || + Array.isArray(rawAdditionalSettings)) + ) { + throw new TypeError("additionalSettings must be an object"); + } + const additionalSettings = { + UserAgent: `foundry-local-js/${SDK_VERSION}`, + ...config.additionalSettings, + }; + this.#native = new (getAddon().Manager)({ ...config, additionalSettings }); liveManager = this; installExitHandlersOnce(); } diff --git a/sdk_v2/js/test/_fixtures/realModelManager.ts b/sdk_v2/js/test/_fixtures/realModelManager.ts index 2c9214453..391639ce0 100644 --- a/sdk_v2/js/test/_fixtures/realModelManager.ts +++ b/sdk_v2/js/test/_fixtures/realModelManager.ts @@ -46,6 +46,8 @@ export interface RealModelManagerOptions { * "qwen2.5-0.5b" — alias of the smallest chat model we ship. */ readonly namePreference?: string; + /** Skip in CI when catalog data cannot identify a matching cached model. */ + readonly skipUnavailableInCi?: boolean; } export interface RealModelManagerFixture { @@ -107,9 +109,11 @@ export async function setupRealModelManager(opts: RealModelManagerOptions = {}): }); if (matching.length === 0) { manager.dispose(); - throw new Error( - `No catalog model matches task='${task}' deviceType='CPU' (and preference '${namePref}' missing)`, - ); + const message = `No catalog model matches task='${task}' deviceType='CPU' (and preference '${namePref}' missing)`; + if (isCi && opts.skipUnavailableInCi === true) { + throw new SkipFixture(`[CI] ${message}`); + } + throw new Error(message); } matching.sort( (a, b) => diff --git a/sdk_v2/js/test/embedding-client.test.ts b/sdk_v2/js/test/embedding-client.test.ts index b9076b93a..a665ef1a7 100644 --- a/sdk_v2/js/test/embedding-client.test.ts +++ b/sdk_v2/js/test/embedding-client.test.ts @@ -1,12 +1,13 @@ // EmbeddingClient (V1 OpenAI-JSON pass-through) against a real loaded // embeddings model. Gated by FOUNDRY_TEST_DATA_DIR. -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { EmbeddingClient } from "../src/openai/embeddingClient.js"; import { type RealModelManagerFixture, haveTestModelCache, + SkipFixture, setupRealModelManager, teardownRealModelManager, testModelCacheDiagnostic, @@ -44,17 +45,34 @@ function l2Distance(a: number[], b: number[]): number { describe.skipIf(!haveTestModelCache)("EmbeddingClient (real model, V1 OpenAI-JSON pass-through)", () => { let fixture: RealModelManagerFixture | undefined; let client: EmbeddingClient | undefined; + let skipReason: string | undefined; beforeAll(async () => { - fixture = await setupRealModelManager({ - task: "embeddings", - namePreference: "qwen3-embedding-0.6b-generic-cpu", - }); + try { + fixture = await setupRealModelManager({ + task: "embeddings", + namePreference: "qwen3-embedding-0.6b-generic-cpu", + skipUnavailableInCi: true, + }); + } catch (error) { + if (error instanceof SkipFixture) { + skipReason = error.message; + console.warn(error.message); + return; + } + throw error; + } if (fixture !== undefined) { client = fixture.model.createEmbeddingClient(); } }, 5 * 60_000); + beforeEach((context) => { + if (skipReason !== undefined) { + context.skip(skipReason); + } + }); + afterAll(() => { client?.dispose(); client = undefined; diff --git a/sdk_v2/python/src/foundry_local_sdk/_native/build_cffi.py b/sdk_v2/python/src/foundry_local_sdk/_native/build_cffi.py index c48ec92ff..f651ef9c4 100644 --- a/sdk_v2/python/src/foundry_local_sdk/_native/build_cffi.py +++ b/sdk_v2/python/src/foundry_local_sdk/_native/build_cffi.py @@ -453,7 +453,7 @@ flStatusPtr (*Session_SetOptions)(flSession* session, const flKeyValuePairs* options); flStatusPtr (*Session_ProcessRequest)(flSession* session, const flRequest* request, flResponse** response); flStatusPtr (*Session_AddToolDefinition)(flSession* session, const flToolDefinition* tool_def); - flStatusPtr (*Session_RemoveToolDefinition)(flSession* session, const char* tool_name, bool* out_removed); + flStatusPtr (*Session_RemoveToolDefinition)(flSession* session, const char* tool_name, _Bool* out_removed); size_t (*Session_GetTurnCount)(const flSession* session); flStatusPtr (*Session_UndoTurns)(flSession* session, size_t count); } flInferenceApi; diff --git a/sdk_v2/python/src/foundry_local_sdk/configuration.py b/sdk_v2/python/src/foundry_local_sdk/configuration.py index c7be7cc91..cfbb08dab 100644 --- a/sdk_v2/python/src/foundry_local_sdk/configuration.py +++ b/sdk_v2/python/src/foundry_local_sdk/configuration.py @@ -9,6 +9,7 @@ from foundry_local_sdk.exception import FoundryLocalException from foundry_local_sdk.logging_helper import LogLevel +from foundry_local_sdk.version import __version__ # Maps Python LogLevel → native flLogLevel integer values (from foundry_local_c.h) _LOG_LEVEL_MAP: dict[LogLevel, int] = { @@ -47,6 +48,8 @@ class Configuration: Each entry is a ``(url, filter)`` tuple where filter may be ``None``. Defaults to the Azure Foundry Local Catalog when empty or ``None``. catalog_region: Region hint forwarded to the catalog service + disable_nonessential_telemetry: When True, disable non-essential telemetry. Foundry + Local may still send a minimal ProcessInfo event. Defaults to False. external_service_url: URL of an external Foundry Local service. When set, the catalog operates in cache-only mode — it reads only the local disk cache populated by that external service and skips @@ -86,6 +89,7 @@ def __init__( additional_settings: dict[str, str] | None = None, catalog_urls: list[tuple[str, str | None]] | None = None, catalog_region: str | None = None, + disable_nonessential_telemetry: bool = False, ) -> None: self.app_name = app_name # v1-compat no-op: native loading happens at import time in @@ -99,6 +103,7 @@ def __init__( self.additional_settings = additional_settings self.catalog_urls = catalog_urls self.catalog_region = catalog_region + self.disable_nonessential_telemetry = disable_nonessential_telemetry def validate(self) -> None: """Validate the configuration. @@ -140,6 +145,7 @@ def as_dictionary(self) -> dict[str, str]: config_values: dict[str, str] = { "AppName": self.app_name, "LogLevel": str(self.log_level), + "UserAgent": f"foundry-local-python/{__version__}", } if self.app_data_dir: @@ -220,7 +226,6 @@ def _apply_settings(self, native_config, api, ffi) -> object: native_config, self.catalog_region.encode("utf-8") ) ) - # Web service configuration if self.web is not None: if self.web.urls is not None: @@ -239,21 +244,26 @@ def _apply_settings(self, native_config, api, ffi) -> object: ) # Additional key/value settings + additional_settings = {"UserAgent": f"foundry-local-python/{__version__}"} + if self.disable_nonessential_telemetry: + additional_settings["DisableNonessentialTelemetry"] = "true" if self.additional_settings: - kvp_out = ffi.new("flKeyValuePairs**") - api.root.CreateKeyValuePairs(kvp_out) - kvp = kvp_out[0] - try: - for key, value in self.additional_settings.items(): - if not key: - continue - api.root.AddKeyValuePair( - kvp, - key.encode("utf-8"), - (value if value is not None else "").encode("utf-8"), - ) - api.check_status(api.config.SetAdditionalOptions(native_config, kvp)) - finally: - api.root.KeyValuePairs_Release(kvp) + additional_settings.update(self.additional_settings) + + kvp_out = ffi.new("flKeyValuePairs**") + api.root.CreateKeyValuePairs(kvp_out) + kvp = kvp_out[0] + try: + for key, value in additional_settings.items(): + if not key: + continue + api.root.AddKeyValuePair( + kvp, + key.encode("utf-8"), + (value if value is not None else "").encode("utf-8"), + ) + api.check_status(api.config.SetAdditionalOptions(native_config, kvp)) + finally: + api.root.KeyValuePairs_Release(kvp) return native_config diff --git a/sdk_v2/python/test/integration/test_configuration_native.py b/sdk_v2/python/test/integration/test_configuration_native.py index 1874e57d0..77fa3d0c1 100644 --- a/sdk_v2/python/test/integration/test_configuration_native.py +++ b/sdk_v2/python/test/integration/test_configuration_native.py @@ -75,3 +75,11 @@ def test_additional_settings_accepted(self): ) with _native_config(c) as ptr: assert ptr is not None + + def test_disable_nonessential_telemetry_accepted(self): + c = Configuration( + app_name="BuildNativeTest", + disable_nonessential_telemetry=True, + ) + with _native_config(c) as ptr: + assert ptr is not None diff --git a/sdk_v2/python/test/unit/test_configuration.py b/sdk_v2/python/test/unit/test_configuration.py index 94a20554a..106504ed4 100644 --- a/sdk_v2/python/test/unit/test_configuration.py +++ b/sdk_v2/python/test/unit/test_configuration.py @@ -36,6 +36,7 @@ def test_minimal_settings_serialize(self): d = c.as_dictionary() assert d["AppName"] == "App" assert d["LogLevel"] == str(LogLevel.INFORMATION) + assert d["UserAgent"].startswith("foundry-local-python/") assert "AppDataDir" not in d def test_all_directory_settings_round_trip(self): @@ -60,6 +61,11 @@ def test_additional_settings_merged_in(self): assert d["Key2"] == "v2" assert "" not in d + def test_user_agent_can_be_overridden(self): + c = Configuration(app_name="App", additional_settings={"UserAgent": "custom-client/1"}) + d = c.as_dictionary() + assert d["UserAgent"] == "custom-client/1" + def test_additional_settings_none_value_becomes_empty(self): c = Configuration(app_name="App", additional_settings={"K": None}) d = c.as_dictionary() @@ -89,10 +95,13 @@ class TestConfigurationExtendedFields: def test_new_fields_default_to_none(self): c = Configuration(app_name="App") assert c.catalog_region is None + assert c.disable_nonessential_telemetry is False def test_new_fields_round_trip(self): c = Configuration( app_name="App", catalog_region="westus2", + disable_nonessential_telemetry=True, ) assert c.catalog_region == "westus2" + assert c.disable_nonessential_telemetry is True