Skip to content

Commit 0ed515e

Browse files
authored
ci: add dependency direction analysis (#19563)
## Description We add a CI jobs that performs dependency direction analysis to detect any new import direction violation in new contributed code. Existing violations are also reported for awareness. Co-authored-by: gabriele.tornetta <gabriele.tornetta@datadoghq.com>
1 parent ccb7951 commit 0ed515e

5 files changed

Lines changed: 716 additions & 0 deletions

File tree

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).

.gitlab-ci.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,47 @@ detect_circular_imports:
424424
- ./post-pr-comment.sh "Circular import analysis" cycles_report.txt
425425
- exit $COMPARE_EXIT
426426

427+
detect_layering_violations:
428+
stage: tests
429+
needs: []
430+
extends: .testrunner
431+
rules:
432+
- if: $RELEASE_ALLOW_TEST_FAILURES == "true"
433+
allow_failure: true
434+
- allow_failure: false
435+
id_tokens:
436+
DDOCTOSTS_ID_TOKEN:
437+
aud: dd-octo-sts
438+
script:
439+
- pip install uv
440+
- cd ..
441+
# Keep copies of PR scripts (and their config) so they survive the git checkout of the base branch below
442+
- cp dd-trace-py/scripts/import-analysis/layers.py layers.py
443+
- cp dd-trace-py/scripts/import-analysis/layers.json layers.json
444+
- cp dd-trace-py/.gitlab/scripts/post-pr-comment.sh post-pr-comment.sh
445+
- uv run --script layers.py analyze --root dd-trace-py/ddtrace layers-pr.json
446+
- cat layers-pr.json
447+
- |
448+
cd dd-trace-py
449+
if [ -z "${GH_TOKEN:-}" ]; then
450+
export GH_TOKEN=$(dd-octo-sts token --scope DataDog/dd-trace-py --policy gitlab.github-access.read)
451+
fi
452+
BASE_BRANCH=$(.gitlab/scripts/resolve-base-branch.sh)
453+
git fetch origin "${BASE_BRANCH}"
454+
git checkout FETCH_HEAD
455+
cd ..
456+
# Analyse the base branch ddtrace using the PR version of layers.py/layers.json (has uv metadata)
457+
- uv run --script layers.py analyze --root dd-trace-py/ddtrace layers-base.json
458+
- cat layers-base.json
459+
- |
460+
set +e
461+
uv run --script layers.py compare layers-base.json layers-pr.json > layers_report.txt
462+
COMPARE_EXIT=$?
463+
set -e
464+
- cat layers_report.txt
465+
- ./post-pr-comment.sh "Dependency direction analysis" layers_report.txt
466+
- exit $COMPARE_EXIT
467+
427468
check_added_file_size:
428469
stage: tests
429470
needs: []

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ Use the Skill tool to invoke these. **Always prefer skills over raw commands.**
114114
| `find-cpython-usage` | Investigating CPython API dependencies or adding a new Python version. |
115115
| `compare-cpython-versions` | Comparing CPython source between two Python versions. |
116116
| `circular-import-analysis` | Detecting circular imports and proposing architectural fixes. Use when the CI job reports new cycles, or proactively when adding/moving modules. |
117+
| `dependency-direction-analysis` | Detecting `ddtrace.internal`/`ddtrace.contrib` depending on product code, or products depending on each other, and proposing fixes. Use when the `detect_layering_violations` CI job reports new violations, or proactively when adding/moving modules across those boundaries. |
117118
| `review-ci` | Reviewing CI results for a branch/commit/PR. Use when CI is failing or to understand what's blocking a PR from merging. Requires Datadog MCP. |
118119
| `run-benchmarks` | Running performance benchmarks to measure the impact of code changes. Use when touching performance-sensitive code or asked about perf impact. |
119120
| `debug-build-times` | Diagnosing slow base venv builds or warm rebuild regressions. Use when ext_cache isn't saving time or when CI venv builds are unexpectedly slow. |

0 commit comments

Comments
 (0)