# Why Go cannot be monkey-patched: no bytecode, no classloader, no runtime hook > [!summary] TL;DR > Go compiles to a self-contained native binary: no bytecode to rewrite at load time, no classloader moment to intercept, no general runtime hook API. There is no `-javaagent` slot. Zero-touch Go instrumentation therefore happens at build time (`-toolexec`), at process start (`LD_PRELOAD`, with limits) or in the kernel (eBPF). ## Key points - Java gets tracing with one extra flag and no source change, because the JVM offers each class's bytes to the agent's `ClassFileTransformer` before it defines the class. Python swaps a method at runtime without even a flag. Go has no such slot. - `go build` gives a self-contained binary of native machine code: no bytecode, no classloader, nothing between your code and the CPU. - `LD_PRELOAD` needs the dynamic linker, and Go links pure-Go programs statically by default, so the loader never runs. - Go has no general runtime hook API; the internal `exithook` covers only program termination. - `go:linkname` reaches unexported runtime symbols, but the runtime calls its users a "hall of shame", its comments say "Do not remove or change the type signature", and [golang/go#67401](https://github.com/golang/go/issues/67401) locked down future uses. It is an accommodation, not a hook. - Three places to intervene instead: build time (a `-toolexec` wrapper in front of every `go tool compile` call), process start (an injector library mapped in before `main`), the kernel (eBPF probes on function entry and exit points). - A pure-Go build is a static ELF with no interpreter; `-linkmode=external` needs cgo, and [golang/go#28909](https://github.com/golang/go/issues/28909) stays open as of October 2026. - Check with `file ./myapp`: on Go 1.27.1 (linux/arm64), `CGO_ENABLED=0` printed `statically linked`, `CGO_ENABLED=1` printed `dynamically linked, interpreter /lib/ld-linux-aarch64.so.1`. Even the dynamic one offers no slot: `readelf --dyn-syms` shows no `net/http` function to wrap. - Datadog has a Go injector hook in development; it is not released. - Go has no goroutine-local storage. dd-trace-go v2.0.0 injects a synthetic `__dd_gls_v2 any` field into `runtime.g` at build time via Orchestrion; otelc adds `otel_trace_context` and `otel_baggage_container` fields and copies them in `newproc1` when a goroutine starts. - The series tools: OBI (the kernel path, no rebuild), otelc (the `-toolexec` path, Go 1.25 or newer), Orchestrion (the same mechanism plus the goroutine hack), the OpenTelemetry eBPF profiler (the kernel path for profiles). - Disclosure: the author works at Datadog, which maintains Orchestrion and dd-trace-go, and is one of otelc's maintainers, so read the comparisons with that in mind. - Versions, links and commands checked on 2 October 2026. ## Why Go has no agent slot Java starts a service with one extra flag and it gets traced, with no source change and no rebuild: ```sh java -javaagent:agent.jar -jar app.jar ``` The JVM offers each class's bytes to the agent's [`ClassFileTransformer`](https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/ClassFileTransformer.html) before it defines the class, so the agent can rewrite a method on its way in. Python lets you swap a method at runtime, which is the monkey-patching of the title. Go has no such slot, and that follows from how Go was designed. Go compiles to native machine code, and `go build` gives you a self-contained binary: no bytecode, no classloader, nothing between your code and the CPU. With no bytecode there is nothing to rewrite at load time, and with no classloader there is no moment between "file on disk" and "code executing" to grab. `LD_PRELOAD`, the classic way to push a shared library into any process, needs the dynamic linker, and Go links pure-Go programs statically by default. And Go has no general runtime hook API; the internal `exithook` covers only program termination. ## go:linkname is an accommodation, not a hook `go:linkname` lets a package reach unexported runtime symbols, and tracers use it. Listen to how the runtime talks about it. Above `gopark` in [`proc.go`](https://github.com/golang/go/blob/go1.26.0/src/runtime/proc.go#L435-L442): > "gopark should be an internal detail, but widely used packages access it using linkname. Notable members of the hall of shame include…" The Go team added those annotations reluctantly. The comment says "Do not remove or change the type signature" and points at [golang/go#67401](https://github.com/golang/go/issues/67401), which locked down future linkname uses. A hall of shame is an odd place to look for a feature. ## Three places to intervene: build time, process start, the kernel - Build time: intercept the build instead of the program. Go's `-toolexec` flag puts a wrapper in front of every `go tool compile` call. The wrapper parses each source file into a syntax tree, injects instrumentation, and hands the modified source to the real compiler, which thinks you wrote it yourself. The wrapper adds no runtime overhead and needs no kernel privilege, but you have to rebuild. A [guest post](https://internals-for-interns.com/posts/hooking-into-the-go-toolchain/) builds this mechanism up from a stopwatch. - Process start: load a library before `main`, with no rebuild. That is the closest cousin of `-javaagent`, and for Go the hardest. - Kernel: Linux's eBPF subsystem attaches probes to function entry and exit points in a running binary without modifying it. A privileged agent on the node attaches and the Go binary never knows. The price is visibility: you see function boundaries (arguments, return values, call counts), and each tool brings its own kernel and privilege requirements. Who stands on those three spots? Four tools. OBI (OpenTelemetry eBPF Instrumentation, which started life as Grafana Beyla) is the kernel path with no rebuild. otelc (OpenTelemetry Go compile-time instrumentation) takes the `-toolexec` path and needs Go 1.25 or newer. Orchestrion uses the same mechanism with the goroutine hack below. The OpenTelemetry eBPF profiler is the kernel path for profiles. None of them is clean; they are workarounds for a runtime built for simplicity, not observability. ## Process start: why LD_PRELOAD fails on Go binaries An injector is a shared library that the dynamic loader maps in before `main`, so it acts before any of your code does. On a host, Datadog's [Single Step Instrumentation](https://docs.datadoghq.com/tracing/trace_collection/single-step-apm/linux/) arms itself through `/etc/ld.so.preload`, and so does the [OpenTelemetry injector](https://github.com/open-telemetry/opentelemetry-injector/blob/v0.10.1/README.md). The OTel one is a smaller trick than it sounds: it sets variables like `JAVA_TOOL_OPTIONS` and `NODE_OPTIONS`, and the runtime loads its own agent. It covers Java, Node.js, .NET, Ruby, and Python (disabled by default), and its [design notes](https://github.com/open-telemetry/opentelemetry-injector/blob/v0.10.1/DESIGN.md) say statically linked executables "will not be affected by `LD_PRELOAD` at all." Go has no agent slot for such a variable to point at. Why can't Go take `LD_PRELOAD`? A pure-Go build is a static ELF with no interpreter (the dynamic loader a binary names in its headers), so the loader never runs. With cgo disabled there is no escape hatch, because `-linkmode=external`, which hands the final link to the system C linker, needs cgo. (cgo binaries, including those using the default `net` resolver on Linux, are dynamically linked and take `LD_PRELOAD` on glibc.) [golang/go#28909](https://github.com/golang/go/issues/28909) is still open as of October 2026, and Ian Lance Taylor was ["perfectly comfortable in saying that anybody who wants to use `LD_PRELOAD` must force the use of external linking."](https://github.com/golang/go/issues/28909#issuecomment-441823877) External linking is supported, but forcing it just for `LD_PRELOAD` is a workaround with sharp edges. To check yours, run `file ./myapp`. For a small `net/http` program on Go 1.27.1 (linux/arm64), `CGO_ENABLED=0` printed `statically linked` and `CGO_ENABLED=1` printed `dynamically linked, interpreter /lib/ld-linux-aarch64.so.1`. Even the dynamic one offers no slot: a preloaded library interposes on dynamic symbols such as libc's `read`, and `readelf --dyn-syms` shows no `net/http` function to wrap. Datadog has a Go injector hook in development, which is not released yet. ## Goroutine-local storage: how the build-time tools fake it Distributed tracing is where the missing slot bites. A request often hops goroutines (Go's lightweight threads), and its trace context has to hop with it. Go has no goroutine-local storage, so after `go func() { ... }()` there is no built-in way to inherit arbitrary values from the parent (pprof labels aside, which are string pairs for profiles). The usual answer is to pass a `context.Context` down by hand, which is the code change this series tries to avoid. Code injected into a library function often has no `ctx` in scope anyway, so the tracer needs somewhere else to keep the context. Orchestrion, Datadog's `-toolexec` tool, answers with something striking: give the tracer somewhere to keep it. dd-trace-go [v2.0.0 ships an aspect](https://github.com/DataDog/dd-trace-go/blob/v2.0.0/internal/orchestrion/gls.orchestrion.yml) that injects a synthetic field into the runtime's internal `g` struct, the one that represents each goroutine. The start of it, abridged: ```yaml aspects: - id: __dd_gls_v2 join-point: struct-definition: runtime.g advice: - add-struct-field: name: __dd_gls_v2 type: any ``` The join point says where to cut and the advice says what to add: a field `__dd_gls_v2 any` on `runtime.g`. Orchestrion rewrites the `runtime` source before the compiler sees it, so every goroutine gets one. The same aspect injects a getter and a setter as `go:linkname`d function variables. Here is that template, re-indented the way gofmt would print it: ```go //go:linkname __dd_orchestrion_gls_get __dd_orchestrion_gls_get.V2 var __dd_orchestrion_gls_get = func() any { return getg().m.curg.__dd_gls_v2 } //go:linkname __dd_orchestrion_gls_set __dd_orchestrion_gls_set.V2 var __dd_orchestrion_gls_set = func(val any) { getg().m.curg.__dd_gls_v2 = val } ``` `getg()` asks the runtime for the current goroutine, and `.m.curg` goes through the `m` (the OS thread running it) to make sure it's yours and not a system one, per the runtime's [`HACKING.md`](https://github.com/golang/go/blob/go1.26.0/src/runtime/HACKING.md). A second aspect sets the field to nil at the top of `goexit1`, so finished goroutines don't leak their context. The field doesn't follow a `go` statement by itself: dd-trace-go still carries a [`TODO: handle cross-goroutine context values`](https://github.com/DataDog/dd-trace-go/blob/v2.0.0/internal/orchestrion/context_stack.go#L10) in its context stack. [otelc goes further](https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.0.1/instrumentation/runtime/otelc.yaml). It adds its own fields to `g` (`otel_trace_context` and `otel_baggage_container`) and copies them in `newproc1` when a goroutine starts, so trace context and baggage follow `go` statements. That is still not the request's `context.Context`: its values, deadline and cancellation stay behind, and carrying trace context from an incoming connection to an outgoing one is unsolved. Other runtimes have a designed hook point for this: thread-local storage in Java, `contextvars` in Python, `AsyncLocalStorage` in Node.js. Go's answer is to patch the runtime's struct layout at build time and hope the runtime keeps allowing it. That is a fair measure of how far you bend to fake what Java ships with. ## FAQ ### Why can't you monkey-patch Go like Python? Go compiles to a self-contained native binary. There is no bytecode to rewrite at load time, no classloader moment between "file on disk" and "code executing", and no general runtime hook API. Python can swap a method at runtime; Go offers no such slot. ### Is there a Go equivalent of -javaagent? No. The JVM offers each class's bytes to a `ClassFileTransformer` before it defines the class, so a Java agent can rewrite a method on its way in. Go has no agent slot, and the closest cousin, a library loaded at process start, is the hardest route for Go because pure-Go binaries cannot take `LD_PRELOAD`. ### Why doesn't LD_PRELOAD work on Go binaries? A pure-Go build is a static ELF with no interpreter, so the dynamic loader never runs. `-linkmode=external` would hand the final link to the system C linker, but it needs cgo. golang/go#28909 is still open as of October 2026, and Ian Lance Taylor was "perfectly comfortable in saying that anybody who wants to use `LD_PRELOAD` must force the use of external linking." ### Does a dynamically linked Go binary accept LD_PRELOAD? cgo binaries, including those using the default `net` resolver on Linux, are dynamically linked and take `LD_PRELOAD` on glibc. But a preloaded library interposes only on dynamic symbols such as libc's `read`, and `readelf --dyn-syms` shows no `net/http` function to wrap, so there is still no slot. ### Can go:linkname be used as a runtime hook? Not really. It lets a package reach unexported runtime symbols, but the runtime's own comment above `gopark` calls linkname users a "hall of shame" and says "Do not remove or change the type signature". golang/go#67401 locked down future linkname uses; it is an accommodation, not a hook. ### How do tracers keep trace context across goroutines? Go has no goroutine-local storage. dd-trace-go's Orchestrion aspect injects a `__dd_gls_v2 any` field into `runtime.g` at build time, with `go:linkname`d getter and setter functions, and nils it at the top of `goexit1`. otelc adds `otel_trace_context` and `otel_baggage_container` fields to `g` and copies them in `newproc1`, so they follow `go` statements. ### Does that goroutine hack give me the request's context.Context? No. The otelc fields carry trace context and baggage, but the context's values, deadline and cancellation stay behind, and carrying trace context from an incoming connection to an outgoing one is unsolved. dd-trace-go still carries a "TODO: handle cross-goroutine context values" in its context stack. ### What are the three places to intervene in a Go program? Build time: a `-toolexec` wrapper parses each source file into a syntax tree, injects instrumentation, and hands the modified source to the real compiler; no runtime overhead, no kernel privilege, but you rebuild. Process start: a shared library mapped in before `main`; no rebuild, and the hardest for Go. Kernel: eBPF attaches probes to function entry and exit points in a running binary without modifying it. ## Related - [[resources/documentation/Go/Instrumentation/Go Instrumentation|Go Instrumentation]] (series hub) - [[resources/documentation/Go/Instrumentation/Go Instrumentation - OBI eBPF Auto-Instrumentation|OBI eBPF Auto-Instrumentation]] (part 2: the kernel route, no rebuild) - [[resources/documentation/Go/Instrumentation/Go Instrumentation - otelc Compile-Time Instrumentation|otelc Compile-Time Instrumentation]] (part 3: the `-toolexec` build route) - [[resources/documentation/Go/Instrumentation/Go Instrumentation - Context Propagation Across Goroutines|Context Propagation Across Goroutines]] (where trace context breaks across goroutines and connections) - [[resources/documentation/Go/Go - uretprobes]] - [[resources/documentation/Go/Go - Internals]] ## Field notes from agent sessions (2026-11-10) Agent sessions on Go instrumentation added the concrete mechanisms behind the `LD_PRELOAD` failure, the missing runtime hook, and the return-address trap. - Go offers no runtime hook surface, so every zero-touch hook lives at compile time (`-toolexec` AST rewriting) or outside the process (eBPF). - Upstream instrumentation matters most in Go because runtime monkey-patching is impossible. The same dependency aversion makes it land hardest. Maintainers pushed back on instrumentation dependencies in open-telemetry/opentelemetry-go#940, grpc/grpc-go#7963, IBM/sarama#2510, and gofiber/fiber#1109. - The `go:linkname`d goroutine-local-storage symbols (`__dd_orchestrion_gls_get/set.V2`) bind functions that exist only when Orchestrion injects the matching field into the runtime `g` struct at compile time. Without that injection, the package silently no-ops. - Go's runtime emits raw `SYSCALL` instructions (verified in `sys_linux_amd64.s`) and bypasses libc. On a `CGO_ENABLED=0` binary, `ld.so` never runs, so the preload vector never runs either. This mechanism is the structural reason Dynatrace OneAgent rejects static Go binaries. The tool's note about a possible static-libc conflict states the secondary cause. - `LD_PRELOAD` on a Go binary is not silently ignored. When the dynamic linker runs, it does load the `.so`, but Go still makes direct syscalls past libc, so interposition has no effect. The honest wording is "Go bypasses libc; interposition has no effect". `CGO_ENABLED=1` builds already select external linking for many production binaries. - A return-address rewrite from a C exit hook is unsound for Go. A GC crash reproduces on Go 1.25 whenever collection runs during an instrumented outbound HTTP call. OBI's uprobes on `RET` avoid the crash because Go's linkage rules out `uretprobe`. Never rewrite a Go return address. - A check for an existing `traceparent` header at the `net/http` `writeSubset` entry runs before the application's headers reach the buffer. That check yields duplicate headers on 49 of 50 requests. ### Observations - [mechanism] Go's runtime emits raw `SYSCALL` instructions and bypasses libc #go-runtime #ld-preload (source: agent sessions, 2026-07) - [mechanism] The `go:linkname`d goroutine-local-storage symbols exist only when a compile-time tool injects them into `runtime.g`. Without injection, the package silently no-ops #goroutine-local-storage (source: agent sessions, 2026-07) - [tooling] Dynatrace OneAgent rejects static Go binaries because the preload vector never runs on them. The tool's note about a static-libc conflict states the secondary cause #ld-preload (source: agent sessions, 2026-07) - [ecosystem] Upstream instrumentation matters most in Go and lands hardest, because the same dependency aversion drives maintainers to reject instrumentation dependencies #go #instrumentation (source: agent sessions, 2026-07) - [mechanism] `LD_PRELOAD` on a Go binary is not silently ignored. The dynamic linker does load the `.so`, but Go makes direct syscalls past libc, so interposition has no effect #ld-preload (source: agent sessions, 2026-08) - [finding] A return-address rewrite from a C exit hook crashes the Go GC on Go 1.25 during an instrumented outbound HTTP call. Uprobes on `RET` avoid it because Go linkage rules out `uretprobe` #go-runtime #uprobes (source: agent sessions, 2026-10) - [experiment] A `traceparent` check at the `writeSubset` entry yields duplicate headers on 49 of 50 requests #http #context-propagation (source: agent sessions, 2026-10) ## Sources - [How to Instrument Go Without Changing a Single Line of Code (talk, GopherCon UK 2026, GoLab 2026)](https://kakkoyun.me/talks/instrument-go-without-changing-a-single-line/) - [How to Instrument Go Without Changing a Single Line of Code (series)](https://kakkoyun.me/series/how-to-instrument-go-without-changing-a-single-line-of-code/) - [ClassFileTransformer (Java 21 javadoc)](https://docs.oracle.com/en/java/javase/21/docs/api/java.instrument/java/lang/instrument/ClassFileTransformer.html) - [runtime/proc.go gopark comment (go1.26.0)](https://github.com/golang/go/blob/go1.26.0/src/runtime/proc.go#L435-L442) - [golang/go#67401: linkname lockdown](https://github.com/golang/go/issues/67401) - [Datadog Single Step Instrumentation on Linux](https://docs.datadoghq.com/tracing/trace_collection/single-step-apm/linux/) - [OpenTelemetry injector README (v0.10.1)](https://github.com/open-telemetry/opentelemetry-injector/blob/v0.10.1/README.md) - [OpenTelemetry injector design notes (v0.10.1)](https://github.com/open-telemetry/opentelemetry-injector/blob/v0.10.1/DESIGN.md) - [golang/go#28909: LD_PRELOAD and Go binaries](https://github.com/golang/go/issues/28909) - [Ian Lance Taylor on golang/go#28909](https://github.com/golang/go/issues/28909#issuecomment-441823877) - [runtime HACKING.md (go1.26.0)](https://github.com/golang/go/blob/go1.26.0/src/runtime/HACKING.md) - [dd-trace-go v2.0.0 gls.orchestrion.yml aspect](https://github.com/DataDog/dd-trace-go/blob/v2.0.0/internal/orchestrion/gls.orchestrion.yml) - [dd-trace-go v2.0.0 context_stack.go TODO](https://github.com/DataDog/dd-trace-go/blob/v2.0.0/internal/orchestrion/context_stack.go#L10) - [otelc runtime rules, otelc.yaml (v1.0.1)](https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.0.1/instrumentation/runtime/otelc.yaml) - [Hooking into the Go toolchain (guest post)](https://internals-for-interns.com/posts/hooking-into-the-go-toolchain/)