observability · ·

Stop Wrapping OpenTelemetry: The Instrumentation Anti-Pattern

Wrapping the OpenTelemetry API in a helper class feels tidy, but it wrecks performance and onboarding. Here is why it is an anti-pattern and what to do instead.

Stop Wrapping OpenTelemetry: The Instrumentation Anti-Pattern

Do not wrap the OpenTelemetry API in your own helper class. It looks like good engineering hygiene, but a MetricsHelper.RecordLatency(name, value) facade throws away the performance model OTel is built on, hides the semantic conventions your backend relies on, and teaches your team an abstraction they cannot take anywhere else. The API is already the abstraction. Hold a direct reference to a tracer or an instrument, create it once, and record against it. The only thing worth writing yourself is a thin bootstrap that configures providers and exporters, and that is not the same thing as wrapping the API surface.

The OpenTelemetry maintainers made this argument directly in a 2026 post titled “Don’t Wrap OpenTelemetry”. If you have ever felt the itch to hide Meter and Counter behind something friendlier, this is worth reading twice, because the itch is real and the fix is not the wrapper you are about to write.

The pattern that feels responsible

Here is the code almost every team writes at some point. You have metrics scattered across the codebase, so you centralize them:

public static class MetricsHelper
{
    private static readonly Meter Meter = new("MyApp");
    private static readonly ConcurrentDictionary<string, Histogram<double>> Histograms = new();

    public static void RecordHistogram(string name, double value, params KeyValuePair<string, object?>[] tags)
    {
        var histogram = Histograms.GetOrAdd(name, n => Meter.CreateHistogram<double>(n));
        histogram.Record(value, tags);
    }
}

Then callers do MetricsHelper.RecordHistogram("http.server.duration", elapsed, tag) and nobody has to think about meters or instrument lifecycles. It compiles, it works, and it feels like the responsible thing to do. That feeling is the trap.

The same shape shows up in every language. In Go it becomes a package with a func Record(name string, v float64) that looks up an instrument in a map behind a mutex. In Rust it is a Mutex<HashMap<String, Histogram>>. In Python it is a decorator that resolves the instrument by string on every call. The details differ; the mistake is identical.

Why the wrapper is worse than the boilerplate it replaces

It destroys the hot path

OpenTelemetry instruments are designed to be created once and held by reference. When you record against a Histogram<double> you already hold, the recording is close to a direct array write into a pre-aggregated bucket. That is the whole point of the metrics design: the expensive work happens at instrument creation, not at record time. The OpenTelemetry metrics spec is explicit that instruments are long-lived and reusable.

Your wrapper reintroduces the cost you were promised you would never pay. GetOrAdd on a ConcurrentDictionary still hashes the string key, walks a bucket, and compares on every single measurement. The Rust version acquires a lock. Under real concurrency, that lock becomes a serialization point in your hottest code, the request path you added metrics to observe in the first place. You are now measuring the overhead of your own measurement layer.

You can prove it to yourself with a microbenchmark:

$ go test -bench=BenchmarkRecord -benchmem ./telemetry/...

Run it once with a direct instrument reference and once with a map lookup per call. The lookup version allocates more and slows down as contention rises. The direct version stays flat. That gap is not theoretical; it is the difference between an observability layer that disappears under load and one that shows up in your own flame graphs.

Your team learns the wrapper, not OpenTelemetry

Spend six months calling MetricsHelper.RecordHistogram and you have not learned OpenTelemetry. You have learned an internal API that exists nowhere else. When an engineer moves to a service that uses the SDK directly, or joins from a company that does, their knowledge does not transfer in either direction. Every new hire learns a private dialect, and the public documentation, the examples, the Stack Overflow answers, and the vendor guides all describe an API your codebase pretends does not exist.

OpenTelemetry was built to be the stable, portable, user-facing abstraction. That is the entire reason it is a standard and not a vendor library. Wrapping it does not simplify it. It converts a skill your engineers can carry between jobs into tribal knowledge that dies when they leave.

You silently lose semantic conventions

This is the failure that costs the most and shows up the latest. OpenTelemetry ships semantic conventions: agreed attribute names like http.request.method, server.address, and db.system that backends, dashboards, and alerting rules already understand. When you record through a generic Record(name, value, tags) wrapper, nothing enforces those names. One service emits http.method, another emits method, a third emits httpMethod, and your queries silently return partial data. No error, no failed build, just dashboards that are quietly wrong.

Grafana’s own OpenTelemetry best practices guide leads with consistent semantic conventions for exactly this reason: the value of the data is in its shape, and a stringly-typed wrapper is a machine for corrupting that shape.

You now own an API surface you did not want

Every method on your wrapper is now a contract. Someone will want a gauge, so you add RecordGauge. Someone needs exemplars, so you thread those through. Someone needs a different aggregation, so you add a parameter. Six months in, you have reimplemented a worse, less documented version of the Metrics API, and you own every bug in it. The OpenTelemetry SDK is maintained by a large community and battle-tested across thousands of deployments. Your wrapper is maintained by whoever last touched it.

What to do instead

The urge behind the wrapper is legitimate. You do not want instrument creation and provider setup smeared across your codebase. Good. Separate the two concerns the wrapper conflates: configuration, which you should centralize, and the API surface, which you should not.

Hold instruments as fields, create them once

Create your tracer or meter and its instruments at construction time, store them, and record against the stored reference. This is the pattern the OpenTelemetry Go instrumentation docs recommend: one tracer per package, instruments created once.

package payments

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/metric"
)

type Service struct {
    duration metric.Float64Histogram
}

func NewService() (*Service, error) {
    meter := otel.Meter("myapp/payments")
    duration, err := meter.Float64Histogram(
        "payment.processing.duration",
        metric.WithUnit("s"),
        metric.WithDescription("time to process a payment"),
    )
    if err != nil {
        return nil, err
    }
    return &Service{duration: duration}, nil
}

func (s *Service) Charge(ctx context.Context, amount float64) error {
    start := time.Now()
    defer func() {
        s.duration.Record(ctx, time.Since(start).Seconds())
    }()
    // real work here
    return nil
}

The instrument is a field. There is no per-call lookup, no lock, no string-keyed cache. The .Record call is the fast path OTel promises, and the instrument name and unit live next to the code that owns them.

The .NET shape is the same idea. Give each subsystem a Meter and create instruments as readonly fields:

public sealed class PaymentService
{
    private readonly Histogram<double> _duration;

    public PaymentService(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("MyApp.Payments");
        _duration = meter.CreateHistogram<double>(
            "payment.processing.duration", unit: "s");
    }

    public void Record(double seconds) => _duration.Record(seconds);
}

Note what is missing: there is no ConcurrentDictionary, no GetOrAdd, and no generic string-keyed method. The .NET metrics best practices spell this out. Instruments are created once and reused; dynamic values become attributes on Record, never new instruments.

A thin bootstrap is fine, and it is not a wrapper

Centralizing setup is good. A single function that builds your TracerProvider and MeterProvider, wires the OTLP exporter, sets the resource attributes, and returns them is not wrapping the API. It touches the SDK, which callers should not, and it leaves the API alone, which callers should use directly.

func InitTelemetry(ctx context.Context) (func(context.Context) error, error) {
    exporter, err := otlpmetricgrpc.New(ctx)
    if err != nil {
        return nil, err
    }
    provider := metric.NewMeterProvider(
        metric.WithReader(metric.NewPeriodicReader(exporter)),
    )
    otel.SetMeterProvider(provider)
    return provider.Shutdown, nil
}

Call this once at startup. Everything else uses otel.Meter(...) and otel.Tracer(...) directly. That is the line: configure through your own code, instrument through OTel’s.

The one exception, and its cost

There is exactly one case where a thin adapter earns its keep: a genuine boundary you do not control. If you are mid-migration off a legacy vendor SDK and need both to emit during the cutover, a temporary shim is defensible. So is a small facade in a shared library that must support consumers on different OTel versions.

Both come with a cost you should say out loud. The moment your adapter exposes record(name, value) instead of typed instruments, you have signed up for the string-keyed lookup, the lost conventions, and the maintenance burden. Keep the shim as thin as possible, treat it as debt with a deletion date, and never let it grow gauge-by-gauge into the full wrapper you were trying to avoid.

The decision in one table

You want to… Wrapper answer Correct answer
Avoid scattered provider setup Wrap the API Thin InitTelemetry bootstrap
Reuse instruments efficiently String-keyed cache per call Instrument as a struct field, created once
Keep attribute names consistent Hope callers agree Use semantic conventions directly
Support two OTel versions in a library Full facade Minimal, time-boxed shim
Teach the team observability Internal dialect The public OTel API

Bottom line

The wrapper solves a real problem in the worst possible way. It answers “our telemetry setup is scattered” by hiding the one part that should stay visible, the API, and leaving the part that should be centralized, the configuration, wherever it happened to land. Flip it. Centralize the bootstrap, expose the instruments, and let your engineers write against the standard everyone else writes against.

If you are building out observability more broadly, the same discipline applies to the pipeline around your code. See our guides on setting up LLM observability with OpenTelemetry, managing OTel Collectors at scale with OpAMP, and choosing between the OpenTelemetry Collector and Grafana Alloy. When you compare backends later, a Datadog versus AWS observability breakdown shows why clean, convention-following telemetry pays off no matter where it lands.

Write against the API. Delete the wrapper.

Get the next article in your inbox

Practical DevOps tips, tutorials, and guides. No spam, unsubscribe anytime.