diff --git a/sdk_v2/cpp/CMakeLists.txt b/sdk_v2/cpp/CMakeLists.txt index 3dc080875..c2e838860 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 # -------------------------------------------------------------------------- @@ -197,7 +234,11 @@ 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/utils.cc src/util/file_lock.cc src/http/http_download.cc @@ -209,6 +250,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}) @@ -251,12 +298,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) @@ -274,6 +327,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() # -------------------------------------------------------------------------- @@ -297,6 +353,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/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/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/manager.cc b/sdk_v2/cpp/src/manager.cc index bd827d7a2..21412a18e 100644 --- a/sdk_v2/cpp/src/manager.cc +++ b/sdk_v2/cpp/src/manager.cc @@ -22,7 +22,9 @@ #include "inferencing/session/session_manager.h" #include "spdlog_logger.h" #include "telemetry/telemetry_action_tracker.h" -#include "telemetry/telemetry_logger.h" +#include "telemetry/one_ds_telemetry.h" +#include "telemetry/telemetry_environment.h" +#include "telemetry/telemetry_metadata.h" #include "util/string_utils.h" #include "utils.h" @@ -302,7 +304,23 @@ Manager::Manager(const Configuration& config) 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_); + const bool disable_nonessential_telemetry = + config_.disable_nonessential_telemetry || + IsAdditionalOptionEnabled(config_, "DisableNonessentialTelemetry"); + const bool telemetry_hard_disabled = + TelemetryEnvironment::IsCiEnvironment() || TelemetryEnvironment::IsTelemetryDisabledByEnvVar(); + telemetry_ = std::make_unique(config_.app_name, *logger_, disable_nonessential_telemetry); + try { + telemetry_->RecordProcessInfo( + BuildProcessInfo(BuildTelemetryMetadata(config_.app_name), + !disable_nonessential_telemetry && !telemetry_hard_disabled)); + } catch (const std::exception& ex) { + logger_->Log( + LogLevel::Warning, + fmt::format("telemetry ProcessInfo failed during Manager initialization: {}", ex.what())); + } catch (...) { + logger_->Log(LogLevel::Warning, "telemetry ProcessInfo failed during Manager initialization."); + } catalog_ = std::make_unique( config_.catalog_urls, download_manager_->GetCacheDirectory(), @@ -444,6 +462,13 @@ void Manager::StartWebService() { bound_urls_ = web_service_->Start(endpoints); web_service_running_ = true; + 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"); + } tracker.SetStatus(ActionStatus::kSuccess); #else FL_LOG_AND_THROW(*logger_, FOUNDRY_LOCAL_ERROR_INVALID_USAGE, @@ -470,6 +495,13 @@ void Manager::StopWebService() { #ifdef FOUNDRY_LOCAL_HAS_WEB_SERVICE web_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"); + } web_service_.reset(); web_service_running_ = false; bound_urls_.clear(); diff --git a/sdk_v2/cpp/src/service/audio_transcriptions_handler.cc b/sdk_v2/cpp/src/service/audio_transcriptions_handler.cc index e7b2ac642..7665d29ca 100644 --- a/sdk_v2/cpp/src/service/audio_transcriptions_handler.cc +++ b/sdk_v2/cpp/src/service/audio_transcriptions_handler.cc @@ -91,12 +91,14 @@ std::shared_ptr AudioTranscriptionsHandler 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 +110,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,6 +123,7 @@ std::shared_ptr AudioTranscriptionsHandler Model* model = nullptr; GenAIModelInstance* loaded = nullptr; if (auto err = ResolveModel(model_name, model, loaded)) { + tracker.SetStatus(ActionStatus::kClientError); return err; } diff --git a/sdk_v2/cpp/src/service/chat_completions_handler.cc b/sdk_v2/cpp/src/service/chat_completions_handler.cc index fcf13fb29..fcc103807 100644 --- a/sdk_v2/cpp/src/service/chat_completions_handler.cc +++ b/sdk_v2/cpp/src/service/chat_completions_handler.cc @@ -108,6 +108,7 @@ std::shared_ptr ChatCompletionsHandler::ha 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 +118,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; } diff --git a/sdk_v2/cpp/src/service/embeddings_handler.cc b/sdk_v2/cpp/src/service/embeddings_handler.cc index b5f734b07..209c83e9f 100644 --- a/sdk_v2/cpp/src/service/embeddings_handler.cc +++ b/sdk_v2/cpp/src/service/embeddings_handler.cc @@ -32,6 +32,7 @@ class EmbeddingsHandler : public HttpRequestHandler { 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 +42,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 +55,7 @@ class EmbeddingsHandler : public HttpRequestHandler { } if (inputs.empty()) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "\"input\" must not be empty"); } @@ -60,11 +63,13 @@ 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); } diff --git a/sdk_v2/cpp/src/service/models_handlers.cc b/sdk_v2/cpp/src/service/models_handlers.cc index 93988d3b6..5cd6ee8ea 100644 --- a/sdk_v2/cpp/src/service/models_handlers.cc +++ b/sdk_v2/cpp/src/service/models_handlers.cc @@ -52,6 +52,7 @@ class LoadModelHandler : public HttpRequestHandler { auto name_raw = request->getPathVariable("name"); if (!name_raw) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing model name"); } @@ -59,6 +60,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 +71,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"); } @@ -105,6 +108,7 @@ class UnloadModelHandler : public HttpRequestHandler { auto name_raw = request->getPathVariable("name"); if (!name_raw) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing model name"); } @@ -112,6 +116,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 + "'"); } @@ -207,6 +212,7 @@ class OpenAIRetrieveModelHandler : public HttpRequestHandler { auto name_raw = request->getPathVariable("name"); if (!name_raw) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing model name"); } @@ -214,6 +220,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..09aa1a8af 100644 --- a/sdk_v2/cpp/src/service/responses_handler.cc +++ b/sdk_v2/cpp/src/service/responses_handler.cc @@ -134,6 +134,7 @@ std::shared_ptr ResponsesHandler::handle( 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 +142,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,6 +157,7 @@ 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; } @@ -602,6 +605,7 @@ std::shared_ptr GetResponseHandler::handle auto id = request->getPathVariable("id"); if (!id) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing response ID"); } @@ -692,6 +696,7 @@ std::shared_ptr DeleteResponseHandler::han auto id = request->getPathVariable("id"); if (!id) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing response ID"); } @@ -737,6 +742,7 @@ std::shared_ptr GetInputItemsHandler::hand auto id = request->getPathVariable("id"); if (!id) { + tracker.SetStatus(ActionStatus::kClientError); return ErrorResponse(Status::CODE_400, "Missing response ID"); } 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/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..02fee0d7c --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/one_ds_telemetry.cc @@ -0,0 +1,542 @@ +// 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_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, double sample_rate_percent = TelemetryInternal::kTelemetrySampleRatePercent) { + EventProperties ev(name); + ev.SetPriority(EventPriority::EventPriority_Normal); + ev.SetPolicyBitFlags(kCriticalData); + ev.SetPopsample(sample_rate_percent); + return ev; +} + +void CleanupLogManager(MatILogManager* log_manager, ILogConfiguration& config) noexcept { + if (log_manager == nullptr) { + return; + } + + try { + log_manager->Flush(); + } catch (...) { + } + + try { + log_manager->FlushAndTeardown(); + } catch (...) { + } + + try { + LogManagerProvider::Release(config); + } catch (...) { + } +} + +bool ShouldSampleEvent(std::string_view app_session_guid, std::string_view correlation_id, + double sample_rate_percent = TelemetryInternal::kTelemetrySampleRatePercent) { + return TelemetryInternal::ShouldSampleTelemetryEvent( + app_session_guid, correlation_id.empty() ? app_session_guid : correlation_id, sample_rate_percent); +} + +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 (TelemetryEnvironment::IsTelemetryDisabledByEnvVar()) { + logger_.Log(LogLevel::Information, + "[Telemetry] Disabled via ORT_TELEMETRY_DISABLED; 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()) { + const auto cache_file_name = + disable_nonessential_telemetry ? "foundry-local-processinfo.db" : "foundry-local.db"; + config[CFG_STR_CACHE_FILE_PATH] = (cache_dir / cache_file_name).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; + } + log_manager_initialized = true; + impl_->logger = impl_->log_manager->GetLogger(token); + if (impl_->logger == nullptr) { + CleanupLogManager(impl_->log_manager, impl_->config); + impl_.reset(); + logger_.Log(LogLevel::Warning, + "[Telemetry] ILogManager::GetLogger returned null; 1DS upload disabled"); + return; + } + if (!disable_nonessential_telemetry && impl_->logger->GetSemanticContext() != nullptr) { + auto* semantic_context = impl_->logger->GetSemanticContext(); + 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) { + if (impl_ != nullptr) { + CleanupLogManager(impl_->log_manager, impl_->config); + } + impl_.reset(); + } + logger_.Log(LogLevel::Warning, + fmt::format("[Telemetry] LogManagerProvider initialization threw: {}; " + "1DS upload disabled", ex.what())); + } catch (...) { + if (log_manager_initialized) { + if (impl_ != nullptr) { + CleanupLogManager(impl_->log_manager, impl_->config); + } + 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); + CleanupLogManager(impl_->log_manager, impl_->config); + impl_.reset(); +} + +void OneDsTelemetry::RecordAction(Action action, ActionStatus status, const InvocationContext& context, + int64_t duration_ms, const std::string& model_id) { + local_log_.RecordAction(action, status, context, duration_ms, model_id); + auto lock = LockForLogging(); + if (!lock.owns_lock()) { + return; + } + if (!ShouldSampleEvent(metadata_.app_session_guid, context.correlation_id, + TelemetryInternal::SampleRateForAction(ActionToString(action)))) { + return; + } + const auto sample_rate_percent = TelemetryInternal::SampleRateForAction(ActionToString(action)); + auto ev = MakeEvent("Action", sample_rate_percent); + 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); + if (!model_id.empty()) { + ev.SetProperty("ModelId", model_id); + } + 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::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); + 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..2cb65f8b5 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/one_ds_telemetry.h @@ -0,0 +1,71 @@ +// 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, const std::string& model_id = {}) 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 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..43378b7e7 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,192 @@ 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 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. + void RecordAction(Action action, ActionStatus status, const std::string& user_agent, + bool indirect, int64_t duration_ms) { + auto context = InvocationContext::Direct(user_agent); + context.indirect = indirect; + RecordAction(action, status, context, duration_ms); + } + + /// 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). ModelId is included + /// when the action resolved a model. 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, + const std::string& model_id = {}) = 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; + + void RecordException(Action action, const std::exception& exception) { + RecordException(action, exception, InvocationContext::Direct()); + } + + /// 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 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..38b5da072 100644 --- a/sdk_v2/cpp/src/telemetry/telemetry_action_tracker.cc +++ b/sdk_v2/cpp/src/telemetry/telemetry_action_tracker.cc @@ -2,24 +2,31 @@ // 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); - - if (!model_id_.empty()) { - telemetry_.RecordModelId(action_, model_id_); + telemetry_.RecordAction(action_, status_, context_, duration_ms, model_id_); + } catch (...) { + // Telemetry is best-effort and must not throw from RAII cleanup. } } @@ -28,7 +35,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_environment.cc b/sdk_v2/cpp/src/telemetry/telemetry_environment.cc new file mode 100644 index 000000000..defa4f469 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_environment.cc @@ -0,0 +1,107 @@ +// 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; +} + +bool TelemetryEnvironment::IsTelemetryDisabledByEnvVar() { + return IsTruthyValue(GetEnv("ORT_TELEMETRY_DISABLED")); +} + +} // 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..95d65d67b --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_environment.h @@ -0,0 +1,30 @@ +// 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(); + + /// Returns true when the shared ORT telemetry opt-out environment variable is set. + static bool IsTelemetryDisabledByEnvVar(); + + /// 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..ee09cdfd7 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,114 @@ 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, const std::string& model_id) { + logger_.Log(LogLevel::Debug, + fmt::format("[Telemetry] Action AppName={} UserAgent={} CorrelationId={} Action={} Status={} " + "Direct={} TimeMs={} ModelId={}", + app_name_, context.user_agent, context.correlation_id, ActionToString(action), + ActionStatusToString(status), !context.indirect, duration_ms, model_id)); +} + +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] AppName:{} UserAgent:{} Command:{} Status:{} Direct:{} Time:{}ms", - app_name_, user_agent, ActionToString(action), - ActionStatusToString(status), !indirect, duration_ms)); + 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::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={}", + 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)); +} + +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..6e41e463e 100644 --- a/sdk_v2/cpp/src/telemetry/telemetry_logger.h +++ b/sdk_v2/cpp/src/telemetry/telemetry_logger.h @@ -9,23 +9,28 @@ 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, const std::string& model_id = {}) 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 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..1838a5a39 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_metadata.cc @@ -0,0 +1,326 @@ +// 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 + +#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 + +} // 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.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..f1672583c --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_redaction.h @@ -0,0 +1,119 @@ +// 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; + } + + const unsigned char prev_anchor = static_cast(s[i - 1]); + if ((std::isspace(prev_anchor) || s[i - 1] == '"' || s[i - 1] == '\'') && i + 1 < s.size() && + !std::isspace(static_cast(s[i + 1]))) { + return i; + } + + size_t segments = 0; + bool segment_has_dot = false; + 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') { + segment_has_dot = segment_has_dot || s[j] == '.'; + ++j; + } + if (j > seg_start) { + ++segments; + } else { + break; + } + } + + if (segments >= 2 || (segments == 1 && segment_has_dot)) { + 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..6c940c719 --- /dev/null +++ b/sdk_v2/cpp/src/telemetry/telemetry_sampling.h @@ -0,0 +1,53 @@ +// 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; +inline constexpr double kCoreAudioTranscribeSampleRatePercent = 2.0; + +static_assert(kTelemetrySampleRatePercent >= 0.0 && kTelemetrySampleRatePercent <= 100.0); +static_assert(kCoreAudioTranscribeSampleRatePercent >= 0.0 && kCoreAudioTranscribeSampleRatePercent <= 100.0); + +inline double SampleRateForAction(std::string_view action_name) { + return action_name == "OpenAIAudioTranscribe" ? kCoreAudioTranscribeSampleRatePercent + : kTelemetrySampleRatePercent; +} + +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/CMakeLists.txt b/sdk_v2/cpp/test/CMakeLists.txt index 214594599..240c64cd7 100644 --- a/sdk_v2/cpp/test/CMakeLists.txt +++ b/sdk_v2/cpp/test/CMakeLists.txt @@ -7,6 +7,7 @@ find_package(httplib CONFIG REQUIRED) # Links against the static library with access to internal src/ headers. # ========================================================================== add_executable(foundry_local_tests + test_main.cc internal_api/audio/audio_session_test.cc internal_api/audio/audio_transcription_contract_test.cc internal_api/audio/pcm_utils_test.cc @@ -68,7 +69,7 @@ target_compile_options(foundry_local_tests PRIVATE ${FOUNDRY_LOCAL_COMPILE_OPTIO target_link_libraries(foundry_local_tests PRIVATE foundry_local_static - GTest::gtest_main + GTest::gtest httplib::httplib ) @@ -128,6 +129,7 @@ gtest_discover_tests(foundry_local_tests # ========================================================================== add_executable(sdk_integration_tests + test_main.cc sdk_api/audio_transcriptions_test.cc sdk_api/cpp_api_test.cc sdk_api/catalog_test.cc @@ -164,7 +166,7 @@ target_include_directories(sdk_integration_tests target_link_libraries(sdk_integration_tests PRIVATE foundry_local_cpp - GTest::gtest_main + GTest::gtest nlohmann_json::nlohmann_json httplib::httplib ) @@ -212,6 +214,7 @@ set_tests_properties(sdk_integration_tests PROPERTIES TIMEOUT 1200) # their own Manager instances with different configurations. # ========================================================================== add_executable(cache_only_tests + test_main.cc sdk_api/cache_only_test.cc sdk_api/catalog_live_test.cc sdk_api/manager_web_service_test.cc @@ -229,7 +232,7 @@ target_include_directories(cache_only_tests target_link_libraries(cache_only_tests PRIVATE foundry_local_cpp - GTest::gtest_main + GTest::gtest nlohmann_json::nlohmann_json ) 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 2e08c5268..d0dff10d9 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 @@ -17,7 +17,6 @@ #include "logger.h" #include "model.h" #include "internal_api/null_session_manager.h" -#include "internal_api/null_telemetry.h" #include "internal_api/test_helpers.h" #include "internal_api/test_model_cache.h" @@ -259,7 +258,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 null_telemetry_{"test", fl::test::NullLog()}; fl::test::NullSessionManager null_session_manager_; }; @@ -311,7 +310,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 null_telemetry_{"test", fl::test::NullLog()}; fl::test::NullSessionManager null_session_manager_; }; @@ -379,7 +378,7 @@ class AudioSessionNemotronInferenceTest : 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 null_telemetry_{"test", fl::test::NullLog()}; fl::test::NullSessionManager null_session_manager_; }; 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..c6511c769 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,6 @@ #include "logger.h" #include "model.h" #include "internal_api/null_session_manager.h" -#include "internal_api/null_telemetry.h" #include "internal_api/test_helpers.h" #include "internal_api/test_model_cache.h" #include "utils/string_utils.h" @@ -68,7 +67,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 null_telemetry_{"test", fl::test::NullLog()}; fl::test::NullSessionManager null_session_manager_; }; 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..ab2034beb 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,6 @@ #include "exception.h" #include "logger.h" #include "model.h" -#include "internal_api/null_telemetry.h" #include "internal_api/test_helpers.h" #include "internal_api/test_model_cache.h" @@ -84,7 +83,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 null_telemetry_{"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..6254a5aa8 100644 --- a/sdk_v2/cpp/test/internal_api/telemetry_test.cc +++ b/sdk_v2/cpp/test/internal_api/telemetry_test.cc @@ -2,21 +2,67 @@ // Licensed under the MIT License. #include "logger.h" #include "telemetry/telemetry_action_tracker.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 +#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; @@ -37,106 +83,328 @@ struct ActionCall { std::string user_agent; bool indirect; int64_t duration_ms; + std::string model_id; }; -class RecordingTelemetry : public ITelemetry { +class CapturingTelemetry : public ITelemetry { public: + using ITelemetry::RecordAction; + 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, + const std::string& model_id) override { + action_calls.push_back( + ActionCall{action, status, context.user_agent, context.indirect, duration_ms, model_id}); } - 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 { - model_id_calls.emplace_back(action, model_id); - } + void RecordEpDownloadAttempt(const EpDownloadAttemptInfo&) override {} - struct ModelUsageCall { - std::string model_id; - int64_t prompt_tokens; - int64_t completion_tokens; - int64_t duration_ms; - }; + 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(TelemetryEnvironmentTest, DetectsSharedOrtTelemetryOptOut) { + ScopedEnvVar disabled("ORT_TELEMETRY_DISABLED", "true"); + EXPECT_TRUE(TelemetryEnvironment::IsTelemetryDisabledByEnvVar()); +} + +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); +} + +TEST(TelemetryActionTest, CompatibilityActionOverloadCreatesCorrelationId) { + CapturingTelemetry telemetry; + + telemetry.RecordAction(Action::kCoreInitialize, ActionStatus::kSuccess, "test-agent", true, 42); + + ASSERT_EQ(telemetry.action_calls.size(), 1u); + EXPECT_EQ(telemetry.action_calls.front().user_agent, "test-agent"); + EXPECT_TRUE(telemetry.action_calls.front().indirect); + EXPECT_EQ(telemetry.action_calls.front().duration_ms, 42); + 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.RecordAction(Action::kModelLoad, ActionStatus::kSuccess, + InvocationContext{"cli/1.0", "", false}, 250, "phi-3-mini"); 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("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("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); - 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); + 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; + + 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, RecordExceptionRedactsPaths) { + RecordingLogger logger; + TelemetryLogger telemetry("foundry-local", logger); + + telemetry.RecordException(Action::kModelLoad, std::runtime_error("failed at C:\\Users\\Alice\\model.onnx"), + InvocationContext{"cli/1.0", "corr-error", false}); + + ASSERT_EQ(logger.entries.size(), 1u); + EXPECT_NE(logger.entries[0].message.find("[Telemetry] Error"), std::string::npos); + EXPECT_NE(logger.entries[0].message.find("Exception=failed at [path]"), std::string::npos); + EXPECT_EQ(logger.entries[0].message.find("Alice"), 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"; - 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); + 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("failed at /secret"), "failed at [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("failed at models/alice.onnx"), "failed at [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(TelemetrySamplingTest, SamplesCoreAudioTranscribeAtTwoPercent) { + EXPECT_DOUBLE_EQ(TelemetryInternal::SampleRateForAction("OpenAIAudioTranscribe"), 2.0); + EXPECT_DOUBLE_EQ(TelemetryInternal::SampleRateForAction("ModelList"), 100.0); + + bool retained = false; + bool dropped = false; + for (int i = 0; i < 10'000 && (!retained || !dropped); ++i) { + const bool sampled = TelemetryInternal::ShouldSampleTelemetryEvent( + "app-session", "audio-correlation-" + std::to_string(i), 2.0); + retained = retained || sampled; + dropped = dropped || !sampled; + } + EXPECT_TRUE(retained); + EXPECT_TRUE(dropped); } 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()); + EXPECT_TRUE(telemetry.action_calls[0].model_id.empty()); + SetDefaultUserAgent({}); } -TEST(ActionTrackerTest, RecordsExceptionSuccessAndModelId) { - RecordingTelemetry telemetry; +TEST(ActionTrackerTest, RecordsExceptionSuccessAndModelIdOnAction) { + 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); @@ -153,7 +421,5 @@ TEST(ActionTrackerTest, RecordsExceptionSuccessAndModelId) { EXPECT_FALSE(telemetry.action_calls[0].indirect); EXPECT_GE(telemetry.action_calls[0].duration_ms, 0); - ASSERT_EQ(telemetry.model_id_calls.size(), 1u); - EXPECT_EQ(telemetry.model_id_calls[0].first, Action::kModelLoad); - EXPECT_EQ(telemetry.model_id_calls[0].second, "phi-3-mini"); + EXPECT_EQ(telemetry.action_calls[0].model_id, "phi-3-mini"); } \ No newline at end of file diff --git a/sdk_v2/cpp/test/internal_api/test_helpers.h b/sdk_v2/cpp/test/internal_api/test_helpers.h index e5ef61ea0..d53677dd8 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" 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..97389f18e 100644 --- a/sdk_v2/cpp/test/internal_api/web_service_test.cc +++ b/sdk_v2/cpp/test/internal_api/web_service_test.cc @@ -15,7 +15,6 @@ #include "logger.h" #include "model.h" #include "model_info.h" -#include "null_telemetry.h" #include "service/web_service.h" #include @@ -71,7 +70,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(); + null_telemetry_ = std::make_unique("test", fl::test::NullLog()); catalog_ = std::make_unique(); // Populate with test models @@ -122,7 +121,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 null_telemetry_; static std::unique_ptr service_; static std::string base_url_; static inline fl::test::FakeServiceBindings svc_; @@ -134,7 +133,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::null_telemetry_; std::unique_ptr WebServiceTest::service_; std::string WebServiceTest::base_url_; @@ -374,7 +373,7 @@ TEST(WebServiceLifecycleTest, StartAndStopOnEphemeralPort) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger null_telemetry{"test", fl::test::NullLog()}; WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); auto urls = service.Start({"http://127.0.0.1:0"}); @@ -396,7 +395,7 @@ TEST(WebServiceLifecycleTest, DoubleStartThrows) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger null_telemetry{"test", fl::test::NullLog()}; WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); service.Start({"http://127.0.0.1:0"}); @@ -412,7 +411,7 @@ TEST(WebServiceLifecycleTest, StopWithoutStartIsNoop) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger null_telemetry{"test", fl::test::NullLog()}; WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); // Should not crash @@ -425,7 +424,7 @@ TEST(WebServiceLifecycleTest, MultipleEndpoints) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger null_telemetry{"test", fl::test::NullLog()}; WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); auto urls = service.Start({"http://127.0.0.1:0", "http://127.0.0.1:0"}); @@ -451,7 +450,7 @@ TEST(WebServiceEmptyCatalogTest, ListModelsReturnsEmptyData) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger null_telemetry{"test", fl::test::NullLog()}; WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); auto urls = service.Start({"http://127.0.0.1:0"}); @@ -471,7 +470,7 @@ TEST(WebServiceEmptyCatalogTest, LoadedModelsReturnsEmptyArray) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger null_telemetry{"test", fl::test::NullLog()}; WebService service(catalog, logger, "/tmp/test", model_load_manager, session_manager, null_telemetry, []() {}); auto urls = service.Start({"http://127.0.0.1:0"}); @@ -775,7 +774,7 @@ TEST(WebServiceShutdownTest, StopReturnsQuicklyWithKeepAliveClient) { test::CpuOnlyEpDetector ep_detector; ModelLoadManager model_load_manager(ep_detector, logger); SessionManager session_manager(logger); - fl::test::NullTelemetry null_telemetry; + TelemetryLogger null_telemetry{"test", fl::test::NullLog()}; test::MockCatalog catalog; WebService service(catalog, logger, "/tmp/test-cache", model_load_manager, session_manager, null_telemetry, diff --git a/sdk_v2/cpp/test/test_main.cc b/sdk_v2/cpp/test/test_main.cc new file mode 100644 index 000000000..2878ee757 --- /dev/null +++ b/sdk_v2/cpp/test/test_main.cc @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#include + +#include + +int main(int argc, char** argv) { +#ifdef _WIN32 + _putenv_s("ORT_TELEMETRY_DISABLED", "1"); +#else + setenv("ORT_TELEMETRY_DISABLED", "1", 1); +#endif + + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} 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..a48cd1f41 100644 --- a/sdk_v2/cs/src/FoundryLocalManager.cs +++ b/sdk_v2/cs/src/FoundryLocalManager.cs @@ -254,6 +254,10 @@ await Task.Run(() => additionalSettings[kvp.Key] = kvp.Value ?? string.Empty; } } + if (_config.DisableNonessentialTelemetry) + { + additionalSettings["DisableNonessentialTelemetry"] = "true"; + } if (additionalSettings.Count > 0) { diff --git a/sdk_v2/js/native/src/manager.cc b/sdk_v2/js/native/src/manager.cc index 6d359b588..e94f29803 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 && disable_nonessential_telemetry) { + kvp.Set("DisableNonessentialTelemetry", disable_nonessential_telemetry ? "true" : "false"); + } + config.SetAdditionalOptions(kvp); + } else if (has_disable_nonessential_telemetry && disable_nonessential_telemetry) { + foundry_local::KeyValuePairs kvp; + kvp.Set("DisableNonessentialTelemetry", "true"); 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/python/src/foundry_local_sdk/configuration.py b/sdk_v2/python/src/foundry_local_sdk/configuration.py index c7be7cc91..350c77c70 100644 --- a/sdk_v2/python/src/foundry_local_sdk/configuration.py +++ b/sdk_v2/python/src/foundry_local_sdk/configuration.py @@ -47,6 +47,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 +88,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 +102,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. @@ -220,7 +224,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,12 +242,18 @@ def _apply_settings(self, native_config, api, ffi) -> object: ) # Additional key/value settings + additional_settings: dict[str, str] = {} if self.additional_settings: + additional_settings.update(self.additional_settings) + if self.disable_nonessential_telemetry: + additional_settings["DisableNonessentialTelemetry"] = "true" + + if 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(): + for key, value in additional_settings.items(): if not key: continue api.root.AddKeyValuePair(