Capstone: tying the three signals together
A short capstone for the applications part. We can't symbol-probe Java or Python the way we did nginx — so we instrument at the kernel socket layer, the way the OpenTelemetry eBPF Instrumentation (OBI) project does, and turn one request into three correlated signals: a span, a log, and a RED metric sharing a trace_id. Then we watch the click-through correlation in Grafana and place it against the production tool.
This part has aimed eBPF at one real server at a time. This capstone steps back and asks the question the whole observability stack was built for: can we make eBPF produce the three signals — metrics, logs, and traces — correlated, so that a latency spike on a graph is one click from the trace that caused it and the logs that explain it? And can we do it for runtimes we can’t symbol-probe — Java and Python — the way the real world does?
The answer is yes, and the route to it is the central lesson of this part. In
Chapter 45 we attached uprobes to nginx’s own functions because nginx is a
native binary with symbols. Java is JIT-compiled and Python is interpreted
— there is no stable handle_request symbol to hook in either. So we drop to
the one layer every networked service shares no matter the language: the
kernel socket layer. That is exactly the insight behind the OpenTelemetry
eBPF Instrumentation project (OBI, the donated Grafana Beyla), and this
chapter builds a teaching-grade version of it, then points you at the real one.
The code is in examples/46-three-signals/. ./demo.sh there runs a Java and
a Python service, drives load, and attaches the probe; its README.md has the
details.
Why the socket layer
A uprobe needs a symbol or an offset in a binary. That works for C servers and
even for Rust and Go (Chapters 14–15), but a JVM compiles methods to machine
code at runtime at addresses that don’t exist on disk, and CPython runs your
handler as bytecode inside the interpreter loop — neither presents a stable
“handle one request” function to attach to. What they do share is that every
request arrives and departs as bytes through a socket: recv then send on
the kernel’s TCP path. Hook there and you get a language-agnostic view of
“a request was handled, and it took this long” without one line of app change.
That is precisely how OBI achieves Java/.NET/Go/Python/Ruby/Node/C/C++/Rust
coverage from a single agent.
How the code works
The kernel side hooks two TCP functions and pairs them per socket:
#[map] static STARTS: HashMap<u64, u64> = HashMap::with_max_entries(10240, 0); // sock ptr -> recv ts
#[map] static EVENTS: RingBuf = RingBuf::with_byte_size(1 << 16, 0);
#[kprobe] // tcp_recvmsg(struct sock *sk, ...)
pub fn on_recv(ctx: ProbeContext) -> u32 {
let sk: u64 = ctx.arg(0).unwrap_or(0);
if sk != 0 { let _ = STARTS.insert(&sk, &unsafe { bpf_ktime_get_ns() }, 0); }
0
}
#[kprobe] // tcp_sendmsg(struct sock *sk, ...)
pub fn on_send(ctx: ProbeContext) -> u32 {
let sk: u64 = ctx.arg(0).unwrap_or(0);
if let Some(&start) = unsafe { STARTS.get(&sk) } {
let now = unsafe { bpf_ktime_get_ns() };
let _ = STARTS.remove(&sk);
if let Some(mut e) = EVENTS.reserve::<Req>(0) {
let comm = bpf_get_current_comm().unwrap_or_default();
unsafe { (*e.as_mut_ptr()) = Req { dur_ns: now - start, comm }; }
e.submit(0);
}
}
0
}
The key choices:
- We key the request on the
struct sock *pointer (read as a plainu64, never dereferenced — like the request pointer in Chapter 45). Arecvstamps the start; the followingsendon the same socket closes it and emits the duration. bpf_get_current_comm()tags each event with the process name —java,python3.14— so user space can attribute it to the right service without reading any kernel struct. That’s our stand-in for OBI’s far richer service discovery.- This is deliberately a simplification: one
recv/sendpair is a decent proxy for a request on a simple keep-alive-light workload, but it doesn’t parse HTTP, doesn’t handle pipelining, and times service work rather than the full request. OBI parses the L7 protocol to get this right; we’re after the shape, not production fidelity.
The part that matters: three signals, one trace_id
The user side is where the capstone earns its name. For each event it mints one trace_id and emits all three signals against it:
let trace_id = TraceId::from_bytes(rand::random());
// 1. a span (-> Tempo), timed to the measured interval
let span = tracer.span_builder("http.server.request")
.with_trace_id(trace_id)
.with_start_time(now - Duration::from_nanos(e.dur_ns))
.with_end_time(now)
.with_attributes([KeyValue::new("service.name", svc_from(&e.comm))])
.start(&tracer);
span.end();
// 2. a log (-> Loki), carrying the same trace_id for correlation
logger.emit(LogRecord::builder()
.with_trace_context(trace_id, span_id)
.with_body(format!("handled request in {} µs", e.dur_ns / 1000))
.build());
// 3. RED metrics (-> Prometheus): a request count and a duration histogram
requests.add(1, &[KeyValue::new("service", svc)]);
duration_ms.record(e.dur_ns as f64 / 1e6, &[KeyValue::new("service", svc)]); // exemplar carries trace_id
The trace_id is the thread that stitches the signals together. Tempo stores
the span under it; the log carries it (Loki’s derivedFields turns it into a
link); and where the metrics SDK supports exemplars, the histogram sample
carries it too, so a point on the latency graph links straight to its trace.
That wiring — trace→logs, trace→metrics, exemplars — is exactly what the
observability stack’s datasources were provisioned for back in Chapter 3;
this is the chapter that finally exercises all of it.
Up-front note on the seams. Minting the
trace_idin the loader gives us intra-service correlation (this request’s span, log, and metric line up), which is the teachable goal. It is not distributed tracing: we don’t read the caller’s W3Ctraceparent, so kernel spans aren’t children of an upstream request’s trace. And exemplar support in the Rust metrics SDK has lagged the spec, so that one hop (metric→trace) is the most likely to be missing on your run. Both are exactly the problems OBI solves properly.
Build, deploy, observe
cd examples/46-three-signals && ./demo.sh
The demo builds and runs two containers — a Java HTTP service and a
Python/FastAPI service — drives a mix of requests at both, and attaches
the socket probe. In the terminal you’ll see a line per completed request
(java 1840µs, python3.14 920µs).
In Grafana (127.0.0.1:3000), this is the click-through to try:
- Explore the metric —
histogram_quantile(0.95, sum by (le,service) (rate(ebpf_http_server_duration_ms_bucket[1m])))— and watch p95 per service. - Open Tempo and find the
http.server.requestspans; each carries aservice.name. - Open Loki with
{service_name=~".*three-signals.*"}and confirm each log line carries atrace_idthat resolves to a span.
That metric → trace → logs path, on a Java and a Python service you never modified, is the whole point of the OpenTelemetry-plus-eBPF story.
The production tool: OBI
What we hand-rolled, the OpenTelemetry eBPF Instrumentation project does for real and at scale. OBI auto-instruments HTTP/S and gRPC services with eBPF — no code changes, no libraries, no restarts — emitting RED metrics and distributed trace spans, enriching logs with trace context, and even seeing inside TLS without decryption. It does real L7 protocol parsing, propagates W3C context across services for true distributed traces, ships database instrumentation (PostgreSQL, MySQL, Redis, and more), and runs either standalone or as an OpenTelemetry Collector receiver.
Read it as the same picture as Figure 46.1, industrialized: the socket and
TLS probes are the recv/send hook done thoroughly; the agent is our loader
done with real protocol parsing and semantic conventions; the Collector and
backends are the stack you already run. If you take one thing from this
capstone, it’s that “observe a service you didn’t write, in any language, with
correlated signals” is not aspirational — it’s a deployed OTel project, and
you now understand exactly how it works under the hood. See the OpenTelemetry
OBI documentation to run it for
real.
Cross-check
[vm]$ curl -s -o /dev/null -w '%{time_total}\n' http://127.0.0.1:8081/ # Java, client-side
[vm]$ curl -s -o /dev/null -w '%{time_total}\n' http://127.0.0.1:8082/ # Python, client-side
[vm]$ sudo bpftool prog show | grep -E 'kprobe' # the two attached kprobes
The client-side time_total should bracket the per-request durations the
probe reports for each service; the span durations in Tempo should match. When
all three agree, your hand-built three-signal pipeline is faithful.
What you learned
- You can’t symbol-probe Java (JIT) or Python (interpreted), so language-agnostic instrumentation hooks the kernel socket layer — the insight behind OBI.
- A measured interval becomes a span, a per-event record becomes a log,
and counts become RED metrics; minting one
trace_idacross all three is what makes them correlate into the metric→trace→logs click-through. - This exercises the trace/log/metric correlation the stack was provisioned for in Chapter 3 — and OBI is the production tool that does it properly, with L7 parsing, W3C propagation, and TLS visibility.
Next, Chapter 47 returns to a single hard target — a postgres database — to observe queries and lock waits.
Verification status: verified — Fedora 44, kernel 7.1.3. Built and run on the lab VM (Fedora 44, kernel 7.1.3-200.fc44): builds, loads, and attaches cleanly and runs without error. Confirmed on this kernel — attach targets and struct offsets can be version-specific.