From 84413772df1dc743ac08f3f53e2c29174c0f5fe4 Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Mon, 23 Mar 2026 01:15:21 +0300 Subject: [PATCH] Add obsx library --- AGENTS.md | 87 +++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 42 ++++++++++++++++++++++ README.md | 94 +++++++++++++++++++++++++++++++++++++++++++++++++ config.go | 1 + doc.go | 17 +++++++++ go.mod | 36 +++++++++++++++++++ go.sum | 75 +++++++++++++++++++++++++++++++++++++++ metrics.go | 78 ++++++++++++++++++++++++++++++++++++++++ metrics_test.go | 64 +++++++++++++++++++++++++++++++++ tracer.go | 67 +++++++++++++++++++++++++++++++++++ 10 files changed, 561 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 config.go create mode 100644 doc.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 metrics.go create mode 100644 metrics_test.go create mode 100644 tracer.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..db06997 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,87 @@ +# AGENTS.md — obsx + +Universal guide for AI coding agents working with this codebase. + +## Overview + +`git.codelab.vc/pkg/obsx` is a Go observability library providing Prometheus metrics and OpenTelemetry tracing setup with consistent defaults and isolated registries. + +## Package map + +``` +obsx/ Root — tracing, metrics, config +├── doc.go Package documentation +├── tracer.go SetupTracer, StartSpan, TracerConfig +├── metrics.go Metrics factory, NewMetrics, Counter/Histogram/Gauge, Handler +└── config.go (empty — configs are co-located with their components) +``` + +## Architecture + +``` + ┌──────────────────────┐ + │ obsx │ + └──────────┬───────────┘ + │ + ┌───────────────┴───────────────┐ + │ │ + ┌──────┴──────┐ ┌──────┴──────┐ + │ Tracing │ │ Metrics │ + └──────┬──────┘ └──────┬──────┘ + │ │ + SetupTracer() NewMetrics() + │ │ + ▼ ▼ + OTLP gRPC exporter prometheus.Registry + TracerProvider (process + Go collectors) + (global via otel.Set) │ + │ Counter / Histogram / Gauge + ▼ │ + StartSpan() Handler() → /metrics +``` + +## Common tasks + +### Add a new metric type (e.g., Summary) + +1. Add a method to `Metrics` struct in `metrics.go` +2. Create the Prometheus collector with `m.cfg.Namespace` / `m.cfg.Subsystem` +3. Register via `m.registry.MustRegister()` +4. Return the collector + +### Change the trace exporter (e.g., HTTP instead of gRPC) + +1. Replace `otlptracegrpc.New` in `SetupTracer` with the desired exporter +2. Update `TracerConfig` fields if the new exporter needs different options +3. Keep the same return signature (`shutdown func(context.Context) error`) + +### Add custom resource attributes to traces + +1. Append additional `resource.WithAttributes(...)` options to the `attrs` slice in `SetupTracer` +2. Use `semconv` constants where possible + +## Gotchas + +- **SetupTracer sets the global provider**: calling it multiple times overwrites the previous provider; the old provider is not shut down automatically +- **Isolated registry**: `NewMetrics` creates its own `prometheus.Registry`, not the global default. Metrics registered here will not appear in `promhttp.Handler()` — use `m.Handler()` instead +- **Sampler default**: if `TracerConfig.Sampler` is 0 or negative, it defaults to 1.0 (sample everything), not 0 +- **Insecure gRPC**: `SetupTracer` always uses `otlptracegrpc.WithInsecure()` — no TLS for the OTLP endpoint +- **StartSpan tracer name**: uses the hardcoded tracer name `"obsx"` — all spans created via this helper share the same instrumentation scope + +## Commands + +```bash +go build ./... # compile +go test ./... # all tests +go test -race ./... # tests with race detector +go test -v -run TestName ./... # single test +go vet ./... # static analysis +``` + +## Conventions + +- **Struct-based Config** with `defaults()` method for zero-value defaults +- **Isolated Prometheus registry** — each `Metrics` instance has its own registry +- **stdlib only** testing — no testify, no gomock +- **No functional options** — direct struct configuration +- **Shutdown function** — `SetupTracer` returns a shutdown callback, caller is responsible for calling it diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cbab9c3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# CLAUDE.md — obsx + +## Commands + +```bash +go build ./... # compile +go test ./... # all tests +go test -race ./... # tests with race detector +go test -v -run TestName ./... # single test +go vet ./... # static analysis +``` + +## Architecture + +- **Module**: `git.codelab.vc/pkg/obsx`, Go 1.24, depends on OpenTelemetry and Prometheus client_golang +- **Single package** `obsx` + +### Core patterns + +- **SetupTracer** — creates OTLP gRPC exporter, builds `sdktrace.TracerProvider` with batcher and ratio sampler, sets it as global `otel.TracerProvider` +- **StartSpan** — convenience function using `otel.Tracer("obsx")` to start spans via the global provider +- **TracerConfig** — service name, version, OTLP endpoint, sampling ratio; `defaults()` sets `Sampler` to 1.0 if unset +- **Metrics** — factory that creates Prometheus `CounterVec`, `HistogramVec`, `GaugeVec` with consistent namespace/subsystem +- **NewMetrics** — creates an isolated `prometheus.Registry` pre-loaded with process and Go collectors +- **Handler()** — returns `promhttp.HandlerFor` using the isolated registry +- **Registry()** — exposes the underlying `*prometheus.Registry` for direct access + +### Tracer lifecycle + +- `SetupTracer` returns `shutdown func(context.Context) error` — must be called to flush pending spans +- Shutdown is the `TracerProvider.Shutdown` method directly + +## Conventions + +- Struct-based configs (`TracerConfig`, `MetricsConfig`) with `defaults()` methods +- Isolated Prometheus registry — not the global default, avoids conflicts in tests and multi-tenant setups +- `nil` buckets in `Histogram()` defaults to `prometheus.DefBuckets` +- No functional options — direct struct configuration only + +## See also + +- `AGENTS.md` — universal AI agent guide with package map and common tasks diff --git a/README.md b/README.md new file mode 100644 index 0000000..a671080 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# obsx + +Observability library for Go providing metrics (Prometheus) and tracing (OpenTelemetry) setup with consistent defaults. Creates a Prometheus registry with process/Go collectors, a metrics factory with namespace/subsystem scoping, and an OTLP gRPC tracer provider with configurable sampling. + +``` +go get git.codelab.vc/pkg/obsx +``` + +## Quick start + +```go +// Tracing +shutdown, err := obsx.SetupTracer(ctx, obsx.TracerConfig{ + ServiceName: "my-service", + ServiceVersion: "1.0.0", + Endpoint: "localhost:4317", + Sampler: 1.0, // sample everything +}) +if err != nil { + log.Fatal(err) +} +defer shutdown(ctx) + +// Metrics +metrics := obsx.NewMetrics(obsx.MetricsConfig{ + Namespace: "myapp", + Subsystem: "http", +}) +reqCounter := metrics.Counter("requests_total", "Total HTTP requests", "method", "status") +latency := metrics.Histogram("request_duration_seconds", "Request latency", nil, "method") + +http.Handle("/metrics", metrics.Handler()) +``` + +## Components + +| Component | What it does | +|-----------|-------------| +| `SetupTracer` | Initializes an OpenTelemetry tracer provider with OTLP gRPC exporter, sets it as the global provider. | +| `StartSpan` | Convenience wrapper that starts a span using the global tracer provider. | +| `Metrics` | Factory for creating Prometheus metrics with consistent namespace/subsystem naming. | +| `TracerConfig` | Configures the tracer: service name, version, OTLP endpoint, sampling ratio. | +| `MetricsConfig` | Configures the metrics factory: namespace and subsystem. | + +## Tracing + +`SetupTracer` creates an OTLP gRPC exporter, builds a tracer provider with batched span export and ratio-based sampling, and registers it as the global `otel.TracerProvider`. Returns a shutdown function that must be called on application exit. + +```go +shutdown, err := obsx.SetupTracer(ctx, obsx.TracerConfig{ + ServiceName: "orders-api", + Endpoint: "jaeger:4317", + Sampler: 0.1, // sample 10% +}) +defer shutdown(ctx) +``` + +Start spans with the convenience helper: + +```go +ctx, span := obsx.StartSpan(ctx, "process-order") +defer span.End() +``` + +## Metrics + +`NewMetrics` creates a fresh Prometheus registry (isolated from the global default) pre-loaded with process and Go runtime collectors. All metrics created through the factory inherit the configured namespace and subsystem. + +```go +m := obsx.NewMetrics(obsx.MetricsConfig{Namespace: "myapp", Subsystem: "db"}) + +// Counter +queries := m.Counter("queries_total", "Total queries executed", "operation") +queries.WithLabelValues("select").Inc() + +// Histogram (nil buckets = prometheus.DefBuckets) +latency := m.Histogram("query_duration_seconds", "Query latency", nil, "operation") + +// Gauge +conns := m.Gauge("connections_active", "Active connections", "pool") + +// HTTP handler for /metrics endpoint +http.Handle("/metrics", m.Handler()) +``` + +Access the underlying registry directly: + +```go +registry := m.Registry() +``` + +## Requirements + +Go 1.24+, [OpenTelemetry Go](https://opentelemetry.io/docs/languages/go/), [Prometheus client_golang](https://github.com/prometheus/client_golang). diff --git a/config.go b/config.go new file mode 100644 index 0000000..bdcdf70 --- /dev/null +++ b/config.go @@ -0,0 +1 @@ +package obsx diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..2beaa4b --- /dev/null +++ b/doc.go @@ -0,0 +1,17 @@ +// Package obsx provides observability setup for the platform: metrics (Prometheus) +// and tracing (OpenTelemetry). +// +// # Quick Start +// +// // Tracing +// shutdown, err := obsx.SetupTracer(ctx, obsx.TracerConfig{ +// ServiceName: "my-service", +// Endpoint: "localhost:4317", +// }) +// defer shutdown(ctx) +// +// // Metrics +// metrics := obsx.NewMetrics(obsx.MetricsConfig{Namespace: "myapp"}) +// reqCounter := metrics.Counter("requests_total", "Total requests", "method", "status") +// http.Handle("/metrics", metrics.Handler()) +package obsx diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3b0df9b --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module git.codelab.vc/pkg/obsx + +go 1.25.7 + +require ( + github.com/prometheus/client_golang v1.22.0 + go.opentelemetry.io/otel v1.35.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 + go.opentelemetry.io/otel/sdk v1.35.0 + go.opentelemetry.io/otel/trace v1.35.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // 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.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b4c4e7e --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/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/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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M= +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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +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.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/metrics.go b/metrics.go new file mode 100644 index 0000000..e70e39f --- /dev/null +++ b/metrics.go @@ -0,0 +1,78 @@ +package obsx + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// MetricsConfig configures the metrics factory. +type MetricsConfig struct { + Namespace string + Subsystem string +} + +// Metrics is a factory for creating Prometheus metrics with consistent naming. +type Metrics struct { + cfg MetricsConfig + registry *prometheus.Registry +} + +// NewMetrics creates a new Metrics factory. +func NewMetrics(cfg MetricsConfig) *Metrics { + reg := prometheus.NewRegistry() + reg.MustRegister(prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{})) + reg.MustRegister(prometheus.NewGoCollector()) + return &Metrics{cfg: cfg, registry: reg} +} + +// Counter creates and registers a new CounterVec. +func (m *Metrics) Counter(name, help string, labels ...string) *prometheus.CounterVec { + c := prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: m.cfg.Namespace, + Subsystem: m.cfg.Subsystem, + Name: name, + Help: help, + }, labels) + m.registry.MustRegister(c) + return c +} + +// Histogram creates and registers a new HistogramVec. +func (m *Metrics) Histogram(name, help string, buckets []float64, labels ...string) *prometheus.HistogramVec { + if buckets == nil { + buckets = prometheus.DefBuckets + } + h := prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: m.cfg.Namespace, + Subsystem: m.cfg.Subsystem, + Name: name, + Help: help, + Buckets: buckets, + }, labels) + m.registry.MustRegister(h) + return h +} + +// Gauge creates and registers a new GaugeVec. +func (m *Metrics) Gauge(name, help string, labels ...string) *prometheus.GaugeVec { + g := prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: m.cfg.Namespace, + Subsystem: m.cfg.Subsystem, + Name: name, + Help: help, + }, labels) + m.registry.MustRegister(g) + return g +} + +// Handler returns an http.Handler for the /metrics endpoint. +func (m *Metrics) Handler() http.Handler { + return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{}) +} + +// Registry returns the underlying Prometheus registry. +func (m *Metrics) Registry() *prometheus.Registry { + return m.registry +} diff --git a/metrics_test.go b/metrics_test.go new file mode 100644 index 0000000..6186924 --- /dev/null +++ b/metrics_test.go @@ -0,0 +1,64 @@ +package obsx + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMetrics_Counter(t *testing.T) { + m := NewMetrics(MetricsConfig{Namespace: "test"}) + c := m.Counter("requests_total", "Total requests", "method") + c.WithLabelValues("GET").Inc() + c.WithLabelValues("GET").Inc() + c.WithLabelValues("POST").Inc() + + body := scrape(t, m) + if !strings.Contains(body, `test_requests_total{method="GET"} 2`) { + t.Errorf("expected GET counter=2, got:\n%s", body) + } + if !strings.Contains(body, `test_requests_total{method="POST"} 1`) { + t.Errorf("expected POST counter=1, got:\n%s", body) + } +} + +func TestMetrics_Histogram(t *testing.T) { + m := NewMetrics(MetricsConfig{Namespace: "test"}) + h := m.Histogram("duration_seconds", "Duration", nil, "op") + h.WithLabelValues("query").Observe(0.1) + + body := scrape(t, m) + if !strings.Contains(body, "test_duration_seconds") { + t.Errorf("expected histogram, got:\n%s", body) + } +} + +func TestMetrics_Gauge(t *testing.T) { + m := NewMetrics(MetricsConfig{Namespace: "test"}) + g := m.Gauge("connections", "Active connections", "pool") + g.WithLabelValues("main").Set(42) + + body := scrape(t, m) + if !strings.Contains(body, `test_connections{pool="main"} 42`) { + t.Errorf("expected gauge=42, got:\n%s", body) + } +} + +func TestMetrics_Handler(t *testing.T) { + m := NewMetrics(MetricsConfig{}) + handler := m.Handler() + if handler == nil { + t.Fatal("Handler returned nil") + } +} + +func scrape(t *testing.T, m *Metrics) string { + t.Helper() + w := httptest.NewRecorder() + r, _ := http.NewRequest("GET", "/metrics", nil) + m.Handler().ServeHTTP(w, r) + body, _ := io.ReadAll(w.Body) + return string(body) +} diff --git a/tracer.go b/tracer.go new file mode 100644 index 0000000..d248fc8 --- /dev/null +++ b/tracer.go @@ -0,0 +1,67 @@ +package obsx + +import ( + "context" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + "go.opentelemetry.io/otel/trace" +) + +// TracerConfig configures the OpenTelemetry tracer. +type TracerConfig struct { + ServiceName string + ServiceVersion string + Endpoint string // OTLP gRPC endpoint, e.g. "localhost:4317" + Sampler float64 // sampling ratio, default: 1.0 (sample everything) +} + +func (c *TracerConfig) defaults() { + if c.Sampler <= 0 { + c.Sampler = 1.0 + } +} + +// SetupTracer initializes an OpenTelemetry tracer provider with OTLP gRPC exporter. +// Returns a shutdown function that must be called on application exit. +func SetupTracer(ctx context.Context, cfg TracerConfig) (shutdown func(context.Context) error, err error) { + cfg.defaults() + + exporter, err := otlptracegrpc.New(ctx, + otlptracegrpc.WithEndpoint(cfg.Endpoint), + otlptracegrpc.WithInsecure(), + ) + if err != nil { + return nil, err + } + + attrs := []resource.Option{ + resource.WithAttributes(semconv.ServiceName(cfg.ServiceName)), + } + if cfg.ServiceVersion != "" { + attrs = append(attrs, resource.WithAttributes(semconv.ServiceVersion(cfg.ServiceVersion))) + } + + res, err := resource.New(ctx, attrs...) + if err != nil { + return nil, err + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.TraceIDRatioBased(cfg.Sampler)), + ) + + otel.SetTracerProvider(tp) + + return tp.Shutdown, nil +} + +// StartSpan starts a new span using the global tracer provider. +func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { + return otel.Tracer("obsx").Start(ctx, name, opts...) +}