End-to-End Tracking in Azure Functions
Implement end-to-end request tracking with a TrackingID in Azure Functions, carrying the ID between services as a Service Bus application property.
A single request now passes through more services than anyone can trace by hand. An API starts a background job, a message lands on a queue, and something downstream of that queue picks it up long after the original request has finished.
The decoupling buys scalability and costs visibility. When something breaks, following one user’s request across a dozen services is the hard part.
The fix is a correlation ID that travels with the work, a TrackingID here. In Azure Functions talking over Service Bus, that means putting the ID on the message as an application property and logging it on every line.
Architecture at a Glance
- Producer Function: Receives an HTTP request, generates or extracts a
TrackingID, sends a message to Service Bus with this ID as a custom property. - Consumer Function: Listens to the Service Bus queue or topic, reads the
TrackingID, and logs it on every line. It can optionally pass the ID further as needed.
0. Wiring Up the Sender
The sender is created once at startup and injected where it’s needed. Here’s Program.cs:
using Azure.Messaging.ServiceBus;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Azure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.Services.AddAzureClients(clients =>
{
clients.AddServiceBusClient(builder.Configuration["ServiceBusConnection"]);
});
// One sender for the life of the process. ServiceBusSender is thread-safe and
// reuses the AMQP link underneath, so don't build one per invocation.
builder.Services.AddSingleton(sp =>
sp.GetRequiredService<ServiceBusClient>().CreateSender("orders"));
builder.Build().Run();
That needs Microsoft.Extensions.Azure, Azure.Messaging.ServiceBus, and Microsoft.Azure.Functions.Worker.Extensions.ServiceBus.
The [ServiceBusOutput] binding won’t do the job here. It sends the message body and nothing else, so it can’t set ApplicationProperties. Since that’s where the TrackingID travels, the message goes out through the SDK instead.
1. Producer Function: Sending a Message with TrackingID
using System.Net;
using Azure.Messaging.ServiceBus;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
public class ProducerFunction
{
private readonly ILogger<ProducerFunction> _logger;
private readonly ServiceBusSender _sender;
public ProducerFunction(ILogger<ProducerFunction> logger, ServiceBusSender sender)
{
_logger = logger;
_sender = sender;
}
[Function("ProducerFunction")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
{
// ReadAsStringAsync handles the body. Reading req.Body with a StreamReader
// is synchronous IO, and ASP.NET Core integration disallows that by default.
string requestBody = await req.ReadAsStringAsync() ?? string.Empty;
// Headers is an HttpHeadersCollection, so there's no string indexer here.
// Read a header with TryGetValues.
string trackingId = req.Headers.TryGetValues("TrackingID", out var values)
? values.FirstOrDefault() ?? Guid.NewGuid().ToString()
: Guid.NewGuid().ToString();
var message = new ServiceBusMessage(BinaryData.FromString(requestBody));
message.ApplicationProperties["TrackingID"] = trackingId;
_logger.LogInformation("Sending message with TrackingID {TrackingID}", trackingId);
await _sender.SendMessageAsync(message);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
Status = "Message sent",
TrackingID = trackingId
});
return response;
}
}
The sender uses the Azure.Messaging.ServiceBus SDK, attaches TrackingID as a custom application property, and logs it as a structured property.
2. Consumer Function: Receiving and Logging with TrackingID
using Azure.Messaging.ServiceBus;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
public class ConsumerFunction
{
private readonly ILogger<ConsumerFunction> _logger;
public ConsumerFunction(ILogger<ConsumerFunction> logger)
{
_logger = logger;
}
[Function("ConsumerFunction")]
public void Run(
[ServiceBusTrigger("orders", Connection = "ServiceBusConnection")]
ServiceBusReceivedMessage message)
{
// Extract TrackingID from the message properties
string trackingId = message.ApplicationProperties.TryGetValue("TrackingID", out var value)
? value?.ToString() ?? "N/A"
: "N/A";
_logger.LogInformation(
"Received message {MessageId} with TrackingID {TrackingID}",
message.MessageId, trackingId);
_logger.LogInformation("Processing message body: {Body}", message.Body);
// Pass trackingId downstream if needed -- for example, add it to outgoing
// HTTP headers or include it in the audit row you just wrote.
}
}
Correlating Logs
Log the TrackingID as a structured property on every line. Don’t bury it in the message text:
_logger.LogInformation("Processing message {MessageId} with TrackingID {TrackingID}",
message.MessageId, trackingId);
Every named placeholder becomes its own field on the log record (customDimensions, if you’re on Application Insights). One query then gives you every line for a single request, across every service it passed through.
I’d skip BeginScope for this. Whether scope values reach your log store is down to the logging provider, not the Functions runtime, and when they don’t, the TrackingID is missing from the exact logs you went looking for it in.
Propagating TrackingID to Downstream Services
Once you’ve got the TrackingID, you can:
- Send it as a header in HTTP requests:
request.Headers.Add("TrackingID", trackingId); - Use it in telemetry or performance metrics
- Store it in databases or audit trails
One ID across every service means one search returns the whole story for a request.
Conclusion
The whole pattern is three things:
- Use
TrackingIDas a unique correlation ID - Forward it through Service Bus with
ApplicationProperties - Log it as a structured property at every stage
There’s no new infrastructure to run, and the code cost is a few lines.
What’s Next?
- Forward
TrackingIDautomatically in outgoing HTTP calls with aDelegatingHandler, so you stop remembering to add the header by hand - Extend the pattern to retries and dead-letter messages, where the
TrackingIDis often the only thing tying the failure back to the original request - Carry it into the places tracing doesn’t reach, like a support ticket or a batch export. That’s where a W3C
traceparenton its own stops being useful
Happy tracing!