Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: GO

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.24"

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.24"

- name: Integration tests
run: go test -tags=integration -v -timeout 180s ./...
199 changes: 198 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,198 @@
Metrics wrapper for golang libraries
# Metrics

Metrics wrapper for Go libraries with support for Prometheus and OpenTelemetry backends.

## Install

```bash
go get github.com/tryfix/metrics/v2
```

## Backends

### Prometheus (direct)

```go
reporter := metrics.PrometheusReporter(metrics.ReporterConf{
System: "namespace",
Subsystem: "subsystem",
ConstLabels: map[string]string{"env": "prod"},
})
```

### OpenTelemetry

Uses the global `MeterProvider`:

```go
reporter := metrics.OTELReporter(metrics.ReporterConf{
System: "namespace",
Subsystem: "subsystem",
})
```

Or inject a custom `MeterProvider`:

```go
reporter := metrics.OTELReporter(conf, metrics.WithMeterProvider(meterProvider))
```

## Usage

```go
// Counter
counter := reporter.Counter(metrics.MetricConf{
Path: "requests",
Labels: []string{"method"},
Help: "Total requests",
})
counter.Count(1, map[string]string{"method": "GET"})

// Gauge
gauge := reporter.Gauge(metrics.MetricConf{
Path: "connections",
Labels: []string{"pool"},
})
gauge.Set(42, map[string]string{"pool": "main"})
gauge.Count(1, map[string]string{"pool": "main"}) // increments by 1

// Observer (Histogram)
observer := reporter.Observer(metrics.MetricConf{
Path: "request_duration_seconds",
Labels: []string{"endpoint"},
})
observer.Observe(0.25, map[string]string{"endpoint": "/api"})

// Sub-reporters inherit parent labels
subReporter := reporter.Reporter(metrics.ReporterConf{
ConstLabels: map[string]string{"component": "auth"},
})
```

### Exemplars (trace context)

Pass a span context to link metrics with traces:

```go
ctx, span := tracer.Start(ctx, "operation")
defer span.End()

counter.Count(1, labels, metrics.WithContext(ctx))
observer.Observe(0.5, labels, metrics.WithContext(ctx))
```

## Exponential Histograms + Exemplars with Prometheus

The OTEL backend supports [exponential (native) histograms](https://prometheus.io/docs/specs/native_histograms/)
which auto-scale buckets without manual configuration. When combined with
exemplars, you get both high-resolution latency data and trace-to-metric
correlation.

### The challenge

Not all ingestion paths support both features simultaneously:

| Ingestion Path | Native Histograms | Exemplars | Both |
|---|---|---|---|
| **Protobuf Scrape (`PrometheusProto`)** | Yes | Yes | **Yes** |
| OpenMetrics Text Scrape | No (classic only) | Yes | No |
| OTLP Push | Yes | Not stored yet | No |
| OTEL Collector -> Remote Write | Yes | Dropped for histograms | No |

**The protobuf scrape path is the only proven end-to-end path** that delivers
both native histograms and exemplars into Prometheus.

### Setup

**Application:**

```go
import (
promexporter "go.opentelemetry.io/otel/exporters/prometheus"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tryfix/metrics/v2"
)

// Trace provider (needed for exemplar generation)
traceProvider := sdktrace.NewTracerProvider(
sdktrace.WithSampler(sdktrace.AlwaysSample()),
)
otel.SetTracerProvider(traceProvider)

// OTEL Prometheus exporter with exponential histograms
exporter, _ := promexporter.New()
meterProvider := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(exporter),
sdkmetric.WithView(sdkmetric.NewView(
sdkmetric.Instrument{Kind: sdkmetric.InstrumentKindHistogram},
sdkmetric.Stream{
Aggregation: sdkmetric.AggregationBase2ExponentialHistogram{
MaxSize: 160,
MaxScale: 20,
},
},
)),
)
otel.SetMeterProvider(meterProvider)

// Metrics reporter
reporter := metrics.OTELReporter(metrics.ReporterConf{
System: "myapp",
Subsystem: "api",
})

// Expose /metrics endpoint (supports protobuf content negotiation)
http.Handle("/metrics", promhttp.HandlerFor(
prometheus.DefaultGatherer,
promhttp.HandlerOpts{EnableOpenMetrics: true},
))
```

**Prometheus configuration:**

```yaml
global:
scrape_protocols:
- PrometheusProto # required for native histograms + exemplars
- OpenMetricsText1.0.0

scrape_configs:
- job_name: myapp
static_configs:
- targets: ['myapp:9090']
```

**Prometheus flags:**

```bash
prometheus \
--enable-feature=native-histograms \
--enable-feature=exemplar-storage
```

### What this gives you

- **Auto-scaling histogram buckets** - no manual bucket boundaries needed, accurate
quantile computation across any value range
- **Exemplars on every observation** - each histogram data point carries a `trace_id`
and `span_id` linking to the distributed trace
- **Full PromQL support** - `histogram_quantile()`, `histogram_count()`,
`histogram_sum()` all work on native histograms

## Integration Tests

The integration tests verify all three ingestion paths using Testcontainers (requires Docker):

```bash
go test -tags=integration -v -timeout 120s
```

| Test | What it verifies |
|------|-----------------|
| `TestOTELDirectPush` | OTLP push: counters, exponential histogram quantiles, SDK-level exemplars |
| `TestExemplarsScrape` | OpenMetrics scrape: counter + classic histogram exemplars in Prometheus |
| `TestNativeHistogramExemplars` | Protobuf scrape: exponential histograms + exemplars together in Prometheus |
67 changes: 58 additions & 9 deletions example/main.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,55 @@
package main

import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tryfix/metrics/v2"
"context"
"log"
"math/rand"
"net/http"
"time"

"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/tryfix/metrics/v2"
"go.opentelemetry.io/otel"
promexporter "go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

func main() {
reporter := metrics.PrometheusReporter(metrics.ReporterConf{
// Set up trace provider with stdout exporter
traceExporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
if err != nil {
log.Fatal(err)
}

traceProvider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(traceExporter))
otel.SetTracerProvider(traceProvider)

// Set up the OTEL Prometheus exporter and MeterProvider
metricExporter, err := promexporter.New()
if err != nil {
log.Fatal(err)
}
meterProvider := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(metricExporter),
// Use exponential histograms — auto-scaling buckets, no manual bucket config needed
sdkmetric.WithView(sdkmetric.NewView(
sdkmetric.Instrument{Kind: sdkmetric.InstrumentKindHistogram},
sdkmetric.Stream{
Aggregation: sdkmetric.AggregationBase2ExponentialHistogram{
MaxSize: 160,
MaxScale: 20,
},
},
)),
)
otel.SetMeterProvider(meterProvider)

tracer := otel.Tracer("example")

reporter := metrics.OTELReporter(metrics.ReporterConf{
System: `namespace`,
Subsystem: `subsystem`,
ConstLabels: map[string]string{
Expand Down Expand Up @@ -59,29 +98,39 @@ func main() {
for {
time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)

subScoreLatency.Observe(float64(time.Since(last).Milliseconds()), map[string]string{`type`: `type1`})
// Create a trace span and pass its context to metrics
ctx, span := tracer.Start(context.Background(), "process_scores")

subScoreLatency.Observe(float64(time.Since(last).Milliseconds()), map[string]string{`type`: `type1`}, metrics.WithContext(ctx))
last = time.Now()

scoreCounter.Count(1, map[string]string{
`type`: `major`,
})
}, metrics.WithContext(ctx))

subScoreCounter.Count(1, map[string]string{
`attr_1`: `val 1`,
`attr_2`: `val 2`,
})
}, metrics.WithContext(ctx))

thirdScoreCounter.Count(1, map[string]string{
`attr_1`: `val 1`,
})
}, metrics.WithContext(ctx))

span.End()
}
}()

r := http.NewServeMux()

r.Handle(`/metrics`, promhttp.Handler())
r.Handle(`/metrics`, promhttp.HandlerFor(
prometheus.DefaultGatherer,
promhttp.HandlerOpts{EnableOpenMetrics: true},
))

if err := http.ListenAndServe(`:9999`, r); err != nil {
host := `:9999`
log.Printf(`metrics endpoint can be accessed from %s/metrics`, host)
if err := http.ListenAndServe(host, r); err != nil {
log.Fatal(err)
}
}
Loading