diff --git a/manifests/dotnet.yml b/manifests/dotnet.yml index 412ad2ae2b5..823a59f41c5 100644 --- a/manifests/dotnet.yml +++ b/manifests/dotnet.yml @@ -577,7 +577,19 @@ tests/: test_otel_env_vars.py: Test_Otel_Env_Vars: v2.53.0 test_otel_logs.py: missing_feature - test_otel_metrics.py: missing_feature + test_otel_metrics.py: + Test_Otel_Metrics_Api_Instrument: v3.28.0 + Test_Otel_Metrics_Api_Meter: v3.28.0 + Test_Otel_Metrics_Api_MeterProvider: v3.28.0 + Test_Otel_Metrics_Configuration_Enabled: v3.28.0 + Test_Otel_Metrics_Configuration_OTLP_Exporter_Metrics_Endpoint: v3.28.0 + Test_Otel_Metrics_Configuration_OTLP_Exporter_Metrics_Headers: v3.28.0 + Test_Otel_Metrics_Configuration_OTLP_Exporter_Metrics_Protocol: v3.28.0 + Test_Otel_Metrics_Configuration_OTLP_Exporter_Metrics_Timeout: v3.28.0 + Test_Otel_Metrics_Configuration_Temporality_Preference: v3.28.0 + Test_Otel_Metrics_Host_Name: v3.28.0 + Test_Otel_Metrics_Resource_Attributes: v3.28.0 + Test_Otel_Metrics_Telemetry: v3.28.0 test_otel_span_with_baggage.py: Test_Otel_Span_With_Baggage: missing_feature test_otel_tracer.py: diff --git a/tests/parametric/test_otel_metrics.py b/tests/parametric/test_otel_metrics.py index bf102711e7b..ffe26375f9a 100644 --- a/tests/parametric/test_otel_metrics.py +++ b/tests/parametric/test_otel_metrics.py @@ -1,13 +1,15 @@ import pytest from utils import context, features, missing_feature, scenarios + from urllib.parse import urlparse EXPECTED_TAGS = [("foo", "bar1"), ("baz", "qux1")] DEFAULT_METER_NAME = "parametric-api" DEFAULT_METER_VERSION = "1.0.0" -DEFAULT_SCHEMA_URL = "https://opentelemetry.io/schemas/1.27.0" +# schema_url is not supported by .NET's System.Diagnostics.Metrics API +DEFAULT_SCHEMA_URL = "" if context.library == "dotnet" else "https://opentelemetry.io/schemas/1.21.0" DEFAULT_INSTRUMENT_UNIT = "triggers" DEFAULT_INSTRUMENT_DESCRIPTION = "test_description" @@ -106,7 +108,13 @@ def assert_sum_aggregation(sum_aggregation, aggregation_temporality, is_monotoni for sum_data_point in sum_aggregation["data_points"]: if attributes == {item["key"]: item["value"]["string_value"] for item in sum_data_point["attributes"]}: - assert sum_data_point["as_double"] == value + if "as_double" in sum_data_point: + actual_value = sum_data_point["as_double"] + elif "as_int" in sum_data_point: + actual_value = int(sum_data_point["as_int"]) + else: + actual_value = None + assert actual_value == value assert ( attributes.items() == {item["key"]: item["value"]["string_value"] for item in sum_data_point["attributes"]}.items() @@ -120,7 +128,13 @@ def assert_sum_aggregation(sum_aggregation, aggregation_temporality, is_monotoni def assert_gauge_aggregation(gauge_aggregation, value, attributes): for gauge_data_point in gauge_aggregation["data_points"]: if attributes == {item["key"]: item["value"]["string_value"] for item in gauge_data_point["attributes"]}: - assert gauge_data_point["as_double"] == value + if "as_double" in gauge_data_point: + actual_value = gauge_data_point["as_double"] + elif "as_int" in gauge_data_point: + actual_value = int(gauge_data_point["as_int"]) + else: + actual_value = None + assert actual_value == value assert "time_unix_nano" in gauge_data_point return @@ -1590,6 +1604,9 @@ class Test_Otel_Metrics_Host_Name: - Resource attributes set through environment variable OTEL_RESOURCE_ATTRIBUTES are preserved """ + @missing_feature( + context.library == "dotnet", reason="DD_HOSTNAME to host.name resource attribute mapping not yet implemented" + ) @pytest.mark.parametrize( "library_env", [ @@ -1985,6 +2002,10 @@ def test_telemetry_exporter_metrics_configurations( config.get("value") == expected_value ), f"Expected {expected_env} to be {expected_value}, configuration: {config}" + @missing_feature( + context.library == "dotnet", + reason="OTel metrics telemetry metrics (otel.metrics_export_attempts) not yet fully flushed in time", + ) @pytest.mark.parametrize( "library_env", [ diff --git a/utils/build/docker/dotnet/parametric/Endpoints/ApmTestApiOtel.cs b/utils/build/docker/dotnet/parametric/Endpoints/ApmTestApiOtel.cs index 6e7c0556957..a6a4184282e 100644 --- a/utils/build/docker/dotnet/parametric/Endpoints/ApmTestApiOtel.cs +++ b/utils/build/docker/dotnet/parametric/Endpoints/ApmTestApiOtel.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.Metrics; using System.Globalization; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -9,6 +10,8 @@ public abstract class ApmTestApiOtel : ApmTestApi { private static readonly ActivitySource ApmTestApiActivitySource = new("ApmTestApi"); private static readonly Dictionary Activities = new(); + private static readonly Dictionary OtelMeters = new(); + private static readonly Dictionary OtelMeterInstruments = new(); private static ILogger? _logger; public static void MapApmOtelEndpoints(WebApplication app, ILogger logger) @@ -26,6 +29,21 @@ public static void MapApmOtelEndpoints(WebApplication app, ILogger logger) app.MapPost("/trace/otel/add_event", OtelAddEvent); app.MapPost("/trace/otel/record_exception", OtelRecordException); app.MapPost("/trace/stats/flush", OtelFlushTraceStats); + + // Metrics endpoints + app.MapPost("/metrics/otel/get_meter", OtelGetMeter); + app.MapPost("/metrics/otel/create_counter", OtelCreateCounter); + app.MapPost("/metrics/otel/counter_add", OtelCounterAdd); + app.MapPost("/metrics/otel/create_updowncounter", OtelCreateUpDownCounter); + app.MapPost("/metrics/otel/updowncounter_add", OtelUpDownCounterAdd); + app.MapPost("/metrics/otel/create_gauge", OtelCreateGauge); + app.MapPost("/metrics/otel/gauge_record", OtelGaugeRecord); + app.MapPost("/metrics/otel/create_histogram", OtelCreateHistogram); + app.MapPost("/metrics/otel/histogram_record", OtelHistogramRecord); + app.MapPost("/metrics/otel/create_asynchronous_counter", OtelCreateAsynchronousCounter); + app.MapPost("/metrics/otel/create_asynchronous_updowncounter", OtelCreateAsynchronousUpDownCounter); + app.MapPost("/metrics/otel/create_asynchronous_gauge", OtelCreateAsynchronousGauge); + app.MapPost("/metrics/otel/force_flush", OtelMetricsForceFlush); } private static async Task OtelStartSpan(HttpRequest request) @@ -472,4 +490,411 @@ public static void ClearActivities() { Activities.Clear(); } + + // Metrics endpoints + private static string CreateInstrumentKey(string meterName, string name, string kind, string unit, string description) + { + // Instrument names are case-insensitive per OpenTelemetry spec + return string.Join(",", meterName, name.Trim().ToLower(), kind, unit, description); + } + + private static string NormalizeInstrumentName(string name) + { + // Per OpenTelemetry spec, instrument names are case-insensitive + // Normalize to lowercase so .NET creates only one Instrument object + return name.Trim().ToLower(); + } + + private static async Task OtelGetMeter(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelGetMeter: {RequestBodyObject}", requestBodyObject); + + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var version = requestBodyObject.TryGetValue("version", out var v) && v != null ? v.ToString() : null; + // Note: schema_url is not supported by .NET's System.Diagnostics.Metrics.Meter API + // It's a concept from OpenTelemetry's Meter API that doesn't exist in .NET + // We ignore it here since the tracer will always export empty schema_url + var attributes = requestBodyObject.TryGetValue("attributes", out var a) && a != null + ? ((JObject)a).ToObject>() + : null; + + if (!OtelMeters.ContainsKey(name)) + { + // Convert attributes to TagList for the Meter constructor + var tags = ConvertToTagList(attributes); + OtelMeters[name] = new Meter(name, version, tags); + } + + _logger?.LogInformation("OtelGetMeterReturn"); + } + + private static async Task OtelCreateCounter(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelCreateCounter: {RequestBodyObject}", requestBodyObject); + + var meterName = requestBodyObject["meter_name"]?.ToString() ?? throw new ArgumentNullException("meter_name"); + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var unit = requestBodyObject["unit"]?.ToString() ?? ""; + var description = requestBodyObject["description"]?.ToString() ?? ""; + + if (!OtelMeters.TryGetValue(meterName, out var meter)) + { + throw new InvalidOperationException($"Meter name {meterName} not found in registered meters"); + } + + var normalizedName = NormalizeInstrumentName(name); + var counter = meter.CreateCounter(normalizedName, unit, description); + var instrumentKey = CreateInstrumentKey(meterName, name, "counter", unit, description); + OtelMeterInstruments[instrumentKey] = counter; + + _logger?.LogInformation("OtelCreateCounterReturn"); + } + + private static async Task OtelCounterAdd(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelCounterAdd: {RequestBodyObject}", requestBodyObject); + + var meterName = requestBodyObject["meter_name"]?.ToString() ?? throw new ArgumentNullException("meter_name"); + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var unit = requestBodyObject["unit"]?.ToString() ?? ""; + var description = requestBodyObject["description"]?.ToString() ?? ""; + var value = Convert.ToDouble(requestBodyObject["value"]); + var attributes = ((JObject?)requestBodyObject["attributes"])?.ToObject>(); + + var instrumentKey = CreateInstrumentKey(meterName, name, "counter", unit, description); + if (!OtelMeterInstruments.TryGetValue(instrumentKey, out var instrument)) + { + throw new InvalidOperationException($"Instrument not found for key {instrumentKey}"); + } + + var counter = (Counter)instrument; + var tagList = ConvertToTagList(attributes); + counter.Add(value, tagList); + + _logger?.LogInformation("OtelCounterAddReturn"); + } + + private static async Task OtelCreateUpDownCounter(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelCreateUpDownCounter: {RequestBodyObject}", requestBodyObject); + + var meterName = requestBodyObject["meter_name"]?.ToString() ?? throw new ArgumentNullException("meter_name"); + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var unit = requestBodyObject["unit"]?.ToString() ?? ""; + var description = requestBodyObject["description"]?.ToString() ?? ""; + + if (!OtelMeters.TryGetValue(meterName, out var meter)) + { + throw new InvalidOperationException($"Meter name {meterName} not found"); + } + + var normalizedName = NormalizeInstrumentName(name); + var upDownCounter = meter.CreateUpDownCounter(normalizedName, unit, description); + var instrumentKey = CreateInstrumentKey(meterName, name, "updowncounter", unit, description); + OtelMeterInstruments[instrumentKey] = upDownCounter; + + _logger?.LogInformation("OtelCreateUpDownCounterReturn"); + } + + private static async Task OtelUpDownCounterAdd(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelUpDownCounterAdd: {RequestBodyObject}", requestBodyObject); + + var meterName = requestBodyObject["meter_name"]?.ToString() ?? throw new ArgumentNullException("meter_name"); + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var unit = requestBodyObject["unit"]?.ToString() ?? ""; + var description = requestBodyObject["description"]?.ToString() ?? ""; + var value = Convert.ToDouble(requestBodyObject["value"]); + var attributes = ((JObject?)requestBodyObject["attributes"])?.ToObject>(); + + var instrumentKey = CreateInstrumentKey(meterName, name, "updowncounter", unit, description); + if (!OtelMeterInstruments.TryGetValue(instrumentKey, out var instrument)) + { + throw new InvalidOperationException($"Instrument not found for key {instrumentKey}"); + } + + var upDownCounter = (UpDownCounter)instrument; + var tagList = ConvertToTagList(attributes); + upDownCounter.Add(value, tagList); + + _logger?.LogInformation("OtelUpDownCounterAddReturn"); + } + + private static async Task OtelCreateGauge(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelCreateGauge: {RequestBodyObject}", requestBodyObject); + + var meterName = requestBodyObject["meter_name"]?.ToString() ?? throw new ArgumentNullException("meter_name"); + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var unit = requestBodyObject["unit"]?.ToString() ?? ""; + var description = requestBodyObject["description"]?.ToString() ?? ""; + + if (!OtelMeters.TryGetValue(meterName, out var meter)) + { + throw new InvalidOperationException($"Meter name {meterName} not found"); + } + + var instrumentKey = CreateInstrumentKey(meterName, name, "gauge", unit, description); + var gaugeValues = new Dictionary(); + OtelMeterInstruments[instrumentKey] = gaugeValues; + + var normalizedName = NormalizeInstrumentName(name); + var observableGauge = meter.CreateObservableGauge(normalizedName, () => + { + var measurements = new List>(); + foreach (var kvp in gaugeValues) + { + var attrs = ParseAttributesFromKey(kvp.Key); + measurements.Add(new Measurement(kvp.Value, attrs)); + } + return measurements; + }, unit, description); + + OtelMeterInstruments[instrumentKey + "_observable"] = observableGauge; + _logger?.LogInformation("OtelCreateGaugeReturn"); + } + + private static async Task OtelGaugeRecord(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelGaugeRecord: {RequestBodyObject}", requestBodyObject); + + var meterName = requestBodyObject["meter_name"]?.ToString() ?? throw new ArgumentNullException("meter_name"); + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var unit = requestBodyObject["unit"]?.ToString() ?? ""; + var description = requestBodyObject["description"]?.ToString() ?? ""; + var value = Convert.ToDouble(requestBodyObject["value"]); + var attributes = ((JObject?)requestBodyObject["attributes"])?.ToObject>(); + + var instrumentKey = CreateInstrumentKey(meterName, name, "gauge", unit, description); + if (!OtelMeterInstruments.TryGetValue(instrumentKey, out var instrument)) + { + throw new InvalidOperationException($"Instrument not found for key {instrumentKey}"); + } + + var gaugeValues = (Dictionary)instrument; + var attrKey = SerializeAttributesToKey(attributes); + gaugeValues[attrKey] = value; + + _logger?.LogInformation("OtelGaugeRecordReturn"); + } + + private static async Task OtelCreateHistogram(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelCreateHistogram: {RequestBodyObject}", requestBodyObject); + + var meterName = requestBodyObject["meter_name"]?.ToString() ?? throw new ArgumentNullException("meter_name"); + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var unit = requestBodyObject["unit"]?.ToString() ?? ""; + var description = requestBodyObject["description"]?.ToString() ?? ""; + + if (!OtelMeters.TryGetValue(meterName, out var meter)) + { + throw new InvalidOperationException($"Meter name {meterName} not found"); + } + + var normalizedName = NormalizeInstrumentName(name); + var histogram = meter.CreateHistogram(normalizedName, unit, description); + var instrumentKey = CreateInstrumentKey(meterName, name, "histogram", unit, description); + OtelMeterInstruments[instrumentKey] = histogram; + + _logger?.LogInformation("OtelCreateHistogramReturn"); + } + + private static async Task OtelHistogramRecord(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelHistogramRecord: {RequestBodyObject}", requestBodyObject); + + var meterName = requestBodyObject["meter_name"]?.ToString() ?? throw new ArgumentNullException("meter_name"); + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var unit = requestBodyObject["unit"]?.ToString() ?? ""; + var description = requestBodyObject["description"]?.ToString() ?? ""; + var value = Convert.ToDouble(requestBodyObject["value"]); + var attributes = ((JObject?)requestBodyObject["attributes"])?.ToObject>(); + + var instrumentKey = CreateInstrumentKey(meterName, name, "histogram", unit, description); + if (!OtelMeterInstruments.TryGetValue(instrumentKey, out var instrument)) + { + throw new InvalidOperationException($"Instrument not found for key {instrumentKey}"); + } + + var histogram = (Histogram)instrument; + var tagList = ConvertToTagList(attributes); + histogram.Record(value, tagList); + + _logger?.LogInformation("OtelHistogramRecordReturn"); + } + + private static async Task OtelCreateAsynchronousCounter(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelCreateAsynchronousCounter: {RequestBodyObject}", requestBodyObject); + + var meterName = requestBodyObject["meter_name"]?.ToString() ?? throw new ArgumentNullException("meter_name"); + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var unit = requestBodyObject["unit"]?.ToString() ?? ""; + var description = requestBodyObject["description"]?.ToString() ?? ""; + var value = Convert.ToDouble(requestBodyObject["value"]); + var attributes = ((JObject?)requestBodyObject["attributes"])?.ToObject>(); + + if (!OtelMeters.TryGetValue(meterName, out var meter)) + { + throw new InvalidOperationException($"Meter name {meterName} not found"); + } + + var normalizedName = NormalizeInstrumentName(name); + var tagList = ConvertToTagList(attributes); + var observableCounter = meter.CreateObservableCounter(normalizedName, () => new Measurement(value, tagList), unit, description); + + var instrumentKey = CreateInstrumentKey(meterName, name, "observable_counter", unit, description); + OtelMeterInstruments[instrumentKey] = observableCounter; + + _logger?.LogInformation("OtelCreateAsynchronousCounterReturn"); + } + + private static async Task OtelCreateAsynchronousUpDownCounter(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelCreateAsynchronousUpDownCounter: {RequestBodyObject}", requestBodyObject); + + var meterName = requestBodyObject["meter_name"]?.ToString() ?? throw new ArgumentNullException("meter_name"); + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var unit = requestBodyObject["unit"]?.ToString() ?? ""; + var description = requestBodyObject["description"]?.ToString() ?? ""; + var value = Convert.ToDouble(requestBodyObject["value"]); + var attributes = ((JObject?)requestBodyObject["attributes"])?.ToObject>(); + + if (!OtelMeters.TryGetValue(meterName, out var meter)) + { + throw new InvalidOperationException($"Meter name {meterName} not found"); + } + + var normalizedName = NormalizeInstrumentName(name); + var tagList = ConvertToTagList(attributes); + var observableUpDownCounter = meter.CreateObservableUpDownCounter(normalizedName, () => new Measurement(value, tagList), unit, description); + + var instrumentKey = CreateInstrumentKey(meterName, name, "observable_updowncounter", unit, description); + OtelMeterInstruments[instrumentKey] = observableUpDownCounter; + + _logger?.LogInformation("OtelCreateAsynchronousUpDownCounterReturn"); + } + + private static async Task OtelCreateAsynchronousGauge(HttpRequest request) + { + var requestBodyObject = await DeserializeRequestObjectAsync(request.Body); + _logger?.LogInformation("OtelCreateAsynchronousGauge: {RequestBodyObject}", requestBodyObject); + + var meterName = requestBodyObject["meter_name"]?.ToString() ?? throw new ArgumentNullException("meter_name"); + var name = requestBodyObject["name"]?.ToString() ?? throw new ArgumentNullException("name"); + var unit = requestBodyObject["unit"]?.ToString() ?? ""; + var description = requestBodyObject["description"]?.ToString() ?? ""; + var value = Convert.ToDouble(requestBodyObject["value"]); + var attributes = ((JObject?)requestBodyObject["attributes"])?.ToObject>(); + + if (!OtelMeters.TryGetValue(meterName, out var meter)) + { + throw new InvalidOperationException($"Meter name {meterName} not found"); + } + + var normalizedName = NormalizeInstrumentName(name); + var tagList = ConvertToTagList(attributes); + var observableGauge = meter.CreateObservableGauge(normalizedName, () => new Measurement(value, tagList), unit, description); + + var instrumentKey = CreateInstrumentKey(meterName, name, "observable_gauge", unit, description); + OtelMeterInstruments[instrumentKey] = observableGauge; + + _logger?.LogInformation("OtelCreateAsynchronousGaugeReturn"); + } + + private static async Task OtelMetricsForceFlush(HttpRequest request) + { + _logger?.LogInformation("OtelMetricsForceFlush"); + + // Force flush via Datadog.Trace.OTelMetrics.MetricsRuntime + try + { + var metricsRuntimeType = Type.GetType("Datadog.Trace.OTelMetrics.MetricsRuntime, Datadog.Trace"); + if (metricsRuntimeType != null) + { + var forceFlushMethod = metricsRuntimeType.GetMethod("ForceFlushAsync", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (forceFlushMethod != null) + { + var flushTask = forceFlushMethod.Invoke(null, null) as Task; + if (flushTask != null) + { + await flushTask; + _logger?.LogInformation("Successfully flushed metrics via MetricsRuntime.ForceFlushAsync"); + } + } + else + { + _logger?.LogWarning("ForceFlushAsync method not found on MetricsRuntime"); + } + } + else + { + _logger?.LogWarning("MetricsRuntime type not found"); + } + } + catch (Exception ex) + { + _logger?.LogWarning("Failed to flush metrics: {Exception}", ex.Message); + } + + var result = JsonConvert.SerializeObject(new { success = true }); + _logger?.LogInformation("OtelMetricsForceFlushReturn: {result}", result); + return result; + } + + private static TagList ConvertToTagList(Dictionary? attributes) + { + var tagList = new TagList(); + if (attributes != null) + { + foreach (var kvp in attributes) + { + tagList.Add(kvp.Key, kvp.Value?.ToString()); + } + } + return tagList; + } + + private static string SerializeAttributesToKey(Dictionary? attributes) + { + if (attributes == null || attributes.Count == 0) + { + return "no_attributes"; + } + + var sorted = attributes.OrderBy(kvp => kvp.Key); + return string.Join("|", sorted.Select(kvp => $"{kvp.Key}={kvp.Value}")); + } + + private static TagList ParseAttributesFromKey(string key) + { + var tagList = new TagList(); + if (key == "no_attributes") + { + return tagList; + } + + var pairs = key.Split('|'); + foreach (var pair in pairs) + { + var parts = pair.Split('=', 2); + if (parts.Length == 2) + { + tagList.Add(parts[0], parts[1]); + } + } + return tagList; + } }