package gateway import ( "sort" "strconv" "strings" "time" ) // Period is a reporting window for the usage dashboard. The dashboard used to // have exactly one view — everything since process start — which made a quiet // morning indistinguishable from a quiet week. Periods give the operator a // scale to read the numbers at: today vs this week vs this month vs all time. // // The set is deliberately calendar-based and UTC-anchored. A rolling 24h window // would put "today" and "the last day" at different boundaries, so the same // number would move between two cards depending on when you looked; calendar // days are what people actually mean by "today". UTC also matches the billing // plugin's peak-window arithmetic, so a peak-rate hour does not land in a // different day in the cost view than in the usage view. type Period string const ( // PeriodDay is the current UTC calendar day. PeriodDay Period = "day" // PeriodWeek is the current ISO week (Mon 00:00 UTC to now). PeriodWeek Period = "week" // PeriodMonth is the current UTC calendar month. PeriodMonth Period = "month" // PeriodAll is since process start — the only view backed by the // in-memory aggregates, and the only one available when no audit file is // configured. PeriodAll Period = "all" ) // ValidPeriod reports whether p is a period the aggregator understands. // An unknown period is a client error, not a silent fallback to "all": a // dashboard that quietly shows lifetime totals when the caller asked for today // is worse than one that refuses. func ValidPeriod(p Period) bool { switch p { case PeriodDay, PeriodWeek, PeriodMonth, PeriodAll: return true } return false } // periodStart returns the inclusive start of the window for p at time now. // Only PeriodDay/Week/Month are meaningful here; PeriodAll returns 0, which // every "from > 0" bounds check treats as unbounded. func periodStart(p Period, now time.Time) int64 { now = now.UTC() switch p { case PeriodDay: return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli() case PeriodWeek: // ISO week starts Monday. Go's Weekday() is Sunday=0, so the shift // below is 1 on Sunday and 0 on Monday..Saturday. off := (int(now.Weekday()) + 6) % 7 d := now.AddDate(0, 0, -off) return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, time.UTC).UnixMilli() case PeriodMonth: return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).UnixMilli() } return 0 } // PeriodBucket is one labelled point on the dashboard's timeline. Buckets are // the aggregation grain for a period: hourly for a day (so a spike is visible // inside "today"), daily for a week or month (so a week is not 7×24 points // wide and a month is not 31×24), and empty for "all" — a lifetime total has // no meaningful timeline, and pretending otherwise by drawing 500 hourly // buckets of rolling memory would be a different lie. type PeriodBucket struct { Bucket string `json:"bucket"` Stat } // PeriodSnapshot is the period-scoped twin of Snapshot's payload: the same // totals and the same by_* rows, plus a timeline. It deliberately mirrors // Snapshot's field names so the dashboard's table painters work unchanged — // paintModelTable(st.by_model) reads rows of {name, ...Stat}, and that shape // does not care where the numbers came from. type PeriodSnapshot struct { Period Period `json:"period"` From int64 `json:"from"` // unix millis, 0 when PeriodAll Total Stat `json:"total"` ByKey []StatsRow `json:"by_key"` Models []StatsRow `json:"by_model"` Srcs []StatsRow `json:"by_source"` Status []agrRow `json:"by_status"` Bucket []PeriodBucket `json:"buckets"` // Truncated marks that the window was clipped by the retained audit // history, so the numbers are a lower bound rather than the true period // total. The dashboard shows this next to the numbers rather than letting // a rotated-away week read as "that week had no traffic". Truncated bool `json:"truncated"` } // bucketKey maps a record's timestamp onto the timeline grain for p. // Daily buckets are stamped at UTC midnight; hourly buckets carry the hour. func bucketKey(p Period, ms int64) string { t := time.UnixMilli(ms).UTC() if p == PeriodDay { return t.Format("2006-01-02T15") } return t.Format("2006-01-02") } // bucketOf returns the bucket label plus the truncated-flag side effects of // walking a file: a record older than the requested window means the window // starts before the retained history, and the file may have been cut short. func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapshot { out := PeriodSnapshot{Period: p} from := periodStart(p, now) out.From = from // "all" is exactly what Snapshot already answers, from the in-memory // aggregates, and it is the one view that must keep working with no audit // file configured at all (a fresh dev setup, or an operator who turned // auditing off). Serving it from the same code path keeps the dashboard's // "total" card identical whether or not a period is selected. if p == PeriodAll { snap := s.Snapshot(firstScreenRecords, key) if tot, ok := snap["total"].(Stat); ok { out.Total = tot } out.ByKey, _ = snap["by_key"].([]StatsRow) out.Models, _ = snap["by_model"].([]StatsRow) out.Srcs, _ = snap["by_source"].([]StatsRow) out.Status, _ = snap["by_status"].([]agrRow) // replay_partial is the same "these numbers came from a bounded // tail" caveat, carried through under this view's own name. out.Truncated, _ = snap["replay_partial"].(bool) return out } // A bounded window is aggregated from the audit files, because the // in-memory aggregates are lifetime totals and the ring buffer holds only // maxRecs records (500 by default). Reading the ring would silently // under-report any period longer than the last few hundred requests. total := Stat{} byKey := map[string]*Stat{} byModel := map[string]*Stat{} bySrc := map[string]*Stat{} byStatus := map[string]*Stat{} buckets := map[string]*Stat{} var seen int err := s.StreamAuditRecords(from, 0, key, func(r Req) error { seen++ incStatus(&total, "", r) inc(byKey, r.Key, r) if r.Model != "" { inc(byModel, r.Model, r) } inc(bySrc, r.Source, r) if r.Status != 0 { inc(byStatus, strconv.Itoa(r.Status), r) } k := bucketKey(p, r.Time) b := buckets[k] if b == nil { b = &Stat{} buckets[k] = b } incStatus(b, k, r) return nil }) out.Total = total out.ByKey = rows(byKey) out.Models = rows(byModel) out.Srcs = rows(bySrc) bs := make([]agrRow, 0, len(byStatus)) for code, st := range byStatus { bs = append(bs, agrRow{Name: code, Stat: *st}) } sort.Slice(bs, func(i, j int) bool { ci, _ := strconv.Atoi(bs[i].Name) cj, _ := strconv.Atoi(bs[j].Name) return ci < cj }) out.Status = bs ks := make([]string, 0, len(buckets)) for k := range buckets { ks = append(ks, k) } // Chronological, string-sorted: "2006-01-02T15" and "2006-01-02" both // sort lexicographically in time order, so no date parsing is needed. sort.Strings(ks) out.Bucket = make([]PeriodBucket, 0, len(ks)) for _, k := range ks { out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *buckets[k]}) } // The window is only "complete" if the audit walk actually reached back // far enough to cover it. Two ways it cannot: // // 1. The walk found nothing at all in a window that certainly had // traffic, because the files holding it rotated away. // 2. The walk errored part-way (I/O), leaving a partial total. // // Case 2 is reported from err; case 1 from seen == 0 combined with the // caller having asked for a bounded window. It is deliberately // conservative: a genuinely empty hour is rare enough that flagging it as // possibly-truncated costs one tooltip, whereas silently under-reporting a // month because rotation ate it is a wrong number with no indication. if err != nil || (seen == 0 && p != PeriodAll) { out.Truncated = true } return out } // periodFromQuery parses the ?period= parameter. An empty value means "all" so // that existing callers of /api/stats keep seeing exactly what they saw. // A malformed value is rejected by the caller (ValidPeriod) rather than // defaulting, so a typo in a bookmarked URL surfaces as an error instead of // silently switching the operator to lifetime totals. func periodFromQuery(q map[string][]string) Period { v := strings.ToLower(strings.TrimSpace(firstQuery(q, "period"))) if v == "" { return PeriodAll } return Period(v) } func firstQuery(q map[string][]string, key string) string { if vs, ok := q[key]; ok && len(vs) > 0 { return vs[0] } return "" }