Skip to content

Commit 0d76d64

Browse files
[Azure Event Grid] Add Azure Functions support
1 parent 7f77047 commit 0d76d64

21 files changed

Lines changed: 947 additions & 67 deletions

File tree

.azure-pipelines/ultimate-pipeline.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2412,6 +2412,9 @@ stages:
24122412
SampleName: $(IntegrationTestSampleName)
24132413
DD_LOGGER_DD_API_KEY: $(ddApiKey)
24142414
COMPOSE_PROFILES: group$(dockerGroup)
2415+
# Event Grid emulator (group2) pushes to the Functions host webhook; in CI the func host runs inside
2416+
# the IntegrationTests container, reachable by sibling containers as "integrationtests".
2417+
EVENTGRID_WEBHOOK_HOST: integrationtests
24152418
displayName: docker-compose build IntegrationTests and run StartDependencies (Group $(dockerGroup))
24162419
retryCountOnTaskFailure: 5
24172420
@@ -2431,6 +2434,7 @@ stages:
24312434
DD_LOGGER_DD_API_KEY: $(ddApiKey)
24322435
baseImage: $(baseImage) # for interpolation in the docker-compose file
24332436
COMPOSE_PROFILES: group$(dockerGroup)
2437+
EVENTGRID_WEBHOOK_HOST: integrationtests
24342438
24352439
- script: docker-compose -f docker-compose.yml -p $(DockerComposeProjectName)-g$(dockerGroup) logs
24362440
displayName: docker-compose logs

docker-compose.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,14 @@ services:
280280
- "127.0.0.1:6500:6500"
281281
volumes:
282282
- ./docker/eventgrid-emulator-config.json:/app/appsettings.json:ro
283+
environment:
284+
# Override the topic's subscriber webhook (ASP.NET array config). The Azure Functions host that
285+
# receives the pushed event is reachable at a different host depending on where it runs: inside the
286+
# IntegrationTests container in CI (EVENTGRID_WEBHOOK_HOST=integrationtests) vs on the Docker host
287+
# locally (host.docker.internal). The emulator adds the aeg-event-type header and forwards the CloudEvent.
288+
- Topics__samples-azure-functions-eventgrid-topic__0=http://${EVENTGRID_WEBHOOK_HOST:-host.docker.internal}:7071/runtime/webhooks/eventgrid?functionName=EventGridTrigger
289+
extra_hosts:
290+
- "host.docker.internal:host-gateway"
283291

284292
cosmosdb-emulator:
285293
image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview@sha256:54d7bc334494c50cea867c270880671a7db080626a9732832b34c0d69342f9b0
@@ -465,6 +473,7 @@ services:
465473
- ASB_CONNECTION_STRING=Endpoint=sb://azureservicebus-emulator:5672;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;
466474
- EVENTHUBS_CONNECTION_STRING=Endpoint=sb://azure-eventhubs-emulator:5672;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;
467475
- EVENTGRID_TOPIC_ENDPOINT=http://azure-eventgrid-emulator:6500/samples-eventgrid-topic/api/events
476+
- EVENTGRID_AZURE_FUNCTIONS_TOPIC_ENDPOINT=http://azure-eventgrid-emulator:6500/samples-azure-functions-eventgrid-topic/api/events
468477
- COSMOSDB_ENDPOINT=https://cosmosdb-emulator:8081
469478
- TEST_AGENT_HOST=test-agent
470479
- CONTAINER_HOSTNAME=http://integrationtests
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"Topics": {
3-
"samples-eventgrid-topic": []
3+
"samples-eventgrid-topic": [],
4+
"samples-azure-functions-eventgrid-topic": []
45
}
56
}

tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Azure/Functions/AzureFunctionsCommon.cs

Lines changed: 13 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
using System;
99
using System.Collections;
1010
using System.Collections.Generic;
11-
using System.Diagnostics.CodeAnalysis;
1211
using System.Linq;
1312
using Datadog.Trace.ClrProfiler.AutoInstrumentation.Azure.Shared;
1413
using Datadog.Trace.ClrProfiler.AutoInstrumentation.Proxy;
@@ -20,9 +19,6 @@
2019
using Datadog.Trace.PlatformHelpers;
2120
using Datadog.Trace.Propagators;
2221
using Datadog.Trace.Tagging;
23-
using Datadog.Trace.Util;
24-
using Datadog.Trace.Util.Json;
25-
using Datadog.Trace.Vendors.Newtonsoft.Json;
2622
using Datadog.Trace.Vendors.Serilog.Events;
2723

2824
#nullable enable
@@ -314,6 +310,12 @@ _ when type.StartsWith("eventGrid", StringComparison.OrdinalIgnoreCase) => "Even
314310
case "EventHub" when tracer.CurrentTraceSettings.Settings.IsIntegrationEnabled(IntegrationId.AzureEventHubs):
315311
extractedContext = ExtractPropagatedContextFromMessaging(functionContext, "Properties", "PropertiesArray").MergeBaggageInto(Baggage.Current);
316312
break;
313+
314+
case "EventGrid" when tracer.CurrentTraceSettings.Settings.IsIntegrationEnabled(IntegrationId.AzureEventGrid, defaultValue: false):
315+
{
316+
extractedContext = EventGridFunctionsCommon.CreateReceiveSpanContext(tracer, functionContext, entry.Key as string, Baggage.Current);
317+
break;
318+
}
317319
}
318320

319321
break;
@@ -483,23 +485,14 @@ private static PropagationContext ExtractPropagatedContextFromHttp<T>(T function
483485

484486
try
485487
{
486-
object? feature = null;
487-
foreach (var keyValuePair in functionContext.Features)
488-
{
489-
if (keyValuePair.Key.FullName?.Equals("Microsoft.Azure.Functions.Worker.Context.Features.IFunctionBindingsFeature") == true)
490-
{
491-
feature = keyValuePair.Value;
492-
break;
493-
}
494-
}
495-
496-
if (feature is null || !feature.TryDuckCast<FunctionBindingsFeatureStruct>(out var bindingFeature))
488+
var bindingsFeature = FunctionBindingsCommon.GetBindingsFeature(functionContext);
489+
if (bindingsFeature is null)
497490
{
498491
return default;
499492
}
500493

501-
if (bindingFeature.InputData is null
502-
|| !bindingFeature.InputData.TryGetValue(bindingName!, out var requestDataObject)
494+
if (bindingsFeature.Value.InputData is null
495+
|| !bindingsFeature.Value.InputData.TryGetValue(bindingName!, out var requestDataObject)
503496
|| requestDataObject is null)
504497
{
505498
return default;
@@ -524,7 +517,7 @@ internal static PropagationContext ExtractPropagatedContextFromMessaging<T>(T co
524517
{
525518
try
526519
{
527-
var bindingsFeature = GetFeatureFromContext<T, FunctionBindingsFeatureStruct>(context, "Microsoft.Azure.Functions.Worker.Context.Features.IFunctionBindingsFeature");
520+
var bindingsFeature = FunctionBindingsCommon.GetBindingsFeature(context);
528521
if (bindingsFeature == null)
529522
{
530523
return default;
@@ -535,7 +528,7 @@ internal static PropagationContext ExtractPropagatedContextFromMessaging<T>(T co
535528

536529
// Extract from single message properties
537530
if (triggerMetadata?.TryGetValue(singlePropertyKey, out var singlePropsObj) == true &&
538-
TryParseJson<Dictionary<string, object>>(singlePropsObj, out var singleProps) && singleProps != null)
531+
FunctionBindingsCommon.TryParseJson<Dictionary<string, object>>(singlePropsObj, out var singleProps))
539532
{
540533
var singleContext = Shared.AzureMessagingCommon.ExtractContext(singleProps);
541534
if (singleContext.SpanContext != null)
@@ -546,7 +539,7 @@ internal static PropagationContext ExtractPropagatedContextFromMessaging<T>(T co
546539

547540
// Extract from batch properties array
548541
if (triggerMetadata?.TryGetValue(batchPropertyKey, out var arrayPropsObj) == true &&
549-
TryParseJson<Dictionary<string, object>[]>(arrayPropsObj, out var propsArray) && propsArray != null)
542+
FunctionBindingsCommon.TryParseJson<Dictionary<string, object>[]>(arrayPropsObj, out var propsArray))
550543
{
551544
foreach (var props in propsArray)
552545
{
@@ -580,27 +573,6 @@ internal static PropagationContext ExtractPropagatedContextFromMessaging<T>(T co
580573
}
581574
}
582575

583-
private static bool TryParseJson<T>(object? jsonObj, [NotNullWhen(true)] out T? result)
584-
where T : class
585-
{
586-
result = null;
587-
if (jsonObj is not string jsonString)
588-
{
589-
return false;
590-
}
591-
592-
try
593-
{
594-
result = JsonHelper.DeserializeObject<T>(jsonString);
595-
return result != null;
596-
}
597-
catch (Exception ex)
598-
{
599-
Log.Debug(ex, "Failed to parse JSON: {Json}", jsonString);
600-
return false;
601-
}
602-
}
603-
604576
// Checks if all SpanContexts are identical (ignores baggage)
605577
private static bool AreAllSpanContextsIdentical(List<PropagationContext> contexts)
606578
{
@@ -615,26 +587,6 @@ private static bool AreAllSpanContextsIdentical(List<PropagationContext> context
615587
ctx.SpanContext.TraceId128 == first!.TraceId128 &&
616588
ctx.SpanContext.SpanId == first.SpanId);
617589
}
618-
619-
private static TFeature? GetFeatureFromContext<T, TFeature>(T context, string featureTypeName)
620-
where T : IFunctionContext
621-
where TFeature : struct
622-
{
623-
if (context.Features == null)
624-
{
625-
return null;
626-
}
627-
628-
foreach (var kvp in context.Features)
629-
{
630-
if (kvp.Key.FullName == featureTypeName)
631-
{
632-
return kvp.Value?.TryDuckCast<TFeature>(out var feature) == true ? feature : null;
633-
}
634-
}
635-
636-
return null;
637-
}
638590
}
639591
}
640592

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// <copyright file="EventGridFunctionsCommon.cs" company="Datadog">
2+
// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
4+
// </copyright>
5+
6+
#if !NETFRAMEWORK
7+
8+
using System;
9+
using System.Collections.Generic;
10+
using Datadog.Trace.ClrProfiler.AutoInstrumentation.Azure.Shared;
11+
using Datadog.Trace.Configuration;
12+
using Datadog.Trace.Configuration.Schema;
13+
using Datadog.Trace.Logging;
14+
using Datadog.Trace.Propagators;
15+
using Datadog.Trace.Tagging;
16+
using Datadog.Trace.Util;
17+
18+
#nullable enable
19+
20+
namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.Azure.Functions
21+
{
22+
internal static class EventGridFunctionsCommon
23+
{
24+
private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(EventGridFunctionsCommon));
25+
26+
/// <summary>
27+
/// Creates an <c>azure_eventgrid.receive</c> consumer span that links to the producers'
28+
/// <c>azure_eventgrid.send</c> spans (when W3C contexts were extracted from the CloudEvents),
29+
/// and returns a <see cref="PropagationContext"/> pointing at the receive span so the
30+
/// function-invoke span is parented under it. This mirrors the Event Hubs / Service Bus
31+
/// receive-span topology (new trace, span-linked to the producer).
32+
/// </summary>
33+
internal static PropagationContext CreateReceiveSpanContext<T>(Tracer tracer, T context, string? bindingName, Baggage destinationBaggage)
34+
where T : IFunctionContext
35+
{
36+
var cloudEvents = GetCloudEvents(context, bindingName);
37+
var producerContexts = ExtractPropagatedContexts(cloudEvents);
38+
39+
// As with Service Bus and Event Hubs batches, use the first extracted producer
40+
// context as the source of ambient baggage.
41+
if (producerContexts.Count > 0)
42+
{
43+
producerContexts[0].MergeBaggageInto(destinationBaggage);
44+
}
45+
46+
return CreateReceiveSpan(tracer, cloudEvents, producerContexts);
47+
}
48+
49+
internal static List<PropagationContext> ExtractPropagatedContexts(Dictionary<string, object>[] cloudEvents)
50+
{
51+
var extractedContexts = new List<PropagationContext>();
52+
53+
try
54+
{
55+
if (cloudEvents.Length == 0)
56+
{
57+
return extractedContexts;
58+
}
59+
60+
var uniqueSpanContexts = new HashSet<SpanContext>(new SpanContextComparer());
61+
62+
foreach (var cloudEventProps in cloudEvents)
63+
{
64+
// Extract W3C trace context and baggage from CloudEvent extension attributes.
65+
// These were injected by EventGridCommon.InjectW3CContext() on the publisher side.
66+
var extractedContext = AzureMessagingCommon.ExtractContext(cloudEventProps);
67+
if (extractedContext.SpanContext is { } spanContext && uniqueSpanContexts.Add(spanContext))
68+
{
69+
extractedContexts.Add(extractedContext);
70+
}
71+
}
72+
}
73+
catch (Exception ex)
74+
{
75+
Log.Error(ex, "Error extracting propagated context from EventGrid binding");
76+
}
77+
78+
return extractedContexts;
79+
}
80+
81+
internal static List<SpanLink>? CreateSpanLinks(List<PropagationContext> producerContexts, bool linksEnabled)
82+
{
83+
if (!linksEnabled || producerContexts.Count == 0)
84+
{
85+
return null;
86+
}
87+
88+
var links = new List<SpanLink>(producerContexts.Count);
89+
foreach (var producerContext in producerContexts)
90+
{
91+
if (producerContext.SpanContext is { } producerSpanContext)
92+
{
93+
links.Add(new SpanLink(producerSpanContext));
94+
}
95+
}
96+
97+
return links;
98+
}
99+
100+
/// <summary>
101+
/// Reads the CloudEvent JSON object or array for the given binding from <c>InputData</c>.
102+
/// The full CloudEvent JSON (including extension attributes) lives in InputData, not
103+
/// TriggerMetadata (which only contains <c>{"data": ...}</c> for Event Grid).
104+
/// </summary>
105+
internal static Dictionary<string, object>[] GetCloudEvents<T>(T context, string? bindingName)
106+
where T : IFunctionContext
107+
{
108+
if (StringUtil.IsNullOrEmpty(bindingName))
109+
{
110+
return [];
111+
}
112+
113+
var bindingsFeature = FunctionBindingsCommon.GetBindingsFeature(context);
114+
if (bindingsFeature is null)
115+
{
116+
return [];
117+
}
118+
119+
if (bindingsFeature.Value.InputData is null
120+
|| !bindingsFeature.Value.InputData.TryGetValue(bindingName!, out var inputDataObj)
121+
|| inputDataObj is not string inputDataJson)
122+
{
123+
return [];
124+
}
125+
126+
var firstNonWhitespaceIndex = 0;
127+
while (firstNonWhitespaceIndex < inputDataJson.Length && char.IsWhiteSpace(inputDataJson[firstNonWhitespaceIndex]))
128+
{
129+
firstNonWhitespaceIndex++;
130+
}
131+
132+
if (firstNonWhitespaceIndex == inputDataJson.Length)
133+
{
134+
return [];
135+
}
136+
137+
if (inputDataJson[firstNonWhitespaceIndex] == '[')
138+
{
139+
return FunctionBindingsCommon.TryParseJson<Dictionary<string, object>[]>(inputDataJson, out var cloudEventBatch) ? cloudEventBatch : [];
140+
}
141+
142+
return FunctionBindingsCommon.TryParseJson<Dictionary<string, object>>(inputDataJson, out var cloudEventProps) ? [cloudEventProps] : [];
143+
}
144+
145+
private static PropagationContext CreateReceiveSpan(Tracer tracer, Dictionary<string, object>[] cloudEvents, List<PropagationContext> producerContexts)
146+
{
147+
try
148+
{
149+
var tags = tracer.CurrentTraceSettings.Schema.Messaging.CreateAzureEventGridTags(SpanKinds.Consumer);
150+
tags.MessagingOperation = "receive";
151+
152+
var links = CreateSpanLinks(producerContexts, tracer.Settings.AzureEventGridBatchLinksEnabled);
153+
var (serviceName, serviceNameSource) = tracer.CurrentTraceSettings.Schema.Messaging.GetServiceNameMetadata(MessagingSchema.ServiceType.AzureEventGrid);
154+
155+
using var scope = tracer.StartActiveInternal(
156+
"azure_eventgrid.receive",
157+
parent: SpanContext.None,
158+
tags: tags,
159+
serviceName: serviceName,
160+
serviceNameSource: serviceNameSource,
161+
links: links);
162+
163+
var span = scope.Span;
164+
span.Type = SpanTypes.Queue;
165+
span.ResourceName = "eventgrid";
166+
167+
if (cloudEvents.Length == 1)
168+
{
169+
var cloudEventProps = cloudEvents[0];
170+
if (cloudEventProps.TryGetValue("id", out var idObj) && idObj is string id && id.Length > 0)
171+
{
172+
span.SetTag(Tags.MessagingMessageId, id);
173+
}
174+
}
175+
else if (cloudEvents.Length > 1)
176+
{
177+
tags.MessagingBatchMessageCount = cloudEvents.Length.ToString();
178+
}
179+
180+
tracer.TracerManager.Telemetry.IntegrationGeneratedSpan(IntegrationId.AzureEventGrid);
181+
182+
return new PropagationContext(span.Context, baggage: null);
183+
}
184+
catch (Exception ex)
185+
{
186+
Log.Error(ex, "Error creating Azure Event Grid receive span");
187+
return producerContexts.Count == 1 ? producerContexts[0] : default;
188+
}
189+
}
190+
}
191+
}
192+
193+
#endif

0 commit comments

Comments
 (0)