What per-module observability in a monolith means and who needs it
Per-module observability treats the boundaries of a modular monolith as observability boundaries. The process still runs as one deployable with one service identity. Each module, such as billing, catalog or notifications, gets its own identity in telemetry. That identity is recorded explicitly wherever one module calls another. A trace through a checkout request then shows which module's code ran, how long each module took and where an error first appeared. The process is not treated as one opaque block.
The reasons are organizational as much as technical. When several teams share a deployable, a latency alert on the whole service tells nobody whose problem it is. With module identity on spans and metrics, three things become possible:
- Ownership. Dashboards and error budgets can be scoped to the code a team actually owns.
- Alert routing. A spike in failures inside the payments module can page the payments team, not a shared on-call rotation.
- Regression location. After a release, you can see that p99 latency rose in the search module's repository calls. You get that without splitting the monolith into services just to gain visibility.
Two groups benefit most. Teams working in a multi-team monolith get attribution they otherwise lack. Platform teams can define module conventions once and apply them to every module.
The approach fits only under certain conditions. Modules need clear APIs, meaning calls between them go through identifiable entry points and not through shared internals. There should also be more than one owning team. If one team owns everything, or the module boundaries exist only in a diagram, the attribution has nothing reliable to attach to.
Why service.name is the wrong place to encode modules
In OpenTelemetry, service.name is a resource attribute. A resource describes the entity producing telemetry: the deployable, its host, its runtime. It is set once per SDK instance and attached to every span, metric and log that instance emits. Encoding modules there means running several tracer and meter providers inside one process, each claiming to be a different service. Backends will take this at face value, and the results mislead:
- Service maps draw in-process function calls as network edges between "services", with latencies that include no network hop.
- Resource-level dashboards break because CPU, memory, GC and connection-pool metrics belong to the process. They end up under one fake service, are duplicated across all of them, or are missing from the rest.
- Correlation by service and instance stops matching. Logs, runtime metrics and deploy markers point at the real deployable, while spans point at invented ones.
- On-call engineers see five services and look for five deployments. In reality there is one artifact to roll back, one scaling policy and one process that crashed.
| Approach | What it gives you | Cost |
|---|---|---|
| Split the deployable | Real service.name per module, real topology |
A distributed system's operational and consistency costs |
| Infer modules from code paths or package names | No new instrumentation | Fragile mapping that breaks on refactors and shared libraries |
| Explicit module attribute plus per-module instrumentation scope | Accurate topology with module-level filtering | Discipline at boundaries and agreed attribute names |
The third approach is the only one that keeps the resource truthful and still lets you group telemetry by module.
How it works: instrumentation scopes, module attributes and boundary spans
Each module gets its own tracer and meter, named after the module, for example billing or inventory. That name becomes the instrumentation scope, which records which code emitted the telemetry. It is useful for filtering at the SDK level and debugging instrumentation, but backends expose it unevenly, and some barely index it. Treat the scope as provenance, not as the primary query dimension.
For querying, put the module on the data itself. Define one project-specific attribute, such as app.module, and set it on every span and metric point a module emits. Restrict it to a closed, documented set of values that maps one-to-one to code ownership: one value per directory or package with a named owning team. A closed set keeps metric cardinality predictable and makes alert routing a simple lookup. If someone invents billing-v2 ad hoc, both guarantees break, so validate values in code review or with a lint rule.
Create spans only at module boundaries: the public API or facade methods other modules call. Use span kind INTERNAL, because no network hop occurs. SERVER and CLIENT would make service maps draw edges that don't exist. Skip private helpers. Boundary spans are enough to attribute time without flooding traces.
The root span carries the entry module, the one that owns the route or job handler. Each child boundary span carries the executing module. This separation lets you ask both "which module's endpoints are slow" and "which module consumes the time."
Baggage can carry the entry module down the call stack, but it is optional. Baggage is propagated in headers to downstream services, and it is not automatically copied onto spans. Keep it to one short key, or skip it.
HTTP POST /orders
|
v
+--------------------------------------------------------------------+
| Monolith process |
| |
| +---------------------------+ root span (SERVER, HTTP entry) |
| | orders (entry module) | scope: orders |
| | route handler | app.module = orders |
| +---------------------------+ |
| | | |
| | facade call | facade call |
| v v |
| +--------------+ +----------------+ child spans, kind INTERNAL |
| | billing | | inventory | scope = module tracer |
| | Billing. | | Inventory. | app.module = executing |
| | charge() | | reserve() | |
| | | | | | |
| | v | | | |
| | helper(): | | | |
| | no span | | | |
| +--------------+ +----------------+ |
| |
| app.module values (closed, owner-mapped): orders|billing|inventory |
| baggage (optional): at most one short key for entry module |
+--------------------------------------------------------------------+
Inside the monolith, the root span records the entry module that owns the route. Each module's tracer emits an INTERNAL span at its public facade, tagged with the executing module from a closed app.module set, and private helpers get no spans.
Implementing boundary instrumentation and per-module metrics
Put the instrumentation in one place: a wrapper applied to each module's public facade, not scattered through business code. The wrapper does four things on every call:
- Obtains the tracer once per module, using the module's scope name (for example
shop.billing), so the instrumentation scope identifies the owning module. - Starts a span of kind
INTERNALnamed after the facade operation and setsapp.moduleon it. The scope says which code emitted the span, and the attribute is what backends can reliably filter and group on. - Records a duration histogram and increments an error counter. The only attributes are
app.module, the operation name, and an outcome. Never use user IDs or raw arguments here, because cardinality multiplies across every series. - Stores the current module in the in-process context, which is separate from baggage.
That context entry is how shared resources get attributed. Database spans already nest under the boundary span, so traces need no extra work. Metrics are different. A connection-pool wait histogram or a thread-pool queue gauge has no parent span, so its recording code should read the module from context and fall back to unattributed rather than dropping the attribute.
Work that does not arrive through a facade needs its own root. Message consumers should start a CONSUMER span, and scheduled tasks and background jobs an INTERNAL one. In each case, set app.module explicitly at registration time instead of inferring it later.
Finally, add a test that iterates over the module registry. It should assert that every module declares a scope name and that its app.module value appears in the allowed list. When someone adds a module and forgets either one, CI fails before the module ships with no telemetry.
import functools, time
from contextvars import ContextVar
from opentelemetry import metrics, trace
from opentelemetry.trace import SpanKind
# In-process module context (not baggage); shared resources read it.
current_module: ContextVar[str] = ContextVar("current_module", default="unattributed")
meter = metrics.get_meter("shop.observability")
duration = meter.create_histogram("app.module.duration", unit="s")
errors = meter.create_counter("app.module.errors")
pool_wait = meter.create_histogram("app.db.pool.wait", unit="s")
def module_facade(module: str):
tracer = trace.get_tracer(f"shop.{module}") # scope names the owning module
def decorate(fn):
op = fn.__name__ # low-cardinality: operation name, never arguments
@functools.wraps(fn)
def wrapper(*args, **kwargs):
token = current_module.set(module)
start, outcome = time.perf_counter(), "ok"
try:
with tracer.start_as_current_span(
op, kind=SpanKind.INTERNAL, attributes={"app.module": module}
):
return fn(*args, **kwargs)
except Exception:
outcome = "error"
errors.add(1, {"app.module": module, "operation": op})
raise
finally:
duration.record(time.perf_counter() - start,
{"app.module": module, "operation": op, "outcome": outcome})
current_module.reset(token)
return wrapper
return decorate
# Called by the DB pool: attribute the wait to the calling module.
def record_pool_wait(seconds: float) -> None:
pool_wait.record(seconds, {"app.module": current_module.get()})
A Python facade decorator that uses a per-module tracer scope, an INTERNAL span tagged with app.module, low-cardinality duration and error metrics, and a context variable so DB pool metrics are attributed to the calling module.
Building per-module dashboards, trace queries and alert routing
Once every span and measurement carries app.module, a per-module dashboard is a group-by on data you already collect. Two panels per module cover most needs. The first shows p95 and p99 latency from the boundary-call duration histogram. The second shows error rate as failed boundary calls divided by total boundary calls. Plot both grouped by app.module, and the allowed list keeps the series count predictable.
For trace queries, filter on spans where app.module equals the module you are investigating, not only on root spans. A request entering through orders may fail inside billing three calls deep, and a root-only filter hides that.
Alert routing needs a module-to-owner mapping. Keep it as a file next to the allowed module list and extend the CI check so every module has an owner. Alert rules then carry app.module as a label, and the alerting system maps that label to a team. This keeps ownership in one reviewed place and prevents silent orphaned alerts.
Alerts should come from metrics, not traces. Head-based sampling decides whether to keep a trace at the root, before anyone knows whether it will fail. As an illustrative estimate, assume a module fails 20 times per hour and traces are sampled at 1%. You would expect about 0.2 sampled failures per hour, too few to alert on. Metrics count every request before sampling applies.
| Signal | Sees every request | Best use |
|---|---|---|
| Module metrics | Yes | Dashboards, SLOs, alerting |
| Sampled traces | No | Diagnosing a specific slow or failed call |
When to avoid it and the limits of in-process attribution
Per-module attribution works best when module boundaries are coarse and stable. Several conditions weaken it or make it misleading.
Chatty call graphs
If modules call each other dozens of times per request, a boundary span on every crossing multiplies span volume per trace. That raises ingest cost and makes traces hard to read. In that case, instrument only entry points and calls that do I/O. Record fine-grained cross-module calls as counters, not spans.
Cardinality from derived names
Module names must come from a fixed, reviewed list. If a name is derived from a class path, route template or tenant, every refactor or new customer creates new time series. Metric backends charge for and degrade under that growth. Unknown values should map to a single fallback bucket.
Shared runtime effects
All modules share one runtime. A GC pause, a saturated thread pool or an exhausted connection pool adds latency to whichever module happens to be running at the time. That module's p99 rises even though another module caused the pressure.
CPU and memory are also process-level. You can attribute wall-clock time to a module, but not heap usage or CPU load. Attributing those requires a profiler, not metrics.
Coverage decay
Boundary instrumentation lives in code. When code is moved, renamed or called through a new path, spans and metrics can drop out without any error. A test that asserts instrumentation exists at each boundary helps catch this.
Implied independence
Per-module dashboards can suggest that modules are independent units. They are not. A bad change in one module still forces a rollback of the whole artifact, and a memory leak still restarts every module. Paging and runbooks should state this so responders do not treat an incident as contained to one module.
Impact on cost and engineering time
Boundary spans add volume in proportion to how often requests cross modules, so estimate it before enabling them everywhere.
Illustrative estimate (assumed numbers, not a benchmark)
Formula: stored spans per day = requests per second × boundary crossings per request × 86,400 × sampling rate
Assume 200 requests per second and 6 boundary crossings per request. That produces 1,200 extra spans per second, or about 103.7 million per day unsampled. At 10% head sampling, about 10.4 million are stored. At 1%, about 1.04 million are stored. The crossings-per-request figure drives the total, because every new boundary multiplies across all traffic. Metrics behave differently. Their cost follows the number of label combinations, not request volume, so a bounded module label is cheap and a free-form one is not.
| Cost | When you pay it | What it buys back |
|---|---|---|
| Boundary wrappers | Once, plus upkeep when signatures change | Triage starts at the right module instead of reading whole traces |
| Tests that assert module attributes exist | Once per boundary | Attribution does not silently disappear after refactors |
| Module registry with owners | Ongoing, small | Alerts route without humans deciding ownership mid-incident |
| Extra span storage | Continuously, scales with traffic | Per-module latency and error breakdowns |
The development cost is front-loaded and fixed. The savings recur on every incident and every ownership dispute.
Keep both costs predictable by drawing coarse boundaries: a handful of modules that match team ownership, not every package. Enforce them with the registry and a CI check that rejects unregistered module names. When the module set is fixed, span growth and label cardinality stay fixed too.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith observability per module, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.