[Java][Profiling] Async-profiler + OpenTelemetry spans
Spans in tracing
The word span doesn’t come from the profiling world, it comes from the distributed tracing one. Before we start playing with the async-profiler, let’s agree on what a span is there, since the profiler borrows both the name and the idea. The most popular tracing standard nowadays is OpenTelemetry, so let’s use it as an example.
Imagine a very simple e-commerce system. A user clicks “buy” and the request travels through a few services:
gateway ---> order-service ---> payment-service
---> warehouse-service ---> database
From the user’s perspective this is one operation that either succeeded or not, and either was fast or was slow. From the infrastructure perspective it is a handful of HTTP calls, a few database queries, maybe a message on a queue, executed by different threads on different machines, each of them producing its own logs. Tracing is a technique of gluing all of that back together into a single picture.
Two definitions:
- Span - a single unit of work, with a name, a start timestamp and a duration. It can be
POST /orders,SELECT * FROM ORDERS, or just a plain method call likecalculateDiscount. - Trace - the whole tree of spans that belongs to one end-to-end operation.
Span context
Every span carries a span context. The three fields that matter for us are:
traceId- 16 bytes (32 hex characters), the same for every span in the tracespanId- 8 bytes (16 hex characters), unique for that particular span- parent
spanId- empty for the root span
The last one is what makes a trace a tree, and not just a bag of spans. For our “buy” request it may look like this:
traceId = 4bf92f3577b34da6a3ce929d0e0e4736
spanId=00f067aa0ba902b7 POST /orders [=================================] 850ms
spanId=a2fb4a1d1a96d312 HTTP POST /payments [=======] 190ms
spanId=b9c7c989f97918e1 INSERT INTO PAYMENTS [==] 55ms
spanId=eee19b7ec3c1b174 HTTP POST /reservations [==========================] 620ms
spanId=00b976fa8a4d2f1c SELECT ... FROM WAREHOUSE [=========================] 600ms
Every service reports its own spans, they are shipped to some collector, and the collector
groups them by traceId and rebuilds the tree using the parent spanId. This is exactly what you see
in Jaeger, Zipkin, Grafana Tempo or any commercial APM.
Besides the context, a span can also carry:
- attributes - key/value pairs, like
http.response.status_code=200ordb.system=postgresql - events - timestamped messages inside the span, exceptions are usually reported that way
- status -
UNSET,OKorERROR - kind -
SERVER,CLIENT,INTERNAL,PRODUCERorCONSUMER
How the spans are created
In the OpenTelemetry Java API, you create a span manually like this:
Tracer tracer = openTelemetry.getTracer("com.example.orders");
Span span = tracer.spanBuilder("processOrder")
.setAttribute("order.id", orderId)
.startSpan();
try (Scope scope = span.makeCurrent()) {
processOrder(orderId);
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR);
throw e;
} finally {
span.end();
}
Three things are worth noticing here, because we will see the very same pattern in the async-profiler later on:
- the span is started and ended explicitly, the difference between those two timestamps is the duration
span.makeCurrent()puts the span into aThreadLocal, so that any code executed below can find out “in which span am I running right now?” - this is what all the instrumentation libraries use- the
Scopehas to be closed on the same thread that opened it
In practice you rarely write that code yourself. The OpenTelemetry Java agent instruments the popular frameworks and libraries for you, and in a Spring Boot 3 application you get Micrometer Tracing, which can bridge to OpenTelemetry, out of the box.
Context propagation
The traceId alone would be useless if it stopped at the service boundary. When order-service
calls payment-service, the span context is serialized into an HTTP header - the
W3C Trace Context standard calls it
traceparent:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^ ^ ^ ^
| | | flags (sampled)
| traceId parent spanId
version
The receiving service reads that header, and every span it creates inherits the same traceId. That’s
the whole magic. The same mechanism works for messaging systems, where the context is put into the
message headers instead.
What tracing gives you, and what it doesn’t
Let’s go back to our waterfall. It tells us a lot:
- the whole request took
850ms 620msof that was spent inwarehouse-service- and almost all of it,
600ms, was a single database query
That is a great starting point for an investigation, and very often it is enough. But now let’s change the example a little bit:
traceId = 4bf92f3577b34da6a3ce929d0e0e4736
spanId=00f067aa0ba902b7 POST /orders [=================================] 850ms
spanId=a2fb4a1d1a96d312 HTTP POST /payments [=======] 190ms
spanId=eee19b7ec3c1b174 HTTP POST /reservations [====] 110ms
Where did the remaining 550ms go? Somewhere in the order-service code, between the calls. Tracing
tells us that it happened, but it will never tell us why, because spans exist only where
somebody - you, or the instrumentation library - decided to put them. Adding more and more spans to
find out is not a great idea either: they are not free, they have to be created, stored and shipped, and
you would need to know upfront where to put them, which is precisely the thing you don’t know.
Filling that gap is a job for a profiler. A profiler doesn’t care about your instrumentation, it just samples what the threads are doing. The problem is the opposite one: a profile gives you a beautiful aggregation over all the requests, while the thing you are usually asked about is that one slow request from the production incident.
This is where the async-profiler’s spans come in. As we will see in the next sections, an async-profiler
span is a much simpler structure than the OpenTelemetry one. There is no spanId, no parent, no
attributes, no events. It is just a [start, end] interval on one thread, plus a single string called
a tag:
long span = Span.start();
actualWork();
Span.end(span, tag);
And that’s on purpose. The profiler doesn’t need to draw a waterfall, it needs just enough information to answer one question: which samples were gathered by this thread while that span was open?
The tag is an arbitrary label, and it is entirely up to you what you put there. It can be an endpoint
name like POST /orders, a message type, a tenant, a query ID. But it can also be the traceId we’ve
just been talking about - and then the profiler and your tracing system start speaking the same
language. Filtering the profile by a single traceId gives us a flame graph of one distributed request,
with all its threads and all its services, which is exactly the “why” that the waterfall above was
missing.
Feeding the profiler from your tracing
The idea of joining the two worlds is trivial:
Whenever a telemetry span becomes active, start a profiler span. Whenever it stops being active, end it, tagged with the tracing IDs.
No new instrumentation, no new decisions about “where to put the spans” - somebody has already made all of them for you, and did it in the places that matter. You are just piggybacking on that work.
The whole trick is in the words becomes active, so let’s look at them before writing any code.
The trap: a span’s lifetime is not a thread’s work
The naive implementation hooks the span lifecycle - start the profiler span when the telemetry span starts, end it when the telemetry span ends. It works in a simple synchronous application, and it breaks everywhere else, for one reason: a telemetry span is not bound to a thread, and a profiler span is.
Span.start() and Span.end() record the interval on whatever thread calls them. But an OpenTelemetry
span:
- can be started on one thread and ended on another - any asynchronous or reactive code does that
- can be open for
850mswhile the thread that created it went off to serve three other requests in the meantime - can be active on several threads at once, if the work was forked out
Hook the lifecycle and you get spans that begin on one thread and end on another (garbage), or spans
that cover 850ms of a thread’s time when only 50ms of it was actually spent on that request
(worse - it looks plausible).
Fortunately OpenTelemetry already has a concept that means exactly “this thread is working under this
span right now”, and it is the Scope:
try (Scope scope = span.makeCurrent()) {
// this thread, and only this thread, is now working on `span`
}
The Scope is thread-bound by contract - it must be closed on the thread that opened it - which is
precisely the contract Span.start()/Span.end() needs. So the rule is:
Don’t hook the span. Hook the scope.
And notice that this gives us the right behaviour for free: if one telemetry span is made current five times, on three threads, we get five profiler spans carrying the same tag. That is not a bug, that is the honest answer to “which stretches of thread time were spent on this request”.
Plain OpenTelemetry
Every makeCurrent() goes through ContextStorage, and OpenTelemetry lets us wrap it. That is a single
place through which every context activation in the whole application has to pass:
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanContext;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.ContextStorage;
import io.opentelemetry.context.Scope;
public final class ProfilerContextStorage implements ContextStorage {
private final ContextStorage delegate;
private ProfilerContextStorage(ContextStorage delegate) {
this.delegate = delegate;
}
public static void install() {
ContextStorage.addWrapper(ProfilerContextStorage::new);
}
@Override
public Scope attach(Context toAttach) {
Scope scope = delegate.attach(toAttach);
SpanContext spanContext = Span.fromContext(toAttach).getSpanContext();
if (!spanContext.isValid()) {
return scope;
}
String tag = spanContext.getTraceId() + '/' + spanContext.getSpanId();
long profilerSpan = one.profiler.Span.start();
return () -> {
one.profiler.Span.endIfProfiled(profilerSpan, tag);
scope.close();
};
}
@Override
public Context current() {
return delegate.current();
}
}
A few notes:
- yes, there are two classes called
Spanhere,io.opentelemetry.api.trace.Spanandone.profiler.Span. Sorry about that, one of them has to be fully qualified. ContextStorage.addWrapperhas to be called before the firstmakeCurrent()in the application. The javadoc suggests a static initializer in your main class, and it means it.- the profiler span is ended before the delegate scope is closed, so that the interval we record is a subset of the interval the context was actually active.
spanContext.isValid()skips contexts that carry no span at all - there is nothing to tag them with.
Spring Boot and Micrometer
In a Spring Boot 3 application you usually don’t touch OpenTelemetry directly, you go through
Micrometer Tracing. Its ObservationHandler has scope callbacks, so we can use the same approach:
public class ProfilerObservationHandler implements ObservationHandler<Observation.Context> {
private static final ThreadLocal<Deque<Long>> STARTS = ThreadLocal.withInitial(ArrayDeque::new);
@Override
public boolean supportsContext(Observation.Context context) {
return true;
}
@Override
public void onScopeOpened(Observation.Context context) {
STARTS.get().push(Span.start());
}
@Override
public void onScopeClosed(Observation.Context context) {
Long start = STARTS.get().poll();
if (start != null) {
Span.endIfProfiled(start, tag(context));
}
}
private static String tag(Observation.Context context) {
TracingContext tracingContext = context.get(TracingContext.class);
if (tracingContext == null || tracingContext.getSpan() == null) {
return context.getContextualName();
}
TraceContext traceContext = tracingContext.getSpan().context();
return traceContext.traceId() + '/' + traceContext.spanId() + '/' + context.getContextualName();
}
}
Scopes on a single thread nest strictly, last opened is first closed, which is why a Deque is enough
here - no need to match the start timestamp with any particular observation.
Registering it is the same as any other handler:
@Bean
ObservedAspect observedAspect(ObservationRegistry observationRegistry) {
observationRegistry.observationConfig().observationHandler(new ProfilerObservationHandler());
return new ObservedAspect(observationRegistry);
}
What to put in the tag
Now the interesting part. traceId + "/" + spanId is a good default, and here is why it works so well:
- both IDs are fixed width -
traceIdis 32 lowercase hex characters,spanIdis 16 - and hex never contains a/, so the tag is unambiguous to split and to match - the coarse key comes first, so a “starts with” filter on
4bf92f35.../gives you the whole distributed request, across every thread and every service, while a full equality match narrows it down to one single tracing span - it is a join key back to your tracing backend. You find a slow span in Jaeger or Tempo, you copy its ID, you paste it into the profiler viewer’s filter. That’s the whole workflow, and it is worth a lot when somebody is standing behind you asking why yesterday’s order took 4 seconds.
The one thing I would add on top is the operation name, as a suffix - traceId/spanId/name. Opaque
hex is fine when you arrive from Jaeger with an ID in your clipboard, but it is unreadable when you are
just browsing the Span stats of a JFR file and trying to work out which of these took 4 seconds.
Putting the name last keeps both prefix filters (by trace, and by trace + span) working exactly as
before.
Whether you can do it depends on which side you hooked:
- Micrometer hands you the name for free -
Observation.ContexthasgetName()andgetContextualName(), which is why the handler above uses it. - Plain OpenTelemetry does not.
io.opentelemetry.api.trace.SpanhasupdateName(), but there is nogetName()- reading the name back means casting to the SDK’sReadableSpan, and coupling your instrumentation to the SDK internals. Not worth it. Stay withtraceId/spanIdthere and let the tracing backend resolve names for you.
One trade-off worth knowing: if what you mostly do is aggregate profiling - “show me the flame graph
of everything that ran under POST /orders this week” - then the name is the useful prefix and the IDs
are the noise, and you may prefer name/traceId/spanId instead. You can’t have both prefixes at once;
pick the one that matches how you actually search. My experience is that people reach for spans when
investigating a single bad request, so I keep the IDs in front.
Your application is not my application
Everything I’ve written above is a default, not a rule. The tag is a free-form string, the profiler
doesn’t parse it, and nothing in async-profiler knows or cares that OpenTelemetry exists. I picked
traceId/spanId because this chapter is about joining the profiler with tracing, but that is my
use case, and yours may be a completely different one.
The question you should be asking is not “what does the standard say”, it is:
When somebody wakes me up at 3 a.m. with a production problem, what will I want to filter the profile by?
The answer to that is domain knowledge, and you have it - I don’t.
The best example is a multitenant system. Sooner or later you get a ticket saying “customer
ACME reports the system is slow”, while every dashboard you own looks perfectly fine, because
ACME is 2% of your traffic and the averages have swallowed them whole. If the customer ID is part of
your tag, that investigation is one filter away - you get a flame graph of everything your application
did for that one customer, and very often the answer is immediately visible: they are the only client
with 40 thousand records in a collection that somebody loops over, or the only one with a feature flag
that turns on an expensive code path.
Adding it is trivial. If you propagate the tenant in OpenTelemetry baggage, it is already sitting in the context you are wrapping:
String tenant = Baggage.fromContext(toAttach).getEntryValue("tenant.id");
String tag = tenant + '/' + spanContext.getTraceId() + '/' + spanContext.getSpanId();
and if you keep it in your own ThreadLocal, in an MDC, or in a security context, use that instead -
it makes no difference to the profiler.
Other things I have seen work well, depending on what the application actually does:
- batch and ETL jobs - the job name plus the chunk or partition number. “Which partition is the slow one, and why” is the whole investigation.
- message-driven systems - the message or event type, sometimes the topic and consumer group. A single poison message type can dominate a consumer without showing up anywhere else.
- reporting - the report type and its parameters. Reports are the classic case where one rarely used variant is three orders of magnitude more expensive than the rest.
- rollouts and feature flags - the variant name. Then you can put two flame graphs side by side and see what the new version actually costs, on production traffic, in the same JFR file.
- cache hit/miss, hot/cold paths - a crude one, but a profile split by cache miss is sometimes all the evidence you need.
And of course you can combine them - tenant/traceId/spanId is a perfectly reasonable tag. Just
remember the ordering rule from the previous section: whatever you put first is what you can
prefix-filter by, so put the dimension you group by in front, and the unique identifier behind it.
Two warnings before you go wild with this:
- Mind the cardinality. Every distinct tag is a separate string in the JFR file. A
traceIdis unbounded too, but it earns its place - it is a join key you will actually use. Something like a full SQL statement with literals inlined, or a user agent, gives you thousands of unique strings that you will never once filter by exactly. Put the low-cardinality thing in the tag, and let the flame graph tell you the rest. - Mind what you are writing to disk. A JFR file gets copied to a laptop, attached to a ticket, sometimes sent to a vendor. A customer ID is usually fine, a customer name, e-mail or a document number is exactly the sort of thing that shouldn’t leave production in a profiling artifact. Tag with the surrogate key, resolve it to a human-readable name later, in a system that has the right to show it to you.
Nesting, and why endIfProfiled matters
Telemetry spans nest, so profiler spans will nest too. A sample taken deep inside payment-service ends
up covered by the HTTP server span, the service span and the JDBC span all at once.
That is a feature. Filtering by the outer tag gives you that whole subtree, filtering by an inner tag narrows you down to just that part of the work - the same drill-down you have in the tracing UI, only now every level has a flame graph attached to it.
It does have a cost though. Every span is a JFR event, and every tag is a distinct string in the JFR
constant pool - and with traceId/spanId every single tag is unique, so nothing deduplicates. An
application with fine-grained instrumentation inside a loop can produce a lot of them.
This is exactly what endIfProfiled is for:
Span.endIfProfiled(profilerSpan, tag);
It records the span only if at least one profiling sample was actually taken on this thread while the span was open, and it decides that without entering native code at all. A span that encloses no sample contributes nothing to the profile - there is nothing to filter with it - so dropping it costs you no information whatsoever. With wall-clock sampling at the default interval, that quietly removes the overwhelming majority of the short nested spans, and leaves you the ones that actually have a flame graph behind them.
Use plain end() only when you genuinely want a complete list of spans, for example when you are also
using the JFR file for latency statistics rather than just for filtering samples.
A note on tracing sampling
Your tracing backend almost certainly does not store 100% of the traces - head-based sampling at 1% or
5% is the norm. Mind that an unsampled trace still gets a perfectly valid traceId, still propagates it
downstream, and will still end up in your profiler spans - it just never reaches Jaeger, so the tag will
point at a trace you cannot look up.
You can gate on it if that bothers you:
if (!spanContext.isSampled()) {
return scope;
}
I would rather not. Profiler spans are far cheaper than exported traces, and the profile stands on its own - a tag is still a perfectly good grouping key even if nobody can resolve it to a waterfall. Being able to profile 100% of the requests while tracing 1% of them is a feature, not a problem.
The demo application
Every snippet above is taken from a working application, which you can find here:
https://github.com/krzysztofslusarski/async-profiler-spans-demo
It is a Maven project with two independent examples - one on the plain OpenTelemetry SDK with the
ContextStorage wrapper, one on Spring Boot with the ObservationHandler - plus a fake workload that
does exactly what the waterfalls in this article show.
You don’t need any infrastructure to run it. There is no Jaeger, no Tempo, no collector:
- finished traces are printed to the console as an ASCII waterfall, the same one you have been reading here
- the resulting JFR file is read back with
jdk.jfr.consumer, which is a part of the JDK, and the spans are correlated with the profiling samples
./run-plain-otel.sh
./run-spring.sh
The only thing you have to provide is the async-profiler native library itself - the scripts will look
for it in a few usual places, or you can point them at it with ASPROF_LIB=/path/to/libasyncProfiler.so.
The output is worth a look even if you don’t intend to write any of this yourself, because it makes the
claims from this chapter checkable rather than merely plausible. The un-instrumented
calculateDiscounts really is a hole in the waterfall and really is the top method once you filter the
samples by tag. The root span’s tag really does show up three times, once per thread it was active on.
And a 290ms span really does cover 29 samples at a 10ms wall-clock interval.