diff --git a/README.md b/README.md index 9cca840..96a3229 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ ph:"skip" // exclude field from registration | `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. | +| `prefix` | embedded struct | Prepend this string to every metric name inside the embedded struct. | +| `suffix` | embedded struct | Append this string to every metric name inside the embedded struct. | The final Prometheus metric name is `namespace_subsystem_name` (empty parts are omitted). @@ -116,14 +118,134 @@ type GRPCMetrics struct { } type AppMetrics struct { - HTTPMetrics // embedded by value — walked automatically - *GRPCMetrics // embedded by pointer — allocated and walked automatically + HTTPMetrics // embedded by value — walked automatically + *GRPCMetrics // embedded by pointer — allocated and walked automatically LegacyMetrics `ph:"skip"` // skip entire embedded group } m, err := ph.New[AppMetrics](prometheus.DefaultRegisterer, nil) ``` +### Embedded struct prefix and suffix + +A `ph` tag on an embedded struct field may carry `prefix=` and/or `suffix=` attributes. When set, they are prepended / appended to every metric name produced by fields inside that embedded struct — after the global `Renamer` (if any) has already run. Fields declared directly in the outer struct are not affected. + +```go +// Shared metric group — no prefix baked in. +type DBMetrics struct { + Queries prometheus.Counter `ph:"name=queries_total"` + Errors prometheus.Counter `ph:"name=errors_total"` +} + +type CacheMetrics struct { + Hits prometheus.Gauge `ph:"name=hits"` + Misses prometheus.Gauge `ph:"name=misses"` +} + +type ServiceMetrics struct { + DBMetrics `ph:"prefix=db_"` // → db_queries_total, db_errors_total + CacheMetrics `ph:"prefix=cache_"` // → cache_hits, cache_misses + + Uptime prometheus.Gauge `ph:"name=uptime_seconds"` // unaffected +} +``` + +`prefix=` and `suffix=` can be combined in the same tag: + +```go +type Metrics struct { + Inner `ph:"prefix=svc_, suffix=_v2"` // → svc__v2 for every field in Inner +} +``` + +When a global `Renamer` is also configured, the rename step runs first and the embedded prefix/suffix are applied on top: + +```go +// With RenamerCase(snake) + embedded prefix "db_": +// HitsTotal → snake → hits_total → prefix → db_hits_total +type DBCounters struct { + HitsTotal prometheus.Counter +} +type App struct { + DBCounters `ph:"prefix=db_"` +} + +opts := ph.DefaultOptions().WithRenamer(ph.RenamerCase(ph.RenamerCaseSnake)) +m, err := ph.New[App](reg, opts) // registers "db_hits_total" +``` + +### Skipping embedded structs + +The `skip` attribute (or the `ph:"-"` shorthand) can be placed on an embedded struct field to exclude every metric inside that struct from registration. The two forms are equivalent: + +```go +type LegacyMetrics struct { + OldCounter prometheus.Counter `ph:"name=old_counter_total"` +} + +type App struct { + LegacyMetrics `ph:"skip"` // entire group excluded — identical to ph:"-" + Active prometheus.Counter `ph:"name=active_total"` +} + +m, err := ph.New[App](reg, nil) +// Registered: active_total +// Not registered: old_counter_total +``` + +`skip` may also appear alongside `prefix=` or `suffix=` in the same tag — it always wins and the group is dropped entirely: + +```go +type App struct { + // The prefix is parsed but never applied because skip discards the group first. + LegacyMetrics `ph:"prefix=old_, skip"` + Active prometheus.Counter `ph:"name=active_total"` +} +``` + +### Nested embedding + +Prefixes and suffixes stack correctly across multiple levels of embedding. The outermost scope's prefix always comes first; the outermost scope's suffix always comes last — forming a symmetric sandwich around the base name: + +``` +outerPrefix + innerPrefix + renamer(name) + innerSuffix + outerSuffix +``` + +```go +type DBMetrics struct { + Queries prometheus.Counter `ph:"name=queries_total"` + Errors prometheus.Counter `ph:"name=errors_total"` +} + +type CacheMetrics struct { + Hits prometheus.Gauge `ph:"name=hits"` + Misses prometheus.Gauge `ph:"name=misses"` +} + +// ServiceMetrics adds a per-group prefix to each embedded struct. +type ServiceMetrics struct { + DBMetrics `ph:"prefix=db_"` // → db_queries_total, db_errors_total + CacheMetrics `ph:"prefix=cache_"` // → cache_hits, cache_misses + Uptime prometheus.Gauge `ph:"name=uptime_seconds"` +} + +// PlatformMetrics wraps the whole ServiceMetrics group with an extra "svc_" prefix. +// ph accumulates all prefixes so the outermost one is always first in the name. +type PlatformMetrics struct { + ServiceMetrics `ph:"prefix=svc_"` // adds "svc_" on top of existing prefixes + TotalOps prometheus.Counter `ph:"name=platform_total_ops"` +} + +m, err := ph.New[PlatformMetrics](reg, nil) +// Registered names: +// svc_db_queries_total ← three levels: PlatformMetrics → ServiceMetrics → DBMetrics +// svc_db_errors_total +// svc_cache_hits ← three levels: PlatformMetrics → ServiceMetrics → CacheMetrics +// svc_cache_misses +// svc_uptime_seconds ← two levels: PlatformMetrics → ServiceMetrics +// platform_total_ops ← direct field, no embedded prefix applied +``` + ## Options `New` and `NewExisting` accept `*Options`. Pass `nil` to use defaults. @@ -296,12 +418,6 @@ m, err := ph.New[Metrics](reg, nil) `AssertMetricExists(t, reg, "metric_name")` and `AssertMetricsCount(t, reg, n)` are provided as test helpers. -## TODO: - -- [ ] Add support for limited tags for embedded structs (e.g. `ph:"prefix='hello_', suffix="_world"` - - - ## Author Peter Vrba diff --git a/apply.go b/apply.go index dfad940..c3d7fd3 100644 --- a/apply.go +++ b/apply.go @@ -44,6 +44,12 @@ func apply(instance any, opts *Options) ([]*collectorInfo, error) { if fieldType.Anonymous { opts.Logger.Debugf("applying anonymous field `%s` of type `%v`", fieldType.Name, fieldType.Type) + // embeddedOpts is the options used for the recursive apply call on this embedded + // struct. It starts as the parent opts and is only replaced when a prefix or suffix + // is present in the tag, in which case we derive a new options value that + // accumulates the scope prefix/suffix for correct multi-level nesting. + embeddedOpts := opts + if hasTag { if tag == tagSkipValue { opts.Logger.Infof("skipping embedded struct `%s` because of skip tag", fieldType.Name) @@ -57,6 +63,30 @@ func apply(instance any, opts *Options) ([]*collectorInfo, error) { opts.Logger.Infof("skipping embedded struct `%s` because of skip tag", fieldType.Name) continue } + + // When prefix or suffix is provided, derive new options that accumulate + // the scope prefix/suffix so that every metric name produced inside this + // embedded struct is wrapped correctly at every nesting depth. + // + // Prefix stacking: outer prefix is prepended first so the final name + // reads from outermost scope to innermost (e.g. "svc_db_queries_total"). + // derived.embeddedPrefix = existingPrefix + newPrefix + // + // Suffix stacking: symmetric — innermost suffix sits closest to the base + // name and outer suffixes follow, forming a sandwich around the name: + // outer_prefix | inner_prefix | name | inner_suffix | outer_suffix + // derived.embeddedSuffix = newSuffix + existingSuffix + // + // The global Renamer is never touched here; it runs independently inside + // getMetricName before the accumulated prefix/suffix are applied. + if ae.Prefix != "" || ae.Suffix != "" { + derived := opts.clone() + derived.embeddedPrefix = opts.embeddedPrefix + ae.Prefix + derived.embeddedSuffix = ae.Suffix + opts.embeddedSuffix + embeddedOpts = derived + opts.Logger.Debugf("embedded struct `%s` uses prefix=%q suffix=%q (accumulated: prefix=%q suffix=%q)", + fieldType.Name, ae.Prefix, ae.Suffix, derived.embeddedPrefix, derived.embeddedSuffix) + } } // get field value @@ -74,8 +104,8 @@ func apply(instance any, opts *Options) ([]*collectorInfo, error) { opts.Logger.Infof("skipping embedded field %q since it's not struct or pointer to struct", fieldType.Name) continue } - // apply again here - if found, err := apply(field.Interface(), opts); err != nil { + // apply recursively with (potentially derived) options + if found, err := apply(field.Interface(), embeddedOpts); err != nil { return nil, fmt.Errorf("%s: %w", fieldType.Name, err) } else { collectorInfos = append(collectorInfos, found...) diff --git a/assert.go b/assert.go index 68c899e..2f46d87 100644 --- a/assert.go +++ b/assert.go @@ -40,6 +40,37 @@ func AssertMetricExists(t *testing.T, gatherer prometheus.Gatherer, name string) assert.Fail(t, fmt.Sprintf("Metric %s not found", name)) } +// AssertMetricNotExists asserts that no metric with the given fully-qualified name was +// gathered from gatherer. It checks both Gather results and registered descriptors so +// that Vec metrics with no observations are also covered. +func AssertMetricNotExists(t *testing.T, gatherer prometheus.Gatherer, name string) { + t.Helper() + + g, err := gatherer.Gather() + assert.NoError(t, err) + for _, metric := range g { + if metric.GetName() == name { + assert.Fail(t, fmt.Sprintf("Metric %s was found but should not exist", name)) + return + } + } + + // Also check descriptors for Vec metrics that have no observations yet. + 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) { + assert.Fail(t, fmt.Sprintf("Metric %s was found in descriptors but should not exist", name)) + return + } + } + } +} + // AssertMetricsCount asserts that exactly expectedCount metrics were gathered from gatherer. func AssertMetricsCount(t *testing.T, gatherer prometheus.Gatherer, expectedCount int) { t.Helper() diff --git a/attribs.go b/attribs.go index 8fe2da9..f8a7219 100644 --- a/attribs.go +++ b/attribs.go @@ -15,6 +15,19 @@ type AttrSkip struct { Skip bool `attr:"name=skip"` } +// AttrEmbedded defines tag attributes for embedded structs. +// Only prefix and suffix are supported — full per-metric attributes (name, help, namespace…) +// are not meaningful at the embedded-struct level. +// When prefix or suffix is non-empty it is prepended/appended to every metric name produced +// by the fields of that embedded struct (after the global renamer has run). +type AttrEmbedded struct { + AttrSkip + // Prefix is prepended to every metric name inside the embedded struct. + Prefix string `attr:"name=prefix"` + // Suffix is appended to every metric name inside the embedded struct. + Suffix string `attr:"name=suffix"` +} + // AttrCommon are common for all metrics type AttrCommon struct { AttrSkip @@ -72,8 +85,9 @@ type AttrHistogramVec struct { // predefined attributes for each collector type via attribs library var ( - attribSkip = attribs.Must(attribs.New(AttrSkip{})) - attribEmbedded = attribs.Must(attribs.New(AttrSkip{})) + attribSkip = attribs.Must(attribs.New(AttrSkip{})) + // attribEmbedded parses ph tags on embedded struct fields (prefix, suffix, skip). + attribEmbedded = attribs.Must(attribs.New(AttrEmbedded{})) attribCounter = attribs.Must(attribs.New(AttrCounter{})) attribCounterVec = attribs.Must(attribs.New(AttrCounterVec{})) attribGauge = attribs.Must(attribs.New(AttrGauge{})) diff --git a/example/example.go b/example/example.go index 93cc09b..47daaae 100644 --- a/example/example.go +++ b/example/example.go @@ -127,6 +127,84 @@ type AutoNameMetrics struct { RequestDuration prometheus.Histogram // no tag at all – name derived entirely from field name } +// ══════════════════════════════════════════════════════════════════════════════ +// Embedded-struct prefix / suffix demo +// +// A ph tag on an embedded struct field may carry prefix= and/or suffix= +// attributes. When present, every metric name produced by that embedded struct +// (after the global Renamer has run) is prepended / appended with those strings. +// Fields in the outer struct are not affected. +// +// Prefixes and suffixes stack correctly across multiple levels of embedding: +// +// final = outerPrefix + innerPrefix + renamer(name) + innerSuffix + outerSuffix +// +// ══════════════════════════════════════════════════════════════════════════════ + +// DBMetrics groups database-layer counters. +// When embedded with a prefix it lets the caller namespace these metrics +// without hard-coding a prefix inside the shared struct itself. +type DBMetrics struct { + Queries prometheus.Counter `ph:"name=queries_total, help='Total database queries executed'"` + Errors prometheus.Counter `ph:"name=errors_total, help='Total database query errors'"` +} + +// CacheMetrics groups cache-layer gauges. +type CacheMetrics struct { + Hits prometheus.Gauge `ph:"name=hits, help='Cache hit count'"` + Misses prometheus.Gauge `ph:"name=misses, help='Cache miss count'"` +} + +// LegacyMetrics is a retired metric group that should not be registered. +// It is embedded in ServiceMetrics with ph:"skip" to exclude it entirely. +// The ph:"-" shorthand is equivalent: Legacy `ph:"-"` +type LegacyMetrics struct { + OldCounter prometheus.Counter `ph:"name=old_counter_total, help='Retired counter'"` +} + +// ServiceMetrics embeds both shared metric groups and assigns each a unique +// prefix so their names do not collide in the registry. LegacyMetrics is +// excluded via the skip attribute. +// +// DBMetrics with prefix "db_" → db_queries_total, db_errors_total +// CacheMetrics with prefix "cache_" → cache_hits, cache_misses +// LegacyMetrics with skip → nothing registered +// +// skip can also appear alongside prefix/suffix in the same tag to drop a group +// that was previously used with a prefix: +// +// OldGroup `ph:"prefix=old_, skip"` // skip wins — nothing from OldGroup is registered +type ServiceMetrics struct { + DBMetrics `ph:"prefix=db_"` // all DBMetrics names gain "db_" prefix + CacheMetrics `ph:"prefix=cache_"` // all CacheMetrics names gain "cache_" prefix + LegacyMetrics `ph:"skip"` // entire group excluded from registration + + // Top-level fields are unaffected by the embedded prefixes. + Uptime prometheus.Gauge `ph:"name=uptime_seconds, help='Seconds since the service started'"` +} + +// ── Nested-embedding prefix demo ───────────────────────────────────────────── +// +// PlatformMetrics demonstrates that prefix/suffix accumulate correctly across +// multiple nesting levels. ServiceMetrics already has DBMetrics (prefix "db_") +// and CacheMetrics (prefix "cache_") inside it. Embedding ServiceMetrics here +// with prefix "svc_" prepends "svc_" to all of those names. +// +// Final metric names: +// +// svc_db_queries_total (three levels: PlatformMetrics → ServiceMetrics → DBMetrics) +// svc_db_errors_total +// svc_cache_hits (three levels: PlatformMetrics → ServiceMetrics → CacheMetrics) +// svc_cache_misses +// svc_uptime_seconds (two levels: PlatformMetrics → ServiceMetrics) +// platform_total_ops (direct field — no embedded prefix) +type PlatformMetrics struct { + ServiceMetrics `ph:"prefix=svc_"` // wraps all ServiceMetrics names with "svc_" + + // Direct field; the "svc_" prefix from the embedding above does not apply here. + TotalOps prometheus.Counter `ph:"name=platform_total_ops, help='Total operations across all services'"` +} + // ══════════════════════════════════════════════════════════════════════════════ // Wrong-type demo // @@ -258,17 +336,19 @@ func main() { warnOpts := ph.DefaultOptions(). WithDefaultNamespace("warn_demo"). - WithLogger(ph.StdErrLogger(ph.LogLevelWarn)). // warnings go to stderr + WithLogger(ph.StdOutLogger(ph.LogLevelWarn)). // route to stdout so output is in context WithMode(ph.ModeWarning) // key: warn instead of error // ph.New succeeds even though every field in BadMetrics has the wrong type. - // Each offending field emits a WARN log line (visible on stderr) and is - // skipped. The returned struct has nil metrics for all those fields. + // Each offending field emits a log line and is skipped. The messages below + // are intentional — they demonstrate that ModeWarning logs problems instead + // of returning an error. + fmt.Println("Expected log messages (ModeWarning logs bad fields instead of failing):") _, err := ph.New[BadMetrics](ph.NewTrackingRegistry(), warnOpts) if err != nil { panic(err) } - fmt.Println("ph.New[BadMetrics] returned nil error (warnings printed to stderr above)") + fmt.Println("ph.New[BadMetrics] returned nil error — bad fields were logged and skipped") // ╔══════════════════════════════════════════════════════════════════════╗ // ║ D ModeStrict – wrong types return descriptive errors ║ @@ -306,4 +386,76 @@ func main() { existing.Requests.Inc() fmt.Println("ph.NewExisting: existing.Requests.Inc() succeeded – metrics are live") + + // ╔══════════════════════════════════════════════════════════════════════╗ + // ║ F Embedded-struct prefix / suffix ║ + // ╚══════════════════════════════════════════════════════════════════════╝ + section("F Embedded-struct prefix / suffix") + + reg6 := ph.NewTrackingRegistry() + + // ph.New walks ServiceMetrics, sees two embedded structs with prefix= tags and + // one with skip — it derives scoped options for each recursive walk so that + // only the fields inside that embedded struct gain the prefix, and the skipped + // group produces no metrics at all. + svc := ph.Must(ph.New[ServiceMetrics](reg6, nil)) + + // Use the metrics to demonstrate they are live. + svc.Queries.Add(120) // registered as "db_queries_total" + svc.Errors.Inc() // registered as "db_errors_total" + svc.Hits.Set(88) // registered as "cache_hits" + svc.Misses.Set(12) // registered as "cache_misses" + svc.Uptime.Set(3600) // registered as "uptime_seconds" + // svc.OldCounter is nil — LegacyMetrics was skipped entirely. + + fmt.Println("Registered metric names (single-level prefix + skip demo):") + for _, name := range reg6.MetricNames() { + fmt.Printf(" %s\n", name) + } + // Output (order may vary): + // db_queries_total + // db_errors_total + // cache_hits + // cache_misses + // uptime_seconds + // Note: old_counter_total is absent — LegacyMetrics was skipped. + + // ╔══════════════════════════════════════════════════════════════════════╗ + // ║ G Nested embedded-struct prefix (multi-level stacking) ║ + // ╚══════════════════════════════════════════════════════════════════════╝ + section("G Nested embedded-struct prefix (multi-level stacking)") + + reg7 := ph.NewTrackingRegistry() + + // PlatformMetrics embeds ServiceMetrics with prefix "svc_". + // ServiceMetrics already embeds DBMetrics with prefix "db_" and + // CacheMetrics with prefix "cache_". ph accumulates all prefixes so + // the outermost prefix always appears first in the final name. + plat := ph.Must(ph.New[PlatformMetrics](reg7, nil)) + + // Three levels of nesting: PlatformMetrics → ServiceMetrics → DBMetrics + plat.Queries.Add(50) // "svc_db_queries_total" + plat.Errors.Inc() // "svc_db_errors_total" + + // Three levels: PlatformMetrics → ServiceMetrics → CacheMetrics + plat.Hits.Set(200) // "svc_cache_hits" + plat.Misses.Set(15) // "svc_cache_misses" + + // Two levels: PlatformMetrics → ServiceMetrics + plat.Uptime.Set(7200) // "svc_uptime_seconds" + + // Direct field on PlatformMetrics — no embedded prefix applied. + plat.TotalOps.Add(265) // "platform_total_ops" + + fmt.Println("Registered metric names (nested prefix demo):") + for _, name := range reg7.MetricNames() { + fmt.Printf(" %s\n", name) + } + // Output (order may vary): + // svc_db_queries_total + // svc_db_errors_total + // svc_cache_hits + // svc_cache_misses + // svc_uptime_seconds + // platform_total_ops } diff --git a/new.go b/new.go index 6c678aa..11a0033 100644 --- a/new.go +++ b/new.go @@ -8,24 +8,34 @@ import ( // // This function instantiates metrics structure, inspects it for tags, and creates appropriate metrics and registers // them to provided prometheus registerer -func New[T any](registerer prometheus.Registerer, opts *Options) (*T, error) { +func New[T any](registerer prometheus.Registerer, opts ...*Options) (*T, error) { var instance T - return NewExisting(registerer, &instance, opts) + var o *Options + if len(opts) > 0 { + o = opts[0] + } + + return NewExisting(registerer, &instance, o) } // NewExisting does magic on given instance -func NewExisting[T any](registerer prometheus.Registerer, instance *T, opts *Options) (*T, error) { - opts = opts.unwrapOrDefault() +func NewExisting[T any](registerer prometheus.Registerer, instance *T, opts ...*Options) (*T, error) { + var o *Options + if len(opts) > 0 { + o = opts[0] + } + + o = o.unwrapOrDefault() // apply magic - collectorInfos, err := apply(instance, opts) + collectorInfos, err := apply(instance, o) if err != nil { return nil, err } // register found collectorInfos - if err = registerCollectorInfos(registerer, instance, collectorInfos, opts); err != nil { + if err = registerCollectorInfos(registerer, instance, collectorInfos, o); err != nil { return nil, err } diff --git a/options.go b/options.go index c2adab3..d9de2ea 100644 --- a/options.go +++ b/options.go @@ -19,6 +19,19 @@ type Options struct { Renamer Renamer Logger Logger Mode Mode + + // embeddedPrefix and embeddedSuffix accumulate the prefix/suffix declared on + // enclosing embedded-struct ph tags. They are applied to every metric name + // after the global Renamer has run. Nesting stacks them so that the outermost + // scope's prefix comes first and the outermost scope's suffix comes last: + // + // final = embeddedPrefix + renamer(name) + embeddedSuffix + // + // These fields are unexported because callers must not set them directly — + // they are only derived inside apply() when an embedded struct tag carries + // prefix= or suffix= attributes. + embeddedPrefix string + embeddedSuffix string } // clean cleans options and returns them @@ -45,6 +58,8 @@ func (o *Options) clone() *Options { result.Renamer = o.Renamer result.Logger = o.Logger result.Mode = o.Mode + result.embeddedPrefix = o.embeddedPrefix + result.embeddedSuffix = o.embeddedSuffix return result } @@ -74,7 +89,13 @@ func (o *Options) getBuckets(common AttrHistogram) []float64 { return o.DefaultBuckets } -// getMetricName returns the metric name, if not set in common it returns the field name, applying renaming if configured +// getMetricName returns the fully-qualified metric name for a field. +// Resolution order: +// 1. explicit name= tag value (skips the renamer) +// 2. struct field name passed through the global Renamer chain +// +// After renaming, the accumulated embeddedPrefix / embeddedSuffix from any +// enclosing embedded-struct ph tags are prepended / appended. func (o *Options) getMetricName(common AttrCommon, fieldName string) string { var metricName string if cn := strings.TrimSpace(common.Name); cn != "" { @@ -82,7 +103,7 @@ func (o *Options) getMetricName(common AttrCommon, fieldName string) string { } else { metricName = fieldName } - return o.renameField(metricName, fieldName) + return o.embeddedPrefix + o.renameField(metricName, fieldName) + o.embeddedSuffix } // getNamespace returns the namespace for the metric, if not set in common it returns the default namespace from options diff --git a/prometheus_test.go b/prometheus_test.go index d823b51..0c96a2a 100644 --- a/prometheus_test.go +++ b/prometheus_test.go @@ -574,3 +574,425 @@ func TestNew_GaugeVec(t *testing.T) { m.G.With(prometheus.Labels{"method": "GET", "status": "200"}).Set(0.5) AssertMetricExists(t, reg, "latency") } + +// TestNew_EmbeddedWithPrefix verifies that the prefix= tag on an embedded struct +// prepends the given string to every metric name inside that struct while leaving +// sibling fields in the outer struct unaffected. +func TestNew_EmbeddedWithPrefix(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits"` + } + type Outer struct { + Inner `ph:"prefix=http_"` + Requests prometheus.Counter `ph:"name=requests"` + } + + reg := prometheus.NewRegistry() + m, err := New[Outer](reg, nil) + require.NoError(t, err) + require.NotNil(t, m) + + // Inner.Hits should be prefixed: "http_hits" + AssertMetricExists(t, reg, "http_hits") + // Outer.Requests must not be affected by the embedded prefix + AssertMetricExists(t, reg, "requests") + AssertMetricsCount(t, reg, 2) +} + +// TestNew_EmbeddedWithSuffix verifies that the suffix= tag on an embedded struct +// appends the given string to every metric name inside that struct. +func TestNew_EmbeddedWithSuffix(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits"` + } + type Outer struct { + Inner `ph:"suffix=_total"` + Requests prometheus.Counter `ph:"name=requests"` + } + + reg := prometheus.NewRegistry() + _, err := New[Outer](reg, nil) + require.NoError(t, err) + + AssertMetricExists(t, reg, "hits_total") + // Outer.Requests must not gain the suffix + AssertMetricExists(t, reg, "requests") + AssertMetricsCount(t, reg, 2) +} + +// TestNew_EmbeddedWithPrefixAndSuffix verifies that both prefix= and suffix= can be +// specified together on an embedded struct tag. +func TestNew_EmbeddedWithPrefixAndSuffix(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits"` + } + type Outer struct { + Inner `ph:"prefix=http_, suffix=_total"` + Requests prometheus.Counter `ph:"name=requests"` + } + + reg := prometheus.NewRegistry() + _, err := New[Outer](reg, nil) + require.NoError(t, err) + + // prefix and suffix both applied: "http_hits_total" + AssertMetricExists(t, reg, "http_hits_total") + // Outer.Requests is unaffected + AssertMetricExists(t, reg, "requests") + AssertMetricsCount(t, reg, 2) +} + +// TestNew_EmbeddedPtrWithPrefix verifies that prefix= works on a pointer-to-struct +// embedded field in the same way as a value-embedded struct. +func TestNew_EmbeddedPtrWithPrefix(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits"` + } + type Outer struct { + *Inner `ph:"prefix=db_"` + Requests prometheus.Counter `ph:"name=requests"` + } + + reg := prometheus.NewRegistry() + m, err := New[Outer](reg, nil) + require.NoError(t, err) + require.NotNil(t, m.Inner) + + AssertMetricExists(t, reg, "db_hits") + AssertMetricExists(t, reg, "requests") + AssertMetricsCount(t, reg, 2) +} + +// TestNew_EmbeddedPrefixWithRenamer verifies that the global renamer runs first and +// the embedded prefix/suffix are applied on top of the renamed value. +// Order: global renamer (snake_case) → embedded prefix → embedded suffix. +func TestNew_EmbeddedPrefixWithRenamer(t *testing.T) { + type Inner struct { + // No explicit name tag: field name goes through snake_case renamer first. + HitsTotal prometheus.Counter + } + type Outer struct { + Inner `ph:"prefix=http_"` + RequestsTotal prometheus.Counter + } + + opts := DefaultOptions().WithRenamer(RenamerCase(RenamerCaseSnake)) + reg := prometheus.NewRegistry() + _, err := New[Outer](reg, opts) + require.NoError(t, err) + + // HitsTotal → snake_case → "hits_total" → prefix → "http_hits_total" + AssertMetricExists(t, reg, "http_hits_total") + // RequestsTotal → snake_case → "requests_total" (no prefix, outer field) + AssertMetricExists(t, reg, "requests_total") + AssertMetricsCount(t, reg, 2) +} + +// TestNew_EmbeddedPrefixExplicitName verifies that the embedded prefix is also applied +// to fields whose metric name is set explicitly via the name= tag attribute. +func TestNew_EmbeddedPrefixExplicitName(t *testing.T) { + type Inner struct { + // Explicit name: renamer is bypassed for the name itself, but prefix still applies. + Requests prometheus.Counter `ph:"name=requests_total"` + } + type Outer struct { + Inner `ph:"prefix=svc_"` + } + + reg := prometheus.NewRegistry() + _, err := New[Outer](reg, nil) + require.NoError(t, err) + + // Even with an explicit name the prefix is applied: "svc_requests_total" + AssertMetricExists(t, reg, "svc_requests_total") + AssertMetricsCount(t, reg, 1) +} + +// TestNew_EmbeddedNoTagNoPrefixSuffix ensures that embedded structs without a tag +// continue to work exactly as before (no regression). +func TestNew_EmbeddedNoTagNoPrefixSuffix(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits_total"` + } + type Outer struct { + Inner // no ph tag at all + Misses prometheus.Counter `ph:"name=misses_total"` + } + + reg := prometheus.NewRegistry() + m, err := New[Outer](reg, nil) + require.NoError(t, err) + require.NotNil(t, m) + + AssertMetricExists(t, reg, "hits_total") + AssertMetricExists(t, reg, "misses_total") + AssertMetricsCount(t, reg, 2) +} + +// TestNew_EmbeddedMultipleWithDifferentPrefixes verifies that two embedded structs in +// the same outer struct can each carry their own independent prefix/suffix. +func TestNew_EmbeddedMultipleWithDifferentPrefixes(t *testing.T) { + type HTTP struct { + Requests prometheus.Counter `ph:"name=requests"` + } + type DB struct { + Queries prometheus.Counter `ph:"name=queries"` + } + type Outer struct { + HTTP `ph:"prefix=http_"` + DB `ph:"prefix=db_"` + } + + reg := prometheus.NewRegistry() + m, err := New[Outer](reg, nil) + require.NoError(t, err) + require.NotNil(t, m) + + AssertMetricExists(t, reg, "http_requests") + AssertMetricExists(t, reg, "db_queries") + AssertMetricsCount(t, reg, 2) +} + +// TestNew_NestedEmbeddedPrefix verifies that prefixes stack correctly across three +// levels of embedding. The outermost prefix always appears first in the final metric +// name, followed by each successive inner prefix, then the base name: +// +// outermost_prefix + … + innermost_prefix + base_name +func TestNew_NestedEmbeddedPrefix(t *testing.T) { + // Three-level nesting: L3 → L2 → L1. + type L1 struct { + Counter prometheus.Counter `ph:"name=counter"` + } + type L2 struct { + L1 `ph:"prefix=l2_"` + Gauge prometheus.Gauge `ph:"name=gauge"` + } + type L3 struct { + L2 `ph:"prefix=l3_"` + Uptime prometheus.Gauge `ph:"name=uptime"` + } + + reg := prometheus.NewRegistry() + _, err := New[L3](reg, nil) + require.NoError(t, err) + + // L3.Uptime — direct field, no embedded prefix applied. + AssertMetricExists(t, reg, "uptime") + // L2.Gauge — inside L3's l3_ scope only. + AssertMetricExists(t, reg, "l3_gauge") + // L1.Counter — inside both scopes; outermost prefix first. + AssertMetricExists(t, reg, "l3_l2_counter") + AssertMetricsCount(t, reg, 3) +} + +// TestNew_NestedEmbeddedSuffix verifies that suffixes stack symmetrically to prefixes: +// the innermost suffix sits closest to the base name and the outermost suffix comes last, +// forming a sandwich around the base name: +// +// base_name + innermost_suffix + … + outermost_suffix +func TestNew_NestedEmbeddedSuffix(t *testing.T) { + type L1 struct { + Counter prometheus.Counter `ph:"name=counter"` + } + type L2 struct { + L1 `ph:"suffix=_l2"` + Gauge prometheus.Gauge `ph:"name=gauge"` + } + type L3 struct { + L2 `ph:"suffix=_l3"` + Uptime prometheus.Gauge `ph:"name=uptime"` + } + + reg := prometheus.NewRegistry() + _, err := New[L3](reg, nil) + require.NoError(t, err) + + // L3.Uptime — no suffix. + AssertMetricExists(t, reg, "uptime") + // L2.Gauge — inside L3's _l3 scope only. + AssertMetricExists(t, reg, "gauge_l3") + // L1.Counter — innermost suffix first, then outer: counter_l2_l3. + AssertMetricExists(t, reg, "counter_l2_l3") + AssertMetricsCount(t, reg, 3) +} + +// TestNew_NestedEmbeddedPrefixAndSuffix verifies the full sandwich structure when both +// prefix and suffix are used across multiple embedding levels: +// +// outerPrefix + innerPrefix + base_name + innerSuffix + outerSuffix +func TestNew_NestedEmbeddedPrefixAndSuffix(t *testing.T) { + type Inner struct { + Hits prometheus.Counter `ph:"name=hits"` + } + type Middle struct { + Inner `ph:"prefix=mid_, suffix=_mid"` + Calls prometheus.Counter `ph:"name=calls"` + } + type Outer struct { + Middle `ph:"prefix=out_, suffix=_out"` + Uptime prometheus.Gauge `ph:"name=uptime"` + } + + reg := prometheus.NewRegistry() + _, err := New[Outer](reg, nil) + require.NoError(t, err) + + // Outer.Uptime — no embedded scope. + AssertMetricExists(t, reg, "uptime") + // Middle.Calls — inside Outer's out_/_out scope only. + AssertMetricExists(t, reg, "out_calls_out") + // Inner.Hits — inside both scopes; sandwich structure: + // out_ + mid_ + hits + _mid + _out = "out_mid_hits_mid_out" + AssertMetricExists(t, reg, "out_mid_hits_mid_out") + AssertMetricsCount(t, reg, 3) +} + +// TestNew_NestedEmbeddedPrefixWithRenamer verifies that the global Renamer runs on the +// raw field/tag name first and the accumulated embedded prefix/suffix are applied on top, +// regardless of nesting depth. +func TestNew_NestedEmbeddedPrefixWithRenamer(t *testing.T) { + type L1 struct { + // No name= tag: field name goes through the global snake_case renamer. + HitsTotal prometheus.Counter + } + type L2 struct { + L1 `ph:"prefix=l2_"` + RequestCount prometheus.Gauge + } + type L3 struct { + L2 `ph:"prefix=l3_"` + Uptime prometheus.Gauge + } + + opts := DefaultOptions().WithRenamer(RenamerCase(RenamerCaseSnake)) + reg := prometheus.NewRegistry() + _, err := New[L3](reg, opts) + require.NoError(t, err) + + // L3.Uptime → snake → "uptime" (no embedded prefix). + AssertMetricExists(t, reg, "uptime") + // L2.RequestCount → snake → "request_count" → l3_ prefix → "l3_request_count". + AssertMetricExists(t, reg, "l3_request_count") + // L1.HitsTotal → snake → "hits_total" → l3_l2_ prefix → "l3_l2_hits_total". + AssertMetricExists(t, reg, "l3_l2_hits_total") + AssertMetricsCount(t, reg, 3) +} + +// ── skip attribute on embedded struct tags ──────────────────────────────────── + +// TestNew_EmbeddedSkipAttribute verifies that ph:"skip" on an embedded struct tag +// excludes all fields of that struct from registration while sibling fields are +// unaffected. This test exercises the skip attribute via the AttrEmbedded parser +// (the same path used by prefix/suffix). +func TestNew_EmbeddedSkipAttribute(t *testing.T) { + type Hidden struct { + Internal prometheus.Counter `ph:"name=internal_total"` + } + type Outer struct { + Hidden `ph:"skip"` + Visible prometheus.Counter `ph:"name=visible_total"` + } + + reg := NewTrackingRegistry() + m, err := New[Outer](reg, nil) + require.NoError(t, err) + require.NotNil(t, m) + + AssertMetricExists(t, reg, "visible_total") + AssertMetricNotExists(t, reg, "internal_total") + AssertMetricsCount(t, reg, 1) +} + +// TestNew_EmbeddedSkipDash verifies that the ph:"-" shorthand is equivalent to +// ph:"skip" for embedded structs — both discard the entire embedded group. +func TestNew_EmbeddedSkipDash(t *testing.T) { + type Hidden struct { + Internal prometheus.Counter `ph:"name=internal_total"` + } + type Outer struct { + Hidden `ph:"-"` + Visible prometheus.Counter `ph:"name=visible_total"` + } + + reg := NewTrackingRegistry() + _, err := New[Outer](reg, nil) + require.NoError(t, err) + + AssertMetricExists(t, reg, "visible_total") + AssertMetricNotExists(t, reg, "internal_total") + AssertMetricsCount(t, reg, 1) +} + +// TestNew_EmbeddedSkipWithPrefix verifies that skip= takes precedence over prefix= +// when both attributes appear in the same embedded struct tag. +// No metrics from the embedded struct should be registered, regardless of the prefix. +func TestNew_EmbeddedSkipWithPrefix(t *testing.T) { + type Hidden struct { + Internal prometheus.Counter `ph:"name=internal_total"` + } + type Outer struct { + // skip wins — prefix is parsed but never applied + Hidden `ph:"prefix=gone_, skip"` + Visible prometheus.Counter `ph:"name=visible_total"` + } + + reg := NewTrackingRegistry() + _, err := New[Outer](reg, nil) + require.NoError(t, err) + + AssertMetricExists(t, reg, "visible_total") + // Neither the bare name nor the prefixed name should exist. + AssertMetricNotExists(t, reg, "internal_total") + AssertMetricNotExists(t, reg, "gone_internal_total") + AssertMetricsCount(t, reg, 1) +} + +// TestNew_EmbeddedSkipWithSuffix mirrors TestNew_EmbeddedSkipWithPrefix but for suffix. +func TestNew_EmbeddedSkipWithSuffix(t *testing.T) { + type Hidden struct { + Internal prometheus.Counter `ph:"name=internal"` + } + type Outer struct { + Hidden `ph:"suffix=_gone, skip"` + Visible prometheus.Counter `ph:"name=visible_total"` + } + + reg := NewTrackingRegistry() + _, err := New[Outer](reg, nil) + require.NoError(t, err) + + AssertMetricExists(t, reg, "visible_total") + AssertMetricNotExists(t, reg, "internal") + AssertMetricNotExists(t, reg, "internal_gone") + AssertMetricsCount(t, reg, 1) +} + +// TestNew_EmbeddedSkipNestedDoesNotAffectParent verifies that skipping an embedded +// struct inside a nested hierarchy only discards that specific struct's metrics — +// other levels are unaffected. +func TestNew_EmbeddedSkipNestedDoesNotAffectParent(t *testing.T) { + type Skipped struct { + Ghost prometheus.Counter `ph:"name=ghost_total"` + } + type Middle struct { + Skipped `ph:"skip"` // this whole group is skipped + Calls prometheus.Counter `ph:"name=calls_total"` + } + type Outer struct { + Middle `ph:"prefix=mid_"` + Uptime prometheus.Gauge `ph:"name=uptime"` + } + + reg := NewTrackingRegistry() + _, err := New[Outer](reg, nil) + require.NoError(t, err) + + // Middle.Calls gets the mid_ prefix from Outer's embed. + AssertMetricExists(t, reg, "mid_calls_total") + // Middle.Skipped was skipped — ghost_total must not appear. + AssertMetricNotExists(t, reg, "ghost_total") + AssertMetricNotExists(t, reg, "mid_ghost_total") + // Outer.Uptime is a direct field. + AssertMetricExists(t, reg, "uptime") + AssertMetricsCount(t, reg, 2) +}