diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Azure/Functions/AzureFunctionsCommon.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Azure/Functions/AzureFunctionsCommon.cs index 7b6ea4280fe0..247d68293253 100644 --- a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Azure/Functions/AzureFunctionsCommon.cs +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Azure/Functions/AzureFunctionsCommon.cs @@ -42,6 +42,7 @@ internal static class AzureFunctionsCommon public const string IntegrationName = nameof(Configuration.IntegrationId.AzureFunctions); public const string OperationName = AzureFunctionsConstants.AzureFunctionName; public const string AzureApim = AzureFunctionsConstants.AzureApimName; + public const string AzureFrontDoor = AzureFunctionsConstants.AzureFrontDoorName; public const IntegrationId IntegrationId = Configuration.IntegrationId.AzureFunctions; private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(AzureFunctionsCommon)); @@ -129,8 +130,9 @@ public static CallTargetState OnFunctionExecutionBegin(TTarg } var functionName = instanceParam.FunctionDescriptor.ShortName; + var opName = tracer.InternalActiveScope?.Root.Span.OperationName; // Check if there's an inferred proxy span (e.g., azure.apim) that we shouldn't overwrite - var isProxySpan = tracer.InternalActiveScope?.Root.Span.OperationName == AzureApim; + var isProxySpan = opName == AzureApim || opName == AzureFrontDoor; // Ignoring null because guaranteed running in AAS if (tracer.Settings.AzureAppServiceMetadata is { IsIsolatedFunctionsApp: true } && tracer.InternalActiveScope is { } activeScope) @@ -330,6 +332,10 @@ _ when type.StartsWith("eventGrid", StringComparison.OrdinalIgnoreCase) => "Even // if available, otherwise fall back to the existing local active scope. var activeScope = tracer.InternalActiveScope; + // Check if there's an inferred proxy span (e.g., azure.frontdoor, azure.apim) that we shouldn't overwrite + var rootOpName = activeScope?.Root.Span.OperationName; + var isProxySpan = rootOpName == AzureFrontDoor || rootOpName == AzureApim; + // Check if the ASP.NET Core scope is already active if (aspNetCoreScope != null && activeScope == aspNetCoreScope) { @@ -337,17 +343,20 @@ _ when type.StartsWith("eventGrid", StringComparison.OrdinalIgnoreCase) => "Even // just update the existing root span's tags to make it a "serverless" span. // Don't assign to `scope`: the ASP.NET Core middleware owns this scope's // lifetime, and returning it here would cause OnAsyncMethodEnd to dispose it. - var rootSpan = activeScope.Root.Span; + if (!isProxySpan) + { + var rootSpan = activeScope.Root.Span; - AzureFunctionsTags.SetRootSpanTags( - rootSpan.Tags, - shortName: tags.ShortName, - fullName: tags.FullName, - bindingSource: rootSpan.Tags is AzureFunctionsTags t ? t.BindingSource : null, - triggerType: tags.TriggerType); + AzureFunctionsTags.SetRootSpanTags( + rootSpan.Tags, + shortName: tags.ShortName, + fullName: tags.FullName, + bindingSource: rootSpan.Tags is AzureFunctionsTags t ? t.BindingSource : null, + triggerType: tags.TriggerType); - rootSpan.Type = SpanType; // "serverless" - rootSpan.ResourceName = $"{tags.TriggerType} {tags.ShortName}"; + rootSpan.Type = SpanType; // "serverless" + rootSpan.ResourceName = $"{tags.TriggerType} {tags.ShortName}"; + } } else { @@ -370,15 +379,23 @@ _ when type.StartsWith("eventGrid", StringComparison.OrdinalIgnoreCase) => "Even } else { - // this is NOT the local root span, copy some tags to the root span - AzureFunctionsTags.SetRootSpanTags( - rootSpan.Tags, - shortName: tags.ShortName, - fullName: tags.FullName, - bindingSource: rootSpan.Tags is AzureFunctionsTags t ? t.BindingSource : null, - triggerType: tags.TriggerType); - - rootSpan.Type = SpanType; // "serverless" + // this is NOT the local root span, copy some tags to the root span, + // unless the root is an inferred proxy span (e.g. azure.frontdoor, azure.apim) + // that we must not overwrite. Check the actual root span here rather than + // reusing isProxySpan, because the root can derive from aspNetCoreScope + // rather than activeScope in this branch. + var rootOp = rootSpan.OperationName; + if (rootOp != AzureFrontDoor && rootOp != AzureApim) + { + AzureFunctionsTags.SetRootSpanTags( + rootSpan.Tags, + shortName: tags.ShortName, + fullName: tags.FullName, + bindingSource: rootSpan.Tags is AzureFunctionsTags t ? t.BindingSource : null, + triggerType: tags.TriggerType); + + rootSpan.Type = SpanType; // "serverless" + } } span.ResourceName = $"{tags.TriggerType} {tags.ShortName}"; diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Azure/Shared/AzureFunctionsConstants.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Azure/Shared/AzureFunctionsConstants.cs index b26765905caf..50507d113624 100644 --- a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Azure/Shared/AzureFunctionsConstants.cs +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Azure/Shared/AzureFunctionsConstants.cs @@ -12,6 +12,9 @@ internal static class AzureFunctionsConstants // Used for the operation name of spans created for Azure API Management requests public const string AzureApimName = "azure.apim"; + // Used for the operation name of the spans created for Azure Front Door requests + public const string AzureFrontDoorName = "azure.frontdoor"; + // Used for the operation name of spans created for Azure Functions requests public const string AzureFunctionName = "azure_functions.invoke"; } diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Proxy/AzureFrontDoorExtractor.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Proxy/AzureFrontDoorExtractor.cs new file mode 100644 index 000000000000..aa093fe74b55 --- /dev/null +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Proxy/AzureFrontDoorExtractor.cs @@ -0,0 +1,64 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; +using Datadog.Trace.Logging; +using Datadog.Trace.Propagators; +using Datadog.Trace.Vendors.Serilog.Events; + +namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.Proxy; + +/// +/// Extracts proxy metadata from Azure Frontdoor headers. +/// +internal sealed class AzureFrontDoorExtractor : IInferredProxyExtractor +{ + private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); + + public bool TryExtract(TCarrier carrier, TCarrierGetter carrierGetter, out InferredProxyData data) + where TCarrierGetter : struct, ICarrierGetter + { + data = default; + + try + { + var startTimeHeaderValue = ParseUtility.ParseString(carrier, carrierGetter, InferredProxyHeaders.StartTime); + if (StringUtil.IsNullOrEmpty(startTimeHeaderValue)) + { + startTimeHeaderValue = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); + } + + // validate the start time is a parseable Unix timestamp in milliseconds + if (!InferredProxySpanHelper.GetStartTime(startTimeHeaderValue, out var startTime)) + { + return false; + } + + // the remaining headers aren't necessarily required + var domainName = ParseUtility.ParseString(carrier, carrierGetter, InferredProxyHeaders.Domain); + var httpMethod = ParseUtility.ParseString(carrier, carrierGetter, InferredProxyHeaders.HttpMethod); + var path = ParseUtility.ParseString(carrier, carrierGetter, InferredProxyHeaders.Path); + var region = ParseUtility.ParseString(carrier, carrierGetter, InferredProxyHeaders.Region); + var stage = ParseUtility.ParseString(carrier, carrierGetter, InferredProxyHeaders.Stage); + data = new InferredProxyData(InferredProxySpanHelper.AzureFrontDoorHeaderValue, startTime, domainName, httpMethod, path, stage, region); + + if (Log.IsEnabled(LogEventLevel.Debug)) + { + Log.Debug( + "Successfully extracted proxy data: StartTime={StartTime}, Domain={Domain}, Method={Method}, Path={Path}, Region={Region}", + [startTimeHeaderValue, domainName, httpMethod, path, region]); + } + + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error extracting proxy data from {Proxy} headers", InferredProxySpanHelper.AzureFrontDoorHeaderValue); + return false; + } + } +} diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Proxy/AzureFrontDoorSpanFactory.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Proxy/AzureFrontDoorSpanFactory.cs new file mode 100644 index 000000000000..445f319b4710 --- /dev/null +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Proxy/AzureFrontDoorSpanFactory.cs @@ -0,0 +1,57 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; +using Datadog.Trace.ClrProfiler.AutoInstrumentation.Azure.Shared; +using Datadog.Trace.Logging; +using Datadog.Trace.Tagging; +using Datadog.Trace.Util; + +namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.Proxy; + +/// +/// Creates spans representing requests handled by Azure Frontdoor. +/// +internal sealed class AzureFrontDoorSpanFactory : IInferredSpanFactory +{ + private const string OperationName = AzureFunctionsConstants.AzureFrontDoorName; + private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(); + + public Scope? CreateSpan(Tracer tracer, InferredProxyData data, ISpanContext? parent = null) + { + try + { + // Azure Front Door currently sends the path relative, without a leading slash (e.g. "api/foo"). + // Trim any leading slash defensively before prepending our own, so the route/url stay + // single-slashed ("/api/foo") even if Front Door starts sending an absolute path later. + var normalizedPath = data.Path?.TrimStart('/'); + var resourceUrl = normalizedPath is null ? string.Empty : UriHelpers.GetCleanUriPath($"/{normalizedPath}").ToLowerInvariant(); + + var tags = new InferredProxyTags + { + HttpMethod = data.HttpMethod, + InstrumentationName = data.ProxyName, + HttpUrl = $"{data.DomainName}/{normalizedPath}", + HttpRoute = resourceUrl, + InferredSpan = 1, + Region = data.Region, + Stage = data.Stage, + }; + + var scope = tracer.StartActiveInternal(operationName: OperationName, parent: parent, startTime: data.StartTime, tags: tags, serviceName: data.DomainName, serviceNameSource: "azure-frontdoor"); + scope.Span.ResourceName = data.HttpMethod is null ? resourceUrl : $"{data.HttpMethod} {resourceUrl}"; + scope.Span.Type = SpanTypes.Web; + + return scope; + } + catch (Exception ex) + { + Log.Error(ex, "Error creating Azure Frontdoor span"); + return null; + } + } +} diff --git a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Proxy/InferredProxySpanHelper.cs b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Proxy/InferredProxySpanHelper.cs index 9c07404ed5c0..376e33ec4a04 100644 --- a/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Proxy/InferredProxySpanHelper.cs +++ b/tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Proxy/InferredProxySpanHelper.cs @@ -20,10 +20,12 @@ namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.Proxy; internal static class InferredProxySpanHelper { public const string AzureProxyHeaderValue = "azure-apim"; + public const string AzureFrontDoorHeaderValue = "azure-fd"; public const string AwsProxyHeaderValue = "aws-apigateway"; private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(InferredProxySpanHelper)); private static InferredProxyCoordinator? _awsCoordinator; - private static InferredProxyCoordinator? _azureCoordinator; + private static InferredProxyCoordinator? _azureApimCoordinator; + private static InferredProxyCoordinator? _azureFrontDoorCoordinator; /// /// Creates an inferred proxy span from request headers. @@ -54,8 +56,14 @@ internal static class InferredProxySpanHelper if (string.Equals(proxyName, AzureProxyHeaderValue, StringComparison.OrdinalIgnoreCase)) { - _azureCoordinator ??= new InferredProxyCoordinator(new AzureApiManagementExtractor(), new AzureApiManagementSpanFactory()); - return _azureCoordinator.ExtractAndCreateScope(tracer, carrier, accessor, propagationContext); + _azureApimCoordinator ??= new InferredProxyCoordinator(new AzureApiManagementExtractor(), new AzureApiManagementSpanFactory()); + return _azureApimCoordinator.ExtractAndCreateScope(tracer, carrier, accessor, propagationContext); + } + + if (string.Equals(proxyName, AzureFrontDoorHeaderValue, StringComparison.OrdinalIgnoreCase)) + { + _azureFrontDoorCoordinator ??= new InferredProxyCoordinator(new AzureFrontDoorExtractor(), new AzureFrontDoorSpanFactory()); + return _azureFrontDoorCoordinator.ExtractAndCreateScope(tracer, carrier, accessor, propagationContext); } if (string.Equals(proxyName, AwsProxyHeaderValue, StringComparison.OrdinalIgnoreCase)) diff --git a/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/AutoInstrumentation/Proxy/AzureFrontDoorExtractorTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/AutoInstrumentation/Proxy/AzureFrontDoorExtractorTests.cs new file mode 100644 index 000000000000..8a738582d375 --- /dev/null +++ b/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/AutoInstrumentation/Proxy/AzureFrontDoorExtractorTests.cs @@ -0,0 +1,137 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +using System; +using System.Globalization; +using Datadog.Trace.ClrProfiler.AutoInstrumentation.Proxy; +using Datadog.Trace.Headers; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.ClrProfiler.Managed.Tests.AutoInstrumentation.Proxy; + +public class AzureFrontDoorExtractorTests +{ + private readonly AzureFrontDoorExtractor _extractor; + + public AzureFrontDoorExtractorTests() + { + _extractor = new AzureFrontDoorExtractor(); + } + + [Fact] + public void TryExtract_WithAllValidHeaders_ReturnsTrue() + { + // this reduces precision to 1ms, so we can't compare extracted value to the original DateTimeOffset directly + var unixTimeMilliseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var start = DateTimeOffset.FromUnixTimeMilliseconds(unixTimeMilliseconds); + + var headers = ProxyTestHelpers.CreateValidAzureFrontDoorHeaders(unixTimeMilliseconds.ToString()); + + var success = _extractor.TryExtract(headers, headers.GetAccessor(), out var data); + + success.Should().BeTrue(); + data.ProxyName.Should().Be("azure-fd"); + data.StartTime.Should().Be(start); + data.DomainName.Should().Be("myapp.azurefd.net"); + data.HttpMethod.Should().Be("GET"); + data.Path.Should().Be("/api/test"); + data.Stage.Should().Be("prod"); + data.Region.Should().Be("canada central"); + } + + [Fact] + public void TryExtract_WithMinimumValidHeaders_ReturnsTrue() + { + var headers = ProxyTestHelpers.CreateValidAzureFrontDoorHeaders(); + headers.Remove(InferredProxyHeaders.HttpMethod); + headers.Remove(InferredProxyHeaders.Path); + headers.Remove(InferredProxyHeaders.Region); + headers.Remove(InferredProxyHeaders.Stage); + + var success = _extractor.TryExtract(headers, headers.GetAccessor(), out var data); + + success.Should().BeTrue(); + data.ProxyName.Should().Be("azure-fd"); + data.DomainName.Should().Be("myapp.azurefd.net"); + data.HttpMethod.Should().BeNull(); + data.Path.Should().BeNull(); + data.Stage.Should().BeNull(); + data.Region.Should().BeNull(); + } + + [Theory] + [InlineData("invalid")] + [InlineData("not-a-number")] + [InlineData("1111111122222222333333334444444455555555666666667777777788888888")] // too large + public void TryExtract_WithInvalidStartTime_ReturnsFalseAndDefaultData(string startTime) + { + // A non-empty but unparseable start time is not synthesized away; extraction must fail + // and leave `data` untouched (default) so no partial/garbage span is created. + var headers = ProxyTestHelpers.CreateValidAzureFrontDoorHeaders(); + headers.Set(InferredProxyHeaders.StartTime, startTime); + + var success = _extractor.TryExtract(headers, headers.GetAccessor(), out var data); + + success.Should().BeFalse(); + data.Should().Be(default(InferredProxyData)); + } + + [Fact] + public void TryExtract_WithMissingStartTime_ReturnsTrue() + { + var headers = ProxyTestHelpers.CreateValidAzureFrontDoorHeaders(); + headers.Remove(InferredProxyHeaders.StartTime); + + var success = _extractor.TryExtract(headers, headers.GetAccessor(), out var data); + + success.Should().BeTrue(); + } + + [Fact] + public void TryExtract_WithEmptyStartTime_SynthesizesStartTimeAndReturnsTrue() + { + // Unlike APIM, Front Door does not emit a start-time header, so a missing/empty value is + // expected and must be synthesized from "now" rather than causing extraction to fail. + var before = DateTimeOffset.UtcNow.AddSeconds(-5); + var headers = ProxyTestHelpers.CreateValidAzureFrontDoorHeaders(); + headers.Set(InferredProxyHeaders.StartTime, string.Empty); + + var success = _extractor.TryExtract(headers, headers.GetAccessor(), out var data); + var after = DateTimeOffset.UtcNow.AddSeconds(5); + + success.Should().BeTrue(); + data.StartTime.Should().NotBe(default); + data.StartTime.Should().BeOnOrAfter(before).And.BeOnOrBefore(after); + } + + [Fact] + public void TryExtract_WithLowerCaseHttpMethod_NormalizesToUpperCase() + { + // The HTTP method must be normalized so downstream resource names / tags are stable + // regardless of the casing the proxy happens to send. + var headers = ProxyTestHelpers.CreateValidAzureFrontDoorHeaders(); + headers.Set(InferredProxyHeaders.HttpMethod, "post"); + + var success = _extractor.TryExtract(headers, headers.GetAccessor(), out var data); + + success.Should().BeTrue(); + data.HttpMethod.Should().Be("POST"); + } + + [Fact] + public void TryExtract_WithMissingDomain_ReturnsTrueWithNullDomain() + { + // Domain is optional; its absence must not fail extraction, and the field should stay null + // (not empty string) so the factory can distinguish "not provided". + var headers = ProxyTestHelpers.CreateValidAzureFrontDoorHeaders(); + headers.Remove(InferredProxyHeaders.Domain); + + var success = _extractor.TryExtract(headers, headers.GetAccessor(), out var data); + + success.Should().BeTrue(); + data.DomainName.Should().BeNull(); + } +} diff --git a/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/AutoInstrumentation/Proxy/AzureFrontDoorSpanFactoryTests.cs b/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/AutoInstrumentation/Proxy/AzureFrontDoorSpanFactoryTests.cs new file mode 100644 index 000000000000..426e54a164ff --- /dev/null +++ b/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/AutoInstrumentation/Proxy/AzureFrontDoorSpanFactoryTests.cs @@ -0,0 +1,125 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; +using System.Threading.Tasks; +using Datadog.Trace.ClrProfiler.AutoInstrumentation.Proxy; +using Datadog.Trace.TestHelpers.TestTracer; +using FluentAssertions; +using Xunit; + +namespace Datadog.Trace.ClrProfiler.Managed.Tests.AutoInstrumentation.Proxy; + +public class AzureFrontDoorSpanFactoryTests +{ + [Fact] + public async Task CreateSpan_CreatesSpanWithCorrectProperties() + { + var factory = new AzureFrontDoorSpanFactory(); + await using var tracer = ProxyTestHelpers.GetMockTracer(); + var startTime = DateTimeOffset.UtcNow; + var data = new InferredProxyData("azure-fd", startTime, "myapp.azurefd.net", "GET", "api/v1/users", null, null); + + var scope = factory.CreateSpan(tracer, data); + + scope.Should().NotBeNull(); + var span = scope!.Span; + span.Should().NotBeNull(); + span.OperationName.Should().Be("azure.frontdoor"); + span.ResourceName.Should().Be("GET /api/v1/users"); // TODO obfuscation and quantization + span.Type.Should().Be("web"); + span.StartTime.Should().Be(startTime); + + var tags = scope.Span.Tags; + tags.Should().NotBeNull(); + tags.GetTag(Tags.HttpMethod).Should().Be("GET"); + tags.GetTag(Tags.InstrumentationName).Should().Be("azure-fd"); + tags.GetTag(Tags.HttpUrl).Should().Be("myapp.azurefd.net/api/v1/users"); + tags.GetTag(Tags.HttpRoute).Should().Be("/api/v1/users"); + } + + [Fact] + public async Task CreateSpan_WithNullPath_ProducesEmptyRoute() + { + // A missing path must not throw or produce a null route; the span should still be created + // with an empty route and a url that is just the domain. + var factory = new AzureFrontDoorSpanFactory(); + await using var tracer = ProxyTestHelpers.GetMockTracer(); + var data = new InferredProxyData("azure-fd", DateTimeOffset.UtcNow, "myapp.azurefd.net", "GET", path: null, null, null); + + var scope = factory.CreateSpan(tracer, data); + + scope.Should().NotBeNull(); + var tags = scope!.Span.Tags; + tags.GetTag(Tags.HttpRoute).Should().BeNullOrEmpty(); + tags.GetTag(Tags.HttpUrl).Should().Be("myapp.azurefd.net/"); + scope.Span.ResourceName.Should().Be("GET "); + } + + [Fact] + public async Task CreateSpan_WithNullHttpMethod_UsesRouteAsResourceName() + { + // With no method, the resource name should fall back to just the route (no leading space + // or stray "null"), and the method tag should be absent. + var factory = new AzureFrontDoorSpanFactory(); + await using var tracer = ProxyTestHelpers.GetMockTracer(); + var data = new InferredProxyData("azure-fd", DateTimeOffset.UtcNow, "myapp.azurefd.net", httpMethod: null, "api/test", null, null); + + var scope = factory.CreateSpan(tracer, data); + + scope.Should().NotBeNull(); + scope!.Span.ResourceName.Should().Be("/api/test"); + scope.Span.Tags.GetTag(Tags.HttpMethod).Should().BeNull(); + } + + [Fact] + public async Task CreateSpan_QuantizesNumericIdInPath() + { + // Numeric identifiers in the path must be quantized so high-cardinality ids don't explode + // the resource/route dimension. + var factory = new AzureFrontDoorSpanFactory(); + await using var tracer = ProxyTestHelpers.GetMockTracer(); + var data = new InferredProxyData("azure-fd", DateTimeOffset.UtcNow, "myapp.azurefd.net", "GET", "api/users/12345", null, null); + + var scope = factory.CreateSpan(tracer, data); + + scope.Should().NotBeNull(); + scope!.Span.Tags.GetTag(Tags.HttpRoute).Should().Be("/api/users/?"); + scope.Span.ResourceName.Should().Be("GET /api/users/?"); + } + + [Fact] + public async Task CreateSpan_LowerCasesRoute() + { + // The route is expected to be normalized to lower case for consistent aggregation. + var factory = new AzureFrontDoorSpanFactory(); + await using var tracer = ProxyTestHelpers.GetMockTracer(); + var data = new InferredProxyData("azure-fd", DateTimeOffset.UtcNow, "myapp.azurefd.net", "GET", "Api/Test", null, null); + + var scope = factory.CreateSpan(tracer, data); + + scope.Should().NotBeNull(); + scope!.Span.Tags.GetTag(Tags.HttpRoute).Should().Be("/api/test"); + } + + [Fact] + public async Task CreateSpan_WithLeadingSlashPath_DoesNotDoubleSlash() + { + // Front Door currently sends a relative path (no leading slash), but guard against a future + // change: an already-absolute path must not produce a double-slashed route/url ("//api/test"). + var factory = new AzureFrontDoorSpanFactory(); + await using var tracer = ProxyTestHelpers.GetMockTracer(); + var data = new InferredProxyData("azure-fd", DateTimeOffset.UtcNow, "myapp.azurefd.net", "GET", "/api/test", null, null); + + var scope = factory.CreateSpan(tracer, data); + + scope.Should().NotBeNull(); + scope!.Span.Tags.GetTag(Tags.HttpRoute).Should().Be("/api/test"); + scope.Span.Tags.GetTag(Tags.HttpUrl).Should().Be("myapp.azurefd.net/api/test"); + scope.Span.ResourceName.Should().Be("GET /api/test"); + } +} diff --git a/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/AutoInstrumentation/Proxy/ProxyTestHelpers.cs b/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/AutoInstrumentation/Proxy/ProxyTestHelpers.cs index ee05ef1617f3..06a2e3722658 100644 --- a/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/AutoInstrumentation/Proxy/ProxyTestHelpers.cs +++ b/tracer/test/Datadog.Trace.ClrProfiler.Managed.Tests/AutoInstrumentation/Proxy/ProxyTestHelpers.cs @@ -56,4 +56,21 @@ internal static NameValueHeadersCollection CreateValidAzureHeaders(string? start headers.Set(InferredProxyHeaders.Region, "canada central"); return headers; } + + internal static NameValueHeadersCollection CreateValidAzureFrontDoorHeaders(string? start = null) + { + var headers = new NameValueHeadersCollection([]); + headers.Set(InferredProxyHeaders.Name, "azure-fd"); + if (start is not null) + { + headers.Set(InferredProxyHeaders.StartTime, start); + } + + headers.Set(InferredProxyHeaders.Domain, "myapp.azurefd.net"); + headers.Set(InferredProxyHeaders.HttpMethod, "GET"); + headers.Set(InferredProxyHeaders.Path, "/api/test"); + headers.Set(InferredProxyHeaders.Stage, "prod"); + headers.Set(InferredProxyHeaders.Region, "canada central"); + return headers; + } } diff --git a/tracer/test/Datadog.Trace.Tests/ClrProfiler/AutoInstrumentation/Azure/Functions/AzureFunctionsCommonTests.cs b/tracer/test/Datadog.Trace.Tests/ClrProfiler/AutoInstrumentation/Azure/Functions/AzureFunctionsCommonTests.cs index 452a76443567..8402712df429 100644 --- a/tracer/test/Datadog.Trace.Tests/ClrProfiler/AutoInstrumentation/Azure/Functions/AzureFunctionsCommonTests.cs +++ b/tracer/test/Datadog.Trace.Tests/ClrProfiler/AutoInstrumentation/Azure/Functions/AzureFunctionsCommonTests.cs @@ -8,9 +8,14 @@ #if !NETFRAMEWORK using System; +using System.Collections; using System.Collections.Generic; +using Datadog.Trace; using Datadog.Trace.ClrProfiler.AutoInstrumentation.Azure.Functions; +using Datadog.Trace.Configuration; using Datadog.Trace.Propagators; +using Datadog.Trace.TestHelpers; +using Datadog.Trace.TestHelpers.TestTracer; using Datadog.Trace.Vendors.Newtonsoft.Json; using FluentAssertions; using Xunit; @@ -29,8 +34,52 @@ namespace Datadog.Trace.Tests.ClrProfiler.AutoInstrumentation.Azure.Functions { #pragma warning restore SA1403 + [Collection(nameof(Datadog.Trace.Tests.TracerInstanceTestCollection))] + [TracerRestorer] public class AzureFunctionsCommonTests { + [Fact] + public async System.Threading.Tasks.Task OnIsolatedFunctionBegin_DoesNotOverwriteInferredProxyRootSpan() + { + // Regression test for the guard in CreateIsolatedFunctionScope: when an inferred proxy + // span (e.g. azure.frontdoor) is the trace root and the function span is created as a + // *child* of it, the function tags/type/resource must NOT be copied onto the proxy root. + var settings = new TracerSettings(); + await using var scopedTracer = TracerHelper.CreateWithFakeAgent(settings); + TracerRestorerAttribute.SetTracer(scopedTracer); + + // Simulate the inferred proxy span as the active trace root. + using var proxyScope = scopedTracer.StartActiveInternal("azure.frontdoor"); + proxyScope.Span.Type = SpanTypes.Web; + proxyScope.Span.ResourceName = "GET /api/test"; + + // A minimal isolated-function context: no ASP.NET Core bridge (Items is null) and no + // input bindings, so the function span is parented to the active (proxy) scope and the + // "not the local root" branch is exercised. + var context = new MockFunctionContext + { + FunctionDefinition = new FunctionDefinitionStruct + { + Name = "MyFunction", + EntryPoint = "MyNamespace.MyFunction", + InputBindings = new Hashtable(), + }, + }; + + var state = AzureFunctionsCommon.OnIsolatedFunctionBegin(context); + + // A child function span was created, rooted at the proxy span. + state.Scope.Should().NotBeNull(); + var functionSpan = state.Scope!.Span; + functionSpan.Should().NotBeSameAs(proxyScope.Span); + state.Scope.Root.Span.Should().BeSameAs(proxyScope.Span); + + // The proxy root span must be untouched by the function instrumentation. + proxyScope.Span.OperationName.Should().Be("azure.frontdoor"); + proxyScope.Span.Type.Should().Be(SpanTypes.Web); + proxyScope.Span.ResourceName.Should().Be("GET /api/test"); + } + [Fact] public void ExtractPropagatedContextFromMessaging_MergesIntoEmptyBaggageCurrent() {