mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-13 18:19:15 +00:00
165 lines
5.0 KiB
Go
165 lines
5.0 KiB
Go
// Package metrics is a tiny in-memory counter store with periodic flush to the
|
|
// project's structured logger.
|
|
//
|
|
// The project intentionally avoids running a metrics sidecar or external
|
|
// exporter. Per-instance counters are reset on flush so each log line
|
|
// represents a delta that can be aggregated by the hosting log sink.
|
|
package metrics
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/tiennm99/miti99bot/internal/log"
|
|
)
|
|
|
|
// DefaultFlushInterval is how often Run flushes counters to the log. 60s
|
|
// keeps log volume modest (1 metrics line per minute per active instance)
|
|
// while still surfacing minute-scale traffic shifts.
|
|
const DefaultFlushInterval = 60 * time.Second
|
|
|
|
// Registry holds named counters across two categories: command invocations
|
|
// and errors. Zero-value Registry is ready to use.
|
|
//
|
|
// Counters use atomic.Int64 so increments don't lock; the per-name map
|
|
// itself is guarded by an RWMutex for the rare add path. Names should be
|
|
// short and stable — they become log label values.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
commands map[string]*atomic.Int64
|
|
errors map[string]*atomic.Int64
|
|
}
|
|
|
|
// New returns an empty Registry. Most callers use the package-level
|
|
// Default instead.
|
|
func New() *Registry {
|
|
return &Registry{
|
|
commands: map[string]*atomic.Int64{},
|
|
errors: map[string]*atomic.Int64{},
|
|
}
|
|
}
|
|
|
|
// Default is the package-level registry. Convenience for the common case
|
|
// where a process needs exactly one. Tests can construct their own and use
|
|
// the methods directly.
|
|
var Default = New()
|
|
|
|
// IncCommand bumps the counter for a command invocation. name is the
|
|
// Telegram command without the leading slash.
|
|
func (r *Registry) IncCommand(name string) { r.inc(r.commandsMap(), name) }
|
|
|
|
// IncError bumps the counter for an error category — small, stable kinds
|
|
// like "ai-429", "kv-unavailable", "telegram-403".
|
|
func (r *Registry) IncError(kind string) { r.inc(r.errorsMap(), kind) }
|
|
|
|
func (r *Registry) commandsMap() map[string]*atomic.Int64 { return r.commands }
|
|
func (r *Registry) errorsMap() map[string]*atomic.Int64 { return r.errors }
|
|
|
|
// inc bumps the counter for name in m, allocating on first use. Allocates
|
|
// only when the name is new, so steady-state increments are mutex-free.
|
|
func (r *Registry) inc(m map[string]*atomic.Int64, name string) {
|
|
r.mu.RLock()
|
|
c, ok := m[name]
|
|
r.mu.RUnlock()
|
|
if ok {
|
|
c.Add(1)
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if c, ok := m[name]; ok {
|
|
c.Add(1)
|
|
return
|
|
}
|
|
c = &atomic.Int64{}
|
|
c.Store(1)
|
|
m[name] = c
|
|
}
|
|
|
|
// snapshot copies and resets the counters atomically per category. The
|
|
// returned maps are owned by the caller; the registry's internal state is
|
|
// reset to zero for the next interval.
|
|
func (r *Registry) snapshot() (cmds, errs map[string]int64) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
cmds = drain(r.commands)
|
|
errs = drain(r.errors)
|
|
return
|
|
}
|
|
|
|
// drain swaps out a counter map's values into a plain int64 map and
|
|
// resets each atomic to zero. The map keys are kept so subsequent
|
|
// increments don't reallocate the entry — only the count is reset.
|
|
func drain(m map[string]*atomic.Int64) map[string]int64 {
|
|
if len(m) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]int64, len(m))
|
|
for k, v := range m {
|
|
n := v.Swap(0)
|
|
if n != 0 {
|
|
out[k] = n
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Flush emits one structured log line with the current counters and
|
|
// resets them. Safe to call from anywhere; tests use it directly.
|
|
//
|
|
// The log line shape:
|
|
//
|
|
// {"msg":"metrics","commands":{"wordle":3,"loldle":1},"errors":{"handler-error":1}}
|
|
//
|
|
// CloudWatch Logs filters on `jsonPayload.msg=metrics` for dashboards.
|
|
// Empty categories appear as null (slog's default for nil maps).
|
|
func (r *Registry) Flush() {
|
|
cmds, errs := r.snapshot()
|
|
// Avoid an empty-everything log line — adds noise without signal.
|
|
if cmds == nil && errs == nil {
|
|
return
|
|
}
|
|
// slog renders map[string]int64 as a JSON object; tests assert on
|
|
// per-key substrings rather than full-line equality so non-deterministic
|
|
// hashtable iteration order doesn't make them flaky.
|
|
log.Info("metrics", "commands", cmds, "errors", errs)
|
|
}
|
|
|
|
// Run starts a goroutine that flushes counters every DefaultFlushInterval
|
|
// until ctx is cancelled. It does one final Flush on exit so a SIGTERM
|
|
// shutdown captures the trailing window. Returns immediately; the
|
|
// goroutine runs in the background.
|
|
//
|
|
// Idiomatic usage:
|
|
//
|
|
// go metrics.Default.Run(rootCtx)
|
|
func (r *Registry) Run(ctx context.Context) {
|
|
tick := time.NewTicker(DefaultFlushInterval)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
r.Flush()
|
|
return
|
|
case <-tick.C:
|
|
r.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// IncCommand / IncError on the package-level Default — short import-path-free
|
|
// spelling for the common case.
|
|
func IncCommand(name string) { Default.IncCommand(name) }
|
|
func IncError(kind string) { Default.IncError(kind) }
|
|
|
|
// Flush flushes the default registry. Used in graceful-shutdown paths.
|
|
func Flush() { Default.Flush() }
|
|
|
|
// Run starts the default-registry's flush loop. Cancels on ctx done.
|
|
func Run(ctx context.Context) { Default.Run(ctx) }
|