From a61fabebcfa1a3e2358481bb31ab3d5eb869c73e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:51:34 -0700 Subject: [PATCH 1/9] chore: Scaffold dotnet plugin project. --- .github/workflows/dotnet-plugin.yml | 25 +++++++++ .prettierignore | 1 + .../observability-dotnet/.gitignore | 2 + .../LaunchDarkly.Observability.sln | 33 ++++++++++++ .../observability-dotnet/README.md | 53 +++++++++++++++++++ .../src/LaunchDarkly.Observability/Class1.cs | 7 +++ .../LaunchDarkly.Observability.csproj | 38 +++++++++++++ .../LaunchDarkly.Observability.Tests.csproj | 20 +++++++ .../UnitTest1.cs | 18 +++++++ 9 files changed, 197 insertions(+) create mode 100644 .github/workflows/dotnet-plugin.yml create mode 100644 sdk/@launchdarkly/observability-dotnet/.gitignore create mode 100644 sdk/@launchdarkly/observability-dotnet/LaunchDarkly.Observability.sln create mode 100644 sdk/@launchdarkly/observability-dotnet/README.md create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj create mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj create mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/UnitTest1.cs diff --git a/.github/workflows/dotnet-plugin.yml b/.github/workflows/dotnet-plugin.yml new file mode 100644 index 000000000..738fc5148 --- /dev/null +++ b/.github/workflows/dotnet-plugin.yml @@ -0,0 +1,25 @@ +name: .NET ObservabilityPlugin +on: + workflow_dispatch: + push: + branches: ['main'] + pull_request: + branches: ['main'] + paths: + - 'sdk/@launchdarkly/observability-dotnet/**' + - '.github/workflows/dotnet-plugin.yml' + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - run: dotnet restore + working-directory: sdk/launchdarkly/observability-dotnet + + - run: dotnet build --no-restore + working-directory: sdk/launchdarkly/observability-dotnet + + - run: dotnet test --no-restore + working-directory: sdk/launchdarkly/observability-dotnet diff --git a/.prettierignore b/.prettierignore index e989a722b..34e0ad7c9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -22,3 +22,4 @@ __generated highlight.io/out sdk/highlightinc-highlight-datasource/grafana/data sdk/highlight-php +sdk/@launchdarkly/observability-dotnet diff --git a/sdk/@launchdarkly/observability-dotnet/.gitignore b/sdk/@launchdarkly/observability-dotnet/.gitignore new file mode 100644 index 000000000..f181d71cc --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/.gitignore @@ -0,0 +1,2 @@ +**/bin +**/obj diff --git a/sdk/@launchdarkly/observability-dotnet/LaunchDarkly.Observability.sln b/sdk/@launchdarkly/observability-dotnet/LaunchDarkly.Observability.sln new file mode 100644 index 000000000..8c8b79796 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/LaunchDarkly.Observability.sln @@ -0,0 +1,33 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.Observability", "src\LaunchDarkly.Observability\LaunchDarkly.Observability.csproj", "{EFEF5623-E551-4085-AB85-1711BD6EA731}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.Observability.Tests", "test\LaunchDarkly.Observability.Tests\LaunchDarkly.Observability.Tests.csproj", "{85022F8E-D264-44F4-A8AF-1117D8202FBD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{25034066-05A5-47F9-9FF3-9213B17E689C}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EFEF5623-E551-4085-AB85-1711BD6EA731}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EFEF5623-E551-4085-AB85-1711BD6EA731}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EFEF5623-E551-4085-AB85-1711BD6EA731}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EFEF5623-E551-4085-AB85-1711BD6EA731}.Release|Any CPU.Build.0 = Release|Any CPU + {85022F8E-D264-44F4-A8AF-1117D8202FBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85022F8E-D264-44F4-A8AF-1117D8202FBD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85022F8E-D264-44F4-A8AF-1117D8202FBD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85022F8E-D264-44F4-A8AF-1117D8202FBD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/sdk/@launchdarkly/observability-dotnet/README.md b/sdk/@launchdarkly/observability-dotnet/README.md new file mode 100644 index 000000000..c178ef403 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/README.md @@ -0,0 +1,53 @@ +LaunchDarkly Observability Plugin for .Net +=========================== + +[//]: # (These can be uncommented once the links are live.) +[//]: # ([![Actions Status][dotnetplugin-sdk-ci-badge]][dotnetplugin-sdk-ci]) +[//]: # ([![Documentation](https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8)][o11y-docs-link]) +[//]: # ([![NuGet][dotnetplugin-nuget-badge]][dotnetplugin-nuget-link]) + +# Early Access Preview️ + +**NB: APIs are subject to change until a 1.x version is released.** + +## Install + +```shell +dotnet add package LaunchDarkly.Observability +``` + +Install the plugin when configuring your LaunchDarkly SDK. + +```csharp + // TODO: Add example. +``` + +LaunchDarkly overview +------------------------- +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) + +## Contributing + +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +## 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. Check out [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 + * [launchdarkly.com/blog](https://launchdarkly.com/blog/ "LaunchDarkly Blog Documentation") for the latest product updates + +[dotnetplugin-sdk-ci-badge]: https://github.com/launchdarkly/observability-sdk/actions/workflows/dotnet-plugin.yml/badge.svg +[dotnetplugin-sdk-ci]: https://github.com/launchdarkly/observability-sdk/actions/workflows/dotnet-plugin.yml +[o11y-docs-link]: https://launchdarkly.github.io/observability-sdk/sdk/@launchdarkly/observability-dotnet/ +[dotnetplugin-nuget-badge]: https://img.shields.io/nuget/v/LaunchDarkly.Observability.svg?style=flat-square +[dotnetplugin-nuget-link]: https://www.nuget.org/packages/LaunchDarkly.Observability/ \ No newline at end of file diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs new file mode 100644 index 000000000..bedb65f37 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs @@ -0,0 +1,7 @@ +namespace LaunchDarkly.Observability +{ + public class Class1 + { + + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj new file mode 100644 index 000000000..d537d06e7 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj @@ -0,0 +1,38 @@ + + + + + 0.0.0 + + disable + + netstandard2.0;net471;net8.0 + $(BUILDFRAMEWORKS) + 7.3 + + LaunchDarkly Observability for Server-Side .NET SDK + LaunchDarkly + LaunchDarkly + LaunchDarkly + Copyright 2025 Catamorphic, Co + Apache-2.0 + https://github.com/launchdarkly/observability-sdk + https://github.com/launchdarkly/observability-sdk + + + 1570,1571,1572,1573,1574,1580,1581,1584,1591,1710,1711,1712 + + + + + + + + + ../../../../../LaunchDarkly.snk + true + + diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj new file mode 100644 index 000000000..9a0318e63 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + disable + + false + true + LaunchDarkly.Observability.Test + 7.3 + + + + + + + + + + diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/UnitTest1.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/UnitTest1.cs new file mode 100644 index 000000000..457ea3606 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/UnitTest1.cs @@ -0,0 +1,18 @@ +using NUnit.Framework; + +namespace LaunchDarkly.Observability.Test +{ + public class Tests + { + [SetUp] + public void Setup() + { + } + + [Test] + public void Test1() + { + Assert.Pass(); + } + } +} \ No newline at end of file From bbb61dcf0398e69b3b08614fc836ed704a987344 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 6 Aug 2025 13:28:39 -0700 Subject: [PATCH 2/9] Add dotnet setup step and correct working directory. --- .github/workflows/dotnet-plugin.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dotnet-plugin.yml b/.github/workflows/dotnet-plugin.yml index 738fc5148..b7db56adc 100644 --- a/.github/workflows/dotnet-plugin.yml +++ b/.github/workflows/dotnet-plugin.yml @@ -15,11 +15,15 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 + with: + dotnet-version: '8.0.x' + - run: dotnet restore - working-directory: sdk/launchdarkly/observability-dotnet + working-directory: sdk/@launchdarkly/observability-dotnet - run: dotnet build --no-restore - working-directory: sdk/launchdarkly/observability-dotnet + working-directory: sdk/@launchdarkly/observability-dotnet - run: dotnet test --no-restore - working-directory: sdk/launchdarkly/observability-dotnet + working-directory: sdk/@launchdarkly/observability-dotnet From 7bb94968d92b5487760b76b3a1070feab7d90af8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 6 Aug 2025 13:29:43 -0700 Subject: [PATCH 3/9] Explicit permissions. --- .github/workflows/dotnet-plugin.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/dotnet-plugin.yml b/.github/workflows/dotnet-plugin.yml index b7db56adc..e4dd23a82 100644 --- a/.github/workflows/dotnet-plugin.yml +++ b/.github/workflows/dotnet-plugin.yml @@ -8,6 +8,8 @@ on: paths: - 'sdk/@launchdarkly/observability-dotnet/**' - '.github/workflows/dotnet-plugin.yml' +permissions: + contents: read jobs: build-and-test: From f694eb1d0eea85dccf5548bd8a3563d0b9b156aa Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:08:02 -0700 Subject: [PATCH 4/9] Reformat workflow file. --- .github/workflows/dotnet-plugin.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-plugin.yml b/.github/workflows/dotnet-plugin.yml index e4dd23a82..0130e4dbb 100644 --- a/.github/workflows/dotnet-plugin.yml +++ b/.github/workflows/dotnet-plugin.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 with: - dotnet-version: '8.0.x' + dotnet-version: '8.0.x' - run: dotnet restore working-directory: sdk/@launchdarkly/observability-dotnet From f9544cdfbe5546686b1e365b3eae41d49787487d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:42:44 -0700 Subject: [PATCH 5/9] Add project reference to implementation from tests. --- .../LaunchDarkly.Observability.Tests.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj index 9a0318e63..6e04f14ba 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj @@ -17,4 +17,8 @@ + + + + From a012647e42cc47d2abf914c13796e5c54b292302 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:14:55 -0700 Subject: [PATCH 6/9] feat: Add basic configuration and otel. --- .../observability-dotnet/.gitignore | 2 + .../AspSampleApp/AspSampleApp.csproj | 18 ++ .../AspSampleApp/AspSampleApp.http | 6 + .../AspSampleApp/Program.cs | 54 ++++++ .../AspSampleApp/appsettings.Development.json | 8 + .../AspSampleApp/appsettings.json | 9 + .../LaunchDarkly.Observability.sln | 6 + .../src/LaunchDarkly.Observability/Class1.cs | 7 - .../LaunchDarkly.Observability.csproj | 29 ++- .../ObservabilityConfig.cs | 165 ++++++++++++++++++ .../ObservabilityExtensions.cs | 119 +++++++++++++ .../ObservabilityConfigBuilderTests.cs | 101 +++++++++++ 12 files changed, 516 insertions(+), 8 deletions(-) create mode 100644 sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.csproj create mode 100644 sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.http create mode 100644 sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/AspSampleApp/appsettings.Development.json create mode 100644 sdk/@launchdarkly/observability-dotnet/AspSampleApp/appsettings.json delete mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityConfigBuilderTests.cs diff --git a/sdk/@launchdarkly/observability-dotnet/.gitignore b/sdk/@launchdarkly/observability-dotnet/.gitignore index f181d71cc..c18720f8b 100644 --- a/sdk/@launchdarkly/observability-dotnet/.gitignore +++ b/sdk/@launchdarkly/observability-dotnet/.gitignore @@ -1,2 +1,4 @@ **/bin **/obj +LaunchDarkly.Observability.sln.DotSettings.user +**/launchSettings.json diff --git a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.csproj b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.csproj new file mode 100644 index 000000000..bd1fbb1ed --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.http b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.http new file mode 100644 index 000000000..e87b02e0b --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.http @@ -0,0 +1,6 @@ +@AspSampleApp_HostAddress = http://localhost:5247 + +GET {{AspSampleApp_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs new file mode 100644 index 000000000..750ce08a6 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs @@ -0,0 +1,54 @@ +using LaunchDarkly.Observability; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddLaunchDarklyObservability( + Environment.GetEnvironmentVariable("LAUNCHDARKLY_SDK_KEY"), + ldBuilder => + { + ldBuilder.WithServiceName("ryan-test-service"); + } +); + + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => + { + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; + }) + .WithName("GetWeatherForecast") + .WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} \ No newline at end of file diff --git a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/appsettings.Development.json b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/appsettings.json b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/sdk/@launchdarkly/observability-dotnet/LaunchDarkly.Observability.sln b/sdk/@launchdarkly/observability-dotnet/LaunchDarkly.Observability.sln index 8c8b79796..34e985177 100644 --- a/sdk/@launchdarkly/observability-dotnet/LaunchDarkly.Observability.sln +++ b/sdk/@launchdarkly/observability-dotnet/LaunchDarkly.Observability.sln @@ -12,6 +12,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{25034066-0 README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspSampleApp", "AspSampleApp\AspSampleApp.csproj", "{84C42307-BD0F-4558-84C5-6E9B06932D8E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -29,5 +31,9 @@ Global {85022F8E-D264-44F4-A8AF-1117D8202FBD}.Debug|Any CPU.Build.0 = Debug|Any CPU {85022F8E-D264-44F4-A8AF-1117D8202FBD}.Release|Any CPU.ActiveCfg = Release|Any CPU {85022F8E-D264-44F4-A8AF-1117D8202FBD}.Release|Any CPU.Build.0 = Release|Any CPU + {84C42307-BD0F-4558-84C5-6E9B06932D8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84C42307-BD0F-4558-84C5-6E9B06932D8E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84C42307-BD0F-4558-84C5-6E9B06932D8E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84C42307-BD0F-4558-84C5-6E9B06932D8E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs deleted file mode 100644 index bedb65f37..000000000 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace LaunchDarkly.Observability -{ - public class Class1 - { - - } -} diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj index d537d06e7..59f5bd4c8 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj @@ -1,4 +1,4 @@ - + @@ -35,4 +35,31 @@ ../../../../../LaunchDarkly.snk true + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs new file mode 100644 index 000000000..e438450ac --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs @@ -0,0 +1,165 @@ +namespace LaunchDarkly.Observability +{ + public struct ObservabilityConfig + { + /// + /// The configured OTLP endpoint. + /// + public string OtlpEndpoint { get; } + + /// + /// The configured back-end URL. + /// + /// This is used for non-telemetry operations such as accessing the sampling configuration. + /// + /// + public string BackendUrl { get; } + /// + /// The name of the service. + /// + /// The service name is used for adding resource attributes. If a service name is not defined, then the + /// service version will also not be included in the resource attributes. + /// + /// + public string ServiceName { get; } + /// + /// The version of the service. + /// + public string ServiceVersion { get; } + /// + /// The environment for the service. + /// + public string Environment { get; } + /// + /// The LaunchDarkly SDK key. + /// + public string SdkKey { get; } + + private ObservabilityConfig( + string otlpEndpoint, + string backendUrl, + string serviceName, + string environment, + string serviceVersion, + string sdkKey) + { + OtlpEndpoint = otlpEndpoint; + BackendUrl = backendUrl; + ServiceName = serviceName; + Environment = environment; + ServiceVersion = serviceVersion; + SdkKey = sdkKey; + } + + /// + /// Create a new builder for . + /// + /// The LaunchDarkly SDK key used for authentication and resource attributes. + /// A new instance for configuring observability. + internal static Builder CreateBuilder(string sdkKey) => new Builder(sdkKey); + + /// + /// Fluent builder for . + /// + public sealed class Builder + { + private const string DefaultOtlpEndpoint = "https://otel.observability.app.launchdarkly.com:4318"; + private const string DefaultBackendUrl = "https://pub.observability.app.launchdarkly.com"; + private string _otlpEndpoint = DefaultOtlpEndpoint; + private string _backendUrl = DefaultBackendUrl; + private string _serviceName = string.Empty; + private string _environment = string.Empty; + private string _serviceVersion = string.Empty; + private readonly string _sdkKey; + + internal Builder(string sdkKey) + { + this._sdkKey = sdkKey; + } + + /// + /// Set the OTLP endpoint. + /// + /// For most configurations, the OTLP endpoint will not need to be set. + /// + /// + /// Setting the endpoint to null will reset the builder value to the default. + /// + /// + /// The OTLP exporter endpoint URL. + /// A reference to this builder. + public Builder WithOtlpEndpoint(string otlpEndpoint) + { + _otlpEndpoint = otlpEndpoint ?? DefaultOtlpEndpoint; + return this; + } + + /// + /// Set the back-end URL for non-telemetry operations. + /// + /// For most configurations, the backend url will not need to be set. + /// + /// + /// Setting the url to null will reset the builder value to the default. + /// + /// + /// The back-end URL used for non-telemetry operations. + /// A reference to this builder. + public Builder WithBackendUrl(string backendUrl) + { + _backendUrl = backendUrl ?? DefaultBackendUrl; + return this; + } + + /// + /// Set the service name. + /// + /// The logical service name used in telemetry resource attributes. + /// A reference to this builder. + public Builder WithServiceName(string serviceName) + { + _serviceName = serviceName ?? string.Empty; + return this; + } + + /// + /// Set the service version. + /// + /// + /// The version of the service that will be added to resource attributes when a service name is provided. + /// + /// A reference to this builder. + public Builder WithServiceVersion(string serviceVersion) + { + _serviceVersion = serviceVersion ?? string.Empty; + return this; + } + + /// + /// Set the environment name. + /// + /// The environment name (for example, "prod" or "staging"). + /// A reference to this builder. + public Builder WithEnvironment(string environment) + { + _environment = environment ?? string.Empty; + return this; + } + + /// + /// Build an immutable instance. + /// + /// The constructed . + public ObservabilityConfig Build() + { + return new ObservabilityConfig( + _otlpEndpoint, + _backendUrl, + _serviceName, + _environment, + _serviceVersion, + _sdkKey); + } + } + } +} \ No newline at end of file diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs new file mode 100644 index 000000000..50155733a --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using OpenTelemetry; +using OpenTelemetry.Exporter; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; + +namespace LaunchDarkly.Observability { + /// + /// Static class containing extension methods for configuring observability + /// + public static class ObservabilityExtensions + { + private const OtlpExportProtocol ExportProtocol = OtlpExportProtocol.HttpProtobuf; + private const int FlushIntervalMs = 5 * 1000; + private const int MaxExportBatchSize = 10000; + private const int MaxQueueSize = 10000; + + private const string TracesPath = "/v1/traces"; + private const string LogsPath = "/v1/logs"; + private const string MetricsPath = "/v1/metrics"; + private static IEnumerable> GetResourceAttributes(ObservabilityConfig config) + { + var attrs = new List>(); + + if (!string.IsNullOrWhiteSpace(config.Environment)) + { + attrs.Add(new KeyValuePair("deployment.environment.name", config.Environment)); + } + + attrs.Add(new KeyValuePair("highlight.project_id", config.SdkKey)); + + return attrs; + } + + /// + /// Add the LaunchDarkly Observability services. This function would typically be called by the LaunchDarkly + /// Observability plugin. This should only be called by the end user if the Observability plugin needs to be + /// initialized earlier than the LaunchDarkly client. + /// + /// The service collection + /// The LaunchDarkly SDK + /// A method to configure the services + /// The service collection + public static IServiceCollection AddLaunchDarklyObservability( + this IServiceCollection services, + string sdkKey, + Action configure) + { + var builder = ObservabilityConfig.CreateBuilder(sdkKey); + configure(builder); + + var config = builder.Build(); + var resourceAttributes = GetResourceAttributes(config); + + var resourceBuilder = ResourceBuilder.CreateDefault(); + if (!string.IsNullOrWhiteSpace(config.ServiceName)) + { + resourceBuilder.AddService(config.ServiceName, serviceVersion: config.ServiceVersion); + resourceBuilder.AddAttributes(resourceAttributes); + } + + services.AddOpenTelemetry().WithTracing(tracing => + { + + tracing.SetResourceBuilder(resourceBuilder) + .AddHttpClientInstrumentation() + .AddGrpcClientInstrumentation() + .AddWcfInstrumentation() + .AddQuartzInstrumentation() + .AddAspNetCoreInstrumentation(options => + { + options.RecordException = true; + }) + .AddSqlClientInstrumentation(options => + { + options.SetDbStatementForText = true; + }) + .AddOtlpExporter(options => + { + options.Endpoint = new Uri(config.OtlpEndpoint + TracesPath); + options.Protocol = ExportProtocol; + options.BatchExportProcessorOptions.MaxExportBatchSize = MaxExportBatchSize; + options.BatchExportProcessorOptions.MaxQueueSize = MaxQueueSize; + options.BatchExportProcessorOptions.ScheduledDelayMilliseconds = FlushIntervalMs; + }); + }).WithLogging(logging => + { + logging.SetResourceBuilder(resourceBuilder) + .AddOtlpExporter(options => + { + options.Endpoint = new Uri(config.OtlpEndpoint + LogsPath); + options.Protocol = ExportProtocol; + options.BatchExportProcessorOptions.MaxExportBatchSize = MaxExportBatchSize; + options.BatchExportProcessorOptions.MaxQueueSize = MaxQueueSize; + options.BatchExportProcessorOptions.ScheduledDelayMilliseconds = FlushIntervalMs; + }); + }).WithMetrics(metrics => + { + metrics.SetResourceBuilder(resourceBuilder) + .AddMeter(config.ServiceName) + .AddRuntimeInstrumentation() + .AddProcessInstrumentation() + .AddHttpClientInstrumentation() + .AddAspNetCoreInstrumentation() + .AddSqlClientInstrumentation() + .AddReader(new PeriodicExportingMetricReader(new OtlpMetricExporter(new OtlpExporterOptions + { + Endpoint = new Uri(config.OtlpEndpoint + MetricsPath), + Protocol = ExportProtocol + }))); + }); + return services; + } + } +} \ No newline at end of file diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityConfigBuilderTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityConfigBuilderTests.cs new file mode 100644 index 000000000..dbb769223 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityConfigBuilderTests.cs @@ -0,0 +1,101 @@ +using LaunchDarkly.Observability; +using NUnit.Framework; + +namespace LaunchDarkly.Observability.Test +{ + [TestFixture] + public class ObservabilityConfigBuilderTests + { + [Test] + public void Build_WithAllFields_SetsValues() + { + var config = ObservabilityConfig.CreateBuilder("sdk-123") + .WithOtlpEndpoint("https://otlp.example.com") + .WithBackendUrl("https://backend.example.com") + .WithServiceName("service-a") + .WithServiceVersion("1.0.0") + .WithEnvironment("prod") + .Build(); + + Assert.Multiple(() => + { + Assert.That(config.OtlpEndpoint, Is.EqualTo("https://otlp.example.com")); + Assert.That(config.BackendUrl, Is.EqualTo("https://backend.example.com")); + Assert.That(config.ServiceName, Is.EqualTo("service-a")); + Assert.That(config.ServiceVersion, Is.EqualTo("1.0.0")); + Assert.That(config.Environment, Is.EqualTo("prod")); + Assert.That(config.SdkKey, Is.EqualTo("sdk-123")); + }); + } + + [Test] + public void Build_WithoutSettingFields_UsesDefaults() + { + var config = ObservabilityConfig.CreateBuilder("sdk-xyz").Build(); + + Assert.Multiple(() => + { + Assert.That(config.OtlpEndpoint, Is.EqualTo("https://otel.observability.app.launchdarkly.com:4318")); + Assert.That(config.BackendUrl, Is.EqualTo("https://pub.observability.app.launchdarkly.com")); + Assert.That(config.ServiceName, Is.EqualTo(string.Empty)); + Assert.That(config.ServiceVersion, Is.EqualTo(string.Empty)); + Assert.That(config.Environment, Is.EqualTo(string.Empty)); + Assert.That(config.SdkKey, Is.EqualTo("sdk-xyz")); + }); + } + + [Test] + public void WithMethods_HandleNullValues_ResetsToDefaults() + { + var config = ObservabilityConfig.CreateBuilder("sdk-null") + .WithOtlpEndpoint(null) + .WithBackendUrl(null) + .WithServiceName(null) + .WithServiceVersion(null) + .WithEnvironment(null) + .Build(); + + Assert.Multiple(() => + { + Assert.That(config.OtlpEndpoint, Is.EqualTo("https://otel.observability.app.launchdarkly.com:4318")); + Assert.That(config.BackendUrl, Is.EqualTo("https://pub.observability.app.launchdarkly.com")); + Assert.That(config.ServiceName, Is.EqualTo(string.Empty)); + Assert.That(config.ServiceVersion, Is.EqualTo(string.Empty)); + Assert.That(config.Environment, Is.EqualTo(string.Empty)); + Assert.That(config.SdkKey, Is.EqualTo("sdk-null")); + }); + } + + [Test] + public void Build_ProducesImmutableConfig() + { + var builder = ObservabilityConfig.CreateBuilder("sdk-immutable") + .WithOtlpEndpoint("e1") + .WithBackendUrl("b1") + .WithServiceName("s1") + .WithServiceVersion("v1") + .WithEnvironment("env1"); + + var first = builder.Build(); + + // Change builder afterward + builder + .WithOtlpEndpoint("e2") + .WithBackendUrl("b2") + .WithServiceName("s2") + .WithServiceVersion("v2") + .WithEnvironment("env2"); + + Assert.Multiple(() => + { + // Previously built config remains unchanged + Assert.That(first.OtlpEndpoint, Is.EqualTo("e1")); + Assert.That(first.BackendUrl, Is.EqualTo("b1")); + Assert.That(first.ServiceName, Is.EqualTo("s1")); + Assert.That(first.ServiceVersion, Is.EqualTo("v1")); + Assert.That(first.Environment, Is.EqualTo("env1")); + Assert.That(first.SdkKey, Is.EqualTo("sdk-immutable")); + }); + } + } +} \ No newline at end of file From a118a1883932250e299ffeee5977de88a3875afe Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 11 Aug 2025 09:09:00 -0700 Subject: [PATCH 7/9] WIP Plugin --- .../ObservabilityPlugin.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityPlugin.cs diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityPlugin.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityPlugin.cs new file mode 100644 index 000000000..5a72e5d21 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityPlugin.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Sdk.Integrations.Plugins; +using LaunchDarkly.Sdk.Server.Hooks; +using LaunchDarkly.Sdk.Server.Interfaces; +using LaunchDarkly.Sdk.Server.Plugins; +using LaunchDarkly.Sdk.Server.Telemetry; +using Microsoft.Extensions.DependencyInjection; + +namespace LaunchDarkly.Observability +{ + public class ObservabilityPlugin : Plugin + { + private readonly Action _configure; + private readonly IServiceCollection _services; + + public static ObservabilityPlugin WithServices(IServiceCollection services, + Action configure) => new ObservabilityPlugin(services, configure); + + public static ObservabilityPlugin () => new ObservabilityPlugin() + + internal ObservabilityPlugin(IServiceCollection services, Action configure) : base("LaunchDarkly.Observability") + { + _configure = configure; + _services = services; + } + + internal ObservabilityPlugin() : base("LaunchDarkly.Observability") + { + _services = null; + _configure = null; + } + + public override void Register(ILdClient client, EnvironmentMetadata metadata) + { + if (_services != null) + { + _services.AddLaunchDarklyObservability(metadata.Credential, _configure); + } + } + + public override IList GetHooks(EnvironmentMetadata metadata) + { + return new List + { + TracingHook.Builder().IncludeVariant().Build() + }; + } + } +} \ No newline at end of file From e75a68fca88d04b6df21aabeb5135c4040f83033 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 12 Aug 2025 09:52:00 -0700 Subject: [PATCH 8/9] feat: Add initial plugin for dotnet. --- .../AspSampleApp/AspSampleApp.csproj | 1 + .../AspSampleApp/Program.cs | 26 ++-- .../LaunchDarkly.Observability/BaseBuilder.cs | 116 ++++++++++++++++++ .../src/LaunchDarkly.Observability/Class1.cs | 7 -- .../LaunchDarkly.Observability.csproj | 2 +- .../ObservabilityConfig.cs | 114 +++-------------- .../ObservabilityExtensions.cs | 71 +++++------ .../ObservabilityPlugin.cs | 91 +++++++++++--- .../ObservabilityConfigBuilderTests.cs | 18 +-- .../ObservabilityPluginBuilderTests.cs | 69 +++++++++++ .../UnitTest1.cs | 18 --- 11 files changed, 339 insertions(+), 194 deletions(-) create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/BaseBuilder.cs delete mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs delete mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/UnitTest1.cs diff --git a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.csproj b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.csproj index bd1fbb1ed..85a10af52 100644 --- a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.csproj +++ b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/AspSampleApp.csproj @@ -7,6 +7,7 @@ + diff --git a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs index de3580e5a..273227309 100644 --- a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs +++ b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs @@ -1,20 +1,24 @@ using LaunchDarkly.Observability; +using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Server; +using LaunchDarkly.Sdk.Server.Integrations; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddLaunchDarklyObservability( - Environment.GetEnvironmentVariable("LAUNCHDARKLY_SDK_KEY"), - ldBuilder => - { - ldBuilder.WithServiceName("ryan-test-service"); - } -); +var config = Configuration.Builder(Environment.GetEnvironmentVariable("LAUNCHDARKLY_SDK_KEY")) + .Plugins(new PluginConfigurationBuilder() + .Add(ObservabilityPlugin.Builder(builder.Services) + .WithServiceName("ryan-test-service") + .WithServiceVersion("0.0.0") + .Build())).Build(); + +// Building the LdClient with the Observability plugin. This line will add services to the web application. +var client = new LdClient(config); +// Client must be built before this line. var app = builder.Build(); // Configure the HTTP request pipeline. @@ -33,11 +37,13 @@ app.MapGet("/weatherforecast", () => { + var isMercury = + client.BoolVariation("isMercury", Context.New(ContextKind.Of("request"), Guid.NewGuid().ToString())); var forecast = Enumerable.Range(1, 5).Select(index => new WeatherForecast ( DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), + Random.Shared.Next(isMercury ? -170 : -20, isMercury ? 400 : 55), summaries[Random.Shared.Next(summaries.Length)] )) .ToArray(); diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/BaseBuilder.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/BaseBuilder.cs new file mode 100644 index 000000000..b5f6655ec --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/BaseBuilder.cs @@ -0,0 +1,116 @@ +using System; + +namespace LaunchDarkly.Observability +{ + /// + /// Base builder which allows for methods to be shared between building a config directly and building a plugin. + /// + /// This uses the CRTP pattern to allow the individual builder methods to return instances of the derived builder + /// type. + /// + /// + public class BaseBuilder where TBuilder : BaseBuilder + { + private const string DefaultOtlpEndpoint = "https://otel.observability.app.launchdarkly.com:4318"; + private const string DefaultBackendUrl = "https://pub.observability.app.launchdarkly.com"; + private string _otlpEndpoint = DefaultOtlpEndpoint; + private string _backendUrl = DefaultBackendUrl; + private string _serviceName = string.Empty; + private string _environment = string.Empty; + private string _serviceVersion = string.Empty; + + protected BaseBuilder() + { + } + + /// + /// Set the OTLP endpoint. + /// + /// For most configurations, the OTLP endpoint will not need to be set. + /// + /// + /// Setting the endpoint to null will reset the builder value to the default. + /// + /// + /// The OTLP exporter endpoint URL. + /// A reference to this builder. + public TBuilder WithOtlpEndpoint(string otlpEndpoint) + { + _otlpEndpoint = otlpEndpoint ?? DefaultOtlpEndpoint; + return (TBuilder)this; + } + + /// + /// Set the back-end URL for non-telemetry operations. + /// + /// For most configurations, the backend url will not need to be set. + /// + /// + /// Setting the url to null will reset the builder value to the default. + /// + /// + /// The back-end URL used for non-telemetry operations. + /// A reference to this builder. + public TBuilder WithBackendUrl(string backendUrl) + { + _backendUrl = backendUrl ?? DefaultBackendUrl; + return (TBuilder)this; + } + + /// + /// Set the service name. + /// + /// The logical service name used in telemetry resource attributes. + /// A reference to this builder. + public TBuilder WithServiceName(string serviceName) + { + _serviceName = serviceName ?? string.Empty; + return (TBuilder)this; + } + + /// + /// Set the service version. + /// + /// + /// The version of the service that will be added to resource attributes when a service name is provided. + /// + /// A reference to this builder. + public TBuilder WithServiceVersion(string serviceVersion) + { + _serviceVersion = serviceVersion ?? string.Empty; + return (TBuilder)this; + } + + /// + /// Set the environment name. + /// + /// The environment name (for example, "prod" or "staging"). + /// A reference to this builder. + public TBuilder WithEnvironment(string environment) + { + _environment = environment ?? string.Empty; + return (TBuilder)this; + } + + /// + /// Build an immutable instance. + /// + /// The constructed . + internal ObservabilityConfig BuildConfig(string sdkKey) + { + if (sdkKey == null) + { + throw new ArgumentNullException(nameof(sdkKey), + "SDK key cannot be null when creating an ObservabilityConfig builder."); + } + + return new ObservabilityConfig( + _otlpEndpoint, + _backendUrl, + _serviceName, + _environment, + _serviceVersion, + sdkKey); + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs deleted file mode 100644 index bedb65f37..000000000 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Class1.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace LaunchDarkly.Observability -{ - public class Class1 - { - - } -} diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj index 571a94c90..7a695c14f 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj @@ -54,7 +54,7 @@ - + diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs index 069dbec81..81f094e1d 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs @@ -1,3 +1,5 @@ +using System; + namespace LaunchDarkly.Observability { public struct ObservabilityConfig @@ -6,7 +8,7 @@ public struct ObservabilityConfig /// The configured OTLP endpoint. /// public string OtlpEndpoint { get; } - + /// /// The configured back-end URL. /// @@ -14,6 +16,7 @@ public struct ObservabilityConfig /// /// public string BackendUrl { get; } + /// /// The name of the service. /// @@ -22,20 +25,23 @@ public struct ObservabilityConfig /// /// public string ServiceName { get; } + /// /// The version of the service. /// public string ServiceVersion { get; } + /// /// The environment for the service. /// public string Environment { get; } + /// /// The LaunchDarkly SDK key. /// public string SdkKey { get; } - private ObservabilityConfig( + internal ObservabilityConfig( string otlpEndpoint, string backendUrl, string serviceName, @@ -54,111 +60,21 @@ private ObservabilityConfig( /// /// Create a new builder for . /// - /// The LaunchDarkly SDK key used for authentication and resource attributes. - /// A new instance for configuring observability. - internal static Builder CreateBuilder(string sdkKey) => new Builder(sdkKey); + /// A new instance for configuring observability. + internal static ObservabilityConfigBuilder Builder() => new ObservabilityConfigBuilder(); /// - /// Fluent builder for . + /// Builder for building an observability configuration. /// - public sealed class Builder + public class ObservabilityConfigBuilder : BaseBuilder { - private const string DefaultOtlpEndpoint = "https://otel.observability.app.launchdarkly.com:4318"; - private const string DefaultBackendUrl = "https://pub.observability.app.launchdarkly.com"; - private string _otlpEndpoint = DefaultOtlpEndpoint; - private string _backendUrl = DefaultBackendUrl; - private string _serviceName = string.Empty; - private string _environment = string.Empty; - private string _serviceVersion = string.Empty; - private readonly string _sdkKey; - - internal Builder(string sdkKey) - { - this._sdkKey = sdkKey; - } - - /// - /// Set the OTLP endpoint. - /// - /// For most configurations, the OTLP endpoint will not need to be set. - /// - /// - /// Setting the endpoint to null will reset the builder value to the default. - /// - /// - /// The OTLP exporter endpoint URL. - /// A reference to this builder. - public Builder WithOtlpEndpoint(string otlpEndpoint) - { - _otlpEndpoint = otlpEndpoint ?? DefaultOtlpEndpoint; - return this; - } - - /// - /// Set the back-end URL for non-telemetry operations. - /// - /// For most configurations, the backend url will not need to be set. - /// - /// - /// Setting the url to null will reset the builder value to the default. - /// - /// - /// The back-end URL used for non-telemetry operations. - /// A reference to this builder. - public Builder WithBackendUrl(string backendUrl) - { - _backendUrl = backendUrl ?? DefaultBackendUrl; - return this; - } - - /// - /// Set the service name. - /// - /// The logical service name used in telemetry resource attributes. - /// A reference to this builder. - public Builder WithServiceName(string serviceName) - { - _serviceName = serviceName ?? string.Empty; - return this; - } - - /// - /// Set the service version. - /// - /// - /// The version of the service that will be added to resource attributes when a service name is provided. - /// - /// A reference to this builder. - public Builder WithServiceVersion(string serviceVersion) - { - _serviceVersion = serviceVersion ?? string.Empty; - return this; - } - - /// - /// Set the environment name. - /// - /// The environment name (for example, "prod" or "staging"). - /// A reference to this builder. - public Builder WithEnvironment(string environment) + internal ObservabilityConfigBuilder() { - _environment = environment ?? string.Empty; - return this; } - /// - /// Build an immutable instance. - /// - /// The constructed . - public ObservabilityConfig Build() + internal ObservabilityConfig Build(string sdkKey) { - return new ObservabilityConfig( - _otlpEndpoint, - _backendUrl, - _serviceName, - _environment, - _serviceVersion, - _sdkKey); + return BuildConfig(sdkKey); } } } diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs index 7ab3ad50a..3be743a3c 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs @@ -8,7 +8,8 @@ using OpenTelemetry.Logs; using OpenTelemetry.Metrics; -namespace LaunchDarkly.Observability { +namespace LaunchDarkly.Observability +{ /// /// Static class containing extension methods for configuring observability /// @@ -22,38 +23,24 @@ public static class ObservabilityExtensions private const string TracesPath = "/v1/traces"; private const string LogsPath = "/v1/logs"; private const string MetricsPath = "/v1/metrics"; - private static IEnumerable> GetResourceAttributes(ObservabilityConfig config) + + private static IEnumerable> GetResourceAttributes(ObservabilityConfig config) { - var attrs = new List>(); + var attrs = new List>(); - if (!string.IsNullOrWhiteSpace(config.Environment)) - { - attrs.Add(new KeyValuePair("deployment.environment.name", config.Environment)); - } + if (!string.IsNullOrWhiteSpace(config.Environment)) + { + attrs.Add(new KeyValuePair("deployment.environment.name", config.Environment)); + } - attrs.Add(new KeyValuePair("highlight.project_id", config.SdkKey)); + attrs.Add(new KeyValuePair("highlight.project_id", config.SdkKey)); - return attrs; + return attrs; } - /// - /// Add the LaunchDarkly Observability services. This function would typically be called by the LaunchDarkly - /// Observability plugin. This should only be called by the end user if the Observability plugin needs to be - /// initialized earlier than the LaunchDarkly client. - /// - /// The service collection - /// The LaunchDarkly SDK - /// A method to configure the services - /// The service collection - public static IServiceCollection AddLaunchDarklyObservability( - this IServiceCollection services, - string sdkKey, - Action configure) + internal static void AddLaunchDarklyObservabilityWithConfig(this IServiceCollection services, + ObservabilityConfig config) { - var builder = ObservabilityConfig.CreateBuilder(sdkKey); - configure(builder); - - var config = builder.Build(); var resourceAttributes = GetResourceAttributes(config); var resourceBuilder = ResourceBuilder.CreateDefault(); @@ -65,20 +52,13 @@ public static IServiceCollection AddLaunchDarklyObservability( services.AddOpenTelemetry().WithTracing(tracing => { - tracing.SetResourceBuilder(resourceBuilder) .AddHttpClientInstrumentation() .AddGrpcClientInstrumentation() .AddWcfInstrumentation() .AddQuartzInstrumentation() - .AddAspNetCoreInstrumentation(options => - { - options.RecordException = true; - }) - .AddSqlClientInstrumentation(options => - { - options.SetDbStatementForText = true; - }) + .AddAspNetCoreInstrumentation(options => { options.RecordException = true; }) + .AddSqlClientInstrumentation(options => { options.SetDbStatementForText = true; }) .AddOtlpExporter(options => { options.Endpoint = new Uri(config.OtlpEndpoint + TracesPath); @@ -113,6 +93,27 @@ public static IServiceCollection AddLaunchDarklyObservability( Protocol = ExportProtocol }))); }); + } + + /// + /// Add the LaunchDarkly Observability services. This function would typically be called by the LaunchDarkly + /// Observability plugin. This should only be called by the end user if the Observability plugin needs to be + /// initialized earlier than the LaunchDarkly client. + /// + /// The service collection + /// The LaunchDarkly SDK + /// A method to configure the services + /// The service collection + public static IServiceCollection AddLaunchDarklyObservability( + this IServiceCollection services, + string sdkKey, + Action configure) + { + var builder = ObservabilityConfig.Builder(); + configure(builder); + + var config = builder.Build(sdkKey); + AddLaunchDarklyObservabilityWithConfig(services, config); return services; } } diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityPlugin.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityPlugin.cs index 5a72e5d21..cf010bcbd 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityPlugin.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityPlugin.cs @@ -11,40 +11,101 @@ namespace LaunchDarkly.Observability { public class ObservabilityPlugin : Plugin { - private readonly Action _configure; + private readonly ObservabilityPluginBuilder _config; private readonly IServiceCollection _services; - public static ObservabilityPlugin WithServices(IServiceCollection services, - Action configure) => new ObservabilityPlugin(services, configure); - - public static ObservabilityPlugin () => new ObservabilityPlugin() - - internal ObservabilityPlugin(IServiceCollection services, Action configure) : base("LaunchDarkly.Observability") + /// + /// Construct a plugin which is intended to be used with already configured observability services. + /// + /// In a typical configuration, this method will not need to be used. + /// + /// + /// This method only needs to be used when observability related functionality must be intialized before it + /// is possible to initialize the LaunchDarkly SDK. + /// + /// + /// an observability plugin instance + public static ObservabilityPlugin ForExistingServices() => new ObservabilityPlugin(); + + /// + /// Create a new builder for . + /// + /// When using this builder, LaunchDarkly client must be constructed before your application is built. + /// For example: + /// + /// var builder = WebApplication.CreateBuilder(args); + /// + /// + /// var config = Configuration.Builder(Environment.GetEnvironmentVariable("your-sdk-key") + /// .Plugins(new PluginConfigurationBuilder() + /// .Add(ObservabilityPlugin.Builder(builder.Services) + /// .WithServiceName("ryan-test-service") + /// .WithServiceVersion("0.0.0") + /// .Build())).Build(); + /// // Building the LdClient with the Observability plugin. This line will add services to the web application. + /// var client = new LdClient(config); + /// + /// // Client must be built before this line. + /// var app = builder.Build(); + /// + /// + /// + /// The service collection for dependency injection. + /// A new instance for configuring the observability plugin. + public static ObservabilityPluginBuilder Builder(IServiceCollection services) => + new ObservabilityPluginBuilder(services); + + internal ObservabilityPlugin(IServiceCollection services, ObservabilityPluginBuilder config) : base( + "LaunchDarkly.Observability") { - _configure = configure; + _config = config; _services = services; } internal ObservabilityPlugin() : base("LaunchDarkly.Observability") { _services = null; - _configure = null; + _config = null; } + /// public override void Register(ILdClient client, EnvironmentMetadata metadata) { - if (_services != null) - { - _services.AddLaunchDarklyObservability(metadata.Credential, _configure); - } + if (_services == null || _config == null) return; + var config = _config.BuildConfig(metadata.Credential); + _services.AddLaunchDarklyObservabilityWithConfig(config); } + /// public override IList GetHooks(EnvironmentMetadata metadata) { return new List { - TracingHook.Builder().IncludeVariant().Build() + TracingHook.Builder().IncludeValue().Build() }; } + + /// + /// Used to build an instance of the Observability Plugin. + /// + public sealed class ObservabilityPluginBuilder : BaseBuilder + { + private readonly IServiceCollection _services; + + internal ObservabilityPluginBuilder(IServiceCollection services) : base() + { + _services = services ?? throw new ArgumentNullException(nameof(services), + "Service collection cannot be null when creating an ObservabilityPlugin builder."); + } + + /// + /// Build an instance with the configured settings. + /// + /// The constructed . + public ObservabilityPlugin Build() + { + return new ObservabilityPlugin(_services, this); + } + } } -} \ No newline at end of file +} diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityConfigBuilderTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityConfigBuilderTests.cs index 6d2dddbd9..cc80fbc1b 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityConfigBuilderTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityConfigBuilderTests.cs @@ -9,13 +9,13 @@ public class ObservabilityConfigBuilderTests [Test] public void Build_WithAllFields_SetsValues() { - var config = ObservabilityConfig.CreateBuilder("sdk-123") + var config = ObservabilityConfig.Builder() .WithOtlpEndpoint("https://otlp.example.com") .WithBackendUrl("https://backend.example.com") .WithServiceName("service-a") .WithServiceVersion("1.0.0") .WithEnvironment("prod") - .Build(); + .Build("sdk-123"); Assert.Multiple(() => { @@ -31,7 +31,7 @@ public void Build_WithAllFields_SetsValues() [Test] public void Build_WithoutSettingFields_UsesDefaults() { - var config = ObservabilityConfig.CreateBuilder("sdk-xyz").Build(); + var config = ObservabilityConfig.Builder().Build("sdk-xyz"); Assert.Multiple(() => { @@ -47,13 +47,13 @@ public void Build_WithoutSettingFields_UsesDefaults() [Test] public void WithMethods_HandleNullValues_ResetsToDefaults() { - var config = ObservabilityConfig.CreateBuilder("sdk-null") + var config = ObservabilityConfig.Builder() .WithOtlpEndpoint(null) .WithBackendUrl(null) .WithServiceName(null) .WithServiceVersion(null) .WithEnvironment(null) - .Build(); + .Build("my-sdk-key"); Assert.Multiple(() => { @@ -62,21 +62,21 @@ public void WithMethods_HandleNullValues_ResetsToDefaults() Assert.That(config.ServiceName, Is.EqualTo(string.Empty)); Assert.That(config.ServiceVersion, Is.EqualTo(string.Empty)); Assert.That(config.Environment, Is.EqualTo(string.Empty)); - Assert.That(config.SdkKey, Is.EqualTo("sdk-null")); + Assert.That(config.SdkKey, Is.EqualTo("my-sdk-key")); }); } [Test] public void Build_ProducesImmutableConfig() { - var builder = ObservabilityConfig.CreateBuilder("sdk-immutable") + var builder = ObservabilityConfig.Builder() .WithOtlpEndpoint("e1") .WithBackendUrl("b1") .WithServiceName("s1") .WithServiceVersion("v1") .WithEnvironment("env1"); - var first = builder.Build(); + var first = builder.Build("my-sdk-key"); // Change builder afterward builder @@ -94,7 +94,7 @@ public void Build_ProducesImmutableConfig() Assert.That(first.ServiceName, Is.EqualTo("s1")); Assert.That(first.ServiceVersion, Is.EqualTo("v1")); Assert.That(first.Environment, Is.EqualTo("env1")); - Assert.That(first.SdkKey, Is.EqualTo("sdk-immutable")); + Assert.That(first.SdkKey, Is.EqualTo("my-sdk-key")); }); } } diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs new file mode 100644 index 000000000..3623679ea --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs @@ -0,0 +1,69 @@ +using NUnit.Framework; +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace LaunchDarkly.Observability.Test +{ + [TestFixture] + public class ObservabilityPluginBuilderTests + { + private IServiceCollection _services; + + [SetUp] + public void SetUp() + { + _services = new ServiceCollection(); + } + + [Test] + public void CreateBuilder_WithValidParameters_CreatesBuilder() + { + var builder = ObservabilityPlugin.Builder(_services); + + Assert.That(builder, Is.Not.Null); + Assert.That(builder, Is.InstanceOf()); + } + + [Test] + public void CreateBuilder_WithNullServices_ThrowsArgumentNullException() + { + var exception = Assert.Throws(() => + ObservabilityPlugin.Builder(null)); + Assert.Multiple(() => + { + Assert.That(exception, Is.Not.Null); + Assert.That(exception.ParamName, Is.EqualTo("services")); + Assert.That(exception.Message, + Does.Contain("Service collection cannot be null when creating an ObservabilityPlugin builder")); + }); + } + + [Test] + public void Build_WithAllFields_CreatesPluginWithConfiguration() + { + var plugin = ObservabilityPlugin.Builder(_services) + .WithOtlpEndpoint("https://otlp.example.com") + .WithBackendUrl("https://backend.example.com") + .WithServiceName("service-a") + .WithServiceVersion("1.0.0") + .WithEnvironment("prod") + .Build(); + + Assert.That(plugin, Is.InstanceOf()); + } + + [Test] + public void Build_WithNullValues_HandlesNullsCorrectly() + { + var plugin = ObservabilityPlugin.Builder(_services) + .WithOtlpEndpoint(null) + .WithBackendUrl(null) + .WithServiceName(null) + .WithServiceVersion(null) + .WithEnvironment(null) + .Build(); + + Assert.That(plugin, Is.InstanceOf()); + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/UnitTest1.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/UnitTest1.cs deleted file mode 100644 index 457ea3606..000000000 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/UnitTest1.cs +++ /dev/null @@ -1,18 +0,0 @@ -using NUnit.Framework; - -namespace LaunchDarkly.Observability.Test -{ - public class Tests - { - [SetUp] - public void Setup() - { - } - - [Test] - public void Test1() - { - Assert.Pass(); - } - } -} \ No newline at end of file From f5c306b83b651ad3e003fe4ff79fb3cea59f2cd8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 12 Aug 2025 10:33:37 -0700 Subject: [PATCH 9/9] Re-add default metrics name. --- .../src/LaunchDarkly.Observability/ObservabilityExtensions.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs index 3be743a3c..9061a12da 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs @@ -15,6 +15,8 @@ namespace LaunchDarkly.Observability /// public static class ObservabilityExtensions { + // Used for metrics when a service name is not specified. + private const string DefaultMetricsName = "launchdarkly-plugin-default-metrics"; private const OtlpExportProtocol ExportProtocol = OtlpExportProtocol.HttpProtobuf; private const int FlushIntervalMs = 5 * 1000; private const int MaxExportBatchSize = 10000; @@ -81,7 +83,7 @@ internal static void AddLaunchDarklyObservabilityWithConfig(this IServiceCollect }).WithMetrics(metrics => { metrics.SetResourceBuilder(resourceBuilder) - .AddMeter(config.ServiceName) + .AddMeter(config.ServiceName ?? DefaultMetricsName) .AddRuntimeInstrumentation() .AddProcessInstrumentation() .AddHttpClientInstrumentation()