# Go context propagation across goroutines: what a go statement copies > [!summary] TL;DR > Three different jobs hide behind "propagation" in Go: copying goroutine-local storage when a goroutine starts, keeping the request's `context.Context`, and carrying trace headers between connections. A goroutine that ends up with `context.Background()` loses the trace ID, the deadline and the cancellation, and `ctx.Err()` stays nil even after the client hangs up. otelc's goroutine slot repairs the span tree but not the request; OBI propagates headers from outside the process using uprobes on `runtime.newproc1` and BPF maps keyed by goroutine. Until the runtime grows inherited storage, you choose which of the three jobs to lose. ## Key points - Follow one request, `GET /orders/42`. The request arrives with a trace ID stored as a value, a one-second deadline, and, because the client hung up, a cancellation. - Go 1.27.1 output: a goroutine handed the request context prints `value=trace-abc123 deadline=true err=context canceled`; a goroutine handed `context.Background()` prints `value=<nil> deadline=false err=<nil>`. One run, one machine, and the output does not depend on timing. - The second goroutine knows nothing: no value, no deadline, `ctx.Err()` nil even though the client left. Hand-threading `ctx` through the call chain fixes it, and that is exactly the code change zero-touch tools try to avoid. - Orchestrion (Datadog's `-toolexec` tool) keeps request data in a `__dd_gls_v2` field on `runtime.g`; its aspect adds the field and clears it in `goexit1`, and neither rule touches the code that starts a goroutine, so the slot does not follow `go`. The `TODO` quoted in part 1 is still there at v2.9.1, and the files match between v2.0.0 and v2.9.1. - otelc copies on `go`: the runtime rule at v1.1.0 (the `instrumentation/runtime/` directory is identical at v1.0.1, the series pin) injects a deferred function into `newproc1` that copies two fields it adds to `g`, `otel_trace_context` and `otel_baggage_container`, from the creator to the new goroutine. - `propagateOtelContext` returns nil for nil, calls `Clone()` when the value has one, and otherwise hands over the same reference. The one `Clone` in the SDK instrumentation is on the span stack: it keeps the innermost live span and drops the rest, so the child starts with a stack of one. In the v1.1.0 tree the only baggage write found is a reset to nil. - A hook on `trace.SpanFromContext` swaps in the goroutine's span when the context has no valid one. What crosses a `go` statement is the parent's current span, and a span is not the request. - With an otelc-style slot, `SpanFromContext(context.Background())` returns the right parent span, so the trace tree stays whole; but `ctx.Err()` is still nil and the deadline is still missing. The trace repaired itself; the request did not. - At library level, the otelhttp handler extracts trace headers from the incoming request into its context, and the transport injects them into the outgoing request from `r.Context()`. Every link in that chain needs the context threaded. - OBI's v0.10.0 design doc describes kernel-side propagation, off by default (`OTEL_EBPF_BPF_CONTEXT_PROPAGATION`): `headers` mode injects a `Traceparent` header into plaintext HTTP, `tcp` mode adds a TCP option to any TCP traffic, and on the way in OBI parses whichever arrived. The security page lists `CAP_NET_ADMIN` for the trace-context-propagation programs. - For Go there is a second mode: a uprobe tries `bpf_probe_write_user` to write the header straight into Go's HTTP buffer and falls back to the kernel-side injector if that fails. The security page lists `CAP_SYS_ADMIN` for "library-level Go trace-context propagation". - OBI hooks the same function otelc does: two uprobes on `runtime.newproc1` record each new goroutine's parent in a BPF map keyed by goroutine address, and when an HTTP client call starts OBI walks up that chain until it finds a goroutine serving a request. - OBI carries trace and span IDs in BPF maps keyed by goroutine; in the Go probes read at v0.10.0 it uses no request-context values, so the deadline, cancellation and values stay behind. - The runtime already inherits one slot: `g.labels`, documented as "A new goroutine inherits the labels of the goroutine that created it", holding string pairs for the profiler; the OTel profiling OTEP says "we foresee Go readers will directly read pprof labels". Open questions for real goroutine-associated storage: copy or share on `go`, who clears it on exit, what happens when two tools want the one slot, what it costs on every `go` statement. Anything touching `newproc1` needs the Go team's agreement. - Versions, links and commands checked on 2 October 2026. ## What a goroutine without a context.Context knows This is the companion note between [[resources/documentation/Go/Instrumentation/Go Instrumentation - Continuous Profiling Without Code Changes|part 4]] and [[resources/documentation/Go/Instrumentation/Go Instrumentation - Flight Recording and USDT|part 5]] of the [series](https://kakkoyun.me/series/how-to-instrument-go-without-changing-a-single-line-of-code/), which accompanies the [GopherCon UK talk](https://kakkoyun.me/talks/instrument-go-without-changing-a-single-line/). [[resources/documentation/Go/Instrumentation/Go Instrumentation - Why Go Cannot Be Monkey Patched|Part 1]] showed tools hacking a field into `g`; here we look at what that field holds, what it can't, and who does the rest. Disclosure: the author works at Datadog and is one of the maintainers of otelc, the OpenTelemetry compile-time instrumentation tool. The request arrives with a context: a trace ID stored as a value, a one-second deadline, and, because the client hung up, a cancellation. The program below hands that context to one goroutine and `context.Background()` to another. The second is what a function ends up with when its signature has no `ctx` parameter and nothing else to go on. Each goroutine prints what it can learn about the request: ```go package main import ( "context" "fmt" "time" ) type traceKey struct{} // inGoroutine runs the report in a new goroutine and waits for it. func inGoroutine(name string, ctx context.Context) { done := make(chan struct{}) go func() { defer close(done) _, hasDeadline := ctx.Deadline() fmt.Printf("%-12s value=%v deadline=%v err=%v\n", name, ctx.Value(traceKey{}), hasDeadline, ctx.Err()) }() <-done } func main() { ctx := context.WithValue(context.Background(), traceKey{}, "trace-abc123") ctx, cancel := context.WithTimeout(ctx, time.Second) cancel() // the client hung up inGoroutine("request ctx:", ctx) inGoroutine("fresh ctx:", context.Background()) } ``` Go 1.27.1 prints this (one run, one machine, though the output doesn't depend on timing): ```text request ctx: value=trace-abc123 deadline=true err=context canceled fresh ctx: value=<nil> deadline=false err=<nil> ``` The first goroutine knows the lot because the context was handed to it. The second knows nothing: no value, no deadline, and `ctx.Err()` is nil even though the client left. Threading `ctx` through by hand fixes it, and that's the code change this series is trying to avoid. A tool that can't ask for that needs somewhere else to keep the request. ## What Orchestrion and otelc copy when a goroutine starts Orchestrion (Datadog's `-toolexec` tool) keeps that somewhere else in a `__dd_gls_v2` field on `runtime.g`, which the goroutine-problem section of [[resources/documentation/Go/Instrumentation/Go Instrumentation - Why Go Cannot Be Monkey Patched|part 1]] walks through. Its [aspect](https://github.com/DataDog/dd-trace-go/blob/v2.9.1/internal/orchestrion/gls.orchestrion.yml) adds the field and clears it in `goexit1`, and neither rule touches the code that starts a goroutine, so the slot does not follow `go`. The `TODO` part 1 quotes from the context stack is still there at v2.9.1; both files were read at v2.0.0 and v2.9.1, and they match. otelc copies on `go`. The [runtime rule at v1.1.0](https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/runtime/otelc.yaml) (the `instrumentation/runtime/` directory is identical at v1.0.1, the series pin) injects a deferred function into `newproc1`, the runtime function that builds a new goroutine, to copy the two fields it adds to `g`, `otel_trace_context` and `otel_baggage_container`. Re-indented the way gofmt would print it, the injected code is: ```go defer func() { _unnamedRetVal0.otel_trace_context = propagateOtelContext(callergp.otel_trace_context) _unnamedRetVal0.otel_baggage_container = propagateOtelContext(callergp.otel_baggage_container) }() ``` `_unnamedRetVal0` is the new goroutine and `callergp` is its creator. [`propagateOtelContext`](https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/runtime/runtime_gls.go#L28-L36) returns nil for nil, calls `Clone()` when the value has one, and otherwise hands over the same reference. The one `Clone` in the SDK instrumentation is on the [span stack](https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/go.opentelemetry.io/otel/sdk/trace/otel_trace_context.go#L264): it keeps the innermost live span and drops the rest, so the child starts with a stack of one. The baggage slot is copied too, but in the v1.1.0 tree the only write found is a reset to nil, so what it carries today cannot be said. Reading it back, a [hook on `trace.SpanFromContext`](https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/go.opentelemetry.io/otel/trace/hook.go#L15) swaps in the goroutine's span when the context has no valid one. What crosses a `go` statement is the parent's current span, and a span is not the request. ## Why a span slot cannot carry the request's context A `context.Context` carries values, a deadline and a cancellation signal, and the goroutine with the fresh context lost all three. Give that goroutine an otelc-style slot and, going by the hook's code, `SpanFromContext(context.Background())` returns the right parent span, so the trace tree stays whole. `ctx.Err()` is still nil and the deadline is still missing. The trace repaired itself and the request didn't. Tools have been solving two problems as one. "Which span is my parent?" is a question a goroutine slot can answer. "Is my caller still waiting, and what did it attach to the request?" needs the context itself. The [`context` package](https://github.com/golang/go/blob/go1.27.1/src/context/context.go#L50) says values are for "request-scoped data that transits processes and APIs", which is precisely the data a span-only copy drops. ## OBI: trace headers from outside the process At library level the job is spelled out in code. The [otelhttp handler](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/instrumentation/net/http/otelhttp/v0.72.0/instrumentation/net/http/otelhttp/handler.go#L98) extracts trace headers from the incoming request into its context. The [transport](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/instrumentation/net/http/otelhttp/v0.72.0/instrumentation/net/http/otelhttp/transport.go#L266) injects them into the outgoing request from `r.Context()`. Every link in that chain needs the context threaded; as the author reads it, that is the gap the `SpanFromContext` fallback from job one is built for. OBI (OpenTelemetry eBPF Instrumentation) does it from outside the process, and here it helps to separate two modes. The [v0.10.0 design doc](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/devdocs/context-propagation.md) describes kernel-side propagation, off by default (`OTEL_EBPF_BPF_CONTEXT_PROPAGATION`). With `headers` it injects a `Traceparent` header into plaintext HTTP, and with `tcp` it adds a TCP option to any TCP traffic. On the way in, it parses whichever one arrived. The [security page](https://opentelemetry.io/docs/zero-code/obi/security/) (unversioned) lists `CAP_NET_ADMIN` for the trace-context-propagation programs. For Go there is a second mode. The same design doc says a uprobe tries `bpf_probe_write_user` to write the header straight into Go's HTTP buffer and leaves the header to the kernel-side injector if that fails. The security page lists `CAP_SYS_ADMIN` for "library-level Go trace-context propagation". How does OBI know which outgoing call belongs to which incoming request? It hooks the same function otelc does. [Two uprobes on `runtime.newproc1`](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/bpf/gotracer/go_runtime.c#L114-L185) record each new goroutine's parent in a BPF map, keyed by goroutine address instead of a field in `g`. When an HTTP client call starts, OBI [walks up that chain](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/bpf/gotracer/go_common.h#L151-L186) until it finds a goroutine serving a request. OBI carries trace and span IDs in BPF maps keyed by goroutine. In the Go probes read at v0.10.0 it uses no request-context values, so the deadline, cancellation and values stay where they were. For a tool that changes nothing that's a fair deal, and it leaves that data behind. Which brings us to the one place neither route can reach: the runtime. ## What the Go runtime would need, and running the demo What follows is the author's opinion, start to finish. The observing-isnt-propagating section of [[resources/documentation/Go/Instrumentation/Go Instrumentation - Flight Recording and USDT|part 5]] looks at the same gap from another side: the tracing fork rides on `context.Context`, so the context still has to be threaded. The runtime already copies one slot when a goroutine starts: [`g.labels`](https://github.com/golang/go/blob/go1.27.1/src/runtime/proc.go#L5403), which [`runtime/pprof` documents](https://github.com/golang/go/blob/go1.27.1/src/runtime/pprof/runtime.go#L39) as "A new goroutine inherits the labels of the goroutine that created it". It holds string pairs for the profiler, and the [OTel profiling OTEP](https://github.com/open-telemetry/opentelemetry-specification/blob/bd83138a95b6ca31384f0cb2490b2069bdec26f5/oteps/profiles/4947-thread-ctx.md#alternative-for-go-support) says "we foresee Go readers will directly read pprof labels". That is precedent for an inherited slot, but the slot belongs to the profiler. What the author would want is goroutine-associated storage that a new goroutine inherits and that does not lose the request's context data: the values, the deadline and the cancellation, not only a span. Open questions: copy or share on `go`, who clears it when the goroutine exits, what happens when two tools want the one slot, and what it costs on every `go` statement. There is no design, and anything touching `newproc1` needs the Go team's agreement. Until someone builds that, propagation stays unsolved, and we keep choosing which of the three jobs to lose. Save the program above as `main.go`, then run it: ```bash go mod init demo go run . ``` To read what otelc copies, print the rule yourself: ```bash gh api 'repos/open-telemetry/opentelemetry-go-compile-instrumentation/contents/instrumentation/runtime/otelc.yaml?ref=v1.1.0' --jq .content | base64 -d ``` Then go hunting in your own services for the function three frames down that never got a `ctx`. It's there. Versions, links and commands checked on 2 October 2026. ## FAQ ### Why does my goroutine lose the trace ID, deadline and cancellation? Its function has no `ctx` parameter, so it ends up with `context.Background()`: no values, no deadline, and `ctx.Err()` returns nil even after the client hung up. Thread `ctx` through the call chain by hand, or use a tool that stores the request somewhere else. ### How does otelc propagate context across goroutines? It injects a deferred function into `runtime.newproc1` that copies `otel_trace_context` and `otel_baggage_container` from the creating goroutine to the new one. `propagateOtelContext` clones values that have a `Clone()`; on the span stack the clone keeps the innermost live span, so the child starts with a stack of one. ### What does otelc's SpanFromContext hook do? When the context has no valid span, the hook swaps in the span stored in the goroutine's slot. That repairs the trace tree, but it does not restore the deadline, the cancellation or the request-scoped values. ### How does OBI propagate trace context without touching the process? Kernel-side, off by default (`OTEL_EBPF_BPF_CONTEXT_PROPAGATION`): `headers` injects a `Traceparent` header into plaintext HTTP, `tcp` adds a TCP option to any TCP traffic, and OBI parses whichever one arrives. For Go, a uprobe can also try `bpf_probe_write_user` to write the header into Go's HTTP buffer, falling back to the kernel-side injector. ### Which capabilities does OBI need for trace-context propagation? The security page lists `CAP_NET_ADMIN` for the trace-context-propagation programs and `CAP_SYS_ADMIN` for "library-level Go trace-context propagation". ### How does OBI know which outgoing call belongs to which incoming request? Two uprobes on `runtime.newproc1` record each new goroutine's parent in a BPF map keyed by goroutine address. When an HTTP client call starts, OBI walks up that chain until it finds a goroutine serving a request. ### Does Go inherit anything from the parent goroutine today? Yes: `g.labels`, which `runtime/pprof` documents as "A new goroutine inherits the labels of the goroutine that created it". It holds string pairs for the profiler, and the OTel profiling OTEP foresees Go readers reading pprof labels directly. ### Can goroutine-local storage replace context.Context? No. A slot answers "which span is my parent", but the values, deadline and cancellation live on the context itself. The `context` package defines values as "request-scoped data that transits processes and APIs", which is exactly what a span-only copy drops. ## Related - [[resources/documentation/Go/Instrumentation/Go Instrumentation|Go Instrumentation]] (series hub) - [[resources/documentation/Go/Instrumentation/Go Instrumentation - Why Go Cannot Be Monkey Patched|Why Go Cannot Be Monkey Patched]] (part 1: the goroutine problem and the `g` field hack) - [[resources/documentation/Go/Instrumentation/Go Instrumentation - otelc Compile-Time Instrumentation|otelc Compile-Time Instrumentation]] (part 3: the tool whose runtime rule this note reads) - [[resources/documentation/Go/Instrumentation/Go Instrumentation - OBI eBPF Auto-Instrumentation|OBI eBPF Auto-Instrumentation]] (part 2: OBI's requirements and Go coverage) - [[resources/documentation/Go/Instrumentation/Go Instrumentation - Continuous Profiling Without Code Changes|Continuous Profiling Without Code Changes]] (part 4: the eBPF profiler) - [[resources/documentation/Go/Instrumentation/Go Instrumentation - Flight Recording and USDT|Flight Recording and USDT]] (part 5: the same gap from the runtime side) - [[resources/documentation/Go/Go - uretprobes|Go - uretprobes]] (related) - [[resources/documentation/Go/Go - Internals|Go - Internals]] (related) ## Field notes from agent sessions (2026-11-10) These notes come from agent sessions that designed and debugged goroutine-local storage (GLS) for a Go tracer. They add design rules for context threading, scope exit and span parenting on reused goroutines. - Go has no counterpart to Node's `AsyncLocalStorage`, which auto-propagates across async boundaries. In Go, the developer must pass `ctx` into the `go` closure. `pprof` labels are the one inherited exception. - Context in a struct field is a Go anti-pattern. Pass `context.Context` as the first function parameter so callers control the lifetime. Refactoring a stored `ctx` into a threaded parameter touched 6 files, including test constructors. - GLS cleanup can be goroutine-safe without locking. Capture the goroutine-local stack pointer at push time and compare it at pop time. Each goroutine has its own stack by design, so a mismatch makes the pop a no-op. - `GLSPopFunc` returns a closure scoped to the exact stack frame pushed. Capture the closure at push time, not at pop time. Captured at push time, the pop becomes idempotent and goroutine-safe. - A stack index is an ABA hazard for scope exit. Positions get recycled, so a captured index points at an unrelated scope after one pop and a later push. A monotonic token makes a stale reference match nothing and turns a destructive pop into a no-op. Keep the counter on the stack object, not per key. Keys are deleted when they empty, so a per-key counter re-issues tokens that live poppers still hold. - Scope identity and liveness are separate mechanisms on one stack entry. The token answers which scope, for out-of-order exit. The done cell answers whether the scope is still live, for a cross-goroutine finish. Neither subsumes the other. The obvious re-activation fix, to allocate a fresh cell and mark the old one done, keeps GLS depth at 1 and passes depth unit tests. But it retires a still-live entry: the parent vanishes from the GLS and the next child reparents. The bug surfaced only as a span on the wrong trace. - Every cleanup path must check the same activation gate as the entry points. A pop or a reset left on an old gate leaks trace context across recycled goroutines under alternative builds. All nine entry points of the GLS implementation were audited, and none was left on the old gate. - A finished span is a valid GLS parent. Its `SpanContext` keeps the trace ID and the span ID intact. The only invalid GLS entry is one whose span object the pool has recycled into a different logical span. Peek-skip-done conflates the two cases and breaks consumers that reuse goroutines. - A GLS stack entry that is never popped is one root cause with two faces. It is a memory leak, and it is a stale parent for the next request on a reused goroutine. The trigger shape is a cross-goroutine `Finish()`: push on the HTTP goroutine, finish on a Kafka producer or consumer goroutine. - The trace-merge path is `StartSpanFromContext` → `SpanFromContext` → GLS `Peek` on a fresh `context.Background()`. With no distributed headers, parent inference falls through to whatever finished span sits on the reused goroutine's stack. The parent-inferring `Peek` runs before the reclaiming `Push`, so the reclaim provably bounds the leak. Merge prevention stays an open question. - A process-global span-state map upgrades a common user bug, spans started but never ended, from a bounded per-goroutine GLS cost to an unbounded process-global leak. The map is injected into every instrumented binary. The `sync.Map` churn was benchmarked, not assumed: about 220 ns and 3 allocations, against a mutex plus plain map at about 180 ns and near 0 allocations, on a create-then-delete pattern. ### Observations - [go] `context.Context` does not cross a `go` statement. Node's `AsyncLocalStorage` auto-propagates, Go does not. #context (source: agent sessions, 2026-07) - [go] Context in a struct field removes caller control of lifetime. Pass it as the first parameter. #context (source: agent sessions, 2026-03) - [gls] Capture the stack pointer at push time and compare it at pop time. This gives lock-free, goroutine-safe cleanup. #gls (source: agent sessions, 2026-02) - [gls] A `GLSPopFunc` closure is scoped to the frame it was pushed on. Capture it at push time. #gls (source: agent sessions, 2026-05) - [gls] An index is an ABA hazard for scope exit. A monotonic token on the stack object makes a stale pop a no-op. #gls (source: agent sessions, 2026-07) - [gls] The token answers which scope. The done cell answers whether it is live. Re-activation that marks a live cell done silently reparents the next child. #gls (source: agent sessions, 2026-07) - [gls] Cleanup paths share the activation gate of the entry points. An old gate leaks trace context across recycled goroutines. #gls (source: agent sessions, 2026-09) - [tracing] A finished span is a valid parent. Only a recycled span object is an invalid entry. #tracing (source: agent sessions, 2026-06) - [tracing] An unpopped GLS entry is a leak and a stale parent at once. Cross-goroutine `Finish()` triggers it. #memory-leak (source: agent sessions, 2026-07) - [tracing] The parent-inferring `Peek` runs before the reclaiming `Push`. The reclaim bounds the leak but does not prevent trace merges. #tracing (source: agent sessions, 2026-07) - [tracing] A process-global span-state map turns a per-goroutine cost into a process-global leak. Benchmark the churn before the claim. #performance (source: agent sessions, 2026-07) ## 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/) - [Orchestrion aspect: gls.orchestrion.yml at v2.9.1](https://github.com/DataDog/dd-trace-go/blob/v2.9.1/internal/orchestrion/gls.orchestrion.yml) - [otelc runtime rule: otelc.yaml at v1.1.0](https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/runtime/otelc.yaml) - [propagateOtelContext: runtime_gls.go at v1.1.0](https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/runtime/runtime_gls.go#L28-L36) - [otelc span stack Clone: otel_trace_context.go at v1.1.0](https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/go.opentelemetry.io/otel/sdk/trace/otel_trace_context.go#L264) - [otelc SpanFromContext hook: hook.go at v1.1.0](https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation/blob/v1.1.0/instrumentation/go.opentelemetry.io/otel/trace/hook.go#L15) - [Go context package: context.go at go1.27.1](https://github.com/golang/go/blob/go1.27.1/src/context/context.go#L50) - [otelhttp handler at v0.72.0](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/instrumentation/net/http/otelhttp/v0.72.0/instrumentation/net/http/otelhttp/handler.go#L98) - [otelhttp transport at v0.72.0](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/instrumentation/net/http/otelhttp/v0.72.0/instrumentation/net/http/otelhttp/transport.go#L266) - [OBI context-propagation design doc at v0.10.0](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/devdocs/context-propagation.md) - [OBI security page](https://opentelemetry.io/docs/zero-code/obi/security/) - [OBI uprobes on runtime.newproc1: go_runtime.c at v0.10.0](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/bpf/gotracer/go_runtime.c#L114-L185) - [OBI goroutine chain walk: go_common.h at v0.10.0](https://github.com/open-telemetry/opentelemetry-ebpf-instrumentation/blob/v0.10.0/bpf/gotracer/go_common.h#L151-L186) - [runtime g.labels: proc.go at go1.27.1](https://github.com/golang/go/blob/go1.27.1/src/runtime/proc.go#L5403) - [runtime/pprof label inheritance doc](https://github.com/golang/go/blob/go1.27.1/src/runtime/pprof/runtime.go#L39) - [OTel profiling OTEP 4947: thread context for Go](https://github.com/open-telemetry/opentelemetry-specification/blob/bd83138a95b6ca31384f0cb2490b2069bdec26f5/oteps/profiles/4947-thread-ctx.md#alternative-for-go-support)