Skip to content

phonkee/ph

Repository files navigation

ph

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

go get github.com/phonkee/ph

Quick start

package main

import (
    "github.com/phonkee/ph"
    "github.com/prometheus/client_golang/prometheus"
)

type Metrics struct {
    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() {
    m, err := ph.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=<name>, help='<text>', namespace=<ns>, subsystem=<sub>"
ph:"labels[<label1>, <label2>]"          // Vec types only
ph:"buckets[0.05, 0.1, 0.25, 0.5, 1.0]" // Histogram types only
ph:"skip"                                 // exclude field from registration
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.
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).

Examples

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.

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 := 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.

// 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:

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:

// 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:

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:

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
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.

opts := ph.DefaultOptions().
    WithDefaultNamespace("myapp").
    WithDefaultSubsystem("api").
    WithDefaultBuckets(prometheus.DefBuckets).
    WithMode(ph.ModeStrict).
    WithLogger(ph.StdOutLogger(ph.LogLevelInfo)).
    WithRenamer(ph.RenamerCase(ph.RenamerCaseSnake))

m, err := ph.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 With* methods return a new *Options — the receiver is never mutated.

Mode

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.

// catch mistakes at startup
opts := ph.DefaultOptions().WithMode(ph.ModeStrict)

// log and continue (useful during migration)
opts := ph.DefaultOptions().WithMode(ph.ModeWarning)

Renamer

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.

// convert PascalCase field names to snake_case
opts := ph.DefaultOptions().WithRenamer(ph.RenamerCase(ph.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) MyMetricmy_metric
RenamerCase(RenamerCaseCamel) MyMetricmyMetric
RenamerCase(RenamerCaseKebab) MyMetricmy-metric
RenamerPrefix("app_") requestsapp_requests
RenamerSuffix("_total") requestsrequests_total
RenamerNoop() no change
Renamers(r1, r2, …) apply renamers left to right

Custom renamer

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.

custom := ph.RenamerFunc(func(name, field string) string {
    return "myapp_" + strings.ToLower(name)
})

opts := ph.DefaultOptions().WithRenamer(
    ph.Renamers(
        ph.RenamerCase(ph.RenamerCaseSnake),
        custom,
    ),
)

Logger

ph logs registration steps and any issues it encounters. The default logger is a no-op.

// log to stdout at Info level and above
opts := ph.DefaultOptions().WithLogger(ph.StdOutLogger(ph.LogLevelInfo))

// log to stderr at Debug level and above
opts := ph.DefaultOptions().WithLogger(ph.StdErrLogger(ph.LogLevelDebug))

// adapt an existing zap logger
opts := ph.DefaultOptions().WithLogger(ph.LoggerAdapterZap(zapLogger))

Log levels: LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError.

You can also implement the Logger interface directly:

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.

m, err := ph.New[Metrics](prometheus.DefaultRegisterer, nil)
if err != nil {
    panic(err)
}
if err := ph.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.

inst := &Metrics{}
inst.Requests = prometheus.NewCounter(prometheus.CounterOpts{Name: "requests_total"})
// ... set other fields ...

if err := ph.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.

// panics if err != nil, otherwise returns the value
m := ph.Must(ph.New[Metrics](reg, opts))

// panics if err == nil (useful for asserting that something must fail)
err := ph.MustError(ph.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.

reg := ph.NewTrackingRegistry()
m, err := ph.New[Metrics](reg, nil)
// ...

AssertMetricExists(t, reg, "metric_name") and AssertMetricsCount(t, reg, n) are provided as test helpers.

Author

Peter Vrba phonkee@phonkee.eu

About

Declarative struct tags for prometheus Collectors

Resources

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages