← Blog

August 2026

What we learned building OpenTelemetry SDKs for iOS and React Native

We just open-sourced two mobile instrumentation libraries: vartio-swift (SPM) and vartio-react-native (npm @vartio/react-native). Apache-2.0, plain OTLP/HTTP, no proprietary wire format.

The libraries are boring by design. A few hundred lines that build a resource, wire a batch processor to an OTLP exporter, and attach a bearer token. What was not boring was everything that broke while we got there, and most of it applies to anyone doing OpenTelemetry on mobile regardless of where they send the data.

OTLP/JSON encodes trace IDs as hex. protojson doesn't.

This one cost us the most time.

The OTLP spec says that in JSON, trace_id and span_id are hex strings: 32 and 16 lowercase hex characters. That is a deliberate deviation from the normal protobuf-to-JSON mapping, which encodes bytes as base64.

So if you implement OTLP/JSON ingest with Go's protojson, like we did, you get base64 behavior. Now the fun part: 32 hex characters happen to be valid base64 too. A spec-compliant client's IDs don't fail to parse. They decode into 24 bytes of garbage, no error anywhere, and traces quietly stop correlating.

We didn't catch this by reading the spec. We caught it by pointing our demo app at a real OpenTelemetry Collector, which refused the payloads:

readLog.traceId: parse trace_id: invalid length for ID

The collector was right and we were wrong twice over. We were emitting base64, which the collector rejects, and accepting base64, which no spec-compliant client sends. The fix was to decode with the collector's own pdata unmarshalers, which are the reference implementation, and keep the old path as a fallback for anything already sending the broken form.

If you accept OTLP/JSON anywhere, go check this now. It fails silently.

Your metrics resource is part of the series key

Host metrics are easy because hosts are countable. Phones aren't.

Every resource attribute on a metric becomes part of its stored series key. Put session.id or an install ID on a mobile metric resource and you've asked your backend to store one time series per install, forever. A fleet of 100k phones will cheerfully do that.

Both SDKs build a separate, deliberately thin resource for metrics: service name, app version, device model, OS version. Nothing else. That bounds cardinality at versions × models × OS versions, a few hundred series, and it's still enough to answer whether 2.4.1 is leaking memory that 2.4.0 wasn't.

We also stopped trusting our own documentation to enforce this and put a hard cap in the ingest path. The convention is one careless resource-builder away from being violated by a client we don't control, and by then it's our storage bill.

A 200 from a collector doesn't mean your data landed

Building the fan-out demo below, we pushed a large backfill through a collector. 201 requests, every one a 200 OK. One backend got all 21,416 spans. The other got almost none.

The OTLP receiver acks a batch when it accepts it into the pipeline, not when the exporter delivers it. Under a burst, one exporter's sending queue overflowed and dropped the excess. No errors in the collector log. The 200s were telling the truth about receipt and nothing at all about delivery.

Verify at the destination. We check otelcol_receiver_accepted_spans_total on the receiving side now, and the demo config ships a deeper queue with explicit retry.

Two bugs that only show up on a real device

The Swift package declared an iOS 15 minimum and then called Task.sleep(for:), which needs iOS 16. Every CI run passed, because SwiftPM tests run on macOS where that API exists. It surfaced the first time we built the example app for an iOS simulator. If your package claims a platform floor, build for that platform in CI. Otherwise the floor is just a comment.

The second one was a deadlock and it was much worse. configure() took a non-reentrant NSLock, then emitted the session-start log while still holding it, and that log path tried to take the same lock. Every test passed, because the tests drove internals directly and never called the real entry point. The first test that actually called Vartio.start() hung for nine minutes at zero CPU. Ship that and you hang every consumer's app on their first call to your primary API.

Fan-out is the only honest proof of no lock-in

Every vendor says "standard OpenTelemetry, no lock-in." The checkable version of that claim is a collector config with two exporters:

exporters:
  otlphttp/vendor:
    endpoint: ${VENDOR_ENDPOINT}
    headers: { Authorization: "Bearer ${TOKEN}" }
  otlphttp/grafana:
    endpoint: http://lgtm:4318

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlphttp/vendor, otlphttp/grafana]

Same app, unmodified, feeding a vendor backend and a self-hosted Grafana/Tempo/Loki/Mimir stack at the same time. Our demo ships it as a docker compose up. Partly that's a sales argument, and partly it's the only way to show that switching backends is a config change and not a re-instrumentation project.

If a mobile SDK can only talk to one backend, evaluating an alternative means rewriting your instrumentation. That's the actual lock-in, and no license or pricing page fixes it.

What doesn't work yet

React Native JS crash symbolication resolves frames through the Metro source map but doesn't implement Hermes's x_hermes_function_offsets bytecode accounting. Frames outside the map's coverage stay raw. Partial symbolication is the normal case, and we'd rather show four resolved frames and two raw ones than six confidently wrong ones.

Crash-free session rate is 1 - crashes/sessions. A true distinct-session calculation needs a COUNT DISTINCT that LogQL can't express, so a session that crashes twice counts twice. It's directionally right and the UI says it's an approximation.

Both packages are 0.x. Vartio.start is stable enough to build on, but we're not claiming a frozen interface.

Issues and PRs welcome on both repos.