Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 124 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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_<name>_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.
Expand Down Expand Up @@ -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 <phonkee@phonkee.eu>
34 changes: 32 additions & 2 deletions apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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...)
Expand Down
31 changes: 31 additions & 0 deletions assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
18 changes: 16 additions & 2 deletions attribs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{}))
Expand Down
Loading
Loading