Skip to content

Commit ca1a6da

Browse files
committed
Merge remote-tracking branch 'origin/main' into taegyunkim/prof-14213-timer-create
2 parents bbb15d1 + 9494dfd commit ca1a6da

94 files changed

Lines changed: 9787 additions & 5966 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
---
2+
3+
name: dependency-direction-analysis
4+
description: >
5+
Run the dependency direction detector against ddtrace and propose architectural
6+
fixes for any violations found. Use this when adding or refactoring modules
7+
under ddtrace/internal, ddtrace/contrib, or any product package, or when the
8+
detect_layering_violations CI job reports new violations on a PR.
9+
allowed-tools:
10+
- Bash
11+
- Read
12+
- Grep
13+
- Glob
14+
- Edit
15+
- TodoWrite
16+
---
17+
18+
# Dependency Direction Analysis Skill
19+
20+
This skill runs the dependency direction detector locally and proposes sound
21+
architectural fixes for any violations found. It enforces two rules:
22+
23+
1. **`ddtrace.internal` and `ddtrace.contrib` must not depend on product code.**
24+
They are shared foundation layers; every product depends on them, so a
25+
dependency running the other way creates hidden coupling and risks circular
26+
imports (see the `circular-import-analysis` skill).
27+
2. **Products must not depend on each other directly.** Tracing, AppSec, AI
28+
Guard, LLM Observability, Profiling, Dynamic Instrumentation, CI
29+
Visibility, Error Tracking, OpenFeature, OpenTelemetry, and Runtime metrics
30+
are each isolated: none of them are mandatory for a given dd-trace-py
31+
install, so one product can't assume another is present.
32+
33+
The guiding principle is the same as circular-import analysis: **Separation of
34+
Concerns**. Fixes must restructure ownership or add a decoupling layer, not
35+
paper over the problem with deferred imports.
36+
37+
## When to Use This Skill
38+
39+
- The `detect_layering_violations` CI job reports new violations on your PR.
40+
- You are adding a new module, or an import, that crosses from `ddtrace/internal`,
41+
`ddtrace/contrib`, or one product package into another product package.
42+
- You are adding a brand new top-level `ddtrace/<x>` package or module and the
43+
CI job reports it as an uncovered/uncategorized top-level module.
44+
- You are refactoring and want to verify you haven't introduced a new violation.
45+
46+
## Running the Analysis
47+
48+
```bash
49+
uv run --script scripts/import-analysis/layers.py analyze violations.json
50+
```
51+
52+
This writes the results to `violations.json` and prints a summary to stdout.
53+
Requires `uv` on `PATH` (`brew install uv` or `pip install uv`). The output has
54+
two top-level keys:
55+
56+
```json
57+
{
58+
"violations": [ ... ],
59+
"uncovered": [ "ddtrace.newthing" ]
60+
}
61+
```
62+
63+
`violations` entries look like:
64+
```json
65+
{
66+
"from": "ddtrace.internal.tracemethods",
67+
"to": "ddtrace.trace",
68+
"from_zone": "internal-core",
69+
"to_zone": "product:tracing",
70+
"score": 139,
71+
"in_tangle": true
72+
}
73+
```
74+
75+
- `from` / `to` — the two modules the violating import connects.
76+
- `from_zone` / `to_zone` — which side of the rule they fall on (`internal-core`,
77+
`contrib`, or `product:<name>`).
78+
- `score` — how bad this specific edge is (see "Severity scoring" below).
79+
- `in_tangle` — the imported module is also part of a strongly connected
80+
component larger than one module, i.e. this violation is compounding an
81+
existing circular-import problem, not just crossing a boundary once.
82+
83+
`uncovered` lists direct children of the `ddtrace` package root (packages or
84+
`.py` modules) that are neither a key in `layers.json`'s `zones` map nor listed
85+
in `foundation.top_level`. This is what catches a new top-level submodule that
86+
was added without anyone deciding which zone it belongs to — without it, a new
87+
package like `ddtrace/newproduct/` would silently be treated as exempt
88+
foundation code and get zero dependency-direction enforcement. Unlike
89+
violations, a new entry here always fails CI on `compare` (see below),
90+
regardless of severity — it represents a config gap, not a graded issue.
91+
92+
To compare against the base branch the way CI does (new vs. pre-existing vs.
93+
worsened vs. removed, for both violations and uncovered modules):
94+
95+
```bash
96+
uv run --script scripts/import-analysis/layers.py compare violations-base.json violations-pr.json
97+
```
98+
99+
Clean up afterwards:
100+
```bash
101+
rm violations.json violations-base.json violations-pr.json
102+
```
103+
104+
## Zone Configuration
105+
106+
Zones are defined in `scripts/import-analysis/layers.json`, keyed by module
107+
prefix (longest match wins), so a product's own `ddtrace.internal.<product>`
108+
subpackage (e.g. `ddtrace.internal.appsec`) is carved out of the
109+
`ddtrace.internal` catch-all and treated as part of that product, not as
110+
foundation code. Modules with no matching prefix (e.g. `ddtrace.ext`,
111+
`ddtrace.propagation`, `ddtrace.vendor`) are unclassified "foundation" code and
112+
are exempt from every rule, both as importer and as imported module.
113+
114+
`layers.json` also has an `exceptions` list of zone-pairs that are deliberately
115+
exempt from the rules — this is how we record a considered decision without
116+
touching detection logic. For example, `ddtrace/contrib/*` modules are tracer
117+
integrations by design, so `contrib -> product:tracing` is listed as an
118+
exception rather than flagged on every run.
119+
120+
**Only add an exception when the dependency is intentional and durable** — not
121+
as a shortcut to make CI pass. If you're unsure whether an edge should be an
122+
exception or a bug, ask; this is a business/architecture decision, not
123+
something to infer from the code.
124+
125+
### Fixing a new "uncovered top-level module" finding
126+
127+
When the CI job (or `analyze`) reports a new entry under `uncovered`, someone
128+
added a new direct child of `ddtrace/` (a package or a `.py` module) that
129+
`layers.json` doesn't know about yet. Resolve it by editing
130+
`scripts/import-analysis/layers.json`:
131+
132+
- If it's a new product (mandatory-or-not feature area, isolated from other
133+
products), add it to `zones` as `"ddtrace.<name>": "product:<name>"`, and
134+
add its `ddtrace.internal.<name>` counterpart too if one exists.
135+
- If it's shared foundation code that everything may depend on and that
136+
itself has no restrictions (like `ddtrace.ext` or `ddtrace.propagation`),
137+
add it to `foundation.top_level`.
138+
- If it's a carve-out of an existing product (e.g. a new
139+
`ddtrace.internal.<product>` subpackage), map it to that product's zone
140+
rather than leaving it to fall through to `internal-core`.
141+
142+
Don't add it to `foundation.top_level` just to silence the check — that
143+
defeats the point of the coverage check. Ask if it's unclear which zone fits.
144+
145+
## Severity Scoring
146+
147+
Each violation's `score` combines three structural signals (no git history
148+
involved):
149+
150+
- **Rule weight**`internal-core`/`contrib` violations start higher (3) than
151+
product-vs-product violations (1), because foundation code reaching upward
152+
is a worse inversion than two peers leaking into each other.
153+
- **Afferent coupling of the target** (`ca` from betsy's `ModuleMetrics`) — how
154+
many other modules already depend on the module being imported. A violation
155+
that reaches into a heavily-relied-upon module has a bigger blast radius to
156+
eventually unwind.
157+
- **Cycle bonus (+5)** — added when the imported module's `nccd` (from betsy)
158+
is greater than 1.0, i.e. it's already part of an import tangle. Fixing the
159+
layering violation first often makes the tangle easier to break too.
160+
161+
Use the score to prioritize: fix the highest-scoring violations first,
162+
especially any marked `in_tangle`.
163+
164+
## Architectural Patterns for Fixing Violations
165+
166+
> **Never use deferred imports (`import x` inside a function body) as a fix.**
167+
> They hide the structural problem and impose a runtime cost on every call.
168+
169+
### Understand the edge first
170+
171+
```bash
172+
# What exactly does <from> import from <to>?
173+
grep -n "^import ddtrace\|^from ddtrace" <path/to/from/module>.py
174+
```
175+
176+
Identify the exact names crossing the boundary before choosing a fix — often
177+
only a small fraction of the target module is actually needed.
178+
179+
---
180+
181+
### Pattern 1 — Core event bus (for `contrib` -> product violations)
182+
183+
**When to use:** A contrib integration wants to notify or be observed by a
184+
product (this is the most common shape for `contrib -> product:X`
185+
violations). This is the documented pattern in
186+
`.cursor/rules/isolated-responsibility.mdc`.
187+
188+
The contrib patch dispatches an event; it does not import the product:
189+
190+
```python
191+
from ddtrace.internal import core
192+
193+
core.dispatch(f"{event}.before", (kwargs,), allow_raise=True)
194+
resp = func(*args, **kwargs)
195+
core.dispatch(f"{event}.after", (kwargs, resp), allow_raise=True)
196+
```
197+
198+
The product registers a listener, guarded by its own enable flag, inside its
199+
own package — not inside `contrib`:
200+
201+
```python
202+
from ddtrace.internal import core
203+
204+
def load_my_product():
205+
core.on("some.integration.before", _before_handler)
206+
```
207+
208+
Neither side imports the other; `ddtrace.internal.core` is foundation code
209+
both may depend on.
210+
211+
---
212+
213+
### Pattern 2 — Dependency inversion (for `internal-core` -> product violations)
214+
215+
**When to use:** `ddtrace.internal` needs to call into a product, but the
216+
product also needs to be the one driving behavior (e.g. registering a hook,
217+
supplying a callback).
218+
219+
Define a `Protocol` or abstract base inside `ddtrace.internal` (or a small
220+
neutral module); the product implements it and registers itself explicitly.
221+
`ddtrace.internal` depends on the abstraction, never on the concrete product
222+
package.
223+
224+
---
225+
226+
### Pattern 3 — Extract shared types into a third, unclassified module
227+
228+
**When to use:** Two zones share a data type, constant, or protocol that both
229+
legitimately need, but neither should own.
230+
231+
Create a thin module outside both zones' prefixes (so it's unclassified
232+
foundation code, e.g. `ddtrace._types` or similar) containing only the shared
233+
contract. Both sides import from it; neither imports from the other.
234+
235+
---
236+
237+
### Pattern 4 — Move the code to the zone that owns it
238+
239+
**When to use:** The violation exists because a function/class ended up in
240+
the wrong package. This is the simplest and often best fix.
241+
242+
If `ddtrace.internal.tracemethods` calls something that conceptually belongs
243+
to the tracing product, move it into `ddtrace.trace`/`ddtrace._trace` so the
244+
dependency direction reverses: the product depends on internal-core (allowed),
245+
not the other way round.
246+
247+
---
248+
249+
### Pattern 5 — Question whether the target should be foundation code
250+
251+
**When to use:** A product-to-product violation involves a genuinely
252+
general-purpose utility that happens to live inside a product package (e.g.
253+
a formatting helper under `ddtrace.trace` that other products also want).
254+
255+
Move the utility down into `ddtrace.internal` (or an unclassified module) so
256+
every product can depend on it without depending on each other. Don't do this
257+
for anything that's conceptually part of the product's public contract (e.g.
258+
`Tracer`, `Span`) — those stay put, and the dependency on them should go
259+
through Pattern 1 or 2 instead.
260+
261+
---
262+
263+
## Decision checklist before proposing a fix
264+
265+
1. **Identify the exact cross-boundary names** — grep the violating file.
266+
2. **Classify the relationship:**
267+
- Contrib notifying/observing a product → Pattern 1 (core event bus)
268+
- internal-core needs product behavior → Pattern 2 (dependency inversion)
269+
- Shared data type/constant → Pattern 3 (extract)
270+
- Wrong home for the code → Pattern 4 (move)
271+
- Misplaced general-purpose utility → Pattern 5 (relocate to foundation)
272+
3. **Consider whether this is actually an intentional, durable dependency**
273+
if so, propose adding it to `layers.json`'s `exceptions` list instead of
274+
restructuring code, but say so explicitly and explain why; this is a call
275+
for the humans reviewing the PR, not something to decide unilaterally.
276+
4. **Verify** by re-running `uv run --script scripts/import-analysis/layers.py analyze violations.json`
277+
after the change and confirming the violation is gone (or, if compared
278+
against a saved base snapshot, that it doesn't appear as new).
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
name: Generated change patch
2+
description: Create or safely apply a patch containing generated version updates
3+
4+
inputs:
5+
mode:
6+
description: Whether to create or apply the patch
7+
required: true
8+
patch-file:
9+
description: Path to the patch artifact
10+
required: true
11+
profile:
12+
description: Allowlist of generated paths to enforce
13+
required: true
14+
15+
outputs:
16+
changed:
17+
description: Whether the generated patch contains changes
18+
value: ${{ steps.patch.outputs.changed }}
19+
20+
runs:
21+
using: composite
22+
steps:
23+
# AIDEV-NOTE: Keep patch application and validation in this single shell step,
24+
# and do not add repository-mutating steps between this action and PR creation.
25+
# The patch is untrusted data and must not replace code that is executed later.
26+
- id: patch
27+
shell: bash
28+
env:
29+
MODE: ${{ inputs.mode }}
30+
PATCH_FILE: ${{ inputs.patch-file }}
31+
PROFILE: ${{ inputs.profile }}
32+
run: |
33+
set -euo pipefail
34+
35+
validate_path() {
36+
local path=$1
37+
38+
case "$PROFILE" in
39+
package-versions)
40+
if [[ "$path" =~ ^\.riot/requirements/[^/]+\.txt$ ]] ||
41+
[[ "$path" == "supported_versions.json" ]] ||
42+
[[ "$path" == "scripts/integration_registry/registry.yaml" ]]; then
43+
return
44+
fi
45+
;;
46+
supported-versions)
47+
if [[ "$path" == "supported_versions.json" ]]; then
48+
return
49+
fi
50+
;;
51+
*)
52+
printf '::error::Unknown generated-change profile: %q\n' "$PROFILE"
53+
exit 1
54+
;;
55+
esac
56+
57+
printf '::error::Unexpected generated change: %q\n' "$path"
58+
exit 1
59+
}
60+
61+
validate_mode() {
62+
local path=$1
63+
local mode
64+
mode=$(git ls-files -s -- "$path" | awk '{print $1}')
65+
if [[ -n "$mode" && "$mode" != "100644" ]]; then
66+
printf '::error::Unexpected file mode %q for %q\n' "$mode" "$path"
67+
exit 1
68+
fi
69+
}
70+
71+
if [[ "$MODE" == "create" ]]; then
72+
while IFS= read -r -d '' path; do
73+
validate_path "$path"
74+
git add --intent-to-add -- "$path"
75+
done < <(git ls-files --others --exclude-standard -z)
76+
77+
if git diff --quiet HEAD --; then
78+
echo "changed=false" >> "$GITHUB_OUTPUT"
79+
exit 0
80+
fi
81+
82+
while IFS= read -r -d '' path; do
83+
validate_path "$path"
84+
validate_mode "$path"
85+
done < <(git diff --name-only -z HEAD --)
86+
87+
# Keep generated artifacts clean before they cross into the PR-creation job.
88+
git diff --check HEAD --
89+
git diff --binary --full-index --no-ext-diff HEAD -- > "$PATCH_FILE"
90+
echo "changed=true" >> "$GITHUB_OUTPUT"
91+
exit 0
92+
fi
93+
94+
if [[ "$MODE" == "apply" ]]; then
95+
git apply --check --index "$PATCH_FILE"
96+
git apply --index "$PATCH_FILE"
97+
98+
changed=false
99+
while IFS= read -r -d '' path; do
100+
changed=true
101+
validate_path "$path"
102+
validate_mode "$path"
103+
done < <(git diff --cached --name-only -z)
104+
105+
if [[ "$changed" != "true" ]]; then
106+
echo "::error::Generated patch contains no changes"
107+
exit 1
108+
fi
109+
110+
git diff --cached --check
111+
echo "changed=true" >> "$GITHUB_OUTPUT"
112+
exit 0
113+
fi
114+
115+
printf '::error::Unknown generated-change mode: %q\n' "$MODE"
116+
exit 1

0 commit comments

Comments
 (0)