Files
miti99bot/internal/metrics/counters.go
T
tiennm99 35f81c6f13 chore: drop CF→AWS migration tooling and stale JS-port references
The CF→AWS data migration (closed 2026-05-16) is long done and the
tooling isn't wired into any production path. Remove the one-shot binary,
its support package, and the migration runbook.

In live code, replace 'JS-parity' / 'same shape as JS' / 'cross-runtime
KV migration' comments with the real, stable reason for each behavior
(wire-format invariant, null-vs-zero distinction, CloudWatch alarm field
name, etc.). 24 files touched across lolschedule, loldle, wordle, twentyq,
trading, misc, util, server, metrics, ai, keylock.

- delete cmd/migrate_cf_data/
- delete internal/migration/
- delete docs/cf-to-aws-migration-runbook.md
2026-05-25 09:39:17 +07:00

181 lines
5.9 KiB
Go

// Package metrics is a tiny in-memory counter store with periodic flush
// to CloudWatch Logs via the project's structured logger.
//
// Why not Prometheus / OpenTelemetry: the project runs on Lambda free
// tier with scale-to-zero. A pull-based exporter would be scraped from
// outside the instance and routinely hit a cold pod, defeating the point.
// Push-based exporters (StatsD, OTLP) require a paid sink.
//
// CloudWatch Logs is already free up to a generous quota and supports
// log-based metrics (count over `jsonPayload.msg=metrics`) for dashboards
// and alerts. Per-instance counters are reset on flush so the log line
// represents a delta, which CloudWatch Logs's count aggregation can sum
// across instances and time windows.
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 in CloudWatch.
const DefaultFlushInterval = 60 * time.Second
// Registry holds named counters across three categories: command
// invocations, errors, and AI calls. 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 CloudWatch Logs label values.
type Registry struct {
mu sync.RWMutex
commands map[string]*atomic.Int64
errors map[string]*atomic.Int64
ai 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{},
ai: 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) }
// IncAI bumps the counter for an AI call by model name. Useful for
// tracking the daily-quota path: sum across instances == requests.
func (r *Registry) IncAI(model string) { r.inc(r.aiMap(), model) }
func (r *Registry) commandsMap() map[string]*atomic.Int64 { return r.commands }
func (r *Registry) errorsMap() map[string]*atomic.Int64 { return r.errors }
func (r *Registry) aiMap() map[string]*atomic.Int64 { return r.ai }
// 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, ai map[string]int64) {
r.mu.Lock()
defer r.mu.Unlock()
cmds = drain(r.commands)
errs = drain(r.errors)
ai = drain(r.ai)
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":{"ai-429":1},"ai":null}
//
// 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, ai := r.snapshot()
// Avoid an empty-everything log line — adds noise without signal.
if cmds == nil && errs == nil && ai == 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, "ai", ai)
}
// 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 / IncAI 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) }
func IncAI(model string) { Default.IncAI(model) }
// 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) }