- reader.js: guard read-aloud against the loading placeholder (bodyReady) and invalidate stale speechSynthesis callbacks with a generation token - storage: check rows.Err() after iterating story-view/content-length reads - metrics: reuse placeholders() instead of duplicating the IN-clause builder
172 lines
5.3 KiB
Go
172 lines
5.3 KiB
Go
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
|
|
}
|