commit 84413772df1dc743ac08f3f53e2c29174c0f5fe4 Author: Aleksey Shakhmatov Date: Mon Mar 23 01:15:21 2026 +0300 Add obsx library 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...) +}