From 9f344f101581ed473f9e1b1691de9d300d3e5fce Mon Sep 17 00:00:00 2001 From: phonkee Date: Thu, 21 May 2026 01:25:44 +0200 Subject: [PATCH 1/7] updates --- README.md | 305 ++++++++++++++++++++++--- apply.go | 2 +- assert.go | 21 +- attribs.go | 2 +- example/example.go | 26 +-- metric.go | 12 +- options.go | 5 +- prometheus.go | 10 +- prometheus_test.go | 540 ++++++++++++++++++++++++++++++++++++++++++--- validate.go | 106 +++++---- 10 files changed, 875 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index 8361f6a..bae9470 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,301 @@ # promh -This small package provides a simple way to declaratively define Prometheus metrics as struct fields, -and then instantiate and automatically register them with the Prometheus client library. +`promh` lets you declare Prometheus metrics as struct fields and register them with a single call. No manual `prometheus.NewCounter(...)` wiring, no forgetting to call `Register` — just annotate your fields and call `New`. -## Usage +## Installation -This is simplified example +```bash +go get github.com/phonkee/promh +``` + +## Quick start ```go package main import ( - "github.com/phonkee/promh" - "github.com/prometheus/client_golang/prometheus" + "github.com/phonkee/promh" + "github.com/prometheus/client_golang/prometheus" ) type Metrics struct { - CounterMetric prometheus.Counter `promh"name=my_counter_metric"` - CounterVecMetric *prometheus.CounterVec `promh"name=my_counter_vec_metric, labels[label1,label2]"` - GaugeMetric prometheus.Gauge `promh"name=my_gauge_metric, namespace=test_namespace, subsystem=test_subsystem"` - GaugeVecMetric *prometheus.GaugeVec `promh"name=my_gauge_vec_metric, labels=[label1,label2]"` -} - -func (m *Metrics) IncCounterMetric() { - m.CounterMetric.Inc() + Requests prometheus.Counter `ph:"name=requests_total, help='total requests handled'"` + RequestsVec *prometheus.CounterVec `ph:"name=requests_by_status, labels[method, status]"` + ActiveConns prometheus.Gauge `ph:"name=active_connections"` + Latency prometheus.Histogram `ph:"name=request_duration_seconds, buckets[0.01, 0.05, 0.1, 0.5, 1.0]"` } func main() { - metrics, err := promh.Register[Metrics](prometheus.DefaultRegisterer, nil) - if err != nil { - panic(err) - } - - // metrics is now initialized and registered with the Prometheus client library - // it is pointer to the struct with initialized metrics, so you can use it directly - metrics.IncCounterMetric() + m, err := promh.New[Metrics](prometheus.DefaultRegisterer, nil) + if err != nil { + panic(err) + } + + m.Requests.Inc() + m.RequestsVec.With(prometheus.Labels{"method": "GET", "status": "200"}).Inc() + m.ActiveConns.Set(42) + m.Latency.Observe(0.123) } +``` + +`New` inspects the struct via reflection, creates each metric, sets the field, and registers everything. The returned pointer is ready to use. +## Supported field types + +| Field type | Prometheus type | +|-------------------------|-------------------| +| `prometheus.Counter` | counter (scalar) | +| `*prometheus.CounterVec`| counter with labels | +| `prometheus.Gauge` | gauge (scalar) | +| `*prometheus.GaugeVec` | gauge with labels | +| `prometheus.Histogram` | histogram (scalar)| +| `*prometheus.HistogramVec` | histogram with labels | + +Interface types (`Counter`, `Gauge`, `Histogram`) are stored by value. Vec types must be pointers. + +## Tag reference + +All tags use the `ph` key. Attributes are comma-separated. Values can be bare identifiers, numbers, single-quoted strings, or double-quoted strings. + +``` +ph:"name=, help='', namespace=, subsystem=" +ph:"labels[, ]" // Vec types only +ph:"buckets[0.05, 0.1, 0.25, 0.5, 1.0]" // Histogram types only +ph:"skip" // exclude field from registration ``` -More advanced example can be found [here](example/example.go) +| Attribute | Applies to | Description | +|--------------|---------------------|-------------| +| `name` | all | Metric name. Falls back to the struct field name if omitted. | +| `help` | all | Help string embedded in the metric descriptor. | +| `namespace` | all | Prometheus namespace prefix. Falls back to `Options.DefaultNamespace`. | +| `subsystem` | all | Prometheus subsystem prefix. Falls back to `Options.DefaultSubsystem`. | +| `labels[…]` | `*Vec` types | Variable label names. | +| `buckets[…]` | Histogram types | Bucket boundaries. Falls back to `Options.DefaultBuckets`. | +| `skip` | any field/embed | Skip this field entirely — do not create or register a metric for it. | + +The final Prometheus metric name is `namespace_subsystem_name` (empty parts are omitted). +### Examples + +```go +type Metrics struct { + // scalar counter — registered as "requests_total" + Requests prometheus.Counter `ph:"name=requests_total, help='total HTTP requests'"` + + // counter with labels — registered as "app_api_requests_total" + RequestsVec *prometheus.CounterVec `ph:"name=requests_total, namespace=app, subsystem=api, labels[method, status_code]"` + + // gauge using field name — registered as "ActiveWorkers" (or renamed via Options.Renamer) + ActiveWorkers prometheus.Gauge + + // histogram with explicit buckets + Latency prometheus.Histogram `ph:"name=latency_seconds, buckets[0.001, 0.01, 0.1, 1.0]"` + + // histogram vec with default buckets from Options + LatencyVec *prometheus.HistogramVec `ph:"name=latency_by_route_seconds, labels[route, method]"` + + // skip this field — will never be registered + Internal prometheus.Counter `ph:"skip"` +} +``` + +## Embedded structs + +Embedded structs (both value and pointer) are recursively walked. This lets you share metric groups across multiple top-level structs. + +```go +type HTTPMetrics struct { + Requests prometheus.Counter `ph:"name=http_requests_total"` + Errors prometheus.Counter `ph:"name=http_errors_total"` +} + +type GRPCMetrics struct { + Calls prometheus.Counter `ph:"name=grpc_calls_total"` + Errors prometheus.Counter `ph:"name=grpc_errors_total"` +} + +type AppMetrics struct { + HTTPMetrics // embedded by value — walked automatically + *GRPCMetrics // embedded by pointer — allocated and walked automatically + LegacyMetrics `ph:"skip"` // skip entire embedded group +} + +m, err := promh.New[AppMetrics](prometheus.DefaultRegisterer, nil) +``` ## Options -Both Register and RegisterExisting accept pointer Options parameter. -You can pass nil if you want to use default options. +`New` and `NewExisting` accept `*Options`. Pass `nil` to use defaults. + +```go +opts := promh.DefaultOptions(). + WithDefaultNamespace("myapp"). + WithDefaultSubsystem("api"). + WithDefaultBuckets(prometheus.DefBuckets). + WithMode(promh.ModeStrict). + WithLogger(promh.StdOutLogger(promh.LogLevelInfo)). + WithRenamer(promh.RenamerCase(promh.RenamerCaseSnake)) + +m, err := promh.New[Metrics](prometheus.DefaultRegisterer, opts) +``` + +| Option | Method | Default | +|----------------------|-------------------------------------|--------------| +| Default namespace | `WithDefaultNamespace(string)` | `""` | +| Default subsystem | `WithDefaultSubsystem(string)` | `""` | +| Default buckets | `WithDefaultBuckets([]float64)` | nil (Prometheus defaults) | +| Mode | `WithMode(Mode)` | `ModeStrict` | +| Logger | `WithLogger(Logger)` | no-op | +| Renamer | `WithRenamer(Renamer)` | nil (no rename) | -All options: +All `With*` methods return a new `*Options` — the receiver is never mutated. -- DefaultNamespace - default namespace if not provided -- DefaultSubsystem - default subsystem if not provided -- DefaultBuckets - default buckets for histogram metrics if not provided -- MetricsRenamer - MetricsRenamer interface to rename metric names. -- Logger - Logger instance (adapters are provided) -- Mode - Mode in which runs (default is strict) +## Mode -## MetricsRenamer +`ModeStrict` (default) returns an error when it detects a likely mistake — for example a `*prometheus.Counter` field (pointer to interface, which is always wrong) or an unexported field that has a `ph` tag. + +`ModeWarning` logs a warning instead of returning an error, which allows the program to continue running despite the misconfiguration. + +```go +// catch mistakes at startup +opts := promh.DefaultOptions().WithMode(promh.ModeStrict) + +// log and continue (useful during migration) +opts := promh.DefaultOptions().WithMode(promh.ModeWarning) +``` + +## Renamer + +When a field has no `name=` tag, `promh` uses the struct field name. A `Renamer` transforms that name before it is used. Renamers are also applied to tag-provided names. + +```go +// convert PascalCase field names to snake_case +opts := promh.DefaultOptions().WithRenamer(promh.RenamerCase(promh.RenamerCaseSnake)) + +type Metrics struct { + RequestsTotal prometheus.Counter // registered as "requests_total" + ActiveWorkers prometheus.Gauge // registered as "active_workers" +} +``` + +### Built-in renamers + +| Function | Effect | +|---------------------------------------|---------------------------------| +| `RenamerCase(RenamerCaseSnake)` | `MyMetric` → `my_metric` | +| `RenamerCase(RenamerCaseCamel)` | `MyMetric` → `myMetric` | +| `RenamerCase(RenamerCaseKebab)` | `MyMetric` → `my-metric` | +| `RenamerPrefix("app_")` | `requests` → `app_requests` | +| `RenamerSuffix("_total")` | `requests` → `requests_total` | +| `RenamerNoop()` | no change | +| `Renamers(r1, r2, …)` | apply renamers left to right | + +### Custom renamer + +```go +type Renamer interface { + RenameMetric(name, field string) string +} +``` + +`name` is the current metric name (possibly already transformed by a previous renamer). `field` is always the original struct field name. Return an empty string to keep the current name. + +```go +custom := promh.RenamerFunc(func(name, field string) string { + return "myapp_" + strings.ToLower(name) +}) + +opts := promh.DefaultOptions().WithRenamer( + promh.Renamers( + promh.RenamerCase(promh.RenamerCaseSnake), + custom, + ), +) +``` + +## Logger + +`promh` logs registration steps and any issues it encounters. The default logger is a no-op. + +```go +// log to stdout at Info level and above +opts := promh.DefaultOptions().WithLogger(promh.StdOutLogger(promh.LogLevelInfo)) + +// log to stderr at Debug level and above +opts := promh.DefaultOptions().WithLogger(promh.StdErrLogger(promh.LogLevelDebug)) + +// adapt an existing zap logger +opts := promh.DefaultOptions().WithLogger(promh.LoggerAdapterZap(zapLogger)) +``` + +Log levels: `LogLevelDebug`, `LogLevelInfo`, `LogLevelWarn`, `LogLevelError`. + +You can also implement the `Logger` interface directly: + +```go +type Logger interface { + Debugf(format string, args ...any) + Infof(format string, args ...any) + Warnf(format string, args ...any) + Errorf(format string, args ...any) +} +``` + +## Validate + +`Validate` checks that every metric field in a struct has been initialized (i.e. is non-nil). This is most useful as a startup assertion after calling `New`. + +```go +m, err := promh.New[Metrics](prometheus.DefaultRegisterer, nil) +if err != nil { + panic(err) +} +if err := promh.Validate(m, true); err != nil { + panic(err) // a field that should have been set is nil +} +``` + +The second argument `withSkips` controls whether fields tagged `ph:"skip"` are excluded from validation (`true` = skip them, `false` = require them to be set too). + +## Register + +`Register` is a lower-level helper for when you have already populated a struct yourself (e.g. by setting some fields manually) and just need to register the collectors it holds. + +```go +inst := &Metrics{} +inst.Requests = prometheus.NewCounter(prometheus.CounterOpts{Name: "requests_total"}) +// ... set other fields ... + +if err := promh.Register(prometheus.DefaultRegisterer, inst); err != nil { + panic(err) +} +``` + +## Must / MustError + +Convenience wrappers for use in `main` or test setup where panicking on error is acceptable. + +```go +// panics if err != nil, otherwise returns the value +m := promh.Must(promh.New[Metrics](reg, opts)) + +// panics if err == nil (useful for asserting that something must fail) +err := promh.MustError(promh.New[BadMetrics](reg, opts)) +``` + +## Testing + +`TrackingRegistry` wraps a standard Prometheus registry and records every descriptor that gets registered. It is useful in tests for asserting which metrics were registered, including Vec metrics that have no observations yet. + +```go +reg := promh.NewTrackingRegistry() +m, err := promh.New[Metrics](reg, nil) +// ... +``` +`AssertMetricExists(t, reg, "metric_name")` and `AssertMetricsCount(t, reg, n)` are provided as test helpers. ## Author -Peter Vrba \ No newline at end of file +Peter Vrba diff --git a/apply.go b/apply.go index 14838cb..90e3742 100644 --- a/apply.go +++ b/apply.go @@ -45,7 +45,7 @@ func apply(instance any, opts *Options) ([]*collectorInfo, error) { opts.Logger.Debugf("applying anonymous field `%s` of type `%v`", fieldType.Name, fieldType.Type) if hasTag { - ae, err := attribEmbedded.Parse(tag) + ae, err := attribEmbedded.Parse(tag, true) if err != nil { return nil, err } diff --git a/assert.go b/assert.go index f2eb977..337282d 100644 --- a/assert.go +++ b/assert.go @@ -2,13 +2,16 @@ package promh import ( "fmt" + "strings" "testing" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" ) -// AssertMetricExists is a helper function to assert that a metric with given name exists in the gatherer +// AssertMetricExists asserts that a metric with the given fully-qualified name was gathered from +// gatherer. For Vec types that have no observations, it also checks registered descriptors if +// gatherer is a *TrackingRegistry. func AssertMetricExists(t *testing.T, gatherer prometheus.Gatherer, name string) { t.Helper() @@ -20,10 +23,24 @@ func AssertMetricExists(t *testing.T, gatherer prometheus.Gatherer, name string) } } + // Vec metrics with no observations don't appear in Gather; fall back to descriptor check. + if tr, ok := gatherer.(*TrackingRegistry); ok { + tr.mu.Lock() + descs := make([]*prometheus.Desc, len(tr.descs)) + copy(descs, tr.descs) + tr.mu.Unlock() + needle := fmt.Sprintf("fqName: %q", name) + for _, d := range descs { + if strings.Contains(d.String(), needle) { + return + } + } + } + assert.Fail(t, fmt.Sprintf("Metric %s not found", name)) } -// AssertMetricsCount is a helper function to assert that a metric with given name exists in the gatherer and has expected count of metrics +// AssertMetricsCount asserts that exactly expectedCount metrics were gathered from gatherer. func AssertMetricsCount(t *testing.T, gatherer prometheus.Gatherer, expectedCount int) { t.Helper() g, err := gatherer.Gather() diff --git a/attribs.go b/attribs.go index fc88645..d1117a4 100644 --- a/attribs.go +++ b/attribs.go @@ -4,7 +4,7 @@ import "github.com/phonkee/attribs" const ( // tagName for ph definitions - tagName = "promh" + tagName = "ph" ) // AttrSkip defines skip ability diff --git a/example/example.go b/example/example.go index e9ebe79..97e7453 100644 --- a/example/example.go +++ b/example/example.go @@ -6,7 +6,7 @@ import ( ) type Common struct { - Base prometheus.Counter `promh"name=base_total"` + Base prometheus.Counter `ph:"name=base_total"` } func (c *Common) IncBase() { @@ -24,13 +24,13 @@ type Skipped struct { type Example struct { Common *Common2 - Skipped `promh"skip"` // skip this embedded field - MetRequests prometheus.Counter `promh"name=requests_total"` - MetRequestsNamed *prometheus.CounterVec `promh"name=requests_named_total, help=\"help is on the way\", namespace=sbs, labels[one, two, 'three']"` - MetCurrentCount prometheus.Gauge `promh"name=current_count"` - MetCurrentGauge *prometheus.GaugeVec `promh"name=current_gauge_vec, labels[one, two, \"seventeen-other\"]"` - MetHistogram prometheus.Histogram `promh"name=histogram, namespace=test_namespace, subsystem=test_subsystem, help='histogram help in single quotes'"` - MetHistogramVec *prometheus.HistogramVec `promh"name=histogram_vec, namespace=sbs, buckets[.1, 0.2, 0.3]"` + Skipped `ph:"skip"` // skip this embedded field + MetRequests prometheus.Counter `ph:"name=requests_total"` + MetRequestsNamed *prometheus.CounterVec `ph:"name=requests_named_total, help=\"help is on the way\", namespace=sbs, labels[one, two, 'three']"` + MetCurrentCount prometheus.Gauge `ph:"name=current_count"` + MetCurrentGauge *prometheus.GaugeVec `ph:"name=current_gauge_vec, labels[one, two, \"seventeen-other\"]"` + MetHistogram prometheus.Histogram `ph:"name=histogram, namespace=test_namespace, subsystem=test_subsystem, help='histogram help in single quotes'"` + MetHistogramVec *prometheus.HistogramVec `ph:"name=histogram_vec, namespace=sbs, buckets[.1, 0.2, 0.3]"` } func (e *Example) IncRequests() { @@ -39,12 +39,12 @@ func (e *Example) IncRequests() { // ExampleSuggest shows how ph can suggest some errors type ExampleSuggest struct { - Requests *prometheus.Counter `promh"name=requests_total, help='help is on the way', namespace=sbs"` - RequestsNamed prometheus.CounterVec `promh"name=requests_named_total, help='help is on the way', namespace=sbs, labels[one, two, 'three']"` - CurrentCount *prometheus.Gauge `promh"name=current_count"` - CurrentGauge prometheus.GaugeVec `promh"name=current_gauge_vec, labels[one, two, 'seventeen']"` + Requests *prometheus.Counter `ph:"name=requests_total, help='help is on the way', namespace=sbs"` + RequestsNamed prometheus.CounterVec `ph:"name=requests_named_total, help='help is on the way', namespace=sbs, labels[one, two, 'three']"` + CurrentCount *prometheus.Gauge `ph:"name=current_count"` + CurrentGauge prometheus.GaugeVec `ph:"name=current_gauge_vec, labels[one, two, 'seventeen']"` HistogramNamed *prometheus.Histogram // Even no tag set, it's enabled (you can use skip) - HistogramVec prometheus.HistogramVec `promh"name=histogram_vec, namespace=sbs"` + HistogramVec prometheus.HistogramVec `ph:"name=histogram_vec, namespace=sbs"` } func main() { diff --git a/metric.go b/metric.go index f730eaa..30bdd93 100644 --- a/metric.go +++ b/metric.go @@ -96,7 +96,7 @@ func metricField(target reflect.Value, field reflect.Value, fieldType reflect.St } func newMetricCounter(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribCounter.Parse(tag) + attribs, err := attribCounter.Parse(tag, false) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidTag, err) } @@ -116,7 +116,7 @@ func newMetricCounter(name string, tag string, opts *Options, info *collectorInf } func newMetricCounterVec(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribCounterVec.Parse(tag) + attribs, err := attribCounterVec.Parse(tag, false) if err != nil { return nil, err } @@ -136,7 +136,7 @@ func newMetricCounterVec(name string, tag string, opts *Options, info *collector } func newMetricGauge(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribGauge.Parse(tag) + attribs, err := attribGauge.Parse(tag, false) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidTag, err) } @@ -155,7 +155,7 @@ func newMetricGauge(name string, tag string, opts *Options, info *collectorInfo) } func newMetricGaugeVec(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribGaugeVec.Parse(tag) + attribs, err := attribGaugeVec.Parse(tag, false) if err != nil { return nil, err } @@ -175,7 +175,7 @@ func newMetricGaugeVec(name string, tag string, opts *Options, info *collectorIn } func newMetricHistogram(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribHistogram.Parse(tag) + attribs, err := attribHistogram.Parse(tag, false) if err != nil { return nil, err } @@ -196,7 +196,7 @@ func newMetricHistogram(name string, tag string, opts *Options, info *collectorI } func newMetricHistogramVec(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribHistogramVec.Parse(tag) + attribs, err := attribHistogramVec.Parse(tag, false) if err != nil { return nil, err } diff --git a/options.go b/options.go index c0547f4..4b08022 100644 --- a/options.go +++ b/options.go @@ -44,6 +44,7 @@ func (o *Options) clone() *Options { copy(result.DefaultBuckets, o.DefaultBuckets) result.Renamer = o.Renamer result.Logger = o.Logger + result.Mode = o.Mode return result } @@ -130,14 +131,14 @@ func (o *Options) WithLogger(logger Logger) *Options { // WithMode sets the mode for the options and returns the modified options func (o *Options) WithMode(mode Mode) *Options { - result := o.unwrapOrDefault() + result := o.unwrapOrDefault().clone() result.Mode = mode return result } // WithRenamer sets the renamer for the options and returns the modified options func (o *Options) WithRenamer(renamer Renamer) *Options { - result := o.unwrapOrDefault() + result := o.unwrapOrDefault().clone() result.Renamer = renamer return result } diff --git a/prometheus.go b/prometheus.go index cccee19..68ab850 100644 --- a/prometheus.go +++ b/prometheus.go @@ -52,20 +52,16 @@ func register(registerer prometheus.Registerer, val reflect.Value, depth int) er continue } - var collector prometheus.Collector - switch fieldType.Type { case counterTyp, counterVecTyp, gaugeTyp, gaugeVecTyp, histogramTyp, histogramVecTyp: - collector = field.Interface().(prometheus.Collector) + if err := registerer.Register(field.Interface().(prometheus.Collector)); err != nil { + return err + } default: if err := register(registerer, field, depth+1); err != nil { return err } } - - if err := registerer.Register(collector); err != nil { - return err - } } return nil diff --git a/prometheus_test.go b/prometheus_test.go index 0f4d57d..50d63c7 100644 --- a/prometheus_test.go +++ b/prometheus_test.go @@ -3,59 +3,533 @@ package promh import ( "testing" - "github.com/davecgh/go-spew/spew" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRegister(t *testing.T) { type RegisterExample struct { - MetRequests prometheus.Counter `promh"name=requests_total"` - MetRequestsNamed *prometheus.CounterVec `promh"name=requests_named_total, help=\"help is on the way\", namespace=sbs, labels[one, two, 'three']"` - MetCurrentCount prometheus.Gauge `promh"name=current_count"` - MetCurrentGauge *prometheus.GaugeVec `promh"name=current_gauge_vec, labels[one, two, \"seventeen-other\"]"` - MetHistogram prometheus.Histogram `promh"name=histogram, namespace=test_namespace, subsystem=test_subsystem, help='histogram help in single quotes'"` - MetHistogramVec *prometheus.HistogramVec `promh"name=histogram_vec, namespace=sbs, buckets[.1, 0.2, 0.3]"` + MetRequests prometheus.Counter `ph:"name=requests_total"` + MetRequestsNamed *prometheus.CounterVec `ph:"name=requests_named_total, help='help is on the way', namespace=sbs, labels[one, two, three]"` + MetCurrentCount prometheus.Gauge `ph:"name=current_count"` + MetCurrentGauge *prometheus.GaugeVec `ph:"name=current_gauge_vec, labels[one, two, seventeen_other]"` + MetHistogram prometheus.Histogram `ph:"name=histogram, namespace=test_namespace, subsystem=test_subsystem, help='histogram help in single quotes'"` + MetHistogramVec *prometheus.HistogramVec `ph:"name=histogram_vec, namespace=sbs, buckets[0.1, 0.2, 0.3]"` } - t.Run("test register count", func(t *testing.T) { - // new empty registry + t.Run("register and gather all metric types", func(t *testing.T) { reg := NewTrackingRegistry() - inst := &RegisterExample{} - - _, err := apply(inst, DefaultOptions().WithLogger(StdOutLogger(LogLevelDebug))) - assert.NoError(t, err) - - inst.MetRequestsNamed = prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "hello", - }, nil) + m, err := New[RegisterExample](reg, nil) + require.NoError(t, err) + require.NotNil(t, m) - assert.NoError(t, Register(reg, inst)) - - spew.Dump(reg.descs) - - //DebugGather(reg) + AssertMetricExists(t, reg, "requests_total") + AssertMetricExists(t, reg, "sbs_requests_named_total") + AssertMetricExists(t, reg, "current_count") + AssertMetricExists(t, reg, "current_gauge_vec") + AssertMetricExists(t, reg, "test_namespace_test_subsystem_histogram") + AssertMetricExists(t, reg, "sbs_histogram_vec") - // we have fresh registerer so we can do this - //AssertMetricsCount(t, reg, 2) + // Add observations so the Vec metrics appear in Gather too. + m.MetRequestsNamed.With(prometheus.Labels{"one": "a", "two": "b", "three": "c"}).Inc() + m.MetCurrentGauge.With(prometheus.Labels{"one": "a", "two": "b", "seventeen_other": "c"}).Set(1) + m.MetHistogramVec.With(prometheus.Labels{}).Observe(0.2) + AssertMetricsCount(t, reg, 6) }) - t.Run("test register metrics", func(t *testing.T) { - // new empty registry - reg := prometheus.NewRegistry() + + t.Run("Register function with pre-populated struct", func(t *testing.T) { + reg := NewTrackingRegistry() inst := &RegisterExample{} _, err := apply(inst, DefaultOptions()) - assert.NoError(t, err) + require.NoError(t, err) - assert.NoError(t, Register(reg, inst)) + require.NoError(t, Register(reg, inst)) AssertMetricExists(t, reg, "requests_total") - AssertMetricExists(t, reg, "requests_named_total") + AssertMetricExists(t, reg, "sbs_requests_named_total") AssertMetricExists(t, reg, "current_count") AssertMetricExists(t, reg, "current_gauge_vec") - AssertMetricExists(t, reg, "current_gauge_vec") - AssertMetricExists(t, reg, "histogram") - AssertMetricExists(t, reg, "histogram_vec") + AssertMetricExists(t, reg, "test_namespace_test_subsystem_histogram") + AssertMetricExists(t, reg, "sbs_histogram_vec") + }) + + t.Run("metrics are functional after New", func(t *testing.T) { + reg := prometheus.NewRegistry() + m, err := New[RegisterExample](reg, nil) + require.NoError(t, err) + + m.MetRequests.Inc() + m.MetCurrentCount.Set(42) + m.MetHistogram.Observe(1.5) + + mfs, err := reg.Gather() + require.NoError(t, err) + assert.NotEmpty(t, mfs) + }) +} + +func TestNew_InvalidInputs(t *testing.T) { + t.Run("nil registerer returns error", func(t *testing.T) { + type S struct{ C prometheus.Counter } + _, err := New[S](nil, nil) + assert.Error(t, err) + }) + + t.Run("non-struct type returns error", func(t *testing.T) { + var s int + _, err := NewExisting[int](prometheus.NewRegistry(), &s, nil) + assert.ErrorIs(t, err, ErrInvalidMetricsStruct) + }) +} + +func TestNew_EmbeddedStructValue(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits_total"` + } + type Outer struct { + Inner + Misses prometheus.Counter `ph:"name=misses_total"` + } + + reg := prometheus.NewRegistry() + m, err := New[Outer](reg, nil) + require.NoError(t, err) + require.NotNil(t, m) + require.NotNil(t, m.Hits) + require.NotNil(t, m.Misses) + + AssertMetricExists(t, reg, "hits_total") + AssertMetricExists(t, reg, "misses_total") + AssertMetricsCount(t, reg, 2) +} + +func TestNew_EmbeddedStructPointer(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits_total"` + } + type Outer struct { + *Inner + Misses prometheus.Counter `ph:"name=misses_total"` + } + + reg := prometheus.NewRegistry() + m, err := New[Outer](reg, nil) + require.NoError(t, err) + require.NotNil(t, m) + require.NotNil(t, m.Inner) + require.NotNil(t, m.Hits) + require.NotNil(t, m.Misses) + + AssertMetricExists(t, reg, "hits_total") + AssertMetricExists(t, reg, "misses_total") +} + +func TestNew_EmbeddedSkip(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits_total"` + } + type Outer struct { + Inner `ph:"skip"` + Others prometheus.Counter `ph:"name=others_total"` + } + + reg := prometheus.NewRegistry() + m, err := New[Outer](reg, nil) + require.NoError(t, err) + require.NotNil(t, m) + + AssertMetricsCount(t, reg, 1) + AssertMetricExists(t, reg, "others_total") +} + +func TestNew_SkipField(t *testing.T) { + type S struct { + Active prometheus.Counter `ph:"name=active_total"` + Ignored prometheus.Counter `ph:"skip"` + } + + reg := prometheus.NewRegistry() + m, err := New[S](reg, nil) + require.NoError(t, err) + require.NotNil(t, m.Active) + assert.Nil(t, m.Ignored) // skip means field is not set + + AssertMetricsCount(t, reg, 1) +} + +func TestNew_NamespaceSubsystem(t *testing.T) { + type S struct { + A prometheus.Counter `ph:"name=alpha, namespace=ns, subsystem=sub"` + B prometheus.Counter `ph:"name=beta"` + } + + opts := DefaultOptions().WithDefaultNamespace("defns").WithDefaultSubsystem("defsub") + reg := prometheus.NewRegistry() + _, err := New[S](reg, opts) + require.NoError(t, err) + + // A overrides namespace/subsystem via tag + AssertMetricExists(t, reg, "ns_sub_alpha") + // B falls back to defaults + AssertMetricExists(t, reg, "defns_defsub_beta") +} + +func TestNew_HistogramBuckets(t *testing.T) { + type S struct { + FromTag prometheus.Histogram `ph:"name=from_tag, buckets[0.1, 0.5, 1.0]"` + FromDefault prometheus.Histogram `ph:"name=from_default"` + } + + customBuckets := []float64{0.01, 0.1, 1.0, 10.0} + opts := DefaultOptions().WithDefaultBuckets(customBuckets) + reg := prometheus.NewRegistry() + _, err := New[S](reg, opts) + require.NoError(t, err) + + AssertMetricExists(t, reg, "from_tag") + AssertMetricExists(t, reg, "from_default") +} + +func TestNew_DuplicateMetricName(t *testing.T) { + type S struct { + A prometheus.Counter `ph:"name=dup_total"` + B prometheus.Counter `ph:"name=dup_total"` + } + + _, err := New[S](prometheus.NewRegistry(), nil) + assert.Error(t, err) +} + +func TestNew_UnexportedFieldWithTag_StrictMode(t *testing.T) { + type S struct { + Exported prometheus.Counter `ph:"name=ok_total"` + unexported prometheus.Counter `ph:"name=bad_total"` + } + + _, err := New[S](prometheus.NewRegistry(), DefaultOptions().WithMode(ModeStrict)) + assert.Error(t, err) +} + +func TestNew_UnexportedFieldWithTag_WarningMode(t *testing.T) { + type S struct { + Exported prometheus.Counter `ph:"name=ok_total"` + unexported prometheus.Counter `ph:"name=bad_total"` + } + + reg := prometheus.NewRegistry() + _, err := New[S](reg, DefaultOptions().WithMode(ModeWarning)) + require.NoError(t, err) + AssertMetricsCount(t, reg, 1) +} + +func TestNew_WrongFieldType_StrictMode(t *testing.T) { + // *prometheus.Counter (pointer to interface) is a common mistake; should error in strict mode + type S struct { + Bad *prometheus.Counter `ph:"name=bad_total"` + } + + _, err := New[S](prometheus.NewRegistry(), DefaultOptions().WithMode(ModeStrict)) + assert.ErrorIs(t, err, ErrImproperlyConfigured) +} + +func TestNew_WrongFieldType_WarningMode(t *testing.T) { + type S struct { + Bad *prometheus.Counter + } + + reg := prometheus.NewRegistry() + _, err := New[S](reg, DefaultOptions().WithMode(ModeWarning)) + require.NoError(t, err) + AssertMetricsCount(t, reg, 0) +} + +func TestOptions_WithModeDoesNotMutate(t *testing.T) { + base := DefaultOptions() + assert.Equal(t, ModeStrict, base.Mode) + + derived := base.WithMode(ModeWarning) + assert.Equal(t, ModeWarning, derived.Mode) + assert.Equal(t, ModeStrict, base.Mode, "WithMode must not mutate the receiver") +} + +func TestOptions_WithRenamerDoesNotMutate(t *testing.T) { + base := DefaultOptions() + assert.Nil(t, base.Renamer) + + derived := base.WithRenamer(RenamerCase(RenamerCaseSnake)) + assert.NotNil(t, derived.Renamer) + assert.Nil(t, base.Renamer, "WithRenamer must not mutate the receiver") +} + +func TestOptions_BuilderChain(t *testing.T) { + opts := DefaultOptions(). + WithDefaultNamespace("ns"). + WithDefaultSubsystem("sub"). + WithDefaultBuckets([]float64{1, 2, 3}). + WithMode(ModeWarning). + WithRenamer(RenamerCase(RenamerCaseSnake)) + + assert.Equal(t, "ns", opts.DefaultNamespace) + assert.Equal(t, "sub", opts.DefaultSubsystem) + assert.Equal(t, []float64{1, 2, 3}, opts.DefaultBuckets) + assert.Equal(t, ModeWarning, opts.Mode) + assert.NotNil(t, opts.Renamer) +} + +func TestRenamer_CaseSnake(t *testing.T) { + r := RenamerCase(RenamerCaseSnake) + assert.Equal(t, "my_counter_total", r.RenameMetric("MyCounterTotal", "MyCounterTotal")) +} + +func TestRenamer_CaseCamel(t *testing.T) { + r := RenamerCase(RenamerCaseCamel) + assert.Equal(t, "myCounter", r.RenameMetric("MyCounter", "MyCounter")) +} + +func TestRenamer_CaseKebab(t *testing.T) { + r := RenamerCase(RenamerCaseKebab) + assert.Equal(t, "my-counter", r.RenameMetric("MyCounter", "MyCounter")) +} + +func TestRenamer_Prefix(t *testing.T) { + r := RenamerPrefix("app_") + assert.Equal(t, "app_requests", r.RenameMetric("requests", "requests")) +} + +func TestRenamer_Suffix(t *testing.T) { + r := RenamerSuffix("_total") + assert.Equal(t, "requests_total", r.RenameMetric("requests", "requests")) +} + +func TestRenamer_EmptyPrefixSuffix_Noop(t *testing.T) { + prefix := RenamerPrefix(" ") + assert.Equal(t, "x", prefix.RenameMetric("x", "x")) + + suffix := RenamerSuffix(" ") + assert.Equal(t, "x", suffix.RenameMetric("x", "x")) +} + +func TestRenamer_Chain(t *testing.T) { + r := Renamers( + RenamerCase(RenamerCaseSnake), + RenamerPrefix("app_"), + RenamerSuffix("_total"), + ) + assert.Equal(t, "app_my_counter_total", r.RenameMetric("MyCounter", "MyCounter")) +} + +func TestRenamer_ChainWithNil(t *testing.T) { + r := Renamers(nil, RenamerPrefix("x_"), nil) + assert.Equal(t, "x_metric", r.RenameMetric("metric", "metric")) +} + +func TestNew_WithSnakeCaseRenamer(t *testing.T) { + type S struct { + RequestsTotal prometheus.Counter `ph:"name=RequestsTotal"` + ActiveSessions prometheus.Gauge + } + + opts := DefaultOptions().WithRenamer(RenamerCase(RenamerCaseSnake)) + reg := prometheus.NewRegistry() + _, err := New[S](reg, opts) + require.NoError(t, err) + + // tag name goes through renamer + AssertMetricExists(t, reg, "requests_total") + // field name goes through renamer when no tag name + AssertMetricExists(t, reg, "active_sessions") +} + +func TestValidate_AllInitialized(t *testing.T) { + type S struct { + C prometheus.Counter `ph:"name=c_total"` + V *prometheus.CounterVec `ph:"name=v_total, labels[a]"` + G prometheus.Gauge `ph:"name=g"` + H prometheus.Histogram `ph:"name=h"` + W *prometheus.HistogramVec `ph:"name=w, labels[b]"` + } + + m, err := New[S](prometheus.NewRegistry(), nil) + require.NoError(t, err) + assert.NoError(t, Validate(m, false)) +} + +func TestValidate_NilCounter(t *testing.T) { + type S struct { + C prometheus.Counter `ph:"name=c_total"` + } + s := &S{} // C is nil interface + err := Validate(s, false) + assert.ErrorIs(t, err, ErrUnsetMetricField) +} + +func TestValidate_NilCounterVec(t *testing.T) { + type S struct { + V *prometheus.CounterVec `ph:"name=v_total, labels[a]"` + } + s := &S{} // V is nil pointer + err := Validate(s, false) + assert.ErrorIs(t, err, ErrUnsetMetricField) +} + +func TestValidate_NilGauge(t *testing.T) { + type S struct { + G prometheus.Gauge `ph:"name=g"` + } + s := &S{} + assert.ErrorIs(t, Validate(s, false), ErrUnsetMetricField) +} + +func TestValidate_NilHistogram(t *testing.T) { + type S struct { + H prometheus.Histogram `ph:"name=h"` + } + s := &S{} + assert.ErrorIs(t, Validate(s, false), ErrUnsetMetricField) +} + +func TestValidate_NilHistogramVec(t *testing.T) { + type S struct { + W *prometheus.HistogramVec `ph:"name=w, labels[a]"` + } + s := &S{} + assert.ErrorIs(t, Validate(s, false), ErrUnsetMetricField) +} + +func TestValidate_WithSkipsTrue_SkipsSkippedFields(t *testing.T) { + type S struct { + Active prometheus.Counter `ph:"name=active_total"` + Ignored prometheus.Counter `ph:"skip"` + } + // Ignored was skipped by New so it is nil; withSkips=true must not error on it + m, err := New[S](prometheus.NewRegistry(), nil) + require.NoError(t, err) + assert.NoError(t, Validate(m, true)) +} + +func TestValidate_WithSkipsFalse_ReportsSkippedNilFields(t *testing.T) { + type S struct { + Active prometheus.Counter `ph:"name=active_total"` + Ignored prometheus.Counter `ph:"skip"` + } + m, err := New[S](prometheus.NewRegistry(), nil) + require.NoError(t, err) + // withSkips=false means skip-tagged fields are included in validation; Ignored is nil → error + assert.ErrorIs(t, Validate(m, false), ErrUnsetMetricField) +} + +func TestValidate_EmbeddedStruct(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits_total"` + } + type Outer struct { + Inner + Misses prometheus.Counter `ph:"name=misses_total"` + } + + m, err := New[Outer](prometheus.NewRegistry(), nil) + require.NoError(t, err) + assert.NoError(t, Validate(m, false)) +} + +func TestValidate_EmbeddedNilPointerStruct(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits_total"` + } + type Outer struct { + *Inner // nil pointer — no metrics underneath, so Validate should not panic + Misses prometheus.Counter `ph:"name=misses_total"` + } + + // Don't go through New; manually construct with Inner=nil + c := prometheus.NewCounter(prometheus.CounterOpts{Name: "misses_total"}) + s := &Outer{Inner: nil, Misses: c} + assert.NoError(t, Validate(s, false)) +} + +func TestMust_PanicsOnError(t *testing.T) { + assert.Panics(t, func() { + Must(0, ErrInvalidMetricsStruct) + }) +} + +func TestMust_ReturnsValue(t *testing.T) { + v := Must(42, nil) + assert.Equal(t, 42, v) +} + +func TestMustError_PanicsOnNilError(t *testing.T) { + assert.Panics(t, func() { + MustError(42, nil) }) +} + +func TestMustError_ReturnsError(t *testing.T) { + err := MustError(0, ErrInvalidMetricsStruct) + assert.ErrorIs(t, err, ErrInvalidMetricsStruct) +} + +func TestMode_String(t *testing.T) { + assert.Equal(t, "strict", ModeStrict.String()) + assert.Equal(t, "warning", ModeWarning.String()) +} + +func TestMode_IsStrict(t *testing.T) { + assert.True(t, ModeStrict.IsStrict()) + assert.False(t, ModeWarning.IsStrict()) +} + +func TestParentsList_Format(t *testing.T) { + // Flat struct: one parent with just structName + p1 := parentsList{newParent("Example")} + assert.Equal(t, "Example.", p1.format()) + + // Embedded: outer struct with fieldName, then inner struct + p2 := parentsList{ + newParentWithField("Example", "Common"), + newParent("Common"), + } + assert.Equal(t, "Example.Common.", p2.format()) + + // Empty + var p3 parentsList + assert.Equal(t, "", p3.format()) +} + +func TestRegisterFunction_SkipsNilCollectors(t *testing.T) { + // Register should not panic when encountering non-metric nested fields + type Inner struct { + C prometheus.Counter `ph:"name=inner_c"` + } + type Outer struct { + Inner + Other prometheus.Counter `ph:"name=outer_other"` + } + + reg := prometheus.NewRegistry() + m, err := New[Outer](reg, nil) + require.NoError(t, err) + + // Register into a second registry to verify it works + reg2 := prometheus.NewRegistry() + err = Register(reg2, m) + require.NoError(t, err) + + AssertMetricExists(t, reg2, "inner_c") + AssertMetricExists(t, reg2, "outer_other") +} + +func TestNew_GaugeVec(t *testing.T) { + type S struct { + G *prometheus.GaugeVec `ph:"name=latency, labels[method, status]"` + } + reg := prometheus.NewRegistry() + m, err := New[S](reg, nil) + require.NoError(t, err) + require.NotNil(t, m.G) + m.G.With(prometheus.Labels{"method": "GET", "status": "200"}).Set(0.5) + AssertMetricExists(t, reg, "latency") } diff --git a/validate.go b/validate.go index 0ded40a..72dd5d5 100644 --- a/validate.go +++ b/validate.go @@ -10,22 +10,22 @@ import ( var ( invalidCounters = []reflect.Type{ - reflect.TypeOf((*prometheus.Counter)(nil)), + reflect.TypeOf((*prometheus.Counter)(nil)), // *prometheus.Counter (pointer to interface) } invalidCounterVecs = []reflect.Type{ - reflect.TypeOf((*prometheus.CounterVec)(nil)).Elem(), + reflect.TypeOf((*prometheus.CounterVec)(nil)).Elem(), // prometheus.CounterVec (value, not pointer) } invalidGauge = []reflect.Type{ - reflect.TypeOf((*prometheus.Gauge)(nil)), + reflect.TypeOf((*prometheus.Gauge)(nil)), // *prometheus.Gauge (pointer to interface) } invalidGaugeVec = []reflect.Type{ - reflect.TypeOf((*prometheus.GaugeVec)(nil)).Elem(), + reflect.TypeOf((*prometheus.GaugeVec)(nil)).Elem(), // prometheus.GaugeVec (value, not pointer) } invalidHistogram = []reflect.Type{ - reflect.TypeOf((*prometheus.Histogram)(nil)), + reflect.TypeOf((*prometheus.Histogram)(nil)), // *prometheus.Histogram (pointer to interface) } invalidHistogramVec = []reflect.Type{ - reflect.TypeOf((*prometheus.Histogram)(nil)), + reflect.TypeOf((*prometheus.HistogramVec)(nil)).Elem(), // prometheus.HistogramVec (value, not pointer) } ) @@ -80,8 +80,8 @@ func validateFieldSuggest(structType reflect.Type, fieldType reflect.StructField {invalid: invalidCounterVecs, expected: "*prometheus.CounterVec"}, {invalid: invalidGauge, expected: "prometheus.Gauge"}, {invalid: invalidGaugeVec, expected: "*prometheus.GaugeVec"}, - {invalid: invalidHistogram, expected: "prometheus.MetHistogram"}, - {invalid: invalidHistogramVec, expected: "*prometheus.MetHistogramVec"}, + {invalid: invalidHistogram, expected: "prometheus.Histogram"}, + {invalid: invalidHistogramVec, expected: "*prometheus.HistogramVec"}, } { if err := validate(def.invalid, def.expected); err != nil { return err @@ -91,67 +91,50 @@ func validateFieldSuggest(structType reflect.Type, fieldType reflect.StructField return nil } -// Validate checks all the metric fields if they are set -// ignoreSkips tells whether to ignore fields with `skip` tag, if false then it will return error if field with `skip` tag is not set +// Validate checks all the metric fields if they are set. +// withSkips controls whether fields tagged with skip=true are excluded from validation. func Validate[T any](what *T, withSkips bool) error { - val := reflect.ValueOf(what) - typ := reflect.TypeOf(what) - - for typ.Kind() == reflect.Ptr { - typ = typ.Elem() - } - - return validateErased(nil, val, nil, withSkips, 0) + return validateErased(nil, reflect.ValueOf(what), nil, withSkips, 0) } func validateErased(parents parentsList, val reflect.Value, ft *reflect.StructField, withSkips bool, level int) error { - for val.Kind() == reflect.Ptr { - val = val.Elem() - } - if val.IsZero() { + // For known metric types check them directly before any pointer dereferencing. + switch val.Type() { + case counterTyp, gaugeTyp, histogramTyp: + // Interface types: nil means the field was never initialized. + if val.IsNil() { + return unsetFieldError(parents, ft, val) + } + return nil + case counterVecTyp, gaugeVecTyp, histogramVecTyp: + // Pointer types: nil means the field was never initialized. + if val.IsNil() { + return unsetFieldError(parents, ft, val) + } return nil } - if val.Kind() != reflect.Struct { - - var invalid bool - - switch val.Type() { - case counterTyp: - invalid = val.IsNil() - case counterVecTyp: - invalid = val.IsNil() - case gaugeTyp: - invalid = val.IsNil() - case gaugeVecTyp: - invalid = val.IsNil() - case histogramTyp: - invalid = val.IsNil() - case histogramVecTyp: - invalid = val.IsNil() - default: + // Dereference pointer chain (handles embedded struct pointers). + for val.Kind() == reflect.Ptr { + if val.IsNil() { + // A nil embedded struct pointer means nothing to validate underneath. return nil } + val = val.Elem() + } - if invalid { - var ff string - if ft != nil { - ff = ft.Name - } - return fmt.Errorf("%w: `%s%s` of type %q", ErrUnsetMetricField, parents.format(), ff, val.Type().Name()) - } + if val.Kind() != reflect.Struct { return nil } typ := val.Type() - for i := 0; i < typ.NumField(); i++ { fieldType := typ.Field(i) fieldVal := val.Field(i) tag, hasTag := fieldType.Tag.Lookup(tagName) if hasTag { - as, err := attribSkip.ParseKnown(tag) + as, err := attribSkip.Parse(tag, true) if err != nil { return fmt.Errorf("%w: could not parse skip `%s`", ErrImproperlyConfigured, tag) } @@ -161,7 +144,6 @@ func validateErased(parents parentsList, val reflect.Value, ft *reflect.StructFi } var nuParents parentsList - if !fieldType.Anonymous { nuParents = parents.append(newParent(typ.Name())) } else { @@ -176,6 +158,14 @@ func validateErased(parents parentsList, val reflect.Value, ft *reflect.StructFi return nil } +func unsetFieldError(parents parentsList, ft *reflect.StructField, val reflect.Value) error { + var ff string + if ft != nil { + ff = ft.Name + } + return fmt.Errorf("%w: `%s%s` of type %q", ErrUnsetMetricField, parents.format(), ff, val.Type().String()) +} + type parent struct { structName string fieldName *string @@ -196,15 +186,21 @@ func (p *parentsList) append(n *parent) parentsList { return result } +// format produces a dot-separated path prefix for error messages (e.g. "Example.Common."). func (p *parentsList) format() string { + if len(*p) == 0 { + return "" + } builder := strings.Builder{} - for i, par := range *p { - builder.WriteString(par.structName) - if i > 0 && (*p)[i-1].fieldName != nil { + // Write the outermost struct name once. + builder.WriteString((*p)[0].structName) + builder.WriteString(".") + // Then append any embedded field names in order. + for _, par := range *p { + if par.fieldName != nil { + builder.WriteString(*par.fieldName) builder.WriteString(".") - builder.WriteString(*(*p)[i-1].fieldName) } } - builder.WriteString(".") return builder.String() } From 77714d5aa99ee8759428afc5cc3b1bf8680bc049 Mon Sep 17 00:00:00 2001 From: phonkee Date: Thu, 21 May 2026 01:28:54 +0200 Subject: [PATCH 2/7] update --- apply.go | 2 +- assert.go | 2 +- attribs.go | 2 +- collector.go | 2 +- debug.go | 2 +- defaults.go | 2 +- error.go | 2 +- example/example.go | 48 +++++++++++++++++++++++----------------------- go.mod | 2 +- logger.go | 2 +- metric.go | 2 +- mode.go | 2 +- must.go | 2 +- new.go | 2 +- options.go | 2 +- panic.go | 2 +- prometheus.go | 2 +- prometheus_test.go | 2 +- reflect.go | 2 +- renamer.go | 2 +- testing.go | 2 +- validate.go | 2 +- 22 files changed, 45 insertions(+), 45 deletions(-) diff --git a/apply.go b/apply.go index 90e3742..889bc93 100644 --- a/apply.go +++ b/apply.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "errors" diff --git a/assert.go b/assert.go index 337282d..68c899e 100644 --- a/assert.go +++ b/assert.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "fmt" diff --git a/attribs.go b/attribs.go index d1117a4..f3afa3c 100644 --- a/attribs.go +++ b/attribs.go @@ -1,4 +1,4 @@ -package promh +package ph import "github.com/phonkee/attribs" diff --git a/collector.go b/collector.go index 65bac77..436d2d0 100644 --- a/collector.go +++ b/collector.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "fmt" diff --git a/debug.go b/debug.go index e090891..8b4163b 100644 --- a/debug.go +++ b/debug.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "fmt" diff --git a/defaults.go b/defaults.go index 8bcbc8d..6f423a7 100644 --- a/defaults.go +++ b/defaults.go @@ -1,4 +1,4 @@ -package promh +package ph import "github.com/prometheus/client_golang/prometheus" diff --git a/error.go b/error.go index 6b993d7..0850e83 100644 --- a/error.go +++ b/error.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "errors" diff --git a/example/example.go b/example/example.go index 97e7453..d99969d 100644 --- a/example/example.go +++ b/example/example.go @@ -1,7 +1,7 @@ package main import ( - "github.com/phonkee/promh" + "github.com/phonkee/ph" "github.com/prometheus/client_golang/prometheus" ) @@ -49,56 +49,56 @@ type ExampleSuggest struct { func main() { // new empty registry - reg := promh.NewTrackingRegistry() + reg := ph.NewTrackingRegistry() // options for registering - opts := promh.DefaultOptions(). + opts := ph.DefaultOptions(). WithRenamer( - promh.Renamers( - promh.RenamerPrefix("prefix_"), - promh.RenamerSuffix("_suffix"), - promh.RenamerCase(promh.RenamerCaseSnake), + ph.Renamers( + ph.RenamerPrefix("prefix_"), + ph.RenamerSuffix("_suffix"), + ph.RenamerCase(ph.RenamerCaseSnake), ), ). WithDefaultNamespace("ns"). - WithDefaultBuckets(promh.DefaultBuckets). - WithLogger(promh.StdOutLogger(promh.LogLevelDebug)). - WithMode(promh.ModeStrict) + WithDefaultBuckets(ph.DefaultBuckets). + WithLogger(ph.StdOutLogger(ph.LogLevelDebug)). + WithMode(ph.ModeStrict) // now try to register (inspect and register to prometheus registerer - m := promh.Must(promh.New[Example](reg, opts)) + m := ph.Must(ph.New[Example](reg, opts)) m.IncRequests() m.IncBase() // debug what we have - promh.DebugGather(reg) + ph.DebugGather(reg) // this should fail since there are some problems - _ = promh.MustError(promh.New[ExampleSuggest](reg, &promh.Options{ - Renamer: promh.Renamers( - promh.RenamerPrefix("prefix_"), - promh.RenamerSuffix("_suffix"), - promh.RenamerCase(promh.RenamerCaseSnake), + _ = ph.MustError(ph.New[ExampleSuggest](reg, &ph.Options{ + Renamer: ph.Renamers( + ph.RenamerPrefix("prefix_"), + ph.RenamerSuffix("_suffix"), + ph.RenamerCase(ph.RenamerCaseSnake), ), DefaultNamespace: "vmc", - DefaultBuckets: promh.DefaultBuckets, - Logger: promh.StdOutLogger(promh.LogLevelDebug), - Mode: promh.ModeStrict, + DefaultBuckets: ph.DefaultBuckets, + Logger: ph.StdOutLogger(ph.LogLevelDebug), + Mode: ph.ModeStrict, })) // validate what we have (with skips it's fine) - if err := promh.Validate(m, true); err != nil { + if err := ph.Validate(m, true); err != nil { panic(err) } // TODO: fix this validate //// mode just warning - //opts.Mode = promh.ModeWarning + //opts.Mode = ph.ModeWarning // - //m2 := promh.Must(promh.New[ExampleSuggest](reg, opts)) + //m2 := ph.Must(ph.New[ExampleSuggest](reg, opts)) //// validate without skips - //err := promh.Validate(m2, true) + //err := ph.Validate(m2, true) //if err == nil { // panic("should have errored when ignored skips") //} diff --git a/go.mod b/go.mod index ba81627..9f23597 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/phonkee/promh +module github.com/phonkee/ph go 1.26 diff --git a/logger.go b/logger.go index 297d1ca..16d38c6 100644 --- a/logger.go +++ b/logger.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "fmt" diff --git a/metric.go b/metric.go index 30bdd93..0a97545 100644 --- a/metric.go +++ b/metric.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "errors" diff --git a/mode.go b/mode.go index 31eb56b..00b34f3 100644 --- a/mode.go +++ b/mode.go @@ -1,4 +1,4 @@ -package promh +package ph // Mode to run type Mode int diff --git a/must.go b/must.go index 3b15c49..1ecf100 100644 --- a/must.go +++ b/must.go @@ -1,4 +1,4 @@ -package promh +package ph // Must is a helper function to unwrap value and error, panicking if error is not nil. Useful for testing and debugging. func Must[T any](value T, err error) T { diff --git a/new.go b/new.go index aa8cefd..6c678aa 100644 --- a/new.go +++ b/new.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "github.com/prometheus/client_golang/prometheus" diff --git a/options.go b/options.go index 4b08022..c2adab3 100644 --- a/options.go +++ b/options.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "strings" diff --git a/panic.go b/panic.go index f9090f7..33bc962 100644 --- a/panic.go +++ b/panic.go @@ -1,4 +1,4 @@ -package promh +package ph import "fmt" diff --git a/prometheus.go b/prometheus.go index 68ab850..4dea66d 100644 --- a/prometheus.go +++ b/prometheus.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "reflect" diff --git a/prometheus_test.go b/prometheus_test.go index 50d63c7..ad0e889 100644 --- a/prometheus_test.go +++ b/prometheus_test.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "testing" diff --git a/reflect.go b/reflect.go index 128b0b9..c68b6cc 100644 --- a/reflect.go +++ b/reflect.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "fmt" diff --git a/renamer.go b/renamer.go index 894eec9..6f04c5a 100644 --- a/renamer.go +++ b/renamer.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "strings" diff --git a/testing.go b/testing.go index 0e0ac0d..03a07f5 100644 --- a/testing.go +++ b/testing.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "sync" diff --git a/validate.go b/validate.go index 72dd5d5..2b77b9c 100644 --- a/validate.go +++ b/validate.go @@ -1,4 +1,4 @@ -package promh +package ph import ( "fmt" From 9a6470e35859918f01eb0cc4ce4d2ea337604233 Mon Sep 17 00:00:00 2001 From: phonkee Date: Thu, 21 May 2026 01:29:57 +0200 Subject: [PATCH 3/7] update --- README.md | 60 +++++++++++++++++++++++++++---------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index bae9470..b2a5c5b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# promh +# ph -`promh` lets you declare Prometheus metrics as struct fields and register them with a single call. No manual `prometheus.NewCounter(...)` wiring, no forgetting to call `Register` — just annotate your fields and call `New`. +`ph` lets you declare Prometheus metrics as struct fields and register them with a single call. No manual `prometheus.NewCounter(...)` wiring, no forgetting to call `Register` — just annotate your fields and call `New`. ## Installation ```bash -go get github.com/phonkee/promh +go get github.com/phonkee/ph ``` ## Quick start @@ -14,7 +14,7 @@ go get github.com/phonkee/promh package main import ( - "github.com/phonkee/promh" + "github.com/phonkee/ph" "github.com/prometheus/client_golang/prometheus" ) @@ -26,7 +26,7 @@ type Metrics struct { } func main() { - m, err := promh.New[Metrics](prometheus.DefaultRegisterer, nil) + m, err := ph.New[Metrics](prometheus.DefaultRegisterer, nil) if err != nil { panic(err) } @@ -121,7 +121,7 @@ type AppMetrics struct { LegacyMetrics `ph:"skip"` // skip entire embedded group } -m, err := promh.New[AppMetrics](prometheus.DefaultRegisterer, nil) +m, err := ph.New[AppMetrics](prometheus.DefaultRegisterer, nil) ``` ## Options @@ -129,15 +129,15 @@ m, err := promh.New[AppMetrics](prometheus.DefaultRegisterer, nil) `New` and `NewExisting` accept `*Options`. Pass `nil` to use defaults. ```go -opts := promh.DefaultOptions(). +opts := ph.DefaultOptions(). WithDefaultNamespace("myapp"). WithDefaultSubsystem("api"). WithDefaultBuckets(prometheus.DefBuckets). - WithMode(promh.ModeStrict). - WithLogger(promh.StdOutLogger(promh.LogLevelInfo)). - WithRenamer(promh.RenamerCase(promh.RenamerCaseSnake)) + WithMode(ph.ModeStrict). + WithLogger(ph.StdOutLogger(ph.LogLevelInfo)). + WithRenamer(ph.RenamerCase(ph.RenamerCaseSnake)) -m, err := promh.New[Metrics](prometheus.DefaultRegisterer, opts) +m, err := ph.New[Metrics](prometheus.DefaultRegisterer, opts) ``` | Option | Method | Default | @@ -159,19 +159,19 @@ All `With*` methods return a new `*Options` — the receiver is never mutated. ```go // catch mistakes at startup -opts := promh.DefaultOptions().WithMode(promh.ModeStrict) +opts := ph.DefaultOptions().WithMode(ph.ModeStrict) // log and continue (useful during migration) -opts := promh.DefaultOptions().WithMode(promh.ModeWarning) +opts := ph.DefaultOptions().WithMode(ph.ModeWarning) ``` ## Renamer -When a field has no `name=` tag, `promh` uses the struct field name. A `Renamer` transforms that name before it is used. Renamers are also applied to tag-provided names. +When a field has no `name=` tag, `ph` uses the struct field name. A `Renamer` transforms that name before it is used. Renamers are also applied to tag-provided names. ```go // convert PascalCase field names to snake_case -opts := promh.DefaultOptions().WithRenamer(promh.RenamerCase(promh.RenamerCaseSnake)) +opts := ph.DefaultOptions().WithRenamer(ph.RenamerCase(ph.RenamerCaseSnake)) type Metrics struct { RequestsTotal prometheus.Counter // registered as "requests_total" @@ -202,13 +202,13 @@ type Renamer interface { `name` is the current metric name (possibly already transformed by a previous renamer). `field` is always the original struct field name. Return an empty string to keep the current name. ```go -custom := promh.RenamerFunc(func(name, field string) string { +custom := ph.RenamerFunc(func(name, field string) string { return "myapp_" + strings.ToLower(name) }) -opts := promh.DefaultOptions().WithRenamer( - promh.Renamers( - promh.RenamerCase(promh.RenamerCaseSnake), +opts := ph.DefaultOptions().WithRenamer( + ph.Renamers( + ph.RenamerCase(ph.RenamerCaseSnake), custom, ), ) @@ -216,17 +216,17 @@ opts := promh.DefaultOptions().WithRenamer( ## Logger -`promh` logs registration steps and any issues it encounters. The default logger is a no-op. +`ph` logs registration steps and any issues it encounters. The default logger is a no-op. ```go // log to stdout at Info level and above -opts := promh.DefaultOptions().WithLogger(promh.StdOutLogger(promh.LogLevelInfo)) +opts := ph.DefaultOptions().WithLogger(ph.StdOutLogger(ph.LogLevelInfo)) // log to stderr at Debug level and above -opts := promh.DefaultOptions().WithLogger(promh.StdErrLogger(promh.LogLevelDebug)) +opts := ph.DefaultOptions().WithLogger(ph.StdErrLogger(ph.LogLevelDebug)) // adapt an existing zap logger -opts := promh.DefaultOptions().WithLogger(promh.LoggerAdapterZap(zapLogger)) +opts := ph.DefaultOptions().WithLogger(ph.LoggerAdapterZap(zapLogger)) ``` Log levels: `LogLevelDebug`, `LogLevelInfo`, `LogLevelWarn`, `LogLevelError`. @@ -247,11 +247,11 @@ type Logger interface { `Validate` checks that every metric field in a struct has been initialized (i.e. is non-nil). This is most useful as a startup assertion after calling `New`. ```go -m, err := promh.New[Metrics](prometheus.DefaultRegisterer, nil) +m, err := ph.New[Metrics](prometheus.DefaultRegisterer, nil) if err != nil { panic(err) } -if err := promh.Validate(m, true); err != nil { +if err := ph.Validate(m, true); err != nil { panic(err) // a field that should have been set is nil } ``` @@ -267,7 +267,7 @@ inst := &Metrics{} inst.Requests = prometheus.NewCounter(prometheus.CounterOpts{Name: "requests_total"}) // ... set other fields ... -if err := promh.Register(prometheus.DefaultRegisterer, inst); err != nil { +if err := ph.Register(prometheus.DefaultRegisterer, inst); err != nil { panic(err) } ``` @@ -278,10 +278,10 @@ Convenience wrappers for use in `main` or test setup where panicking on error is ```go // panics if err != nil, otherwise returns the value -m := promh.Must(promh.New[Metrics](reg, opts)) +m := ph.Must(ph.New[Metrics](reg, opts)) // panics if err == nil (useful for asserting that something must fail) -err := promh.MustError(promh.New[BadMetrics](reg, opts)) +err := ph.MustError(ph.New[BadMetrics](reg, opts)) ``` ## Testing @@ -289,8 +289,8 @@ err := promh.MustError(promh.New[BadMetrics](reg, opts)) `TrackingRegistry` wraps a standard Prometheus registry and records every descriptor that gets registered. It is useful in tests for asserting which metrics were registered, including Vec metrics that have no observations yet. ```go -reg := promh.NewTrackingRegistry() -m, err := promh.New[Metrics](reg, nil) +reg := ph.NewTrackingRegistry() +m, err := ph.New[Metrics](reg, nil) // ... ``` From 2764a58402f2039d8d0e38c33468fa4b94a5c7d4 Mon Sep 17 00:00:00 2001 From: phonkee Date: Thu, 21 May 2026 01:34:22 +0200 Subject: [PATCH 4/7] Add CLAUDE.md with architecture and development guidance Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9a02c6b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +`ph` (module: `github.com/phonkee/ph`) is a Go library for declaratively defining Prometheus metrics as struct fields. It uses struct tags to inspect fields and auto-register them with the Prometheus client library. + +## Commands + +```bash +# Run all tests +go test ./... + +# Run a single test +go test -run TestRegister ./... + +# Run tests with verbose output +go test -v ./... + +# Build the example +go build ./example/... + +# Run the example +go run ./example/example.go +``` + +## Architecture + +### Core flow + +1. `New[T]` / `NewExisting[T]` (`new.go`) — entry points; call `apply()` then `registerCollectorInfos()` +2. `apply()` (`apply.go`) — walks struct fields via reflection; handles embedded structs recursively; calls `metricField()` for each supported field +3. `metricField()` (`metric.go`) — dispatches to the appropriate `newMetric*` factory based on the field's reflect type; populates a `collectorInfo` +4. `registerCollectorInfos()` (`collector.go`) — iterates `collectorInfo` slice and calls `registerer.Register()` for each + +### Tag parsing + +The `ph` struct tag is parsed via the external `github.com/phonkee/attribs` library (local replace directive points to `../attribs`). Tag attribute types are defined in `attribs.go`: `AttrCommon` (name, help, namespace, subsystem, skip), `AttrLabels` (labels), `AttrHistogram` (buckets). Pre-built parsers (`attribCounter`, `attribCounterVec`, etc.) are package-level vars. + +### Supported field types + +| Field type | Tag struct | +|---|---| +| `prometheus.Counter` (interface) | `AttrCounter` | +| `*prometheus.CounterVec` | `AttrCounterVec` | +| `prometheus.Gauge` (interface) | `AttrGauge` | +| `*prometheus.GaugeVec` | `AttrGaugeVec` | +| `prometheus.Histogram` (interface) | `AttrHistogram` | +| `*prometheus.HistogramVec` | `AttrHistogramVec` | + +Interfaces are stored by value; `Vec` types must be pointers. `validate.go` detects common mistakes (e.g. `*prometheus.Counter` instead of `prometheus.Counter`) and emits a suggestion error. + +### Options & Renamer + +`Options` (`options.go`) controls defaults for namespace, subsystem, buckets, logger, mode, and renamer. All `With*` methods return a cloned options struct (immutable builder style). `Renamer` (`renamer.go`) is an interface applied to field names when no explicit `name=` tag is set; `Renamers()` chains multiple renamers; built-ins: `RenamerCase`, `RenamerPrefix`, `RenamerSuffix`. + +### Mode + +`ModeStrict` (default) returns an error for fields that look wrong (wrong pointer-ness, unexported fields with tags, etc.). `ModeWarning` logs warnings instead of failing. + +### Utilities + +- `Register[T]` (`prometheus.go`) — registers an already-populated struct into a registerer (no metric creation) +- `Validate[T]` (`validate.go`) — checks that all metric fields in a struct are non-nil after `New` +- `Must` / `MustError` (`must.go`) — panic helpers for tests/examples +- `DebugGather` (`debug.go`) — pretty-prints gathered metrics to stdout +- `TrackingRegistry` (`testing.go`) — test helper that wraps `prometheus.Registry` and tracks registered descriptors +- `StdOutLogger` / `StdErrLogger` / `FileLogger` / `LoggerAdapterZap` (`logger.go`) — logger adapters; default is `noopLogger` \ No newline at end of file From a4016b77ac0e82047ff3aafbdf46a77a7a46fd18 Mon Sep 17 00:00:00 2001 From: phonkee Date: Thu, 21 May 2026 01:58:07 +0200 Subject: [PATCH 5/7] update --- apply.go | 10 +++++++--- attribs.go | 3 +++ go.mod | 2 -- go.sum | 2 ++ metric.go | 16 ++++++++++------ prometheus_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ validate.go | 35 +++++++++++++++++++++++++++++++++-- 7 files changed, 96 insertions(+), 13 deletions(-) diff --git a/apply.go b/apply.go index 889bc93..f6a828d 100644 --- a/apply.go +++ b/apply.go @@ -45,7 +45,11 @@ func apply(instance any, opts *Options) ([]*collectorInfo, error) { opts.Logger.Debugf("applying anonymous field `%s` of type `%v`", fieldType.Name, fieldType.Type) if hasTag { - ae, err := attribEmbedded.Parse(tag, true) + if tag == tagSkipValue { + opts.Logger.Infof("skipping embedded struct `%s` because of skip tag", fieldType.Name) + continue + } + ae, err := attribEmbedded.Parse(tag) if err != nil { return nil, err } @@ -82,7 +86,7 @@ func apply(instance any, opts *Options) ([]*collectorInfo, error) { // discard non exported fields if !fieldType.IsExported() { opts.Logger.Warnf("skipping non-exported field `%s`", fieldType.Name) - if opts.Mode.IsStrict() && hasTag { + if opts.Mode.IsStrict() && hasTag && tag != tagSkipValue { return nil, fmt.Errorf("%w: struct `%v` field `%s` is not exported but has `ph` tag, to dismiss message set `skip` on field", ErrImproperlyConfigured, typ.Name(), fieldType.Name) } continue @@ -90,7 +94,7 @@ func apply(instance any, opts *Options) ([]*collectorInfo, error) { // discard values that cannot be set if !val.Field(i).CanSet() { opts.Logger.Warnf("skipping non-settable field `%s`", fieldType.Name) - if opts.Mode.IsStrict() && hasTag { + if opts.Mode.IsStrict() && hasTag && tag != tagSkipValue { return nil, fmt.Errorf("%w: struct `%v` field `%s` is not settable but has `ph` tag, to dismiss message set `skip` on field", ErrImproperlyConfigured, typ.Name(), fieldType.Name) } continue diff --git a/attribs.go b/attribs.go index f3afa3c..8fe2da9 100644 --- a/attribs.go +++ b/attribs.go @@ -5,6 +5,9 @@ import "github.com/phonkee/attribs" const ( // tagName for ph definitions tagName = "ph" + + // tagSkipValue is the shorthand tag value that unconditionally skips a field, like json:"-" + tagSkipValue = "-" ) // AttrSkip defines skip ability diff --git a/go.mod b/go.mod index 9f23597..82d7db7 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,6 @@ module github.com/phonkee/ph go 1.26 -replace github.com/phonkee/attribs => ../attribs - require ( github.com/phonkee/attribs v1.8.0 github.com/prometheus/client_golang v1.23.2 diff --git a/go.sum b/go.sum index d6d5bb8..764c035 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/phonkee/attribs v1.8.0 h1:Gtp/rkmU+jQcSabtwN3lxcBPlmIk4TGOQoRBtRobKT8= +github.com/phonkee/attribs v1.8.0/go.mod h1:czT0y6yKWBpJJyIxEKm1xVY/zrVbpGDr7ud4uWZBp4s= 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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= diff --git a/metric.go b/metric.go index 0a97545..fc821c9 100644 --- a/metric.go +++ b/metric.go @@ -45,6 +45,10 @@ func metricField(target reflect.Value, field reflect.Value, fieldType reflect.St name := fieldType.Name tag, hasTag := fieldType.Tag.Lookup(tagName) + if hasTag && tag == tagSkipValue { + return nil, fmt.Errorf("%w: %v", ErrFieldNotInterested, fieldType.Name) + } + // check which function is good var newCollectorFunc collectorFunc @@ -96,7 +100,7 @@ func metricField(target reflect.Value, field reflect.Value, fieldType reflect.St } func newMetricCounter(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribCounter.Parse(tag, false) + attribs, err := attribCounter.Parse(tag) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidTag, err) } @@ -116,7 +120,7 @@ func newMetricCounter(name string, tag string, opts *Options, info *collectorInf } func newMetricCounterVec(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribCounterVec.Parse(tag, false) + attribs, err := attribCounterVec.Parse(tag) if err != nil { return nil, err } @@ -136,7 +140,7 @@ func newMetricCounterVec(name string, tag string, opts *Options, info *collector } func newMetricGauge(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribGauge.Parse(tag, false) + attribs, err := attribGauge.Parse(tag) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidTag, err) } @@ -155,7 +159,7 @@ func newMetricGauge(name string, tag string, opts *Options, info *collectorInfo) } func newMetricGaugeVec(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribGaugeVec.Parse(tag, false) + attribs, err := attribGaugeVec.Parse(tag) if err != nil { return nil, err } @@ -175,7 +179,7 @@ func newMetricGaugeVec(name string, tag string, opts *Options, info *collectorIn } func newMetricHistogram(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribHistogram.Parse(tag, false) + attribs, err := attribHistogram.Parse(tag) if err != nil { return nil, err } @@ -196,7 +200,7 @@ func newMetricHistogram(name string, tag string, opts *Options, info *collectorI } func newMetricHistogramVec(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribHistogramVec.Parse(tag, false) + attribs, err := attribHistogramVec.Parse(tag) if err != nil { return nil, err } diff --git a/prometheus_test.go b/prometheus_test.go index ad0e889..d823b51 100644 --- a/prometheus_test.go +++ b/prometheus_test.go @@ -521,6 +521,47 @@ func TestRegisterFunction_SkipsNilCollectors(t *testing.T) { AssertMetricExists(t, reg2, "outer_other") } +func TestNew_DashSkipsField(t *testing.T) { + type S struct { + Requests prometheus.Counter `ph:"name=requests_total"` + Ignored prometheus.Counter `ph:"-"` + } + reg := prometheus.NewRegistry() + m, err := New[S](reg, nil) + require.NoError(t, err) + require.NotNil(t, m.Requests) + require.Nil(t, m.Ignored) + AssertMetricExists(t, reg, "requests_total") +} + +func TestNew_DashSkipsEmbeddedStruct(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits_total"` + } + type S struct { + Requests prometheus.Counter `ph:"name=requests_total"` + Inner `ph:"-"` + } + reg := prometheus.NewRegistry() + m, err := New[S](reg, nil) + require.NoError(t, err) + require.NotNil(t, m.Requests) + require.Nil(t, m.Hits) // embedded struct skipped entirely + AssertMetricExists(t, reg, "requests_total") +} + +func TestValidate_DashSkippedFieldNotChecked(t *testing.T) { + type S struct { + Requests prometheus.Counter `ph:"name=requests_total"` + Ignored prometheus.Counter `ph:"-"` + } + reg := prometheus.NewRegistry() + m, err := New[S](reg, nil) + require.NoError(t, err) + // withSkips=true: dash-skipped nil field must not cause an error + require.NoError(t, Validate(m, true)) +} + func TestNew_GaugeVec(t *testing.T) { type S struct { G *prometheus.GaugeVec `ph:"name=latency, labels[method, status]"` diff --git a/validate.go b/validate.go index 2b77b9c..3493667 100644 --- a/validate.go +++ b/validate.go @@ -134,11 +134,11 @@ func validateErased(parents parentsList, val reflect.Value, ft *reflect.StructFi tag, hasTag := fieldType.Tag.Lookup(tagName) if hasTag { - as, err := attribSkip.Parse(tag, true) + skip, err := parseFieldSkip(fieldType, tag) if err != nil { return fmt.Errorf("%w: could not parse skip `%s`", ErrImproperlyConfigured, tag) } - if as.Skip && withSkips { + if skip && withSkips { continue } } @@ -204,3 +204,34 @@ func (p *parentsList) format() string { } return builder.String() } + +// parseFieldSkip extracts the Skip flag from a ph tag using the correct parser for the field type. +// Using the full parser (not attribSkip) ensures unknown attributes like name/help don't cause errors. +func parseFieldSkip(ft reflect.StructField, tag string) (bool, error) { + if tag == tagSkipValue { + return true, nil + } + switch ft.Type { + case counterTyp: + a, err := attribCounter.Parse(tag) + return a.Skip, err + case counterVecTyp: + a, err := attribCounterVec.Parse(tag) + return a.Skip, err + case gaugeTyp: + a, err := attribGauge.Parse(tag) + return a.Skip, err + case gaugeVecTyp: + a, err := attribGaugeVec.Parse(tag) + return a.Skip, err + case histogramTyp: + a, err := attribHistogram.Parse(tag) + return a.Skip, err + case histogramVecTyp: + a, err := attribHistogramVec.Parse(tag) + return a.Skip, err + default: + a, err := attribSkip.Parse(tag) + return a.Skip, err + } +} From d6e3d72557695f4efad89b80d4df5aa37ae0d1b7 Mon Sep 17 00:00:00 2001 From: phonkee Date: Thu, 21 May 2026 02:02:32 +0200 Subject: [PATCH 6/7] update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 82d7db7..a2c3000 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/phonkee/ph go 1.26 require ( - github.com/phonkee/attribs v1.8.0 + github.com/phonkee/attribs v1.9.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/stoewer/go-strcase v1.3.1 diff --git a/go.sum b/go.sum index 764c035..e5c9343 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/phonkee/attribs v1.8.0 h1:Gtp/rkmU+jQcSabtwN3lxcBPlmIk4TGOQoRBtRobKT8= -github.com/phonkee/attribs v1.8.0/go.mod h1:czT0y6yKWBpJJyIxEKm1xVY/zrVbpGDr7ud4uWZBp4s= +github.com/phonkee/attribs v1.9.0 h1:uOa4YulUkdO8o64H+dgrF9SntZ0lGryvAtdvtNfv+qg= +github.com/phonkee/attribs v1.9.0/go.mod h1:oc3fR4BOUGTArUG0grTDF7VnDeu0PInaEWE8BxCwT4M= 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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= From 8f88ea88e4a0b502b9382bdee9462453d17cb9a2 Mon Sep 17 00:00:00 2001 From: phonkee Date: Thu, 21 May 2026 02:07:02 +0200 Subject: [PATCH 7/7] update --- apply.go | 2 +- metric.go | 12 ++++++------ validate.go | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apply.go b/apply.go index f6a828d..dfad940 100644 --- a/apply.go +++ b/apply.go @@ -49,7 +49,7 @@ func apply(instance any, opts *Options) ([]*collectorInfo, error) { opts.Logger.Infof("skipping embedded struct `%s` because of skip tag", fieldType.Name) continue } - ae, err := attribEmbedded.Parse(tag) + ae, err := attribEmbedded.Parse(tag, true) if err != nil { return nil, err } diff --git a/metric.go b/metric.go index fc821c9..1a81831 100644 --- a/metric.go +++ b/metric.go @@ -100,7 +100,7 @@ func metricField(target reflect.Value, field reflect.Value, fieldType reflect.St } func newMetricCounter(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribCounter.Parse(tag) + attribs, err := attribCounter.Parse(tag, false) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidTag, err) } @@ -120,7 +120,7 @@ func newMetricCounter(name string, tag string, opts *Options, info *collectorInf } func newMetricCounterVec(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribCounterVec.Parse(tag) + attribs, err := attribCounterVec.Parse(tag, false) if err != nil { return nil, err } @@ -140,7 +140,7 @@ func newMetricCounterVec(name string, tag string, opts *Options, info *collector } func newMetricGauge(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribGauge.Parse(tag) + attribs, err := attribGauge.Parse(tag, false) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidTag, err) } @@ -159,7 +159,7 @@ func newMetricGauge(name string, tag string, opts *Options, info *collectorInfo) } func newMetricGaugeVec(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribGaugeVec.Parse(tag) + attribs, err := attribGaugeVec.Parse(tag, false) if err != nil { return nil, err } @@ -179,7 +179,7 @@ func newMetricGaugeVec(name string, tag string, opts *Options, info *collectorIn } func newMetricHistogram(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribHistogram.Parse(tag) + attribs, err := attribHistogram.Parse(tag, false) if err != nil { return nil, err } @@ -200,7 +200,7 @@ func newMetricHistogram(name string, tag string, opts *Options, info *collectorI } func newMetricHistogramVec(name string, tag string, opts *Options, info *collectorInfo) (prometheus.Collector, error) { - attribs, err := attribHistogramVec.Parse(tag) + attribs, err := attribHistogramVec.Parse(tag, false) if err != nil { return nil, err } diff --git a/validate.go b/validate.go index 3493667..c4a0f67 100644 --- a/validate.go +++ b/validate.go @@ -213,25 +213,25 @@ func parseFieldSkip(ft reflect.StructField, tag string) (bool, error) { } switch ft.Type { case counterTyp: - a, err := attribCounter.Parse(tag) + a, err := attribCounter.Parse(tag, false) return a.Skip, err case counterVecTyp: - a, err := attribCounterVec.Parse(tag) + a, err := attribCounterVec.Parse(tag, false) return a.Skip, err case gaugeTyp: - a, err := attribGauge.Parse(tag) + a, err := attribGauge.Parse(tag, false) return a.Skip, err case gaugeVecTyp: - a, err := attribGaugeVec.Parse(tag) + a, err := attribGaugeVec.Parse(tag, false) return a.Skip, err case histogramTyp: - a, err := attribHistogram.Parse(tag) + a, err := attribHistogram.Parse(tag, false) return a.Skip, err case histogramVecTyp: - a, err := attribHistogramVec.Parse(tag) + a, err := attribHistogramVec.Parse(tag, false) return a.Skip, err default: - a, err := attribSkip.Parse(tag) + a, err := attribSkip.Parse(tag, true) return a.Skip, err } }