package storage import ( "log/slog" "sort" ) const secondsPerDay = 86400 // unixDay returns the current UTC day number (floor(unix / 86400)). func unixDay() int64 { return nowUnix() / secondsPerDay } // UnixDay is the exported current UTC day number, for callers building // day-windowed queries (e.g. the trending rail's 7-day cutoff). func UnixDay() int64 { return unixDay() } // RecordPageView increments the all-time and per-day view counter for a coarse // path label ("home", a channel slug, …). Fire-and-forget: a failure here must // never affect serving a page, so errors are logged and swallowed. func RecordPageView(path string) { exec("record page view", `INSERT INTO page_views (path, day, views) VALUES (?, ?, 1) ON CONFLICT(path, day) DO UPDATE SET views = views + 1`, path, unixDay()) } // RecordVisitor records a (already-hashed, salted) visitor token for today's // unique estimate. Duplicate tokens within the same day are ignored. func RecordVisitor(token string) { exec("record visitor", `INSERT OR IGNORE INTO daily_visitors (day, visitor) VALUES (?, ?)`, unixDay(), token) } // RecordStoryView bumps the per-day read counter for a single story. Called // when a visitor opens the story in reader mode. Fire-and-forget like the other // metrics writes: a failure here must never affect serving the article. func RecordStoryView(id int64) { exec("record story view", `INSERT INTO story_views (story_id, day, views) VALUES (?, ?, 1) ON CONFLICT(story_id, day) DO UPDATE SET views = views + 1`, id, unixDay()) } // StoryViewTotals returns all-time read counts for the given story ids, as a // map keyed by id. Ids with no recorded views are simply absent from the map // (callers treat missing as zero). Best-effort: on error it returns whatever it // managed to read, so a metrics hiccup never blanks a page. func StoryViewTotals(ids []int64) map[int64]int { out := make(map[int64]int, len(ids)) if len(ids) == 0 { return out } ph, args := intInClause(ids) rows, err := Get().Query( `SELECT story_id, SUM(views) FROM story_views WHERE story_id IN (`+ph+`) GROUP BY story_id`, args...) if err != nil { slog.Error("metrics: story view totals query failed", "err", err) return out } defer rows.Close() for rows.Next() { var id int64 var n int if err := rows.Scan(&id, &n); err != nil { slog.Error("metrics: scan story view total failed", "err", err) continue } out[id] = n } if err := rows.Err(); err != nil { slog.Error("metrics: story view totals iteration failed", "err", err) } return out } // intInClause builds a "?, ?, …" placeholder string and matching args slice for // a SQL IN (…) over int64 ids. func intInClause(ids []int64) (string, []any) { args := make([]any, len(ids)) for i, id := range ids { args[i] = id } return placeholders(len(ids)), args } // PathStat is one row of the per-page usage breakdown. type PathStat struct { Path string Total int // all-time views Today int // views since the start of the current UTC day } // DayStat is one day's unique-visitor estimate. type DayStat struct { Day int64 // unix day number Uniques int } // MetricsSummary is the aggregate usage readout surfaced by !petestats. type MetricsSummary struct { Pages []PathStat // per-path, busiest first TotalViews int // all-time, all paths ViewsToday int // all paths, current UTC day UniquesToday int // distinct visitor tokens, current UTC day Last7Days []DayStat // daily uniques, oldest → newest (note: salt rotates // daily, so these CANNOT be summed into a cross-day unique count) } // GetMetricsSummary assembles the usage readout. Best-effort: any sub-query // failure leaves that field at its zero value rather than failing the whole // command. func GetMetricsSummary() MetricsSummary { today := unixDay() var m MetricsSummary rows, err := Get().Query( `SELECT path, SUM(views) AS total, COALESCE(SUM(CASE WHEN day = ? THEN views END), 0) AS today FROM page_views GROUP BY path`, today) if err != nil { slog.Error("metrics: page_views query failed", "err", err) } else { defer rows.Close() for rows.Next() { var p PathStat if err := rows.Scan(&p.Path, &p.Total, &p.Today); err != nil { slog.Error("metrics: scan page stat failed", "err", err) continue } m.Pages = append(m.Pages, p) m.TotalViews += p.Total m.ViewsToday += p.Today } } sort.Slice(m.Pages, func(i, j int) bool { if m.Pages[i].Total != m.Pages[j].Total { return m.Pages[i].Total > m.Pages[j].Total } return m.Pages[i].Path < m.Pages[j].Path }) if err := Get().QueryRow( `SELECT COUNT(*) FROM daily_visitors WHERE day = ?`, today, ).Scan(&m.UniquesToday); err != nil { slog.Error("metrics: uniques-today query failed", "err", err) } since := today - 6 // inclusive 7-day window vrows, err := Get().Query( `SELECT day, COUNT(*) FROM daily_visitors WHERE day >= ? GROUP BY day ORDER BY day`, since) if err != nil { slog.Error("metrics: 7-day uniques query failed", "err", err) } else { defer vrows.Close() for vrows.Next() { var d DayStat if err := vrows.Scan(&d.Day, &d.Uniques); err != nil { slog.Error("metrics: scan day stat failed", "err", err) continue } m.Last7Days = append(m.Last7Days, d) } } return m }