OpenTelemetry has become the standard for distributed tracing, but the official SDKs come with significant weight. For a C application where I control the dependencies carefully, pulling in the OTel C++ SDK or going through a language binding wasn’t appealing. The dependency tree is substantial, the build complexity increases, and most of what the SDK provides — context propagation across thread pools, complex sampler hierarchies, batch processing with configurable queue sizes — wasn’t needed for the use case.

The alternative: speak the wire protocol directly.

OTLP/HTTP Is Just JSON Over HTTP

The OpenTelemetry Protocol (OTLP) has an HTTP transport with a JSON encoding. Once you accept that, the implementation becomes straightforward: build the JSON payload, POST it to the collector endpoint, move on.

The JSON structure for a trace export looks roughly like this:

{
  "resourceSpans": [{
    "resource": {
      "attributes": [
        {"key": "service.name", "value": {"stringValue": "my-service"}}
      ]
    },
    "scopeSpans": [{
      "scope": {"name": "my-instrumentation"},
      "spans": [{
        "traceId": "...",
        "spanId": "...",
        "name": "operation.name",
        "startTimeUnixNano": "1234567890000000000",
        "endTimeUnixNano": "1234567890100000000",
        "status": {"code": 1},
        "attributes": [...]
      }]
    }]
  }]
}

No SDK required. A span is a JSON object with a trace ID, span ID, timing, and attributes. If you can generate random 128-bit and 64-bit hex IDs and serialize JSON, you can emit spans.

The Implementation Pattern

For a C application using libsoup and json-glib (already in the dependency tree for other reasons), the implementation is:

  1. Config layer: Read enabled, endpoint, and service_name from the application config. Default enabled: false so instrumentation is a no-op unless explicitly turned on.

  2. No-op stubs when disabled: If OTEL=0 at build time or enabled: false at runtime, all telemetry calls compile away or return immediately. Zero cost in the common case.

  3. Span creation: Generate trace ID and span ID at span start. Record startTimeUnixNano using clock_gettime(CLOCK_REALTIME) converted to nanoseconds.

  4. Span completion: Record endTimeUnixNano, set status based on whether an error was flagged during the span, attach accumulated attributes.

  5. Export: Serialize to OTLP JSON and POST to the configured endpoint using libsoup. Fire-and-forget — don’t block the request path waiting for the collector response.

The whole thing is two files: an implementation and a header. No new dependencies beyond what the application already uses.

What You Get

With this approach, you get full OpenTelemetry-compatible traces that any OTel collector can ingest — Jaeger, Tempo, the OTEL Collector itself, cloud providers that accept OTLP. The traces land in whatever backend you configure the collector to send them to.

The span attributes tell you what happened: which channel a message came from, which room, which session, whether the operation succeeded or failed. The trace timeline tells you how long each operation took. A single root span per request keeps the initial implementation simple — child spans for AI calls, routing decisions, and other sub-operations can be added later without changing the core architecture.

The Trade-off

What you don’t get: automatic context propagation, sampling, baggage, the full W3C Trace Context header handling, or any of the richer features that make the SDK valuable in complex distributed systems.

For a simple service with a well-understood request lifecycle, this is a good trade. You get observability with minimal dependency cost, the traces are compatible with the full OTel ecosystem, and the implementation is small enough to reason about completely.

When the system grows to the point where you need sophisticated sampling or cross-service trace propagation, the SDK becomes worth its weight. Until then, the wire protocol is enough.


The SDK is the right answer for complex distributed systems. For constrained environments with simple request patterns, speaking the protocol directly works fine — and you can always migrate to the SDK later when you need what it provides.