diff --git a/README.md b/README.md index b2a5c5b..9cca840 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,12 @@ 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/example/example.go b/example/example.go index d99969d..93cc09b 100644 --- a/example/example.go +++ b/example/example.go @@ -1,105 +1,309 @@ +// Package main is a runnable example demonstrating every feature of the ph library. +// +// Run it with: +// +// go run ./example/example.go package main import ( + "fmt" + "strings" + "github.com/phonkee/ph" "github.com/prometheus/client_golang/prometheus" ) -type Common struct { - Base prometheus.Counter `ph:"name=base_total"` +// ══════════════════════════════════════════════════════════════════════════════ +// Embedded structs +// +// ph walks embedded (anonymous) structs recursively and registers their fields +// alongside the parent struct's fields as if they were declared inline. +// ══════════════════════════════════════════════════════════════════════════════ + +// RuntimeInfo is a value-embedded struct. +// Its fields are promoted into whatever struct embeds it. +type RuntimeInfo struct { + // Info is commonly set to 1 so that metric labels carry build metadata. + Info prometheus.Gauge `ph:"name=info, help='Static gauge set to 1, used for build-info labels'"` } -func (c *Common) IncBase() { - c.Base.Inc() +// ProcessInfo is a pointer-embedded struct. +// ph calls reflect.New to allocate it before walking its fields, so callers +// never have to write MyStruct.ProcessInfo = new(ProcessInfo). +type ProcessInfo struct { + OpenFDs prometheus.Gauge `ph:"name=open_fds, help='Number of open file descriptors'"` + Threads prometheus.Gauge `ph:"name=threads, help='Number of OS threads in the process'"` } -type Common2 struct { - Base2 prometheus.Counter +// HealthChecks holds metrics that we intentionally skip in AppMetrics below. +// It is shown here only to demonstrate the skip mechanism. +type HealthChecks struct { + Pings prometheus.Counter `ph:"name=pings_total, help='Health-check pings received'"` } -type Skipped struct { - Base3 prometheus.Counter +// ══════════════════════════════════════════════════════════════════════════════ +// Main application metrics struct +// ══════════════════════════════════════════════════════════════════════════════ + +// AppMetrics is a realistic, production-style metrics struct that exercises +// every ph feature: all six metric types, value and pointer embeds, the skip +// mechanism, default options, and per-field namespace/subsystem overrides. +// +// # Type invariants +// +// ph validates these rules and returns a descriptive suggestion on violation: +// +// - Scalar metrics → interface value (no pointer) +// prometheus.Counter / .Gauge / .Histogram +// +// - Vector metrics → pointer to concrete struct +// *prometheus.CounterVec / *prometheus.GaugeVec / *prometheus.HistogramVec +// +// # Tag syntax +// +// ph:"name=, help='', namespace=, subsystem=, +// labels[l1, l2], buckets[v1, v2, ...]" +// +// Every attribute is optional. When name= is absent, the field name is used +// (after any Renamer chain configured in Options). Fields of unrecognised types +// (strings, ints, unexported fields …) are silently skipped. +type AppMetrics struct { + RuntimeInfo // value embed – fields are promoted into the same registry + *ProcessInfo // pointer embed – ph allocates this automatically + + // ph:"-" is the shorthand for ph:"skip". + // It tells ph to ignore the embedded struct entirely (none of its metric + // fields will be registered). Use this for config/state structs that happen + // to be embedded alongside real metrics. + HealthChecks `ph:"-"` + + // ── Counters (interface value – no asterisk) ────────────────────────────── + Requests prometheus.Counter `ph:"name=requests_total, help='Total HTTP requests received'"` + Errors prometheus.Counter `ph:"name=errors_total, help='Total requests that resulted in an error'"` + + // CounterVec slices a single counter across label dimensions. + ByCode *prometheus.CounterVec `ph:"name=requests_by_code, help='Requests broken down by HTTP method and status code', labels[method, code]"` + + // ── Gauges (interface value – no asterisk) ──────────────────────────────── + InFlight prometheus.Gauge `ph:"name=in_flight, help='Requests currently being processed'"` + InFlightByMethod *prometheus.GaugeVec `ph:"name=in_flight_by_method, help='In-flight requests per method', labels[method]"` + + // ── Histograms (interface value – no asterisk) ──────────────────────────── + // Duration inherits the default buckets from Options.WithDefaultBuckets. + Duration prometheus.Histogram `ph:"name=duration_seconds, help='End-to-end request latency in seconds'"` + + // DurationByPath overrides the default buckets inline with SLO-friendly values. + // Inline buckets take precedence over Options.DefaultBuckets for this field only. + DurationByPath *prometheus.HistogramVec `ph:"name=duration_by_path, help='Request latency per URL path', labels[path], buckets[.005, .01, .025, .05, .1, .25, .5, 1]"` + + // Per-field namespace and subsystem override the Options defaults. + // This metric becomes infra_cache_cache_hits_total regardless of what + // namespace/subsystem is set in the Options passed to ph.New. + CacheHits prometheus.Counter `ph:"name=cache_hits_total, namespace=infra, subsystem=cache, help='Cache hits'"` } -type Example struct { - Common - *Common2 - 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]"` +// ══════════════════════════════════════════════════════════════════════════════ +// Auto-rename demo +// +// When a field has no name= attribute, ph derives the metric name from the Go +// field name and passes it through the Renamer chain configured in Options. +// ══════════════════════════════════════════════════════════════════════════════ + +// AutoNameMetrics has no name= attribute on any field. +// With RenamerCase(snake) + RenamerPrefix("srv_") the mapping is: +// +// HttpRequestsTotal → snake → http_requests_total → prefix → srv_http_requests_total +// ActiveConnections → snake → active_connections → prefix → srv_active_connections +// RequestDuration → snake → request_duration → prefix → srv_request_duration +// +// Combined with namespace "app" the final metric names are: +// +// app_srv_http_requests_total +// app_srv_active_connections +// app_srv_request_duration +type AutoNameMetrics struct { + HttpRequestsTotal prometheus.Counter `ph:"help='Total HTTP requests'"` + ActiveConnections prometheus.Gauge `ph:"help='Current number of active connections'"` + RequestDuration prometheus.Histogram // no tag at all – name derived entirely from field name } -func (e *Example) IncRequests() { - e.MetRequests.Inc() +// ══════════════════════════════════════════════════════════════════════════════ +// Wrong-type demo +// +// ph detects these six common pointer/value mistakes and returns a descriptive +// ErrImproperlyConfigured in ModeStrict, or logs a warning in ModeWarning. +// ══════════════════════════════════════════════════════════════════════════════ + +// BadMetrics intentionally uses the wrong type for every supported metric. +// +// *prometheus.Counter → should be prometheus.Counter (drop *) +// prometheus.CounterVec → should be *prometheus.CounterVec (add *) +// *prometheus.Gauge → should be prometheus.Gauge (drop *) +// prometheus.GaugeVec → should be *prometheus.GaugeVec (add *) +// *prometheus.Histogram → should be prometheus.Histogram (drop *) +// prometheus.HistogramVec → should be *prometheus.HistogramVec (add *) +type BadMetrics struct { + Counter *prometheus.Counter `ph:"name=bad_counter"` + CounterVec prometheus.CounterVec `ph:"name=bad_counter_vec, labels[x]"` + Gauge *prometheus.Gauge `ph:"name=bad_gauge"` + GaugeVec prometheus.GaugeVec `ph:"name=bad_gauge_vec, labels[x]"` + Histogram *prometheus.Histogram `ph:"name=bad_histogram"` + HistoVec prometheus.HistogramVec `ph:"name=bad_histogram_vec, labels[x]"` } -// ExampleSuggest shows how ph can suggest some errors -type ExampleSuggest struct { - 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 `ph:"name=histogram_vec, namespace=sbs"` +// ══════════════════════════════════════════════════════════════════════════════ +// main +// ══════════════════════════════════════════════════════════════════════════════ + +func section(label string) { + bar := strings.Repeat("─", 60) + fmt.Printf("\n%s\n %s\n%s\n\n", bar, label, bar) } func main() { - // new empty registry + + // ╔══════════════════════════════════════════════════════════════════════╗ + // ║ A Register all metric types ║ + // ╚══════════════════════════════════════════════════════════════════════╝ + section("A Register all metric types") + reg := ph.NewTrackingRegistry() - // options for registering + // Options are immutable-builder style: each With* method returns a new + // cloned Options value, so the original is never mutated. opts := ph.DefaultOptions(). - WithRenamer( - ph.Renamers( - ph.RenamerPrefix("prefix_"), - ph.RenamerSuffix("_suffix"), - ph.RenamerCase(ph.RenamerCaseSnake), - ), - ). - WithDefaultNamespace("ns"). - WithDefaultBuckets(ph.DefaultBuckets). - WithLogger(ph.StdOutLogger(ph.LogLevelDebug)). - WithMode(ph.ModeStrict) - - // now try to register (inspect and register to prometheus registerer - m := ph.Must(ph.New[Example](reg, opts)) - - m.IncRequests() - m.IncBase() - - // debug what we have - ph.DebugGather(reg) + WithDefaultNamespace("myapp"). // prepended to every metric name + WithDefaultSubsystem("http"). // inserted between namespace and name + WithDefaultBuckets(prometheus.DefBuckets). // default histogram buckets + WithLogger(ph.StdOutLogger(ph.LogLevelDebug)). // log each registered field + WithMode(ph.ModeStrict) // fail fast on misconfigured fields (default) + + // ph.New[T] instantiates T, inspects every field via reflection, creates + // the appropriate Prometheus metric for each supported field type, and + // registers all of them with reg. + // + // ph.Must unwraps the (*T, error) pair and panics if err != nil. + m := ph.Must(ph.New[AppMetrics](reg, opts)) + + // All fields are now live Prometheus metrics – use them as normal. + m.Info.Set(1) + m.OpenFDs.Set(42) // field from the pointer-embedded *ProcessInfo + m.Threads.Set(8) + + m.Requests.Inc() + m.Requests.Add(9) + m.Errors.Inc() + + m.ByCode.WithLabelValues("GET", "200").Add(10) + m.ByCode.WithLabelValues("POST", "201").Add(5) + m.ByCode.WithLabelValues("GET", "500").Inc() + + m.InFlight.Set(3) + m.InFlightByMethod.WithLabelValues("GET").Set(2) + m.InFlightByMethod.WithLabelValues("POST").Set(1) - // this should fail since there are some problems - _ = ph.MustError(ph.New[ExampleSuggest](reg, &ph.Options{ - Renamer: ph.Renamers( - ph.RenamerPrefix("prefix_"), - ph.RenamerSuffix("_suffix"), - ph.RenamerCase(ph.RenamerCaseSnake), - ), - DefaultNamespace: "vmc", - DefaultBuckets: ph.DefaultBuckets, - Logger: ph.StdOutLogger(ph.LogLevelDebug), - Mode: ph.ModeStrict, - })) - - // validate what we have (with skips it's fine) + m.Duration.Observe(0.042) + m.DurationByPath.WithLabelValues("/api/users").Observe(0.012) + m.DurationByPath.WithLabelValues("/api/items").Observe(0.157) + + m.CacheHits.Add(7) // registered under namespace=infra, subsystem=cache + + // ph.Validate checks that every metric field is non-nil (i.e. was + // initialised by ph.New). Pass withSkips=true to honour ph:"skip" fields; + // false checks every field regardless of skip tags. if err := ph.Validate(m, true); err != nil { panic(err) } + fmt.Println("ph.Validate: OK – all metrics are initialised") + fmt.Println() - // TODO: fix this validate - //// mode just warning - //opts.Mode = ph.ModeWarning - // - //m2 := ph.Must(ph.New[ExampleSuggest](reg, opts)) - //// validate without skips - //err := ph.Validate(m2, true) - //if err == nil { - // panic("should have errored when ignored skips") - //} + // DebugGather gathers every registered metric and pretty-prints it to + // stdout. Handy for smoke-testing metric definitions during development. + ph.DebugGather(reg) + + // ╔══════════════════════════════════════════════════════════════════════╗ + // ║ B Automatic field-name renaming ║ + // ╚══════════════════════════════════════════════════════════════════════╝ + section("B Automatic field-name renaming (no name= tags)") + + reg2 := ph.NewTrackingRegistry() + + renameOpts := ph.DefaultOptions(). + WithDefaultNamespace("app"). + WithDefaultBuckets(prometheus.DefBuckets). + // ph.Renamers chains multiple Renamer implementations left-to-right. + // Built-in renamers: + // RenamerCase(snake|camel|kebab) – converts the case style + // RenamerPrefix(s) – prepends s to every name + // RenamerSuffix(s) – appends s to every name + // RenamerFunc(fn) – arbitrary custom function + WithRenamer(ph.Renamers( + ph.RenamerCase(ph.RenamerCaseSnake), // PascalCase → snake_case + ph.RenamerPrefix("srv_"), // add service prefix + )) + + ph.Must(ph.New[AutoNameMetrics](reg2, renameOpts)) + + // MetricNames returns the raw prometheus.Desc string for each registered + // descriptor, which includes the fully-qualified metric name. + fmt.Println("Registered metric descriptors (fqName visible inside Desc{…}):") + for _, d := range reg2.MetricNames() { + fmt.Printf(" %s\n", d) + } + + // ╔══════════════════════════════════════════════════════════════════════╗ + // ║ C ModeWarning – wrong types produce warnings, not errors ║ + // ╚══════════════════════════════════════════════════════════════════════╝ + section("C ModeWarning – bad types are logged and skipped, not fatal") + + warnOpts := ph.DefaultOptions(). + WithDefaultNamespace("warn_demo"). + WithLogger(ph.StdErrLogger(ph.LogLevelWarn)). // warnings go to stderr + 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. + _, 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)") + + // ╔══════════════════════════════════════════════════════════════════════╗ + // ║ D ModeStrict – wrong types return descriptive errors ║ + // ╚══════════════════════════════════════════════════════════════════════╝ + section("D ModeStrict – bad types return descriptive errors") + + // ph.MustError is the mirror of ph.Must: it panics if err IS nil. + // It is useful in tests and examples to assert that an error is returned. + strictErr := ph.MustError(ph.New[BadMetrics]( + ph.NewTrackingRegistry(), + ph.DefaultOptions(). + WithDefaultNamespace("strict_demo"). + WithMode(ph.ModeStrict), // default, shown explicitly for clarity + )) + fmt.Printf("Caught expected error:\n %v\n", strictErr) + + // ╔══════════════════════════════════════════════════════════════════════╗ + // ║ E NewExisting – register into a pre-allocated struct ║ + // ╚══════════════════════════════════════════════════════════════════════╝ + section("E NewExisting – populate metrics into an existing struct pointer") + + // ph.NewExisting is useful when the metrics struct is already embedded + // inside a larger struct that you control (e.g. a server object). + // It behaves identically to ph.New but uses the struct you hand it. + existing := &AppMetrics{} + if _, err := ph.NewExisting( + ph.NewTrackingRegistry(), + existing, + ph.DefaultOptions(). + WithDefaultNamespace("existing"). + WithDefaultBuckets(prometheus.DefBuckets), + ); err != nil { + panic(err) + } + + existing.Requests.Inc() + fmt.Println("ph.NewExisting: existing.Requests.Inc() succeeded – metrics are live") }