Skip to content

fix(internal/injector): ArgumentThatImplements always fails for context.Context#798

Merged
kakkoyun merged 3 commits into
mainfrom
dario.castane/APMS-18578/fix-context-argumenthatimplements
Mar 12, 2026
Merged

fix(internal/injector): ArgumentThatImplements always fails for context.Context#798
kakkoyun merged 3 commits into
mainfrom
dario.castane/APMS-18578/fix-context-argumenthatimplements

Conversation

@darccio

@darccio darccio commented Mar 12, 2026

Copy link
Copy Markdown
Member

Go's type checker assigns each loaded package a unique object. If the same package is loaded twice through different means, you get two distinct objects for what is logically the same type. types.Implements uses pointer equality to compare types across method signatures, so it sees them as different and returns false.

ResolveInterfaceTypeByName loads context.Context one way; the instrumented code is type-checked another way. The two context packages end up as different objects, types.Implements fails, and ArgumentThatImplements never matches.

Fix by falling back to comparing method names and package paths instead of relying on pointer equality.

…ntext.Context`

Go's type checker assigns each loaded package a unique object. If the same
package is loaded twice through different means, you get two distinct objects
for what is logically the same type. `types.Implements` uses pointer equality to
compare types across method signatures, so it sees them as different and
returns `false`.

`ResolveInterfaceTypeByName` loads context.Context one way; the instrumented
code is type-checked another way. The two `context` packages end up as different
objects, `types.Implements` fails, and `ArgumentThatImplements` never matches.

Fix by falling back to comparing method names and package paths instead of
relying on pointer equality.
@darccio
darccio requested a review from a team as a code owner March 12, 2026 09:34
@darccio

darccio commented Mar 12, 2026

Copy link
Copy Markdown
Member Author

Additional context:

Background

The ArgumentThatImplements directive (used by the //dd:span template) relies on typeImplements to check if a function parameter's type satisfies a given interface. It was silently returning no match for context.Context, even for types that correctly implement it.


Reproduction Scenario

The repro project used to replicate the issue described in the JIRA ticket implements a CustomContext type that embeds context.Context and adds a UserID field, mimicking how frameworks like Fiber wrap request contexts:

type CustomContext struct {
    context.Context
    UserID string
}

//dd:span
func DoThingWithCtx(ctx context.Context) error { return nil }

//dd:span
func DoThingWithCustomCtx(ctx *CustomContext) error { return nil }

theOperation (excluded from instrumentation with //orchestrion:ignore) does the following:

// Creates span1 explicitly, wraps it in a CustomContext
span1, nativeCtx := tracer.StartSpanFromContext(context.Background(), "theOperation")
customCtx := &CustomContext{nativeCtx, "user-id"}

DoThingWithCtx(customCtx)       // SPAN 0 — should be child of span1
DoThingWithCustomCtx(customCtx) // SPAN 1 — should be child of span1

// Creates span2 with context.Background() — should be a root span
span2, _ := tracer.StartSpanFromContext(context.Background(), "theOperation2") // SPAN 4

// customCtx still carries span1 — both calls should still be children of span1
DoThingWithCustomCtx(customCtx) // SPAN 2 — should be child of span1
DoThingWithCtx(customCtx)       // SPAN 3 — should be child of span1

Observed output (before fixes):

SPAN 0 [ DoThingWithCtx       ] parent=span1   ✓ correct
SPAN 1 [ DoThingWithCustomCtx ] parent=span1   ✓ correct (coincidence — GLS has span1 at this point)
SPAN 2 [ DoThingWithCustomCtx ] parent=span2   ✗ wrong  (should be span1)
SPAN 3 [ DoThingWithCtx       ] parent=span2   ✗ wrong  (should be span1)
SPAN 4 [ theOperation2        ] parent=span1   ✗ wrong  (should be root)
SPAN 5 [ theOperation         ] parent=0       ✓ correct

How each bug manifests:

SPANs 2 & 3 — After span2 is created, GLS holds span2 as the active span. The second calls to both functions receive customCtx which carries span1 in its context chain, but GLS takes priority, so span2 becomes the parent. This is Bug 2 (GLS priority).

Additionally, SPAN 1 appearing correct is coincidental: without Bug 3 fixed, DoThingWithCustomCtx can't extract a context from *CustomContext and falls back to context.TODO(). At that point GLS holds span1, which happens to be the right answer. After span2 is created, the same fallback to GLS explains why SPAN 2 is wrong: context.TODO() has no span, GLS returns span2.

SPAN 4theOperation2 is created with context.Background(). Since Background has no values, the GLS fallback kicks in and returns the active span1, making it appear as a child of span1 instead of a root span. This is Bug 1 (context.Background() sentinel).


Fixes

Bug — typeImplements always fails for context.Context

Root cause: Orchestrion resolves interface types for matching (e.g. context.Context) using importer.Default(), while the instrumented code is type-checked using importer.ForCompiler. These two importers produce different *types.Package objects for the same import path. types.Implements relies on types.Identical to compare method signatures, which in turn compares named types by pointer identity — not by import path. Since context.Context.Deadline() returns time.Time, and time.Time is loaded from two different importer universes, types.Identical rejects it and types.Implements returns false.

Three fixes were needed:

1. typed/typecheck.go — method-name fallback in typeImplements

After types.Implements fails, fall back to checking all interface methods by name using types.LookupFieldOrMethod, which compares by package path rather than pointer identity. This correctly finds matching methods across importer boundaries.

2. advice/code/dot_function.go — use typed.ExprImplements in findImplementingField

findImplementingField was calling types.Implements directly, bypassing the method-name fallback entirely. Changed to use typed.ExprImplements so both call sites benefit from the same fallback logic.

3. aspect/context/context.go — resolve *CustomType in function parameter position

ResolveType resolves a DST expression to a *types.Type via a nodeMap (DST node → AST node). For types appearing in function parameter position (e.g. *CustomContext), the reverse nodeMap lookup returns nil because the DST node for the pointer base type was never registered. Added a fallback that looks up the identifier directly in typeInfo.Uses, which is populated by the type checker for all identifier references regardless of nodeMap coverage.

@darccio darccio added the AI Assisted AI/LLM assistance used in this PR (partially or fully) label Mar 12, 2026

@kakkoyun kakkoyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Great catch!

// signatures (e.g., time.Time in context.Context.Deadline).
// types.LookupFieldOrMethod compares by package path, not pointer, so it
// correctly finds matching methods across importers.
for i := 0; i < iface.NumMethods(); i++ {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@kakkoyun
kakkoyun added this pull request to the merge queue Mar 12, 2026
Merged via the queue into main with commit 8093b0b Mar 12, 2026
64 checks passed
@kakkoyun
kakkoyun deleted the dario.castane/APMS-18578/fix-context-argumenthatimplements branch March 12, 2026 12:49
@codecov

codecov Bot commented Mar 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.57143% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.62%. Comparing base (e061d12) to head (40c57d4).
⚠️ Report is 64 commits behind head on main.

Files with missing lines Patch % Lines
internal/injector/aspect/context/context.go 57.14% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #798      +/-   ##
==========================================
+ Coverage   65.72%   69.62%   +3.90%     
==========================================
  Files         113      116       +3     
  Lines        7926     6900    -1026     
==========================================
- Hits         5209     4804     -405     
+ Misses       2192     1545     -647     
- Partials      525      551      +26     
Components Coverage Δ
Generators 83.23% <ø> (+2.98%) ⬆️
Instruments ∅ <ø> (∅)
Go Driver 75.58% <65.38%> (-0.23%) ⬇️
Toolexec Driver 74.78% <100.00%> (+7.25%) ⬆️
Aspects 76.97% <75.78%> (+5.06%) ⬆️
Injector 77.17% <77.14%> (+4.38%) ⬆️
Job Server 68.16% <55.55%> (+2.24%) ⬆️
Other 69.62% <65.33%> (+3.90%) ⬆️
Files with missing lines Coverage Δ
...ternal/injector/aspect/advice/code/dot_function.go 60.54% <100.00%> (+2.16%) ⬆️
internal/injector/typed/typecheck.go 72.88% <100.00%> (+7.49%) ⬆️
internal/injector/aspect/context/context.go 80.86% <57.14%> (+8.07%) ⬆️

... and 105 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

darccio added a commit that referenced this pull request Mar 12, 2026
…ntext.Context` (#798)

Go's type checker assigns each loaded package a unique object. If the
same package is loaded twice through different means, you get two
distinct objects for what is logically the same type. `types.Implements`
uses pointer equality to compare types across method signatures, so it
sees them as different and returns `false`.

`ResolveInterfaceTypeByName` loads context.Context one way; the
instrumented code is type-checked another way. The two `context`
packages end up as different objects, `types.Implements` fails, and
`ArgumentThatImplements` never matches.

Fix by falling back to comparing method names and package paths instead
of relying on pointer equality.
@darccio darccio mentioned this pull request Mar 12, 2026
gh-worker-dd-mergequeue-cf854d Bot pushed a commit to DataDog/dd-trace-go that referenced this pull request Mar 20, 2026
### What does this PR do?

This PR fixes three independent bugs where the wrong span was used as the parent.

Relates to DataDog/orchestrion#798.

### Motivation

Orchestrion instruments Go code by injecting span creation logic and using goroutine-local storage (GLS) as a side-channel to propagate the active span through call sites that don't have a `context.Context` in their signature. Spans are stored both in the `context.Context` chain (explicit) and in the GLS stack (implicit). `glsContext` wraps a `context.Context` and intercepts `.Value()` calls to consult both sources.

#### Reproduction Scenario

The repro project used to replicate the issue described in the JIRA ticket implements a `CustomContext` type that embeds `context.Context` and adds a `UserID` field, mimicking how frameworks like Fiber wrap request contexts:

```go
type CustomContext struct {
    context.Context
    UserID string
}

//dd:span
func DoThingWithCtx(ctx context.Context) error { return nil }

//dd:span
func DoThingWithCustomCtx(ctx *CustomContext) error { return nil }
```

`theOperation` (excluded from instrumentation with `//orchestrion:ignore`) does the following:

```go
// Creates span1 explicitly, wraps it in a CustomContext
span1, nativeCtx := tracer.StartSpanFromContext(context.Background(), "theOperation")
customCtx := &CustomContext{nativeCtx, "user-id"}

DoThingWithCtx(customCtx)       // SPAN 0 — should be child of span1
DoThingWithCustomCtx(customCtx) // SPAN 1 — should be child of span1

// Creates span2 with context.Background() — should be a root span
span2, _ := tracer.StartSpanFromContext(context.Background(), "theOperation2") // SPAN 4

// customCtx still carries span1 — both calls should still be children of span1
DoThingWithCustomCtx(customCtx) // SPAN 2 — should be child of span1
DoThingWithCtx(customCtx)       // SPAN 3 — should be child of span1
```

**Observed output (before fixes):**

```
SPAN 0 [ DoThingWithCtx       ] parent=span1   ✓ correct
SPAN 1 [ DoThingWithCustomCtx ] parent=span1   ✓ correct (coincidence — GLS has span1 at this point)
SPAN 2 [ DoThingWithCustomCtx ] parent=span2   ✗ wrong  (should be span1)
SPAN 3 [ DoThingWithCtx       ] parent=span2   ✗ wrong  (should be span1)
SPAN 4 [ theOperation2        ] parent=span1   ✗ wrong  (should be root)
SPAN 5 [ theOperation         ] parent=0       ✓ correct
```

**How each bug manifests:**

**SPANs 2 & 3** — After `span2` is created, GLS holds `span2` as the active span. The second calls to both functions receive `customCtx` which carries `span1` in its context chain, but GLS takes priority, so `span2` becomes the parent. This is **Bug 2** (GLS priority).

Additionally, **SPAN 1** appearing correct is coincidental: without **Bug 3** fixed, `DoThingWithCustomCtx` can't extract a context from `*CustomContext` and falls back to `context.TODO()`. At that point GLS holds `span1`, which happens to be the right answer. After `span2` is created, the same fallback to GLS explains why SPAN 2 is wrong: `context.TODO()` has no span, GLS returns `span2`.

**SPAN 4** — `theOperation2` is created with `context.Background()`. Since Background has no values, the GLS fallback kicks in and returns the active `span1`, making it appear as a child of span1 instead of a root span. This is **Bug 1** (`context.Background()` sentinel).

---

#### Fixes

##### Bug 1 — `context.Background()` inherits active GLS span

**Symptom**: A span started with `context.Background()` (explicit intent: no parent) would incorrectly inherit the active GLS span as its parent.

**Root cause**: `SpanFromContext(context.Background())` would call `WrapContext(context.Background())`, producing a `glsContext` wrapping an empty context. When `.Value(activeSpanKey)` is called on it, the context chain returns `nil` (Background has no values), which triggers the GLS fallback — returning whatever span happens to be active. The span ends up with an unintended parent.

**Fix**: Early-return `(nil, false)` in `SpanFromContext` when `ctx == context.Background()`. `context.Background()` is the conventional "no context" sentinel; a caller using it is explicitly opting out of context-based trace propagation.

---

##### Bug 2 — GLS overrides an explicitly-propagated context

**Symptom**: When a function received an explicit `context.Context` carrying span X, but GLS held span Y (from an outer scope), span Y was used as the parent instead of X.

**Root cause**: `glsContext.Value` consulted GLS **first**, then fell back to the context chain. Any value present in GLS would silently override the explicitly-propagated one.

**Fix**: Reverse the lookup order in `glsContext.Value`: check the context chain first; fall back to GLS only if the chain returns `nil`. GLS is designed for implicit propagation through *un-instrumented* call sites that lack a `context.Context` — it must not override contexts that were explicitly propagated.

---

##### Bug 3 — `*CustomContext` not recognized as a context source in `//dd:span`

**Symptom**: Functions annotated with `//dd:span` whose first context-like argument was a custom type implementing `context.Context` (e.g. `*fiber.Ctx`) would fall back to `context.TODO()`, breaking trace propagation.

**Root cause**: The `//dd:span` injection template only looked for an argument of the **exact** type `context.Context`. If the function's parameter was a custom type that satisfied the interface, it was ignored, and the template fell back to `context.TODO()`.

**Fix**: Added an `ArgumentThatImplements "context.Context"` lookup path. When found, the argument is assigned with an explicit interface type declaration (`var ctx context.Context = $impl`) so that the subsequent reassignment from `StartSpanFromContext` (which returns `context.Context`) compiles correctly against the concrete type.

### Reviewer's Checklist

- [x] Changed code has unit tests for its functionality at or near 100% coverage.
- [x] New code is free of linting errors. You can check this by running `make lint` locally.
- [x] New code doesn't break existing tests. You can check this by running `make test` locally.
- [x] Add an appropriate team label so this PR gets put in the right place for the release notes.
- [x] All generated files are up to date. You can check this by running `make generate` locally.

Unsure? Have a question? Request a review!


Co-authored-by: kakkoyun <[email protected]>
Co-authored-by: dario.castane <[email protected]>
darccio added a commit to DataDog/dd-trace-go that referenced this pull request Mar 27, 2026
This PR fixes three independent bugs where the wrong span was used as the parent.

Relates to DataDog/orchestrion#798.

Orchestrion instruments Go code by injecting span creation logic and using goroutine-local storage (GLS) as a side-channel to propagate the active span through call sites that don't have a `context.Context` in their signature. Spans are stored both in the `context.Context` chain (explicit) and in the GLS stack (implicit). `glsContext` wraps a `context.Context` and intercepts `.Value()` calls to consult both sources.

The repro project used to replicate the issue described in the JIRA ticket implements a `CustomContext` type that embeds `context.Context` and adds a `UserID` field, mimicking how frameworks like Fiber wrap request contexts:

```go
type CustomContext struct {
    context.Context
    UserID string
}

//dd:span
func DoThingWithCtx(ctx context.Context) error { return nil }

//dd:span
func DoThingWithCustomCtx(ctx *CustomContext) error { return nil }
```

`theOperation` (excluded from instrumentation with `//orchestrion:ignore`) does the following:

```go
// Creates span1 explicitly, wraps it in a CustomContext
span1, nativeCtx := tracer.StartSpanFromContext(context.Background(), "theOperation")
customCtx := &CustomContext{nativeCtx, "user-id"}

DoThingWithCtx(customCtx)       // SPAN 0 — should be child of span1
DoThingWithCustomCtx(customCtx) // SPAN 1 — should be child of span1

// Creates span2 with context.Background() — should be a root span
span2, _ := tracer.StartSpanFromContext(context.Background(), "theOperation2") // SPAN 4

// customCtx still carries span1 — both calls should still be children of span1
DoThingWithCustomCtx(customCtx) // SPAN 2 — should be child of span1
DoThingWithCtx(customCtx)       // SPAN 3 — should be child of span1
```

**Observed output (before fixes):**

```
SPAN 0 [ DoThingWithCtx       ] parent=span1   ✓ correct
SPAN 1 [ DoThingWithCustomCtx ] parent=span1   ✓ correct (coincidence — GLS has span1 at this point)
SPAN 2 [ DoThingWithCustomCtx ] parent=span2   ✗ wrong  (should be span1)
SPAN 3 [ DoThingWithCtx       ] parent=span2   ✗ wrong  (should be span1)
SPAN 4 [ theOperation2        ] parent=span1   ✗ wrong  (should be root)
SPAN 5 [ theOperation         ] parent=0       ✓ correct
```

**How each bug manifests:**

**SPANs 2 & 3** — After `span2` is created, GLS holds `span2` as the active span. The second calls to both functions receive `customCtx` which carries `span1` in its context chain, but GLS takes priority, so `span2` becomes the parent. This is **Bug 2** (GLS priority).

Additionally, **SPAN 1** appearing correct is coincidental: without **Bug 3** fixed, `DoThingWithCustomCtx` can't extract a context from `*CustomContext` and falls back to `context.TODO()`. At that point GLS holds `span1`, which happens to be the right answer. After `span2` is created, the same fallback to GLS explains why SPAN 2 is wrong: `context.TODO()` has no span, GLS returns `span2`.

**SPAN 4** — `theOperation2` is created with `context.Background()`. Since Background has no values, the GLS fallback kicks in and returns the active `span1`, making it appear as a child of span1 instead of a root span. This is **Bug 1** (`context.Background()` sentinel).

---

**Symptom**: A span started with `context.Background()` (explicit intent: no parent) would incorrectly inherit the active GLS span as its parent.

**Root cause**: `SpanFromContext(context.Background())` would call `WrapContext(context.Background())`, producing a `glsContext` wrapping an empty context. When `.Value(activeSpanKey)` is called on it, the context chain returns `nil` (Background has no values), which triggers the GLS fallback — returning whatever span happens to be active. The span ends up with an unintended parent.

**Fix**: Early-return `(nil, false)` in `SpanFromContext` when `ctx == context.Background()`. `context.Background()` is the conventional "no context" sentinel; a caller using it is explicitly opting out of context-based trace propagation.

---

**Symptom**: When a function received an explicit `context.Context` carrying span X, but GLS held span Y (from an outer scope), span Y was used as the parent instead of X.

**Root cause**: `glsContext.Value` consulted GLS **first**, then fell back to the context chain. Any value present in GLS would silently override the explicitly-propagated one.

**Fix**: Reverse the lookup order in `glsContext.Value`: check the context chain first; fall back to GLS only if the chain returns `nil`. GLS is designed for implicit propagation through *un-instrumented* call sites that lack a `context.Context` — it must not override contexts that were explicitly propagated.

---

**Symptom**: Functions annotated with `//dd:span` whose first context-like argument was a custom type implementing `context.Context` (e.g. `*fiber.Ctx`) would fall back to `context.TODO()`, breaking trace propagation.

**Root cause**: The `//dd:span` injection template only looked for an argument of the **exact** type `context.Context`. If the function's parameter was a custom type that satisfied the interface, it was ignored, and the template fell back to `context.TODO()`.

**Fix**: Added an `ArgumentThatImplements "context.Context"` lookup path. When found, the argument is assigned with an explicit interface type declaration (`var ctx context.Context = $impl`) so that the subsequent reassignment from `StartSpanFromContext` (which returns `context.Context`) compiles correctly against the concrete type.

- [x] Changed code has unit tests for its functionality at or near 100% coverage.
- [x] New code is free of linting errors. You can check this by running `make lint` locally.
- [x] New code doesn't break existing tests. You can check this by running `make test` locally.
- [x] Add an appropriate team label so this PR gets put in the right place for the release notes.
- [x] All generated files are up to date. You can check this by running `make generate` locally.

Unsure? Have a question? Request a review!

Co-authored-by: kakkoyun <[email protected]>
Co-authored-by: dario.castane <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI Assisted AI/LLM assistance used in this PR (partially or fully) conventional-commit/fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants