diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..668140c --- /dev/null +++ b/.github/workflows/go.yml @@ -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 ./... diff --git a/README.md b/README.md index cc6bd2e..660afe0 100644 --- a/README.md +++ b/README.md @@ -1 +1,198 @@ -Metrics wrapper for golang libraries \ No newline at end of file +# 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 | diff --git a/example/main.go b/example/main.go index 8dbbda4..5c45ba8 100644 --- a/example/main.go +++ b/example/main.go @@ -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{ @@ -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) } } diff --git a/go.mod b/go.mod index 2688cf2..2b2e30d 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,87 @@ module github.com/tryfix/metrics/v2 -go 1.19 +go 1.24.0 -require github.com/prometheus/client_golang v1.20.0 +require ( + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/common v0.67.5 + github.com/testcontainers/testcontainers-go v0.40.0 + go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 + go.opentelemetry.io/otel/exporters/prometheus v0.62.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 + go.opentelemetry.io/otel/metric v1.40.0 + go.opentelemetry.io/otel/sdk v1.40.0 + go.opentelemetry.io/otel/sdk/metric v1.40.0 +) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.1+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - golang.org/x/sys v0.22.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2041dc1..ccbf874 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,215 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/prometheus/client_golang v1.20.0 h1:jBzTZ7B099Rg24tny+qngoynol8LtVYlA2bqx3vEloI= -github.com/prometheus/client_golang v1.20.0/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/noop.go b/noop.go index e4956a1..97fb19d 100644 --- a/noop.go +++ b/noop.go @@ -22,14 +22,14 @@ func (noopReporter) UnRegister(metrics string) {} type noopCounter struct{} -func (noopCounter) Count(value float64, lbs map[string]string) {} -func (noopCounter) UnRegister() {} +func (noopCounter) Count(value float64, lbs map[string]string, opts ...RecordOption) {} +func (noopCounter) UnRegister() {} type noopGauge struct{} -func (noopGauge) Count(value float64, lbs map[string]string) {} -func (noopGauge) Set(value float64, lbs map[string]string) {} -func (noopGauge) UnRegister() {} +func (noopGauge) Count(value float64, lbs map[string]string, opts ...RecordOption) {} +func (noopGauge) Set(value float64, lbs map[string]string, opts ...RecordOption) {} +func (noopGauge) UnRegister() {} type noopGaugeFunc struct{} @@ -37,5 +37,5 @@ func (noopGaugeFunc) UnRegister() {} type noopObserver struct{} -func (noopObserver) Observe(value float64, lbs map[string]string) {} -func (noopObserver) UnRegister() {} +func (noopObserver) Observe(value float64, lbs map[string]string, opts ...RecordOption) {} +func (noopObserver) UnRegister() {} diff --git a/otel.go b/otel.go new file mode 100644 index 0000000..54f1db7 --- /dev/null +++ b/otel.go @@ -0,0 +1,291 @@ +package metrics + +import ( + "context" + "fmt" + "sort" + "sync" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +type otelReporter struct { + meter metric.Meter + meterProvider metric.MeterProvider + namespace string + subSystem string + constLabels map[string]string + availableMetrics map[string]Collector + *sync.Mutex +} + +// OTELOption configures the OTEL reporter. +type OTELOption func(*otelOptions) + +type otelOptions struct { + meterProvider metric.MeterProvider +} + +func applyOTELOptions(opts []OTELOption) otelOptions { + o := otelOptions{meterProvider: otel.GetMeterProvider()} + for _, opt := range opts { + opt(&o) + } + return o +} + +// WithMeterProvider sets a custom MeterProvider for the reporter. +// By default, the global provider from otel.GetMeterProvider() is used. +func WithMeterProvider(provider metric.MeterProvider) OTELOption { + return func(o *otelOptions) { + o.meterProvider = provider + } +} + +// OTELReporter creates a new OpenTelemetry-based reporter. +// Use WithMeterProvider to inject a custom MeterProvider. +func OTELReporter(conf ReporterConf, opts ...OTELOption) Reporter { + o := applyOTELOptions(opts) + + constLabels := map[string]string{} + if conf.ConstLabels != nil { + for k, v := range conf.ConstLabels { + constLabels[k] = v + } + } + + meterName := conf.System + if conf.Subsystem != "" { + meterName = fmt.Sprintf("%s_%s", conf.System, conf.Subsystem) + } + + r := &otelReporter{ + meterProvider: o.meterProvider, + meter: o.meterProvider.Meter(meterName), + namespace: conf.System, + subSystem: conf.Subsystem, + constLabels: constLabels, + availableMetrics: make(map[string]Collector), + Mutex: new(sync.Mutex), + } + + return r +} + +func (r *otelReporter) Reporter(conf ReporterConf) Reporter { + rConf := ReporterConf{ + System: r.namespace, + Subsystem: r.subSystem, + ConstLabels: mergeLabels(r.constLabels, conf.ConstLabels), + } + if conf.Subsystem != "" { + rConf.Subsystem = fmt.Sprintf("%s_%s", r.subSystem, conf.Subsystem) + } + return OTELReporter(rConf, WithMeterProvider(r.meterProvider)) +} + +func (r *otelReporter) Counter(conf MetricConf) Counter { + r.Lock() + defer r.Unlock() + + if c, ok := r.availableMetrics[conf.Path]; ok { + return c.(Counter) + } + + name := r.metricName(conf.Path) + counter, err := r.meter.Float64Counter(name, + metric.WithDescription(conf.Help), + ) + if err != nil { + panic(err) + } + + c := &otelCounter{ + counter: counter, + constLabels: mergeLabels(r.constLabels, conf.ConstLabels), + } + r.availableMetrics[conf.Path] = c + return c +} + +func (r *otelReporter) Gauge(conf MetricConf) Gauge { + r.Lock() + defer r.Unlock() + + if g, ok := r.availableMetrics[conf.Path]; ok { + return g.(Gauge) + } + + name := r.metricName(conf.Path) + + gauge, err := r.meter.Float64Gauge(name, + metric.WithDescription(conf.Help), + ) + if err != nil { + panic(err) + } + + g := &otelGauge{ + gauge: gauge, + constLabels: mergeLabels(r.constLabels, conf.ConstLabels), + values: make(map[string]float64), + Mutex: new(sync.Mutex), + } + r.availableMetrics[conf.Path] = g + return g +} + +func (r *otelReporter) GaugeFunc(conf MetricConf, f func() float64) GaugeFunc { + r.Lock() + defer r.Unlock() + + name := r.metricName(conf.Path) + constAttrs := labelsToAttributes(mergeLabels(r.constLabels, conf.ConstLabels)) + + _, err := r.meter.Float64ObservableGauge(name, + metric.WithDescription(conf.Help), + metric.WithFloat64Callback(func(ctx context.Context, obs metric.Float64Observer) error { + obs.Observe(f(), metric.WithAttributes(constAttrs...)) + return nil + }), + ) + if err != nil { + panic(err) + } + + g := &otelGaugeFunc{} + r.availableMetrics[conf.Path] = g + return g +} + +func (r *otelReporter) Observer(conf MetricConf) Observer { + r.Lock() + defer r.Unlock() + + if o, ok := r.availableMetrics[conf.Path]; ok { + return o.(Observer) + } + + name := r.metricName(conf.Path) + histogram, err := r.meter.Float64Histogram(name, + metric.WithDescription(conf.Help), + ) + if err != nil { + panic(err) + } + + o := &otelHistogram{ + histogram: histogram, + constLabels: mergeLabels(r.constLabels, conf.ConstLabels), + } + r.availableMetrics[conf.Path] = o + return o +} + +func (r *otelReporter) Info() string { return "otel" } + +func (r *otelReporter) UnRegister(metrics string) { + r.Lock() + defer r.Unlock() + // OTEL doesn't support unregistering individual metrics + delete(r.availableMetrics, metrics) +} + +func (r *otelReporter) metricName(path string) string { + if r.namespace != "" && r.subSystem != "" { + return fmt.Sprintf("%s_%s_%s", r.namespace, r.subSystem, path) + } + if r.namespace != "" { + return fmt.Sprintf("%s_%s", r.namespace, path) + } + return path +} + +// --- Metric Implementations --- + +type otelCounter struct { + counter metric.Float64Counter + constLabels map[string]string +} + +func (c *otelCounter) Count(value float64, lbs map[string]string, opts ...RecordOption) { + o := applyRecordOptions(opts) + attrs := labelsToAttributes(mergeLabels(c.constLabels, lbs)) + c.counter.Add(o.ctx, value, metric.WithAttributes(attrs...)) +} + +func (c *otelCounter) UnRegister() {} + +type otelGauge struct { + gauge metric.Float64Gauge + constLabels map[string]string + values map[string]float64 + *sync.Mutex +} + +func (g *otelGauge) Count(value float64, lbs map[string]string, opts ...RecordOption) { + g.Lock() + defer g.Unlock() + + o := applyRecordOptions(opts) + key := labelsKey(lbs) + g.values[key] += value + + attrs := labelsToAttributes(mergeLabels(g.constLabels, lbs)) + g.gauge.Record(o.ctx, g.values[key], metric.WithAttributes(attrs...)) +} + +func (g *otelGauge) Set(value float64, lbs map[string]string, opts ...RecordOption) { + g.Lock() + defer g.Unlock() + + o := applyRecordOptions(opts) + key := labelsKey(lbs) + g.values[key] = value + + attrs := labelsToAttributes(mergeLabels(g.constLabels, lbs)) + g.gauge.Record(o.ctx, value, metric.WithAttributes(attrs...)) +} + +func (g *otelGauge) UnRegister() {} + +type otelGaugeFunc struct{} + +func (g *otelGaugeFunc) UnRegister() {} + +type otelHistogram struct { + histogram metric.Float64Histogram + constLabels map[string]string +} + +func (h *otelHistogram) Observe(value float64, lbs map[string]string, opts ...RecordOption) { + o := applyRecordOptions(opts) + attrs := labelsToAttributes(mergeLabels(h.constLabels, lbs)) + h.histogram.Record(o.ctx, value, metric.WithAttributes(attrs...)) +} + +func (h *otelHistogram) UnRegister() {} + +func labelsToAttributes(labels map[string]string) []attribute.KeyValue { + attrs := make([]attribute.KeyValue, 0, len(labels)) + for k, v := range labels { + attrs = append(attrs, attribute.String(k, v)) + } + return attrs +} + +func labelsKey(labels map[string]string) string { + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + + result := "" + for _, k := range keys { + result += k + "=" + labels[k] + ";" + } + return result +} diff --git a/otel_integration_test.go b/otel_integration_test.go new file mode 100644 index 0000000..0b3e48f --- /dev/null +++ b/otel_integration_test.go @@ -0,0 +1,768 @@ +//go:build integration + +package metrics + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/api" + promv1 "github.com/prometheus/client_golang/api/prometheus/v1" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/prometheus/common/model" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" + promexporter "go.opentelemetry.io/otel/exporters/prometheus" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/exemplar" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +const promConfig = ` +global: + scrape_interval: 15s + evaluation_interval: 15s +` + +func startPrometheus(t *testing.T, ctx context.Context) string { + t.Helper() + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "prom/prometheus:v3.0.0", + ExposedPorts: []string{"9090/tcp"}, + Cmd: []string{ + "--config.file=/etc/prometheus/prometheus.yml", + "--web.enable-otlp-receiver", + "--enable-feature=native-histograms", + }, + Files: []testcontainers.ContainerFile{ + { + Reader: strings.NewReader(promConfig), + ContainerFilePath: "/etc/prometheus/prometheus.yml", + FileMode: 0o644, + }, + }, + WaitingFor: wait.ForHTTP("/-/ready").WithPort("9090/tcp").WithStartupTimeout(30 * time.Second), + }, + Started: true, + }) + if err != nil { + t.Fatalf("failed to start prometheus container: %v", err) + } + + t.Cleanup(func() { + if err := container.Terminate(context.Background()); err != nil { + t.Logf("failed to terminate prometheus container: %v", err) + } + }) + + host, err := container.Host(ctx) + if err != nil { + t.Fatalf("failed to get container host: %v", err) + } + port, err := container.MappedPort(ctx, "9090/tcp") + if err != nil { + t.Fatalf("failed to get mapped port: %v", err) + } + + return fmt.Sprintf("%s:%s", host, port.Port()) +} + +func setupOTELSDK(t *testing.T, ctx context.Context, promEndpoint string) *sdkmetric.MeterProvider { + t.Helper() + + // Trace provider with AlwaysSample — needed for exemplar generation + traceProvider := sdktrace.NewTracerProvider( + sdktrace.WithSampler(sdktrace.AlwaysSample()), + ) + otel.SetTracerProvider(traceProvider) + t.Cleanup(func() { + traceProvider.Shutdown(context.Background()) + }) + + // OTLP HTTP exporter pointing at Prometheus's OTLP receiver + // Prometheus requires cumulative temporality + exporter, err := otlpmetrichttp.New(ctx, + otlpmetrichttp.WithEndpoint(promEndpoint), + otlpmetrichttp.WithURLPath("/api/v1/otlp/v1/metrics"), + otlpmetrichttp.WithInsecure(), + otlpmetrichttp.WithTemporalitySelector(func(sdkmetric.InstrumentKind) metricdata.Temporality { + return metricdata.CumulativeTemporality + }), + ) + if err != nil { + t.Fatalf("failed to create OTLP exporter: %v", err) + } + + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter, + sdkmetric.WithInterval(1*time.Second), + )), + sdkmetric.WithView(sdkmetric.NewView( + sdkmetric.Instrument{Kind: sdkmetric.InstrumentKindHistogram}, + sdkmetric.Stream{ + Aggregation: sdkmetric.AggregationBase2ExponentialHistogram{ + MaxSize: 160, + MaxScale: 20, + }, + }, + )), + sdkmetric.WithExemplarFilter(exemplar.AlwaysOnFilter), + ) + + t.Cleanup(func() { + meterProvider.Shutdown(context.Background()) + }) + + return meterProvider +} + +func queryWithRetry(t *testing.T, promAPI promv1.API, query string, maxRetries int, retryDelay time.Duration) model.Value { + t.Helper() + var result model.Value + var err error + for i := 0; i < maxRetries; i++ { + result, _, err = promAPI.Query(context.Background(), query, time.Now()) + if err == nil { + if vec, ok := result.(model.Vector); ok && len(vec) > 0 { + return result + } + } + time.Sleep(retryDelay) + } + t.Fatalf("query %q returned no results after %d retries: err=%v, result=%v", + query, maxRetries, err, result) + return nil +} + +func TestOTELDirectPush(t *testing.T) { + ctx := context.Background() + + // Phase 1: Start Prometheus with OTLP receiver and native histograms + promEndpoint := startPrometheus(t, ctx) + t.Logf("Prometheus running at %s", promEndpoint) + + // Phase 2: Set up OTEL SDK with OTLP exporter and exponential histograms + meterProvider := setupOTELSDK(t, ctx, promEndpoint) + + // Phase 3: Record metrics via the library + reporter := OTELReporter(ReporterConf{ + System: "test", + Subsystem: "integration", + }, WithMeterProvider(meterProvider)) + + counter := reporter.Counter(MetricConf{ + Path: "requests", + Labels: []string{"method"}, + Help: "Total requests", + }) + + observer := reporter.Observer(MetricConf{ + Path: "request_duration_seconds", + Labels: []string{"endpoint"}, + Help: "Request duration in seconds", + }) + + tracer := otel.Tracer("test-tracer") + + // Record counter values with trace context + ctx1, span1 := tracer.Start(ctx, "counter-op") + counter.Count(1, map[string]string{"method": "GET"}, WithContext(ctx1)) + counter.Count(1, map[string]string{"method": "GET"}, WithContext(ctx1)) + counter.Count(1, map[string]string{"method": "POST"}, WithContext(ctx1)) + span1.End() + + // Record histogram values with trace context + histValues := []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5} + for _, v := range histValues { + ctx2, span2 := tracer.Start(ctx, "observe-op") + observer.Observe(v, map[string]string{"endpoint": "/api"}, WithContext(ctx2)) + span2.End() + } + + // Phase 4: Flush metrics to Prometheus + if err := meterProvider.ForceFlush(ctx); err != nil { + t.Fatalf("failed to flush metrics: %v", err) + } + + // Create Prometheus API client + client, err := api.NewClient(api.Config{ + Address: fmt.Sprintf("http://%s", promEndpoint), + }) + if err != nil { + t.Fatalf("failed to create prometheus client: %v", err) + } + promAPI := promv1.NewAPI(client) + + // Wait for Prometheus to ingest + time.Sleep(3 * time.Second) + + // Phase 5: Verify + t.Run("counter_values", func(t *testing.T) { + result := queryWithRetry(t, promAPI, `test_integration_requests_total`, 10, 2*time.Second) + vec := result.(model.Vector) + + if len(vec) < 2 { + t.Fatalf("expected at least 2 series, got %d", len(vec)) + } + + counts := map[string]float64{} + for _, sample := range vec { + method := string(sample.Metric["method"]) + counts[method] = float64(sample.Value) + } + + if counts["GET"] != 2 { + t.Errorf("expected GET count=2, got %v", counts["GET"]) + } + if counts["POST"] != 1 { + t.Errorf("expected POST count=1, got %v", counts["POST"]) + } + }) + + t.Run("histogram_buckets", func(t *testing.T) { + // Verify observation count + countResult := queryWithRetry(t, promAPI, `histogram_count(test_integration_request_duration_seconds)`, 10, 2*time.Second) + countVec := countResult.(model.Vector) + count := float64(countVec[0].Value) + if count != 7 { + t.Errorf("expected histogram count=7, got %v", count) + } + + // Verify sum + sumResult := queryWithRetry(t, promAPI, `histogram_sum(test_integration_request_duration_seconds)`, 10, 2*time.Second) + sumVec := sumResult.(model.Vector) + sum := float64(sumVec[0].Value) + expectedSum := 0.01 + 0.05 + 0.1 + 0.25 + 0.5 + 1.0 + 2.5 // 4.41 + if diff := sum - expectedSum; diff > 0.01 || diff < -0.01 { + t.Errorf("expected histogram sum≈%.2f, got %v", expectedSum, sum) + } + + // Verify quantile — proves real buckets exist (not just +Inf) + p50Result := queryWithRetry(t, promAPI, `histogram_quantile(0.5, test_integration_request_duration_seconds)`, 10, 2*time.Second) + p50Vec := p50Result.(model.Vector) + p50 := float64(p50Vec[0].Value) + // Median of [0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5] is 0.25 + // Exponential buckets give approximate values + if p50 < 0.1 || p50 > 0.5 { + t.Errorf("expected p50 between 0.1 and 0.5, got %v", p50) + } + t.Logf("p50 quantile: %v", p50) + }) + + t.Run("exemplars", func(t *testing.T) { + // Verify the OTEL SDK produces exemplars with trace context. + // Prometheus's OTLP receiver does not yet store exemplars from OTLP push, + // so we verify at the SDK level using a ManualReader. + manualReader := sdkmetric.NewManualReader() + exemplarProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(manualReader), + sdkmetric.WithExemplarFilter(exemplar.AlwaysOnFilter), + ) + defer exemplarProvider.Shutdown(ctx) + + exemplarReporter := OTELReporter(ReporterConf{ + System: "exemplar", + Subsystem: "test", + }, WithMeterProvider(exemplarProvider)) + + exemplarCounter := exemplarReporter.Counter(MetricConf{ + Path: "exemplar_counter", + Labels: []string{"k"}, + }) + + exemplarObserver := exemplarReporter.Observer(MetricConf{ + Path: "exemplar_histogram", + Labels: []string{"k"}, + }) + + // Record counter with trace context + spanCtx, span := tracer.Start(ctx, "exemplar-counter-span") + exemplarCounter.Count(1, map[string]string{"k": "v"}, WithContext(spanCtx)) + span.End() + + // Record histogram with trace context + spanCtx2, span2 := tracer.Start(ctx, "exemplar-histogram-span") + exemplarObserver.Observe(0.5, map[string]string{"k": "v"}, WithContext(spanCtx2)) + span2.End() + + var rm metricdata.ResourceMetrics + if err := manualReader.Collect(ctx, &rm); err != nil { + t.Fatalf("failed to collect: %v", err) + } + + foundCounter := false + foundHistogram := false + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + switch data := m.Data.(type) { + case metricdata.Sum[float64]: + for _, dp := range data.DataPoints { + for _, ex := range dp.Exemplars { + if len(ex.TraceID) > 0 && len(ex.SpanID) > 0 { + foundCounter = true + t.Logf("counter exemplar: traceID=%x spanID=%x", ex.TraceID, ex.SpanID) + } + } + } + case metricdata.Histogram[float64]: + for _, dp := range data.DataPoints { + for _, ex := range dp.Exemplars { + if len(ex.TraceID) > 0 && len(ex.SpanID) > 0 { + foundHistogram = true + t.Logf("histogram exemplar: traceID=%x spanID=%x", ex.TraceID, ex.SpanID) + } + } + } + case metricdata.ExponentialHistogram[float64]: + for _, dp := range data.DataPoints { + for _, ex := range dp.Exemplars { + if len(ex.TraceID) > 0 && len(ex.SpanID) > 0 { + foundHistogram = true + t.Logf("exp histogram exemplar: traceID=%x spanID=%x", ex.TraceID, ex.SpanID) + } + } + } + } + } + } + + if !foundCounter { + t.Error("expected counter exemplar with valid trace_id and span_id") + } + if !foundHistogram { + t.Error("expected histogram exemplar with valid trace_id and span_id") + } + }) +} + +func TestExemplarsScrape(t *testing.T) { + ctx := context.Background() + + // Phase 1: Set up trace provider + traceProvider := sdktrace.NewTracerProvider( + sdktrace.WithSampler(sdktrace.AlwaysSample()), + ) + otel.SetTracerProvider(traceProvider) + defer traceProvider.Shutdown(ctx) + + // Phase 2: Set up OTEL Prometheus exporter bridge + metricExporter, err := promexporter.New() + if err != nil { + t.Fatalf("failed to create prometheus exporter: %v", err) + } + + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(metricExporter), + sdkmetric.WithExemplarFilter(exemplar.AlwaysOnFilter), + ) + defer meterProvider.Shutdown(ctx) + + // Phase 3: Start HTTP server exposing /metrics with OpenMetrics format + listener, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + metricsPort := listener.Addr().(*net.TCPAddr).Port + + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.HandlerFor( + prometheus.DefaultGatherer, + promhttp.HandlerOpts{EnableOpenMetrics: true}, + )) + server := &http.Server{Handler: mux} + go server.Serve(listener) + defer server.Close() + + t.Logf("Metrics server on port %d", metricsPort) + + // Phase 4: Record metrics with trace context + reporter := OTELReporter(ReporterConf{ + System: "scrape", + Subsystem: "test", + }, WithMeterProvider(meterProvider)) + + counter := reporter.Counter(MetricConf{ + Path: "requests", + Labels: []string{"method"}, + Help: "Total requests", + }) + + observer := reporter.Observer(MetricConf{ + Path: "duration_seconds", + Labels: []string{"op"}, + Help: "Duration in seconds", + }) + + tracer := otel.Tracer("scrape-test-tracer") + + ctx1, span1 := tracer.Start(ctx, "counter-op") + counter.Count(1, map[string]string{"method": "GET"}, WithContext(ctx1)) + span1.End() + + ctx2, span2 := tracer.Start(ctx, "observe-op") + observer.Observe(0.5, map[string]string{"op": "read"}, WithContext(ctx2)) + span2.End() + + // Phase 5: Start Prometheus configured to scrape our metrics server + // Use testcontainers.HostInternal so this works on both Docker Desktop and Linux CI + scrapeConfig := fmt.Sprintf(` +global: + scrape_interval: 2s + evaluation_interval: 2s + scrape_protocols: + - OpenMetricsText1.0.0 + - OpenMetricsText0.0.1 +scrape_configs: + - job_name: test + scrape_interval: 2s + metrics_path: /metrics + static_configs: + - targets: ['%s:%d'] +`, testcontainers.HostInternal, metricsPort) + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "prom/prometheus:v3.0.0", + ExposedPorts: []string{"9090/tcp"}, + Cmd: []string{ + "--config.file=/etc/prometheus/prometheus.yml", + "--enable-feature=exemplar-storage", + }, + Files: []testcontainers.ContainerFile{ + { + Reader: strings.NewReader(scrapeConfig), + ContainerFilePath: "/etc/prometheus/prometheus.yml", + FileMode: 0o644, + }, + }, + HostAccessPorts: []int{metricsPort}, + WaitingFor: wait.ForHTTP("/-/ready").WithPort("9090/tcp").WithStartupTimeout(30 * time.Second), + }, + Started: true, + }) + if err != nil { + t.Fatalf("failed to start prometheus container: %v", err) + } + defer func() { + if err := container.Terminate(ctx); err != nil { + t.Logf("failed to terminate: %v", err) + } + }() + + host, _ := container.Host(ctx) + port, _ := container.MappedPort(ctx, "9090/tcp") + promEndpoint := fmt.Sprintf("%s:%s", host, port.Port()) + t.Logf("Prometheus running at %s", promEndpoint) + + // Phase 6: Create Prometheus API client and wait for scrape + client, err := api.NewClient(api.Config{ + Address: fmt.Sprintf("http://%s", promEndpoint), + }) + if err != nil { + t.Fatalf("failed to create prometheus client: %v", err) + } + promAPI := promv1.NewAPI(client) + + // Wait for at least one scrape cycle + queryWithRetry(t, promAPI, `scrape_test_requests_total`, 15, 2*time.Second) + + // Phase 7: Query exemplars + t.Run("counter_exemplars", func(t *testing.T) { + results, err := promAPI.QueryExemplars(ctx, + `scrape_test_requests_total`, + time.Now().Add(-5*time.Minute), + time.Now(), + ) + if err != nil { + t.Fatalf("failed to query exemplars: %v", err) + } + + found := false + for _, result := range results { + for _, ex := range result.Exemplars { + t.Logf("counter exemplar: labels=%v value=%v", ex.Labels, ex.Value) + if _, ok := ex.Labels["trace_id"]; ok { + found = true + } + } + } + + if !found { + t.Error("expected counter exemplar with trace_id from Prometheus scrape") + } + }) + + t.Run("histogram_exemplars", func(t *testing.T) { + // Prometheus stores histogram exemplars against _bucket series + results, err := promAPI.QueryExemplars(ctx, + `scrape_test_duration_seconds_bucket`, + time.Now().Add(-5*time.Minute), + time.Now(), + ) + if err != nil { + t.Fatalf("failed to query exemplars: %v", err) + } + + found := false + for _, result := range results { + for _, ex := range result.Exemplars { + t.Logf("histogram exemplar: labels=%v value=%v", ex.Labels, ex.Value) + if _, ok := ex.Labels["trace_id"]; ok { + found = true + } + } + } + + if !found { + t.Error("expected histogram exemplar with trace_id from Prometheus scrape") + } + }) +} + +// TestNativeHistogramExemplars verifies that exponential (native) histograms +// AND exemplars work together end-to-end through Prometheus protobuf scraping. +// This is the only scrape path that supports both features simultaneously: +// - PrometheusProto scrape protocol carries native histogram data +// - Native histograms support exemplars in the protobuf format +// - Prometheus TSDB stores exemplars for native histograms +func TestNativeHistogramExemplars(t *testing.T) { + ctx := context.Background() + + // Phase 1: Set up trace provider + traceProvider := sdktrace.NewTracerProvider( + sdktrace.WithSampler(sdktrace.AlwaysSample()), + ) + otel.SetTracerProvider(traceProvider) + defer traceProvider.Shutdown(ctx) + + // Phase 2: Set up OTEL Prometheus exporter with exponential histograms + metricExporter, err := promexporter.New() + if err != nil { + t.Fatalf("failed to create prometheus exporter: %v", err) + } + + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(metricExporter), + sdkmetric.WithView(sdkmetric.NewView( + sdkmetric.Instrument{Kind: sdkmetric.InstrumentKindHistogram}, + sdkmetric.Stream{ + Aggregation: sdkmetric.AggregationBase2ExponentialHistogram{ + MaxSize: 160, + MaxScale: 20, + }, + }, + )), + sdkmetric.WithExemplarFilter(exemplar.AlwaysOnFilter), + ) + defer meterProvider.Shutdown(ctx) + + // Phase 3: Start HTTP server exposing /metrics with protobuf negotiation + listener, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + metricsPort := listener.Addr().(*net.TCPAddr).Port + + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.HandlerFor( + prometheus.DefaultGatherer, + promhttp.HandlerOpts{EnableOpenMetrics: true}, + )) + server := &http.Server{Handler: mux} + go server.Serve(listener) + defer server.Close() + + t.Logf("Metrics server on port %d", metricsPort) + + // Phase 4: Record metrics with trace context + reporter := OTELReporter(ReporterConf{ + System: "native", + Subsystem: "hist", + }, WithMeterProvider(meterProvider)) + + counter := reporter.Counter(MetricConf{ + Path: "requests", + Labels: []string{"method"}, + Help: "Total requests", + }) + + observer := reporter.Observer(MetricConf{ + Path: "duration_seconds", + Labels: []string{"op"}, + Help: "Duration in seconds", + }) + + tracer := otel.Tracer("native-hist-tracer") + + ctx1, span1 := tracer.Start(ctx, "counter-op") + counter.Count(1, map[string]string{"method": "GET"}, WithContext(ctx1)) + span1.End() + + // Record multiple histogram values with trace context + histValues := []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5} + for _, v := range histValues { + spanCtx, span := tracer.Start(ctx, "observe-op") + observer.Observe(v, map[string]string{"op": "read"}, WithContext(spanCtx)) + span.End() + } + + // Phase 5: Start Prometheus with PrometheusProto scrape protocol + native histograms + // Use testcontainers.HostInternal so this works on both Docker Desktop and Linux CI + scrapeConfig := fmt.Sprintf(` +global: + scrape_interval: 2s + evaluation_interval: 2s + scrape_protocols: + - PrometheusProto + - OpenMetricsText1.0.0 +scrape_configs: + - job_name: test + scrape_interval: 2s + metrics_path: /metrics + static_configs: + - targets: ['%s:%d'] +`, testcontainers.HostInternal, metricsPort) + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "prom/prometheus:v3.0.0", + ExposedPorts: []string{"9090/tcp"}, + Cmd: []string{ + "--config.file=/etc/prometheus/prometheus.yml", + "--enable-feature=native-histograms,exemplar-storage", + }, + Files: []testcontainers.ContainerFile{ + { + Reader: strings.NewReader(scrapeConfig), + ContainerFilePath: "/etc/prometheus/prometheus.yml", + FileMode: 0o644, + }, + }, + HostAccessPorts: []int{metricsPort}, + WaitingFor: wait.ForHTTP("/-/ready").WithPort("9090/tcp").WithStartupTimeout(30 * time.Second), + }, + Started: true, + }) + if err != nil { + t.Fatalf("failed to start prometheus container: %v", err) + } + defer func() { + if err := container.Terminate(ctx); err != nil { + t.Logf("failed to terminate: %v", err) + } + }() + + host, _ := container.Host(ctx) + port, _ := container.MappedPort(ctx, "9090/tcp") + promEndpoint := fmt.Sprintf("%s:%s", host, port.Port()) + t.Logf("Prometheus running at %s", promEndpoint) + + // Phase 6: Create Prometheus API client and wait for scrape + client, err := api.NewClient(api.Config{ + Address: fmt.Sprintf("http://%s", promEndpoint), + }) + if err != nil { + t.Fatalf("failed to create prometheus client: %v", err) + } + promAPI := promv1.NewAPI(client) + + // Wait for data to appear — use counter as readiness signal + queryWithRetry(t, promAPI, `native_hist_requests_total`, 15, 2*time.Second) + + // Phase 7: Verify native histogram buckets + t.Run("native_histogram_quantile", func(t *testing.T) { + // histogram_count works for both classic and native histograms + countResult := queryWithRetry(t, promAPI, `histogram_count(native_hist_duration_seconds)`, 10, 2*time.Second) + countVec := countResult.(model.Vector) + count := float64(countVec[0].Value) + if count != 7 { + t.Errorf("expected histogram count=7, got %v", count) + } + + // histogram_quantile on native histograms proves real bucket resolution + p50Result := queryWithRetry(t, promAPI, `histogram_quantile(0.5, native_hist_duration_seconds)`, 10, 2*time.Second) + p50Vec := p50Result.(model.Vector) + p50 := float64(p50Vec[0].Value) + // Median of [0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5] is 0.25 + if p50 < 0.1 || p50 > 0.5 { + t.Errorf("expected p50 between 0.1 and 0.5, got %v (proves native histogram has real bucket resolution)", p50) + } + t.Logf("native histogram p50 quantile: %v", p50) + }) + + // Phase 8: Verify exemplars from Prometheus + t.Run("counter_exemplars", func(t *testing.T) { + results, err := promAPI.QueryExemplars(ctx, + `native_hist_requests_total`, + time.Now().Add(-5*time.Minute), + time.Now(), + ) + if err != nil { + t.Fatalf("failed to query exemplars: %v", err) + } + + found := false + for _, result := range results { + for _, ex := range result.Exemplars { + t.Logf("counter exemplar: labels=%v value=%v", ex.Labels, ex.Value) + if _, ok := ex.Labels["trace_id"]; ok { + found = true + } + } + } + + if !found { + t.Error("expected counter exemplar with trace_id") + } + }) + + t.Run("native_histogram_exemplars", func(t *testing.T) { + // For native histograms, exemplars are stored against the base metric name + // (no _bucket suffix since native histograms don't have explicit bucket series) + selectors := []string{ + `native_hist_duration_seconds`, + `{__name__=~"native_hist_duration.*"}`, + } + + found := false + for _, selector := range selectors { + results, err := promAPI.QueryExemplars(ctx, + selector, + time.Now().Add(-5*time.Minute), + time.Now(), + ) + if err != nil { + t.Logf("exemplar query %q error: %v", selector, err) + continue + } + t.Logf("exemplar query %q returned %d result(s)", selector, len(results)) + for _, result := range results { + for _, ex := range result.Exemplars { + t.Logf("native histogram exemplar: labels=%v value=%v", ex.Labels, ex.Value) + if _, ok := ex.Labels["trace_id"]; ok { + found = true + } + } + } + if found { + break + } + } + + if !found { + t.Error("expected native histogram exemplar with trace_id — " + + "this proves exponential histograms + exemplars work together via PrometheusProto scrape") + } + }) +} diff --git a/prometheus.go b/prometheus.go index 1bc5c66..ecf81a3 100644 --- a/prometheus.go +++ b/prometheus.go @@ -2,13 +2,13 @@ package metrics import ( "fmt" - "github.com/prometheus/client_golang/prometheus" "sync" + + "github.com/prometheus/client_golang/prometheus" ) type prometheusReporter struct { registry prometheus.Registerer - prefix string namespace, subSystem string constLabels map[string]string availableMetrics map[string]Collector @@ -65,7 +65,9 @@ func (r *prometheusReporter) Counter(conf MetricConf) Counter { }, conf.Labels) if err := r.registry.Register(promCounter); err != nil { - if _, ok := err.(prometheus.AlreadyRegisteredError); !ok { + if registeredErr, ok := err.(prometheus.AlreadyRegisteredError); ok { + promCounter = registeredErr.ExistingCollector.(*prometheus.CounterVec) + } else { panic(err) } } @@ -96,7 +98,9 @@ func (r *prometheusReporter) Gauge(conf MetricConf) Gauge { }, conf.Labels) if err := r.registry.Register(promGauge); err != nil { - if _, ok := err.(prometheus.AlreadyRegisteredError); !ok { + if registeredErr, ok := err.(prometheus.AlreadyRegisteredError); ok { + promGauge = registeredErr.ExistingCollector.(*prometheus.GaugeVec) + } else { panic(err) } } @@ -124,7 +128,9 @@ func (r *prometheusReporter) GaugeFunc(conf MetricConf, f func() float64) GaugeF }, f) if err := r.registry.Register(promGauge); err != nil { - if _, ok := err.(prometheus.AlreadyRegisteredError); !ok { + if registeredErr, ok := err.(prometheus.AlreadyRegisteredError); ok { + promGauge = registeredErr.ExistingCollector.(prometheus.GaugeFunc) + } else { panic(err) } } @@ -157,7 +163,9 @@ func (r *prometheusReporter) Observer(conf MetricConf) Observer { }, conf.Labels) if err := r.registry.Register(promObserver); err != nil { - if _, ok := err.(prometheus.AlreadyRegisteredError); !ok { + if registeredErr, ok := err.(prometheus.AlreadyRegisteredError); ok { + promObserver = registeredErr.ExistingCollector.(*prometheus.SummaryVec) + } else { panic(err) } } @@ -189,7 +197,9 @@ func (r *prometheusReporter) Summary(conf MetricConf) Observer { }, conf.Labels) if err := r.registry.Register(promObserver); err != nil { - if _, ok := err.(prometheus.AlreadyRegisteredError); !ok { + if registeredErr, ok := err.(prometheus.AlreadyRegisteredError); ok { + promObserver = registeredErr.ExistingCollector.(*prometheus.SummaryVec) + } else { panic(err) } } @@ -221,7 +231,9 @@ func (r *prometheusReporter) Histogram(conf MetricConf) Observer { }, conf.Labels) if err := r.registry.Register(promObserver); err != nil { - if _, ok := err.(prometheus.AlreadyRegisteredError); !ok { + if registeredErr, ok := err.(prometheus.AlreadyRegisteredError); ok { + promObserver = registeredErr.ExistingCollector.(*prometheus.HistogramVec) + } else { panic(err) } } @@ -251,7 +263,7 @@ type prometheusCounter struct { counter *prometheus.CounterVec } -func (c *prometheusCounter) Count(value float64, lbs map[string]string) { +func (c *prometheusCounter) Count(value float64, lbs map[string]string, opts ...RecordOption) { c.counter.With(lbs).Add(value) } @@ -263,11 +275,11 @@ type prometheusGauge struct { gauge *prometheus.GaugeVec } -func (g *prometheusGauge) Count(value float64, lbs map[string]string) { +func (g *prometheusGauge) Count(value float64, lbs map[string]string, opts ...RecordOption) { g.gauge.With(lbs).Add(value) } -func (g *prometheusGauge) Set(value float64, lbs map[string]string) { +func (g *prometheusGauge) Set(value float64, lbs map[string]string, opts ...RecordOption) { g.gauge.With(lbs).Set(value) } @@ -287,7 +299,7 @@ type prometheusHistogram struct { observer *prometheus.HistogramVec } -func (c *prometheusHistogram) Observe(value float64, lbs map[string]string) { +func (c *prometheusHistogram) Observe(value float64, lbs map[string]string, opts ...RecordOption) { c.observer.With(lbs).Observe(value) } @@ -299,7 +311,7 @@ type prometheusSummary struct { observer *prometheus.SummaryVec } -func (c *prometheusSummary) Observe(value float64, lbs map[string]string) { +func (c *prometheusSummary) Observe(value float64, lbs map[string]string, opts ...RecordOption) { c.observer.With(lbs).Observe(value) } @@ -319,7 +331,7 @@ func mergeLabels(from map[string]string, to map[string]string) map[string]string if to != nil { for label, val := range to { if _, ok := constLabels[label]; ok { - panic(fmt.Sprintf(`label %s already registred`, label)) + panic(fmt.Sprintf(`label %s already registered`, label)) } constLabels[label] = val } diff --git a/reporter.go b/reporter.go index 4acc326..457bb8b 100644 --- a/reporter.go +++ b/reporter.go @@ -1,48 +1,110 @@ package metrics +import "context" + +// MetricConf configures an individual metric (counter, gauge, or observer). type MetricConf struct { - Path string - Labels []string - Help string + // Path is the metric name suffix. The full name is built as {System}_{Subsystem}_{Path}. + Path string + // Labels are the dynamic label keys that must be provided when recording values. + Labels []string + // Help is a human-readable description of the metric. + Help string + // ConstLabels are static key-value pairs attached to every observation of this metric. ConstLabels map[string]string } +// ReporterConf configures a Reporter instance. type ReporterConf struct { - System string - Subsystem string + // System is the top-level namespace prefix for all metrics created by this reporter. + System string + // Subsystem is appended after System in the metric name hierarchy. + // Sub-reporters created via Reporter.Reporter() concatenate their Subsystem with the parent's. + Subsystem string + // ConstLabels are static key-value pairs attached to every metric created by this reporter. + // Child reporters inherit and merge parent const labels; duplicate keys cause a panic. ConstLabels map[string]string } +// RecordOption applies optional configuration to metric recording calls. +type RecordOption func(*recordOptions) + +// recordOptions holds the resolved configuration from RecordOption functions. +type recordOptions struct { + // ctx carries trace context used by the OTEL backend to generate exemplars. + ctx context.Context +} + +func applyRecordOptions(opts []RecordOption) recordOptions { + o := recordOptions{ctx: context.Background()} + for _, opt := range opts { + opt(&o) + } + return o +} + +// WithContext sets the context for a metric recording call. +// When using the OTEL backend, this propagates trace context as exemplars. +func WithContext(ctx context.Context) RecordOption { + return func(o *recordOptions) { + o.ctx = ctx + } +} + +// Reporter is the central factory for creating metrics. It is backend-agnostic +// interface and swap implementations (Prometheus, OTEL, Noop) +// without changing application code. type Reporter interface { + // Reporter creates a child reporter that inherits the parent's const labels + // and appends to the subsystem prefix. Reporter(ReporterConf) Reporter + // Counter creates or retrieves a monotonically increasing counter metric. Counter(MetricConf) Counter + // Observer creates or retrieves a histogram/summary metric for recording distributions. Observer(MetricConf) Observer + // Gauge creates or retrieves a metric that can go up and down. Gauge(MetricConf) Gauge + // GaugeFunc creates a gauge whose value is computed by the provided function on each collection. GaugeFunc(MetricConf, func() float64) GaugeFunc + // Info returns a description of the reporter backend. Info() string + // UnRegister removes a previously registered metric by its Path. UnRegister(metrics string) } +// Collector is the base interface for all metric types, providing lifecycle management. type Collector interface { + // UnRegister removes this metric from the underlying registry. UnRegister() } +// Counter is a monotonically increasing metric (e.g. total requests, errors). type Counter interface { Collector - Count(value float64, lbs map[string]string) + // Count adds the given value to the counter. Labels must match the keys defined in MetricConf.Labels. + // Pass WithContext(ctx) to attach trace context as exemplars (OTEL backend). + Count(value float64, lbs map[string]string, opts ...RecordOption) } +// Gauge is a metric that can increase and decrease (e.g. active connections, queue depth). type Gauge interface { Collector - Count(value float64, lbs map[string]string) - Set(value float64, lbs map[string]string) + // Count adds the given value to the gauge (use negative values to decrement). + Count(value float64, lbs map[string]string, opts ...RecordOption) + // Set replaces the gauge value with the given absolute value. + Set(value float64, lbs map[string]string, opts ...RecordOption) } +// GaugeFunc is a gauge whose value is computed by a callback function on each collection. type GaugeFunc interface { Collector } +// Observer records value distributions (e.g. request latency, response sizes). +// Backed by a histogram (OTEL, Prometheus Histogram) or summary (Prometheus Summary). type Observer interface { Collector - Observe(value float64, lbs map[string]string) + // Observe records a single value into the distribution. + // Pass WithContext(ctx) to attach trace context as exemplars (OTEL backend). + Observe(value float64, lbs map[string]string, opts ...RecordOption) }