From raw data to flame graphs: A deep dive into how the OpenTelemetry eBPF profiler symbolizes Go
Go binaries carry a secret weapon for production profiling that most engineers don't fully appreciate: the .gopclntab section. While C, C++, and Rust programs become nearly opaque when stripped of debug symbols, Go programs retain full symbolization capabilities. Understanding why this matters—and how the symbolization pipeline actually works—is critical when you're staring at hex addresses in production instead of function names.
The eBPF profiler constraint that shapes everything: these profilers run in kernel space, which means they can sample any process without instrumentation overhead, but they only see raw memory addresses. No runtime introspection, no function calls into your application, no access to language-specific metadata. When the profiler captures a stack trace, it gets something like [0x00f0318, 0x00f0478, 0x0050c08]. The transformation from these addresses to "main.computeResult" happens entirely outside the process by parsing binary files on disk.
Most native languages store function name mappings in separate debug symbol files (DWARF sections in ELF binaries). When you strip a binary with `strip myapp`, these sections disappear, reducing binary size by 30-60% but destroying symbolization. The standard workaround is server-side symbolization: ship debug symbols separately, upload them to your profiling backend, and reconstruct names there. This adds latency, requires symbol management infrastructure, and breaks when you don't have the exact binary version.
Go sidesteps this entirely through .gopclntab (Go Program Counter Line Table), a compact data structure embedded in every Go binary that survives stripping. It maps every function's address range to its name and source location. A stripped 2.6MB Go binary might have 200KB of gopclntab data—small enough to keep in production binaries, large enough to symbolize everything. This is why `go build` produces binaries that profile correctly out of the box, even when deployed to locked-down production environments where you can't easily retrieve debug symbols.
The symbolization pipeline has three performance-critical steps. First, the profiler extracts the gopclntab section from the binary using ELF parsing—this happens once per binary. Second, for each sampled address, it performs a binary search through the function table. A typical Go service might have 5,000-15,000 functions; binary search finds the right one in 13-14 comparisons. Third, it caches results aggressively because stack traces repeat: if you've seen [0xf0318, 0xf0478] once, you'll likely see it thousands of times in the next second.
The binary search optimization matters more than you'd think. Gopclntab stores functions sorted by start address. Given address 0xf0318, the profiler searches for the largest function start address that's still less than or equal to 0xf0318. In our example, it finds 0xf0310 (main.processRequest starts here) and 0xf0370 (main.fetchData starts next), confirming 0xf0318 falls inside processRequest. With 100 samples/second across 50 processes, that's 5,000 lookups/second—linear search would destroy CPU overhead guarantees.
Frame caching typically achieves 95%+ hit rates in production. The profiler maintains a map from address to resolved function name. Hot paths get sampled repeatedly, so after the first expensive lookup, subsequent samples hit the cache. This is why eBPF profilers can maintain sub-1% CPU overhead even while symbolizing thousands of addresses per second.
When symbolization fails, you see hex addresses in your flame graphs. Common causes: the binary on disk doesn't match what's running (deployment race condition), the profiler can't read the binary (permissions issue), or you're profiling a language without embedded symbols and haven't configured server-side symbolization. For Go specifically, if you see hex addresses, check that your binary wasn't built with `-ldflags="-s -w"`, which strips gopclntab itself—a rare but devastating mistake.
The practical implication: Go programs are uniquely suited for eBPF profiling in production. You can deploy stripped binaries, profile them immediately without symbol uploads, and get full function names in your flame graphs. Other languages require either unstripped binaries (larger containers, slower deployments) or symbol management infrastructure (complexity, latency, version matching problems). This isn't just convenience—it's the difference between profiling being a routine debugging tool and a deployment-blocking compliance issue.