From ea9bae57133a00e50e884c175728764646f26ad6 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 29 Oct 2025 08:43:11 -0700 Subject: [PATCH 01/10] feat: Add tracing hook. --- .github/workflows/manual-publish-doc.yml | 1 + .github/workflows/release-please.yml | 3 + .github/workflows/server-otel.yml | 48 +++ .release-please-manifest.json | 3 +- CMakeLists.txt | 7 + examples/CMakeLists.txt | 4 + examples/hello-cpp-server-otel/CMakeLists.txt | 23 ++ examples/hello-cpp-server-otel/README.md | 217 +++++++++++ examples/hello-cpp-server-otel/main.cpp | 345 ++++++++++++++++++ libs/server-sdk-otel/CHANGELOG.md | 3 + libs/server-sdk-otel/CMakeLists.txt | 55 +++ libs/server-sdk-otel/README.md | 276 ++++++++++++++ .../integrations/otel/tracing_hook.hpp | 302 +++++++++++++++ libs/server-sdk-otel/package.json | 9 + libs/server-sdk-otel/src/CMakeLists.txt | 54 +++ libs/server-sdk-otel/src/tracing_hook.cpp | 240 ++++++++++++ libs/server-sdk-otel/tests/CMakeLists.txt | 31 ++ .../tests/tracing_hook_test.cpp | 76 ++++ release-please-config.json | 7 + scripts/build-release-windows.sh | 17 + scripts/build-release.sh | 13 +- scripts/build.sh | 18 +- 22 files changed, 1746 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/server-otel.yml create mode 100644 examples/hello-cpp-server-otel/CMakeLists.txt create mode 100644 examples/hello-cpp-server-otel/README.md create mode 100644 examples/hello-cpp-server-otel/main.cpp create mode 100644 libs/server-sdk-otel/CHANGELOG.md create mode 100644 libs/server-sdk-otel/CMakeLists.txt create mode 100644 libs/server-sdk-otel/README.md create mode 100644 libs/server-sdk-otel/include/launchdarkly/server_side/integrations/otel/tracing_hook.hpp create mode 100644 libs/server-sdk-otel/package.json create mode 100644 libs/server-sdk-otel/src/CMakeLists.txt create mode 100644 libs/server-sdk-otel/src/tracing_hook.cpp create mode 100644 libs/server-sdk-otel/tests/CMakeLists.txt create mode 100644 libs/server-sdk-otel/tests/tracing_hook_test.cpp 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..5664c7e72 --- /dev/null +++ b/.github/workflows/server-otel.yml @@ -0,0 +1,48 @@ +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 + simulate_release: true + 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 + simulate_release: true + 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 + simulate_windows_release: true diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a6783277b..755e59acf 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -4,5 +4,6 @@ "libs/common": "1.10.0", "libs/internal": "0.12.1", "libs/server-sdk": "3.9.1", - "libs/server-sdk-redis-source": "2.2.0" + "libs/server-sdk-redis-source": "2.2.0", + "libs/server-sdk-otel": "0.0.0" } diff --git a/CMakeLists.txt b/CMakeLists.txt index f1736f3ab..6e6c7a4d1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -101,6 +101,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) + # If using 'make' as the build system, CMake causes the 'install' target to have a dependency on 'all', meaning # it will cause a full build. This disables that, allowing us to build piecemeal instead. This is useful # so that we only need to build the client or server for a given release (if only the client or server were affected.) @@ -195,6 +197,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/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..5326cb798 --- /dev/null +++ b/examples/hello-cpp-server-otel/README.md @@ -0,0 +1,217 @@ +# 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 +- OpenTelemetry collector (or compatible backend) running on `localhost:4318` + +## 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. Start an OpenTelemetry Collector + +The easiest way is using Docker: + +```bash +docker run -p 4318:4318 otel/opentelemetry-collector:latest +``` + +Or use Jaeger (which has a built-in OTLP receiver): + +```bash +docker run -d -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest +``` + +### 2. 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 +``` + +### 3. Create a Feature Flag + +In your LaunchDarkly dashboard, create a boolean flag named `show-detailed-weather`. + +### 4. 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 (OTLP HTTP to localhost:4318) +*** LaunchDarkly integration enabled with OpenTelemetry tracing hook +``` + +### 5. Make Requests + +```bash +curl http://localhost:8080/weather +``` + +### 6. View Traces + +If using Jaeger, open http://localhost:16686 in your browser. You should see traces with: + +- HTTP request spans +- Feature flag evaluation events with attributes: + - `feature_flag.key`: "show-detailed-weather" + - `feature_flag.provider.name`: "LaunchDarkly" + - `feature_flag.context.id`: Context canonical key + - `feature_flag.result.value`: The flag value (since `IncludeValue` is enabled) + +## How It Works + +### OpenTelemetry Setup + +```cpp +void InitTracer() { + opentelemetry::exporter::otlp::OtlpHttpExporterOptions opts; + opts.url = "http://localhost:4318/v1/traces"; + + auto exporter = opentelemetry::exporter::otlp::OtlpHttpExporterFactory::Create(opts); + auto processor = trace_sdk::SimpleSpanProcessorFactory::Create(std::move(exporter)); + std::shared_ptr provider = + trace_sdk::TracerProviderFactory::Create(std::move(processor)); + trace_api::Provider::SetTracerProvider(provider); +} +``` + +### LaunchDarkly Hook Setup + +```cpp +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(hook_options); + +auto config = launchdarkly::server_side::ConfigBuilder(sdk_key) + .Hooks(tracing_hook) + .Build(); +``` + +### Passing Parent Span Context + +When using async frameworks like Boost.Beast, you need to manually pass the parent span: + +```cpp +auto span = tracer->StartSpan("HTTP GET /weather"); +auto scope = trace_api::Scope(span); + +// Create hook context with the span +auto hook_ctx = launchdarkly::server_side::integrations::otel::MakeHookContextWithSpan(span); + +// Pass it to the evaluation +auto flag_value = ld_client->BoolVariation(context, "my-flag", false, hook_ctx); +``` + +This ensures feature flag events appear as children of the correct span. + +## What You'll See + +### In Your Application Logs + +``` +*** SDK successfully initialized! + +*** Weather server running on http://0.0.0.0:8080 +``` + +### In Your Traces + +Each HTTP request will have: +1. **Root Span**: "HTTP GET /weather" with HTTP attributes +2. **Span Event**: "feature_flag" with LaunchDarkly evaluation details + +Example trace structure: +``` +HTTP GET /weather (span) + └─ feature_flag (event) + ├─ feature_flag.key: "show-detailed-weather" + ├─ feature_flag.provider.name: "LaunchDarkly" + ├─ feature_flag.context.id: "user:weather-api-user" + └─ feature_flag.result.value: "true" +``` + +## Customization + +### Include/Exclude Flag Values + +For privacy, you can exclude flag values from traces: + +```cpp +.IncludeValue(false) // Don't include flag values +``` + +### Create Dedicated Spans + +For detailed performance tracking: + +```cpp +.CreateSpans(true) // Create a span for each evaluation +``` + +This creates spans like `LDClient.BoolVariation` in addition to the feature_flag event. + +### Set Environment ID + +To include environment information in traces: + +```cpp +.EnvironmentId("production") +``` + +## Troubleshooting + +### No traces appear + +1. Verify OpenTelemetry collector is running: `curl http://localhost:4318/v1/traces` +2. Check the SDK initialized successfully +3. Ensure you're making requests to the server + +### Feature flag events missing + +1. Verify the hook is registered before creating the client +2. Check that you're passing the HookContext when evaluating flags in async contexts +3. Ensure there's an active span when the evaluation happens + +## Architecture + +This example uses: +- **Boost.Beast**: Async HTTP server +- **OpenTelemetry C++**: Distributed tracing +- **LaunchDarkly C++ Server SDK**: Feature flags +- **LaunchDarkly OTel Integration**: Automatic trace enrichment + +The integration is non-invasive - the hook automatically captures all flag evaluations without changing your evaluation code. diff --git a/examples/hello-cpp-server-otel/main.cpp b/examples/hello-cpp-server-otel/main.cpp new file mode 100644 index 000000000..6260de966 --- /dev/null +++ b/examples/hello-cpp-server-otel/main.cpp @@ -0,0 +1,345 @@ +#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() { + opentelemetry::exporter::otlp::OtlpHttpExporterOptions opts; + opts.url = "http://localhost:4318/v1/traces"; + + 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)); + 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 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(); + auto res = std::make_shared>( + handle_request(std::move(req_), ld_client_)); + + http::async_write(socket_, *res, + [self, res](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 OpenTelemetry + InitTracer(); + + // 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."); + + // 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 (OTLP HTTP to localhost:4318)\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); +} \ No newline at end of file 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..62e24fb33 --- /dev/null +++ b/libs/server-sdk-otel/CMakeLists.txt @@ -0,0 +1,55 @@ +# 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) + +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) + + # 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 ea1f0d61ce5baa5584b097266bf133d1f31e3607 # v1.23.0 + ) + 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..acbd98eba --- /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..74c391297 --- /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 \ No newline at end of file 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..4f882c111 --- /dev/null +++ b/libs/server-sdk-otel/tests/tracing_hook_test.cpp @@ -0,0 +1,76 @@ +/** + * @file tracing_hook_test.cpp + * @brief Unit tests for OpenTelemetry tracing hook + */ + +#include + +#include + +namespace launchdarkly::server_side::integrations::otel { + +// Basic smoke test +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"); +} + +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"); +} + +// TODO: Add integration tests with mock OpenTelemetry components +// TODO: Add tests for BeforeEvaluation and AfterEvaluation +// TODO: Add tests for span event creation +// TODO: Add tests for span creation when enabled +// TODO: Add tests for HookContext span injection + +} // namespace launchdarkly::server_side::integrations::otel diff --git a/release-please-config.json b/release-please-config.json index b99bcb78f..fd53f0b1a 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-release-windows.sh b/scripts/build-release-windows.sh index 9ad5f3c1d..3183e3e69 100755 --- a/scripts/build-release-windows.sh +++ b/scripts/build-release-windows.sh @@ -12,11 +12,22 @@ if [ "$1" == "launchdarkly-cpp-server-redis-source" ]; then build_redis="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" +if [ "$1" == "launchdarkly-cpp-server-otel" ]; then + build_otel="ON" + build_otel_fetch_deps="ON" +fi + # Build a static release. mkdir -p build-static && cd build-static mkdir -p release cmake -G Ninja -D CMAKE_BUILD_TYPE=Release \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ + -D LD_BUILD_OTEL_SUPPORT="$build_otel" \ + -D LD_BUILD_OTEL_FETCH_DEPS="$build_otel_fetch_deps" \ -D BUILD_TESTING=OFF \ -D CMAKE_INSTALL_PREFIX=./release .. @@ -29,6 +40,8 @@ mkdir -p build-dynamic && cd build-dynamic mkdir -p release cmake -G Ninja -D CMAKE_BUILD_TYPE=Release \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ + -D LD_BUILD_OTEL_SUPPORT="$build_otel" \ + -D LD_BUILD_OTEL_FETCH_DEPS="$build_otel_fetch_deps" \ -D BUILD_TESTING=OFF \ -D LD_BUILD_SHARED_LIBS=ON \ -D LD_DYNAMIC_LINK_BOOST=OFF \ @@ -44,6 +57,8 @@ mkdir -p release cmake -G Ninja -D CMAKE_BUILD_TYPE=Debug \ -D BUILD_TESTING=OFF \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ + -D LD_BUILD_OTEL_SUPPORT="$build_otel" \ + -D LD_BUILD_OTEL_FETCH_DEPS="$build_otel_fetch_deps" \ -D CMAKE_INSTALL_PREFIX=./release .. cmake --build . --target "$1" @@ -57,6 +72,8 @@ mkdir -p release cmake -G Ninja -D CMAKE_BUILD_TYPE=Debug \ -D BUILD_TESTING=OFF \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ + -D LD_BUILD_OTEL_SUPPORT="$build_otel" \ + -D LD_BUILD_OTEL_FETCH_DEPS="$build_otel_fetch_deps" \ -D LD_BUILD_SHARED_LIBS=ON \ -D LD_DYNAMIC_LINK_BOOST=OFF \ -D CMAKE_INSTALL_PREFIX=./release .. diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 7d24f754e..5f754edda 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -13,10 +13,19 @@ if [ "$1" == "launchdarkly-cpp-server-redis-source" ]; then build_redis="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" +if [ "$1" == "launchdarkly-cpp-server-otel" ]; then + build_otel="ON" + build_otel_fetch_deps="ON" +fi + # Build a static release. mkdir -p build-static && cd build-static mkdir -p release -cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D LD_BUILD_REDIS_SUPPORT="$build_redis" -D BUILD_TESTING=OFF -D CMAKE_INSTALL_PREFIX=./release .. +cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D LD_BUILD_REDIS_SUPPORT="$build_redis" -D LD_BUILD_OTEL_SUPPORT="$build_otel" -D LD_BUILD_OTEL_FETCH_DEPS="$build_otel_fetch_deps" -D BUILD_TESTING=OFF -D CMAKE_INSTALL_PREFIX=./release .. cmake --build . --target "$1" cmake --install . @@ -25,7 +34,7 @@ cd .. # Build a dynamic release. mkdir -p build-dynamic && cd build-dynamic mkdir -p release -cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D LD_BUILD_REDIS_SUPPORT="$build_redis" -D BUILD_TESTING=OFF -D LD_BUILD_SHARED_LIBS=ON -D CMAKE_INSTALL_PREFIX=./release .. +cmake -G Ninja -D CMAKE_BUILD_TYPE=Release -D LD_BUILD_REDIS_SUPPORT="$build_redis" -D LD_BUILD_OTEL_SUPPORT="$build_otel" -D LD_BUILD_OTEL_FETCH_DEPS="$build_otel_fetch_deps" -D BUILD_TESTING=OFF -D LD_BUILD_SHARED_LIBS=ON -D CMAKE_INSTALL_PREFIX=./release .. cmake --build . --target "$1" cmake --install . diff --git a/scripts/build.sh b/scripts/build.sh index 27967a1ad..65bac2ef5 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -24,12 +24,24 @@ if [ "$1" == "launchdarkly-cpp-server-redis-source" ] || [ "$1" == "gtest_launch build_redis="ON" fi - +# Special case: OpenTelemetry support requires additional dependencies. +# Enable OTEL support and fetch deps when building OTEL targets. +# Disable contract tests for OTEL builds to avoid dependency conflicts. +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" + build_contract_tests="OFF" +fi 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_BUILD_CONTRACT_TESTS="$build_contract_tests" \ + -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ + -D LD_BUILD_OTEL_SUPPORT="$build_otel" \ + -D LD_BUILD_OTEL_FETCH_DEPS="$build_otel_fetch_deps" .. cmake --build . --target "$1" From 540107bb53c3343da1a1c2d8001d0986d48e78b7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:41:50 -0700 Subject: [PATCH 02/10] Don't simulate release for otel package. --- .github/workflows/server-otel.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/server-otel.yml b/.github/workflows/server-otel.yml index 227f8a6e3..a4acf52ff 100644 --- a/.github/workflows/server-otel.yml +++ b/.github/workflows/server-otel.yml @@ -22,7 +22,8 @@ jobs: with: cmake_target: launchdarkly-cpp-server-otel install_curl: true - simulate_release: true + # We don't produce release artifacts. + simulate_release: false build-otel-mac: runs-on: macos-13 steps: @@ -32,7 +33,8 @@ jobs: cmake_target: launchdarkly-cpp-server-otel platform_version: 12 install_curl: true - simulate_release: true + # We don't produce release artifacts. + simulate_release: false build-test-otel-windows: runs-on: windows-2022 steps: @@ -48,4 +50,5 @@ jobs: platform_version: 2022 toolset: msvc install_curl: true - simulate_windows_release: true + # We don't produce release artifacts. + simulate_windows_release: false From 891dcf87e0e5a1defdd5d8f2b8bd8b1e462dae3d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:16:25 -0700 Subject: [PATCH 03/10] Allow custom GIT_TAG for OpenTelemetry. --- README.md | 3 +++ libs/server-sdk-otel/CMakeLists.txt | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) 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/libs/server-sdk-otel/CMakeLists.txt b/libs/server-sdk-otel/CMakeLists.txt index c32367d33..dd5537b89 100644 --- a/libs/server-sdk-otel/CMakeLists.txt +++ b/libs/server-sdk-otel/CMakeLists.txt @@ -24,6 +24,10 @@ 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") @@ -44,7 +48,7 @@ if (LD_BUILD_OTEL_FETCH_DEPS) FetchContent_Declare( opentelemetry-cpp GIT_REPOSITORY https://github.com/open-telemetry/opentelemetry-cpp.git - GIT_TAG ea1f0d61ce5baa5584b097266bf133d1f31e3607 # v1.23.0 + GIT_TAG ${LD_OTEL_CPP_VERSION} ) FetchContent_MakeAvailable(opentelemetry-cpp) else() From 52a49fc7f38a478f87193c0ff04aac4870f5665a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:16:38 -0700 Subject: [PATCH 04/10] Extend tests. --- .../tests/tracing_hook_test.cpp | 82 +++++++++++++++++-- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/libs/server-sdk-otel/tests/tracing_hook_test.cpp b/libs/server-sdk-otel/tests/tracing_hook_test.cpp index 4f882c111..6183a481c 100644 --- a/libs/server-sdk-otel/tests/tracing_hook_test.cpp +++ b/libs/server-sdk-otel/tests/tracing_hook_test.cpp @@ -9,7 +9,7 @@ namespace launchdarkly::server_side::integrations::otel { -// Basic smoke test +// Basic construction tests TEST(TracingHookTest, ConstructsWithDefaultOptions) { TracingHook hook; EXPECT_EQ(hook.Metadata().Name(), "LaunchDarkly OpenTelemetry Tracing Hook"); @@ -26,6 +26,7 @@ TEST(TracingHookTest, ConstructsWithCustomOptions) { EXPECT_EQ(hook.Metadata().Name(), "LaunchDarkly OpenTelemetry Tracing Hook"); } +// Options builder tests TEST(TracingHookOptionsBuilderTest, BuildsDefaultOptions) { auto options = TracingHookOptionsBuilder().Build(); @@ -67,10 +68,79 @@ TEST(TracingHookOptionsBuilderTest, ChainsMethods) { EXPECT_EQ(options.EnvironmentId().value(), "staging"); } -// TODO: Add integration tests with mock OpenTelemetry components -// TODO: Add tests for BeforeEvaluation and AfterEvaluation -// TODO: Add tests for span event creation -// TODO: Add tests for span creation when enabled -// TODO: Add tests for HookContext span injection +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 From 57a75d82e5e9a3396082b1b1eafbbed9142d3784 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 30 Oct 2025 11:01:24 -0700 Subject: [PATCH 05/10] Update example to use LD Observability by default. --- examples/hello-cpp-server-otel/README.md | 114 ++++++----------------- examples/hello-cpp-server-otel/main.cpp | 28 ++++-- 2 files changed, 50 insertions(+), 92 deletions(-) diff --git a/examples/hello-cpp-server-otel/README.md b/examples/hello-cpp-server-otel/README.md index 5326cb798..cba843134 100644 --- a/examples/hello-cpp-server-otel/README.md +++ b/examples/hello-cpp-server-otel/README.md @@ -16,7 +16,6 @@ This example demonstrates how to integrate the LaunchDarkly C++ Server SDK with - CMake 3.19 or later - Boost 1.81 or later - LaunchDarkly SDK key -- OpenTelemetry collector (or compatible backend) running on `localhost:4318` ## Building @@ -30,21 +29,7 @@ cmake --build . --target hello-cpp-server-otel ## Running -### 1. Start an OpenTelemetry Collector - -The easiest way is using Docker: - -```bash -docker run -p 4318:4318 otel/opentelemetry-collector:latest -``` - -Or use Jaeger (which has a built-in OTLP receiver): - -```bash -docker run -d -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest -``` - -### 2. Set Your LaunchDarkly SDK Key +### 1. Set Your LaunchDarkly SDK Key Either edit `main.cpp` and set the `SDK_KEY` constant, or use an environment variable: @@ -52,11 +37,11 @@ Either edit `main.cpp` and set the `SDK_KEY` constant, or use an environment var export LD_SDK_KEY=your-sdk-key-here ``` -### 3. Create a Feature Flag +### 2. Create a Feature Flag In your LaunchDarkly dashboard, create a boolean flag named `show-detailed-weather`. -### 4. Run the Example +### 3. Run the Example ```bash ./build/examples/hello-cpp-server-otel/hello-cpp-server-otel @@ -69,74 +54,36 @@ You should see: *** Weather server running on http://0.0.0.0:8080 *** Try: curl http://localhost:8080/weather -*** OpenTelemetry tracing enabled (OTLP HTTP to localhost:4318) +*** OpenTelemetry tracing enabled, sending traces to LaunchDarkly *** LaunchDarkly integration enabled with OpenTelemetry tracing hook ``` -### 5. Make Requests +### 4. Make Requests ```bash curl http://localhost:8080/weather ``` -### 6. View Traces - -If using Jaeger, open http://localhost:16686 in your browser. You should see traces with: +### 5. View Traces in LaunchDarkly -- HTTP request spans -- Feature flag evaluation events with attributes: - - `feature_flag.key`: "show-detailed-weather" - - `feature_flag.provider.name`: "LaunchDarkly" - - `feature_flag.context.id`: Context canonical key - - `feature_flag.result.value`: The flag value (since `IncludeValue` is enabled) +1. Go to your LaunchDarkly project +2. Navigate to the Observability section +3. View traces containing your feature flag evaluations with attributes: + - `feature_flag.key`: "show-detailed-weather" + - `feature_flag.provider.name`: "LaunchDarkly" + - `feature_flag.context.id`: Context canonical key + - `feature_flag.result.value`: The flag value (since `IncludeValue` is enabled) -## How It Works +### Custom OTLP Endpoint -### OpenTelemetry Setup +To send traces to a different OpenTelemetry collector, set the `LD_OTEL_ENDPOINT` environment variable: -```cpp -void InitTracer() { - opentelemetry::exporter::otlp::OtlpHttpExporterOptions opts; - opts.url = "http://localhost:4318/v1/traces"; - - auto exporter = opentelemetry::exporter::otlp::OtlpHttpExporterFactory::Create(opts); - auto processor = trace_sdk::SimpleSpanProcessorFactory::Create(std::move(exporter)); - std::shared_ptr provider = - trace_sdk::TracerProviderFactory::Create(std::move(processor)); - trace_api::Provider::SetTracerProvider(provider); -} -``` - -### LaunchDarkly Hook Setup - -```cpp -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(hook_options); - -auto config = launchdarkly::server_side::ConfigBuilder(sdk_key) - .Hooks(tracing_hook) - .Build(); -``` - -### Passing Parent Span Context - -When using async frameworks like Boost.Beast, you need to manually pass the parent span: - -```cpp -auto span = tracer->StartSpan("HTTP GET /weather"); -auto scope = trace_api::Scope(span); - -// Create hook context with the span -auto hook_ctx = launchdarkly::server_side::integrations::otel::MakeHookContextWithSpan(span); - -// Pass it to the evaluation -auto flag_value = ld_client->BoolVariation(context, "my-flag", false, hook_ctx); +```bash +export LD_OTEL_ENDPOINT=http://localhost:4318/v1/traces +./build/examples/hello-cpp-server-otel/hello-cpp-server-otel ``` -This ensures feature flag events appear as children of the correct span. +Note: The `/v1/traces` path is automatically appended to the endpoint. ## What You'll See @@ -146,23 +93,20 @@ This ensures feature flag events appear as children of the correct span. *** 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 ``` -### In Your Traces +### In LaunchDarkly Observability -Each HTTP request will have: +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. **Span Event**: "feature_flag" with LaunchDarkly evaluation details - -Example trace structure: -``` -HTTP GET /weather (span) - └─ feature_flag (event) - ├─ feature_flag.key: "show-detailed-weather" - ├─ feature_flag.provider.name: "LaunchDarkly" - ├─ feature_flag.context.id: "user:weather-api-user" - └─ feature_flag.result.value: "true" -``` +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 ## Customization diff --git a/examples/hello-cpp-server-otel/main.cpp b/examples/hello-cpp-server-otel/main.cpp index 6260de966..6eabde49d 100644 --- a/examples/hello-cpp-server-otel/main.cpp +++ b/examples/hello-cpp-server-otel/main.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -46,16 +47,29 @@ namespace trace_sdk = opentelemetry::sdk::trace; namespace nostd = opentelemetry::nostd; // Initialize OpenTelemetry -void InitTracer() { +void InitTracer(char const* sdk_key) { opentelemetry::exporter::otlp::OtlpHttpExporterOptions opts; - opts.url = "http://localhost:4318/v1/traces"; + + // 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 highlight.project_id attribute + auto resource_attributes = opentelemetry::sdk::resource::ResourceAttributes{ + {"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)); + trace_sdk::TracerProviderFactory::Create(std::move(processor), resource); trace_api::Provider::SetTracerProvider(provider); } @@ -253,9 +267,6 @@ class listener : public std::enable_shared_from_this { }; int main() { - // Initialize OpenTelemetry - InitTracer(); - // Initialize LaunchDarkly char const* sdk_key = get_with_env_fallback( SDK_KEY, "LD_SDK_KEY", @@ -264,6 +275,9 @@ int main() { "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() @@ -315,7 +329,7 @@ int main() { 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 (OTLP HTTP to localhost:4318)\n"; + "*** OpenTelemetry tracing enabled, sending traces to LaunchDarkly\n"; std::cout << "*** LaunchDarkly integration enabled with OpenTelemetry tracing hook\n\n"; From 6aedc8963cffd7b4a1eae1bbec12fb01b0bea1ff Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:09:26 -0700 Subject: [PATCH 06/10] Trim readme. --- examples/hello-cpp-server-otel/README.md | 86 ++---------------------- 1 file changed, 5 insertions(+), 81 deletions(-) diff --git a/examples/hello-cpp-server-otel/README.md b/examples/hello-cpp-server-otel/README.md index cba843134..e8e4c91ff 100644 --- a/examples/hello-cpp-server-otel/README.md +++ b/examples/hello-cpp-server-otel/README.md @@ -66,40 +66,6 @@ curl http://localhost:8080/weather ### 5. View Traces in LaunchDarkly -1. Go to your LaunchDarkly project -2. Navigate to the Observability section -3. View traces containing your feature flag evaluations with attributes: - - `feature_flag.key`: "show-detailed-weather" - - `feature_flag.provider.name`: "LaunchDarkly" - - `feature_flag.context.id`: Context canonical key - - `feature_flag.result.value`: The flag value (since `IncludeValue` is enabled) - -### 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 -``` - -Note: The `/v1/traces` path is automatically appended to the endpoint. - -## What You'll See - -### In Your Application Logs - -``` -*** 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 -``` - -### In LaunchDarkly Observability - 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: @@ -108,54 +74,12 @@ Navigate to your LaunchDarkly project's Observability section to view traces. Ea - `feature_flag.context.id`: "user:weather-api-user" - `feature_flag.result.value`: The evaluated flag value -## Customization - -### Include/Exclude Flag Values - -For privacy, you can exclude flag values from traces: - -```cpp -.IncludeValue(false) // Don't include flag values -``` - -### Create Dedicated Spans -For detailed performance tracking: - -```cpp -.CreateSpans(true) // Create a span for each evaluation -``` - -This creates spans like `LDClient.BoolVariation` in addition to the feature_flag event. - -### Set Environment ID +### Custom OTLP Endpoint -To include environment information in traces: +To send traces to a different OpenTelemetry collector, set the `LD_OTEL_ENDPOINT` environment variable: -```cpp -.EnvironmentId("production") +```bash +export LD_OTEL_ENDPOINT=http://localhost:4318/v1/traces +./build/examples/hello-cpp-server-otel/hello-cpp-server-otel ``` - -## Troubleshooting - -### No traces appear - -1. Verify OpenTelemetry collector is running: `curl http://localhost:4318/v1/traces` -2. Check the SDK initialized successfully -3. Ensure you're making requests to the server - -### Feature flag events missing - -1. Verify the hook is registered before creating the client -2. Check that you're passing the HookContext when evaluating flags in async contexts -3. Ensure there's an active span when the evaluation happens - -## Architecture - -This example uses: -- **Boost.Beast**: Async HTTP server -- **OpenTelemetry C++**: Distributed tracing -- **LaunchDarkly C++ Server SDK**: Feature flags -- **LaunchDarkly OTel Integration**: Automatic trace enrichment - -The integration is non-invasive - the hook automatically captures all flag evaluations without changing your evaluation code. From b87de85e1dd20ddc33d8c2e789bea7d88ea59500 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:17:33 -0700 Subject: [PATCH 07/10] Remove outdated comment. --- scripts/build-release.sh | 9 --------- scripts/build.sh | 1 - 2 files changed, 10 deletions(-) diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 7ec97638f..38332ecf6 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -32,15 +32,6 @@ else suffix="" 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" -if [ "$1" == "launchdarkly-cpp-server-otel" ]; then - build_otel="ON" - build_otel_fetch_deps="ON" -fi - # Build a static release. mkdir -p "build-static${suffix}" && cd "build-static${suffix}" mkdir -p release diff --git a/scripts/build.sh b/scripts/build.sh index 21ed7623a..9ca6d21a2 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -32,7 +32,6 @@ if [ "$3" == "true" ]; then fi # Special case: OpenTelemetry support requires additional dependencies. # Enable OTEL support and fetch deps when building OTEL targets. -# Disable contract tests for OTEL builds to avoid dependency conflicts. build_otel="OFF" build_otel_fetch_deps="OFF" build_contract_tests="$2" From 2a7f7da0d907121e3130653099646e7ebb5b945a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:31:32 -0700 Subject: [PATCH 08/10] Add service name to otel example. --- examples/hello-cpp-server-otel/main.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/hello-cpp-server-otel/main.cpp b/examples/hello-cpp-server-otel/main.cpp index 6eabde49d..a623fb638 100644 --- a/examples/hello-cpp-server-otel/main.cpp +++ b/examples/hello-cpp-server-otel/main.cpp @@ -1,6 +1,5 @@ #include #include -#include #include #include #include @@ -58,8 +57,9 @@ void InitTracer(char const* sdk_key) { opts.url = "https://otel.observability.app.launchdarkly.com:4318/v1/traces"; } - // Create resource with highlight.project_id attribute + // 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); @@ -171,6 +171,7 @@ 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: @@ -197,11 +198,11 @@ class session : public std::enable_shared_from_this { void do_write() { auto self = shared_from_this(); - auto res = std::make_shared>( + res_ = std::make_shared>( handle_request(std::move(req_), ld_client_)); - http::async_write(socket_, *res, - [self, res](beast::error_code ec, std::size_t) { + http::async_write(socket_, *res_, + [self](beast::error_code ec, std::size_t) { self->socket_.shutdown( tcp::socket::shutdown_send, ec); }); @@ -356,4 +357,4 @@ char const* get_with_env_fallback(char const* source_val, std::cout << "*** " << error_msg << std::endl; std::exit(1); -} \ No newline at end of file +} From 5d4c1dd0e57f9f154cf1842ef83574df57294c65 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:39:38 -0700 Subject: [PATCH 09/10] Revert windows built script changes. --- scripts/build-release-windows.sh | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/scripts/build-release-windows.sh b/scripts/build-release-windows.sh index ca1617d09..5f5d5427f 100755 --- a/scripts/build-release-windows.sh +++ b/scripts/build-release-windows.sh @@ -38,8 +38,6 @@ mkdir -p release cmake -G Ninja -D CMAKE_BUILD_TYPE=Release \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ -D LD_CURL_NETWORKING="$build_curl" \ - -D LD_BUILD_OTEL_SUPPORT="$build_otel" \ - -D LD_BUILD_OTEL_FETCH_DEPS="$build_otel_fetch_deps" \ -D BUILD_TESTING=OFF \ -D CMAKE_INSTALL_PREFIX=./release .. @@ -53,8 +51,6 @@ mkdir -p release cmake -G Ninja -D CMAKE_BUILD_TYPE=Release \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ -D LD_CURL_NETWORKING="$build_curl" \ - -D LD_BUILD_OTEL_SUPPORT="$build_otel" \ - -D LD_BUILD_OTEL_FETCH_DEPS="$build_otel_fetch_deps" \ -D BUILD_TESTING=OFF \ -D LD_BUILD_SHARED_LIBS=ON \ -D LD_DYNAMIC_LINK_BOOST=OFF \ @@ -70,8 +66,7 @@ mkdir -p release cmake -G Ninja -D CMAKE_BUILD_TYPE=Debug \ -D BUILD_TESTING=OFF \ -D LD_BUILD_REDIS_SUPPORT="$build_redis" \ - -D LD_BUILD_OTEL_SUPPORT="$build_otel" \ - -D LD_BUILD_OTEL_FETCH_DEPS="$build_otel_fetch_deps" \ + -D LD_CURL_NETWORKING="$build_curl" \ -D CMAKE_INSTALL_PREFIX=./release .. cmake --build . --target "$TARGET" From 0771956f72cf5f962f8b74d15c3197a40dcb9922 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:44:18 -0700 Subject: [PATCH 10/10] Remove errant slash. --- libs/server-sdk-otel/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/server-sdk-otel/README.md b/libs/server-sdk-otel/README.md index acbd98eba..4bccc4bf0 100644 --- a/libs/server-sdk-otel/README.md +++ b/libs/server-sdk-otel/README.md @@ -141,7 +141,7 @@ launchdarkly::server_side::Client client(std::move(config)); // 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); ```