Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -129,8 +130,9 @@ public static CallTargetState OnFunctionExecutionBegin<TTarget, TFunction>(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;
Comment thread
TophrC-dd marked this conversation as resolved.
// Ignoring null because guaranteed running in AAS
if (tracer.Settings.AzureAppServiceMetadata is { IsIsolatedFunctionsApp: true }
&& tracer.InternalActiveScope is { } activeScope)
Expand Down Expand Up @@ -330,24 +332,31 @@ _ 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)
{
// The ASP.NET Core span is already active - don't create a new span,
// 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
{
Expand All @@ -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}";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// <copyright file="AzureFrontDoorExtractor.cs" company="Datadog">
// 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.
// </copyright>

#nullable enable

using System;
using Datadog.Trace.Logging;
using Datadog.Trace.Propagators;
using Datadog.Trace.Vendors.Serilog.Events;

namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.Proxy;

/// <summary>
/// Extracts proxy metadata from Azure Frontdoor headers.
/// </summary>
internal sealed class AzureFrontDoorExtractor : IInferredProxyExtractor
{
private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor<AzureFrontDoorExtractor>();

public bool TryExtract<TCarrier, TCarrierGetter>(TCarrier carrier, TCarrierGetter carrierGetter, out InferredProxyData data)
where TCarrierGetter : struct, ICarrierGetter<TCarrier>
{
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// <copyright file="AzureFrontDoorSpanFactory.cs" company="Datadog">
// 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.
// </copyright>

#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;

/// <summary>
/// Creates spans representing requests handled by Azure Frontdoor.
/// </summary>
internal sealed class AzureFrontDoorSpanFactory : IInferredSpanFactory
{
private const string OperationName = AzureFunctionsConstants.AzureFrontDoorName;
private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor<AzureFrontDoorSpanFactory>();

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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Creates an inferred proxy span from request headers.
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// <copyright file="AzureFrontDoorExtractorTests.cs" company="Datadog">
// 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.
// </copyright>

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();
}
}
Loading
Loading