rgoussu@goussu: ~/library/go/storage
~/library/go/storage cat time-series.md

Time-series stores from Go

# Prometheus as Go's native habitat, OpenTelemetry as the converging facade, and the Influx, Timescale, VictoriaMetrics, and QuestDB client paths.

Conceptsaved 2026-08-09updated 2026-08-11 #go#storage#time-series#prometheus#observability

Overview

Time series is the one storage kind where Go is the home team: Prometheus, VictoriaMetrics, and InfluxDB are all written in Go, and the instrumentation idioms of the whole cloud-native world were set by prometheus/client_golang. So a Go service usually meets time-series storage from the producer side — exposing metrics to be pulled — rather than through a query driver. The write-side clients (Influx line protocol, Timescale via pgx, remote-write) matter when you push measurements as data rather than expose them as telemetry.

Key points

  • client_golang is the canonical instrumentation library: Counter, Gauge, Histogram, Summary, their *Vec labeled variants, registered on a Registry and served by promhttp.Handler() at /metrics. The pull model inverts the driver relationship — Prometheus scrapes you; your only "storage code" is an HTTP handler.
  • Custom collectors implement prometheus.Collector (Describe/Collect) to expose state computed at scrape time — pool sizes, queue depths — instead of maintaining gauges by hand. GaugeFunc is the one-metric shortcut.
  • Exemplars attach a trace ID to a histogram observation (ExemplarObserver.ObserveWithExemplar), linking a latency bucket to an exact trace — needs OpenMetrics negotiation on the scrape.
  • OpenTelemetry metrics (otel-go) is the converging path: instrument once against the OTel API, choose the exporter — Prometheus pull or OTLP push — at the edge. New greenfield code increasingly starts here rather than on client_golang directly.
  • InfluxDB — influxdb-client-go: push model, line protocol (measurement,tag=v field=1 timestamp), a non-blocking WriteAPI that batches in the background (listen on Errors()) and a blocking variant for when you need the error now.
  • TimescaleDB is Postgres: the entire pgx stack applies unchanged — batch inserts via CopyFrom, hypertables and time_bucket are just SQL. No new client to learn is the selling point.
  • VictoriaMetrics is Go-native and speaks Prometheus remote-write plus Influx line protocol on ingest; from application code you rarely target it directly — you point remote-write or vmagent at it. VictoriaMetrics/metrics exists as a lighter client_golang alternative.
  • QuestDB ingests via ILP (Influx line protocol) with an official Go client (questdb/go-questdb-client); queries over PG-wire — one line, same shape as Timescale.
  • Cardinality is the failure mode: every distinct label combination is a stored series. No user IDs, no unbounded values in labels; batch client-side writes; pre-aggregate where the query only ever needs the aggregate.

Details

Producer side vs writer side

Path Library Model When
Expose metrics client_golang pull (scrape) service telemetry — the default
Expose metrics, portable otel-go SDK pull or OTLP push greenfield, multi-backend
Push measurements influxdb-client-go push, line protocol, batched business/IoT measurements as data
SQL time series pgx → TimescaleDB SQL, CopyFrom bulk relational + time series in one store
Remote-write Prometheus remote-write → VictoriaMetrics push, snappy-compressed protobuf long-term storage behind Prometheus

The split worth internalizing: telemetry about the service goes out the pull path; measurements as domain data (readings, prices, events you query arbitrarily) belong in a store you write to explicitly — Influx, Timescale, QuestDB.

Batching discipline

  • Influx/QuestDB clients batch for you — size and flush interval are tunables; watch the async error channel or you will drop points silently.
  • With Timescale, single-row INSERTs are the classic mistake: use pgx.CopyFrom or batched multi-row inserts for ingest-heavy paths.

Examples

var reqDuration = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "Request latency.",
        Buckets: prometheus.DefBuckets,
    },
    []string{"route", "method"}, // bounded labels only — never user IDs
)

func init() { prometheus.MustRegister(reqDuration) }

func handler(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    defer func() {
        reqDuration.WithLabelValues("/orders", r.Method).
            Observe(time.Since(start).Seconds())
    }()
    // ...
}

// main: http.Handle("/metrics", promhttp.Handler())

Related