diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index 49ea82113..35c1ca23b 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -33,6 +33,10 @@ inputs: description: 'Whether to enable CURL networking (LD_CURL_NETWORKING=ON)' required: false default: 'false' + install_curl: + description: 'Whether to install CURL development libraries. Required for OpenTelemetry builds (server-sdk-otel), but does not enable CURL networking for the SDK itself.' + required: false + default: 'false' runs: using: composite @@ -49,7 +53,7 @@ runs: uses: ./.github/actions/install-openssl id: install-openssl - name: Install CURL - if: inputs.use_curl == 'true' + if: inputs.use_curl == 'true' || inputs.install_curl == 'true' uses: ./.github/actions/install-curl id: install-curl - name: Build Library diff --git a/.github/workflows/manual-publish-doc.yml b/.github/workflows/manual-publish-doc.yml index 2c15eabb4..b3bb8f016 100644 --- a/.github/workflows/manual-publish-doc.yml +++ b/.github/workflows/manual-publish-doc.yml @@ -10,6 +10,7 @@ on: - libs/client-sdk - libs/server-sdk - libs/server-sdk-redis-source + - libs/server-sdk-otel name: Publish Documentation jobs: build-publish: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 8786577f2..413ed4778 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -14,6 +14,8 @@ jobs: package-server-tag: ${{ steps.release.outputs['libs/server-sdk--tag_name'] }} package-server-redis-released: ${{ steps.release.outputs['libs/server-sdk-redis-source--release_created'] }} package-server-redis-tag: ${{ steps.release.outputs['libs/server-sdk-redis-source--tag_name'] }} + package-server-otel-released: ${{ steps.release.outputs['libs/server-sdk-otel--release_created'] }} + package-server-otel-tag: ${{ steps.release.outputs['libs/server-sdk-otel--tag_name'] }} steps: - uses: googleapis/release-please-action@v4 id: release @@ -142,3 +144,4 @@ jobs: upload-assets: true upload-tag-name: ${{ needs.release-please.outputs.package-server-redis-tag }} provenance-name: ${{ format('{0}-server-redis-multiple-provenance.intoto.jsonl', matrix.os) }} + diff --git a/.github/workflows/server-otel.yml b/.github/workflows/server-otel.yml new file mode 100644 index 000000000..a4acf52ff --- /dev/null +++ b/.github/workflows/server-otel.yml @@ -0,0 +1,54 @@ +name: libs/server-sdk-otel + +on: + push: + branches: [ main ] + paths-ignore: + - '**.md' # Do not need to run CI for markdown changes. + pull_request: + branches: [ "main", "feat/**" ] + paths-ignore: + - '**.md' + schedule: + # Run daily at midnight PST + - cron: '0 8 * * *' + +jobs: + build-test-otel: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-cpp-server-otel + install_curl: true + # We don't produce release artifacts. + simulate_release: false + build-otel-mac: + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + cmake_target: launchdarkly-cpp-server-otel + platform_version: 12 + install_curl: true + # We don't produce release artifacts. + simulate_release: false + build-test-otel-windows: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + - uses: ilammy/msvc-dev-cmd@v1 + - uses: ./.github/actions/ci + env: + BOOST_LIBRARY_DIR: 'C:\local\boost_1_87_0\lib64-msvc-14.3' + BOOST_LIBRARYDIR: 'C:\local\boost_1_87_0\lib64-msvc-14.3' + Boost_DIR: 'C:\local\boost_1_87_0\lib64-msvc-14.3\cmake\Boost-1.87.0' + with: + cmake_target: launchdarkly-cpp-server-otel + platform_version: 2022 + toolset: msvc + install_curl: true + # We don't produce release artifacts. + simulate_windows_release: false diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0f6d9e1f1..49ce3f964 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -5,5 +5,6 @@ "libs/internal": "0.12.1", "libs/server-sdk": "3.9.1", "libs/server-sdk-redis-source": "2.2.0", + "libs/server-sdk-otel": "0.0.0", "libs/networking": "0.1.0" } diff --git a/CMakeLists.txt b/CMakeLists.txt index 2b07f0679..173a383ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -108,6 +108,8 @@ option(LD_BUILD_EXAMPLES "Build hello-world examples." ON) option(LD_BUILD_REDIS_SUPPORT "Build redis support." OFF) +option(LD_BUILD_OTEL_SUPPORT "Build OpenTelemetry integration." OFF) + option(LD_CURL_NETWORKING "Enable CURL-based networking for SSE client (alternative to Boost.Beast/Foxy)" OFF) # If using 'make' as the build system, CMake causes the 'install' target to have a dependency on 'all', meaning @@ -220,6 +222,11 @@ if (LD_BUILD_REDIS_SUPPORT) add_subdirectory(libs/server-sdk-redis-source) endif () +if (LD_BUILD_OTEL_SUPPORT) + message("LaunchDarkly: building OpenTelemetry integration") + add_subdirectory(libs/server-sdk-otel) +endif () + # Built as static or shared depending on LD_BUILD_SHARED_LIBS variable. # This target "links" in common, internal, and sse as object libraries. add_subdirectory(libs/client-sdk) diff --git a/README.md b/README.md index d99e8dcb6..251ef3d08 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,9 @@ Various CMake options are available to customize the client/server SDK builds. | `LD_DYNAMIC_LINK_OPENSSL` | Whether OpenSSL is dynamically linked or not. | Off (static link) | N/A | | `LD_BUILD_REDIS_SUPPORT` | Whether the server-side Redis Source is built or not. | Off | N/A | | `LD_CURL_NETWORKING` | Enable CURL-based networking for all HTTP requests (SSE streams and event delivery). When OFF, Boost.Beast/Foxy is used instead. CURL must be available as a dependency when this option is ON. | Off | N/A | +| `LD_BUILD_OTEL_SUPPORT` | Whether the server-side OpenTelemetry integration package is built or not. | Off | N/A | +| `LD_BUILD_OTEL_FETCH_DEPS` | When building OpenTelemetry support, automatically fetch and configure OpenTelemetry dependencies via CMake FetchContent. This is useful for local development and CI. When OFF, you must provide OpenTelemetry yourself via `find_package`. | Off | `LD_BUILD_OTEL_SUPPORT` | +| `LD_OTEL_CPP_VERSION` | Specifies the OpenTelemetry C++ SDK version (git tag or commit hash) to fetch when `LD_BUILD_OTEL_FETCH_DEPS` is enabled. Can be set to any valid git reference from the [opentelemetry-cpp repository](https://github.com/open-telemetry/opentelemetry-cpp). | `ea1f0d61ce5baa5584b097266bf133d1f31e3607` (v1.23.0) | `LD_BUILD_OTEL_FETCH_DEPS` | > [!WARNING] > When building shared libraries C++ symbols are not exported, only the C API will be exported. This is because C++ does diff --git a/cmake/json.cmake b/cmake/json.cmake index 896f5071a..cce92b294 100644 --- a/cmake/json.cmake +++ b/cmake/json.cmake @@ -9,8 +9,9 @@ if (${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.24") cmake_policy(SET CMP0135 NEW) endif () -FetchContent_Declare(json +# Use the same FetchContent name as OpenTelemetry to avoid duplicate targets +FetchContent_Declare(nlohmann_json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz ) -FetchContent_MakeAvailable(json) +FetchContent_MakeAvailable(nlohmann_json) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index a90423d95..58ea41da7 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -8,3 +8,7 @@ if (LD_BUILD_REDIS_SUPPORT) add_subdirectory(hello-cpp-server-redis) add_subdirectory(hello-c-server-redis) endif () + +if (LD_BUILD_OTEL_SUPPORT) + add_subdirectory(hello-cpp-server-otel) +endif () diff --git a/examples/hello-cpp-server-otel/CMakeLists.txt b/examples/hello-cpp-server-otel/CMakeLists.txt new file mode 100644 index 000000000..35c2fd7dd --- /dev/null +++ b/examples/hello-cpp-server-otel/CMakeLists.txt @@ -0,0 +1,23 @@ +# Required for Apple Silicon support. +cmake_minimum_required(VERSION 3.19) + +project( + LaunchDarklyHelloCPPServerOTel + VERSION 0.1 + DESCRIPTION "LaunchDarkly Hello CPP Server-side SDK with OpenTelemetry Integration" + LANGUAGES CXX +) + +set(THREADS_PREFER_PTHREAD_FLAG ON) +find_package(Threads REQUIRED) + +add_executable(hello-cpp-server-otel main.cpp) + +target_link_libraries(hello-cpp-server-otel + PRIVATE + launchdarkly::server + launchdarkly::server_otel + Threads::Threads + opentelemetry_trace + opentelemetry_exporter_otlp_http +) diff --git a/examples/hello-cpp-server-otel/README.md b/examples/hello-cpp-server-otel/README.md new file mode 100644 index 000000000..e8e4c91ff --- /dev/null +++ b/examples/hello-cpp-server-otel/README.md @@ -0,0 +1,85 @@ +# LaunchDarkly C++ Server SDK - OpenTelemetry Integration Example + +This example demonstrates how to integrate the LaunchDarkly C++ Server SDK with OpenTelemetry tracing to automatically enrich your distributed traces with feature flag evaluation data. + +## What This Example Shows + +- Setting up OpenTelemetry with OTLP HTTP exporter +- Configuring the LaunchDarkly OpenTelemetry tracing hook +- Creating HTTP spans with Boost.Beast +- Automatic feature flag span events in traces +- Passing explicit parent span context to evaluations + +## Prerequisites + +- C++17 or later +- CMake 3.19 or later +- Boost 1.81 or later +- LaunchDarkly SDK key + +## Building + +From the repository root: + +```bash +mkdir build && cd build +cmake .. -DLD_BUILD_EXAMPLES=ON -DLD_BUILD_OTEL_SUPPORT=ON +cmake --build . --target hello-cpp-server-otel +``` + +## Running + +### 1. Set Your LaunchDarkly SDK Key + +Either edit `main.cpp` and set the `SDK_KEY` constant, or use an environment variable: + +```bash +export LD_SDK_KEY=your-sdk-key-here +``` + +### 2. Create a Feature Flag + +In your LaunchDarkly dashboard, create a boolean flag named `show-detailed-weather`. + +### 3. Run the Example + +```bash +./build/examples/hello-cpp-server-otel/hello-cpp-server-otel +``` + +You should see: + +``` +*** SDK successfully initialized! + +*** Weather server running on http://0.0.0.0:8080 +*** Try: curl http://localhost:8080/weather +*** OpenTelemetry tracing enabled, sending traces to LaunchDarkly +*** LaunchDarkly integration enabled with OpenTelemetry tracing hook +``` + +### 4. Make Requests + +```bash +curl http://localhost:8080/weather +``` + +### 5. View Traces in LaunchDarkly + +Navigate to your LaunchDarkly project's Observability section to view traces. Each HTTP request will have: +1. **Root Span**: "HTTP GET /weather" with HTTP attributes +2. **Feature Flag Event**: Attached to the span with evaluation details: + - `feature_flag.key`: "show-detailed-weather" + - `feature_flag.provider.name`: "LaunchDarkly" + - `feature_flag.context.id`: "user:weather-api-user" + - `feature_flag.result.value`: The evaluated flag value + + +### Custom OTLP Endpoint + +To send traces to a different OpenTelemetry collector, set the `LD_OTEL_ENDPOINT` environment variable: + +```bash +export LD_OTEL_ENDPOINT=http://localhost:4318/v1/traces +./build/examples/hello-cpp-server-otel/hello-cpp-server-otel +``` diff --git a/examples/hello-cpp-server-otel/main.cpp b/examples/hello-cpp-server-otel/main.cpp new file mode 100644 index 000000000..a623fb638 --- /dev/null +++ b/examples/hello-cpp-server-otel/main.cpp @@ -0,0 +1,360 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +// Set SDK_KEY to your LaunchDarkly SDK key. +#define SDK_KEY "" + +// Set FEATURE_FLAG_KEY to the feature flag key you want to evaluate. +#define FEATURE_FLAG_KEY "show-detailed-weather" + +// Set INIT_TIMEOUT_MILLISECONDS to the amount of time you will wait for +// the client to become initialized. +#define INIT_TIMEOUT_MILLISECONDS 3000 + +char const* get_with_env_fallback(char const* source_val, + char const* env_variable, + char const* error_msg); + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +using tcp = net::ip::tcp; + +namespace trace_api = opentelemetry::trace; +namespace trace_sdk = opentelemetry::sdk::trace; +namespace nostd = opentelemetry::nostd; + +// Initialize OpenTelemetry +void InitTracer(char const* sdk_key) { + opentelemetry::exporter::otlp::OtlpHttpExporterOptions opts; + + // Check for custom endpoint from environment variable + if (char const* custom_endpoint = std::getenv("LD_OTEL_ENDPOINT"); + custom_endpoint && strlen(custom_endpoint)) { + opts.url = std::string(custom_endpoint); + } else { + opts.url = "https://otel.observability.app.launchdarkly.com:4318/v1/traces"; + } + + // Create resource with service name and highlight.project_id attribute + auto resource_attributes = opentelemetry::sdk::resource::ResourceAttributes{ + {"service.name", "weather-server"}, + {"highlight.project_id", sdk_key} + }; + auto resource = opentelemetry::sdk::resource::Resource::Create(resource_attributes); + + auto exporter = + opentelemetry::exporter::otlp::OtlpHttpExporterFactory::Create(opts); + auto processor = trace_sdk::SimpleSpanProcessorFactory::Create( + std::move(exporter)); + const std::shared_ptr provider = + trace_sdk::TracerProviderFactory::Create(std::move(processor), resource); + trace_api::Provider::SetTracerProvider(provider); +} + +// Get tracer +nostd::shared_ptr get_tracer() { + const auto provider = trace_api::Provider::GetTracerProvider(); + return provider->GetTracer("weather-server", "1.0.0"); +} + +// Random weather generator +std::string get_random_weather() { + static std::vector weather_conditions = { + "Sunny", + "Cloudy", + "Rainy", + "Snowy", + "Windy", + "Foggy", + "Stormy", + "Partly Cloudy" + }; + + static std::random_device rd; + static std::mt19937 gen(rd()); + static std::uniform_int_distribution<> + dis(0, weather_conditions.size() - 1); + + return weather_conditions[dis(gen)]; +} + +// Handle HTTP request +http::response handle_request( + http::request&& req, + std::shared_ptr ld_client) { + auto tracer = get_tracer(); + + // Start a span for the HTTP request + auto span = tracer->StartSpan( + "HTTP " + std::string(req.method_string()) + " " + std::string( + req.target())); + auto scope = trace_api::Scope(span); + + // Add HTTP attributes to the span + span->SetAttribute("http.method", std::string(req.method_string())); + span->SetAttribute("http.target", std::string(req.target())); + span->SetAttribute("http.scheme", "http"); + + http::response res; + + if (req.target() == "/weather") { + auto context = launchdarkly::ContextBuilder() + .Kind("user", "weather-api-user") + .Name("Weather API User") + .Build(); + + // When using an async framework you need to manually specify the current span. + // With a threaded framework the active span can be accessed automatically. + auto hook_ctx = + launchdarkly::server_side::integrations::otel::MakeHookContextWithSpan( + span); + + // Pass the HookContext to the evaluation + auto show_detailed_weather = ld_client->BoolVariation( + context, FEATURE_FLAG_KEY, false, hook_ctx); + + std::string weather = get_random_weather(); + span->SetAttribute("weather.condition", weather); + + res.result(http::status::ok); + res.set(http::field::content_type, "text/plain"); + + if (show_detailed_weather) { + res.body() = "Current weather: " + weather + + " (detailed mode enabled via LaunchDarkly flag)"; + } else { + res.body() = "Current weather: " + weather; + } + + span->SetAttribute("http.status_code", 200); + } else { + res.result(http::status::not_found); + res.set(http::field::content_type, "text/plain"); + res.body() = "404 Not Found"; + + span->SetAttribute("http.status_code", 404); + } + + res.version(req.version()); + res.keep_alive(req.keep_alive()); + res.prepare_payload(); + + span->End(); + + return res; +} + +// Session handles a single connection +class session : public std::enable_shared_from_this { + tcp::socket socket_; + beast::flat_buffer buffer_; + http::request req_; + std::shared_ptr> res_; + std::shared_ptr ld_client_; + +public: + explicit session(tcp::socket socket, + const std::shared_ptr + & ld_client) + : socket_(std::move(socket)), ld_client_(ld_client) { + } + + void run() { + do_read(); + } + +private: + void do_read() { + auto self = shared_from_this(); + http::async_read(socket_, buffer_, req_, + [self](const beast::error_code& ec, std::size_t) { + if (!ec) { + self->do_write(); + } + }); + } + + void do_write() { + auto self = shared_from_this(); + res_ = std::make_shared>( + handle_request(std::move(req_), ld_client_)); + + http::async_write(socket_, *res_, + [self](beast::error_code ec, std::size_t) { + self->socket_.shutdown( + tcp::socket::shutdown_send, ec); + }); + } +}; + +// Listener accepts incoming connections +class listener : public std::enable_shared_from_this { + net::io_context& ioc_; + tcp::acceptor acceptor_; + std::shared_ptr ld_client_; + +public: + listener(net::io_context& ioc, + const tcp::endpoint& endpoint, + const std::shared_ptr& ld_client) + : ioc_(ioc) + , acceptor_(ioc) + , ld_client_(ld_client) { + beast::error_code ec; + + acceptor_.open(endpoint.protocol(), ec); + if (ec) { + std::cerr << "open: " << ec.message() << std::endl; + return; + } + + acceptor_.set_option(net::socket_base::reuse_address(true), ec); + if (ec) { + std::cerr << "set_option: " << ec.message() << std::endl; + return; + } + + acceptor_.bind(endpoint, ec); + if (ec) { + std::cerr << "bind: " << ec.message() << std::endl; + return; + } + + acceptor_.listen(net::socket_base::max_listen_connections, ec); + if (ec) { + std::cerr << "listen: " << ec.message() << std::endl; + return; + } + } + + void run() { + do_accept(); + } + +private: + void do_accept() { + acceptor_.async_accept( + [self = shared_from_this()](const beast::error_code& ec, + tcp::socket socket) { + if (!ec) { + std::make_shared(std::move(socket), + self->ld_client_)->run(); + } + self->do_accept(); + }); + } +}; + +int main() { + // Initialize LaunchDarkly + char const* sdk_key = get_with_env_fallback( + SDK_KEY, "LD_SDK_KEY", + "Please edit main.cpp to set SDK_KEY to your LaunchDarkly server-side " + "SDK key first.\n\nAlternatively, set the LD_SDK_KEY environment " + "variable.\n" + "The value of SDK_KEY in main.cpp takes priority over LD_SDK_KEY."); + + // Initialize OpenTelemetry + InitTracer(sdk_key); + + // Create the OpenTelemetry tracing hook using builder pattern + auto hook_options = + launchdarkly::server_side::integrations::otel::TracingHookOptionsBuilder() + .IncludeValue(true) // Include flag values in traces + .CreateSpans(false) // Only create span events, not full spans + .Build(); + auto tracing_hook = std::make_shared< + launchdarkly::server_side::integrations::otel::TracingHook>( + hook_options); + + auto config = launchdarkly::server_side::ConfigBuilder(sdk_key) + .Hooks(tracing_hook) + .Build(); + if (!config) { + std::cerr << "*** LaunchDarkly config is invalid: " << config.error() << + std::endl; + return EXIT_FAILURE; + } + + auto ld_client = std::make_shared( + std::move(*config)); + + auto start_result = ld_client->StartAsync(); + + if (auto const status = start_result.wait_for( + std::chrono::milliseconds(INIT_TIMEOUT_MILLISECONDS)); + status == std::future_status::ready) { + if (start_result.get()) { + std::cout << "*** SDK successfully initialized!\n\n"; + } else { + std::cerr << "*** SDK failed to initialize\n"; + return EXIT_FAILURE; + } + } else { + std::cerr << "*** SDK initialization didn't complete in " + << INIT_TIMEOUT_MILLISECONDS << "ms\n"; + return EXIT_FAILURE; + } + + try { + auto const address = net::ip::make_address("0.0.0.0"); + constexpr auto port = static_cast(8080); + + net::io_context ioc{1}; + + std::make_shared(ioc, tcp::endpoint{address, port}, ld_client) + ->run(); + + std::cout << "*** Weather server running on http://0.0.0.0:8080\n"; + std::cout << "*** Try: curl http://localhost:8080/weather\n"; + std::cout << + "*** OpenTelemetry tracing enabled, sending traces to LaunchDarkly\n"; + std::cout << + "*** LaunchDarkly integration enabled with OpenTelemetry tracing hook\n\n"; + + ioc.run(); + } catch (const std::exception& e) { + std::cerr << "*** Error: " << e.what() << std::endl; + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} + +char const* get_with_env_fallback(char const* source_val, + char const* env_variable, + char const* error_msg) { + if (strlen(source_val)) { + return source_val; + } + + if (char const* from_env = std::getenv(env_variable); + from_env && strlen(from_env)) { + return from_env; + } + + std::cout << "*** " << error_msg << std::endl; + std::exit(1); +} diff --git a/libs/server-sdk-otel/CHANGELOG.md b/libs/server-sdk-otel/CHANGELOG.md new file mode 100644 index 000000000..ff41e206c --- /dev/null +++ b/libs/server-sdk-otel/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to the LaunchDarkly C++ Server SDK OpenTelemetry Integration will be documented in this file. diff --git a/libs/server-sdk-otel/CMakeLists.txt b/libs/server-sdk-otel/CMakeLists.txt new file mode 100644 index 000000000..dd5537b89 --- /dev/null +++ b/libs/server-sdk-otel/CMakeLists.txt @@ -0,0 +1,65 @@ +# This project aims to follow modern cmake guidelines, e.g. +# https://cliutils.gitlab.io/modern-cmake + +# Required for Apple Silicon support. +cmake_minimum_required(VERSION 3.19) + +project( + LaunchDarklyCPPServerOtel + VERSION 0.1.0 + DESCRIPTION "LaunchDarkly C++ Server SDK OpenTelemetry Integration" + LANGUAGES CXX +) + +set(LIBNAME "launchdarkly-cpp-server-otel") + +# If this project is the main CMake project (as opposed to being included via add_subdirectory) +if (CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) + # Disable C++ extensions for portability. + set(CMAKE_CXX_EXTENSIONS OFF) + # Enable folder support in IDEs. + set_property(GLOBAL PROPERTY USE_FOLDERS ON) +endif () + +# Option for local development and CI builds +option(LD_BUILD_OTEL_FETCH_DEPS "Fetch OpenTelemetry dependencies for local/CI builds" OFF) + +# Allow users to specify which version of OpenTelemetry to fetch +# Default to v1.23.0 (commit ea1f0d61ce5baa5584b097266bf133d1f31e3607) +set(LD_OTEL_CPP_VERSION "ea1f0d61ce5baa5584b097266bf133d1f31e3607" CACHE STRING "OpenTelemetry C++ SDK version (git tag or commit hash)") + +if (LD_BUILD_OTEL_FETCH_DEPS) + # For local development and CI: fetch OpenTelemetry with sensible defaults + message("LaunchDarkly: fetching OpenTelemetry dependencies for local/CI build") + include(FetchContent) + + # Find system curl first to prevent OpenTelemetry from building it + find_package(CURL REQUIRED) + if(CURL_FOUND) + message(STATUS "LaunchDarkly: found system CURL at ${CURL_LIBRARIES}") + endif() + + # Configure OpenTelemetry options with sensible defaults + set(WITH_OTLP_HTTP ON CACHE BOOL "Build with OTLP HTTP exporter" FORCE) + set(WITH_EXAMPLES OFF CACHE BOOL "Build examples" FORCE) + set(WITH_ABSEIL ON CACHE BOOL "Build with Abseil" FORCE) + set(BUILD_TESTING OFF CACHE BOOL "Build tests" FORCE) + + FetchContent_Declare( + opentelemetry-cpp + GIT_REPOSITORY https://github.com/open-telemetry/opentelemetry-cpp.git + GIT_TAG ${LD_OTEL_CPP_VERSION} + ) + FetchContent_MakeAvailable(opentelemetry-cpp) +else() + # Normal usage: find OpenTelemetry provided by the user + # Users must provide OpenTelemetry themselves (via find_package or by setting paths) + # This gives users full control over OpenTelemetry build configuration + find_package(opentelemetry-cpp REQUIRED COMPONENTS api) +endif() + +add_subdirectory(src) + +if (LD_BUILD_UNIT_TESTS) + add_subdirectory(tests) +endif () diff --git a/libs/server-sdk-otel/README.md b/libs/server-sdk-otel/README.md new file mode 100644 index 000000000..4bccc4bf0 --- /dev/null +++ b/libs/server-sdk-otel/README.md @@ -0,0 +1,276 @@ +# LaunchDarkly C++ Server SDK - OpenTelemetry Integration + +This package provides OpenTelemetry tracing integration for the LaunchDarkly C++ Server SDK, enabling automatic enrichment of distributed traces with feature flag evaluation data. + +## Overview + +The OpenTelemetry integration implements the [OpenTelemetry Integration Specification](https://github.com/launchdarkly/open-sdk-specs/blob/main/specs/OTEL-opentelemetry-integration/README.md) using the LaunchDarkly Hooks framework. It automatically adds feature flag evaluation information to your OpenTelemetry traces. + +> [!WARNING] +> Currently the C++ SDK doesn't support automatic collection of the environment ID. +> So the `feature_flag.set.id` will only be set if the environment ID is explicitly set. +> +> In a future version automatic collection will be supported. + +> [!WARNING] +> This hook can only be used when using the SDK with C++. +> The OpenTelemetry C++ SDK is only designed for use with C++. + +## Requirements + +- C++17 or later +- LaunchDarkly C++ Server SDK 3.5.0 or later +- OpenTelemetry C++ API 1.23.0 or later + +## Installation + +### Prerequisites + +This package has a **peer dependency** on OpenTelemetry C++. + +**For end users:** You must provide OpenTelemetry yourself (via `find_package` or FetchContent in your project). + +**For local development and CI:** Use the `LD_BUILD_OTEL_FETCH_DEPS=ON` CMake option to automatically fetch OpenTelemetry with sensible defaults. + +**Important:** This package is not available as a pre-built binary. Users must build it themselves after providing OpenTelemetry. + +### Method 1: Using CMake with FetchContent + +```cmake +cmake_minimum_required(VERSION 3.19) +project(YourApp) + +include(FetchContent) + +# Step 1: Configure and fetch OpenTelemetry FIRST +# Set options before fetching +set(WITH_OTLP_HTTP ON CACHE BOOL "Build with OTLP HTTP exporter" FORCE) +set(WITH_EXAMPLES OFF CACHE BOOL "Build examples" FORCE) +set(BUILD_TESTING OFF CACHE BOOL "Build tests" FORCE) + +FetchContent_Declare( + opentelemetry-cpp + GIT_REPOSITORY https://github.com/open-telemetry/opentelemetry-cpp.git + GIT_TAG ea1f0d61ce5baa5584b097266bf133d1f31e3607 # v1.23.0 +) +FetchContent_MakeAvailable(opentelemetry-cpp) + +# Step 2: Fetch LaunchDarkly SDK with OTel support enabled +FetchContent_Declare( + launchdarkly-cpp + GIT_REPOSITORY https://github.com/launchdarkly/cpp-sdks.git + GIT_TAG main +) +set(LD_BUILD_OTEL_SUPPORT ON CACHE BOOL "Enable OTel integration" FORCE) +FetchContent_MakeAvailable(launchdarkly-cpp) + +# Step 3: Link your application +add_executable(your_app main.cpp) +target_link_libraries(your_app + PRIVATE + launchdarkly::server + launchdarkly::server_otel + opentelemetry_trace + opentelemetry_exporter_otlp_http # Or your preferred exporter +) +``` + +### Method 2: Using CMake with find_package + +If OpenTelemetry is already installed on your system: + +```cmake +find_package(launchdarkly-cpp REQUIRED) +find_package(opentelemetry-cpp REQUIRED) + +target_link_libraries(your_app + PRIVATE + launchdarkly::server + launchdarkly::server_otel + opentelemetry-cpp::trace # For OpenTelemetry SDK functionality + opentelemetry-cpp::otlp_http_exporter # For exporting traces, or your preferred exporter. +) +``` + +### Method 3: Local Development and CI Builds + +For local development or CI environments where you want automatic dependency management: + +```bash +# From the repository root +mkdir build && cd build +cmake .. -DLD_BUILD_OTEL_SUPPORT=ON \ + -DLD_BUILD_OTEL_FETCH_DEPS=ON \ + -DLD_BUILD_EXAMPLES=ON \ + -DBUILD_TESTING=OFF +cmake --build . --target launchdarkly-cpp-server-otel +cmake --build . --target hello-cpp-server-otel # Build the example +``` + +The `LD_BUILD_OTEL_FETCH_DEPS=ON` flag automatically: +- Fetches OpenTelemetry v1.23.0 via FetchContent +- Enables OTLP HTTP exporter +- Configures with sensible defaults for development + +## Quick Start + +Please refer to the [OpenTelemetry C++ installation guide](https://github.com/open-telemetry/opentelemetry-cpp/blob/main/INSTALL.md) for configuration of the OpenTelemetry library. + +### Basic Usage (Span Events Only) + +```cpp +#include +#include +#include + +// Create the hook +auto hook = std::make_shared(); + +// Register it with the SDK +auto config = launchdarkly::server_side::ConfigBuilder("your-sdk-key") + .Hooks(hook) + .Build() + .value(); + +launchdarkly::server_side::Client client(std::move(config)); + +// Later inside instrumented code. +// Feature flag evaluations will now emit span events automatically +// Span events attach to the currently active span, so if there is no active span, then there is nothing to enrich +// with span events. +// This will use the active span based on your open telemetry context managent. For asynchronous frameworks handling +// multiple requests per-thread, either custom context management is required, or the parent span can be explicitly +// provided. Refer to `Passing Parent Span Explicitly`. + +bool result = client.BoolVariation(context, "my-flag", false); +``` + +### Advanced Usage with Options + +```cpp +#include + +// Configure the hook +auto options = launchdarkly::server_side::integrations::otel::TracingHookOptionsBuilder() + .IncludeValue(true) // Include flag values in traces + .CreateSpans(true) // Create dedicated spans + .EnvironmentId("ld-environment-id") // Override environment ID + .Build(); + +auto hook = std::make_shared(options); + +auto config = launchdarkly::server_side::ConfigBuilder("your-sdk-key") + .Hooks(hook) + .Build() + .value(); +``` + +### Passing Parent Span Explicitly + +```cpp +#include +#include + +// Get your tracer +auto tracer = opentelemetry::trace::Provider::GetTracerProvider() + ->GetTracer("my-service"); + +// Start a span +auto span = tracer->StartSpan("handle_request"); + +// Create hook context with the span +auto hook_ctx = launchdarkly::server_side::integrations::otel::MakeHookContextWithSpan(span); + +// Evaluate with the hook context +bool result = client.BoolVariation(context, "my-flag", false, hook_ctx); + +span->End(); +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `IncludeValue` | `bool` | `false` | Include flag evaluation results in span events. **Privacy consideration**: flag values may contain sensitive data. | +| `CreateSpans` | `bool` | `false` | Create a dedicated span for each flag evaluation. **Performance consideration**: spans have higher overhead than events. | +| `EnvironmentId` | `std::optional` | `nullopt` | Environment ID to include in telemetry. If not set, uses the environment ID from the SDK after initialization. | + +## Span Event Attributes + +The hook adds `feature_flag` events to spans with the following attributes: + +### Required Attributes +- `feature_flag.key`: The flag key being evaluated +- `feature_flag.provider.name`: Always "LaunchDarkly" +- `feature_flag.context.id`: The canonical key of the evaluation context + +### Optional Attributes +- `feature_flag.set.id`: Environment ID (if configured or available) +- `feature_flag.result.value`: Evaluated flag value as JSON string (if `IncludeValue` is enabled) +- `feature_flag.result.variationIndex`: Variation index (if available) +- `feature_flag.result.reason.inExperiment`: Whether the evaluation is part of an experiment (only if true) + +## Dedicated Spans (When Enabled) + +When `CreateSpans` is enabled, the hook creates spans with: +- **Name**: `LDClient.{method}` (e.g., `LDClient.BoolVariation`) +- **Kind**: Internal +- **Attributes**: + - `feature_flag.key`: The flag key + - `feature_flag.context.key`: The context's canonical key + +## Examples + +An example is included in `examples/hello-cpp-server-otel`. + +Learn more +----------- + +Read our [documentation](https://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. +You can also head straight to +the [complete reference guide for this SDK][reference-guide]. + +Testing +------- + +We run integration tests for all our SDKs using a centralized test harness. This approach gives us the ability to test +for consistency across SDKs, as well as test networking behavior in a long-running application. These tests cover each +method in the SDK, and verify that event sending, flag evaluation, stream reconnection, and other aspects of the SDK all +behave correctly. + +Contributing +------------ + +We encourage pull requests and other contributions from the community. Read +our [contributing guidelines](../../CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +Verifying SDK build provenance with the SLSA framework +------------ + +LaunchDarkly uses the [SLSA framework](https://slsa.dev/spec/v1.0/about) (Supply-chain Levels for Software Artifacts) to help developers make their supply chain more secure by ensuring the authenticity and build integrity of our published SDK packages. To learn more, see the [provenance guide](../../PROVENANCE.md). + +About LaunchDarkly +----------- + +* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to + iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. + With LaunchDarkly, you can: + * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), + gathering feedback and bug reports from real-world use cases. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on + key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, + or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get + access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate + maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. + Read [our documentation](https://docs.launchdarkly.com/docs) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and + SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API + documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product + updates diff --git a/libs/server-sdk-otel/include/launchdarkly/server_side/integrations/otel/tracing_hook.hpp b/libs/server-sdk-otel/include/launchdarkly/server_side/integrations/otel/tracing_hook.hpp new file mode 100644 index 000000000..47801f2b3 --- /dev/null +++ b/libs/server-sdk-otel/include/launchdarkly/server_side/integrations/otel/tracing_hook.hpp @@ -0,0 +1,302 @@ +/** + * @file tracing_hook.hpp + * @brief OpenTelemetry integration hook for LaunchDarkly C++ Server SDK + * + * This hook implements the OpenTelemetry integration specification for + * LaunchDarkly, adding feature flag evaluation data to distributed traces. + * + * Specification: OTEL-opentelemetry-integration + * Reference: https://github.com/launchdarkly/open-sdk-specs/specs/OTEL-opentelemetry-integration/ + */ + +#pragma once + +#include + +#include +#include +#include + +#include +#include +#include + +namespace launchdarkly::server_side::integrations::otel { + +// Forward declaration +class TracingHookOptionsBuilder; + +/** + * @brief Configuration options for the OpenTelemetry tracing hook + * + * This class is immutable. Use TracingHookOptionsBuilder to construct instances. + */ +class TracingHookOptions { + public: + /** + * @brief Whether to include the flag evaluation result value in span events + * @return true if values should be included + */ + [[nodiscard]] bool IncludeValue() const { return include_value_; } + + /** + * @brief Whether to create dedicated spans for each flag evaluation + * @return true if dedicated spans should be created + */ + [[nodiscard]] bool CreateSpans() const { return create_spans_; } + + /** + * @brief Optional environment ID to include in telemetry + * @return Environment ID if configured + */ + [[nodiscard]] std::optional const& EnvironmentId() const { + return environment_id_; + } + + private: + friend class TracingHookOptionsBuilder; + + bool include_value_ = false; + bool create_spans_ = false; + std::optional environment_id_; + + TracingHookOptions() = default; +}; + +/** + * @brief Builder for TracingHookOptions + * + * @example Basic usage + * ```cpp + * auto options = launchdarkly::server_side::integrations::otel::TracingHookOptionsBuilder() + * .IncludeValue(true) + * .CreateSpans(false) + * .Build(); + * auto hook = std::make_shared(options); + * ``` + * + * @example With environment ID + * ```cpp + * auto options = launchdarkly::server_side::integrations::otel::TracingHookOptionsBuilder() + * .IncludeValue(true) + * .EnvironmentId("production") + * .Build(); + * ``` + */ +class TracingHookOptionsBuilder { + public: + /** + * @brief Construct a builder with default options + */ + TracingHookOptionsBuilder() = default; + + /** + * @brief Set whether to include flag values in telemetry + * + * When enabled, the `feature_flag.result.value` attribute will be added + * to span events with the evaluated flag value as a JSON string. + * + * @param include_value true to include values (default: false) + * @return Reference to this builder for chaining + */ + TracingHookOptionsBuilder& IncludeValue(bool include_value) { + options_.include_value_ = include_value; + return *this; + } + + /** + * @brief Set whether to create dedicated spans for evaluations + * + * When enabled, creates a new span for each flag evaluation with the name + * format "LDClient.{method}" (e.g., "LDClient.BoolVariation"). + * + * @param create_spans true to create spans (default: false) + * @return Reference to this builder for chaining + */ + TracingHookOptionsBuilder& CreateSpans(bool create_spans) { + options_.create_spans_ = create_spans; + return *this; + } + + /** + * @brief Set the environment ID to include in telemetry + * + * When provided, this will be used as the `feature_flag.set.id` attribute + * in all span events. + * + * @param environment_id The LaunchDarkly environment ID + * @return Reference to this builder for chaining + */ + TracingHookOptionsBuilder& EnvironmentId(std::string environment_id) { + if (!environment_id.empty()) { + options_.environment_id_ = std::move(environment_id); + } + return *this; + } + + /** + * @brief Build the TracingHookOptions + * + * @return Configured TracingHookOptions instance + */ + [[nodiscard]] TracingHookOptions Build() const { return options_; } + + private: + TracingHookOptions options_; +}; + +/** + * @brief OpenTelemetry tracing hook for LaunchDarkly feature flag evaluations + * + * ## Usage + * + * ### Basic Usage (Span Events Only) + * ```cpp + * auto hook = std::make_shared(); + * auto config = launchdarkly::server_side::ConfigBuilder("sdk-key") + * .Hooks(hook) + * .Build() + * .value(); + * launchdarkly::server_side::Client client(std::move(config)); + * ``` + * + * ### Advanced Usage (With Options) + * ```cpp + * auto options = launchdarkly::server_side::integrations::otel::TracingHookOptionsBuilder() + * .IncludeValue(true) + * .CreateSpans(true) + * .EnvironmentId("my-environment-id") + * .Build(); + * + * auto hook = std::make_shared(options); + * auto config = launchdarkly::server_side::ConfigBuilder("sdk-key") + * .Hooks(hook) + * .Build() + * .value(); + * ``` + * + * ### Providing a Parent Span via HookContext + * ```cpp + * // Get current OpenTelemetry span + * auto current_span = opentelemetry::trace::Tracer::GetCurrentSpan(); + * + * // Create hook context with parent span + * launchdarkly::server_side::hooks::HookContext hook_ctx; + * hook_ctx.Set("otel.span", std::make_shared(current_span)); + * + * // Evaluate with hook context + * bool result = client.BoolVariation(context, "my-flag", false, hook_ctx); + * ``` + */ +class TracingHook : public hooks::Hook { + public: + /** + * @brief Construct a tracing hook with default options + */ + TracingHook(); + + /** + * @brief Construct a tracing hook with custom options + * @param options Configuration options for the hook + */ + explicit TracingHook(TracingHookOptions options); + + /** + * @brief Get metadata about this hook + * @return Hook metadata containing the hook name + */ + [[nodiscard]] hooks::HookMetadata const& Metadata() const override; + + /** + * @brief Stage executed before flag evaluation + * + * @param series_context Context information about the evaluation + * @param data Series data from previous stages (empty for first stage) + * @return Series data with span reference (if span creation enabled) + */ + hooks::EvaluationSeriesData BeforeEvaluation( + hooks::EvaluationSeriesContext const& series_context, + hooks::EvaluationSeriesData data) override; + + /** + * @brief Stage executed after flag evaluation + * + * @param series_context Context information about the evaluation + * @param data Series data from BeforeEvaluation stage + * @param detail The evaluation result detail + * @return Series data (unchanged) + */ + hooks::EvaluationSeriesData AfterEvaluation( + hooks::EvaluationSeriesContext const& series_context, + hooks::EvaluationSeriesData data, + EvaluationDetail const& detail) override; + + private: + /** + * @brief Get the active OpenTelemetry span + * + * @param hook_ctx Hook context that may contain a parent span + * @return Shared pointer to the active span, or nullptr if none exists + */ + [[nodiscard]] static opentelemetry::nostd::shared_ptr + GetActiveSpan(hooks::HookContext const& hook_ctx); + + /** + * @brief Get the tracer for creating new spans + * @return Shared pointer to the tracer + */ + [[nodiscard]] static opentelemetry::nostd::shared_ptr + GetTracer(); + + /** + * @brief Add a feature_flag event to a span + * + * @param span The span to add the event to + * @param series_context Context with flag key and evaluation context + * @param detail Evaluation result with value and variation index + */ + void AddFeatureFlagEvent( + opentelemetry::nostd::shared_ptr const& + span, + hooks::EvaluationSeriesContext const& series_context, + EvaluationDetail const& detail) const; + + /** + * @brief Get the environment ID to use in telemetry + * + * @param series_context Context that may contain environment ID + * @return Environment ID if available + */ + [[nodiscard]] std::optional GetEnvironmentId( + hooks::EvaluationSeriesContext const& series_context) const; + + TracingHookOptions options_; + hooks::HookMetadata metadata_; +}; + +/** + * @brief Helper function to create a HookContext from an OpenTelemetry span + * + * This convenience function simplifies passing the current span to flag + * evaluation methods, ensuring that feature flag events are added to the + * correct span in the trace hierarchy. + * + * @param span The OpenTelemetry span to attach to the HookContext + * @return HookContext configured with the provided span + * + * @example + * ```cpp + * auto span = tracer->StartSpan("handle_request"); + * auto hook_ctx = launchdarkly::server_side::integrations::otel::MakeHookContextWithSpan(span); + * bool result = client.BoolVariation(context, "my-flag", false, hook_ctx); + * span->End(); + * ``` + */ +inline hooks::HookContext MakeHookContextWithSpan( + const opentelemetry::nostd::shared_ptr& span) { + hooks::HookContext ctx; + ctx.Set("otel.span", std::make_shared(span)); + return ctx; +} + +} // namespace launchdarkly::server_side::integrations::otel diff --git a/libs/server-sdk-otel/package.json b/libs/server-sdk-otel/package.json new file mode 100644 index 000000000..bb0d5f325 --- /dev/null +++ b/libs/server-sdk-otel/package.json @@ -0,0 +1,9 @@ +{ + "name": "launchdarkly-cpp-server-otel", + "description": "This package.json exists for modeling dependencies for the release process.", + "version": "0.1.0", + "private": true, + "dependencies": { + "launchdarkly-cpp-server": "3.9.1" + } +} diff --git a/libs/server-sdk-otel/src/CMakeLists.txt b/libs/server-sdk-otel/src/CMakeLists.txt new file mode 100644 index 000000000..5448119c7 --- /dev/null +++ b/libs/server-sdk-otel/src/CMakeLists.txt @@ -0,0 +1,54 @@ +file(GLOB HEADER_LIST CONFIGURE_DEPENDS + "${LaunchDarklyCPPServerOtel_SOURCE_DIR}/include/launchdarkly/server_side/integrations/otel/*.hpp" +) + +if (LD_BUILD_SHARED_LIBS) + message(STATUS "LaunchDarkly: building server-sdk-otel as shared library") + add_library(${LIBNAME} SHARED) +else () + message(STATUS "LaunchDarkly: building server-sdk-otel as static library") + add_library(${LIBNAME} STATIC) +endif () + +target_sources(${LIBNAME} + PRIVATE + ${HEADER_LIST} + tracing_hook.cpp +) + +target_link_libraries(${LIBNAME} + PUBLIC launchdarkly::server + PUBLIC opentelemetry-cpp::api +) + +add_library(launchdarkly::server_otel ALIAS ${LIBNAME}) + +if (LD_BUILD_SHARED_LIBS AND MSVC) + install(FILES $ DESTINATION ${CMAKE_INSTALL_BINDIR} OPTIONAL) +endif () + +# Using PUBLIC_HEADERS would flatten the include. +# This will preserve it, but dependencies must do the same. + +install(DIRECTORY "${LaunchDarklyCPPServerOtel_SOURCE_DIR}/include/launchdarkly" + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} +) + +# Need the public headers to build. +target_include_directories(${LIBNAME} + PUBLIC + $ + $ +) + +# Minimum C++ standard needed for consuming the public API is C++17. +target_compile_features(${LIBNAME} PUBLIC cxx_std_17) + +# Note: We don't export this target because OpenTelemetry is a peer dependency +# and consumers need to link against it directly in their own projects +install( + TARGETS ${LIBNAME} OPTIONAL + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) diff --git a/libs/server-sdk-otel/src/tracing_hook.cpp b/libs/server-sdk-otel/src/tracing_hook.cpp new file mode 100644 index 000000000..a24610f7e --- /dev/null +++ b/libs/server-sdk-otel/src/tracing_hook.cpp @@ -0,0 +1,240 @@ +/** + * @file tracing_hook.cpp + * @brief Implementation of OpenTelemetry tracing hook for LaunchDarkly + */ + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include + +namespace launchdarkly::server_side::integrations::otel { +// OpenTelemetry semantic convention attribute names +namespace otel_attrs { +constexpr auto FEATURE_FLAG_KEY = "feature_flag.key"; +constexpr auto FEATURE_FLAG_PROVIDER_NAME = "feature_flag.provider.name"; +constexpr auto FEATURE_FLAG_CONTEXT_ID = "feature_flag.context.id"; +constexpr auto FEATURE_FLAG_CONTEXT_KEY = "feature_flag.context.key"; +constexpr auto FEATURE_FLAG_SET_ID = "feature_flag.set.id"; +constexpr auto FEATURE_FLAG_RESULT_VALUE = "feature_flag.result.value"; +constexpr auto FEATURE_FLAG_RESULT_VARIATION_INDEX = + "feature_flag.result.variationIndex"; +constexpr auto FEATURE_FLAG_RESULT_REASON_IN_EXPERIMENT = + "feature_flag.result.reason.inExperiment"; + +constexpr auto PROVIDER_NAME = "LaunchDarkly"; +constexpr auto EVENT_NAME = "feature_flag"; +} // namespace otel_attrs + +// Keys for series data +namespace series_keys { +constexpr auto SPAN = "otel.span"; +} + +// Keys for hook context +namespace hook_ctx_keys { +constexpr auto SPAN = "otel.span"; +} + +TracingHook::TracingHook() + : TracingHook(TracingHookOptionsBuilder().Build()) { +} + +TracingHook::TracingHook(TracingHookOptions options) + : options_(std::move(options)), + metadata_("LaunchDarkly OpenTelemetry Tracing Hook") { + // Options are validated by the builder +} + +hooks::HookMetadata const& TracingHook::Metadata() const { + return metadata_; +} + +opentelemetry::nostd::shared_ptr +TracingHook::GetTracer() { + const auto provider = opentelemetry::trace::Provider::GetTracerProvider(); + return provider->GetTracer("launchdarkly-cpp-server", "1.0.0"); +} + +opentelemetry::nostd::shared_ptr +TracingHook::GetActiveSpan(hooks::HookContext const& hook_ctx) { + // First, check if a span was provided via HookContext + if (const auto maybe_span_any = hook_ctx.Get(hook_ctx_keys::SPAN); + maybe_span_any.has_value()) { + try { + auto& span_any = *maybe_span_any.value(); + const auto span_ptr = std::any_cast< + opentelemetry::nostd::shared_ptr>( + &span_any); + if (span_ptr && *span_ptr) { + return *span_ptr; + } + } catch (const std::bad_any_cast&) { + // Ignore and fall through to get active span from context + } + } + + // Fall back to getting active span from OpenTelemetry context + return opentelemetry::trace::Tracer::GetCurrentSpan(); +} + +std::optional TracingHook::GetEnvironmentId( + hooks::EvaluationSeriesContext const& series_context) const { + // Configured environment ID takes precedence + if (options_.EnvironmentId().has_value()) { + return options_.EnvironmentId(); + } + + // Fall back to environment ID from context (available after init) + if (const auto env_id = series_context.EnvironmentId(); env_id. + has_value()) { + return std::string(env_id.value()); + } + + return std::nullopt; +} + +hooks::EvaluationSeriesData TracingHook::BeforeEvaluation( + hooks::EvaluationSeriesContext const& series_context, + hooks::EvaluationSeriesData data) { + // Only create spans if configured to do so + if (!options_.CreateSpans()) { + return data; + } + + // Any exception will be handled by the SDK and it will log that an error + // has happened in hook execution. + const auto tracer = GetTracer(); + + // Build span name: "LDClient.{method}" + std::string span_name = "LDClient."; + span_name.append(series_context.Method()); + + // Create span options + opentelemetry::trace::StartSpanOptions options; + options.kind = opentelemetry::trace::SpanKind::kInternal; + + if (auto span = tracer-> + StartSpan(span_name, options)) { + // Add attributes to span + span->SetAttribute(otel_attrs::FEATURE_FLAG_KEY, + std::string(series_context.FlagKey())); + span->SetAttribute( + otel_attrs::FEATURE_FLAG_CONTEXT_KEY, + std::string(series_context.EvaluationContext().CanonicalKey())); + + // Store span in series data for AfterEvaluation + auto builder = hooks::EvaluationSeriesDataBuilder(data); + builder.SetShared(series_keys::SPAN, + std::make_shared(span)); + return builder.Build(); + } + + return data; +} + +void TracingHook::AddFeatureFlagEvent( + opentelemetry::nostd::shared_ptr const& span, + hooks::EvaluationSeriesContext const& series_context, + EvaluationDetail const& detail) const { + // Copy all dynamic data to owned storage + auto flag_key = std::string(series_context.FlagKey()); + auto context_id = + std::string(series_context.EvaluationContext().CanonicalKey()); + auto provider_name = std::string(otel_attrs::PROVIDER_NAME); + + std::string env_id_str; + bool has_env_id = false; + if (auto env_id = GetEnvironmentId(series_context); env_id.has_value()) { + env_id_str = env_id.value(); + has_env_id = true; + } + + std::string value_str; + bool has_value = false; + if (options_.IncludeValue()) { + boost::json::value json_value; + boost::json::value_from(detail.Value(), json_value); + value_str = boost::json::serialize(json_value); + has_value = true; + } + + int64_t variation_index = 0; + bool has_variation_index = detail.VariationIndex().has_value(); + if (has_variation_index) { + variation_index = static_cast(detail.VariationIndex().value()); + } + + bool in_experiment = false; + if (detail.Reason().has_value() && detail.Reason()->InExperiment()) { + in_experiment = true; + } + + std::vector> + attributes; + + attributes.emplace_back(otel_attrs::FEATURE_FLAG_KEY, flag_key); + attributes.emplace_back(otel_attrs::FEATURE_FLAG_PROVIDER_NAME, + provider_name); + attributes.emplace_back(otel_attrs::FEATURE_FLAG_CONTEXT_ID, context_id); + + if (has_env_id) { + attributes.emplace_back(otel_attrs::FEATURE_FLAG_SET_ID, env_id_str); + } + if (has_value) { + attributes.emplace_back(otel_attrs::FEATURE_FLAG_RESULT_VALUE, + value_str); + } + if (in_experiment) { + attributes.emplace_back( + otel_attrs::FEATURE_FLAG_RESULT_REASON_IN_EXPERIMENT, + in_experiment); + } + if (has_variation_index) { + attributes.emplace_back( + otel_attrs::FEATURE_FLAG_RESULT_VARIATION_INDEX, variation_index); + } + + span->AddEvent(otel_attrs::EVENT_NAME, attributes); +} + +hooks::EvaluationSeriesData TracingHook::AfterEvaluation( + hooks::EvaluationSeriesContext const& series_context, + hooks::EvaluationSeriesData data, + EvaluationDetail const& detail) { + // First, end any span we created in BeforeEvaluation + if (options_.CreateSpans()) { + if (const auto maybe_span_any = data.GetShared(series_keys::SPAN); + maybe_span_any.has_value()) { + const auto& span_any = *maybe_span_any.value(); + const auto span_ptr = + std::any_cast>(&span_any); + if (span_ptr && *span_ptr) { + (*span_ptr)->End(); + } + } + } + + // Get the active span (either from hook context or global context) + + // Only add event if there's an active span + if (const auto active_span = GetActiveSpan(series_context.HookCtx()); + active_span && active_span->GetContext().IsValid()) { + AddFeatureFlagEvent(active_span, series_context, detail); + } + return data; +} +} // namespace launchdarkly::server_side::integrations::otel diff --git a/libs/server-sdk-otel/tests/CMakeLists.txt b/libs/server-sdk-otel/tests/CMakeLists.txt new file mode 100644 index 000000000..668631526 --- /dev/null +++ b/libs/server-sdk-otel/tests/CMakeLists.txt @@ -0,0 +1,31 @@ +# Tests for LaunchDarkly OpenTelemetry integration + +file(GLOB SOURCES "*.cpp") + +# Place test executable in the build root directory to match CI expectations +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) + +# Get things in the same directory on windows. +if (WIN32) + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}../") + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}../") +endif () + +add_executable(gtest_launchdarkly-cpp-server-otel ${SOURCES}) + +target_link_libraries(gtest_launchdarkly-cpp-server-otel + PRIVATE + ${LIBNAME} + launchdarkly::server + GTest::gtest + GTest::gtest_main + opentelemetry-cpp::api + opentelemetry_trace # SDK needed for test fixtures +) + +target_include_directories(gtest_launchdarkly-cpp-server-otel + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../include +) + +gtest_discover_tests(gtest_launchdarkly-cpp-server-otel) diff --git a/libs/server-sdk-otel/tests/tracing_hook_test.cpp b/libs/server-sdk-otel/tests/tracing_hook_test.cpp new file mode 100644 index 000000000..6183a481c --- /dev/null +++ b/libs/server-sdk-otel/tests/tracing_hook_test.cpp @@ -0,0 +1,146 @@ +/** + * @file tracing_hook_test.cpp + * @brief Unit tests for OpenTelemetry tracing hook + */ + +#include + +#include + +namespace launchdarkly::server_side::integrations::otel { + +// Basic construction tests +TEST(TracingHookTest, ConstructsWithDefaultOptions) { + TracingHook hook; + EXPECT_EQ(hook.Metadata().Name(), "LaunchDarkly OpenTelemetry Tracing Hook"); +} + +TEST(TracingHookTest, ConstructsWithCustomOptions) { + auto options = TracingHookOptionsBuilder() + .IncludeValue(true) + .CreateSpans(true) + .EnvironmentId("test-env") + .Build(); + + TracingHook hook(options); + EXPECT_EQ(hook.Metadata().Name(), "LaunchDarkly OpenTelemetry Tracing Hook"); +} + +// Options builder tests +TEST(TracingHookOptionsBuilderTest, BuildsDefaultOptions) { + auto options = TracingHookOptionsBuilder().Build(); + + EXPECT_FALSE(options.IncludeValue()); + EXPECT_FALSE(options.CreateSpans()); + EXPECT_FALSE(options.EnvironmentId().has_value()); +} + +TEST(TracingHookOptionsBuilderTest, BuildsWithAllOptions) { + auto options = TracingHookOptionsBuilder() + .IncludeValue(true) + .CreateSpans(true) + .EnvironmentId("production") + .Build(); + + EXPECT_TRUE(options.IncludeValue()); + EXPECT_TRUE(options.CreateSpans()); + ASSERT_TRUE(options.EnvironmentId().has_value()); + EXPECT_EQ(options.EnvironmentId().value(), "production"); +} + +TEST(TracingHookOptionsBuilderTest, IgnoresEmptyEnvironmentId) { + auto options = TracingHookOptionsBuilder() + .EnvironmentId("") + .Build(); + + EXPECT_FALSE(options.EnvironmentId().has_value()); +} + +TEST(TracingHookOptionsBuilderTest, ChainsMethods) { + auto options = TracingHookOptionsBuilder() + .IncludeValue(true) + .CreateSpans(false) + .EnvironmentId("staging") + .Build(); + + EXPECT_TRUE(options.IncludeValue()); + EXPECT_FALSE(options.CreateSpans()); + EXPECT_EQ(options.EnvironmentId().value(), "staging"); +} + +TEST(TracingHookOptionsBuilderTest, IncludeValueDefaultsFalse) { + auto options = TracingHookOptionsBuilder() + .CreateSpans(true) + .Build(); + + EXPECT_FALSE(options.IncludeValue()); + EXPECT_TRUE(options.CreateSpans()); +} + +TEST(TracingHookOptionsBuilderTest, CreateSpansDefaultsFalse) { + auto options = TracingHookOptionsBuilder() + .IncludeValue(true) + .Build(); + + EXPECT_TRUE(options.IncludeValue()); + EXPECT_FALSE(options.CreateSpans()); +} + +TEST(TracingHookOptionsBuilderTest, CanSetMultipleOptions) { + auto options = TracingHookOptionsBuilder() + .IncludeValue(true) + .CreateSpans(true) + .EnvironmentId("dev") + .Build(); + + EXPECT_TRUE(options.IncludeValue()); + EXPECT_TRUE(options.CreateSpans()); + ASSERT_TRUE(options.EnvironmentId().has_value()); + EXPECT_EQ(options.EnvironmentId().value(), "dev"); +} + +TEST(TracingHookOptionsBuilderTest, EnvironmentIdIsOptional) { + auto options1 = TracingHookOptionsBuilder() + .IncludeValue(true) + .Build(); + EXPECT_FALSE(options1.EnvironmentId().has_value()); + + auto options2 = TracingHookOptionsBuilder() + .CreateSpans(true) + .Build(); + EXPECT_FALSE(options2.EnvironmentId().has_value()); +} + +TEST(TracingHookOptionsBuilderTest, BuilderIsReusable) { + auto builder = TracingHookOptionsBuilder() + .IncludeValue(true) + .CreateSpans(true); + + auto options1 = builder.Build(); + auto options2 = builder.EnvironmentId("test").Build(); + + EXPECT_TRUE(options1.IncludeValue()); + EXPECT_TRUE(options2.IncludeValue()); + EXPECT_FALSE(options1.EnvironmentId().has_value()); + EXPECT_TRUE(options2.EnvironmentId().has_value()); +} + +// Metadata tests +TEST(TracingHookTest, MetadataNameIsCorrect) { + TracingHook hook; + EXPECT_EQ(hook.Metadata().Name(), "LaunchDarkly OpenTelemetry Tracing Hook"); +} + +TEST(TracingHookTest, MetadataIsConsistent) { + auto options = TracingHookOptionsBuilder() + .IncludeValue(true) + .CreateSpans(true) + .EnvironmentId("production") + .Build(); + TracingHook hook(options); + + // Metadata should be the same regardless of options + EXPECT_EQ(hook.Metadata().Name(), "LaunchDarkly OpenTelemetry Tracing Hook"); +} + +} // namespace launchdarkly::server_side::integrations::otel diff --git a/release-please-config.json b/release-please-config.json index 52016632f..f55d283df 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -24,6 +24,13 @@ "CMakeLists.txt" ] }, + "libs/server-sdk-otel": { + "release-as": "0.1.0", + "bump-minor-pre-major": true, + "extra-files": [ + "CMakeLists.txt" + ] + }, "libs/server-sent-events": {}, "libs/common": {}, "libs/internal": {}, diff --git a/scripts/build.sh b/scripts/build.sh index 331217f34..9ca6d21a2 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -2,7 +2,7 @@ # This script builds a specific cmake target. # This script should be ran from the root directory of the project. -# ./scripts/build.sh my-build-target ON [true|false] +# ./scripts/build.sh my-build-target ON # # $1 the name of the target. For example "launchdarkly-cpp-common". # $2 ON/OFF which enables/disables building in a test configuration (unit tests + contract tests.) @@ -30,12 +30,34 @@ build_curl="OFF" if [ "$3" == "true" ]; then build_curl="ON" fi +# Special case: OpenTelemetry support requires additional dependencies. +# Enable OTEL support and fetch deps when building OTEL targets. +build_otel="OFF" +build_otel_fetch_deps="OFF" +build_contract_tests="$2" +if [ "$1" == "launchdarkly-cpp-server-otel" ] || [ "$1" == "gtest_launchdarkly-cpp-server-otel" ]; then + build_otel="ON" + build_otel_fetch_deps="ON" +fi + +echo "==== Build Configuration ====" +echo "Target: $1" +echo "BUILD_TESTING: $2" +echo "LD_BUILD_UNIT_TESTS: $2" +echo "LD_BUILD_CONTRACT_TESTS: $2" +echo "LD_BUILD_REDIS_SUPPORT: $build_redis" +echo "LD_CURL_NETWORKING: $build_curl" +echo "LD_BUILD_OTEL_SUPPORT: $build_otel" +echo "LD_BUILD_OTEL_FETCH_DEPS: $build_otel_fetch_deps" +echo "=============================" cmake -G Ninja -D CMAKE_COMPILE_WARNING_AS_ERROR=TRUE \ -D BUILD_TESTING="$2" \ -D LD_BUILD_UNIT_TESTS="$2" \ -D LD_BUILD_CONTRACT_TESTS="$2" \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ - -D LD_CURL_NETWORKING="$build_curl" .. + -D LD_CURL_NETWORKING="$build_curl" \ + -D LD_BUILD_OTEL_SUPPORT="$build_otel" \ + -D LD_BUILD_OTEL_FETCH_DEPS="$build_otel_fetch_deps" .. cmake --build . --target "$1"