July 6, 2026 · Mihai · 9 min read

Don't Wrap OpenTelemetry: What the Official Warning Means for Your Migration Off Datadog

The OpenTelemetry project published a blunt warning this month: stop wrapping the OTel SDK in your own abstraction layer. If you are mid-migration from Datadog or Splunk and you built a thin internal telemetry library on top of OTel, this post explains exactly what breaks, why it breaks, and what to do instead — with concrete examples from services we have migrated on AWS.

The OpenTelemetry project published a blunt warning this month: stop wrapping the OTel SDK in your own abstraction layer. If you are mid-migration from Datadog or Splunk and you built a thin internal telemetry library on top of OTel, this post explains exactly what breaks, why it breaks, and what to do instead — with concrete examples from services we have migrated on AWS.

The short version: wrapper libraries feel like good platform engineering. They are not. They create a class of instrumentation bugs that are nearly invisible until you are debugging a production incident at 2 AM and your traces are missing half their spans.

What the OTel Project Actually Said

The post "Don't Wrap OpenTelemetry" from the OTel blog is worth reading in full, but the core argument is this: when you create an internal SDK wrapper — a MyCompanyTracer or PlatformTelemetry class — you break the assumption that OTel's auto-instrumentation and third-party libraries can reach the global TracerProvider. The result is two parallel, disconnected trace trees. Your manual spans exist. The auto-instrumented spans from your HTTP client, database driver, or gRPC library exist. They do not share a trace ID. Correlation is gone.

This is not a theoretical concern. We have seen it in production.

The Migration Context Where This Bites You

When a team migrates from Datadog, they usually go through a phase where they want to preserve their existing instrumentation API. Datadog's tracer has a specific call style. Engineers are used to it. The instinct is to write a shim:

# Don't do this
from opentelemetry import trace as otel_trace

class PlatformTracer:
    def __init__(self, service_name: str):
        self._tracer = otel_trace.get_tracer(service_name)

    def start_span(self, name: str):
        # Wraps OTel but hides the context manager protocol
        return self._tracer.start_span(name)

The problem here is subtle. start_span() without a context manager does not attach the span to the current context. It creates an orphaned span. Every downstream auto-instrumented library — opentelemetry-instrumentation-requests, opentelemetry-instrumentation-sqlalchemy, whatever — will start a new root span instead of a child span. Your trace in Tempo looks like a forest of unconnected root spans instead of a single tree.

The correct pattern is to use OTel's context manager protocol directly and stop hiding it:

# Do this instead
from opentelemetry import trace

tracer = trace.get_tracer(__name__)

with tracer.start_as_current_span("my-operation") as span:
    span.set_attribute("db.statement", query)
    result = execute_query(query)

This looks like more exposure of OTel internals to your application code. That is intentional. OTel is the abstraction. You do not need another one on top of it.

Why Teams Wrap Anyway: The Three Legitimate Reasons (and Why They Fail)

I want to be fair here. Teams wrap OTel for reasons that seem reasonable at design time.

Reason 1: Vendor portability. "We want to be able to swap OTel for something else later." This is the same argument people made for abstracting away cloud SDKs in 2015. OTel is the CNCF graduated standard. It is the thing you swap to, not the thing you swap away from. Wrapping it to preserve optionality is defending against a threat that does not exist.

Reason 2: Enforcing company-wide attribute standards. This one is more legitimate. You want every span to carry service.team, deployment.environment, and cloud.region without trusting every developer to add them. The correct solution is a Resource configured at the SDK level and a custom SpanProcessor — not a wrapper class.

from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider

resource = Resource.create({
    "service.name": "payments-api",
    "service.team": "platform",
    "deployment.environment": "production",
    "cloud.region": "eu-west-1",
})

provider = TracerProvider(resource=resource)

Every span emitted by every library using this provider will carry those attributes. No wrapper needed.

Reason 3: Reducing boilerplate for developers. "We want import platform_telemetry to be the only import." This is a DX concern, not an architecture concern. Solve it with documentation and cookiecutter templates, not a wrapper library that breaks context propagation.

The Specific Failure Mode in Loki and Tempo Correlation

Here is where this becomes a real operational problem on a self-hosted Grafana stack.

When context propagation is broken, your trace IDs in logs and your trace IDs in Tempo do not match. Loki's {service_name="payments-api"} | logfmt | trace_id="abc123" returns nothing, because the trace ID your logging library injected into the log line came from a different, orphaned span context than the one Tempo recorded.

We debugged exactly this on a migration from Splunk for a fintech client running on EKS in eu-west-1. Their internal PlatformLogger was injecting the current OTel span's trace ID into log lines. Their internal PlatformTracer was creating spans outside the active context. The trace IDs in Loki were real — they just belonged to orphaned spans that Tempo had never seen, because those spans were never exported (the exporter only flushed spans that were children of a root span that completed successfully).

The fix took 45 minutes once we understood the cause. Undoing the wrapper library and migrating call sites took three weeks.

What a Correct Self-Hosted OTel Setup Looks Like on AWS

For completeness, here is the collector configuration we use as a baseline for AWS deployments. This runs as a DaemonSet on EKS and receives OTLP from application pods.

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 5s
    send_batch_size: 1000
  resource:
    attributes:
      - key: cloud.provider
        value: aws
        action: insert
      - key: cloud.region
        from_attribute: AWS_REGION
        action: insert
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors-policy
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: slow-traces-policy
        type: latency
        latency: {threshold_ms: 500}
      - name: probabilistic-policy
        type: probabilistic
        probabilistic: {sampling_percentage: 5}

exporters:
  otlp/tempo:
    endpoint: tempo.monitoring.svc.cluster.local:4317
    tls:
      insecure: true
  loki:
    endpoint: http://loki.monitoring.svc.cluster.local:3100/loki/api/v1/push
  prometheusremotewrite:
    endpoint: http://mimir.monitoring.svc.cluster.local:9090/api/v1/push

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [resource, batch, tail_sampling]
      exporters: [otlp/tempo]
    metrics:
      receivers: [otlp]
      processors: [resource, batch]
      exporters: [prometheusremotewrite]
    logs:
      receivers: [otlp]
      processors: [resource, batch]
      exporters: [loki]

Notice that attribute enrichment happens in the collector's resource processor, not in application code. This is the pattern that replaces the "enforce company-wide attributes" wrapper use case.

The Honest Tradeoff: When a Thin Wrapper Is Acceptable

There is one case where a wrapper is defensible: migrating a very large codebase where you cannot update every call site at once.

If you have 400 services and they all call DatadogTracer.startSpan(), you might write a compatibility shim that translates that API to OTel's start_as_current_span() correctly — using the context manager, propagating context, not hiding it. This is a migration tool, not a permanent architecture. You set a deadline for its removal (we suggest 90 days), you track adoption in your platform metrics, and you delete it.

The failure mode is when the shim becomes permanent because the migration never quite finishes. We have seen wrappers from 2021 still running in production in 2026 because "we'll clean it up next quarter." At that point you have inherited all the maintenance cost of the wrapper with none of the portability benefit.

Approach Context propagation Attribute enforcement Long-term maintenance
Direct OTel SDK Correct by default Via Resource + Processor Low — follows upstream
Thin wrapper (correct impl) Correct if implemented carefully Possible but fragile Medium — you own the shim
Thin wrapper (incorrect impl) Broken Appears to work High — silent bugs
Permanent abstraction layer Broken Appears to work Very high — you own an SDK

What This Means for Your Datadog Migration Plan

If you are currently planning a migration off Datadog and your platform team's first instinct is to write a compatibility layer, push back on that instinct early. The compatibility layer is not the migration — it is the thing that makes the migration take 18 months instead of 6.

The migrations we complete fastest follow the same pattern:

  1. Stand up the collector infrastructure first (OTel Collector DaemonSet, Tempo, Loki, Mimir on AWS).
  2. Instrument one service end-to-end with raw OTel SDK, no wrapper. Validate traces in Tempo, logs in Loki, metrics in Mimir.
  3. Use that service as the reference implementation. Write the docs. Record the demo.
  4. Migrate services in waves, updating call sites to native OTel as you go.
  5. Run Datadog and OTel in parallel for 30 days using the OTel Collector's dual-export capability, then cut over.

Step 5 is the one that surprises people. The OTel Collector can export to both your self-hosted Tempo and to Datadog's OTLP endpoint simultaneously. You get a 30-day overlap window where you can validate that traces in Tempo match what Datadog shows, without paying for double ingestion on the Datadog side (you are already paying for it; you are just not adding new cost).

The Version Note

Everything above applies to OTel SDK versions current as of mid-2026. The Python SDK is at 1.x stable. The Java agent is at 2.x. The Go SDK reached 1.0 in 2024. If you are still on pre-1.0 versions of any of these, the wrapper problem is compounded by API instability — another reason to migrate to native SDK usage on stable releases before you finalize your collector infrastructure.

Where to Go Next

The OTel project's "Don't Wrap OpenTelemetry" post is the right starting point for the conceptual argument. For the AWS-specific collector deployment, the OpenTelemetry Operator for Kubernetes is worth evaluating — it handles DaemonSet lifecycle and auto-instrumentation injection in a way that makes the wrapper temptation largely disappear.

If you are mid-migration and your traces in Tempo look like disconnected root spans, the first thing to check is whether any wrapper library is calling start_span() instead of start_as_current_span(). That single change fixes the majority of context propagation breaks we have seen.

We work on these migrations at Etalon. If you are trying to scope a move off Datadog or Splunk and want a second opinion on your current instrumentation architecture before you commit to a direction, we are worth a conversation.

Category: Observability

Comments

Leave a comment