Distributed Tracing with OpenTelemetry
In a distributed system, one request passes through several services. Distributed tracing gives each request an ID and records each part of the work as a Span with a start time and a duration. With one look we see where the request went, where it got stuck, and where its time was spent.
Author: bezzad
The problem: one order, four services, thousands of log lines
In our shop, an order takes this path:
- The order service. It gets the HTTP request, saves the order, and puts a message in Kafka.
- The Worker service. It reads the message and calls the payment service.
- The payment service. It calls the bank.
A customer calls: “My payment took too long.” Each service has several copies (Pods), and each one writes its own log. These are the questions:
- Which Pods did this request pass through?
- Of the three seconds of total time, how much was in the database, how much in the queue, and how much at the bank?
- Which logs belong to this request?
With logs alone, we must match the log times with each other and guess. This takes hours.
The idea: Trace and Span
Distributed Tracing has two simple ideas:
- The full path (Trace). The whole journey of one request through all services. It has a unique ID that we call the Trace Id.
- One part of the work (Span). One specific job inside this journey. For example, a database query, an HTTP call, or processing a message. Each Span has a start time, a duration, a name, a few tags (Tag), and a parent Span.
All Spans of one request have the same Trace Id. Each Span knows its parent. So a tool can show them as a tree and as a waterfall chart:
This chart answers the questions above with one look. Notice one more point: the main request finished after 60 milliseconds, but the Worker’s job happened later and separately. A Trace also shows asynchronous work.
Passing the ID between services
For Spans from different services to join one Trace, each service must give the Trace Id and the parent Span ID to the next service. We call this Context Propagation.
The W3C Trace Context standard defines a header named traceparent. This header has four parts:
- In HTTP it is automatic. In .NET, ASP.NET Core itself reads the traceparent header from the request. The HttpClient class also adds it to the next request.
- In a message queue it is not automatic. Kafka does not do this by itself. The producer must put the ID in the message header. The consumer must read it and make its own Span a child of it. Or use a ready Instrumentation library that does the same thing.
If we forget this step, the Trace breaks right at Kafka. See what happens:
Code: setting up OpenTelemetry in .NET
In .NET, the Activity class is the Span, and the ActivitySource class is what creates Spans. These classes are part of .NET itself. OpenTelemetry collects them and sends them to the viewing tool.
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("orders-api"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation() // incoming HTTP requests
.AddHttpClientInstrumentation() // outgoing HTTP calls
.AddSource(Telemetry.SourceName) // our own spans
.AddOtlpExporter()); // send to the collector
For our own important jobs, we create a manual Span and put the order ID on it. Support does not know the Trace Id, but they know the order number. With this tag, we get from the order number to the Trace:
public static class Telemetry
{
public const string SourceName = "Shop.Orders";
public static readonly ActivitySource Source = new(SourceName);
}
public async Task ReserveStockAsync(Order order, CancellationToken ct)
{
using var activity = Telemetry.Source.StartActivity("ReserveStock");
activity?.SetTag("order.id", order.Id);
try
{
await warehouse.ReserveAsync(order.Id, order.Items, ct);
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
The question mark after activity is important. If nobody listens to this source, or this request was not chosen to be saved, the StartActivity method returns null. This is on purpose, so that when a Trace is not needed, it also has no cost.
Passing the ID through Kafka
This code is written with the Confluent.Kafka library and the OpenTelemetry context propagation API. The producer side puts the ID in the header:
private static readonly TextMapPropagator Propagator = Propagators.DefaultTextMapPropagator;
public async Task PublishAsync(OrderPlaced message, CancellationToken ct)
{
using var activity = Telemetry.Source.StartActivity("PublishOrderPlaced", ActivityKind.Producer);
var headers = new Headers();
Propagator.Inject(
new PropagationContext(activity?.Context ?? default, Baggage.Current),
headers,
(h, key, value) => h.Add(key, Encoding.UTF8.GetBytes(value)));
await producer.ProduceAsync("orders",
new Message<string, string> { Key = message.OrderId.ToString(), Value = Serialize(message), Headers = headers }, ct);
}
The consumer side reads the ID and makes its own Span a child of it:
var parent = Propagator.Extract(default, result.Message.Headers,
(h, key) => h.TryGetLastBytes(key, out var bytes)
? new[] { Encoding.UTF8.GetString(bytes) }
: Enumerable.Empty<string>());
Baggage.Current = parent.Baggage;
using var activity = Telemetry.Source.StartActivity(
"ProcessOrderPlaced", ActivityKind.Consumer, parent.ActivityContext);
Where does the data go?
The app sends Trace data with the standard OTLP protocol. Usually it first goes to an OpenTelemetry Collector. The Collector gathers the data, filters it, and sends it to the viewing tool. Tools like Jaeger, Grafana Tempo or Zipkin. Because the protocol is standard, changing the viewing tool does not need a change in the app code.
Sampling
Saving the Trace of every request in a high-traffic system is very expensive. So we keep only a part. We have two ways:
At the start of the request (Head-based)
- The first service decides. For example, ten percent of requests.
- The decision goes to the next services in the last flag of traceparent. So a Trace is not left half done.
- It is simple and cheap.
- Downside: it does not yet know if the request will fail. An important error Trace may be thrown away.
After it finishes (Tail-based)
- We decide after the Trace finishes. Usually in the Collector.
- We keep all Traces with errors or slowness. From normal Traces, only a small percent.
- Downside: the Collector must keep all Spans in memory for a while. It is more complex and more expensive.
Head-based sampling in .NET looks like this. ParentBased means: if the previous service has decided, follow the same decision:
tracing.SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.1)));
Important rules
- Check context propagation at every boundary. HTTP, message queues, background jobs. Wherever the ID is lost, the Trace breaks.
- A business ID on the Span. The order number, not sensitive data.
- Put the Trace ID in the logs too. So you get from an error log straight to the Trace, and the other way around.
- Create manual Spans for important jobs, but not for every method. Too many Spans cost money and also clutter the chart.
- A Span name should be short and fixed. Do not put IDs in the name. Make them tags. A fixed name lets us compare similar Spans.
- Mark errors on the Span. Otherwise, in the viewing tool, a failed Span looks green.
Common mistakes
| Mistake | Result | Right way |
|---|---|---|
| Not passing the ID in Kafka | The Trace breaks at the queue, and we have two unrelated pieces. | Put traceparent in the message header and read it. |
| Making a Trace Id by hand, apart from the standard | Tools and libraries do not recognize it. | The W3C standard and the Activity class. |
| Putting the order ID in the Span name | Thousands of different names, comparison is impossible. | A fixed name, the ID as a tag. |
| Saving one hundred percent of Traces at high traffic | Very high storage cost. | Sampling, and keeping all errors. |
| Only a fixed ten percent sampling | An important error Trace may be thrown away. | Tail-based sampling for errors. |
| Sensitive data in tags | A token or card number in the viewing tool. | Only IDs and safe data. |
Summary in six lines
- A Trace is the whole path of one request. Each Span is one part of the work, with a time and a parent.
- The waterfall chart shows where the time was spent. We no longer need to guess.
- The ID goes between services with the traceparent header. In HTTP it is automatic, in Kafka it is not.
- In .NET, the Activity class is the Span, and OpenTelemetry collects it and sends it.
- Put the order number on the Span and the Trace Id on the logs.
- Sample to control cost, but keep the Traces with errors.