From c3386b10aece4e56913bac0870ec8f06f651f90b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 18 Aug 2025 13:53:43 -0700 Subject: [PATCH 01/10] WIP implement dotnet singleton. --- .../AspSampleApp/Program.cs | 1 + .../DefaultNames.cs | 8 + .../ObservabilityExtensions.cs | 9 +- .../src/LaunchDarkly.Observability/Observe.cs | 183 ++++++++++++++++++ .../Otel/AttributeNames.cs | 8 + 5 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/DefaultNames.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/AttributeNames.cs diff --git a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs index 273227309..907c383f1 100644 --- a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs +++ b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using LaunchDarkly.Observability; using LaunchDarkly.Sdk; using LaunchDarkly.Sdk.Server; diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/DefaultNames.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/DefaultNames.cs new file mode 100644 index 000000000..91a20e2b4 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/DefaultNames.cs @@ -0,0 +1,8 @@ +namespace LaunchDarkly.Observability +{ + internal static class DefaultNames + { + public const string MeterName = "launchdarkly-plugin-default-metrics"; + public const string ActivitySourceName = "launchdarkly-plugin-default-activity"; + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs index 9061a12da..81d01c2be 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using LaunchDarkly.Observability.Otel; using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -15,8 +16,6 @@ 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; @@ -32,10 +31,10 @@ private static IEnumerable> GetResourceAttributes(O if (!string.IsNullOrWhiteSpace(config.Environment)) { - attrs.Add(new KeyValuePair("deployment.environment.name", config.Environment)); + attrs.Add(new KeyValuePair(AttributeNames.DeploymentEnvironment, config.Environment)); } - attrs.Add(new KeyValuePair("highlight.project_id", config.SdkKey)); + attrs.Add(new KeyValuePair(AttributeNames.ProjectId, config.SdkKey)); return attrs; } @@ -83,7 +82,7 @@ internal static void AddLaunchDarklyObservabilityWithConfig(this IServiceCollect }).WithMetrics(metrics => { metrics.SetResourceBuilder(resourceBuilder) - .AddMeter(config.ServiceName ?? DefaultMetricsName) + .AddMeter(config.ServiceName ?? DefaultNames.MeterName) .AddRuntimeInstrumentation() .AddProcessInstrumentation() .AddHttpClientInstrumentation() diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs new file mode 100644 index 000000000..fe35dd566 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Threading; +using OpenTelemetry.Metrics; + +namespace LaunchDarkly.Observability +{ + public static class Observe + { + private const string ErrorSpanName = "launchdarkly.error"; + + private class Instance + { + public readonly Meter Meter; + public readonly ActivitySource ActivitySource; + + public readonly ConcurrentDictionary> Counters = + new ConcurrentDictionary>(); + + public readonly ConcurrentDictionary> Gauges = + new ConcurrentDictionary>(); + + public readonly ConcurrentDictionary> Histograms = + new ConcurrentDictionary>(); + + public readonly ConcurrentDictionary> UpDownCounters = + new ConcurrentDictionary>(); + + internal Instance(ObservabilityConfig config) + { + Meter = new Meter(config.ServiceName ?? DefaultNames.MeterName, + config.ServiceVersion); + ActivitySource = new ActivitySource(config.ServiceName ?? DefaultNames.ActivitySourceName, + config.ServiceVersion); + } + } + + private static Instance _instance; + + internal static void Initialize(ObservabilityConfig config) + { + Volatile.Write(ref _instance, new Instance(config)); + } + + private static Instance GetInstance() + { + return Volatile.Read(ref _instance); + } + + private static void WithInstance(Action action) + { + var instance = GetInstance(); + if (instance == null) + { + // TODO: Log after PR with logger merged. + return; + } + + action(instance); + } + + public static void RecordError(Exception exception) + { + WithInstance(instance => + { + var activity = Activity.Current; + var created = false; + if (activity == null) + { + activity = instance.ActivitySource.StartActivity(ErrorSpanName); + created = true; + } + + activity?.AddException(exception); + if (created) + { + activity?.Stop(); + } + }); + } + + public static void RecordMetric(string name, double value, TagList? tags = null) + { + if (name == null) throw new ArgumentNullException(nameof(name), "Metric name cannot be null."); + + WithInstance(instance => + { + var gauge = instance.Gauges.GetOrAdd(name, (key) => instance.Meter.CreateGauge(key)); + if (tags.HasValue) + { + gauge.Record(value, tags.Value); + } + else + { + gauge.Record(value); + } + }); + } + + public static void RecordCount(string name, long value, TagList? tags = null) + { + if (name == null) throw new ArgumentNullException(nameof(name), "Count name cannot be null."); + + WithInstance(instance => + { + var count = instance.Counters.GetOrAdd(name, (key) => instance.Meter.CreateCounter(key)); + if (tags.HasValue) + { + count.Add(value, tags.Value); + } + else + { + count.Add(value); + } + }); + } + + public static void RecordIncr(string name, TagList? tags = null) + { + RecordCount(name, 1, tags); + } + + public static void RecordHistogram(string name, double value, TagList? tags = null) + { + if (name == null) throw new ArgumentNullException(nameof(name), "Histogram name cannot be null."); + + WithInstance(instance => + { + var histogram = + instance.Histograms.GetOrAdd(name, (key) => instance.Meter.CreateHistogram(key)); + if (tags != null) + { + histogram.Record(value, tags.Value); + } + else + { + histogram.Record(value); + } + }); + } + + public static void RecordUpDownCounter(string name, long delta, TagList? tags = null) + { + if (name == null) throw new ArgumentNullException(nameof(name), "UpDownCounter name cannot be null."); + + WithInstance(instance => + { + var upDownCounter = + instance.UpDownCounters.GetOrAdd(name, (key) => instance.Meter.CreateUpDownCounter(key)); + + if (tags != null) + { + upDownCounter.Add(delta, tags.Value); + } + else + { + upDownCounter.Add(delta); + } + }); + } + + public static void RecordLog(string message, string severityText, IDictionary attributes) + { + } + + public static Activity StartActivity(string name, ActivityKind kind = ActivityKind.Internal, IDictionary attributes = null) + { + var instance = GetInstance(); + if (instance == null) return null; + var activity = instance.ActivitySource.StartActivity(name, kind); + if (attributes == null) return null; + foreach (var attribute in attributes) + { + activity?.AddTag(attribute.Key, attribute.Value); + } + // TODO: Do we need to log? + return null; + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/AttributeNames.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/AttributeNames.cs new file mode 100644 index 000000000..ec40f9e02 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/AttributeNames.cs @@ -0,0 +1,8 @@ +namespace LaunchDarkly.Observability.Otel +{ + internal static class AttributeNames + { + public const string DeploymentEnvironment = "deployment.environment.name"; + public const string ProjectId = "highlight.project_id"; + } +} From 07d56fa968e818da1238b2f726ad1714ba807c61 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 18 Aug 2025 15:30:51 -0700 Subject: [PATCH 02/10] WIP Metrics interface implemented. --- .../src/LaunchDarkly.Observability/Observe.cs | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs index fe35dd566..8a1eeb44b 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs @@ -62,6 +62,24 @@ private static void WithInstance(Action action) action(instance); } + private static KeyValuePair[] ConvertToKeyValuePairs(IDictionary dictionary) + { + if (dictionary == null || dictionary.Count == 0) + { + return null; + } + + var result = new KeyValuePair[dictionary.Count]; + var index = 0; + foreach (var kvp in dictionary) + { + result[index] = new KeyValuePair(kvp.Key, kvp.Value); + index++; + } + + return result; + } + public static void RecordError(Exception exception) { WithInstance(instance => @@ -82,16 +100,17 @@ public static void RecordError(Exception exception) }); } - public static void RecordMetric(string name, double value, TagList? tags = 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."); WithInstance(instance => { var gauge = instance.Gauges.GetOrAdd(name, (key) => instance.Meter.CreateGauge(key)); - if (tags.HasValue) + var keyValuePairs = ConvertToKeyValuePairs(attributes); + if (keyValuePairs != null) { - gauge.Record(value, tags.Value); + gauge.Record(value, keyValuePairs); } else { @@ -100,16 +119,17 @@ public static void RecordMetric(string name, double value, TagList? tags = null) }); } - public static void RecordCount(string name, long value, TagList? tags = 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."); WithInstance(instance => { var count = instance.Counters.GetOrAdd(name, (key) => instance.Meter.CreateCounter(key)); - if (tags.HasValue) + var keyValuePairs = ConvertToKeyValuePairs(attributes); + if (keyValuePairs != null) { - count.Add(value, tags.Value); + count.Add(value, keyValuePairs); } else { @@ -118,12 +138,12 @@ public static void RecordCount(string name, long value, TagList? tags = null) }); } - public static void RecordIncr(string name, TagList? tags = null) + public static void RecordIncr(string name, IDictionary attributes = null) { - RecordCount(name, 1, tags); + RecordCount(name, 1, attributes); } - public static void RecordHistogram(string name, double value, TagList? tags = 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."); @@ -131,9 +151,10 @@ public static void RecordHistogram(string name, double value, TagList? tags = nu { var histogram = instance.Histograms.GetOrAdd(name, (key) => instance.Meter.CreateHistogram(key)); - if (tags != null) + var keyValuePairs = ConvertToKeyValuePairs(attributes); + if (keyValuePairs != null) { - histogram.Record(value, tags.Value); + histogram.Record(value, keyValuePairs); } else { @@ -142,7 +163,7 @@ public static void RecordHistogram(string name, double value, TagList? tags = nu }); } - public static void RecordUpDownCounter(string name, long delta, TagList? tags = 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."); @@ -151,9 +172,10 @@ public static void RecordUpDownCounter(string name, long delta, TagList? tags = var upDownCounter = instance.UpDownCounters.GetOrAdd(name, (key) => instance.Meter.CreateUpDownCounter(key)); - if (tags != null) + var keyValuePairs = ConvertToKeyValuePairs(attributes); + if (keyValuePairs != null) { - upDownCounter.Add(delta, tags.Value); + upDownCounter.Add(delta, keyValuePairs); } else { @@ -166,7 +188,8 @@ public static void RecordLog(string message, string severityText, IDictionary attributes = null) + public static Activity StartActivity(string name, ActivityKind kind = ActivityKind.Internal, + IDictionary attributes = null) { var instance = GetInstance(); if (instance == null) return null; @@ -176,6 +199,7 @@ public static Activity StartActivity(string name, ActivityKind kind = ActivityKi { activity?.AddTag(attribute.Key, attribute.Value); } + // TODO: Do we need to log? return null; } From ad45c43ec81736af23aef6a2aa34c916f0ceb835 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 19 Aug 2025 13:07:51 -0700 Subject: [PATCH 03/10] Base singleton implemented. --- .../AspSampleApp/Program.cs | 138 ++++++++++++++++++ .../DefaultNames.cs | 1 + .../ObservabilityExtensions.cs | 30 ++++ .../src/LaunchDarkly.Observability/Observe.cs | 50 +++++-- 4 files changed, 210 insertions(+), 9 deletions(-) diff --git a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs index 907c383f1..4a46ef578 100644 --- a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs +++ b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs @@ -1,8 +1,10 @@ using System.ComponentModel; +using System.Diagnostics; using LaunchDarkly.Observability; using LaunchDarkly.Sdk; using LaunchDarkly.Sdk.Server; using LaunchDarkly.Sdk.Server.Integrations; +using Microsoft.Extensions.Logging; var builder = WebApplication.CreateBuilder(args); @@ -14,6 +16,7 @@ .Add(ObservabilityPlugin.Builder(builder.Services) .WithServiceName("ryan-test-service") .WithServiceVersion("0.0.0") + .WithOtlpEndpoint("http://localhost:4318") .Build())).Build(); // Building the LdClient with the Observability plugin. This line will add services to the web application. @@ -36,6 +39,86 @@ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; +app.MapGet("/recordexception", () => + { + Observe.RecordException(new InvalidOperationException("this is a recorded exception"), + new Dictionary + { + { "key", "value" }, + }); + return ""; + }) + .WithName("GetRecordException") + .WithOpenApi(); + +app.MapGet("/recordmetrics", () => + { + var random = new Random(); + + // Record a gauge metric (CPU usage percentage) + var cpuUsage = Math.Round(random.NextDouble() * 100, 2); + Observe.RecordMetric("cpu_usage_percent", cpuUsage, new Dictionary + { + { "environment", "development" }, + { "service", "asp-sample" } + }); + + // Record a counter with random value (requests processed) + var requestsProcessed = random.Next(1, 100); + Observe.RecordCount("requests_processed", requestsProcessed, new Dictionary + { + { "operation", "test" }, + { "status", "success" } + }); + + // Record an increment (counter with value 1) + Observe.RecordIncr("endpoint_hits", new Dictionary + { + { "endpoint", "/recordmetrics" }, + { "method", "GET" } + }); + + // Record a histogram value (request duration in seconds) + var requestDuration = Math.Round(random.NextDouble() * 2.0, 3); // 0-2 seconds + Observe.RecordHistogram("request_duration_seconds", requestDuration, new Dictionary + { + { "handler", "recordmetrics" }, + { "response_code", "200" } + }); + + // Record an up-down counter (active connections - positive) + var connectionDelta = random.Next(1, 10); + Observe.RecordUpDownCounter("active_connections", connectionDelta, new Dictionary + { + { "connection_type", "http" }, + { "region", "us-east-1" } + }); + + // Record another up-down counter with negative delta (queue items processed) + var queueDelta = -random.Next(1, 5); + Observe.RecordUpDownCounter("queue_size", queueDelta, new Dictionary + { + { "queue_name", "processing" }, + { "priority", "high" } + }); + + return new + { + message = "Metrics recorded successfully", + metrics = new + { + gauge = $"cpu_usage_percent: {cpuUsage}%", + counter = $"requests_processed: +{requestsProcessed}", + increment = "endpoint_hits: +1", + histogram = $"request_duration_seconds: {requestDuration}s", + upDownCounter1 = $"active_connections: +{connectionDelta}", + upDownCounter2 = $"queue_size: {queueDelta}" + } + }; + }) + .WithName("GetRecordMetrics") + .WithOpenApi(); + app.MapGet("/weatherforecast", () => { var isMercury = @@ -53,6 +136,61 @@ .WithName("GetWeatherForecast") .WithOpenApi(); +app.MapGet("/recordlog", () => + { + var random = new Random(); + var logMessages = new[] + { + "User authentication successful", + "Database connection established", + "Cache miss occurred, falling back to database", + "API rate limit approaching threshold", + "Background job completed successfully" + }; + + var severityLevels = new[] { LogLevel.Information, LogLevel.Warning, LogLevel.Error, LogLevel.Debug }; + + var message = logMessages[random.Next(logMessages.Length)]; + var severity = severityLevels[random.Next(severityLevels.Length)]; + + Observe.RecordLog(message, severity, new Dictionary + { + {"component", "asp-sample-app"}, + {"endpoint", "/recordlog"}, + {"timestamp", DateTime.UtcNow.ToString("O")}, + {"user_id", Guid.NewGuid().ToString()} + }); + + return new + { + message = "Log recorded successfully", + log_entry = new + { + message, + severity = severity.ToString(), + component = "asp-sample-app" + } + }; + }) + .WithName("GetRecordLog") + .WithOpenApi(); + +app.MapGet("/manualinstrumentation", () => + { + using (Observe.StartActivity("manual-instrumentation", ActivityKind.Internal, + new Dictionary { { "test", "attribute" } })) + { + var enableMetrics = client.BoolVariation("enableMetrics", + Context.New(ContextKind.Of("request"), Guid.NewGuid().ToString())); + + if (!enableMetrics) return "Manual instrumentation completed"; + Observe.RecordIncr("manual_instrumentation_calls"); + return "Manual instrumentation completed with metrics enabled"; + } + }) + .WithName("GetManualInstrumentation") + .WithOpenApi(); + app.Run(); record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/DefaultNames.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/DefaultNames.cs index 91a20e2b4..3f7866bf6 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/DefaultNames.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/DefaultNames.cs @@ -4,5 +4,6 @@ internal static class DefaultNames { public const string MeterName = "launchdarkly-plugin-default-metrics"; public const string ActivitySourceName = "launchdarkly-plugin-default-activity"; + public const string DefaultLoggerName = "launchdarkly-plugin-default-logger"; } } diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs index 81d01c2be..7144022a8 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs @@ -1,7 +1,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; using LaunchDarkly.Observability.Otel; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using OpenTelemetry; @@ -25,6 +30,26 @@ public static class ObservabilityExtensions private const string LogsPath = "/v1/logs"; private const string MetricsPath = "/v1/metrics"; + private class LdObservabilityHostedService : IHostedService + { + private ObservabilityConfig _config; + private ActivitySource _activitySource; + private 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 IEnumerable> GetResourceAttributes(ObservabilityConfig config) { var attrs = new List>(); @@ -60,6 +85,7 @@ internal static void AddLaunchDarklyObservabilityWithConfig(this IServiceCollect .AddQuartzInstrumentation() .AddAspNetCoreInstrumentation(options => { options.RecordException = true; }) .AddSqlClientInstrumentation(options => { options.SetDbStatementForText = true; }) + .AddSource(config.ServiceName ?? DefaultNames.ActivitySourceName) .AddOtlpExporter(options => { options.Endpoint = new Uri(config.OtlpEndpoint + TracesPath); @@ -94,6 +120,10 @@ internal static void AddLaunchDarklyObservabilityWithConfig(this IServiceCollect Protocol = ExportProtocol }))); }); + + // Attach a hosted service which will allow us to get a logger provider instance from the built + // serice collection. + services.AddHostedService((serviceProvider) => new LdObservabilityHostedService(config, serviceProvider)); } /// diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs index 8a1eeb44b..f296f781e 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs @@ -4,7 +4,7 @@ using System.Diagnostics; using System.Diagnostics.Metrics; using System.Threading; -using OpenTelemetry.Metrics; +using Microsoft.Extensions.Logging; namespace LaunchDarkly.Observability { @@ -16,6 +16,7 @@ private class Instance { public readonly Meter Meter; public readonly ActivitySource ActivitySource; + public readonly ILogger Logger; public readonly ConcurrentDictionary> Counters = new ConcurrentDictionary>(); @@ -29,20 +30,24 @@ private class Instance public readonly ConcurrentDictionary> UpDownCounters = new ConcurrentDictionary>(); - internal Instance(ObservabilityConfig config) + internal Instance(ObservabilityConfig config, ILoggerProvider loggerProvider) { Meter = new Meter(config.ServiceName ?? DefaultNames.MeterName, config.ServiceVersion); ActivitySource = new ActivitySource(config.ServiceName ?? DefaultNames.ActivitySourceName, config.ServiceVersion); + if (loggerProvider != null) + { + Logger = loggerProvider.CreateLogger(config.ServiceName ?? DefaultNames.DefaultLoggerName); + } } } private static Instance _instance; - internal static void Initialize(ObservabilityConfig config) + internal static void Initialize(ObservabilityConfig config, ILoggerProvider loggerProvider) { - Volatile.Write(ref _instance, new Instance(config)); + Volatile.Write(ref _instance, new Instance(config, loggerProvider)); } private static Instance GetInstance() @@ -80,7 +85,16 @@ private static KeyValuePair[] ConvertToKeyValuePairs(IDictionary return result; } - public static void RecordError(Exception exception) + /// + /// Record an error in the active activity. + /// + /// If there is not an active activity, then a new activity will be started and the error will be recorded + /// into the activity. + /// + /// + /// the exception to record + /// any additional attributes to add to the exception event + public static void RecordException(Exception exception, IDictionary attributes = null) { WithInstance(instance => { @@ -92,7 +106,16 @@ public static void RecordError(Exception exception) created = true; } - activity?.AddException(exception); + if (attributes != null) + { + var tags = new TagList(ConvertToKeyValuePairs(attributes)); + activity?.AddException(exception, tags); + } + else + { + activity?.AddException(exception); + } + if (created) { activity?.Stop(); @@ -184,8 +207,18 @@ public static void RecordUpDownCounter(string name, long delta, IDictionary attributes) + public static void RecordLog(string message, LogLevel level, IDictionary attributes) { + WithInstance(instance => + { + if (instance.Logger == null) return; + if (attributes != null && attributes.Count > 0) + instance.Logger.Log(level, new EventId(), attributes, null, + (objects, exception) => message); + else + instance.Logger.Log(level, new EventId(), null, null, + (objects, exception) => message); + }); } public static Activity StartActivity(string name, ActivityKind kind = ActivityKind.Internal, @@ -200,8 +233,7 @@ public static Activity StartActivity(string name, ActivityKind kind = ActivityKi activity?.AddTag(attribute.Key, attribute.Value); } - // TODO: Do we need to log? - return null; + return activity; } } } From db640decb2f3c76f9a4f23a2de3ebd4fb57e643e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 19 Aug 2025 13:45:25 -0700 Subject: [PATCH 04/10] WIP tests. --- .../src/LaunchDarkly.Observability/Observe.cs | 14 +- .../LaunchDarkly.Observability.Tests.csproj | 1 + .../ObserveTests.cs | 690 ++++++++++++++++++ 3 files changed, 702 insertions(+), 3 deletions(-) create mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs index f296f781e..aaa6f70b2 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs @@ -96,6 +96,12 @@ private static KeyValuePair[] ConvertToKeyValuePairs(IDictionary /// any additional attributes to add to the exception event public static void RecordException(Exception exception, IDictionary attributes = null) { + if (exception == null) + { + // Silently return if exception is null + return; + } + WithInstance(instance => { var activity = Activity.Current; @@ -227,10 +233,12 @@ public static Activity StartActivity(string name, ActivityKind kind = ActivityKi var instance = GetInstance(); if (instance == null) return null; var activity = instance.ActivitySource.StartActivity(name, kind); - if (attributes == null) return null; - foreach (var attribute in attributes) + if (attributes != null) { - activity?.AddTag(attribute.Key, attribute.Value); + foreach (var attribute in attributes) + { + activity?.AddTag(attribute.Key, attribute.Value); + } } return activity; 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 5b3344f8c..53ad72fa8 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 @@ -18,6 +18,7 @@ + diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs new file mode 100644 index 000000000..689333b2e --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs @@ -0,0 +1,690 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace LaunchDarkly.Observability.Test +{ + [TestFixture] + public class ObserveTests + { + private class TestLoggerProvider : ILoggerProvider + { + public List Loggers { get; } = new List(); + + public ILogger CreateLogger(string categoryName) + { + var logger = new TestLogger(categoryName); + Loggers.Add(logger); + return logger; + } + + public void Dispose() + { + Loggers.Clear(); + } + } + + private class TestLogger : ILogger + { + public string CategoryName { get; } + public List LogEntries { get; } = new List(); + + public TestLogger(string categoryName) + { + CategoryName = categoryName; + } + + public IDisposable BeginScope(TState state) => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + LogEntries.Add(new LogEntry + { + LogLevel = logLevel, + EventId = eventId, + State = state, + Exception = exception, + Message = formatter?.Invoke(state, exception) + }); + } + } + + private class LogEntry + { + public LogLevel LogLevel { get; set; } + public EventId EventId { get; set; } + public object State { get; set; } + public Exception Exception { get; set; } + public string Message { get; set; } + } + + private TestLoggerProvider _loggerProvider; + private ObservabilityConfig _config; + private TracerProvider _tracerProvider; + private ActivityListener _activityListener; + private List _exportedActivities; + + [SetUp] + public void SetUp() + { + // Reset the singleton instance before each test + ResetObserveInstance(); + + _loggerProvider = new TestLoggerProvider(); + _config = new ObservabilityConfig( + otlpEndpoint: "https://test-endpoint.com", + backendUrl: "https://test-backend.com", + serviceName: "test-service", + environment: "test", + serviceVersion: "1.0.0", + sdkKey: "test-key" + ); + + // Set up OpenTelemetry + _exportedActivities = new List(); + + // Create an ActivityListener to ensure activities are created + _activityListener = new ActivityListener + { + ShouldListenTo = (source) => source.Name == "test-service" || source.Name.StartsWith("test"), + Sample = (ref ActivityCreationOptions options) => + ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => { }, + ActivityStopped = activity => _exportedActivities.Add(activity) + }; + + ActivitySource.AddActivityListener(_activityListener); + + // Set up TracerProvider with in-memory exporter + _tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource("test-service") + .AddSource("test") + .SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService("test-service", "test", "1.0.0")) + .AddInMemoryExporter(_exportedActivities) + .Build(); + } + + [TearDown] + public void TearDown() + { + _loggerProvider?.Dispose(); + _tracerProvider?.Dispose(); + _activityListener?.Dispose(); + _exportedActivities?.Clear(); + ResetObserveInstance(); + } + + private static void ResetObserveInstance() + { + // Use reflection to reset the static instance + var field = typeof(Observe).GetField("_instance", BindingFlags.NonPublic | BindingFlags.Static); + field?.SetValue(null, null); + } + + #region Initialization Tests + + [Test] + public void Initialize_WithValidConfig_SetsUpInstanceCorrectly() + { + Observe.Initialize(_config, _loggerProvider); + + Observe.RecordIncr("test-counter"); + Observe.RecordMetric("test-gauge", 42.0); + + // Just making sure nothing before this point threw. + Assert.Pass("Initialization successful - no exceptions thrown during metric recording"); + } + + [Test] + public void Initialize_WithNullLoggerProvider_WorksCorrectly() + { + Observe.Initialize(_config, null); + + Observe.RecordIncr("test-counter"); + Observe.RecordMetric("test-gauge", 42.0); + + // Logging should not throw when logger provider is null + Assert.DoesNotThrow(() => Observe.RecordLog("test message", LogLevel.Information, null)); + } + + [Test] + public void Initialize_WithNullServiceName_UsesDefaults() + { + var configWithNullServiceName = new ObservabilityConfig( + otlpEndpoint: "https://test-endpoint.com", + backendUrl: "https://test-backend.com", + serviceName: null, + environment: "test", + serviceVersion: "1.0.0", + sdkKey: "test-key" + ); + + // Null service name doesn't break anything. + Assert.DoesNotThrow(() => Observe.Initialize(configWithNullServiceName, _loggerProvider)); + Assert.DoesNotThrow(() => Observe.RecordIncr("test-counter")); + } + + #endregion + + #region Exception Recording Tests + + [Test] + public void RecordException_WithActiveActivity_AddsExceptionToCurrentActivity() + { + Observe.Initialize(_config, _loggerProvider); + var exception = new InvalidOperationException("Test exception"); + + using (var activity = new ActivitySource("test").StartActivity("test-operation")) + { + var initialEventCount = activity?.Events.Count() ?? 0; + + Observe.RecordException(exception); + + Assert.That(activity, Is.Not.Null); + var events = activity.Events.ToList(); + Assert.That(events.Count, Is.EqualTo(initialEventCount + 1)); + var exceptionEvent = events.Last(); + Assert.That(exceptionEvent.Name, Is.EqualTo("exception")); + } + } + + [Test] + public void RecordException_WithoutActiveActivity_CreatesNewActivity() + { + Observe.Initialize(_config, _loggerProvider); + var exception = new InvalidOperationException("Test exception"); + + // Ensure no active activity + Assert.That(Activity.Current, Is.Null); + + Observe.RecordException(exception); + + // Assert - Verify an activity was created and exported + Assert.That(_exportedActivities.Count, Is.GreaterThan(0), "Should have created and exported an activity"); + var createdActivity = _exportedActivities.Last(); + Assert.That(createdActivity, Is.Not.Null); + Assert.That(createdActivity.DisplayName, Is.EqualTo("launchdarkly.error")); + + // Verify the exception was recorded + var exceptionEvent = createdActivity.Events.FirstOrDefault(e => e.Name == "exception"); + Assert.That(exceptionEvent.Name, Is.Not.Null, "Should have an exception event"); + } + + [Test] + public void RecordException_WithAttributes_AddsAttributesToException() + { + // Arrange + Observe.Initialize(_config, _loggerProvider); + var exception = new InvalidOperationException("Test exception"); + var attributes = new Dictionary + { + ["error.type"] = "validation", + ["error.code"] = 400 + }; + + using (var activity = new ActivitySource("test").StartActivity("test-operation")) + { + Observe.RecordException(exception, attributes); + + Assert.That(activity, Is.Not.Null); + var exceptionEvent = activity.Events.LastOrDefault(e => e.Name == "exception"); + Assert.That(exceptionEvent.Name, Is.Not.Null, "Should have recorded an exception event"); + + // Verify attributes were added + var tags = exceptionEvent.Tags.ToList(); + Assert.Multiple(() => + { + Assert.That(tags.Any(t => t.Key == "error.type" && t.Value.ToString() == "validation"), + Is.True, "Should have error.type attribute"); + Assert.That(tags.Any(t => t.Key == "error.code" && t.Value.ToString() == "400"), + Is.True, "Should have error.code attribute"); + }); + } + } + + [Test] + public void RecordException_WithNullException_DoesNotThrow() + { + Observe.Initialize(_config, _loggerProvider); + + Assert.DoesNotThrow(() => Observe.RecordException(null)); + } + + [Test] + public void RecordException_BeforeInitialization_DoesNotThrow() + { + var exception = new InvalidOperationException("Test exception"); + Assert.DoesNotThrow(() => Observe.RecordException(exception)); + } + + #endregion + + #region Metric Recording Tests + + [Test] + public void RecordMetric_WithValidName_RecordsGaugeValue() + { + Observe.Initialize(_config, _loggerProvider); + + Assert.DoesNotThrow(() => Observe.RecordMetric("cpu.usage", 75.5)); + Assert.DoesNotThrow(() => Observe.RecordMetric("memory.usage", 80.0)); + } + + [Test] + public void RecordMetric_WithAttributes_RecordsGaugeValueWithAttributes() + { + Observe.Initialize(_config, _loggerProvider); + var attributes = new Dictionary + { + ["host"] = "server-1", + ["region"] = "us-west-2" + }; + + Assert.DoesNotThrow(() => Observe.RecordMetric("cpu.usage", 75.5, attributes)); + } + + [Test] + public void RecordMetric_WithNullName_ThrowsArgumentNullException() + { + Observe.Initialize(_config, _loggerProvider); + + var exception = Assert.Throws(() => Observe.RecordMetric(null, 42.0)); + Assert.That(exception, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(exception.ParamName, Is.EqualTo("name")); + Assert.That(exception.Message, Does.Contain("Metric name cannot be null")); + }); + } + + [Test] + public void RecordMetric_BeforeInitialization_DoesNotThrow() + { + Assert.DoesNotThrow(() => Observe.RecordMetric("test.metric", 42.0)); + } + + [Test] + public void RecordCount_WithValidName_RecordsCounterValue() + { + Observe.Initialize(_config, _loggerProvider); + + Assert.DoesNotThrow(() => Observe.RecordCount("requests.total", 5)); + Assert.DoesNotThrow(() => Observe.RecordCount("errors.total", 1)); + } + + [Test] + public void RecordCount_WithAttributes_RecordsCounterValueWithAttributes() + { + Observe.Initialize(_config, _loggerProvider); + var attributes = new Dictionary + { + ["method"] = "GET", + ["status"] = 200 + }; + + Assert.DoesNotThrow(() => Observe.RecordCount("requests.total", 5, attributes)); + } + + [Test] + public void RecordCount_WithNullName_ThrowsArgumentNullException() + { + Observe.Initialize(_config, _loggerProvider); + + var exception = Assert.Throws(() => Observe.RecordCount(null, 1)); + Assert.That(exception, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(exception.ParamName, Is.EqualTo("name")); + Assert.That(exception.Message, Does.Contain("Count name cannot be null")); + }); + } + + [Test] + public void RecordIncr_WithValidName_IncrementsCounterByOne() + { + Observe.Initialize(_config, _loggerProvider); + + Assert.DoesNotThrow(() => Observe.RecordIncr("page.views")); + Assert.DoesNotThrow(() => Observe.RecordIncr("api.calls")); + } + + [Test] + public void RecordIncr_WithAttributes_IncrementsCounterWithAttributes() + { + Observe.Initialize(_config, _loggerProvider); + var attributes = new Dictionary + { + ["page"] = "home", + ["user_type"] = "premium" + }; + + Assert.DoesNotThrow(() => Observe.RecordIncr("page.views", attributes)); + } + + [Test] + public void RecordHistogram_WithValidName_RecordsHistogramValue() + { + Observe.Initialize(_config, _loggerProvider); + + Assert.DoesNotThrow(() => Observe.RecordHistogram("request.duration", 150.5)); + Assert.DoesNotThrow(() => Observe.RecordHistogram("response.size", 1024.0)); + } + + [Test] + public void RecordHistogram_WithAttributes_RecordsHistogramValueWithAttributes() + { + Observe.Initialize(_config, _loggerProvider); + var attributes = new Dictionary + { + ["endpoint"] = "/api/users", + ["method"] = "POST" + }; + + Assert.DoesNotThrow(() => Observe.RecordHistogram("request.duration", 150.5, attributes)); + } + + [Test] + public void RecordHistogram_WithNullName_ThrowsArgumentNullException() + { + Observe.Initialize(_config, _loggerProvider); + + var exception = Assert.Throws(() => Observe.RecordHistogram(null, 150.5)); + Assert.That(exception, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(exception.ParamName, Is.EqualTo("name")); + Assert.That(exception.Message, Does.Contain("Histogram name cannot be null")); + }); + } + + [Test] + public void RecordUpDownCounter_WithValidName_RecordsUpDownCounterValue() + { + Observe.Initialize(_config, _loggerProvider); + + Assert.DoesNotThrow(() => Observe.RecordUpDownCounter("active.connections", 5)); + Assert.DoesNotThrow(() => Observe.RecordUpDownCounter("active.connections", -2)); + } + + [Test] + public void RecordUpDownCounter_WithAttributes_RecordsUpDownCounterValueWithAttributes() + { + Observe.Initialize(_config, _loggerProvider); + var attributes = new Dictionary + { + ["server"] = "web-1", + ["protocol"] = "http" + }; + + Assert.DoesNotThrow(() => Observe.RecordUpDownCounter("active.connections", 5, attributes)); + } + + [Test] + public void RecordUpDownCounter_WithNullName_ThrowsArgumentNullException() + { + Observe.Initialize(_config, _loggerProvider); + + var exception = Assert.Throws(() => Observe.RecordUpDownCounter(null, 1)); + Assert.That(exception, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(exception.ParamName, Is.EqualTo("name")); + Assert.That(exception.Message, Does.Contain("UpDownCounter name cannot be null")); + }); + } + + #endregion + + #region Activity Creation Tests + + [Test] + public void StartActivity_WithValidName_ReturnsActivity() + { + // Arrange + Observe.Initialize(_config, _loggerProvider); + + // Act + using (var activity = Observe.StartActivity("test-operation")) + { + // Assert + Assert.That(activity, Is.Not.Null, "Activity should be created when ActivityListener is registered"); + Assert.That(activity.DisplayName, Is.EqualTo("test-operation")); + Assert.That(activity.Source.Name, Is.EqualTo("test-service")); + } + } + + [Test] + public void StartActivity_WithKindAndAttributes_ReturnsActivityWithAttributes() + { + // Arrange + Observe.Initialize(_config, _loggerProvider); + var attributes = new Dictionary + { + ["operation.type"] = "database", + ["table.name"] = "users" + }; + + // Act + using (var activity = Observe.StartActivity("db-query", ActivityKind.Client, attributes)) + { + // Assert + Assert.That(activity, Is.Not.Null, "Activity should be created when ActivityListener is registered"); + Assert.That(activity.DisplayName, Is.EqualTo("db-query")); + Assert.That(activity.Kind, Is.EqualTo(ActivityKind.Client)); + + // Verify attributes were added as tags + Assert.That(activity.GetTagItem("operation.type"), Is.EqualTo("database")); + Assert.That(activity.GetTagItem("table.name"), Is.EqualTo("users")); + } + } + + [Test] + public void StartActivity_BeforeInitialization_ReturnsNull() + { + // Act + var activity = Observe.StartActivity("test-operation"); + + // Assert + Assert.That(activity, Is.Null); + } + + [Test] + public void StartActivity_WithNullAttributes_DoesNotThrow() + { + // Arrange + Observe.Initialize(_config, _loggerProvider); + + // Act & Assert + Assert.DoesNotThrow(() => + { + using (var activity = Observe.StartActivity("test-operation", ActivityKind.Internal, null)) + { + // Activity usage + } + }); + } + + #endregion + + #region Logging Tests + + [Test] + public void RecordLog_WithMessage_LogsMessageCorrectly() + { + // Arrange + Observe.Initialize(_config, _loggerProvider); + var message = "Test log message"; + + // Act + Observe.RecordLog(message, LogLevel.Information, null); + + // Assert + var logger = _loggerProvider.Loggers.FirstOrDefault(); + Assert.That(logger, Is.Not.Null); + Assert.That(logger.LogEntries.Count, Is.EqualTo(1)); + Assert.That(logger.LogEntries[0].Message, Is.EqualTo(message)); + Assert.That(logger.LogEntries[0].LogLevel, Is.EqualTo(LogLevel.Information)); + } + + [Test] + public void RecordLog_WithAttributes_LogsMessageWithAttributes() + { + // Arrange + Observe.Initialize(_config, _loggerProvider); + var message = "Test log with attributes"; + var attributes = new Dictionary + { + ["user.id"] = "12345", + ["request.id"] = "abc-def-ghi" + }; + + // Act + Observe.RecordLog(message, LogLevel.Warning, attributes); + + // Assert + var logger = _loggerProvider.Loggers.FirstOrDefault(); + Assert.That(logger, Is.Not.Null); + Assert.That(logger.LogEntries.Count, Is.EqualTo(1)); + Assert.That(logger.LogEntries[0].Message, Is.EqualTo(message)); + Assert.That(logger.LogEntries[0].LogLevel, Is.EqualTo(LogLevel.Warning)); + Assert.That(logger.LogEntries[0].State, Is.EqualTo(attributes)); + } + + [Test] + public void RecordLog_WithDifferentLogLevels_LogsAtCorrectLevel() + { + // Arrange + Observe.Initialize(_config, _loggerProvider); + + // Act + Observe.RecordLog("Debug message", LogLevel.Debug, null); + Observe.RecordLog("Info message", LogLevel.Information, null); + Observe.RecordLog("Warning message", LogLevel.Warning, null); + Observe.RecordLog("Error message", LogLevel.Error, null); + + // Assert + var logger = _loggerProvider.Loggers.FirstOrDefault(); + Assert.That(logger, Is.Not.Null); + Assert.That(logger.LogEntries.Count, Is.EqualTo(4)); + Assert.That(logger.LogEntries[0].LogLevel, Is.EqualTo(LogLevel.Debug)); + Assert.That(logger.LogEntries[1].LogLevel, Is.EqualTo(LogLevel.Information)); + Assert.That(logger.LogEntries[2].LogLevel, Is.EqualTo(LogLevel.Warning)); + Assert.That(logger.LogEntries[3].LogLevel, Is.EqualTo(LogLevel.Error)); + } + + [Test] + public void RecordLog_WithNullLoggerProvider_DoesNotThrow() + { + // Arrange + Observe.Initialize(_config, null); + + // Act & Assert + Assert.DoesNotThrow(() => Observe.RecordLog("Test message", LogLevel.Information, null)); + } + + [Test] + public void RecordLog_WithEmptyAttributes_LogsCorrectly() + { + // Arrange + Observe.Initialize(_config, _loggerProvider); + var emptyAttributes = new Dictionary(); + + // Act + Observe.RecordLog("Test message", LogLevel.Information, emptyAttributes); + + // Assert + var logger = _loggerProvider.Loggers.FirstOrDefault(); + Assert.That(logger, Is.Not.Null); + Assert.That(logger.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public void RecordLog_BeforeInitialization_DoesNotThrow() + { + // Act & Assert + Assert.DoesNotThrow(() => Observe.RecordLog("Test message", LogLevel.Information, null)); + } + + #endregion + + #region Utility Method Tests + + [Test] + public void ConvertToKeyValuePairs_WithNullDictionary_ReturnsNull() + { + // This tests the private method indirectly through public methods + // Arrange + Observe.Initialize(_config, _loggerProvider); + + // Act & Assert - Should not throw with null attributes + Assert.DoesNotThrow(() => Observe.RecordMetric("test.metric", 42.0, null)); + Assert.DoesNotThrow(() => Observe.RecordCount("test.counter", 1, null)); + Assert.DoesNotThrow(() => Observe.RecordHistogram("test.histogram", 150.0, null)); + Assert.DoesNotThrow(() => Observe.RecordUpDownCounter("test.updown", 1, null)); + } + + [Test] + public void ConvertToKeyValuePairs_WithEmptyDictionary_ReturnsNull() + { + // This tests the private method indirectly through public methods + // Arrange + Observe.Initialize(_config, _loggerProvider); + var emptyAttributes = new Dictionary(); + + // Act & Assert - Should not throw with empty attributes + Assert.DoesNotThrow(() => Observe.RecordMetric("test.metric", 42.0, emptyAttributes)); + Assert.DoesNotThrow(() => Observe.RecordCount("test.counter", 1, emptyAttributes)); + Assert.DoesNotThrow(() => Observe.RecordHistogram("test.histogram", 150.0, emptyAttributes)); + Assert.DoesNotThrow(() => Observe.RecordUpDownCounter("test.updown", 1, emptyAttributes)); + } + + #endregion + + #region Multiple Metrics of Same Name Tests + + [Test] + public void RecordMetric_WithSameName_ReusesGaugeInstance() + { + // Arrange + Observe.Initialize(_config, _loggerProvider); + var metricName = "cpu.usage"; + + // Act - Record multiple values for the same metric + Assert.DoesNotThrow(() => Observe.RecordMetric(metricName, 50.0)); + Assert.DoesNotThrow(() => Observe.RecordMetric(metricName, 75.0)); + Assert.DoesNotThrow(() => Observe.RecordMetric(metricName, 90.0)); + + // Assert - Should not throw, indicating proper reuse of gauge instance + Assert.Pass("Multiple metrics with same name recorded successfully"); + } + + [Test] + public void RecordCount_WithSameName_ReusesCounterInstance() + { + // Arrange + Observe.Initialize(_config, _loggerProvider); + var counterName = "requests.total"; + + // Act - Record multiple values for the same counter + Assert.DoesNotThrow(() => Observe.RecordCount(counterName, 1)); + Assert.DoesNotThrow(() => Observe.RecordCount(counterName, 5)); + Assert.DoesNotThrow(() => Observe.RecordCount(counterName, 10)); + + // Assert - Should not throw, indicating proper reuse of counter instance + Assert.Pass("Multiple counters with same name recorded successfully"); + } + + #endregion + } +} From e6bcae41b104385ef4440ca6516463408e15c92b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 19 Aug 2025 13:51:35 -0700 Subject: [PATCH 05/10] Remove redundant comments. --- .../ObserveTests.cs | 53 +++---------------- 1 file changed, 7 insertions(+), 46 deletions(-) diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs index 689333b2e..eb4a83b45 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs @@ -210,7 +210,7 @@ public void RecordException_WithoutActiveActivity_CreatesNewActivity() Observe.RecordException(exception); - // Assert - Verify an activity was created and exported + // Verify an activity was created and exported Assert.That(_exportedActivities.Count, Is.GreaterThan(0), "Should have created and exported an activity"); var createdActivity = _exportedActivities.Last(); Assert.That(createdActivity, Is.Not.Null); @@ -224,7 +224,6 @@ public void RecordException_WithoutActiveActivity_CreatesNewActivity() [Test] public void RecordException_WithAttributes_AddsAttributesToException() { - // Arrange Observe.Initialize(_config, _loggerProvider); var exception = new InvalidOperationException("Test exception"); var attributes = new Dictionary @@ -451,13 +450,10 @@ public void RecordUpDownCounter_WithNullName_ThrowsArgumentNullException() [Test] public void StartActivity_WithValidName_ReturnsActivity() { - // Arrange Observe.Initialize(_config, _loggerProvider); - // Act using (var activity = Observe.StartActivity("test-operation")) { - // Assert Assert.That(activity, Is.Not.Null, "Activity should be created when ActivityListener is registered"); Assert.That(activity.DisplayName, Is.EqualTo("test-operation")); Assert.That(activity.Source.Name, Is.EqualTo("test-service")); @@ -467,7 +463,6 @@ public void StartActivity_WithValidName_ReturnsActivity() [Test] public void StartActivity_WithKindAndAttributes_ReturnsActivityWithAttributes() { - // Arrange Observe.Initialize(_config, _loggerProvider); var attributes = new Dictionary { @@ -475,10 +470,8 @@ public void StartActivity_WithKindAndAttributes_ReturnsActivityWithAttributes() ["table.name"] = "users" }; - // Act using (var activity = Observe.StartActivity("db-query", ActivityKind.Client, attributes)) { - // Assert Assert.That(activity, Is.Not.Null, "Activity should be created when ActivityListener is registered"); Assert.That(activity.DisplayName, Is.EqualTo("db-query")); Assert.That(activity.Kind, Is.EqualTo(ActivityKind.Client)); @@ -492,25 +485,18 @@ public void StartActivity_WithKindAndAttributes_ReturnsActivityWithAttributes() [Test] public void StartActivity_BeforeInitialization_ReturnsNull() { - // Act var activity = Observe.StartActivity("test-operation"); - - // Assert Assert.That(activity, Is.Null); } [Test] public void StartActivity_WithNullAttributes_DoesNotThrow() { - // Arrange Observe.Initialize(_config, _loggerProvider); - - // Act & Assert Assert.DoesNotThrow(() => { using (var activity = Observe.StartActivity("test-operation", ActivityKind.Internal, null)) { - // Activity usage } }); } @@ -522,14 +508,10 @@ public void StartActivity_WithNullAttributes_DoesNotThrow() [Test] public void RecordLog_WithMessage_LogsMessageCorrectly() { - // Arrange Observe.Initialize(_config, _loggerProvider); var message = "Test log message"; - // Act Observe.RecordLog(message, LogLevel.Information, null); - - // Assert var logger = _loggerProvider.Loggers.FirstOrDefault(); Assert.That(logger, Is.Not.Null); Assert.That(logger.LogEntries.Count, Is.EqualTo(1)); @@ -540,7 +522,6 @@ public void RecordLog_WithMessage_LogsMessageCorrectly() [Test] public void RecordLog_WithAttributes_LogsMessageWithAttributes() { - // Arrange Observe.Initialize(_config, _loggerProvider); var message = "Test log with attributes"; var attributes = new Dictionary @@ -549,10 +530,7 @@ public void RecordLog_WithAttributes_LogsMessageWithAttributes() ["request.id"] = "abc-def-ghi" }; - // Act Observe.RecordLog(message, LogLevel.Warning, attributes); - - // Assert var logger = _loggerProvider.Loggers.FirstOrDefault(); Assert.That(logger, Is.Not.Null); Assert.That(logger.LogEntries.Count, Is.EqualTo(1)); @@ -564,16 +542,11 @@ public void RecordLog_WithAttributes_LogsMessageWithAttributes() [Test] public void RecordLog_WithDifferentLogLevels_LogsAtCorrectLevel() { - // Arrange Observe.Initialize(_config, _loggerProvider); - - // Act Observe.RecordLog("Debug message", LogLevel.Debug, null); Observe.RecordLog("Info message", LogLevel.Information, null); Observe.RecordLog("Warning message", LogLevel.Warning, null); Observe.RecordLog("Error message", LogLevel.Error, null); - - // Assert var logger = _loggerProvider.Loggers.FirstOrDefault(); Assert.That(logger, Is.Not.Null); Assert.That(logger.LogEntries.Count, Is.EqualTo(4)); @@ -586,24 +559,17 @@ public void RecordLog_WithDifferentLogLevels_LogsAtCorrectLevel() [Test] public void RecordLog_WithNullLoggerProvider_DoesNotThrow() { - // Arrange Observe.Initialize(_config, null); - - // Act & Assert Assert.DoesNotThrow(() => Observe.RecordLog("Test message", LogLevel.Information, null)); } [Test] public void RecordLog_WithEmptyAttributes_LogsCorrectly() { - // Arrange Observe.Initialize(_config, _loggerProvider); var emptyAttributes = new Dictionary(); - // Act Observe.RecordLog("Test message", LogLevel.Information, emptyAttributes); - - // Assert var logger = _loggerProvider.Loggers.FirstOrDefault(); Assert.That(logger, Is.Not.Null); Assert.That(logger.LogEntries.Count, Is.EqualTo(1)); @@ -612,7 +578,6 @@ public void RecordLog_WithEmptyAttributes_LogsCorrectly() [Test] public void RecordLog_BeforeInitialization_DoesNotThrow() { - // Act & Assert Assert.DoesNotThrow(() => Observe.RecordLog("Test message", LogLevel.Information, null)); } @@ -624,10 +589,9 @@ public void RecordLog_BeforeInitialization_DoesNotThrow() public void ConvertToKeyValuePairs_WithNullDictionary_ReturnsNull() { // This tests the private method indirectly through public methods - // Arrange Observe.Initialize(_config, _loggerProvider); - // Act & Assert - Should not throw with null attributes + // Should not throw with null attributes Assert.DoesNotThrow(() => Observe.RecordMetric("test.metric", 42.0, null)); Assert.DoesNotThrow(() => Observe.RecordCount("test.counter", 1, null)); Assert.DoesNotThrow(() => Observe.RecordHistogram("test.histogram", 150.0, null)); @@ -638,11 +602,10 @@ public void ConvertToKeyValuePairs_WithNullDictionary_ReturnsNull() public void ConvertToKeyValuePairs_WithEmptyDictionary_ReturnsNull() { // This tests the private method indirectly through public methods - // Arrange Observe.Initialize(_config, _loggerProvider); var emptyAttributes = new Dictionary(); - // Act & Assert - Should not throw with empty attributes + // Should not throw with empty attributes Assert.DoesNotThrow(() => Observe.RecordMetric("test.metric", 42.0, emptyAttributes)); Assert.DoesNotThrow(() => Observe.RecordCount("test.counter", 1, emptyAttributes)); Assert.DoesNotThrow(() => Observe.RecordHistogram("test.histogram", 150.0, emptyAttributes)); @@ -656,32 +619,30 @@ public void ConvertToKeyValuePairs_WithEmptyDictionary_ReturnsNull() [Test] public void RecordMetric_WithSameName_ReusesGaugeInstance() { - // Arrange Observe.Initialize(_config, _loggerProvider); var metricName = "cpu.usage"; - // Act - Record multiple values for the same metric + // Record multiple values for the same metric Assert.DoesNotThrow(() => Observe.RecordMetric(metricName, 50.0)); Assert.DoesNotThrow(() => Observe.RecordMetric(metricName, 75.0)); Assert.DoesNotThrow(() => Observe.RecordMetric(metricName, 90.0)); - // Assert - Should not throw, indicating proper reuse of gauge instance + // Should not throw, indicating proper reuse of gauge instance Assert.Pass("Multiple metrics with same name recorded successfully"); } [Test] public void RecordCount_WithSameName_ReusesCounterInstance() { - // Arrange Observe.Initialize(_config, _loggerProvider); var counterName = "requests.total"; - // Act - Record multiple values for the same counter + // Record multiple values for the same counter Assert.DoesNotThrow(() => Observe.RecordCount(counterName, 1)); Assert.DoesNotThrow(() => Observe.RecordCount(counterName, 5)); Assert.DoesNotThrow(() => Observe.RecordCount(counterName, 10)); - // Assert - Should not throw, indicating proper reuse of counter instance + // Should not throw, indicating proper reuse of counter instance Assert.Pass("Multiple counters with same name recorded successfully"); } From 50160bc97ac491d09a954b9daff5dccda3b01e6b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:02:22 -0700 Subject: [PATCH 06/10] Complete singleton tests. --- .../ObserveTests.cs | 693 ++++++++++++++++-- 1 file changed, 617 insertions(+), 76 deletions(-) diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs index eb4a83b45..826087839 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObserveTests.cs @@ -5,7 +5,7 @@ using System.Reflection; using Microsoft.Extensions.Logging; using NUnit.Framework; -using OpenTelemetry; +using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -54,7 +54,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except EventId = eventId, State = state, Exception = exception, - Message = formatter?.Invoke(state, exception) + Message = formatter.Invoke(state, exception) }); } } @@ -71,8 +71,10 @@ private class LogEntry private TestLoggerProvider _loggerProvider; private ObservabilityConfig _config; private TracerProvider _tracerProvider; + private MeterProvider _meterProvider; private ActivityListener _activityListener; private List _exportedActivities; + private List _exportedMetrics; [SetUp] public void SetUp() @@ -92,6 +94,7 @@ public void SetUp() // Set up OpenTelemetry _exportedActivities = new List(); + _exportedMetrics = new List(); // Create an ActivityListener to ensure activities are created _activityListener = new ActivityListener @@ -113,6 +116,14 @@ public void SetUp() .AddService("test-service", "test", "1.0.0")) .AddInMemoryExporter(_exportedActivities) .Build(); + + // Set up MeterProvider with in-memory exporter + _meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder() + .AddMeter("test-service") + .SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService("test-service", "test", "1.0.0")) + .AddInMemoryExporter(_exportedMetrics) + .Build(); } [TearDown] @@ -120,8 +131,10 @@ public void TearDown() { _loggerProvider?.Dispose(); _tracerProvider?.Dispose(); + _meterProvider?.Dispose(); _activityListener?.Dispose(); _exportedActivities?.Clear(); + _exportedMetrics?.Clear(); ResetObserveInstance(); } @@ -137,42 +150,135 @@ private static void ResetObserveInstance() [Test] public void Initialize_WithValidConfig_SetsUpInstanceCorrectly() { - Observe.Initialize(_config, _loggerProvider); + Assert.DoesNotThrow(() => Observe.Initialize(_config, _loggerProvider)); + } + + [Test] + public void Initialize_WithNullLoggerProvider_WorksCorrectly() + { + Assert.DoesNotThrow(() => Observe.Initialize(_config, null)); + } + + [Test] + public void Initialize_WithNullServiceName_UsesDefaults() + { + var configWithNullServiceName = new ObservabilityConfig( + otlpEndpoint: "https://test-endpoint.com", + backendUrl: "https://test-backend.com", + serviceName: null, + environment: "test", + serviceVersion: "1.0.0", + sdkKey: "test-key" + ); + + // Reset meter provider to use default service name + _meterProvider?.Dispose(); + _meterProvider = OpenTelemetry.Sdk.CreateMeterProviderBuilder() + .AddMeter("launchdarkly-plugin-default-metrics") // Default meter name + .SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService("launchdarkly-plugin-default-metrics", serviceVersion: "1.0.0")) + .AddInMemoryExporter(_exportedMetrics) + .Build(); + + // Initialize with null service name + Observe.Initialize(configWithNullServiceName, _loggerProvider); + // Record a metric to verify it works with defaults Observe.RecordIncr("test-counter"); - Observe.RecordMetric("test-gauge", 42.0); - // Just making sure nothing before this point threw. - Assert.Pass("Initialization successful - no exceptions thrown during metric recording"); + // Force export + _meterProvider.ForceFlush(); + + // Verify metric was exported using the default meter + Assert.That(_exportedMetrics, Is.Not.Empty, "Should have exported metrics with default meter"); + var metric = _exportedMetrics.FirstOrDefault(m => m.Name == "test-counter"); + Assert.That(metric, Is.Not.Null, "Should have exported test-counter metric using defaults"); + + // Verify an activity can be created with a default activity source + using (Observe.StartActivity("test-operation")) + { + // Activity might be null if no listener is registered for the default source, + // but the important thing is it doesn't throw + } + + // Verify logging works with default logger name + Observe.RecordLog("Test message with defaults", LogLevel.Information, null); + var logger = _loggerProvider.Loggers.FirstOrDefault(); + Assert.That(logger, Is.Not.Null, "Should have created a logger"); + Assert.That(logger.CategoryName, Is.EqualTo("launchdarkly-plugin-default-logger"), + "Should use default logger name when service name is null"); } [Test] - public void Initialize_WithNullLoggerProvider_WorksCorrectly() + public void Initialize_WithEmptyServiceName_UsesEmptyString() { - Observe.Initialize(_config, null); + var configWithEmptyServiceName = new ObservabilityConfig( + otlpEndpoint: "https://test-endpoint.com", + backendUrl: "https://test-backend.com", + serviceName: "", + environment: "test", + serviceVersion: "1.0.0", + sdkKey: "test-key" + ); - Observe.RecordIncr("test-counter"); - Observe.RecordMetric("test-gauge", 42.0); + // Note: We can't test meter creation with empty string because OpenTelemetry doesn't allow it + // But we can test that Observe itself handles empty string without crashing + + // Initialize with empty service name + Observe.Initialize(configWithEmptyServiceName, _loggerProvider); - // Logging should not throw when logger provider is null - Assert.DoesNotThrow(() => Observe.RecordLog("test message", LogLevel.Information, null)); + // Try to record a metric - the Meter constructor will use empty string + // This should work because .NET's Meter class accepts empty string + Assert.DoesNotThrow(() => Observe.RecordMetric("test.gauge", 42.0), + "Should be able to record metric even with empty service name"); + + // Verify logging uses empty string, not default + Observe.RecordLog("Test message with empty service name", LogLevel.Warning, null); + var logger = _loggerProvider.Loggers.FirstOrDefault(); + Assert.That(logger, Is.Not.Null, "Should have created a logger"); + // Empty string is used as-is (not replaced with default) because the code uses ?? operator + Assert.That(logger.CategoryName, Is.EqualTo(""), + "Should use empty string as logger name when service name is empty (not null)"); + + // Verify activity source also uses empty string + // Note: Activity with empty source name might not be sampled by our listener + // but it should not throw + Assert.DoesNotThrow(() => + { + using (Observe.StartActivity("test-operation")) + { + // Activity might be null but shouldn't throw + } + }, "Should be able to start activity even with empty service name"); } [Test] - public void Initialize_WithNullServiceName_UsesDefaults() + public void Initialize_WithWhitespaceServiceName_UsesWhitespaceString() { - var configWithNullServiceName = new ObservabilityConfig( + var configWithWhitespaceServiceName = new ObservabilityConfig( otlpEndpoint: "https://test-endpoint.com", backendUrl: "https://test-backend.com", - serviceName: null, + serviceName: " ", environment: "test", serviceVersion: "1.0.0", sdkKey: "test-key" ); - // Null service name doesn't break anything. - Assert.DoesNotThrow(() => Observe.Initialize(configWithNullServiceName, _loggerProvider)); - Assert.DoesNotThrow(() => Observe.RecordIncr("test-counter")); + // Initialize with whitespace service name + Observe.Initialize(configWithWhitespaceServiceName, _loggerProvider); + + // Try to record a metric - the Meter constructor will use whitespace string + // This should work because .NET's Meter class accepts whitespace + Assert.DoesNotThrow(() => Observe.RecordMetric("test.gauge", 42.0), + "Should be able to record metric even with whitespace service name"); + + // Verify logging uses whitespace string, not default + Observe.RecordLog("Test message with whitespace service name", LogLevel.Warning, null); + var logger = _loggerProvider.Loggers.FirstOrDefault(); + Assert.That(logger, Is.Not.Null, "Should have created a logger"); + // Whitespace string is used as-is (not replaced with default) because the code uses ?? operator + Assert.That(logger.CategoryName, Is.EqualTo(" "), + "Should use whitespace string as logger name when service name is whitespace (not null)"); } #endregion @@ -193,7 +299,7 @@ public void RecordException_WithActiveActivity_AddsExceptionToCurrentActivity() Assert.That(activity, Is.Not.Null); var events = activity.Events.ToList(); - Assert.That(events.Count, Is.EqualTo(initialEventCount + 1)); + Assert.That(events, Has.Count.EqualTo(initialEventCount + 1)); var exceptionEvent = events.Last(); Assert.That(exceptionEvent.Name, Is.EqualTo("exception")); } @@ -211,7 +317,7 @@ public void RecordException_WithoutActiveActivity_CreatesNewActivity() Observe.RecordException(exception); // Verify an activity was created and exported - Assert.That(_exportedActivities.Count, Is.GreaterThan(0), "Should have created and exported an activity"); + Assert.That(_exportedActivities, Is.Not.Empty, "Should have created and exported an activity"); var createdActivity = _exportedActivities.Last(); Assert.That(createdActivity, Is.Not.Null); Assert.That(createdActivity.DisplayName, Is.EqualTo("launchdarkly.error")); @@ -244,9 +350,9 @@ public void RecordException_WithAttributes_AddsAttributesToException() var tags = exceptionEvent.Tags.ToList(); Assert.Multiple(() => { - Assert.That(tags.Any(t => t.Key == "error.type" && t.Value.ToString() == "validation"), + Assert.That(tags.Any(t => t.Key == "error.type" && t.Value?.ToString() == "validation"), Is.True, "Should have error.type attribute"); - Assert.That(tags.Any(t => t.Key == "error.code" && t.Value.ToString() == "400"), + Assert.That(tags.Any(t => t.Key == "error.code" && t.Value?.ToString() == "400"), Is.True, "Should have error.code attribute"); }); } @@ -276,8 +382,31 @@ public void RecordMetric_WithValidName_RecordsGaugeValue() { Observe.Initialize(_config, _loggerProvider); - Assert.DoesNotThrow(() => Observe.RecordMetric("cpu.usage", 75.5)); - Assert.DoesNotThrow(() => Observe.RecordMetric("memory.usage", 80.0)); + // Record metrics + Observe.RecordMetric("cpu.usage", 75.5); + Observe.RecordMetric("memory.usage", 80.0); + + // Force export + _meterProvider.ForceFlush(); + + // Verify metrics were exported + Assert.That(_exportedMetrics, Is.Not.Empty, "Should have exported metrics"); + + // Find the cpu.usage metric + var cpuMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "cpu.usage"); + Assert.That(cpuMetric, Is.Not.Null, "Should have exported cpu.usage metric"); + + // Find the memory.usage metric + var memoryMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "memory.usage"); + Assert.Multiple(() => + { + Assert.That(memoryMetric, Is.Not.Null, "Should have exported memory.usage metric"); + + // Verify the metric type is gauge + Assert.That(cpuMetric.MetricType, Is.EqualTo(MetricType.DoubleGauge), "cpu.usage should be a gauge"); + }); + Assert.That(memoryMetric, Is.Not.Null, "Should have exported memory.usage metric"); + Assert.That(memoryMetric.MetricType, Is.EqualTo(MetricType.DoubleGauge), "memory.usage should be a gauge"); } [Test] @@ -290,7 +419,47 @@ public void RecordMetric_WithAttributes_RecordsGaugeValueWithAttributes() ["region"] = "us-west-2" }; - Assert.DoesNotThrow(() => Observe.RecordMetric("cpu.usage", 75.5, attributes)); + // Record metric with attributes + Observe.RecordMetric("cpu.usage", 75.5, attributes); + + // Force export + _meterProvider.ForceFlush(); + + // Verify metric was exported + Assert.That(_exportedMetrics, Is.Not.Empty, "Should have exported metrics"); + + var cpuMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "cpu.usage"); + Assert.That(cpuMetric, Is.Not.Null, "Should have exported cpu.usage metric"); + + // Verify attributes were included + var metricPoints = cpuMetric.GetMetricPoints(); + var metricPointsEnumerator = metricPoints.GetEnumerator(); + Assert.That(metricPointsEnumerator.MoveNext(), Is.True, "Should have metric points"); + + var metricPoint = metricPointsEnumerator.Current; + var tags = metricPoint.Tags; + + // Check if attributes are present + object hostValue = null; + object regionValue = null; + foreach (var tag in tags) + { + switch (tag.Key) + { + case "host": + hostValue = tag.Value; + break; + case "region": + regionValue = tag.Value; + break; + } + } + + Assert.Multiple(() => + { + Assert.That(hostValue, Is.EqualTo("server-1"), "Should have host attribute"); + Assert.That(regionValue, Is.EqualTo("us-west-2"), "Should have region attribute"); + }); } [Test] @@ -318,8 +487,60 @@ public void RecordCount_WithValidName_RecordsCounterValue() { Observe.Initialize(_config, _loggerProvider); - Assert.DoesNotThrow(() => Observe.RecordCount("requests.total", 5)); - Assert.DoesNotThrow(() => Observe.RecordCount("errors.total", 1)); + // Record counters + Observe.RecordCount("requests.total", 5); + Observe.RecordCount("errors.total", 1); + + // Force export + _meterProvider.ForceFlush(); + + // Verify metrics were exported + Assert.That(_exportedMetrics, Is.Not.Empty, "Should have exported metrics"); + + // Find the counters + var requestsMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "requests.total"); + var errorsMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "errors.total"); + + Assert.Multiple(() => + { + Assert.That(requestsMetric, Is.Not.Null, "Should have exported requests.total metric"); + Assert.That(errorsMetric, Is.Not.Null, "Should have exported errors.total metric"); + }); + + Assert.That(requestsMetric, Is.Not.Null, "Should have exported requests.total metric"); + Assert.That(errorsMetric, Is.Not.Null, "Should have exported errors.total metric"); + Assert.Multiple(() => + { + // Verify the metric type is counter + Assert.That(requestsMetric.MetricType, + Is.EqualTo(MetricType.LongSumNonMonotonic).Or.EqualTo(MetricType.LongSum), + "requests.total should be a counter"); + Assert.That(errorsMetric.MetricType, + Is.EqualTo(MetricType.LongSumNonMonotonic).Or.EqualTo(MetricType.LongSum), + "errors.total should be a counter"); + }); + + // Verify counter values + var requestsPoints = requestsMetric.GetMetricPoints(); + var errorsPoints = errorsMetric.GetMetricPoints(); + + var requestsEnumerator = requestsPoints.GetEnumerator(); + var errorsEnumerator = errorsPoints.GetEnumerator(); + + Assert.Multiple(() => + { + Assert.That(requestsEnumerator.MoveNext(), Is.True, "Should have request metric points"); + Assert.That(errorsEnumerator.MoveNext(), Is.True, "Should have error metric points"); + }); + + var requestPoint = requestsEnumerator.Current; + var errorPoint = errorsEnumerator.Current; + + Assert.Multiple(() => + { + Assert.That(requestPoint.GetSumLong(), Is.EqualTo(5), "Requests counter should have value 5"); + Assert.That(errorPoint.GetSumLong(), Is.EqualTo(1), "Errors counter should have value 1"); + }); } [Test] @@ -332,7 +553,47 @@ public void RecordCount_WithAttributes_RecordsCounterValueWithAttributes() ["status"] = 200 }; - Assert.DoesNotThrow(() => Observe.RecordCount("requests.total", 5, attributes)); + // Record counter with attributes + Observe.RecordCount("requests.total", 5, attributes); + + // Force export + _meterProvider.ForceFlush(); + + // Verify metric was exported + Assert.That(_exportedMetrics, Is.Not.Empty, "Should have exported metrics"); + + var requestsMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "requests.total"); + Assert.That(requestsMetric, Is.Not.Null, "Should have exported requests.total metric"); + + // Verify attributes were included + var metricPoints = requestsMetric.GetMetricPoints(); + var metricPointsEnumerator = metricPoints.GetEnumerator(); + Assert.That(metricPointsEnumerator.MoveNext(), Is.True, "Should have metric points"); + + var metricPoint = metricPointsEnumerator.Current; + var tags = metricPoint.Tags; + + // Check if attributes are present + object methodValue = null; + object statusValue = null; + foreach (var tag in tags) + { + switch (tag.Key) + { + case "method": + methodValue = tag.Value; + break; + case "status": + statusValue = tag.Value; + break; + } + } + + Assert.Multiple(() => + { + Assert.That(methodValue, Is.EqualTo("GET"), "Should have method attribute"); + Assert.That(statusValue?.ToString(), Is.EqualTo("200"), "Should have status attribute"); + }); } [Test] @@ -354,8 +615,51 @@ public void RecordIncr_WithValidName_IncrementsCounterByOne() { Observe.Initialize(_config, _loggerProvider); - Assert.DoesNotThrow(() => Observe.RecordIncr("page.views")); - Assert.DoesNotThrow(() => Observe.RecordIncr("api.calls")); + // Record increments + Observe.RecordIncr("page.views"); + Observe.RecordIncr("api.calls"); + Observe.RecordIncr("api.calls"); // Call twice to verify increment + + // Force export + _meterProvider.ForceFlush(); + + // Verify metrics were exported + Assert.That(_exportedMetrics.Count, Is.GreaterThan(0), "Should have exported metrics"); + + // Find the counters + var pageViewsMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "page.views"); + var apiCallsMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "api.calls"); + + Assert.Multiple(() => + { + Assert.That(pageViewsMetric, Is.Not.Null, "Should have exported page.views metric"); + Assert.That(apiCallsMetric, Is.Not.Null, "Should have exported api.calls metric"); + }); + + Assert.That(pageViewsMetric, Is.Not.Null); + Assert.That(apiCallsMetric, Is.Not.Null); + // Verify the values (RecordIncr should increment by 1 each time) + var pageViewsPoints = pageViewsMetric.GetMetricPoints(); + var apiCallsPoints = apiCallsMetric.GetMetricPoints(); + + var pageViewsEnumerator = pageViewsPoints.GetEnumerator(); + var apiCallsEnumerator = apiCallsPoints.GetEnumerator(); + + Assert.Multiple(() => + { + Assert.That(pageViewsEnumerator.MoveNext(), Is.True, "Should have page views metric points"); + Assert.That(apiCallsEnumerator.MoveNext(), Is.True, "Should have api calls metric points"); + }); + + var pageViewsPoint = pageViewsEnumerator.Current; + var apiCallsPoint = apiCallsEnumerator.Current; + + Assert.Multiple(() => + { + Assert.That(pageViewsPoint.GetSumLong(), Is.EqualTo(1), "Page views should be incremented by 1"); + Assert.That(apiCallsPoint.GetSumLong(), Is.EqualTo(2), + "API calls should be incremented by 2 (called twice)"); + }); } [Test] @@ -368,7 +672,48 @@ public void RecordIncr_WithAttributes_IncrementsCounterWithAttributes() ["user_type"] = "premium" }; - Assert.DoesNotThrow(() => Observe.RecordIncr("page.views", attributes)); + // Record increment with attributes + Observe.RecordIncr("page.views", attributes); + + // Force export + _meterProvider.ForceFlush(); + + // Verify metric was exported + Assert.That(_exportedMetrics, Is.Not.Empty, "Should have exported metrics"); + + var pageViewsMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "page.views"); + Assert.That(pageViewsMetric, Is.Not.Null, "Should have exported page.views metric"); + + // Verify attributes were included + var metricPoints = pageViewsMetric.GetMetricPoints(); + var metricPointsEnumerator = metricPoints.GetEnumerator(); + Assert.That(metricPointsEnumerator.MoveNext(), Is.True, "Should have metric points"); + + var metricPoint = metricPointsEnumerator.Current; + var tags = metricPoint.Tags; + + // Check if attributes are present + object pageValue = null; + object userTypeValue = null; + foreach (var tag in tags) + { + switch (tag.Key) + { + case "page": + pageValue = tag.Value; + break; + case "user_type": + userTypeValue = tag.Value; + break; + } + } + + Assert.Multiple(() => + { + Assert.That(pageValue, Is.EqualTo("home"), "Should have page attribute"); + Assert.That(userTypeValue, Is.EqualTo("premium"), "Should have user_type attribute"); + Assert.That(metricPoint.GetSumLong(), Is.EqualTo(1), "Should have incremented by 1"); + }); } [Test] @@ -376,8 +721,46 @@ public void RecordHistogram_WithValidName_RecordsHistogramValue() { Observe.Initialize(_config, _loggerProvider); - Assert.DoesNotThrow(() => Observe.RecordHistogram("request.duration", 150.5)); - Assert.DoesNotThrow(() => Observe.RecordHistogram("response.size", 1024.0)); + // Record histogram values + Observe.RecordHistogram("request.duration", 150.5); + Observe.RecordHistogram("request.duration", 200.0); + Observe.RecordHistogram("request.duration", 100.0); + Observe.RecordHistogram("response.size", 1024.0); + + // Force export + _meterProvider.ForceFlush(); + + // Verify metrics were exported + Assert.That(_exportedMetrics, Is.Not.Empty, "Should have exported metrics"); + + // Find the histograms + var durationMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "request.duration"); + var sizeMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "response.size"); + + Assert.That(durationMetric, Is.Not.Null, "Should have exported request.duration metric"); + Assert.That(sizeMetric, Is.Not.Null, "Should have exported response.size metric"); + + // Verify the metric type is histogram + Assert.That(durationMetric.MetricType, Is.EqualTo(MetricType.Histogram), + "request.duration should be a histogram"); + Assert.That(sizeMetric.MetricType, Is.EqualTo(MetricType.Histogram), + "response.size should be a histogram"); + + // Verify histogram data + var durationPoints = durationMetric.GetMetricPoints(); + var durationEnumerator = durationPoints.GetEnumerator(); + Assert.That(durationEnumerator.MoveNext(), Is.True, "Should have duration metric points"); + + var durationPoint = durationEnumerator.Current; + var histogramData = durationPoint.GetHistogramSum(); + var histogramCount = durationPoint.GetHistogramCount(); + + Assert.Multiple(() => + { + // We recorded 3 values: 150.5, 200.0, 100.0 + Assert.That(histogramCount, Is.EqualTo(3), "Should have recorded 3 histogram values"); + Assert.That(histogramData, Is.EqualTo(450.5), "Sum should be 450.5 (150.5 + 200 + 100)"); + }); } [Test] @@ -390,7 +773,50 @@ public void RecordHistogram_WithAttributes_RecordsHistogramValueWithAttributes() ["method"] = "POST" }; - Assert.DoesNotThrow(() => Observe.RecordHistogram("request.duration", 150.5, attributes)); + // Record histogram with attributes + Observe.RecordHistogram("request.duration", 150.5, attributes); + Observe.RecordHistogram("request.duration", 75.0, attributes); + + // Force export + _meterProvider.ForceFlush(); + + // Verify metric was exported + Assert.That(_exportedMetrics.Count, Is.GreaterThan(0), "Should have exported metrics"); + + var durationMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "request.duration"); + Assert.That(durationMetric, Is.Not.Null, "Should have exported request.duration metric"); + + // Verify attributes were included + var metricPoints = durationMetric.GetMetricPoints(); + var metricPointsEnumerator = metricPoints.GetEnumerator(); + Assert.That(metricPointsEnumerator.MoveNext(), Is.True, "Should have metric points"); + + var metricPoint = metricPointsEnumerator.Current; + var tags = metricPoint.Tags; + + // Check if attributes are present + object endpointValue = null; + object methodValue = null; + foreach (var tag in tags) + { + switch (tag.Key) + { + case "endpoint": + endpointValue = tag.Value; + break; + case "method": + methodValue = tag.Value; + break; + } + } + + Assert.Multiple(() => + { + Assert.That(endpointValue, Is.EqualTo("/api/users"), "Should have endpoint attribute"); + Assert.That(methodValue, Is.EqualTo("POST"), "Should have method attribute"); + Assert.That(metricPoint.GetHistogramCount(), Is.EqualTo(2), "Should have recorded 2 values"); + Assert.That(metricPoint.GetHistogramSum(), Is.EqualTo(225.5), "Sum should be 225.5 (150.5 + 75)"); + }); } [Test] @@ -412,8 +838,34 @@ public void RecordUpDownCounter_WithValidName_RecordsUpDownCounterValue() { Observe.Initialize(_config, _loggerProvider); - Assert.DoesNotThrow(() => Observe.RecordUpDownCounter("active.connections", 5)); - Assert.DoesNotThrow(() => Observe.RecordUpDownCounter("active.connections", -2)); + // Record up/down counter values + Observe.RecordUpDownCounter("active.connections", 5); + Observe.RecordUpDownCounter("active.connections", -2); + Observe.RecordUpDownCounter("active.connections", 3); + + // Force export + _meterProvider.ForceFlush(); + + // Verify metrics were exported + Assert.That(_exportedMetrics, Is.Not.Empty, "Should have exported metrics"); + + // Find the up/down counter + var connectionsMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "active.connections"); + Assert.That(connectionsMetric, Is.Not.Null, "Should have exported active.connections metric"); + + // Verify the metric type is up/down counter + Assert.That(connectionsMetric.MetricType, + Is.EqualTo(MetricType.LongSumNonMonotonic).Or.EqualTo(MetricType.LongSum), + "active.connections should be an up/down counter"); + + // Verify the value (5 - 2 + 3 = 6) + var metricPoints = connectionsMetric.GetMetricPoints(); + var metricPointsEnumerator = metricPoints.GetEnumerator(); + Assert.That(metricPointsEnumerator.MoveNext(), Is.True, "Should have metric points"); + + var metricPoint = metricPointsEnumerator.Current; + Assert.That(metricPoint.GetSumLong(), Is.EqualTo(6), + "Up/down counter should have net value of 6 (5 - 2 + 3)"); } [Test] @@ -426,7 +878,49 @@ public void RecordUpDownCounter_WithAttributes_RecordsUpDownCounterValueWithAttr ["protocol"] = "http" }; - Assert.DoesNotThrow(() => Observe.RecordUpDownCounter("active.connections", 5, attributes)); + // Record up/down counter with attributes + Observe.RecordUpDownCounter("active.connections", 5, attributes); + Observe.RecordUpDownCounter("active.connections", -1, attributes); + + // Force export + _meterProvider.ForceFlush(); + + // Verify metric was exported + Assert.That(_exportedMetrics, Is.Not.Empty, "Should have exported metrics"); + + var connectionsMetric = _exportedMetrics.FirstOrDefault(m => m.Name == "active.connections"); + Assert.That(connectionsMetric, Is.Not.Null, "Should have exported active.connections metric"); + + // Verify attributes were included + var metricPoints = connectionsMetric.GetMetricPoints(); + var metricPointsEnumerator = metricPoints.GetEnumerator(); + Assert.That(metricPointsEnumerator.MoveNext(), Is.True, "Should have metric points"); + + var metricPoint = metricPointsEnumerator.Current; + var tags = metricPoint.Tags; + + // Check if attributes are present + object serverValue = null; + object protocolValue = null; + foreach (var tag in tags) + { + switch (tag.Key) + { + case "server": + serverValue = tag.Value; + break; + case "protocol": + protocolValue = tag.Value; + break; + } + } + + Assert.Multiple(() => + { + Assert.That(serverValue, Is.EqualTo("web-1"), "Should have server attribute"); + Assert.That(protocolValue, Is.EqualTo("http"), "Should have protocol attribute"); + Assert.That(metricPoint.GetSumLong(), Is.EqualTo(4), "Should have net value of 4 (5 - 1)"); + }); } [Test] @@ -455,8 +949,11 @@ public void StartActivity_WithValidName_ReturnsActivity() using (var activity = Observe.StartActivity("test-operation")) { Assert.That(activity, Is.Not.Null, "Activity should be created when ActivityListener is registered"); - Assert.That(activity.DisplayName, Is.EqualTo("test-operation")); - Assert.That(activity.Source.Name, Is.EqualTo("test-service")); + Assert.Multiple(() => + { + Assert.That(activity.DisplayName, Is.EqualTo("test-operation")); + Assert.That(activity.Source.Name, Is.EqualTo("test-service")); + }); } } @@ -473,12 +970,15 @@ public void StartActivity_WithKindAndAttributes_ReturnsActivityWithAttributes() using (var activity = Observe.StartActivity("db-query", ActivityKind.Client, attributes)) { Assert.That(activity, Is.Not.Null, "Activity should be created when ActivityListener is registered"); - Assert.That(activity.DisplayName, Is.EqualTo("db-query")); - Assert.That(activity.Kind, Is.EqualTo(ActivityKind.Client)); + Assert.Multiple(() => + { + Assert.That(activity.DisplayName, Is.EqualTo("db-query")); + Assert.That(activity.Kind, Is.EqualTo(ActivityKind.Client)); - // Verify attributes were added as tags - Assert.That(activity.GetTagItem("operation.type"), Is.EqualTo("database")); - Assert.That(activity.GetTagItem("table.name"), Is.EqualTo("users")); + // Verify attributes were added as tags + Assert.That(activity.GetTagItem("operation.type"), Is.EqualTo("database")); + Assert.That(activity.GetTagItem("table.name"), Is.EqualTo("users")); + }); } } @@ -495,7 +995,7 @@ public void StartActivity_WithNullAttributes_DoesNotThrow() Observe.Initialize(_config, _loggerProvider); Assert.DoesNotThrow(() => { - using (var activity = Observe.StartActivity("test-operation", ActivityKind.Internal, null)) + using (Observe.StartActivity("test-operation")) { } }); @@ -509,21 +1009,24 @@ public void StartActivity_WithNullAttributes_DoesNotThrow() public void RecordLog_WithMessage_LogsMessageCorrectly() { Observe.Initialize(_config, _loggerProvider); - var message = "Test log message"; + const string message = "Test log message"; Observe.RecordLog(message, LogLevel.Information, null); var logger = _loggerProvider.Loggers.FirstOrDefault(); Assert.That(logger, Is.Not.Null); - Assert.That(logger.LogEntries.Count, Is.EqualTo(1)); - Assert.That(logger.LogEntries[0].Message, Is.EqualTo(message)); - Assert.That(logger.LogEntries[0].LogLevel, Is.EqualTo(LogLevel.Information)); + Assert.That(logger.LogEntries, Has.Count.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(logger.LogEntries[0].Message, Is.EqualTo(message)); + Assert.That(logger.LogEntries[0].LogLevel, Is.EqualTo(LogLevel.Information)); + }); } [Test] public void RecordLog_WithAttributes_LogsMessageWithAttributes() { Observe.Initialize(_config, _loggerProvider); - var message = "Test log with attributes"; + const string message = "Test log with attributes"; var attributes = new Dictionary { ["user.id"] = "12345", @@ -533,10 +1036,13 @@ public void RecordLog_WithAttributes_LogsMessageWithAttributes() Observe.RecordLog(message, LogLevel.Warning, attributes); var logger = _loggerProvider.Loggers.FirstOrDefault(); Assert.That(logger, Is.Not.Null); - Assert.That(logger.LogEntries.Count, Is.EqualTo(1)); - Assert.That(logger.LogEntries[0].Message, Is.EqualTo(message)); - Assert.That(logger.LogEntries[0].LogLevel, Is.EqualTo(LogLevel.Warning)); - Assert.That(logger.LogEntries[0].State, Is.EqualTo(attributes)); + Assert.That(logger.LogEntries, Has.Count.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(logger.LogEntries[0].Message, Is.EqualTo(message)); + Assert.That(logger.LogEntries[0].LogLevel, Is.EqualTo(LogLevel.Warning)); + Assert.That(logger.LogEntries[0].State, Is.EqualTo(attributes)); + }); } [Test] @@ -549,11 +1055,14 @@ public void RecordLog_WithDifferentLogLevels_LogsAtCorrectLevel() Observe.RecordLog("Error message", LogLevel.Error, null); var logger = _loggerProvider.Loggers.FirstOrDefault(); Assert.That(logger, Is.Not.Null); - Assert.That(logger.LogEntries.Count, Is.EqualTo(4)); - Assert.That(logger.LogEntries[0].LogLevel, Is.EqualTo(LogLevel.Debug)); - Assert.That(logger.LogEntries[1].LogLevel, Is.EqualTo(LogLevel.Information)); - Assert.That(logger.LogEntries[2].LogLevel, Is.EqualTo(LogLevel.Warning)); - Assert.That(logger.LogEntries[3].LogLevel, Is.EqualTo(LogLevel.Error)); + Assert.That(logger.LogEntries, Has.Count.EqualTo(4)); + Assert.Multiple(() => + { + Assert.That(logger.LogEntries[0].LogLevel, Is.EqualTo(LogLevel.Debug)); + Assert.That(logger.LogEntries[1].LogLevel, Is.EqualTo(LogLevel.Information)); + Assert.That(logger.LogEntries[2].LogLevel, Is.EqualTo(LogLevel.Warning)); + Assert.That(logger.LogEntries[3].LogLevel, Is.EqualTo(LogLevel.Error)); + }); } [Test] @@ -572,7 +1081,7 @@ public void RecordLog_WithEmptyAttributes_LogsCorrectly() Observe.RecordLog("Test message", LogLevel.Information, emptyAttributes); var logger = _loggerProvider.Loggers.FirstOrDefault(); Assert.That(logger, Is.Not.Null); - Assert.That(logger.LogEntries.Count, Is.EqualTo(1)); + Assert.That(logger.LogEntries, Has.Count.EqualTo(1)); } [Test] @@ -592,10 +1101,10 @@ public void ConvertToKeyValuePairs_WithNullDictionary_ReturnsNull() Observe.Initialize(_config, _loggerProvider); // Should not throw with null attributes - Assert.DoesNotThrow(() => Observe.RecordMetric("test.metric", 42.0, null)); - Assert.DoesNotThrow(() => Observe.RecordCount("test.counter", 1, null)); - Assert.DoesNotThrow(() => Observe.RecordHistogram("test.histogram", 150.0, null)); - Assert.DoesNotThrow(() => Observe.RecordUpDownCounter("test.updown", 1, null)); + Assert.DoesNotThrow(() => Observe.RecordMetric("test.metric", 42.0)); + Assert.DoesNotThrow(() => Observe.RecordCount("test.counter", 1)); + Assert.DoesNotThrow(() => Observe.RecordHistogram("test.histogram", 150.0)); + Assert.DoesNotThrow(() => Observe.RecordUpDownCounter("test.updown", 1)); } [Test] @@ -620,30 +1129,62 @@ public void ConvertToKeyValuePairs_WithEmptyDictionary_ReturnsNull() public void RecordMetric_WithSameName_ReusesGaugeInstance() { Observe.Initialize(_config, _loggerProvider); - var metricName = "cpu.usage"; + const string metricName = "cpu.usage"; // Record multiple values for the same metric - Assert.DoesNotThrow(() => Observe.RecordMetric(metricName, 50.0)); - Assert.DoesNotThrow(() => Observe.RecordMetric(metricName, 75.0)); - Assert.DoesNotThrow(() => Observe.RecordMetric(metricName, 90.0)); + Observe.RecordMetric(metricName, 50.0); + Observe.RecordMetric(metricName, 75.0); + Observe.RecordMetric(metricName, 90.0); + + // Force export + _meterProvider.ForceFlush(); + + // Verify metrics were exported + Assert.That(_exportedMetrics, Is.Not.Empty, "Should have exported metrics"); - // Should not throw, indicating proper reuse of gauge instance - Assert.Pass("Multiple metrics with same name recorded successfully"); + // Should have only one metric with the given name (reused instance) + var cpuMetrics = _exportedMetrics.Where(m => m.Name == metricName).ToList(); + Assert.That(cpuMetrics, Has.Count.EqualTo(1), "Should have only one cpu.usage metric instance"); + + // The last recorded value should be 90.0 for a gauge + var metricPoints = cpuMetrics.First().GetMetricPoints(); + var metricPointsEnumerator = metricPoints.GetEnumerator(); + Assert.That(metricPointsEnumerator.MoveNext(), Is.True, "Should have metric points"); + + var metricPoint = metricPointsEnumerator.Current; + Assert.That(metricPoint.GetGaugeLastValueDouble(), Is.EqualTo(90.0), + "Last gauge value should be 90.0"); } [Test] public void RecordCount_WithSameName_ReusesCounterInstance() { Observe.Initialize(_config, _loggerProvider); - var counterName = "requests.total"; + const string counterName = "requests.total"; // Record multiple values for the same counter - Assert.DoesNotThrow(() => Observe.RecordCount(counterName, 1)); - Assert.DoesNotThrow(() => Observe.RecordCount(counterName, 5)); - Assert.DoesNotThrow(() => Observe.RecordCount(counterName, 10)); + Observe.RecordCount(counterName, 1); + Observe.RecordCount(counterName, 5); + Observe.RecordCount(counterName, 10); + + // Force export + _meterProvider.ForceFlush(); + + // Verify metrics were exported + Assert.That(_exportedMetrics, Is.Not.Empty, "Should have exported metrics"); + + // Should have only one metric with the given name (reused instance) + var requestMetrics = _exportedMetrics.Where(m => m.Name == counterName).ToList(); + Assert.That(requestMetrics, Has.Count.EqualTo(1), "Should have only one requests.total metric instance"); + + // The cumulative value should be 16 (1 + 5 + 10) + var metricPoints = requestMetrics.First().GetMetricPoints(); + var metricPointsEnumerator = metricPoints.GetEnumerator(); + Assert.That(metricPointsEnumerator.MoveNext(), Is.True, "Should have metric points"); - // Should not throw, indicating proper reuse of counter instance - Assert.Pass("Multiple counters with same name recorded successfully"); + var point = metricPointsEnumerator.Current; + Assert.That(point.GetSumLong(), Is.EqualTo(16), + "Counter cumulative value should be 16 (1 + 5 + 10)"); } #endregion From 5dec8614946fd24babdd96c38fa4016003fd37e5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:03:51 -0700 Subject: [PATCH 07/10] Remove local connection. --- sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs index 4a46ef578..4649ce347 100644 --- a/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs +++ b/sdk/@launchdarkly/observability-dotnet/AspSampleApp/Program.cs @@ -16,7 +16,6 @@ .Add(ObservabilityPlugin.Builder(builder.Services) .WithServiceName("ryan-test-service") .WithServiceVersion("0.0.0") - .WithOtlpEndpoint("http://localhost:4318") .Build())).Build(); // Building the LdClient with the Observability plugin. This line will add services to the web application. From d608cc7628e2ae4ddabebdc7408a04fdda0b3e01 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 20 Aug 2025 09:08:05 -0700 Subject: [PATCH 08/10] PR feedback. --- .../ObservabilityExtensions.cs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs index 7144022a8..483a46da1 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/ObservabilityExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using LaunchDarkly.Observability.Otel; @@ -9,7 +8,6 @@ using Microsoft.Extensions.Logging; using OpenTelemetry.Resources; using OpenTelemetry.Trace; -using OpenTelemetry; using OpenTelemetry.Exporter; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; @@ -32,15 +30,15 @@ public static class ObservabilityExtensions private class LdObservabilityHostedService : IHostedService { - private ObservabilityConfig _config; - private ActivitySource _activitySource; - private ILoggerProvider _loggerProvider; + 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); @@ -120,10 +118,10 @@ internal static void AddLaunchDarklyObservabilityWithConfig(this IServiceCollect Protocol = ExportProtocol }))); }); - + // Attach a hosted service which will allow us to get a logger provider instance from the built - // serice collection. - services.AddHostedService((serviceProvider) => new LdObservabilityHostedService(config, serviceProvider)); + // service collection. + services.AddHostedService((serviceProvider) => new LdObservabilityHostedService(config, serviceProvider)); } /// From 55b7d31244f2bfbb4aac60db1c90f019b562d365 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 20 Aug 2025 09:14:28 -0700 Subject: [PATCH 09/10] Add debug log. --- .../src/LaunchDarkly.Observability/Observe.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs index aaa6f70b2..6036649ce 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Diagnostics.Metrics; using System.Threading; +using LaunchDarkly.Observability.Logging; using Microsoft.Extensions.Logging; namespace LaunchDarkly.Observability @@ -60,7 +61,7 @@ private static void WithInstance(Action action) var instance = GetInstance(); if (instance == null) { - // TODO: Log after PR with logger merged. + DebugLogger.DebugLog("Instance used before Observability Plugin initialized."); return; } @@ -101,7 +102,7 @@ public static void RecordException(Exception exception, IDictionary { var activity = Activity.Current; From c1807f4be1df1fa68348a040b84b3e8ac0d06e83 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 20 Aug 2025 09:15:21 -0700 Subject: [PATCH 10/10] Add debug log. --- .../src/LaunchDarkly.Observability/Observe.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs index 6036649ce..ce32d3a65 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Observe.cs @@ -99,7 +99,7 @@ public static void RecordException(Exception exception, IDictionary