diff --git a/server-sdk-ai/frameworks/openai/chat-completions/.env.example b/server-sdk-ai/frameworks/openai/chat-completions/.env.example index f7dc420..d1d26bf 100644 --- a/server-sdk-ai/frameworks/openai/chat-completions/.env.example +++ b/server-sdk-ai/frameworks/openai/chat-completions/.env.example @@ -6,3 +6,8 @@ OPENAI_API_KEY= # Override to use a different AI Config key LAUNCHDARKLY_COMPLETION_KEY=sample-completion + +# The example enables the OpenAI SDK's experimental OpenTelemetry instrumentation +# in code. Optionally override the observability plugin's OTLP endpoint (for +# example on networks that only allow port 443). +# OTEL_EXPORTER_OTLP_ENDPOINT= diff --git a/server-sdk-ai/frameworks/openai/chat-completions/OpenAiChatCompletions.csproj b/server-sdk-ai/frameworks/openai/chat-completions/OpenAiChatCompletions.csproj index 7c15241..fdf6503 100644 --- a/server-sdk-ai/frameworks/openai/chat-completions/OpenAiChatCompletions.csproj +++ b/server-sdk-ai/frameworks/openai/chat-completions/OpenAiChatCompletions.csproj @@ -10,6 +10,8 @@ + + diff --git a/server-sdk-ai/frameworks/openai/chat-completions/Program.cs b/server-sdk-ai/frameworks/openai/chat-completions/Program.cs index 6664a47..1a4d8b2 100644 --- a/server-sdk-ai/frameworks/openai/chat-completions/Program.cs +++ b/server-sdk-ai/frameworks/openai/chat-completions/Program.cs @@ -1,14 +1,25 @@ using DotNetEnv; +using LaunchDarkly.Observability; using LaunchDarkly.Sdk; using LaunchDarkly.Sdk.Server; using LaunchDarkly.Sdk.Server.Ai; using LaunchDarkly.Sdk.Server.Ai.Adapters; using LaunchDarkly.Sdk.Server.Ai.Config; using LaunchDarkly.Sdk.Server.Ai.Tracking; +using LaunchDarkly.Sdk.Server.Integrations; +using Microsoft.Extensions.Hosting; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; using OpenAI.Chat; Env.TraversePath().Load(); +// Enable the OpenAI SDK's experimental OpenTelemetry instrumentation so its chat +// completions emit spans and metrics. This must be set before the ChatClient is +// used; the observability plugin configured below exports the telemetry to +// LaunchDarkly. +Environment.SetEnvironmentVariable("OPENAI_EXPERIMENTAL_ENABLE_OPEN_TELEMETRY", "true"); + var sdkKey = Environment.GetEnvironmentVariable("LAUNCHDARKLY_SDK_KEY"); if (string.IsNullOrEmpty(sdkKey)) { @@ -29,7 +40,22 @@ var completionKey = Environment.GetEnvironmentVariable("LAUNCHDARKLY_COMPLETION_KEY") ?? "sample-completion"; -var ldClient = new LdClient(Configuration.Builder(sdkKey).Build()); +// The observability plugin registers OpenTelemetry into a dependency-injection +// service collection and relies on the .NET generic host to run the exporters. +var hostBuilder = Host.CreateApplicationBuilder(args); + +var ldClient = new LdClient(Configuration.Builder(sdkKey) + .Plugins(new PluginConfigurationBuilder() + .Add(ObservabilityPlugin.Builder(hostBuilder.Services) + .WithServiceName("openai-chat-completions") + .WithServiceVersion("1.0.0") + // The OpenAI SDK emits telemetry under the "OpenAI.ChatClient" activity + // source and meter. Add them so the plugin exports the model call's + // spans and token-usage metrics alongside the AI Config tracker events. + .WithExtendedTracingConfig(tracing => tracing.AddSource("OpenAI.ChatClient")) + .WithExtendedMeterConfiguration(metrics => metrics.AddMeter("OpenAI.ChatClient")) + .Build())) + .Build()); if (!ldClient.Initialized) { Console.Error.WriteLine( @@ -39,6 +65,11 @@ } Console.WriteLine("*** SDK successfully initialized!"); +// Building the LdClient registered the plugin's OpenTelemetry services on the host, +// so the host must be built after the client. Starting it boots the exporters. +using var host = hostBuilder.Build(); +await host.StartAsync(); + var aiClient = new LdAiClient(new LdClientAdapter(ldClient)); // Set up the evaluation context. This context should appear on your @@ -113,6 +144,9 @@ finally { ldClient.FlushAndWait(TimeSpan.FromSeconds(5)); + // Stop the host so the OpenTelemetry exporters flush any buffered spans and + // metrics to LaunchDarkly before the process exits. + await host.StopAsync(); ldClient.Dispose(); } diff --git a/server-sdk-ai/frameworks/openai/chat-completions/README.md b/server-sdk-ai/frameworks/openai/chat-completions/README.md index 9375d0f..b29b774 100644 --- a/server-sdk-ai/frameworks/openai/chat-completions/README.md +++ b/server-sdk-ai/frameworks/openai/chat-completions/README.md @@ -2,6 +2,8 @@ This example demonstrates how to use the LaunchDarkly AI SDK for .NET with the [OpenAI Chat Completions API](https://platform.openai.com/docs/api-reference/chat). +It also wires up the LaunchDarkly [observability plugin](https://launchdarkly.com/docs/sdk/observability/dotnet). The example sets the `OPENAI_EXPERIMENTAL_ENABLE_OPEN_TELEMETRY` environment variable to `true` so the OpenAI SDK emits OpenTelemetry spans and metrics (under the `OpenAI.ChatClient` source and meter), and registers those with the plugin so the model call's traces and token-usage metrics are exported to LaunchDarkly alongside the AI Config tracker events. + ## Prerequisites - .NET 8.0 SDK diff --git a/server-sdk/README.md b/server-sdk/README.md index 4a84f9e..4dfc8eb 100644 --- a/server-sdk/README.md +++ b/server-sdk/README.md @@ -9,3 +9,9 @@ For more comprehensive instructions, you can visit your [Quickstart page](https: | Example | Description | |--------------------------------------|-------------------------------------------------| | [Flag Retrieval](./getting-started/) | Initialize the SDK and evaluate a feature flag | + +## Features + +| Example | Description | +|------------------------------------------------------|----------------------------------------------------------------------| +| [Observability](./features/plugins/observability/) | Register the observability plugin and record custom telemetry | diff --git a/server-sdk/features/plugins/observability/Hello.cs b/server-sdk/features/plugins/observability/Hello.cs new file mode 100644 index 0000000..c989904 --- /dev/null +++ b/server-sdk/features/plugins/observability/Hello.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Observability; +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Server; +using LaunchDarkly.Sdk.Server.Integrations; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace HelloDotNet +{ + class Hello + { + public static void ShowBanner(){ + Console.WriteLine( +@" ██ + ██ + ████████ + ███████ +██ LAUNCHDARKLY █ + ███████ + ████████ + ██ + ██ +"); + } + + static void Main(string[] args) + { + bool CI = Environment.GetEnvironmentVariable("CI") != null; + + string SdkKey = Environment.GetEnvironmentVariable("LAUNCHDARKLY_SDK_KEY"); + + // Set FeatureFlagKey to the feature flag key you want to evaluate. + string FeatureFlagKey = "sample-feature"; + + if (string.IsNullOrEmpty(SdkKey)) + { + Console.WriteLine("*** Please set LAUNCHDARKLY_SDK_KEY environment variable to your LaunchDarkly SDK key first\n"); + Environment.Exit(1); + } + + // The observability plugin registers OpenTelemetry into a dependency-injection + // service collection and relies on the .NET generic host to run the exporters. + // A console application creates that host explicitly; an ASP.NET Core app would + // pass its existing builder.Services instead. + var hostBuilder = Host.CreateApplicationBuilder(args); + + // Compared to the getting-started example, the only configuration change is + // adding the observability plugin. By default it exports telemetry to + // LaunchDarkly; override the endpoint with the OTEL_EXPORTER_OTLP_ENDPOINT + // environment variable for networks that only allow port 443. + var ldConfig = Configuration.Builder(SdkKey) + .Plugins(new PluginConfigurationBuilder() + .Add(ObservabilityPlugin.Builder(hostBuilder.Services) + .WithServiceName("hello-dotnet-observability") + .WithServiceVersion("1.0.0") + .Build())) + .Build(); + + var client = new LdClient(ldConfig); + + if (client.Initialized) + { + Console.WriteLine("*** SDK successfully initialized!\n"); + } + else + { + Console.WriteLine("*** SDK failed to initialize\n"); + Environment.Exit(1); + } + + // Building the LdClient above registered the plugin's OpenTelemetry services on + // the host, so the host must be built after the client. Starting it boots the + // exporters that ship telemetry to LaunchDarkly. + var host = hostBuilder.Build(); + host.Start(); + + // Set up the evaluation context. This context should appear on your LaunchDarkly contexts + // dashboard soon after you run the demo. + var context = Context.Builder("example-user-key") + .Name("Sandy") + .Build(); + + if (Environment.GetEnvironmentVariable("LAUNCHDARKLY_FLAG_KEY") != null) + { + FeatureFlagKey = Environment.GetEnvironmentVariable("LAUNCHDARKLY_FLAG_KEY"); + } + + var flagAttributes = new Dictionary { { "flag.key", FeatureFlagKey } }; + + // A web framework would create spans automatically for incoming requests; a console + // application has none, so we start one manually with Observe.StartActivity. The custom + // metric and log recorded inside it are grouped under this trace in LaunchDarkly. + using (Observe.StartActivity("evaluate-flag")) + { + var flagValue = client.BoolVariation(FeatureFlagKey, context, false); + + Console.WriteLine($"*** The {FeatureFlagKey} feature flag evaluates to {flagValue}.\n"); + + Observe.RecordIncr("flag_evaluations", flagAttributes); + Observe.RecordLog($"Evaluated {FeatureFlagKey}: {flagValue}", LogLevel.Information, flagAttributes); + + if (flagValue) ShowBanner(); + } + + client.FlagTracker.FlagChanged += client.FlagTracker.FlagValueChangeHandler( + FeatureFlagKey, + context, + (sender, changeArgs) => { + // Record each change as its own span so the update is visible in LaunchDarkly. + using (Observe.StartActivity("flag-changed")) + { + Console.WriteLine($"*** The {FeatureFlagKey} feature flag evaluates to {changeArgs.NewValue}.\n"); + + Observe.RecordIncr("flag_changes", flagAttributes); + + if (changeArgs.NewValue.AsBool) ShowBanner(); + } + } + ); + + if (CI) + { + // In CI just verify startup, then stop the host so any buffered + // telemetry is flushed before exiting. + host.StopAsync().GetAwaiter().GetResult(); + client.Dispose(); + return; + } + + Console.WriteLine("*** Waiting for changes (press Ctrl+C to exit) \n"); + + // Block until Ctrl+C. The host's console lifetime handles the signal, + // stops the OpenTelemetry exporters (flushing buffered telemetry), and + // then returns so the process exits cleanly. Blocking any other way + // (for example on a Task that never completes) would leave the process + // hung at "Application is shutting down..." because nothing would be + // driving the host's shutdown. + host.WaitForShutdown(); + + client.Dispose(); + } + } +} diff --git a/server-sdk/features/plugins/observability/NOTICE b/server-sdk/features/plugins/observability/NOTICE new file mode 100644 index 0000000..9583678 --- /dev/null +++ b/server-sdk/features/plugins/observability/NOTICE @@ -0,0 +1,4 @@ +LaunchDarkly .NET Server SDK Observability Plugin example +Copyright 2026 Catamorphic, Co. + +This product includes software developed at LaunchDarkly (https://launchdarkly.com/). diff --git a/server-sdk/features/plugins/observability/Observability.csproj b/server-sdk/features/plugins/observability/Observability.csproj new file mode 100644 index 0000000..462e88e --- /dev/null +++ b/server-sdk/features/plugins/observability/Observability.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0 + + + + + + + + + diff --git a/server-sdk/features/plugins/observability/README.md b/server-sdk/features/plugins/observability/README.md new file mode 100644 index 0000000..7edbb29 --- /dev/null +++ b/server-sdk/features/plugins/observability/README.md @@ -0,0 +1,53 @@ +# Observability Plugin + +This example builds on the [Hello .NET getting-started example](../../../getting-started/) and adds the LaunchDarkly [observability plugin](https://launchdarkly.com/docs/sdk/observability/dotnet). The only change to the flag-evaluation code is registering the plugin on the SDK configuration; everything else demonstrates how to record custom telemetry from a console application. + +It demonstrates: + +- Registering `ObservabilityPlugin` on the SDK configuration with `PluginConfigurationBuilder` +- Hosting the plugin's OpenTelemetry exporters in a console app with `Host.CreateApplicationBuilder` +- Creating spans manually with `Observe.StartActivity` +- Recording a custom metric with `Observe.RecordIncr` +- Emitting a structured log with `Observe.RecordLog` + +## Spans in a console application + +Framework integrations (for example ASP.NET Core) create spans automatically for +incoming requests. A console application has no framework generating requests, so +**nothing produces spans unless you create them yourself** with `Observe.StartActivity`. +This example wraps the flag evaluation, and each flag change, in a manually created +span so the custom metrics and logs recorded inside them are grouped under a trace in +LaunchDarkly. + +The plugin also relies on the .NET generic host to run its OpenTelemetry exporters. +This console app creates one with `Host.CreateApplicationBuilder` and calls `host.Start()` +after the `LdClient` is built (constructing the client registers the exporter services on +the host). Because the app then stays running, the exporters flush telemetry on their +normal schedule. + +## Prerequisites + +- .NET 8.0 SDK +- A LaunchDarkly account and a server-side SDK key +- [Observability enabled](https://launchdarkly.com/docs/sdk/observability/dotnet) for your LaunchDarkly project + +## Build instructions + +1. Set the environment variable `LAUNCHDARKLY_SDK_KEY` to your LaunchDarkly SDK key. If there is an existing boolean feature flag in your LaunchDarkly project that you want to evaluate, set `LAUNCHDARKLY_FLAG_KEY` to the flag key; otherwise, a boolean flag of `sample-feature` will be assumed. + + ```bash + export LAUNCHDARKLY_SDK_KEY="1234567890abcdef" + export LAUNCHDARKLY_FLAG_KEY="my-boolean-flag" + ``` + + By default the plugin exports telemetry to LaunchDarkly. To send it elsewhere (for example on networks that only allow port 443), set `OTEL_EXPORTER_OTLP_ENDPOINT`. + +2. Run the application from the command line: + + ```bash + dotnet run + ``` + +You should receive the message "The feature flag evaluates to ." The application runs continuously and reacts to flag changes in LaunchDarkly, recording a span, a metric, and a log for each evaluation. The telemetry appears on your LaunchDarkly observability dashboard shortly afterward. + +> **Note:** Traces and logs are exported within a few seconds, but metrics are collected by a periodic reader and export on a longer interval (roughly a minute), so they take longer to appear. Keep the application running long enough for the export to occur.