Skip to content

Bridging OpenTelemetry

Code instrumented with the OpenTelemetry Go SDK joins systemd's report framework through a ManualReader: add it to the MeterProvider, wrap it in otelbridge.New, register it on a provider.

reader := sdkmetric.NewManualReader()
otel.SetMeterProvider(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)))

p, err := provider.New("io.example.Api")
if err != nil {
    return err
}
p.Register(otelbridge.New(reader))
go p.Serve(ctx)

Each List or Describe call collects from the reader once. Other readers, such as a periodic OTLP push, can sit next to it on the same MeterProvider: the bridge doesn't replace your pipeline, it adds systemd as another consumer.

Cumulative temporality

The reader must use cumulative temporality, the SDK default. Delta sums are published as gauges, since they reset on every collection, and delta histograms as gauges too.

A complete example

examples/otel-bridge is a worker instrumented with the OpenTelemetry SDK:

examples/otel-bridge/main.go
// Command otel-bridge is a worker instrumented with the OpenTelemetry Go
// SDK that publishes its metrics through systemd's report framework. A
// ManualReader on the MeterProvider is all the bridge needs; other
// readers, such as an OTLP push, can sit next to it.
//
//  go run ./examples/otel-bridge -dir /tmp/report
//  go run ./cmd/sdmetrics-exporter -once -dir /tmp/report
package main

import (
    "context"
    "flag"
    "log"
    "math/rand/v2" // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
    "os"
    "os/signal"
    "syscall"
    "time"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/metric"
    sdkmetric "go.opentelemetry.io/otel/sdk/metric"

    "github.com/rossedman/sdmetrics/provider"
    "github.com/rossedman/sdmetrics/provider/otelbridge"
)

func main() {
    dir := flag.String("dir", provider.DefaultDir, "report directory")
    flag.Parse()

    reader := sdkmetric.NewManualReader()
    mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
    defer mp.Shutdown(context.Background())
    otel.SetMeterProvider(mp)

    p, err := provider.New("io.example.Worker", provider.WithDir(*dir), provider.WithSocketMode(0o666))
    if err != nil {
        log.Fatal(err)
    }
    p.Register(otelbridge.New(reader))

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()
    go func() {
        if err := p.Serve(ctx); err != nil {
            log.Fatal(err)
        }
    }()

    // Existing instrumentation, unchanged.
    meter := otel.Meter("example/worker")
    tasks, _ := meter.Int64Counter("worker.tasks", metric.WithDescription("Tasks processed"))
    dur, _ := meter.Float64Histogram("worker.task.duration", metric.WithUnit("s"), metric.WithDescription("Task duration"))
    log.Printf("publishing %s", p.Path())
    for ctx.Err() == nil {
        d := time.Duration(rand.IntN(500)) * time.Millisecond
        time.Sleep(d)
        kind := []string{"resize", "encode"}[rand.IntN(2)]
        tasks.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", kind)))
        dur.Record(ctx, d.Seconds(), metric.WithAttributes(attribute.String("kind", kind)))
    }
}
go run ./examples/otel-bridge -dir /tmp/report &
go run ./cmd/sdmetrics-exporter -once -dir /tmp/report -include io.example.Worker

How instruments map

OpenTelemetry data systemd families Fields
Sum, monotonic, cumulative (Counter, ObservableCounter) counter name attributes
Sum, non-monotonic or delta (UpDownCounter, ...) gauge name attributes
Gauge (Gauge, ObservableGauge) gauge name attributes
Histogram, explicit buckets counter name_bucket, counter name_sum, counter name_count attributes; cumulative le on buckets
Exponential histogram counter name_sum, counter name_count attributes
Summary gauge name, counter name_sum, counter name_count attributes; quantile on name

Attribute values are published as their string form. Buckets are made cumulative, with a final le="+Inf", as in the Prometheus layout. Exponential histograms keep only their sum and count: the report framework has no way to carry their buckets.

How names map

systemd accepts letters, digits and single underscores in a metric name, so the bridge rewrites OpenTelemetry names:

  1. Dots and other characters systemd does not accept become _.
  2. The units s, ms and By are appended as _seconds, _milliseconds and _bytes, unless the name already ends that way.
Instrument Unit Published as
http.server.request.duration s io.example.Api.http_server_request_duration_seconds
worker.tasks — io.example.Worker.worker_tasks
worker.task.duration s io.example.Worker.worker_task_duration_seconds_bucket (+ _sum, _count)
cache.size By io.example.Api.cache_size_bytes

otelbridge.Name(name, unit) returns the name the bridge uses, if you need it elsewhere:

otelbridge.Name("http.server.request.duration", "s") // "http_server_request_duration_seconds"

With the exporter's naming: otel the unit suffix moves back into the unit field on the way out: io.example.Api.http_server_request_duration_seconds is exported as io.example.api.http_server_request_duration with unit s. The round trip recovers the unit; the original dots in the instrument name become underscores.

Histograms on the Prometheus endpoint

Bridged histogram families are plain counters to systemd. The exporter's Prometheus endpoint keeps their _bucket, _sum and _count names in both naming modes, so histogram_quantile works on the _bucket series.

Next: Scraping from Go.