diff --git a/sdk/@launchdarkly/observability-dotnet/.gitignore b/sdk/@launchdarkly/observability-dotnet/.gitignore index 858f059cc..69b1c92ce 100644 --- a/sdk/@launchdarkly/observability-dotnet/.gitignore +++ b/sdk/@launchdarkly/observability-dotnet/.gitignore @@ -2,6 +2,8 @@ **/obj LaunchDarkly.Observability.sln.DotSettings.user **/launchSettings.json +**/.vs/** +packages/ docs/ api/ nupkgs/ diff --git a/sdk/@launchdarkly/observability-dotnet/README.md b/sdk/@launchdarkly/observability-dotnet/README.md index 25b0fe63e..3e723e9e2 100644 --- a/sdk/@launchdarkly/observability-dotnet/README.md +++ b/sdk/@launchdarkly/observability-dotnet/README.md @@ -9,7 +9,7 @@ LaunchDarkly Observability Plugin for .Net **NB: APIs are subject to change until a 1.x version is released.** -## Install +## Install for ASP.Net Core ```shell dotnet add package LaunchDarkly.Observability @@ -18,7 +18,54 @@ dotnet add package LaunchDarkly.Observability Install the plugin when configuring your LaunchDarkly SDK. ```csharp - // TODO: Add example. +var builder = WebApplication.CreateBuilder(args); + + +var config = Configuration.Builder("your-sdk-key") + .Plugins(new PluginConfigurationBuilder() + .Add(ObservabilityPlugin.Builder(builder.Services) + .WithServiceName("your-service-name") + .WithServiceVersion("example-sha") + .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(); +``` + +## Install for an ASP.Net application with .Net Framework + +```csharp +// In Global.asax.cs +protected void Application_Start() + { + // Other application specific code. + var client = new LdClient(Configuration.Builder("your-sdk-key") + .Plugins(new PluginConfigurationBuilder().Add(ObservabilityPlugin.Builder() + .WithServiceName("your-service-name") + .WithServiceVersion("example-sha") + .Build())) + .Build()); + } + + protected void Application_End() { + Observe.Shutdown(); + } +``` + +```xml + + + // Any existing content should remain and the following should be added. + + + + ``` LaunchDarkly overview @@ -49,4 +96,4 @@ We encourage pull requests and other contributions from the community. Check out [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 +[dotnetplugin-nuget-link]: https://www.nuget.org/packages/LaunchDarkly.Observability/ diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Asp/Core/ObservabilityExtensions.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Asp/Core/ObservabilityExtensions.cs new file mode 100644 index 000000000..87e89eded --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Asp/Core/ObservabilityExtensions.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Observability.Otel; +using LaunchDarkly.Logging; +using LaunchDarkly.Observability.Logging; +using LaunchDarkly.Observability.Sampling; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using OpenTelemetry.Exporter; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; + +// ReSharper disable once CheckNamespace +namespace LaunchDarkly.Observability +{ + /// + /// Static class containing extension methods for configuring observability + /// + public static class ObservabilityExtensions + { + private class LdObservabilityHostedService : IHostedService + { + private readonly ObservabilityConfig _config; + private readonly ILoggerProvider _loggerProvider; + + public LdObservabilityHostedService(ObservabilityConfig config, IServiceProvider provider) + { + _loggerProvider = provider.GetService(); + _config = config; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + Observe.Initialize(_config, _loggerProvider); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + internal static void AddLaunchDarklyObservabilityWithConfig(this IServiceCollection services, + ObservabilityConfig config, Logger logger = null) + { + DebugLogger.SetLogger(logger); + + var sampler = CommonOtelOptions.GetSampler(config); + + var resourceBuilder = CommonOtelOptions.GetResourceBuilder(config); + + services.AddOpenTelemetry().WithTracing(tracing => + { + tracing + .WithCommonLaunchDarklyConfig(config, resourceBuilder, sampler) + .AddAspNetCoreInstrumentation(options => { options.RecordException = true; }); + }).WithLogging(logging => + { + logging.SetResourceBuilder(resourceBuilder) + .AddProcessor(new SamplingLogProcessor(sampler)) + .AddOtlpExporter(options => + { + options.WithCommonLaunchDarklyLoggingExport(config); + }); + config.ExtendedLoggerConfiguration?.Invoke(logging); + }).WithMetrics(metrics => + { + metrics + .WithCommonLaunchDarklyConfig(config, resourceBuilder) + .AddAspNetCoreInstrumentation(); + }); + + // Attach a hosted service which will allow us to get a logger provider instance from the built + // service collection. + services.AddHostedService((serviceProvider) => + new LdObservabilityHostedService(config, serviceProvider)); + } + + /// + /// 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/Asp/Core/ObservabilityPlugin.cs similarity index 94% rename from sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityPlugin.cs rename to sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Asp/Core/ObservabilityPlugin.cs index 0859fe116..9bead039e 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityPlugin.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Asp/Core/ObservabilityPlugin.cs @@ -6,7 +6,9 @@ using LaunchDarkly.Sdk.Server.Plugins; using LaunchDarkly.Sdk.Server.Telemetry; using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Logs; +// ReSharper disable once CheckNamespace namespace LaunchDarkly.Observability { public class ObservabilityPlugin : Plugin @@ -20,7 +22,7 @@ public class ObservabilityPlugin : Plugin /// 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 + /// This method only needs to be used when observability related functionality must be initialized before it /// is possible to initialize the LaunchDarkly SDK. /// /// @@ -32,15 +34,16 @@ public class ObservabilityPlugin : Plugin /// /// 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") + /// var config = Configuration.Builder("your-sdk-key") /// .Plugins(new PluginConfigurationBuilder() /// .Add(ObservabilityPlugin.Builder(builder.Services) /// .WithServiceName("ryan-test-service") - /// .WithServiceVersion("0.0.0") + /// .WithServiceVersion("example-sha") /// .Build())).Build(); /// // Building the LdClient with the Observability plugin. This line will add services to the web application. /// var client = new LdClient(config); diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Asp/Legacy/ObservabilityPlugin.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Asp/Legacy/ObservabilityPlugin.cs new file mode 100644 index 000000000..3eb157449 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Asp/Legacy/ObservabilityPlugin.cs @@ -0,0 +1,113 @@ +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; + +// ReSharper disable once CheckNamespace +namespace LaunchDarkly.Observability +{ + public class ObservabilityPlugin : Plugin + { + private readonly ObservabilityPluginBuilder _config; + + /// + /// Create a new builder for . + /// + /// When using this builder, LaunchDarkly client must be constructed during Application_Start of your + /// web application in Global.asax.cs. + /// For example: + /// + /// + /// protected void Application_Start() + /// { + /// // Other application specific code. + /// var client = new LdClient(Configuration.Builder("your-sdk-key") + /// .Plugins(new PluginConfigurationBuilder().Add(ObservabilityPlugin.Builder() + /// .WithServiceName("classic-asp-application") + /// .WithServiceVersion("example-sha") + /// .Build())) + /// .Build()); + /// } + /// + /// protected void Application_End() { + /// Observe.Shutdown(); + /// } + /// + /// + /// Additionally the Web.config must include the following. + /// + /// + /// + /// // Any existing content should remain and the following should be added. + /// + /// + /// + /// + /// + /// + /// + /// + /// A new instance for configuring the observability plugin. + public static ObservabilityPluginBuilder Builder() => + new ObservabilityPluginBuilder(); + + /// + /// 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 initialized before it + /// is possible to initialize the LaunchDarkly SDK. + /// + /// + /// an observability plugin instance + public static ObservabilityPlugin ForExistingServices() => new ObservabilityPlugin(); + + internal ObservabilityPlugin(ObservabilityPluginBuilder config) : base( + "LaunchDarkly.Observability") + { + _config = config; + } + + internal ObservabilityPlugin() : base("LaunchDarkly.Observability") + { + _config = null; + } + + /// + public override void Register(ILdClient client, EnvironmentMetadata metadata) + { + if (_config == null) return; + var config = _config.BuildConfig(metadata.Credential); + OpenTelemetry.Register(config); + } + + /// + public override IList GetHooks(EnvironmentMetadata metadata) + { + return new List + { + TracingHook.Builder().IncludeValue().Build() + }; + } + + /// + /// Used to build an instance of the Observability Plugin. + /// + public sealed class ObservabilityPluginBuilder : BaseBuilder + { + /// + /// Build an instance with the configured settings. + /// + /// The constructed . + public ObservabilityPlugin Build() + { + return new ObservabilityPlugin(this); + } + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Asp/Legacy/OpenTelemetryConfig.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Asp/Legacy/OpenTelemetryConfig.cs new file mode 100644 index 000000000..c98b3cef4 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Asp/Legacy/OpenTelemetryConfig.cs @@ -0,0 +1,100 @@ +using LaunchDarkly.Logging; +using LaunchDarkly.Observability.Otel; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +// ReSharper disable once CheckNamespace +namespace LaunchDarkly.Observability +{ + public static class OpenTelemetry + { + private static TracerProvider _tracerProvider; + private static MeterProvider _meterProvider; + private static readonly object ProviderLock = new object(); + private static ILoggerFactory _loggerFactory; + + /// + /// Extension method which adds LaunchDarkly logging to a logging factory. + /// + /// + /// using (var factory = LoggerFactory.Create(builder => { builder.AddLaunchDarklyLogging(config); })) { + /// // Use factory to get logger which uses LaunchDarkly loging. + /// } + /// + /// + /// the logging builder for the factory + /// the LaunchDarkly observability configuration + public static void AddLaunchDarklyLogging(this ILoggingBuilder loggingBuilder, ObservabilityConfig config) + { + loggingBuilder.AddOpenTelemetry(options => + { + options.SetResourceBuilder(CommonOtelOptions.GetResourceBuilder(config)) + .AddProcessor(new SamplingLogProcessor(CommonOtelOptions.GetSampler(config))) + .AddOtlpExporter(exportOptions => { exportOptions.WithCommonLaunchDarklyLoggingExport(config); }); + + config.ExtendedLoggerConfiguration?.Invoke(options); + }); + } + + /// + /// Configure LaunchDarkly observability. In typical usage, the ObservabilityPlugin should be used instead. + /// + /// This method is for advanced use-cases where the LaunchDarkly client needs to be initialized later than the + /// telemetry implementation. + /// + /// + /// If this method is used, then the should be instantiated using + /// method. + /// + /// + /// configuration for LaunchDarkly Observability + /// an optional debug logger + public static void Register(ObservabilityConfig config, Logger debugLogger = null) + { + lock (ProviderLock) + { + // If the providers are set, then the implementation has already been configured. + if (_tracerProvider != null) + { + return; + } + + var resourceBuilder = CommonOtelOptions.GetResourceBuilder(config); + var sampler = CommonOtelOptions.GetSampler(config); + + _tracerProvider = global::OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .WithCommonLaunchDarklyConfig(config, resourceBuilder, sampler) + .AddAspNetInstrumentation() + .Build(); + + _meterProvider = global::OpenTelemetry.Sdk.CreateMeterProviderBuilder() + .WithCommonLaunchDarklyConfig(config, resourceBuilder) + .AddAspNetInstrumentation() + .Build(); + + _loggerFactory = LoggerFactory.Create(builder => { builder.AddLaunchDarklyLogging(config); }); + var logger = _loggerFactory.CreateLogger(); + Observe.Initialize(config, logger); + } + } + + /// + /// Shutdown the underlying telemetry. + /// + public static void Shutdown() + { + lock (ProviderLock) + { + _tracerProvider?.Dispose(); + _meterProvider?.Dispose(); + _loggerFactory?.Dispose(); + _tracerProvider = null; + _meterProvider = null; + _loggerFactory = null; + } + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/BaseBuilder.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/BaseBuilder.cs index d24b3fd19..e7f9a283e 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/BaseBuilder.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/BaseBuilder.cs @@ -6,6 +6,12 @@ namespace LaunchDarkly.Observability { +#if NETFRAMEWORK + using LoggerBuilderType = OpenTelemetryLoggerOptions; +#else + using LoggerBuilderType = LoggerProviderBuilder; +#endif + /// /// Base builder which allows for methods to be shared between building a config directly and building a plugin. /// @@ -23,7 +29,7 @@ public class BaseBuilder where TBuilder : BaseBuilder private string _environment = string.Empty; private string _serviceVersion = string.Empty; private Action _extendedTracerConfiguration; - private Action _extendedLoggerConfiguration; + private Action _extendedLoggerConfiguration; private Action _extendedMeterConfiguration; protected BaseBuilder() @@ -171,7 +177,7 @@ public TBuilder WithExtendedTracingConfig(Action extended /// /// A function used to extend the logging configuration. /// A reference to this builder. - public TBuilder WithExtendedLoggerConfiguration(Action extendedLoggerConfiguration) + public TBuilder WithExtendedLoggerConfiguration(Action extendedLoggerConfiguration) { _extendedLoggerConfiguration = extendedLoggerConfiguration; return (TBuilder)this; 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 9b0e9e7bd..9156aa90e 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/LaunchDarkly.Observability.csproj @@ -1,71 +1,97 @@ - - - - 0.1.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 - - - - true - snupkg - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + 0.1.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 + + false + + + 1570,1571,1572,1573,1574,1580,1581,1584,1591,1710,1711,1712 + + + + + + + + + + + ../../../../../LaunchDarkly.snk + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + snupkg + + + + + + + diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs index 3da379deb..16faee42a 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityConfig.cs @@ -5,7 +5,13 @@ namespace LaunchDarkly.Observability { - public struct ObservabilityConfig +#if NETFRAMEWORK + using LoggerBuilderType = OpenTelemetryLoggerOptions; +#else + using LoggerBuilderType = LoggerProviderBuilder; +#endif + + public class ObservabilityConfig { /// /// The configured OTLP endpoint. @@ -52,7 +58,7 @@ public struct ObservabilityConfig /// /// Function which extends the configuration of the logger provider. /// - public Action ExtendedLoggerConfiguration { get; } + public Action ExtendedLoggerConfiguration { get; } /// /// Function which extends the configuration of the meter provider. @@ -67,9 +73,9 @@ internal ObservabilityConfig( string serviceVersion, string sdkKey, Action extendedTracerConfiguration, - Action extendedLoggerConfiguration, + Action extendedLoggerConfiguration, Action extendedMeterConfiguration - ) + ) { OtlpEndpoint = otlpEndpoint; BackendUrl = backendUrl; diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs deleted file mode 100644 index a97b7dfd1..000000000 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using LaunchDarkly.Observability.Otel; -using LaunchDarkly.Logging; -using LaunchDarkly.Observability.Logging; -using LaunchDarkly.Observability.Sampling; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenTelemetry; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; -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 int ExportTimeoutMs = 30000; - - private const string TracesPath = "/v1/traces"; - private const string LogsPath = "/v1/logs"; - private const string MetricsPath = "/v1/metrics"; - - - private class LdObservabilityHostedService : IHostedService - { - private readonly ObservabilityConfig _config; - private readonly ILoggerProvider _loggerProvider; - - public LdObservabilityHostedService(ObservabilityConfig config, IServiceProvider provider) - { - _loggerProvider = provider.GetService(); - _config = config; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - Observe.Initialize(_config, _loggerProvider); - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - } - - private static async Task GetSamplingConfigAsync(CustomSampler sampler, ObservabilityConfig config) - { - using (var samplingClient = new SamplingConfigClient(config.BackendUrl)) - { - try - { - var res = await samplingClient.GetSamplingConfigAsync(config.SdkKey).ConfigureAwait(false); - if (res == null) return; - sampler.SetConfig(res); - } - catch (Exception ex) - { - DebugLogger.DebugLog($"Exception while getting sampling config: {ex}"); - } - } - } - - private static IEnumerable> GetResourceAttributes(ObservabilityConfig config) - { - var attrs = new List>(); - - if (!string.IsNullOrWhiteSpace(config.Environment)) - { - attrs.Add( - new KeyValuePair(AttributeNames.DeploymentEnvironment, config.Environment)); - } - - attrs.Add(new KeyValuePair(AttributeNames.ProjectId, config.SdkKey)); - - return attrs; - } - - internal static void AddLaunchDarklyObservabilityWithConfig(this IServiceCollection services, - ObservabilityConfig config, Logger logger = null) - { - DebugLogger.SetLogger(logger); - var resourceAttributes = GetResourceAttributes(config); - - var sampler = new CustomSampler(); - - // Asynchronously get sampling config. - _ = Task.Run(() => GetSamplingConfigAsync(sampler, 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; }) - .AddSource(DefaultNames.ActivitySourceNameOrDefault(config.ServiceName)); - - // Always use sampling exporter for traces - var samplingTraceExporter = new SamplingTraceExporter(sampler, new OtlpExporterOptions - { - Endpoint = new Uri(config.OtlpEndpoint + TracesPath), - Protocol = OtlpExportProtocol.HttpProtobuf, - }); - - tracing.AddProcessor(new BatchActivityExportProcessor(samplingTraceExporter, MaxQueueSize, - FlushIntervalMs, ExportTimeoutMs, MaxExportBatchSize)); - config.ExtendedTracerConfiguration?.Invoke(tracing); - }).WithLogging(logging => - { - logging.SetResourceBuilder(resourceBuilder) - .AddProcessor(new SamplingLogProcessor(sampler)) - .AddOtlpExporter(options => - { - options.Endpoint = new Uri(config.OtlpEndpoint + LogsPath); - options.Protocol = ExportProtocol; - options.BatchExportProcessorOptions.MaxExportBatchSize = MaxExportBatchSize; - options.BatchExportProcessorOptions.MaxQueueSize = MaxQueueSize; - options.BatchExportProcessorOptions.ScheduledDelayMilliseconds = FlushIntervalMs; - }); - config.ExtendedLoggerConfiguration?.Invoke(logging); - }).WithMetrics(metrics => - { - metrics.SetResourceBuilder(resourceBuilder) - .AddMeter(DefaultNames.MeterNameOrDefault(config.ServiceName)) - .AddRuntimeInstrumentation() - .AddProcessInstrumentation() - .AddHttpClientInstrumentation() - .AddAspNetCoreInstrumentation() - .AddSqlClientInstrumentation() - .AddReader(new PeriodicExportingMetricReader(new OtlpMetricExporter(new OtlpExporterOptions - { - Endpoint = new Uri(config.OtlpEndpoint + MetricsPath), - Protocol = ExportProtocol - }))); - config.ExtendedMeterConfiguration?.Invoke(metrics); - }); - - // Attach a hosted service which will allow us to get a logger provider instance from the built - // service collection. - services.AddHostedService((serviceProvider) => - new LdObservabilityHostedService(config, serviceProvider)); - } - - /// - /// 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/Observe.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs index 0f3e03f9b..5c1c7019c 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs @@ -31,6 +31,7 @@ private class Instance public readonly ConcurrentDictionary> UpDownCounters = new ConcurrentDictionary>(); +#if NETSTANDARD2_0 || NET5_0_OR_GREATER internal Instance(ObservabilityConfig config, ILoggerProvider loggerProvider) { Meter = new Meter(DefaultNames.MeterNameOrDefault(config.ServiceName), @@ -42,14 +43,32 @@ internal Instance(ObservabilityConfig config, ILoggerProvider loggerProvider) Logger = loggerProvider.CreateLogger(DefaultNames.LoggerNameOrDefault(config.ServiceName)); } } +#endif + +#if NETFRAMEWORK + internal Instance(ObservabilityConfig config, ILogger logger) { + Meter = new Meter(DefaultNames.MeterNameOrDefault(config.ServiceName), config.ServiceVersion); + ActivitySource = new ActivitySource(DefaultNames.ActivitySourceNameOrDefault(config.ServiceName), config.ServiceVersion); + Logger = logger; + } +#endif } private static Instance _instance; +#if NETSTANDARD2_0 || NET5_0_OR_GREATER internal static void Initialize(ObservabilityConfig config, ILoggerProvider loggerProvider) { Volatile.Write(ref _instance, new Instance(config, loggerProvider)); } +#endif + +#if NETFRAMEWORK + internal static void Initialize(ObservabilityConfig config, ILogger logger) + { + Volatile.Write(ref _instance, new Instance(config, logger)); + } +#endif private static Instance GetInstance() { @@ -130,6 +149,16 @@ public static void RecordException(Exception exception, IDictionary + /// Record a metric gauge value. + /// + /// Records a measurement value for a gauge metric with the specified name and optional attributes. + /// + /// + /// the name of the metric + /// the value to record + /// any additional attributes to add to the metric + /// thrown when name is null public static void RecordMetric(string name, double value, IDictionary attributes = null) { if (name == null) throw new ArgumentNullException(nameof(name), "Metric name cannot be null."); @@ -149,6 +178,17 @@ public static void RecordMetric(string name, double value, IDictionary + /// Record a counter value. + /// + /// Records a count value for a counter metric with the specified name and optional attributes. + /// Counter values should be monotonically increasing. + /// + /// + /// the name of the counter + /// the value to add to the counter + /// any additional attributes to add to the counter + /// thrown when name is null public static void RecordCount(string name, long value, IDictionary attributes = null) { if (name == null) throw new ArgumentNullException(nameof(name), "Count name cannot be null."); @@ -168,11 +208,30 @@ public static void RecordCount(string name, long value, IDictionary + /// Increment a counter by 1. + /// + /// This is a convenience method equivalent to calling RecordCount with a value of 1. + /// + /// + /// the name of the counter + /// any additional attributes to add to the counter public static void RecordIncr(string name, IDictionary attributes = null) { RecordCount(name, 1, attributes); } + /// + /// Record a histogram value. + /// + /// Records a measurement value for a histogram metric with the specified name and optional attributes. + /// Histograms are used to record distributions of values. + /// + /// + /// the name of the histogram + /// the value to record + /// any additional attributes to add to the histogram + /// thrown when name is null public static void RecordHistogram(string name, double value, IDictionary attributes = null) { if (name == null) throw new ArgumentNullException(nameof(name), "Histogram name cannot be null."); @@ -193,6 +252,17 @@ public static void RecordHistogram(string name, double value, IDictionary + /// Record an up-down counter value. + /// + /// Records a delta value for an up-down counter metric with the specified name and optional attributes. + /// Up-down counters can increase or decrease and are useful for tracking values like queue size. + /// + /// + /// the name of the up-down counter + /// the delta value to add (can be positive or negative) + /// any additional attributes to add to the counter + /// thrown when name is null public static void RecordUpDownCounter(string name, long delta, IDictionary attributes = null) { if (name == null) throw new ArgumentNullException(nameof(name), "UpDownCounter name cannot be null."); @@ -214,6 +284,16 @@ public static void RecordUpDownCounter(string name, long delta, IDictionary + /// Record a log message. + /// + /// Records a log message with the specified level and optional attributes using the configured logger. + /// If no logger is configured, the log message will be ignored. + /// + /// + /// the log message + /// the log level + /// any additional attributes to add to the log entry public static void RecordLog(string message, LogLevel level, IDictionary attributes) { WithInstance(instance => @@ -228,6 +308,17 @@ public static void RecordLog(string message, LogLevel level, IDictionary + /// Start a new activity (span) with the specified name and kind. + /// + /// Creates and starts a new activity using the configured ActivitySource. The activity + /// should be disposed when the operation is complete to properly end the span. + /// + /// + /// the name of the activity + /// the kind of activity (defaults to Internal) + /// any initial attributes to add to the activity + /// the started activity, or null if the observability system is not initialized public static Activity StartActivity(string name, ActivityKind kind = ActivityKind.Internal, IDictionary attributes = null) { @@ -244,5 +335,19 @@ public static Activity StartActivity(string name, ActivityKind kind = ActivityKi return activity; } + +#if NETFRAMEWORK + /// + /// Shutdown LaunchDarkly Observability. + /// + /// Properly shuts down the OpenTelemetry system and flushes any pending telemetry data. + /// This method is only available on .NET Framework. + /// + /// + public static void Shutdown() + { + OpenTelemetry.Shutdown(); + } +#endif } } diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/CommonOtelOptions.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/CommonOtelOptions.cs new file mode 100644 index 000000000..3f765ada2 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/CommonOtelOptions.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using LaunchDarkly.Observability.Logging; +using LaunchDarkly.Observability.Sampling; +using LaunchDarkly.Sdk.Internal.Concurrent; +using OpenTelemetry; +using OpenTelemetry.Exporter; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace LaunchDarkly.Observability.Otel +{ + internal static class CommonOtelOptions + { + private const OtlpExportProtocol ExportProtocol = OtlpExportProtocol.HttpProtobuf; + private const int FlushIntervalMs = 5 * 1000; + private const int MaxExportBatchSize = 10000; + private const int MaxQueueSize = 10000; + private const int ExportTimeoutMs = 30000; + + private const string TracesPath = "/v1/traces"; + private const string LogsPath = "/v1/logs"; + private const string MetricsPath = "/v1/metrics"; + + private static readonly ConcurrentDictionary Samplers = + new ConcurrentDictionary(); + + private static async Task GetSamplingConfigAsync(CustomSampler sampler, ObservabilityConfig config) + { + using (var samplingClient = new SamplingConfigClient(config.BackendUrl)) + { + try + { + var res = await samplingClient.GetSamplingConfigAsync(config.SdkKey).ConfigureAwait(false); + if (res == null) return; + sampler.SetConfig(res); + } + catch (Exception ex) + { + DebugLogger.DebugLog($"Exception while getting sampling config: {ex}"); + } + } + } + + private static IEnumerable> GetResourceAttributes(ObservabilityConfig config) + { + var attrs = new List>(); + + if (!string.IsNullOrWhiteSpace(config.Environment)) + { + attrs.Add( + new KeyValuePair(AttributeNames.DeploymentEnvironment, config.Environment)); + } + + attrs.Add(new KeyValuePair(AttributeNames.ProjectId, config.SdkKey)); + + return attrs; + } + + public static CustomSampler GetSampler(ObservabilityConfig config) + { + return Samplers.GetOrAdd(config.SdkKey, _ => + { + var sampler = new CustomSampler(); + Task.Run(() => GetSamplingConfigAsync(sampler, config)); + return sampler; + }); + } + + public static ResourceBuilder GetResourceBuilder(ObservabilityConfig config) + { + var resourceBuilder = ResourceBuilder.CreateDefault(); + if (string.IsNullOrWhiteSpace(config.ServiceName)) return resourceBuilder; + resourceBuilder.AddService(config.ServiceName, serviceVersion: config.ServiceVersion); + resourceBuilder.AddAttributes(GetResourceAttributes(config)); + return resourceBuilder; + } + + + public static TracerProviderBuilder WithCommonLaunchDarklyConfig(this TracerProviderBuilder builder, + ObservabilityConfig config, ResourceBuilder resourceBuilder, CustomSampler sampler) + { + var samplingTraceExporter = new SamplingTraceExporter(sampler, new OtlpExporterOptions + { + Endpoint = new Uri(config.OtlpEndpoint + TracesPath), + Protocol = OtlpExportProtocol.HttpProtobuf, + BatchExportProcessorOptions = + { + MaxExportBatchSize = MaxExportBatchSize, + MaxQueueSize = MaxQueueSize, + ScheduledDelayMilliseconds = FlushIntervalMs + } + }); + + builder.SetResourceBuilder(resourceBuilder) + .AddHttpClientInstrumentation() + .AddGrpcClientInstrumentation() + .AddWcfInstrumentation() + .AddQuartzInstrumentation() + .AddSqlClientInstrumentation(options => { options.SetDbStatementForText = true; }) + .AddSource(DefaultNames.ActivitySourceNameOrDefault(config.ServiceName)) + .AddProcessor(new BatchActivityExportProcessor(samplingTraceExporter, MaxQueueSize, + FlushIntervalMs, ExportTimeoutMs, MaxExportBatchSize)); + + config.ExtendedTracerConfiguration?.Invoke(builder); + return builder; + } + + public static MeterProviderBuilder WithCommonLaunchDarklyConfig(this MeterProviderBuilder builder, + ObservabilityConfig config, ResourceBuilder resourceBuilder) + { + builder.SetResourceBuilder(resourceBuilder) + .AddMeter(DefaultNames.MeterNameOrDefault(config.ServiceName)) + .AddRuntimeInstrumentation() + .AddProcessInstrumentation() + .AddHttpClientInstrumentation() + .AddSqlClientInstrumentation() + .AddReader(new PeriodicExportingMetricReader(new OtlpMetricExporter(new OtlpExporterOptions + { + Endpoint = new Uri(config.OtlpEndpoint + MetricsPath), + Protocol = ExportProtocol + }))); + config.ExtendedMeterConfiguration?.Invoke(builder); + return builder; + } + + public static void WithCommonLaunchDarklyLoggingExport(this OtlpExporterOptions options, + ObservabilityConfig config) + { + options.Endpoint = new Uri(config.OtlpEndpoint + LogsPath); + options.Protocol = ExportProtocol; + options.BatchExportProcessorOptions.MaxExportBatchSize = MaxExportBatchSize; + options.BatchExportProcessorOptions.MaxQueueSize = MaxQueueSize; + options.BatchExportProcessorOptions.ScheduledDelayMilliseconds = FlushIntervalMs; + } + } +}