Run the dependency direction detector against ddtrace and propose architectural fixes for any violations found. Use this when adding or refactoring modules under ddtrace/internal, ddtrace/contrib, or any product package, or when the detect_layering_violations CI job reports new violations on a PR.
This skill runs the dependency direction detector locally and proposes sound architectural fixes for any violations found. It enforces two rules:
ddtrace.internal and ddtrace.contrib must not depend on product code.
They are shared foundation layers; every product depends on them, so a
dependency running the other way creates hidden coupling and risks circular
imports (see the circular-import-analysis skill).The guiding principle is the same as circular-import analysis: Separation of Concerns. Fixes must restructure ownership or add a decoupling layer, not paper over the problem with deferred imports.
detect_layering_violations CI job reports new violations on your PR.ddtrace/internal,
ddtrace/contrib, or one product package into another product package.ddtrace/<x> package or module and the
CI job reports it as an uncovered/uncategorized top-level module.uv run --script scripts/import-analysis/layers.py analyze violations.jsonThis writes the results to violations.json and prints a summary to stdout.
Requires uv on PATH (brew install uv or pip install uv). The output has
two top-level keys:
{
"violations": [ ... ],
"uncovered": [ "ddtrace.newthing" ]
}violations entries look like:
{
"from": "ddtrace.internal.tracemethods",
"to": "ddtrace.trace",
"from_zone": "internal-core",
"to_zone": "product:tracing",
"score": 139,
"in_tangle": true
}from / to — the two modules the violating import connects.from_zone / to_zone — which side of the rule they fall on (internal-core,
contrib, or product:<name>).score — how bad this specific edge is (see "Severity scoring" below).in_tangle — the imported module is also part of a strongly connected
component larger than one module, i.e. this violation is compounding an
existing circular-import problem, not just crossing a boundary once.uncovered lists direct children of the ddtrace package root (packages or
.py modules) that are neither a key in layers.json's zones map nor listed
in foundation.top_level. This is what catches a new top-level submodule that
was added without anyone deciding which zone it belongs to — without it, a new
package like ddtrace/newproduct/ would silently be treated as exempt
foundation code and get zero dependency-direction enforcement. Unlike
violations, a new entry here always fails CI on compare (see below),
regardless of severity — it represents a config gap, not a graded issue.
To compare against the base branch the way CI does (new vs. pre-existing vs. worsened vs. removed, for both violations and uncovered modules):
uv run --script scripts/import-analysis/layers.py compare violations-base.json violations-pr.jsonClean up afterwards:
rm violations.json violations-base.json violations-pr.jsonZones are defined in scripts/import-analysis/layers.json, keyed by module
prefix (longest match wins), so a product's own ddtrace.internal.<product>
subpackage (e.g. ddtrace.internal.appsec) is carved out of the
ddtrace.internal catch-all and treated as part of that product, not as
foundation code. Modules with no matching prefix (e.g. ddtrace.ext,
ddtrace.propagation, ddtrace.vendor) are unclassified "foundation" code and
are exempt from every rule, both as importer and as imported module.
layers.json also has an exceptions list of zone-pairs that are deliberately
exempt from the rules — this is how we record a considered decision without
touching detection logic. For example, ddtrace/contrib/* modules are tracer
integrations by design, so contrib -> product:tracing is listed as an
exception rather than flagged on every run.
Only add an exception when the dependency is intentional and durable — not as a shortcut to make CI pass. If you're unsure whether an edge should be an exception or a bug, ask; this is a business/architecture decision, not something to infer from the code.
When the CI job (or analyze) reports a new entry under uncovered, someone
added a new direct child of ddtrace/ (a package or a .py module) that
layers.json doesn't know about yet. Resolve it by editing
scripts/import-analysis/layers.json:
zones as "ddtrace.<name>": "product:<name>", and
add its ddtrace.internal.<name> counterpart too if one exists.ddtrace.ext or ddtrace.propagation),
add it to foundation.top_level.ddtrace.internal.<product> subpackage), map it to that product's zone
rather than leaving it to fall through to internal-core.Don't add it to foundation.top_level just to silence the check — that
defeats the point of the coverage check. Ask if it's unclear which zone fits.
Each violation's score combines three structural signals (no git history
involved):
internal-core/contrib violations start higher (3) than
product-vs-product violations (1), because foundation code reaching upward
is a worse inversion than two peers leaking into each other.ca from betsy's ModuleMetrics) — how
many other modules already depend on the module being imported. A violation
that reaches into a heavily-relied-upon module has a bigger blast radius to
eventually unwind.nccd (from betsy)
is greater than 1.0, i.e. it's already part of an import tangle. Fixing the
layering violation first often makes the tangle easier to break too.Use the score to prioritize: fix the highest-scoring violations first,
especially any marked in_tangle.
Never use deferred imports (
import xinside a function body) as a fix. They hide the structural problem and impose a runtime cost on every call.
# What exactly does <from> import from <to>?
grep -n "^import ddtrace\|^from ddtrace" <path/to/from/module>.pyIdentify the exact names crossing the boundary before choosing a fix — often only a small fraction of the target module is actually needed.
contrib -> product violations)When to use: A contrib integration wants to notify or be observed by a
product (this is the most common shape for contrib -> product:X
violations). This is the documented pattern in
.cursor/rules/isolated-responsibility.mdc.
The contrib patch dispatches an event; it does not import the product:
from ddtrace.internal import core
core.dispatch(f"{event}.before", (kwargs,), allow_raise=True)
resp = func(*args, **kwargs)
core.dispatch(f"{event}.after", (kwargs, resp), allow_raise=True)The product registers a listener, guarded by its own enable flag, inside its
own package — not inside contrib:
from ddtrace.internal import core
def load_my_product():
core.on("some.integration.before", _before_handler)Neither side imports the other; ddtrace.internal.core is foundation code
both may depend on.
internal-core -> product violations)When to use: ddtrace.internal needs to call into a product, but the
product also needs to be the one driving behavior (e.g. registering a hook,
supplying a callback).
Define a Protocol or abstract base inside ddtrace.internal (or a small
neutral module); the product implements it and registers itself explicitly.
ddtrace.internal depends on the abstraction, never on the concrete product
package.
When to use: Two zones share a data type, constant, or protocol that both legitimately need, but neither should own.
Create a thin module outside both zones' prefixes (so it's unclassified
foundation code, e.g. ddtrace._types or similar) containing only the shared
contract. Both sides import from it; neither imports from the other.
When to use: The violation exists because a function/class ended up in the wrong package. This is the simplest and often best fix.
If ddtrace.internal.tracemethods calls something that conceptually belongs
to the tracing product, move it into ddtrace.trace/ddtrace._trace so the
dependency direction reverses: the product depends on internal-core (allowed),
not the other way round.
When to use: A product-to-product violation involves a genuinely
general-purpose utility that happens to live inside a product package (e.g.
a formatting helper under ddtrace.trace that other products also want).
Move the utility down into ddtrace.internal (or an unclassified module) so
every product can depend on it without depending on each other. Don't do this
for anything that's conceptually part of the product's public contract (e.g.
Tracer, Span) — those stay put, and the dependency on them should go
through Pattern 1 or 2 instead.
layers.json's exceptions list instead of
restructuring code, but say so explicitly and explain why; this is a call
for the humans reviewing the PR, not something to decide unilaterally.uv run --script scripts/import-analysis/layers.py analyze violations.json
after the change and confirming the violation is gone (or, if compared
against a saved base snapshot, that it doesn't appear as new).57aff59
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.