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.
go get github.com/phonkee/phpackage 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.
| 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.
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).
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 (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)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"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_totalskip 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"`
}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 appliedNew 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.
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)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"
}| 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 |
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,
),
)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 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 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)
}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))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.
Peter Vrba phonkee@phonkee.eu