OpenTelemetry metrics refactored: breaking the god-class curse

OpenTelemetry’s metrics API forces every instrument—counter, histogram, or gauge—into a single, process-wide singleton. That constraint makes the location of that singleton the central architectural question for any codebase using OTel. A recent refactor in a production system shows how the wrong answer can balloon into a maintenance nightmare, while the right pattern keeps things lean and safe.
The anti-pattern: one god class, many mixins
Teams often centralize all metrics in one global Metrics object, built at startup and passed around the application. It works as a singleton, but quickly mutates into a sprawling “god object.” Each subsystem simply tacks on another mixin—GeneralMetricsMixin, AcquisitionMetricsMixin, CameraMetricsMixin—and the class grows without bound. The deeper flaw shows up when mixins collide. Because Python’s method resolution order silently resolves name clashes, two mixins defining _duration_metric can override each other, quietly disabling one metric stream. Developers resorted to baroque work-arounds like explicitly calling GeneralMetricsMixin.measurement_stopped(self) to dodge the collision.
The replacement: per-subsystem modules
The new pattern dismantles the monolith. Each subsystem gets its own module with four parts: a scope constant, a frozen dataclass holding the expensive OTel handles, a memoized factory function for the singleton handles, and a lightweight wrapper class that can be instantiated per request.
import dataclasses as dc import functools from opentelemetry import metrics
_SCOPE = "myapp.api.prediction"
@dc.dataclass(frozen=True, slots=True) class _Instruments: failure_counter: metrics.Counter duration: metrics.Histogram
@functools.cache def _get_instruments() -> _Instruments: meter = metrics.get_meter(_SCOPE) return _Instruments( failure_counter=meter.create_counter(name=f"{meter.name}.failures"), duration=meter.create_histogram(name=f"{meter.name}.duration", unit="s"), )
class PredictionMetrics: def init(self, settings): self._instruments = _get_instruments() self._settings = settings
The dataclass cannot be mutated once created, the cache guarantees one process-wide instance, and the wrapper class keeps the API ergonomic for callers. No inheritance, no shadowed names, no hidden failures.
Why it matters
This refactor is more than a style preference; it turns OpenTelemetry’s singleton constraint from a liability into a feature. By scoping instruments to subsystems, teams reduce merge conflicts, eliminate silent metric loss, and make ownership obvious. The pattern scales cleanly across services and languages, and it aligns with the broader industry shift toward modular, observable architectures. For any team wrestling with sprawling metrics classes, the lesson is clear: when the runtime demands a singleton, design the code so that the singleton is the exception, not the entire application.
Source: DEV Community. AI-assisted editorial synthesis — TechnoExpress.

