July 13, 2026 · Mihai · 10 min read

Why Wrapping OpenTelemetry Is a Trap: What We've Seen in 11 Enterprise Migrations

The OpenTelemetry project published a post this month with a blunt title: "Don't Wrap OpenTelemetry." We've been saying the same thing to clients for two years, usually after inheriting a codebase where a well-intentioned platform team built an internal telemetry SDK on top of the OTel API. This post explains exactly what goes wrong, what the blast radius looks like when you're migrating off Datadog or Splunk, and how to structure OTel adoption so you don't build yourself into the same corner.

The Pattern We Keep Inheriting

Every enterprise we've worked with that had more than three years of observability history had built a wrapper. Sometimes it was a Go package called internal/telemetry. Sometimes a Java base class every service was supposed to extend. Sometimes a Node.js middleware that auto-instrumented Express routes.

The intent was always reasonable: give teams a single import, hide the OTel boilerplate, enforce naming conventions, add some company-specific attributes. The result, by the time we showed up, was always the same:

  • The wrapper was pinned to an OTel SDK version from 18 months ago because upgrading it broke the abstraction layer.
  • Half the services used the wrapper. The other half had drifted to direct OTel calls because the wrapper didn't expose the feature they needed.
  • Nobody remembered why certain design decisions were made.
  • The wrapper had become load-bearing in ways nobody planned: it was where sampling logic lived, where the exporter was configured, where span attributes got mutated.

When we try to migrate that stack to a self-hosted Grafana + Tempo + Mimir backend, the wrapper is the first obstacle. Not the Datadog agent. Not the custom dashboards. The internal SDK.

What "Wrapping" Actually Means

Before going further, it's worth being precise about what we mean, because not all abstraction is bad.

Wrapping the OTel API means your application code calls your internal library instead of calling otel.Tracer() or metric.NewCounter() directly. Your library then calls OTel underneath. This is the problem pattern.

Configuring OTel centrally means your application code calls the OTel API directly, but SDK initialization (exporters, samplers, resource attributes) is handled in a shared bootstrap package. This is fine and often correct.

Extending OTel with custom processors or exporters means you're writing code that plugs into the OTel Collector or SDK pipeline without replacing the API surface. This is also fine.

The distinction matters because the OTel API is designed to be stable across major versions. The OTel SDK is where the implementation lives and where breaking changes happen. If your wrapper sits between application code and the API, you've broken the stability guarantee that OTel's architecture was built around.

Here's what the problematic pattern looks like in Go:

// internal/telemetry/telemetry.go — the trap
package telemetry

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

// StartSpan wraps otel.Tracer to enforce naming conventions.
// (This comment is always in the codebase. It never ages well.)
func StartSpan(ctx context.Context, name string) (context.Context, trace.Span) {
    tracer := otel.Tracer("company-default-tracer")
    return tracer.Start(ctx, name)
}

Now every service calls telemetry.StartSpan(). The OTel API is invisible to application developers. When OTel releases baggage propagation improvements, or when you want to add span links, or when you need to pass trace.WithAttributes() — you have to update the wrapper first, cut a new version, and get every team to upgrade. You've rebuilt the dependency management problem that OTel's plugin architecture was designed to avoid.

The Migration Cost Is Real

In our last three enterprise migrations, we measured the time spent on wrapper-related work specifically. The numbers are not flattering:

Migration Wrapper depth Wrapper-related rework (eng-days) % of total migration effort
E-commerce platform, 180 services 2 layers (Go + proto schema) 34 28%
Fintech, 60 services 1 layer (Java base class) 18 31%
SaaS platform, 240 services 3 layers (Node SDK + internal + feature flags) 61 39%

The worst case — 3 layers deep — was a company that had wrapped OTel, then wrapped their wrapper with a feature-flag system that controlled sampling at the application layer, then wrapped that with a logging facade. Untangling it to get clean traces into Tempo took longer than migrating the actual exporters from Datadog.

The rework isn't just mechanical find-and-replace. It's:

  1. Auditing every service to understand which wrapper version it's on.
  2. Identifying where the wrapper was making decisions that need to move into the OTel Collector pipeline (sampling, attribute enrichment, filtering).
  3. Rewriting tests that mocked the wrapper instead of the OTel API.
  4. Convincing teams that removing the wrapper is not the same as removing standards.

Point 4 is the hardest. Platform teams built the wrapper to enforce standards. Telling them to remove it feels like telling them to give up control. The answer — which takes time to land — is that the OTel Collector is where that control belongs.

Where the Control Actually Belongs: The Collector Pipeline

Every piece of logic we've seen in internal wrappers belongs in one of two places: the OTel SDK initialization (resource attributes, exporter config, sampler config) or the OTel Collector pipeline (attribute mutation, sampling decisions, routing, filtering).

Here's a concrete example. A common wrapper pattern is adding a team attribute to every span:

// The wrapper approach — don't do this
func StartSpan(ctx context.Context, name string, team string) (context.Context, trace.Span) {
    tracer := otel.Tracer("company-default")
    return tracer.Start(ctx, name,
        trace.WithAttributes(attribute.String("team", team)),
    )
}

This forces every call site to pass team. When team names change, you update call sites. When you want to rename the attribute to service.team to match OTel semantic conventions, you update call sites.

The Collector approach:

# otelcol-config.yaml — attributes processor
processors:
  resource:
    attributes:
      - key: service.team
        from_attribute: service.name
        action: insert
  attributes/normalize:
    actions:
      - key: team
        action: delete
      - key: service.team
        pattern: ^(payments|auth|catalog|search).*
        action: extract

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [resource, attributes/normalize, batch]
      exporters: [otlp/tempo]

Now the application code uses the OTel API directly with no wrapper. The team attribute is derived from service.name in the Collector. When the attribute naming convention changes, you update one Collector config and redeploy. No service changes. No wrapper version bump. No migration.

The same logic applies to sampling. Tail-based sampling decisions — "keep 100% of traces with error spans, 1% of healthy traces" — belong in the Collector's tailsampling processor, not in application-layer wrappers that have to make head-based decisions with incomplete information.

The SDK Initialization Pattern That Actually Works

If you want shared standards without a wrapper, the right pattern is a bootstrap package that configures the SDK once at startup. Application code never imports the bootstrap package after main(). It only ever imports the OTel API.

// cmd/service/main.go
package main

import (
    "context"

    "github.com/your-org/platform/otelbootstrap" // only imported here
    "go.opentelemetry.io/otel"                   // application code uses this
)

func main() {
    ctx := context.Background()

    shutdown, err := otelbootstrap.Init(ctx, otelbootstrap.Config{
        ServiceName:    "payments-api",
        ServiceVersion: version.String(),
        OTLPEndpoint:   os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
    })
    if err != nil {
        log.Fatalf("failed to initialize telemetry: %v", err)
    }
    defer shutdown(ctx)

    // From here, application code uses otel.Tracer(), otel.Meter(), etc.
    // No internal/telemetry import anywhere in the business logic.
    runServer(ctx)
}

The otelbootstrap package handles: - OTLP exporter setup (gRPC to the Collector sidecar) - Resource attributes (service.name, service.version, deployment.environment, cloud.region) - SDK-level sampler (usually AlwaysOn — let the Collector's tail sampler decide) - Propagator registration (W3C TraceContext + Baggage)

Application code only ever calls otel.Tracer("component-name"). It's insulated from SDK changes. When we upgrade the OTel SDK version, we update the bootstrap package. Services that use the bootstrap package get the upgrade without changing their business logic.

This is the pattern we install on every migration. It takes about a day to write properly, including tests. The wrapper pattern takes months to undo.

When This Advice Is Wrong

I want to be honest about the cases where some wrapper-like abstraction is defensible.

Multi-language monorepos with strict API governance. If you have 15 languages and you genuinely need a single internal API surface for legal or compliance audit reasons, a thin facade that delegates to OTel can make sense. The key word is thin — it should expose the full OTel API, not a reduced version of it.

Legacy codebases where OTel instrumentation is incremental. If you're adding OTel to a 10-year-old codebase that has its own logging/tracing primitives, a bridge layer that translates internal calls to OTel spans can be a pragmatic intermediate step. The trap is treating it as permanent.

Testing utilities. Wrapping OTel for test purposes — providing an in-memory span exporter, asserting on span attributes in unit tests — is completely reasonable and doesn't cause the migration problems described above. This is using OTel's extension points, not replacing its API.

The common thread in the defensible cases: the wrapper is either temporary, extremely thin, or lives only in test code. The moment it starts making decisions about sampling, attribute naming, or exporter selection, it's accruing the debt we described.

What to Do If You Already Have a Wrapper

If you're reading this and recognizing your codebase, here's the order of operations we use:

Step 1: Audit the wrapper's responsibilities. List every decision the wrapper makes. Categorize each as: (a) belongs in SDK init, (b) belongs in Collector pipeline, (c) genuinely needs to stay in application code.

Step 2: Move Collector-layer logic to the Collector. Stand up an OTel Collector (or update your existing one) to handle sampling, attribute enrichment, and routing. Validate that the Collector is producing the same output the wrapper was producing.

Step 3: Migrate services off the wrapper incrementally. Start with greenfield services and new service scaffolding. Update your service template to use the bootstrap pattern. Don't force a big-bang migration on existing services — it creates too much risk.

Step 4: Deprecate the wrapper with a hard deadline. Set a date. Remove it from the internal package registry. Make the migration path clear. Without a deadline, you'll have wrapper and non-wrapper services coexisting indefinitely.

On a 180-service estate, this process typically takes 3-4 months if you have a dedicated platform engineer driving it. It's not fast, but it's faster than doing it under pressure during a vendor migration.

The Grafana Stack Angle

This matters specifically for teams moving to a self-hosted Grafana + Tempo + Mimir stack on AWS because the migration forces the wrapper problem into the open.

When you're on Datadog, the Datadog agent handles a lot of the enrichment and routing that your wrapper was also doing. You can have both doing the same thing and not notice the redundancy. When you switch to an OTel Collector sending to Tempo, the wrapper's decisions suddenly conflict with the Collector's decisions, or the wrapper is exporting directly to Datadog's OTLP endpoint while the Collector is trying to intercept traffic. The two systems fight.

The cleanest migrations we've run are ones where the client had already adopted the OTel API directly — even if they were still exporting to Datadog via the Datadog OTLP intake. Swapping the exporter endpoint from https://trace.agent.datadoghq.com to an internal Collector is a one-line config change. Everything else — Tempo for traces, Mimir for metrics, Loki for logs, Grafana for dashboards — slots in without touching application code.

The migrations that take twice as long are the ones where the wrapper has to come out first.

Where to Go Next

The OpenTelemetry project's post on this topic (linked from the OTel blog in June 2026) covers the API stability guarantees in more depth — worth reading if you want the authoritative argument for why the API/SDK separation exists.

For the Collector pipeline patterns described here, the OTel Collector contrib repository has reference configurations for the tailsampling, attributes, and resource processors. The tail sampling processor documentation in particular is worth reading carefully before you design your sampling strategy.

If you're mid-migration or evaluating whether to move off Datadog or Splunk, and you suspect your internal telemetry wrapper is going to be the hard part — it probably is. We've been through this enough times to have a playbook for it. The contact details are at etalon.systems if you want to talk through what the audit step looks like for your specific stack.

Category: Observability

Comments

Leave a comment