Upgrade to Pro — share decks privately, control downloads, hide ads and more …

OBI Deep Dive — How Is Automatic Instrumentatio...

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for Mitsuhiro Tanda Mitsuhiro Tanda
August 27, 2026
6

OBI Deep Dive — How Is Automatic Instrumentation Actually Implemented?

Avatar for Mitsuhiro Tanda

Mitsuhiro Tanda

August 27, 2026

Transcript

  1. OBI Deep Dive How is automatic instrumentation actually implemented? eBPF

    Japan Meetup #7 Staff Developer Advocate, Grafana Labs Mitsuhiro Tanda
  2. About Me Mitsuhiro Tanda @mtanda Staff Developer Advocate Grafana Labs

    ▶ Grafana / Prometheus user for about 10 years ▶ Contributor since Grafana’s early versions
  3. 1. OBI Overview What Is Automatic Instrumentation? • A mechanism

    for collecting telemetry (metrics, traces, etc.) without modifying the application’s code • With manual instrumentation, you have to use the OpenTelemetry SDK to embed spans in your code • The approach to automatic instrumentation differs by language • OBI uses eBPF to automatically instrument multiple languages
  4. 1. OBI Overview What Is OBI? • Short for OpenTelemetry

    eBPF Instrumentation1 • Supported protocols ‣ HTTP, MySQL, PostgreSQL, Redis, Kafka, HTTP/2, gRPC, etc • Supported languages ‣ Go, Python, Ruby, Node.js, Java, .NET, Rust, etc • Internally there are two kinds of tracers ‣ gotracer — Go-only. Embeds uprobes directly into Go programs (this is what we’ll cover today) ‣ generictracer — A generic tracer. Parses network protocols directly 1 github.com/open-telemetry/opentelemetry-ebpf-instrumentation(Apache License 2.0)
  5. 1. OBI Overview What Is Tracing? When a single request

    is processed across multiple microservices, each individual unit of work inside it is recorded as a span, and the full set of spans is managed together as a trace. This is useful for things like pinpointing bottlenecks across the whole request. Two spans sharing the same parent_id (s2) Payment Service trace_id: abc123 span_id: s3 parent_id: s2 Gateway trace_id: abc123 span_id: s1 parent_id: (none) call Order Service trace_id: abc123 span_id: s2 parent_id: s1 call call Inventory Service trace_id: abc123 span_id: s4 parent_id: s2
  6. 2. Automatic Instrumentation What Manual Instrumentation Does • With manual

    instrumentation, you embed span start/end around the code being instrumented • Nesting spans expresses the dependency relationships between operations • In Go, this state is propagated by passing a context that carries the trace state as an argument
  7. 2. Automatic Instrumentation What Manual Instrumentation Looks Like ctx, span

    := tracer.Start(ctx, "GET /users") // start parent span ctx, child := tracer.Start(ctx, "SELECT users.name") // start child span child.SetAttributes( attribute.String("db.query.text", "SELECT name FROM users")) // ... do some work ... child.End() // end child span span.End() // end parent span
  8. 2. Automatic Instrumentation Example Instrumentation Output Spans are emitted and

    stored individually. At query time, the parent/child relationships are used to reconstruct the trace. { "Name": "SELECT users.name", "SpanContext": { "TraceID": "3fa0f027c5eac4614599f227f8fc877e", "SpanID": "a84667204884ec1f" }, "Parent": { "TraceID": "3fa0f027c5eac4614599f227f8fc877e", "SpanID": "2246972e944f7b38" }, "Attributes": [ {"Key": "db.query.text", "Value": {"Type": "STRING", "Value": "SELECT name FROM users"}} ] }
  9. 2. Automatic Instrumentation Recording Spans via Automatic Instrumentation Span recording

    is achieved by observing the start and end of processing from outside, using uprobes. Application HTTP SQL userland kernel Exporter uprobe ring buffer OBI (uprobe) SQL span HTTP span Tempo, etc.
  10. 2. Automatic Instrumentation Associating Spans with Goroutines With manual instrumentation,

    the parent/child relationship is conveyed by explicitly passing the context. OBI doesn’t modify code, so it has no way to pass anything through that channel. Instead, it records the currently active span on each goroutine, so the parent/child relationship can still be traced. span A (parent) span B (child) associated with the same goroutine goroutine
  11. 2. Automatic Instrumentation Associating Context with External State OBI also

    sets uprobes on the Go runtime itself, associating context with external state so the trace state can be tracked. ① A new goroutine spawns ② Handed off via a channel G (parent) G’ (sender) G’ (child) runtime.newproc1 W (receiver) chansend1 / chanrecv1 ③ The thread changes G (Thread M1) G (Thread M2) runtime.casgstatus
  12. 3. Embedding uprobes What OBI Sets uprobes On OBI keeps

    a list of functions to set uprobes on. Keys are Go symbol names, and probes can be set separately for function entry and return. // excerpted and simplified from gotracer.go (Start/End are actually references to eBPF programs) func (p *Tracer) GoProbes() map[string][]*ebpfcommon.ProbeDesc { m := map[string][]*ebpfcommon.ProbeDesc{ "runtime.newproc1": {{ Start: ..., End: ... }}, "net/http.serverHandler.ServeHTTP": {{ Start: ..., End: ... }}, "net/http.(*conn).readRequest": {{ Start: ..., End: ... }}, // ... } }
  13. 3. Embedding uprobes How Do We Find the Addresses to

    Set uprobes On? • Uses Go’s Program Counter Line Table (pclntab) • pclntab is metadata embedded in the Go binary • It records things like the mapping between symbol names and addresses • Used for things like generating stack traces • Because the runtime itself depends on it, it can be relied on fairly reliably • (Doesn’t depend on DWARF)
  14. 3. Embedding uprobes Reading pclntab in Practice You can verify

    this with code like the following, using debug/elf and debug/gosym. package main import ( "debug/elf" "debug/gosym" "fmt" "os" ) func main() { f, _ := elf.Open(os.Args[1]) pclndat, _ := f.Section(".gopclntab").Data() var symdat []byte if sec := f.Section(".gosymtab"); sec != nil { symdat, _ = sec.Data() } tab, _ := gosym.NewTable(symdat, gosym.NewLineTable(pclndat, f.Section(".text").Addr)) for _, fn := range tab.Funcs { fmt.Printf("%x %s\n", fn.Entry, fn.Name) // <address> <function name> } }
  15. 3. Embedding uprobes What’s Actually Inside pclntab This lets us

    obtain the addresses of the symbols OBI sets uprobes on. $ go run main.go target_binary | grep -E \ 'chansend1|chanrecv1|casgstatus|newproc1|execDC|queryDC|readRequest|ServeHTTP' 224f0 runtime.chansend1 233d0 runtime.chanrecv1 5afc0 runtime.casgstatus 63160 runtime.newproc1 f0930 database/sql.(*DB).execDC f1230 database/sql.(*DB).queryDC 201d30 net/http.(*conn).readRequest 20f370 net/http.serverHandler.ServeHTTP
  16. 4. Generating HTTP/SQL Spans Generating Spans for HTTP Requests 1.

    Sets uprobes on net/http functions 2. If the request has a traceparent header set, the trace ID is carried forward 3. There are several other paths as well (details omitted) 4. If none of these apply, a new trace ID is generated // excerpted from go_nethttp.c SEC("uprobe/ServeHTTP") int obi_uprobe_ServeHTTP(struct pt_regs *ctx) { void *req = GO_PARAM4(ctx); // check the header info already read by readContinuedLineSlice server_http_func_invocation_t *header_inv = bpf_map_lookup_elem(&ongoing_http_server_requests, &g_key); tp_info_t *decoded_tp = 0; if (header_inv && valid_trace(header_inv->tp.trace_id)) { decoded_tp = &header_inv->tp; } // use decoded_tp if present, otherwise fall back to other paths if (req) { server_trace_parent(goroutine_addr, &invocation.tp, decoded_tp); }
  17. 4. Generating HTTP/SQL Spans Generating Spans for SQL Requests 1.

    Sets uprobes on database/sql functions 2. The SQL string can be read directly (with generictracer, you’d need to parse it per protocol) // excerpted from go_sql.c SEC("uprobe/queryDC") int obi_uprobe_queryDC(struct pt_regs *ctx) { void *goroutine_addr = GOROUTINE_PTR(ctx); void *driver_conn = GO_PARAM6(ctx); void *sql_param = GO_PARAM8(ctx); // pointer to the SQL string void *query_len = GO_PARAM9(ctx); // its length set_sql_info(goroutine_addr, driver_conn, sql_param, query_len); return 0; }
  18. 5. Propagating Context Recording the Actual Context in go_trace_map The

    most recently started span’s info is recorded in go_trace_map, keyed by the goroutine’s address. This makes it possible to find the parent span. go_trace_map stands in for the information that manual instrumentation would propagate via context. // excerpted from go_common.h struct { __type(key, go_addr_key_t); // key: the goroutine's address __type(value, tp_info_t); // value: trace_id/span_id/parent_id/... __uint(pinning, OBI_PIN_INTERNAL); // a single map shared across all uprobes } go_trace_map SEC(".maps"); // go_trace_map gets updated from places like ServeHTTP bpf_map_update_elem(&go_trace_map, &g_key, tp, BPF_ANY); span A (parent) ① write go_trace_map ② look up and link span B (child)
  19. 5. Propagating Context ① Tracing Context from Child Goroutines Since

    go_trace_map is keyed by goroutine ID, it alone can’t locate the parent span. So OBI also records the parent/child relationship between goroutines, making the context traceable. // excerpted from go_runtime.c (call and return uprobes bridge the info across) SEC("uprobe/runtime_newproc1") int obi_uprobe_runtime_newproc1(struct pt_regs *ctx) { void *creator_goroutine_addr = GOROUTINE_PTR(ctx); // call: the caller is the parent new_func_invocation_t invocation = {.parent = (u64)GO_PARAM2(ctx)}; go_addr_key_t g_key = {}; go_addr_key_from_id(&g_key, creator_goroutine_addr); bpf_map_update_elem(&newproc1, &g_key, &invocation, BPF_ANY); // stashed until return return 0; } SEC("uprobe/runtime_newproc1_return") int obi_uprobe_runtime_newproc1_return(struct pt_regs *ctx) { // return: retrieve the parent info stashed by the preceding call, keyed by the caller's address new_func_invocation_t *invocation = bpf_map_lookup_elem(&newproc1, &c_key); void *parent_goroutine = (void *)invocation->parent; void *goroutine_addr = (void *)GO_PARAM1(ctx); // newproc1's return value = the new goroutine goroutine_metadata metadata = {.parent = p_key}; bpf_map_update_elem(&ongoing_goroutines, &g_key, &metadata, BPF_ANY); // parent/child relationship finalized bpf_map_delete_elem(&newproc1, &c_key); // clean up the temporary map return 0; }
  20. 5. Propagating Context Finding the Parent Span When generating spans

    for things like SQL/Redis, client_trace_parent() walks the parent/child chain via find_parent_goroutine() and finds the parent span in the parent goroutine’s go_trace_map. // excerpted from go_common.h static u64 find_parent_goroutine(go_addr_key_t *current) { u64 r_addr = current->addr; go_addr_key_t *parent = current; int attempts = 0; do { // check whether the current goroutine is already tracing tp_info_t *p_inv = bpf_map_lookup_elem(&go_trace_map, parent); if (p_inv) return r_addr; // found -> carry this trace_id forward // otherwise, walk the parent/child chain up one more level goroutine_metadata *g = bpf_map_lookup_elem(&ongoing_goroutines, parent); if (!g) break; // can't go any further r_addr = g->parent.addr; parent = &g->parent; } while (++attempts < 6); // walk up at most 6 levels return 0; // if nothing is found, this becomes the root of a new trace }
  21. 5. Propagating Context ② For Channels, Associate via Span Links

    • With channels, there isn’t necessarily a clear parent/child relationship between sender and receiver • Because we’re observing this via eBPF, a technical limitation keeps this parent/ child relationship from being pinned down • Sets uprobes on runtime.chansend1 / chanrecv1 / chanrecv2, and records only the relationship between spans, via span links • Code excerpt omitted
  22. 5. Propagating Context ③ Reflecting Execution-Thread Switches When a goroutine

    moves to a different OS thread while running, OBI reconstructs the correct information from what it holds internally. // excerpted from go_runtime.c SEC("uprobe/runtime.casgstatus") int obi_uprobe_runtime_casgstatus(struct pt_regs *ctx) { void *g = (void *)GO_PARAM1(ctx); // the goroutine whose state is changing // identify the current OS thread from g->m->procid and build g_pid_tgid u64 g_pid_tgid = ((u64)pid << 32) | (procid & 0xffffffff); go_addr_key_t g_key = {.addr = (u64)g, .pid = pid}; const u32 newval = (u32)(uintptr_t)GO_PARAM3(ctx); // the state being transitioned to switch (newval) { case g_running: case g_syscall: // check the corresponding maps in turn (http/sql/redis/...) http_server_inv = bpf_map_lookup_elem(&ongoing_http_server_requests, &g_key); if (http_server_inv) { obi_ctx__set_(g_pid_tgid, &http_server_inv->tp, &obi_info); // update traces_ctx_v1 return 0; } // ...sql/redis/... continue in the same shape break; default: obi_ctx__del(g_pid_tgid); // otherwise, remove it from traces_ctx_v1 } return 0; }
  23. 5. Propagating Context Propagating trace_id/span_id to External Calls When the

    instrumented service itself calls an external service over HTTP, the trace breaks at the far end unless the traceparent request header carries the trace_id/ span_id forward. Since only the eBPF side knows the trace_id/span_id, OBI intercepts the outgoing request and writes them in directly. 1. gotracer-specific approach: while the goroutine is trapped in the header_writeSubset uprobe, write directly into the bufio.Writer via bpf_probe_write_user 2. tpinjector fallback: rewrite the packet bytes themselves via the kernel’s sk_msg hook
  24. 6. From OBI to OTLP Sending Spans via a Ring

    Buffer Once a span is finalized, it’s written out to the ring buffer // excerpted from go_sql.c (inside process_sql_return) sql_request_trace_t *trace = bpf_ringbuf_reserve(&events, sizeof(sql_request_trace_t), 0); if (trace) { trace->tp = invocation->tp; // trace_id/span_id bpf_probe_read(trace->sql, query_len, (void *)invocation->sql_param); bpf_ringbuf_submit(trace, get_flags()); // the write is finalized here }
  25. 6. From OBI to OTLP What Happens Past the Ring

    Buffer eBPF (kernel side) writes into the ring buffer, and from there the OBI process (user space) reads it out and delivers it to the external backend. OBI process (user space) ring buffer read batch OTLP export OTLP Tempo, etc.
  26. 7. Manual and Automatic Combined Combining Automatic and Manual •

    The OpenTelemetry Go SDK includes an Auto SDK, designed from the ground up to work together with eBPF-based automatic instrumentation • With OpenTelemetry Go SDK v1.36.0 or later, you can just use it without any special setup • OBI checks whether the SDK is registered, and switches between prioritizing automatic or manual instrumentation accordingly
  27. 7. Manual and Automatic Combined On Span Start: Stash the

    Parent In the uprobe on Tracer.Start’s return, OBI uses find_parent_goroutine to obtain the parent’s context, stashes it in span->prev_tp, and then overwrites go_trace_map with the child’s context. // excerpted from go_sdk.c obi_uprobe_tracer_Start_Returns() tp_info_t *tp = tp_info_from_parent_go(&g_key, &span->parent_go); // via find_parent_goroutine if (tp) { __builtin_memcpy(&span->prev_tp, tp, sizeof(tp_info_t)); // stash the parent's context tp_from_parent(&span->tp, tp); // the child's trace_id is inherited from the parent urand_bytes(span->tp.span_id, SPAN_ID_SIZE_BYTES); // a new span_id is generated if (span->parent_go) { go_addr_key_t gp_key = {}; go_addr_key_from_id(&gp_key, (void *)span->parent_go); update_tp_parent_go(&gp_key, &span->tp); // go_trace_map[parent goroutine] = child context (overwritten) go_addr_key_from_id(&gp_key, span_ptr); bpf_map_update_elem(&active_spans, &gp_key, span, BPF_ANY); // the span's own state is stored here } }
  28. 7. Manual and Automatic Combined On Span End: Restore the

    Stashed Value OBI sets a uprobe on the function corresponding to Span.End, and overwrites go_trace_map again with the span->prev_tp stashed at start. // excerpted from go_sdk.c obi_uprobe_nonRecordingSpan_End() otel_span_t *span = bpf_map_lookup_elem(&active_spans, &s_key); if (span == NULL) { return 0; } span->end_time = bpf_ktime_get_ns(); if (span->parent_go) { go_addr_key_t gp_key = {}; go_addr_key_from_id(&gp_key, (void *)span->parent_go); update_tp_parent_go(&gp_key, &span->prev_tp); // restore go_trace_map to the parent's context (pop) } bpf_ringbuf_output(&events, span, sizeof(otel_span_t), get_flags()); // emit the span bpf_map_delete_elem(&active_spans, &s_key);
  29. 8. Conclusion Summary • OBI achieves automatic instrumentation by combining

    a variety of techniques • It can already reproduce much of what manual instrumentation achieves • With OBI, you can start adopting OpenTelemetry right away, without changing any code