Commit 23438de
Eric Bower
·
2026-05-03 13:36:05 -0400 EDT
parent 4cd5fc0
refactor(visits): aggregate visit tables Previously we had an analytics_visits table that held all the raw visit data. This mean our analytics UI was constantly executing queries across 18 mil records which was extremely slow. This change aggregates those analytics every month and then deletes all the raw data. We keep 2 months worth of raw data and then everything else gets pushed into the aggregate tables.
9 files changed,
+1825,
-113
M
Makefile
+4,
-2
| ... | ... | @@ -150,10 +150,11 @@ migrate: | |
| 150 | 150 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20251226_add_pipe_monitoring.sql | |
| 151 | 151 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260116_add_analytics_filter_indexes.sql | |
| 152 | 152 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260305_add_forms_table.sql | |
| 153 | + | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260503_add_analytics_summary_tables.sql | |
| 153 | 154 | .PHONY: migrate | |
| 154 | 155 | ||
| 155 | 156 | latest: | |
| 156 | - | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260305_add_forms_table.sql | |
| 157 | + | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260503_add_analytics_summary_tables.sql | |
| 157 | 158 | .PHONY: latest | |
| 158 | 159 | ||
| 159 | 160 | psql: |
| ... | ... | @@ -164,8 +165,9 @@ dump: | |
| 164 | 165 | $(DOCKER_CMD) exec $(DB_CONTAINER) pg_dump -U $(PGUSER) $(PGDATABASE) > ./backup.sql | |
| 165 | 166 | .PHONY: dump | |
| 166 | 167 | ||
| 168 | + | BACKUP_FILE?=./backup.sql | |
| 167 | 169 | restore: | |
| 168 | - | $(DOCKER_CMD) cp ./backup.sql $(DB_CONTAINER):/backup.sql | |
| 170 | + | $(DOCKER_CMD) cp $(BACKUP_FILE) $(DB_CONTAINER):/backup.sql | |
| 169 | 171 | $(DOCKER_CMD) exec -it $(DB_CONTAINER) /bin/bash | |
| 170 | 172 | # psql postgres -U postgres -d pico < /backup.sql | |
| 171 | 173 | .PHONY: restore |
+101,
-0
| ... | ... | @@ -0,0 +1,101 @@ | |
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "flag" | |
| 5 | + | "fmt" | |
| 6 | + | "log/slog" | |
| 7 | + | "os" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/picosh/pico/pkg/apps/auth" | |
| 11 | + | "github.com/picosh/pico/pkg/db/postgres" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func main() { | |
| 15 | + | monthPtr := flag.String("month", "", "target month in YYYY-MM format (default: previous month)") | |
| 16 | + | backfill := flag.Bool("backfill", false, "aggregate all historical months up to the month before last") | |
| 17 | + | dryRun := flag.Bool("dry-run", false, "print months that would be processed without running aggregation") | |
| 18 | + | flag.Parse() | |
| 19 | + | ||
| 20 | + | logger := slog.Default() | |
| 21 | + | dbURL := os.Getenv("DATABASE_URL") | |
| 22 | + | if dbURL == "" { | |
| 23 | + | fmt.Fprintln(os.Stderr, "DATABASE_URL must be set") | |
| 24 | + | os.Exit(1) | |
| 25 | + | } | |
| 26 | + | dbpool := postgres.NewDB(dbURL, logger) | |
| 27 | + | defer func() { _ = dbpool.Close() }() | |
| 28 | + | ||
| 29 | + | if *backfill { | |
| 30 | + | runBackfill(dbpool, logger, *dryRun) | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | targetMonth := parseMonth(*monthPtr, logger) | |
| 35 | + | if err := auth.RunAnalyticsAggregation(dbpool, logger, targetMonth); err != nil { | |
| 36 | + | logger.Error("aggregation failed", "err", err) | |
| 37 | + | os.Exit(1) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func runBackfill(dbpool *postgres.PsqlDB, logger *slog.Logger, dryRun bool) { | |
| 42 | + | months, err := fetchHistoricalMonths(dbpool) | |
| 43 | + | if err != nil { | |
| 44 | + | logger.Error("failed to fetch historical months", "err", err) | |
| 45 | + | os.Exit(1) | |
| 46 | + | } | |
| 47 | + | if dryRun { | |
| 48 | + | fmt.Println("Months to backfill:") | |
| 49 | + | for _, m := range months { | |
| 50 | + | fmt.Println(" ", m.Format("2006-01")) | |
| 51 | + | } | |
| 52 | + | return | |
| 53 | + | } | |
| 54 | + | for _, m := range months { | |
| 55 | + | if err := auth.RunAnalyticsAggregation(dbpool, logger, m); err != nil { | |
| 56 | + | logger.Error("aggregation failed for month", "month", m.Format("2006-01"), "err", err) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | logger.Info("backfill complete", "months", len(months)) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // fetchHistoricalMonths returns all distinct months that have data in analytics_visits, | |
| 63 | + | // excluding the current month and the previous month (handled by the auth service cron). | |
| 64 | + | func fetchHistoricalMonths(dbpool *postgres.PsqlDB) ([]time.Time, error) { | |
| 65 | + | cutoff := time.Now().AddDate(0, -1, 0) | |
| 66 | + | cutoffMonth := time.Date(cutoff.Year(), cutoff.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 67 | + | ||
| 68 | + | rows, err := dbpool.Db.Queryx(` | |
| 69 | + | SELECT DISTINCT date_trunc('month', created_at)::date AS month_start | |
| 70 | + | FROM analytics_visits | |
| 71 | + | WHERE created_at < $1 | |
| 72 | + | ORDER BY month_start ASC | |
| 73 | + | `, cutoffMonth) | |
| 74 | + | if err != nil { | |
| 75 | + | return nil, err | |
| 76 | + | } | |
| 77 | + | defer func() { _ = rows.Close() }() | |
| 78 | + | ||
| 79 | + | var months []time.Time | |
| 80 | + | for rows.Next() { | |
| 81 | + | var monthDate time.Time | |
| 82 | + | if err := rows.Scan(&monthDate); err != nil { | |
| 83 | + | return nil, err | |
| 84 | + | } | |
| 85 | + | months = append(months, monthDate) | |
| 86 | + | } | |
| 87 | + | return months, rows.Err() | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func parseMonth(arg string, logger *slog.Logger) time.Time { | |
| 91 | + | now := time.Now() | |
| 92 | + | if arg != "" { | |
| 93 | + | t, err := time.Parse("2006-01", arg) | |
| 94 | + | if err != nil { | |
| 95 | + | logger.Error("invalid month format, use YYYY-MM", "err", err, "input", arg) | |
| 96 | + | os.Exit(1) | |
| 97 | + | } | |
| 98 | + | return t | |
| 99 | + | } | |
| 100 | + | return now.AddDate(0, -1, 0) | |
| 101 | + | } |
+306,
-0
| ... | ... | @@ -0,0 +1,306 @@ | |
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "flag" | |
| 5 | + | "fmt" | |
| 6 | + | "log/slog" | |
| 7 | + | "os" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/picosh/pico/pkg/db" | |
| 11 | + | "github.com/picosh/pico/pkg/db/postgres" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func main() { | |
| 15 | + | host := flag.String("host", "", "host to compare (required)") | |
| 16 | + | userID := flag.String("user-id", "", "user ID to compare (required)") | |
| 17 | + | interval := flag.String("interval", "day", "interval: day, week, month") | |
| 18 | + | origin := flag.String("origin", "", "origin date in YYYY-MM-DD format (default: start of year)") | |
| 19 | + | flag.Parse() | |
| 20 | + | ||
| 21 | + | if *host == "" || *userID == "" { | |
| 22 | + | fmt.Fprintln(os.Stderr, "usage: compare-analytics -host example.com -user-id <uuid> [-interval day|week|month] [-origin 2025-01-01]") | |
| 23 | + | os.Exit(1) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | logger := slog.Default() | |
| 27 | + | dbURL := os.Getenv("DATABASE_URL") | |
| 28 | + | if dbURL == "" { | |
| 29 | + | fmt.Fprintln(os.Stderr, "DATABASE_URL must be set") | |
| 30 | + | os.Exit(1) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | dbpool := postgres.NewDB(dbURL, logger) | |
| 34 | + | defer func() { _ = dbpool.Close() }() | |
| 35 | + | ||
| 36 | + | originTime := time.Date(time.Now().AddDate(-1, 0, 0).Year(), time.January, 1, 0, 0, 0, 0, time.UTC) | |
| 37 | + | if *origin != "" { | |
| 38 | + | var err error | |
| 39 | + | originTime, err = time.Parse("2006-01-02", *origin) | |
| 40 | + | if err != nil { | |
| 41 | + | logger.Error("invalid origin format, use YYYY-MM-DD", "err", err) | |
| 42 | + | os.Exit(1) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | opts := &db.SummaryOpts{ | |
| 47 | + | Host: *host, | |
| 48 | + | UserID: *userID, | |
| 49 | + | Interval: *interval, | |
| 50 | + | Origin: originTime, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // New path: reads from summary tables + current month raw | |
| 54 | + | newSummary, err := dbpool.VisitSummary(opts) | |
| 55 | + | if err != nil { | |
| 56 | + | logger.Error("VisitSummary failed", "err", err) | |
| 57 | + | os.Exit(1) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Old path: direct queries against analytics_visits | |
| 61 | + | oldSummary, err := queryRawVisits(dbpool, opts) | |
| 62 | + | if err != nil { | |
| 63 | + | logger.Error("raw query failed", "err", err) | |
| 64 | + | os.Exit(1) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Compare | |
| 68 | + | fmt.Println("=== Unique Visitors (Intervals) ===") | |
| 69 | + | compareIntervals(newSummary.Intervals, oldSummary.Intervals) | |
| 70 | + | ||
| 71 | + | fmt.Println("\n=== Top URLs ===") | |
| 72 | + | compareUrls(newSummary.TopUrls, oldSummary.TopUrls) | |
| 73 | + | ||
| 74 | + | fmt.Println("\n=== Top Referers ===") | |
| 75 | + | compareUrls(newSummary.TopReferers, oldSummary.TopReferers) | |
| 76 | + | ||
| 77 | + | fmt.Println("\n=== 404 URLs ===") | |
| 78 | + | compareUrls(newSummary.NotFoundUrls, oldSummary.NotFoundUrls) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func queryRawVisits(dbpool *postgres.PsqlDB, opts *db.SummaryOpts) (*db.SummaryVisits, error) { | |
| 82 | + | intervals, err := rawVisitUnique(dbpool, opts) | |
| 83 | + | if err != nil { | |
| 84 | + | return nil, fmt.Errorf("raw visit unique: %w", err) | |
| 85 | + | } | |
| 86 | + | urls, err := rawVisitUrl(dbpool, opts) | |
| 87 | + | if err != nil { | |
| 88 | + | return nil, fmt.Errorf("raw visit url: %w", err) | |
| 89 | + | } | |
| 90 | + | refs, err := rawVisitReferer(dbpool, opts) | |
| 91 | + | if err != nil { | |
| 92 | + | return nil, fmt.Errorf("raw visit referer: %w", err) | |
| 93 | + | } | |
| 94 | + | notFound, err := rawVisitUrlNotFound(dbpool, opts) | |
| 95 | + | if err != nil { | |
| 96 | + | return nil, fmt.Errorf("raw visit url not found: %w", err) | |
| 97 | + | } | |
| 98 | + | return &db.SummaryVisits{ | |
| 99 | + | Intervals: intervals, | |
| 100 | + | TopUrls: urls, | |
| 101 | + | TopReferers: refs, | |
| 102 | + | NotFoundUrls: notFound, | |
| 103 | + | }, nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func rawVisitUnique(dbpool *postgres.PsqlDB, opts *db.SummaryOpts) ([]*db.VisitInterval, error) { | |
| 107 | + | where, with := visitFilterBy(opts) | |
| 108 | + | query := fmt.Sprintf(` | |
| 109 | + | SELECT date_trunc('%s', created_at)::timestamptz as interval_start, | |
| 110 | + | count(DISTINCT ip_address) as unique_visitors | |
| 111 | + | FROM analytics_visits | |
| 112 | + | WHERE created_at >= $1 AND %s = $2 AND user_id = $3 AND status <> 404 | |
| 113 | + | GROUP BY interval_start | |
| 114 | + | ORDER BY interval_start`, opts.Interval, where) | |
| 115 | + | ||
| 116 | + | rows, err := dbpool.Db.Queryx(query, opts.Origin, with, opts.UserID) | |
| 117 | + | if err != nil { | |
| 118 | + | return nil, err | |
| 119 | + | } | |
| 120 | + | defer func() { _ = rows.Close() }() | |
| 121 | + | ||
| 122 | + | var intervals []*db.VisitInterval | |
| 123 | + | for rows.Next() { | |
| 124 | + | iv := &db.VisitInterval{} | |
| 125 | + | if err := rows.Scan(&iv.Interval, &iv.Visitors); err != nil { | |
| 126 | + | return nil, err | |
| 127 | + | } | |
| 128 | + | intervals = append(intervals, iv) | |
| 129 | + | } | |
| 130 | + | return intervals, rows.Err() | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func rawVisitUrl(dbpool *postgres.PsqlDB, opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 134 | + | where, with := visitFilterBy(opts) | |
| 135 | + | query := fmt.Sprintf(` | |
| 136 | + | SELECT path as url, count(DISTINCT ip_address) as count | |
| 137 | + | FROM analytics_visits | |
| 138 | + | WHERE created_at >= $1 AND %s = $2 AND user_id = $3 AND path <> '' AND status <> 404 | |
| 139 | + | GROUP BY path | |
| 140 | + | ORDER BY count DESC | |
| 141 | + | LIMIT 10`, where) | |
| 142 | + | ||
| 143 | + | rows, err := dbpool.Db.Queryx(query, opts.Origin, with, opts.UserID) | |
| 144 | + | if err != nil { | |
| 145 | + | return nil, err | |
| 146 | + | } | |
| 147 | + | defer func() { _ = rows.Close() }() | |
| 148 | + | ||
| 149 | + | var urls []*db.VisitUrl | |
| 150 | + | for rows.Next() { | |
| 151 | + | u := &db.VisitUrl{} | |
| 152 | + | if err := rows.Scan(&u.Url, &u.Count); err != nil { | |
| 153 | + | return nil, err | |
| 154 | + | } | |
| 155 | + | urls = append(urls, u) | |
| 156 | + | } | |
| 157 | + | return urls, rows.Err() | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func rawVisitReferer(dbpool *postgres.PsqlDB, opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 161 | + | where, with := visitFilterBy(opts) | |
| 162 | + | query := fmt.Sprintf(` | |
| 163 | + | SELECT referer as url, count(DISTINCT ip_address) as count | |
| 164 | + | FROM analytics_visits | |
| 165 | + | WHERE created_at >= $1 AND %s = $2 AND user_id = $3 AND referer <> '' AND status <> 404 | |
| 166 | + | GROUP BY referer | |
| 167 | + | ORDER BY count DESC | |
| 168 | + | LIMIT 10`, where) | |
| 169 | + | ||
| 170 | + | rows, err := dbpool.Db.Queryx(query, opts.Origin, with, opts.UserID) | |
| 171 | + | if err != nil { | |
| 172 | + | return nil, err | |
| 173 | + | } | |
| 174 | + | defer func() { _ = rows.Close() }() | |
| 175 | + | ||
| 176 | + | var urls []*db.VisitUrl | |
| 177 | + | for rows.Next() { | |
| 178 | + | u := &db.VisitUrl{} | |
| 179 | + | if err := rows.Scan(&u.Url, &u.Count); err != nil { | |
| 180 | + | return nil, err | |
| 181 | + | } | |
| 182 | + | urls = append(urls, u) | |
| 183 | + | } | |
| 184 | + | return urls, rows.Err() | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func rawVisitUrlNotFound(dbpool *postgres.PsqlDB, opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 188 | + | where, with := visitFilterBy(opts) | |
| 189 | + | limit := opts.Limit | |
| 190 | + | if limit == 0 { | |
| 191 | + | limit = 10 | |
| 192 | + | } | |
| 193 | + | query := fmt.Sprintf(` | |
| 194 | + | SELECT path as url, count(DISTINCT ip_address) as count | |
| 195 | + | FROM analytics_visits | |
| 196 | + | WHERE created_at >= $1 AND %s = $2 AND user_id = $3 AND path <> '' AND status = 404 | |
| 197 | + | GROUP BY path | |
| 198 | + | ORDER BY count DESC | |
| 199 | + | LIMIT %d`, where, limit) | |
| 200 | + | ||
| 201 | + | rows, err := dbpool.Db.Queryx(query, opts.Origin, with, opts.UserID) | |
| 202 | + | if err != nil { | |
| 203 | + | return nil, err | |
| 204 | + | } | |
| 205 | + | defer func() { _ = rows.Close() }() | |
| 206 | + | ||
| 207 | + | var urls []*db.VisitUrl | |
| 208 | + | for rows.Next() { | |
| 209 | + | u := &db.VisitUrl{} | |
| 210 | + | if err := rows.Scan(&u.Url, &u.Count); err != nil { | |
| 211 | + | return nil, err | |
| 212 | + | } | |
| 213 | + | urls = append(urls, u) | |
| 214 | + | } | |
| 215 | + | return urls, rows.Err() | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func visitFilterBy(opts *db.SummaryOpts) (string, string) { | |
| 219 | + | if opts.Host != "" { | |
| 220 | + | return "host", opts.Host | |
| 221 | + | } | |
| 222 | + | if opts.Path != "" { | |
| 223 | + | return "path", opts.Path | |
| 224 | + | } | |
| 225 | + | return "host", "" | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func compareIntervals(newData, oldData []*db.VisitInterval) { | |
| 229 | + | newMap := make(map[string]int) | |
| 230 | + | for _, iv := range newData { | |
| 231 | + | key := iv.Interval.Format("2006-01-02") | |
| 232 | + | newMap[key] = iv.Visitors | |
| 233 | + | } | |
| 234 | + | oldMap := make(map[string]int) | |
| 235 | + | for _, iv := range oldData { | |
| 236 | + | key := iv.Interval.Format("2006-01-02") | |
| 237 | + | oldMap[key] = iv.Visitors | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // Collect all keys | |
| 241 | + | keys := make(map[string]bool) | |
| 242 | + | for k := range newMap { | |
| 243 | + | keys[k] = true | |
| 244 | + | } | |
| 245 | + | for k := range oldMap { | |
| 246 | + | keys[k] = true | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | mismatches := 0 | |
| 250 | + | for k := range keys { | |
| 251 | + | n, nOk := newMap[k] | |
| 252 | + | o, oOk := oldMap[k] | |
| 253 | + | if nOk && oOk { | |
| 254 | + | if n != o { | |
| 255 | + | fmt.Printf(" MISMATCH %s: new=%d old=%d\n", k, n, o) | |
| 256 | + | mismatches++ | |
| 257 | + | } | |
| 258 | + | } else if nOk { | |
| 259 | + | fmt.Printf(" NEW ONLY %s: %d\n", k, n) | |
| 260 | + | mismatches++ | |
| 261 | + | } else { | |
| 262 | + | fmt.Printf(" OLD ONLY %s: %d\n", k, o) | |
| 263 | + | mismatches++ | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | if mismatches == 0 { | |
| 267 | + | fmt.Println(" OK: all intervals match") | |
| 268 | + | } else { | |
| 269 | + | fmt.Printf(" %d mismatches\n", mismatches) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func compareUrls(newData, oldData []*db.VisitUrl) { | |
| 274 | + | newMap := make(map[string]int) | |
| 275 | + | for _, u := range newData { | |
| 276 | + | newMap[u.Url] = u.Count | |
| 277 | + | } | |
| 278 | + | oldMap := make(map[string]int) | |
| 279 | + | for _, u := range oldData { | |
| 280 | + | oldMap[u.Url] = u.Count | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | mismatches := 0 | |
| 284 | + | for url, nCount := range newMap { | |
| 285 | + | if oCount, ok := oldMap[url]; ok { | |
| 286 | + | if nCount != oCount { | |
| 287 | + | fmt.Printf(" MISMATCH %s: new=%d old=%d\n", url, nCount, oCount) | |
| 288 | + | mismatches++ | |
| 289 | + | } | |
| 290 | + | } else { | |
| 291 | + | fmt.Printf(" NEW ONLY %s: %d\n", url, nCount) | |
| 292 | + | mismatches++ | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | for url, oCount := range oldMap { | |
| 296 | + | if _, ok := newMap[url]; !ok { | |
| 297 | + | fmt.Printf(" OLD ONLY %s: %d\n", url, oCount) | |
| 298 | + | mismatches++ | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | if mismatches == 0 { | |
| 302 | + | fmt.Println(" OK: all entries match") | |
| 303 | + | } else { | |
| 304 | + | fmt.Printf(" %d mismatches\n", mismatches) | |
| 305 | + | } | |
| 306 | + | } |
+336,
-0
| ... | ... | @@ -0,0 +1,336 @@ | |
| 1 | + | # Analytics Data Retention Proposal | |
| 2 | + | ||
| 3 | + | ## Problem | |
| 4 | + | ||
| 5 | + | The `analytics_visits` table has **18 million rows** in production. All analytics queries (unique visitors, top URLs, top referers, 404s) aggregate raw visit data on every request, causing slow query performance. The table continues to grow unbounded. | |
| 6 | + | ||
| 7 | + | ## Goal | |
| 8 | + | ||
| 9 | + | - Keep `analytics_visits` small by deleting raw data older than **1 month** | |
| 10 | + | - Pre-aggregate monthly stats into summary tables so historical queries remain fast | |
| 11 | + | - Preserve all data the user currently sees in the TUI and SSH CLI | |
| 12 | + | ||
| 13 | + | --- | |
| 14 | + | ||
| 15 | + | ## What We Display Today | |
| 16 | + | ||
| 17 | + | ### TUI Analytics Screen (`pkg/tui/analytics.go`) | |
| 18 | + | ||
| 19 | + | When a user views analytics for a site, we show: | |
| 20 | + | ||
| 21 | + | | Section | Query | Aggregation | | |
| 22 | + | |---------|-------|-------------| | |
| 23 | + | | **Site list** (left pane) | `visitHost()` | `COUNT(DISTINCT ip_address)` grouped by `host` | | |
| 24 | + | | **Visits by period** | `visitUnique()` | `COUNT(DISTINCT ip_address)` grouped by `date_trunc(interval, created_at)` | | |
| 25 | + | | **Top URLs** | `visitUrl()` | `COUNT(DISTINCT ip_address)` grouped by `path`, LIMIT 10, excludes 404s | | |
| 26 | + | | **Top Referers** | `visitReferer()` | `COUNT(DISTINCT ip_address)` grouped by `referer`, LIMIT 10, excludes 404s | | |
| 27 | + | | **Top 404s** | `VisitUrlNotFound()` | `COUNT(DISTINCT ip_address)` grouped by `path`, LIMIT 10, status=404 | | |
| 28 | + | ||
| 29 | + | The TUI supports two intervals: | |
| 30 | + | - **"day"** — daily buckets from start of current month (`StartOfMonth()`) | |
| 31 | + | - **"month"** — monthly buckets from 1 year ago (`StartOfYear()` which is `now - 1 year`) | |
| 32 | + | ||
| 33 | + | ### SSH CLI (`pkg/apps/pico/cli.go`) | |
| 34 | + | ||
| 35 | + | | Command | Query | Aggregation | | |
| 36 | + | |---------|-------|-------------| | |
| 37 | + | | `not-found hostname.com [year\|month]` | `VisitUrlNotFound()` | `COUNT(DISTINCT ip_address)` grouped by `path`, LIMIT 100, status=404 | | |
| 38 | + | ||
| 39 | + | --- | |
| 40 | + | ||
| 41 | + | ## Proposed Schema | |
| 42 | + | ||
| 43 | + | ### New Table: `analytics_user_sites` | |
| 44 | + | ||
| 45 | + | Tracks every site a user has had traffic on. Powers the site list in the left pane of the TUI. | |
| 46 | + | ||
| 47 | + | ```sql | |
| 48 | + | CREATE TABLE analytics_user_sites ( | |
| 49 | + | id SERIAL PRIMARY KEY, | |
| 50 | + | user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, | |
| 51 | + | host VARCHAR(253) NOT NULL, | |
| 52 | + | total_visits INT NOT NULL DEFAULT 0, -- running total of all-time unique visitors | |
| 53 | + | last_seen DATE NOT NULL, -- most recent month with traffic | |
| 54 | + | created_at TIMESTAMP NOT NULL DEFAULT now(), | |
| 55 | + | updated_at TIMESTAMP NOT NULL DEFAULT now(), | |
| 56 | + | ||
| 57 | + | UNIQUE (user_id, host) | |
| 58 | + | ); | |
| 59 | + | ||
| 60 | + | CREATE INDEX idx_user_sites_user ON analytics_user_sites (user_id); | |
| 61 | + | ``` | |
| 62 | + | ||
| 63 | + | **How it's populated:** During monthly aggregation, for each `(user_id, host)` pair found in the previous month's raw data: | |
| 64 | + | - `INSERT ... ON CONFLICT (user_id, host) DO UPDATE` — add that month's unique visitor count to `total_visits`, set `last_seen` to the month's date. | |
| 65 | + | ||
| 66 | + | **Query replacement:** `visitHost()` becomes: | |
| 67 | + | ```sql | |
| 68 | + | SELECT host, total_visits as host_count | |
| 69 | + | FROM analytics_user_sites | |
| 70 | + | WHERE user_id = $1 | |
| 71 | + | ORDER BY total_visits DESC | |
| 72 | + | ``` | |
| 73 | + | ||
| 74 | + | No GROUP BY, no scan of raw data — just an indexed lookup. | |
| 75 | + | ||
| 76 | + | ### New Table: `analytics_monthly_visits` | |
| 77 | + | ||
| 78 | + | Stores pre-aggregated daily unique visitor counts per host. This powers the "visits by period" chart and site list. | |
| 79 | + | ||
| 80 | + | ```sql | |
| 81 | + | CREATE TABLE analytics_monthly_visits ( | |
| 82 | + | id SERIAL PRIMARY KEY, | |
| 83 | + | user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, | |
| 84 | + | host VARCHAR(253) NOT NULL, | |
| 85 | + | visit_date DATE NOT NULL, -- the day this count is for | |
| 86 | + | unique_visits INT NOT NULL DEFAULT 0, -- COUNT(DISTINCT ip_address) for that day | |
| 87 | + | mobile_visits INT NOT NULL DEFAULT 0, -- unique visitors from mobile user-agents | |
| 88 | + | desktop_visits INT NOT NULL DEFAULT 0, -- unique visitors from desktop user-agents | |
| 89 | + | created_at TIMESTAMP NOT NULL DEFAULT now(), | |
| 90 | + | ||
| 91 | + | UNIQUE (user_id, host, visit_date) | |
| 92 | + | ); | |
| 93 | + | ||
| 94 | + | CREATE INDEX idx_monthly_visits_user_host ON analytics_monthly_visits (user_id, host); | |
| 95 | + | CREATE INDEX idx_monthly_visits_user_date ON analytics_monthly_visits (user_id, visit_date DESC); | |
| 96 | + | ``` | |
| 97 | + | ||
| 98 | + | **Why this shape:** The TUI queries `date_trunc('day', created_at)` or `date_trunc('month', created_at)` with `COUNT(DISTINCT ip_address)`. We pre-compute the daily distinct count. For "month" interval view, the app sums daily rows within each month client-side or via a simple `GROUP BY date_trunc('month', visit_date)`. | |
| 99 | + | ||
| 100 | + | **Device detection:** `mobile_visits` and `desktop_visits` are derived from the `user_agent` column during aggregation. A user-agent library (e.g., [`pdericx/ua-parser`](https://github.com/pdericx/ua-parser) or [`mssola/useragent`](https://github.com/mssola/useragent)) classifies each visit. An IP counted as mobile if its user-agent is mobile, desktop otherwise. Note: `mobile_visits + desktop_visits = unique_visits` since every visit is one or the other. | |
| 101 | + | ||
| 102 | + | ### New Table: `analytics_monthly_top_urls` | |
| 103 | + | ||
| 104 | + | Stores the top URLs per host per month. Powers "Top URLs" and "Top 404s" sections. | |
| 105 | + | ||
| 106 | + | ```sql | |
| 107 | + | CREATE TABLE analytics_monthly_top_urls ( | |
| 108 | + | id SERIAL PRIMARY KEY, | |
| 109 | + | user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, | |
| 110 | + | host VARCHAR(253) NOT NULL, | |
| 111 | + | month DATE NOT NULL, -- first day of the month (e.g., 2025-01-01) | |
| 112 | + | path VARCHAR(2048) NOT NULL, | |
| 113 | + | unique_visits INT NOT NULL DEFAULT 0, -- COUNT(DISTINCT ip_address) for that path | |
| 114 | + | is_404 BOOLEAN NOT NULL DEFAULT false, | |
| 115 | + | rank INT NOT NULL, -- 1-10 (top 10) or 1-100 for CLI | |
| 116 | + | created_at TIMESTAMP NOT NULL DEFAULT now(), | |
| 117 | + | ||
| 118 | + | UNIQUE (user_id, host, month, path, is_404) | |
| 119 | + | ); | |
| 120 | + | ||
| 121 | + | CREATE INDEX idx_monthly_top_urls_user_host_month ON analytics_monthly_top_urls (user_id, host, month); | |
| 122 | + | CREATE INDEX idx_monthly_top_urls_404 ON analytics_monthly_top_urls (user_id, host, month, is_404, rank); | |
| 123 | + | ``` | |
| 124 | + | ||
| 125 | + | ### New Table: `analytics_monthly_top_referers` | |
| 126 | + | ||
| 127 | + | Stores top referers per host per month. Powers "Top Referers" section. | |
| 128 | + | ||
| 129 | + | ```sql | |
| 130 | + | CREATE TABLE analytics_monthly_top_referers ( | |
| 131 | + | id SERIAL PRIMARY KEY, | |
| 132 | + | user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, | |
| 133 | + | host VARCHAR(253) NOT NULL, | |
| 134 | + | month DATE NOT NULL, -- first day of the month | |
| 135 | + | referer VARCHAR(253) NOT NULL, | |
| 136 | + | unique_visits INT NOT NULL DEFAULT 0, | |
| 137 | + | rank INT NOT NULL, -- 1-10 | |
| 138 | + | created_at TIMESTAMP NOT NULL DEFAULT now(), | |
| 139 | + | ||
| 140 | + | UNIQUE (user_id, host, month, referer) | |
| 141 | + | ); | |
| 142 | + | ||
| 143 | + | CREATE INDEX idx_monthly_referers_user_host_month ON analytics_monthly_top_referers (user_id, host, month); | |
| 144 | + | ``` | |
| 145 | + | ||
| 146 | + | --- | |
| 147 | + | ||
| 148 | + | ## Monthly Aggregation Job | |
| 149 | + | ||
| 150 | + | A script (e.g., `cmd/scripts/analytics-aggregate/main.go`) runs **once per month** (e.g., on the 1st). It: | |
| 151 | + | ||
| 152 | + | ### Step 1: Aggregate the previous month | |
| 153 | + | ||
| 154 | + | For each user with the `analytics` feature flag who has raw visits in the previous month: | |
| 155 | + | ||
| 156 | + | ``` | |
| 157 | + | FOR each (user_id, host) pair in analytics_visits WHERE created_at IN previous_month: | |
| 158 | + | -- Daily unique visitors (with device breakdown) | |
| 159 | + | -- Device detection runs in Go (user-agent parsing), not pure SQL. | |
| 160 | + | -- Pseudo-SQL for clarity: | |
| 161 | + | INSERT INTO analytics_monthly_visits (user_id, host, visit_date, unique_visits, mobile_visits, desktop_visits) | |
| 162 | + | SELECT user_id, host, date_trunc('day', created_at)::date, | |
| 163 | + | COUNT(DISTINCT ip_address), | |
| 164 | + | COUNT(DISTINCT CASE WHEN is_mobile(user_agent) THEN ip_address END), | |
| 165 | + | COUNT(DISTINCT CASE WHEN NOT is_mobile(user_agent) THEN ip_address END) | |
| 166 | + | FROM analytics_visits | |
| 167 | + | WHERE user_id = $1 AND host = $2 AND created_at >= start_of_month AND created_at < start_of_next_month | |
| 168 | + | GROUP BY user_id, host, date_trunc('day', created_at) | |
| 169 | + | ON CONFLICT (user_id, host, visit_date) DO NOTHING; | |
| 170 | + | ||
| 171 | + | -- In practice, `is_mobile()` is a Go function. The aggregation job fetches | |
| 172 | + | -- raw rows in batches, classifies each user_agent, and issues INSERTs. | |
| 173 | + | ||
| 174 | + | -- Top 10 URLs (non-404) | |
| 175 | + | INSERT INTO analytics_monthly_top_urls (user_id, host, month, path, unique_visits, is_404, rank) | |
| 176 | + | SELECT user_id, host, date_trunc('month', created_at)::date, path, COUNT(DISTINCT ip_address), false, | |
| 177 | + | ROW_NUMBER() OVER (PARTITION BY user_id, host ORDER BY COUNT(DISTINCT ip_address) DESC) | |
| 178 | + | FROM analytics_visits | |
| 179 | + | WHERE user_id = $1 AND host = $2 AND created_at >= start_of_month AND created_at < start_of_next_month AND status <> 404 | |
| 180 | + | GROUP BY user_id, host, path | |
| 181 | + | HAVING ROW_NUMBER() <= 10 | |
| 182 | + | ON CONFLICT (user_id, host, month, path, is_404) DO NOTHING; | |
| 183 | + | ||
| 184 | + | -- Top 10 URLs (404s) | |
| 185 | + | INSERT INTO analytics_monthly_top_urls (user_id, host, month, path, unique_visits, is_404, rank) | |
| 186 | + | SELECT user_id, host, date_trunc('month', created_at)::date, path, COUNT(DISTINCT ip_address), true, | |
| 187 | + | ROW_NUMBER() OVER (PARTITION BY user_id, host ORDER BY COUNT(DISTINCT ip_address) DESC) | |
| 188 | + | FROM analytics_visits | |
| 189 | + | WHERE user_id = $1 AND host = $2 AND created_at >= start_of_month AND created_at < start_of_next_month AND status = 404 | |
| 190 | + | GROUP BY user_id, host, path | |
| 191 | + | HAVING ROW_NUMBER() <= 100 -- 100 for CLI, 10 for TUI (use 100 to cover both) | |
| 192 | + | ON CONFLICT (user_id, host, month, path, is_404) DO NOTHING; | |
| 193 | + | ||
| 194 | + | -- Top 10 referers | |
| 195 | + | INSERT INTO analytics_monthly_top_referers (user_id, host, month, referer, unique_visits, rank) | |
| 196 | + | SELECT user_id, host, date_trunc('month', created_at)::date, referer, COUNT(DISTINCT ip_address), | |
| 197 | + | ROW_NUMBER() OVER (PARTITION BY user_id, host ORDER BY COUNT(DISTINCT ip_address) DESC) | |
| 198 | + | FROM analytics_visits | |
| 199 | + | WHERE user_id = $1 AND host = $2 AND created_at >= start_of_month AND created_at < start_of_next_month AND referer <> '' AND status <> 404 | |
| 200 | + | GROUP BY user_id, host, referer | |
| 201 | + | HAVING ROW_NUMBER() <= 10 | |
| 202 | + | ON CONFLICT (user_id, host, month, referer) DO NOTHING; | |
| 203 | + | ``` | |
| 204 | + | ||
| 205 | + | ### Step 2: Delete raw data older than 1 month | |
| 206 | + | ||
| 207 | + | ```sql | |
| 208 | + | DELETE FROM analytics_visits | |
| 209 | + | WHERE created_at < date_trunc('month', now())::timestamp; | |
| 210 | + | ``` | |
| 211 | + | ||
| 212 | + | This keeps only the current month's raw data. After the first run, `analytics_visits` should drop from ~18M rows to ~1 month of data (estimated 150K–500K depending on traffic). | |
| 213 | + | ||
| 214 | + | ### Step 3: Backfill (one-time) | |
| 215 | + | ||
| 216 | + | On first deployment, run the aggregation for all historical months before deleting anything. This is a separate migration pass that iterates month-by-month from the oldest data to 2 months ago. | |
| 217 | + | ||
| 218 | + | --- | |
| 219 | + | ||
| 220 | + | ## Query Changes | |
| 221 | + | ||
| 222 | + | ### Current queries → New queries | |
| 223 | + | ||
| 224 | + | Each existing query method gets a `since` parameter. If `since` is within the last month, query `analytics_visits` directly (current behavior). If `since` spans older months, union results from summary tables with raw data. | |
| 225 | + | ||
| 226 | + | #### `visitUnique()` — visits by period | |
| 227 | + | ||
| 228 | + | ``` | |
| 229 | + | -- For "day" interval (current month only, always from raw): | |
| 230 | + | -- No change — reads from analytics_visits | |
| 231 | + | ||
| 232 | + | -- For "month" interval (historical): | |
| 233 | + | -- Read from analytics_monthly_visits, sum daily counts per month: | |
| 234 | + | SELECT visit_date::date as interval_start, SUM(unique_visits) as visitors | |
| 235 | + | FROM analytics_monthly_visits | |
| 236 | + | WHERE user_id = $1 AND host = $2 AND visit_date >= $3 | |
| 237 | + | GROUP BY date_trunc('month', visit_date), visit_date | |
| 238 | + | ORDER BY visit_date | |
| 239 | + | ``` | |
| 240 | + | ||
| 241 | + | #### `visitUrl()` — top URLs | |
| 242 | + | ||
| 243 | + | ``` | |
| 244 | + | -- Current month: read from analytics_visits (no change) | |
| 245 | + | -- Historical months: read from analytics_monthly_top_urls WHERE is_404 = false | |
| 246 | + | ``` | |
| 247 | + | ||
| 248 | + | #### `visitReferer()` — top referers | |
| 249 | + | ||
| 250 | + | ``` | |
| 251 | + | -- Current month: read from analytics_visits (no change) | |
| 252 | + | -- Historical months: read from analytics_monthly_top_referers | |
| 253 | + | ``` | |
| 254 | + | ||
| 255 | + | #### `VisitUrlNotFound()` — top 404s | |
| 256 | + | ||
| 257 | + | ``` | |
| 258 | + | -- Current month: read from analytics_visits (no change) | |
| 259 | + | -- Historical months: read from analytics_monthly_top_urls WHERE is_404 = true | |
| 260 | + | ``` | |
| 261 | + | ||
| 262 | + | #### `visitHost()` — site list | |
| 263 | + | ||
| 264 | + | ``` | |
| 265 | + | -- Replaced entirely by analytics_user_sites lookup: | |
| 266 | + | SELECT host, total_visits as host_count | |
| 267 | + | FROM analytics_user_sites | |
| 268 | + | WHERE user_id = $1 | |
| 269 | + | ORDER BY total_visits DESC | |
| 270 | + | ``` | |
| 271 | + | ||
| 272 | + | No raw table scan. Current month's new hosts are upserted during the monthly aggregation job. | |
| 273 | + | ||
| 274 | + | --- | |
| 275 | + | ||
| 276 | + | ## Implementation Phases | |
| 277 | + | ||
| 278 | + | ### Phase 1: Schema + Backfill (no behavior change) | |
| 279 | + | 1. Create the four new tables (user_sites, monthly_visits, monthly_top_urls, monthly_top_referers) | |
| 280 | + | 2. Write the aggregation script (`cmd/scripts/analytics-aggregate/`) | |
| 281 | + | 3. Run backfill for all historical data | |
| 282 | + | 4. **Do not delete any data yet** — verify summary tables are correct | |
| 283 | + | ||
| 284 | + | ### Phase 2: Query migration | |
| 285 | + | 1. Add new DB interface methods that read from summary tables | |
| 286 | + | 2. Modify existing query methods to union summary + raw data | |
| 287 | + | 3. Verify TUI and CLI output matches before/after | |
| 288 | + | 4. Run `make check` | |
| 289 | + | ||
| 290 | + | ### Phase 3: Enable deletion | |
| 291 | + | 1. Add raw data deletion to the aggregation job | |
| 292 | + | 2. Schedule the job to run monthly (cron, systemd timer, or app-level scheduler) | |
| 293 | + | 3. Monitor `analytics_visits` row count after first deletion | |
| 294 | + | ||
| 295 | + | ### Phase 4: Cleanup | |
| 296 | + | 1. Remove any code paths that no longer need raw historical data | |
| 297 | + | 2. Consider dropping unused indexes on `analytics_visits` since the table is now small | |
| 298 | + | ||
| 299 | + | --- | |
| 300 | + | ||
| 301 | + | ## Trade-offs | |
| 302 | + | ||
| 303 | + | | Aspect | Before | After | | |
| 304 | + | |--------|--------|-------| | |
| 305 | + | | `analytics_visits` size | 18M+ rows (unbounded) | ~1 month of data (~150K-500K) | | |
| 306 | + | | Query latency | Scans full table each time | Reads small table or pre-aggregated rows | | |
| 307 | + | | Historical granularity | Full raw data | Monthly top-10/100 summaries | | |
| 308 | + | | Disk overhead | Single table | 4 additional tables (user_sites, monthly_visits, monthly_top_urls, monthly_top_referers) | | |
| 309 | + | | Mobile/desktop | Not tracked | Tracked via user-agent classification during aggregation | | |
| 310 | + | | Data loss | None | Raw data older than 1 month is gone | | |
| 311 | + | ||
| 312 | + | The key trade-off is that we lose the ability to run *arbitrary* ad-hoc queries on historical data. But we preserve exactly what the user sees today in the TUI and CLI. If we need new historical queries in the future, we'd add them to the aggregation job. | |
| 313 | + | ||
| 314 | + | --- | |
| 315 | + | ||
| 316 | + | ## Open Questions | |
| 317 | + | ||
| 318 | + | 1. **Scheduling**: How do we run the monthly job? Options: | |
| 319 | + | - Cron job on the server | |
| 320 | + | - `systemd` timer | |
| 321 | + | - Built into one of our services as a startup check | |
| 322 | + | - External scheduler (GitHub Actions, etc.) | |
| 323 | + | ||
| 324 | + | 2. **`visitHost()` (site list)**: Solved by `analytics_user_sites` table — simple indexed lookup, no aggregation needed. | |
| 325 | + | ||
| 326 | + | 3. **Retention period**: Is 1 month the right window for raw data? Could adjust based on observed query patterns. | |
| 327 | + | ||
| 328 | + | 4. **Concurrent writes**: During the aggregation window, new visits are still being inserted. The job needs to handle this (e.g., aggregate up to a specific timestamp, then delete up to that same timestamp). | |
| 329 | + | ||
| 330 | + | 5. **Users who disable analytics**: The banner says "when analytics are disabled we do not purge usage statistics." Deletion should respect this — only delete for users with the `analytics` feature flag active. | |
| 331 | + | ||
| 332 | + | 6. **Device detection library**: Which Go user-agent parser to use for mobile/desktop classification? Candidates: | |
| 333 | + | - [`mssola/useragent`](https://github.com/mssola/useragent) — lightweight, device.OS / device.Type | |
| 334 | + | - [`pdericx/ua-parser`](https://github.com/pdericx/ua-parser) — fuller UAParser port | |
| 335 | + | - [`danielgtaylor/mobile`](https://github.com/danielgtaylor/mobile) — simple mobile-only detection | |
| 336 | + | The existing codebase already uses `x-way/crawlerdetect` for bot filtering, so a similar pattern fits. |
+437,
-0
| ... | ... | @@ -0,0 +1,437 @@ | |
| 1 | + | package auth | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "log/slog" | |
| 7 | + | "strings" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/picosh/pico/pkg/db/postgres" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // visitRow represents a single raw visit record for aggregation. | |
| 14 | + | type visitRow struct { | |
| 15 | + | UserID string | |
| 16 | + | Host string | |
| 17 | + | Path string | |
| 18 | + | IPAddress string | |
| 19 | + | Referer string | |
| 20 | + | Status int | |
| 21 | + | UserAgent string | |
| 22 | + | CreatedAt time.Time | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // dayStats accumulates per-day visitor counts for a (user_id, host) pair. | |
| 26 | + | type dayStats struct { | |
| 27 | + | Date time.Time | |
| 28 | + | AllIPs map[string]bool | |
| 29 | + | MobileIPs map[string]bool | |
| 30 | + | DesktopIPs map[string]bool | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // pathStats accumulates per-path visitor counts for a (user_id, host, status_code) triplet. | |
| 34 | + | type pathStats struct { | |
| 35 | + | Path string | |
| 36 | + | Status int | |
| 37 | + | IPs map[string]bool | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // userHostPair represents a unique (user_id, host) combination. | |
| 41 | + | type userHostPair struct { | |
| 42 | + | UserID string | |
| 43 | + | Host string | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // RunAnalyticsAggregation aggregates raw visits for the given month into summary tables. | |
| 47 | + | // Exported for use by the analytics-aggregate CLI script. | |
| 48 | + | func RunAnalyticsAggregation(dbpool *postgres.PsqlDB, logger *slog.Logger, targetMonth time.Time) error { | |
| 49 | + | monthStart := time.Date(targetMonth.Year(), targetMonth.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 50 | + | monthEnd := monthStart.AddDate(0, 1, 0) | |
| 51 | + | ||
| 52 | + | logger.Info("starting analytics aggregation", "month", targetMonth.Format("2006-01"), "from", monthStart, "to", monthEnd) | |
| 53 | + | ||
| 54 | + | pairs, err := fetchUserHostPairs(dbpool, monthStart, monthEnd) | |
| 55 | + | if err != nil { | |
| 56 | + | return fmt.Errorf("fetch user/host pairs: %w", err) | |
| 57 | + | } | |
| 58 | + | if len(pairs) == 0 { | |
| 59 | + | logger.Info("no user/host pairs to aggregate", "month", targetMonth.Format("2006-01")) | |
| 60 | + | return nil | |
| 61 | + | } | |
| 62 | + | logger.Info("found user/host pairs to process", "count", len(pairs), "month", targetMonth.Format("2006-01")) | |
| 63 | + | ||
| 64 | + | for _, pair := range pairs { | |
| 65 | + | visits, err := fetchVisitsForPair(dbpool, pair.UserID, pair.Host, monthStart, monthEnd) | |
| 66 | + | if err != nil { | |
| 67 | + | logger.Error("failed to fetch visits", "err", err, "user_id", pair.UserID, "host", pair.Host) | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Aggregate daily stats | |
| 72 | + | dayMap := aggregateDays(visits) | |
| 73 | + | if err := insertMonthlyVisits(dbpool, pair.UserID, pair.Host, dayMap); err != nil { | |
| 74 | + | logger.Error("failed to insert monthly visits", "err", err) | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Aggregate top URLs grouped by status code | |
| 79 | + | topURLsByStatus := aggregatePaths(visits) | |
| 80 | + | for status, paths := range topURLsByStatus { | |
| 81 | + | if err := insertTopURLs(dbpool, pair.UserID, pair.Host, targetMonth, status, paths); err != nil { | |
| 82 | + | logger.Error("failed to insert top URLs", "err", err, "status", status) | |
| 83 | + | continue | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Aggregate top referers | |
| 88 | + | topReferers := aggregateReferers(visits) | |
| 89 | + | if err := insertTopReferers(dbpool, pair.UserID, pair.Host, targetMonth, topReferers); err != nil { | |
| 90 | + | logger.Error("failed to insert top referers", "err", err) | |
| 91 | + | continue | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Upsert user site | |
| 95 | + | totalUnique := countTotalUnique(dayMap) | |
| 96 | + | if err := upsertUserSite(dbpool, pair.UserID, pair.Host, totalUnique, targetMonth); err != nil { | |
| 97 | + | logger.Error("failed to upsert user site", "err", err) | |
| 98 | + | continue | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Delete raw visit data for users with analytics enabled | |
| 103 | + | if err := deleteAggregatedVisits(dbpool, logger, monthStart, monthEnd); err != nil { | |
| 104 | + | logger.Error("failed to delete aggregated visits", "err", err, "month", targetMonth.Format("2006-01")) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | logger.Info("analytics aggregation complete", "month", targetMonth.Format("2006-01")) | |
| 108 | + | return nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // analyticsAggregationCron runs once per day to check if the previous month needs aggregation. | |
| 112 | + | func analyticsAggregationCron(ctx context.Context, dbpool *postgres.PsqlDB, logger *slog.Logger) { | |
| 113 | + | // Check at midnight UTC every day | |
| 114 | + | ticker := time.NewTicker(24 * time.Hour) | |
| 115 | + | defer ticker.Stop() | |
| 116 | + | ||
| 117 | + | // Run immediately on startup to catch up if the service was down | |
| 118 | + | if err := checkAndRunAggregation(dbpool, logger); err != nil { | |
| 119 | + | logger.Error("startup analytics aggregation check failed", "err", err) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | for { | |
| 123 | + | select { | |
| 124 | + | case <-ctx.Done(): | |
| 125 | + | logger.Info("analytics aggregation cron stopped") | |
| 126 | + | return | |
| 127 | + | case <-ticker.C: | |
| 128 | + | if err := checkAndRunAggregation(dbpool, logger); err != nil { | |
| 129 | + | logger.Error("analytics aggregation check failed", "err", err) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // checkAndRunAggregation checks if the previous month has been aggregated and runs if needed. | |
| 136 | + | func checkAndRunAggregation(dbpool *postgres.PsqlDB, logger *slog.Logger) error { | |
| 137 | + | prevMonth := time.Now().AddDate(0, -1, 0) | |
| 138 | + | monthStart := time.Date(prevMonth.Year(), prevMonth.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 139 | + | monthEnd := monthStart.AddDate(0, 1, 0) | |
| 140 | + | ||
| 141 | + | // Check if any data exists for this month in the summary table | |
| 142 | + | var count int | |
| 143 | + | err := dbpool.Db.QueryRowContext(context.Background(), | |
| 144 | + | `SELECT COUNT(DISTINCT host) FROM analytics_monthly_visits WHERE visit_date >= $1 AND visit_date < $2`, | |
| 145 | + | monthStart, monthEnd, | |
| 146 | + | ).Scan(&count) | |
| 147 | + | if err != nil { | |
| 148 | + | return fmt.Errorf("check existing aggregation: %w", err) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | if count > 0 { | |
| 152 | + | logger.Info("previous month already aggregated, skipping", "month", prevMonth.Format("2006-01"), "hosts", count) | |
| 153 | + | return nil | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | logger.Info("previous month not aggregated, running now", "month", prevMonth.Format("2006-01")) | |
| 157 | + | return RunAnalyticsAggregation(dbpool, logger, prevMonth) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func fetchUserHostPairs(dbpool *postgres.PsqlDB, monthStart, monthEnd time.Time) ([]userHostPair, error) { | |
| 161 | + | rows, err := dbpool.Db.Queryx( | |
| 162 | + | `SELECT DISTINCT user_id, host FROM analytics_visits WHERE created_at >= $1 AND created_at < $2 AND host <> '' ORDER BY user_id, host`, | |
| 163 | + | monthStart, monthEnd, | |
| 164 | + | ) | |
| 165 | + | if err != nil { | |
| 166 | + | return nil, err | |
| 167 | + | } | |
| 168 | + | defer func() { _ = rows.Close() }() | |
| 169 | + | ||
| 170 | + | var pairs []userHostPair | |
| 171 | + | for rows.Next() { | |
| 172 | + | var p userHostPair | |
| 173 | + | if err := rows.Scan(&p.UserID, &p.Host); err != nil { | |
| 174 | + | return nil, err | |
| 175 | + | } | |
| 176 | + | pairs = append(pairs, p) | |
| 177 | + | } | |
| 178 | + | return pairs, rows.Err() | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func fetchVisitsForPair(dbpool *postgres.PsqlDB, userID, host string, monthStart, monthEnd time.Time) ([]visitRow, error) { | |
| 182 | + | rows, err := dbpool.Db.Queryx( | |
| 183 | + | `SELECT user_id, host, path, ip_address, referer, status, user_agent, created_at | |
| 184 | + | FROM analytics_visits | |
| 185 | + | WHERE user_id = $1 AND host = $2 AND created_at >= $3 AND created_at < $4`, | |
| 186 | + | userID, host, monthStart, monthEnd, | |
| 187 | + | ) | |
| 188 | + | if err != nil { | |
| 189 | + | return nil, err | |
| 190 | + | } | |
| 191 | + | defer func() { _ = rows.Close() }() | |
| 192 | + | ||
| 193 | + | var visits []visitRow | |
| 194 | + | for rows.Next() { | |
| 195 | + | var v visitRow | |
| 196 | + | if err := rows.Scan(&v.UserID, &v.Host, &v.Path, &v.IPAddress, &v.Referer, &v.Status, &v.UserAgent, &v.CreatedAt); err != nil { | |
| 197 | + | return nil, err | |
| 198 | + | } | |
| 199 | + | visits = append(visits, v) | |
| 200 | + | } | |
| 201 | + | return visits, rows.Err() | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func aggregateDays(visits []visitRow) map[string]*dayStats { | |
| 205 | + | dayMap := make(map[string]*dayStats) | |
| 206 | + | ||
| 207 | + | for _, v := range visits { | |
| 208 | + | // Exclude 404s from unique visitor counts — bots hitting non-existent | |
| 209 | + | // paths shouldn't count as visitors | |
| 210 | + | if v.Status == 404 { | |
| 211 | + | continue | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | dateKey := v.CreatedAt.UTC().Format("2006-01-02") | |
| 215 | + | ds, ok := dayMap[dateKey] | |
| 216 | + | if !ok { | |
| 217 | + | ds = &dayStats{ | |
| 218 | + | Date: time.Date(v.CreatedAt.Year(), v.CreatedAt.Month(), v.CreatedAt.Day(), 0, 0, 0, 0, time.UTC), | |
| 219 | + | AllIPs: make(map[string]bool), | |
| 220 | + | MobileIPs: make(map[string]bool), | |
| 221 | + | DesktopIPs: make(map[string]bool), | |
| 222 | + | } | |
| 223 | + | dayMap[dateKey] = ds | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | ds.AllIPs[v.IPAddress] = true | |
| 227 | + | if isMobile(v.UserAgent) { | |
| 228 | + | ds.MobileIPs[v.IPAddress] = true | |
| 229 | + | } else { | |
| 230 | + | ds.DesktopIPs[v.IPAddress] = true | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | return dayMap | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func aggregatePaths(visits []visitRow) map[int][]pathStats { | |
| 237 | + | // Maps status_code -> list of pathStats | |
| 238 | + | statusMap := make(map[int]map[string]*pathStats) | |
| 239 | + | ||
| 240 | + | for _, v := range visits { | |
| 241 | + | if v.Path == "" { | |
| 242 | + | continue | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | if _, ok := statusMap[v.Status]; !ok { | |
| 246 | + | statusMap[v.Status] = make(map[string]*pathStats) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | key := fmt.Sprintf("%s/%d", v.Path, v.Status) | |
| 250 | + | ps, ok := statusMap[v.Status][key] | |
| 251 | + | if !ok { | |
| 252 | + | ps = &pathStats{Path: v.Path, Status: v.Status, IPs: make(map[string]bool)} | |
| 253 | + | statusMap[v.Status][key] = ps | |
| 254 | + | } | |
| 255 | + | ps.IPs[v.IPAddress] = true | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | result := make(map[int][]pathStats) | |
| 259 | + | for status, paths := range statusMap { | |
| 260 | + | list := make([]pathStats, 0, len(paths)) | |
| 261 | + | for _, ps := range paths { | |
| 262 | + | list = append(list, *ps) | |
| 263 | + | } | |
| 264 | + | sortByCount(list) | |
| 265 | + | result[status] = list | |
| 266 | + | } | |
| 267 | + | return result | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func aggregateReferers(visits []visitRow) []pathStats { | |
| 271 | + | refMap := make(map[string]*pathStats) | |
| 272 | + | ||
| 273 | + | for _, v := range visits { | |
| 274 | + | if v.Referer == "" || v.Status == 404 { | |
| 275 | + | continue | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | rs, ok := refMap[v.Referer] | |
| 279 | + | if !ok { | |
| 280 | + | rs = &pathStats{Path: v.Referer, IPs: make(map[string]bool)} | |
| 281 | + | refMap[v.Referer] = rs | |
| 282 | + | } | |
| 283 | + | rs.IPs[v.IPAddress] = true | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | result := make([]pathStats, 0, len(refMap)) | |
| 287 | + | for _, rs := range refMap { | |
| 288 | + | result = append(result, *rs) | |
| 289 | + | } | |
| 290 | + | sortByCount(result) | |
| 291 | + | return result | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func sortByCount(paths []pathStats) { | |
| 295 | + | for i := 1; i < len(paths); i++ { | |
| 296 | + | for j := i; j > 0 && len(paths[j].IPs) > len(paths[j-1].IPs); j-- { | |
| 297 | + | paths[j], paths[j-1] = paths[j-1], paths[j] | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func insertMonthlyVisits(dbpool *postgres.PsqlDB, userID, host string, dayMap map[string]*dayStats) error { | |
| 303 | + | for _, ds := range dayMap { | |
| 304 | + | _, err := dbpool.Db.Exec( | |
| 305 | + | `INSERT INTO analytics_monthly_visits (user_id, host, visit_date, unique_visits, mobile_visits, desktop_visits) | |
| 306 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 307 | + | ON CONFLICT (user_id, host, visit_date) DO UPDATE | |
| 308 | + | SET unique_visits = EXCLUDED.unique_visits, | |
| 309 | + | mobile_visits = EXCLUDED.mobile_visits, | |
| 310 | + | desktop_visits = EXCLUDED.desktop_visits`, | |
| 311 | + | userID, host, ds.Date, | |
| 312 | + | len(ds.AllIPs), len(ds.MobileIPs), len(ds.DesktopIPs), | |
| 313 | + | ) | |
| 314 | + | if err != nil { | |
| 315 | + | return fmt.Errorf("insert monthly visits for %s: %w", ds.Date.Format("2006-01-02"), err) | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | return nil | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func insertTopURLs(dbpool *postgres.PsqlDB, userID, host string, month time.Time, statusCode int, paths []pathStats) error { | |
| 322 | + | limit := 10 | |
| 323 | + | if statusCode == 404 { | |
| 324 | + | limit = 100 | |
| 325 | + | } | |
| 326 | + | if len(paths) > limit { | |
| 327 | + | paths = paths[:limit] | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | for rank, ps := range paths { | |
| 331 | + | _, err := dbpool.Db.Exec( | |
| 332 | + | `INSERT INTO analytics_monthly_top_urls (user_id, host, month, path, unique_visits, status_code, rank) | |
| 333 | + | VALUES ($1, $2, $3, $4, $5, $6, $7) | |
| 334 | + | ON CONFLICT (user_id, host, month, path, status_code) DO UPDATE | |
| 335 | + | SET unique_visits = EXCLUDED.unique_visits, | |
| 336 | + | rank = EXCLUDED.rank`, | |
| 337 | + | userID, host, month, ps.Path, len(ps.IPs), statusCode, rank+1, | |
| 338 | + | ) | |
| 339 | + | if err != nil { | |
| 340 | + | return fmt.Errorf("insert top url %s: %w", ps.Path, err) | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | return nil | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func insertTopReferers(dbpool *postgres.PsqlDB, userID, host string, month time.Time, refs []pathStats) error { | |
| 347 | + | limit := 10 | |
| 348 | + | if len(refs) > limit { | |
| 349 | + | refs = refs[:limit] | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | for rank, rs := range refs { | |
| 353 | + | _, err := dbpool.Db.Exec( | |
| 354 | + | `INSERT INTO analytics_monthly_top_referers (user_id, host, month, referer, unique_visits, rank) | |
| 355 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 356 | + | ON CONFLICT (user_id, host, month, referer) DO UPDATE | |
| 357 | + | SET unique_visits = EXCLUDED.unique_visits, | |
| 358 | + | rank = EXCLUDED.rank`, | |
| 359 | + | userID, host, month, rs.Path, len(rs.IPs), rank+1, | |
| 360 | + | ) | |
| 361 | + | if err != nil { | |
| 362 | + | return fmt.Errorf("insert top referer %s: %w", rs.Path, err) | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | return nil | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | func countTotalUnique(dayMap map[string]*dayStats) int { | |
| 369 | + | allIPs := make(map[string]bool) | |
| 370 | + | for _, ds := range dayMap { | |
| 371 | + | for ip := range ds.AllIPs { | |
| 372 | + | allIPs[ip] = true | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | return len(allIPs) | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func upsertUserSite(dbpool *postgres.PsqlDB, userID, host string, monthUnique int, month time.Time) error { | |
| 379 | + | _, err := dbpool.Db.Exec( | |
| 380 | + | `INSERT INTO analytics_user_sites (user_id, host, total_visits, last_seen) | |
| 381 | + | VALUES ($1, $2, $3, $4) | |
| 382 | + | ON CONFLICT (user_id, host) DO UPDATE | |
| 383 | + | SET total_visits = analytics_user_sites.total_visits + EXCLUDED.total_visits, | |
| 384 | + | last_seen = EXCLUDED.last_seen`, | |
| 385 | + | userID, host, monthUnique, month, | |
| 386 | + | ) | |
| 387 | + | return err | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | // isMobile checks common mobile user-agent patterns. | |
| 391 | + | // TODO: replace with a proper UA parser library (e.g., mssola/useragent). | |
| 392 | + | func isMobile(userAgent string) bool { | |
| 393 | + | ua := strings.ToLower(userAgent) | |
| 394 | + | mobileKeywords := []string{ | |
| 395 | + | "mobile", | |
| 396 | + | "android", | |
| 397 | + | "iphone", | |
| 398 | + | "ipad", | |
| 399 | + | "ipod", | |
| 400 | + | "blackberry", | |
| 401 | + | "windows phone", | |
| 402 | + | } | |
| 403 | + | for _, kw := range mobileKeywords { | |
| 404 | + | if strings.Contains(ua, kw) { | |
| 405 | + | return true | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | return false | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // deleteAggregatedVisits deletes raw visit data from analytics_visits for the given month. | |
| 412 | + | // Data is preserved in summary tables, so this is safe regardless of feature flags. | |
| 413 | + | // Raw data for the current and previous months is never deleted since visitUniqueFromRaw | |
| 414 | + | // still reads from analytics_visits for those months. | |
| 415 | + | func deleteAggregatedVisits(dbpool *postgres.PsqlDB, logger *slog.Logger, monthStart, monthEnd time.Time) error { | |
| 416 | + | now := time.Now() | |
| 417 | + | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 418 | + | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 419 | + | ||
| 420 | + | // Never delete raw data for the current or previous month — | |
| 421 | + | // visitUniqueFromRaw still reads from analytics_visits for those months. | |
| 422 | + | if !monthStart.Before(previousMonthStart) { | |
| 423 | + | logger.Info("skipping raw visit deletion for recent month", "month_start", monthStart.Format("2006-01")) | |
| 424 | + | return nil | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | result, err := dbpool.Db.Exec(` | |
| 428 | + | DELETE FROM analytics_visits | |
| 429 | + | WHERE created_at >= $1 AND created_at < $2`, monthStart, monthEnd) | |
| 430 | + | if err != nil { | |
| 431 | + | return fmt.Errorf("delete aggregated visits: %w", err) | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | deleted, _ := result.RowsAffected() | |
| 435 | + | logger.Info("deleted aggregated visits", "month_start", monthStart.Format("2006-01"), "deleted", deleted) | |
| 436 | + | return nil | |
| 437 | + | } |
+2,
-0
| ... | ... | @@ -922,6 +922,8 @@ func StartApiServer() { | |
| 922 | 922 | go metricDrainSub(ctx, db, logger, cfg.Secret) | |
| 923 | 923 | // gather connect/disconnect logs from tuns | |
| 924 | 924 | go tunsEventLogDrainSub(ctx, db, logger, cfg.Secret) | |
| 925 | + | // monthly analytics aggregation (checks daily, runs if previous month is missing) | |
| 926 | + | go analyticsAggregationCron(ctx, db, logger) | |
| 925 | 927 | ||
| 926 | 928 | apiConfig := &router.ApiConfig{ | |
| 927 | 929 | Cfg: cfg, |
+4,
-2
| ... | ... | @@ -159,8 +159,10 @@ type Paginate[T any] struct { | |
| 159 | 159 | } | |
| 160 | 160 | ||
| 161 | 161 | type VisitInterval struct { | |
| 162 | - | Interval *time.Time `json:"interval" db:"interval"` | |
| 163 | - | Visitors int `json:"visitors" db:"visitors"` | |
| 162 | + | Interval *time.Time `json:"interval" db:"interval"` | |
| 163 | + | Visitors int `json:"visitors" db:"visitors"` | |
| 164 | + | MobileVisitors int `json:"mobile_visitors" db:"mobile_visitors"` | |
| 165 | + | DesktopVisitors int `json:"desktop_visitors" db:"desktop_visitors"` | |
| 164 | 166 | } | |
| 165 | 167 | ||
| 166 | 168 | type VisitUrl struct { |
+550,
-109
| ... | ... | @@ -7,6 +7,7 @@ import ( | |
| 7 | 7 | "log/slog" | |
| 8 | 8 | "math" | |
| 9 | 9 | "slices" | |
| 10 | + | "sort" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "time" | |
| 12 | 13 |
| ... | ... | @@ -16,6 +17,9 @@ import ( | |
| 16 | 17 | "github.com/picosh/pico/pkg/shared" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| 20 | + | // mobileUserAgentExpr is a SQL expression to detect mobile user agents. | |
| 21 | + | const mobileUserAgentExpr = `user_agent ILIKE '%mobile%' OR user_agent ILIKE '%android%' OR user_agent ILIKE '%iphone%' OR user_agent ILIKE '%ipad%' OR user_agent ILIKE '%ipod%' OR user_agent ILIKE '%blackberry%' OR user_agent ILIKE '%windows phone%'` | |
| 22 | + | ||
| 19 | 23 | var PAGER_SIZE = 15 | |
| 20 | 24 | ||
| 21 | 25 | var SelectPost = ` |
| ... | ... | @@ -720,109 +724,444 @@ func visitFilterBy(opts *db.SummaryOpts) (string, string) { | |
| 720 | 724 | } | |
| 721 | 725 | ||
| 722 | 726 | func (me *PsqlDB) visitUnique(opts *db.SummaryOpts) ([]*db.VisitInterval, error) { | |
| 723 | - | where, with := visitFilterBy(opts) | |
| 724 | - | uniqueVisitors := fmt.Sprintf(`SELECT | |
| 725 | - | date_trunc('%s', created_at) as interval_start, | |
| 726 | - | count(DISTINCT ip_address) as unique_visitors | |
| 727 | - | FROM analytics_visits | |
| 728 | - | WHERE created_at >= $1 AND %s = $2 AND user_id = $3 AND status <> 404 | |
| 729 | - | GROUP BY interval_start`, opts.Interval, where) | |
| 727 | + | intervals, err := me.visitUniqueFromSummary(opts) | |
| 728 | + | if err != nil { | |
| 729 | + | return nil, fmt.Errorf("query summary visits: %w", err) | |
| 730 | + | } | |
| 731 | + | ||
| 732 | + | currentIntervals, err := me.visitUniqueFromRaw(opts) | |
| 733 | + | if err != nil { | |
| 734 | + | return nil, fmt.Errorf("query raw visits: %w", err) | |
| 735 | + | } | |
| 736 | + | ||
| 737 | + | // Merge: current month data may overlap with summary data, combine counts | |
| 738 | + | return mergeVisitIntervals(intervals, currentIntervals), nil | |
| 739 | + | } | |
| 740 | + | ||
| 741 | + | // visitUniqueFromSummary reads unique visitor counts from analytics_monthly_visits for historical data. | |
| 742 | + | func (me *PsqlDB) visitUniqueFromSummary(opts *db.SummaryOpts) ([]*db.VisitInterval, error) { | |
| 743 | + | now := time.Now() | |
| 744 | + | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 745 | + | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 746 | + | ||
| 747 | + | // Always include at least the previous month in the summary query, since raw data | |
| 748 | + | // for the previous month may have been deleted by aggregation. visitUniqueFromRaw | |
| 749 | + | // covers the previous and current months, but once aggregation runs and deletes raw | |
| 750 | + | // data, the summary table is the only source. Using min(origin, previousMonthStart) | |
| 751 | + | // ensures we don't skip the previous month even when origin is set to the current month. | |
| 752 | + | effectiveOrigin := opts.Origin | |
| 753 | + | if opts.Origin.After(previousMonthStart) { | |
| 754 | + | effectiveOrigin = previousMonthStart | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | // If effective origin is in or after current month, no historical data to fetch | |
| 758 | + | if !effectiveOrigin.Before(currentMonthStart) { | |
| 759 | + | return nil, nil | |
| 760 | + | } | |
| 730 | 761 | ||
| 731 | - | intervals := []*db.VisitInterval{} | |
| 732 | - | rs, err := me.Db.Queryx(uniqueVisitors, opts.Origin, with, opts.UserID) | |
| 762 | + | where := "" | |
| 763 | + | args := []interface{}{opts.UserID, effectiveOrigin, currentMonthStart} | |
| 764 | + | argIdx := 4 | |
| 765 | + | if opts.Host != "" { | |
| 766 | + | where = "AND host = $" + fmt.Sprintf("%d", argIdx) | |
| 767 | + | args = append(args, opts.Host) | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | query := fmt.Sprintf(` | |
| 771 | + | SELECT | |
| 772 | + | date_trunc('%s', visit_date)::timestamptz as interval_start, | |
| 773 | + | sum(unique_visits) as unique_visitors, | |
| 774 | + | sum(mobile_visits) as mobile_visits, | |
| 775 | + | sum(desktop_visits) as desktop_visits | |
| 776 | + | FROM analytics_monthly_visits | |
| 777 | + | WHERE user_id = $1 AND visit_date >= $2 AND visit_date < $3 %s | |
| 778 | + | GROUP BY interval_start | |
| 779 | + | ORDER BY interval_start`, opts.Interval, where) | |
| 780 | + | ||
| 781 | + | rows, err := me.Db.Queryx(query, args...) | |
| 733 | 782 | if err != nil { | |
| 734 | 783 | return nil, err | |
| 735 | 784 | } | |
| 736 | - | defer func() { _ = rs.Close() }() | |
| 785 | + | defer func() { _ = rows.Close() }() | |
| 737 | 786 | ||
| 738 | - | for rs.Next() { | |
| 787 | + | var intervals []*db.VisitInterval | |
| 788 | + | for rows.Next() { | |
| 739 | 789 | interval := &db.VisitInterval{} | |
| 740 | - | err := rs.Scan( | |
| 741 | - | &interval.Interval, | |
| 742 | - | &interval.Visitors, | |
| 743 | - | ) | |
| 744 | - | if err != nil { | |
| 790 | + | if err := rows.Scan(&interval.Interval, &interval.Visitors, &interval.MobileVisitors, &interval.DesktopVisitors); err != nil { | |
| 745 | 791 | return nil, err | |
| 746 | 792 | } | |
| 793 | + | intervals = append(intervals, interval) | |
| 794 | + | } | |
| 795 | + | return intervals, rows.Err() | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | // visitUniqueFromRaw reads unique visitor counts from analytics_visits for the previous and current months. | |
| 799 | + | // This covers the gap between the last aggregated month and the current month. | |
| 800 | + | func (me *PsqlDB) visitUniqueFromRaw(opts *db.SummaryOpts) ([]*db.VisitInterval, error) { | |
| 801 | + | now := time.Now() | |
| 802 | + | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 803 | + | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 747 | 804 | ||
| 805 | + | where, with := visitFilterBy(opts) | |
| 806 | + | ||
| 807 | + | // Determine the effective start: max(origin, previousMonthStart) | |
| 808 | + | effectiveStart := previousMonthStart | |
| 809 | + | if opts.Origin.After(previousMonthStart) { | |
| 810 | + | effectiveStart = opts.Origin | |
| 811 | + | } | |
| 812 | + | ||
| 813 | + | uniqueVisitors := fmt.Sprintf(` | |
| 814 | + | SELECT | |
| 815 | + | date_trunc('%s', created_at)::timestamptz as interval_start, | |
| 816 | + | count(DISTINCT CASE WHEN %s THEN ip_address END) as mobile_visitors, | |
| 817 | + | count(DISTINCT CASE WHEN NOT %s THEN ip_address END) as desktop_visitors, | |
| 818 | + | count(DISTINCT ip_address) as unique_visitors | |
| 819 | + | FROM analytics_visits | |
| 820 | + | WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND status <> 404 | |
| 821 | + | GROUP BY interval_start | |
| 822 | + | ORDER BY interval_start`, | |
| 823 | + | opts.Interval, mobileUserAgentExpr, mobileUserAgentExpr, where) | |
| 824 | + | ||
| 825 | + | rows, err := me.Db.Queryx(uniqueVisitors, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID) | |
| 826 | + | if err != nil { | |
| 827 | + | return nil, err | |
| 828 | + | } | |
| 829 | + | defer func() { _ = rows.Close() }() | |
| 830 | + | ||
| 831 | + | var intervals []*db.VisitInterval | |
| 832 | + | for rows.Next() { | |
| 833 | + | interval := &db.VisitInterval{} | |
| 834 | + | if err := rows.Scan(&interval.Interval, &interval.MobileVisitors, &interval.DesktopVisitors, &interval.Visitors); err != nil { | |
| 835 | + | return nil, err | |
| 836 | + | } | |
| 748 | 837 | intervals = append(intervals, interval) | |
| 749 | 838 | } | |
| 750 | - | if rs.Err() != nil { | |
| 751 | - | return nil, rs.Err() | |
| 839 | + | return intervals, rows.Err() | |
| 840 | + | } | |
| 841 | + | ||
| 842 | + | // mergeVisitIntervals combines historical (summary table) and current (raw) intervals. | |
| 843 | + | // Summary data is preferred when both sources have the same interval to avoid double-counting. | |
| 844 | + | func mergeVisitIntervals(historical, current []*db.VisitInterval) []*db.VisitInterval { | |
| 845 | + | if len(historical) == 0 { | |
| 846 | + | return current | |
| 847 | + | } | |
| 848 | + | if len(current) == 0 { | |
| 849 | + | return historical | |
| 850 | + | } | |
| 851 | + | ||
| 852 | + | // Build a map by interval timestamp for merging. | |
| 853 | + | // Summary data takes precedence over raw data for the same interval | |
| 854 | + | // (e.g. when a month is aggregated but raw data still exists in a local dump). | |
| 855 | + | intervalMap := make(map[int64]*db.VisitInterval) | |
| 856 | + | for _, ci := range current { | |
| 857 | + | ts := ci.Interval.Unix() | |
| 858 | + | intervalMap[ts] = ci | |
| 859 | + | } | |
| 860 | + | for _, hi := range historical { | |
| 861 | + | ts := hi.Interval.Unix() | |
| 862 | + | intervalMap[ts] = hi // summary overwrites raw if both exist | |
| 863 | + | } | |
| 864 | + | ||
| 865 | + | // Sort by interval | |
| 866 | + | result := make([]*db.VisitInterval, 0, len(intervalMap)) | |
| 867 | + | for _, iv := range intervalMap { | |
| 868 | + | result = append(result, iv) | |
| 752 | 869 | } | |
| 753 | - | return intervals, nil | |
| 870 | + | sort.Slice(result, func(i, j int) bool { | |
| 871 | + | return result[i].Interval.Before(*result[j].Interval) | |
| 872 | + | }) | |
| 873 | + | return result | |
| 874 | + | } | |
| 875 | + | ||
| 876 | + | // mergeTopUrls combines historical and current top URLs, summing counts for overlapping URLs, | |
| 877 | + | // then returns the top 10 by total count. | |
| 878 | + | func mergeTopUrls(historical, current []*db.VisitUrl) []*db.VisitUrl { | |
| 879 | + | if len(historical) == 0 { | |
| 880 | + | return current | |
| 881 | + | } | |
| 882 | + | if len(current) == 0 { | |
| 883 | + | return historical | |
| 884 | + | } | |
| 885 | + | ||
| 886 | + | // Build a map by URL for merging | |
| 887 | + | urlMap := make(map[string]*db.VisitUrl) | |
| 888 | + | for _, hu := range historical { | |
| 889 | + | urlMap[hu.Url] = hu | |
| 890 | + | } | |
| 891 | + | ||
| 892 | + | for _, cu := range current { | |
| 893 | + | if existing, ok := urlMap[cu.Url]; ok { | |
| 894 | + | existing.Count += cu.Count | |
| 895 | + | } else { | |
| 896 | + | urlMap[cu.Url] = cu | |
| 897 | + | } | |
| 898 | + | } | |
| 899 | + | ||
| 900 | + | // Sort by count descending and take top 10 | |
| 901 | + | result := make([]*db.VisitUrl, 0, len(urlMap)) | |
| 902 | + | for _, u := range urlMap { | |
| 903 | + | result = append(result, u) | |
| 904 | + | } | |
| 905 | + | sort.Slice(result, func(i, j int) bool { | |
| 906 | + | return result[i].Count > result[j].Count | |
| 907 | + | }) | |
| 908 | + | if len(result) > 10 { | |
| 909 | + | result = result[:10] | |
| 910 | + | } | |
| 911 | + | return result | |
| 912 | + | } | |
| 913 | + | ||
| 914 | + | // mergeTopReferers combines historical and current top referers, summing counts for overlapping referers, | |
| 915 | + | // then returns the top 10 by total count. | |
| 916 | + | func mergeTopReferers(historical, current []*db.VisitUrl) []*db.VisitUrl { | |
| 917 | + | return mergeTopUrls(historical, current) // Same logic as mergeTopUrls | |
| 918 | + | } | |
| 919 | + | ||
| 920 | + | // mergeHosts combines historical and current hosts, summing counts for overlapping hosts, | |
| 921 | + | // then returns sorted by total count descending. | |
| 922 | + | func mergeHosts(historical, current []*db.VisitUrl) []*db.VisitUrl { | |
| 923 | + | if len(historical) == 0 { | |
| 924 | + | return current | |
| 925 | + | } | |
| 926 | + | if len(current) == 0 { | |
| 927 | + | return historical | |
| 928 | + | } | |
| 929 | + | ||
| 930 | + | // Build a map by host for merging | |
| 931 | + | hostMap := make(map[string]*db.VisitUrl) | |
| 932 | + | for _, hu := range historical { | |
| 933 | + | hostMap[hu.Url] = hu | |
| 934 | + | } | |
| 935 | + | ||
| 936 | + | for _, cu := range current { | |
| 937 | + | if existing, ok := hostMap[cu.Url]; ok { | |
| 938 | + | existing.Count += cu.Count | |
| 939 | + | } else { | |
| 940 | + | hostMap[cu.Url] = cu | |
| 941 | + | } | |
| 942 | + | } | |
| 943 | + | ||
| 944 | + | // Sort by count descending | |
| 945 | + | result := make([]*db.VisitUrl, 0, len(hostMap)) | |
| 946 | + | for _, h := range hostMap { | |
| 947 | + | result = append(result, h) | |
| 948 | + | } | |
| 949 | + | sort.Slice(result, func(i, j int) bool { | |
| 950 | + | return result[i].Count > result[j].Count | |
| 951 | + | }) | |
| 952 | + | return result | |
| 754 | 953 | } | |
| 755 | 954 | ||
| 756 | 955 | func (me *PsqlDB) visitReferer(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 757 | - | where, with := visitFilterBy(opts) | |
| 758 | - | topUrls := fmt.Sprintf(`SELECT | |
| 759 | - | referer, | |
| 760 | - | count(DISTINCT ip_address) as referer_count | |
| 761 | - | FROM analytics_visits | |
| 762 | - | WHERE created_at >= $1 AND %s = $2 AND user_id = $3 AND referer <> '' AND status <> 404 | |
| 763 | - | GROUP BY referer | |
| 764 | - | ORDER BY referer_count DESC | |
| 765 | - | LIMIT 10`, where) | |
| 956 | + | historical, err := me.visitRefererFromSummary(opts) | |
| 957 | + | if err != nil { | |
| 958 | + | return nil, fmt.Errorf("query summary referers: %w", err) | |
| 959 | + | } | |
| 766 | 960 | ||
| 767 | - | intervals := []*db.VisitUrl{} | |
| 768 | - | rs, err := me.Db.Queryx(topUrls, opts.Origin, with, opts.UserID) | |
| 961 | + | current, err := me.visitRefererFromRaw(opts) | |
| 962 | + | if err != nil { | |
| 963 | + | return nil, fmt.Errorf("query raw referers: %w", err) | |
| 964 | + | } | |
| 965 | + | ||
| 966 | + | return mergeTopReferers(historical, current), nil | |
| 967 | + | } | |
| 968 | + | ||
| 969 | + | // visitRefererFromSummary reads top referers from analytics_monthly_top_referers for historical data. | |
| 970 | + | func (me *PsqlDB) visitRefererFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 971 | + | now := time.Now() | |
| 972 | + | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 973 | + | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 974 | + | ||
| 975 | + | effectiveOrigin := opts.Origin | |
| 976 | + | if opts.Origin.After(previousMonthStart) { | |
| 977 | + | effectiveOrigin = previousMonthStart | |
| 978 | + | } | |
| 979 | + | ||
| 980 | + | // If effective origin is in or after current month, no historical data to fetch | |
| 981 | + | if !effectiveOrigin.Before(currentMonthStart) { | |
| 982 | + | return nil, nil | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | // Clamp origin to month boundary for summary table lookup | |
| 986 | + | originMonthStart := time.Date(effectiveOrigin.Year(), effectiveOrigin.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 987 | + | ||
| 988 | + | where := "" | |
| 989 | + | args := []interface{}{opts.UserID, originMonthStart, currentMonthStart} | |
| 990 | + | if opts.Host != "" { | |
| 991 | + | where = "AND host = $4" | |
| 992 | + | args = append(args, opts.Host) | |
| 993 | + | } | |
| 994 | + | ||
| 995 | + | query := fmt.Sprintf(` | |
| 996 | + | SELECT referer, sum(unique_visits) as total_visits | |
| 997 | + | FROM analytics_monthly_top_referers | |
| 998 | + | WHERE user_id = $1 AND month >= $2 AND month < $3 %s | |
| 999 | + | GROUP BY referer | |
| 1000 | + | ORDER BY total_visits DESC | |
| 1001 | + | LIMIT 10`, where) | |
| 1002 | + | ||
| 1003 | + | rows, err := me.Db.Queryx(query, args...) | |
| 769 | 1004 | if err != nil { | |
| 770 | 1005 | return nil, err | |
| 771 | 1006 | } | |
| 772 | - | defer func() { _ = rs.Close() }() | |
| 1007 | + | defer func() { _ = rows.Close() }() | |
| 773 | 1008 | ||
| 774 | - | for rs.Next() { | |
| 775 | - | interval := &db.VisitUrl{} | |
| 776 | - | err := rs.Scan( | |
| 777 | - | &interval.Url, | |
| 778 | - | &interval.Count, | |
| 779 | - | ) | |
| 780 | - | if err != nil { | |
| 1009 | + | var results []*db.VisitUrl | |
| 1010 | + | for rows.Next() { | |
| 1011 | + | result := &db.VisitUrl{} | |
| 1012 | + | if err := rows.Scan(&result.Url, &result.Count); err != nil { | |
| 781 | 1013 | return nil, err | |
| 782 | 1014 | } | |
| 1015 | + | results = append(results, result) | |
| 1016 | + | } | |
| 1017 | + | return results, rows.Err() | |
| 1018 | + | } | |
| 783 | 1019 | ||
| 784 | - | intervals = append(intervals, interval) | |
| 1020 | + | // visitRefererFromRaw reads top referers from analytics_visits for the previous and current months. | |
| 1021 | + | func (me *PsqlDB) visitRefererFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 1022 | + | now := time.Now() | |
| 1023 | + | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1024 | + | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 1025 | + | ||
| 1026 | + | where, with := visitFilterBy(opts) | |
| 1027 | + | ||
| 1028 | + | // Determine the effective start: max(origin, previousMonthStart) | |
| 1029 | + | effectiveStart := previousMonthStart | |
| 1030 | + | if opts.Origin.After(previousMonthStart) { | |
| 1031 | + | effectiveStart = opts.Origin | |
| 785 | 1032 | } | |
| 786 | - | if rs.Err() != nil { | |
| 787 | - | return nil, rs.Err() | |
| 1033 | + | ||
| 1034 | + | topUrls := fmt.Sprintf(` | |
| 1035 | + | SELECT | |
| 1036 | + | referer, | |
| 1037 | + | count(DISTINCT ip_address) as referer_count | |
| 1038 | + | FROM analytics_visits | |
| 1039 | + | WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND referer <> '' AND status <> 404 | |
| 1040 | + | GROUP BY referer | |
| 1041 | + | ORDER BY referer_count DESC | |
| 1042 | + | LIMIT 10`, where) | |
| 1043 | + | ||
| 1044 | + | rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID) | |
| 1045 | + | if err != nil { | |
| 1046 | + | return nil, err | |
| 788 | 1047 | } | |
| 789 | - | return intervals, nil | |
| 1048 | + | defer func() { _ = rows.Close() }() | |
| 1049 | + | ||
| 1050 | + | var results []*db.VisitUrl | |
| 1051 | + | for rows.Next() { | |
| 1052 | + | result := &db.VisitUrl{} | |
| 1053 | + | if err := rows.Scan(&result.Url, &result.Count); err != nil { | |
| 1054 | + | return nil, err | |
| 1055 | + | } | |
| 1056 | + | results = append(results, result) | |
| 1057 | + | } | |
| 1058 | + | return results, rows.Err() | |
| 790 | 1059 | } | |
| 791 | 1060 | ||
| 792 | 1061 | func (me *PsqlDB) visitUrl(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 793 | - | where, with := visitFilterBy(opts) | |
| 794 | - | topUrls := fmt.Sprintf(`SELECT | |
| 795 | - | path, | |
| 796 | - | count(DISTINCT ip_address) as path_count | |
| 797 | - | FROM analytics_visits | |
| 798 | - | WHERE created_at >= $1 AND %s = $2 AND user_id = $3 AND path <> '' AND status <> 404 | |
| 799 | - | GROUP BY path | |
| 800 | - | ORDER BY path_count DESC | |
| 801 | - | LIMIT 10`, where) | |
| 1062 | + | historical, err := me.visitUrlFromSummary(opts) | |
| 1063 | + | if err != nil { | |
| 1064 | + | return nil, fmt.Errorf("query summary urls: %w", err) | |
| 1065 | + | } | |
| 1066 | + | ||
| 1067 | + | current, err := me.visitUrlFromRaw(opts) | |
| 1068 | + | if err != nil { | |
| 1069 | + | return nil, fmt.Errorf("query raw urls: %w", err) | |
| 1070 | + | } | |
| 1071 | + | ||
| 1072 | + | return mergeTopUrls(historical, current), nil | |
| 1073 | + | } | |
| 1074 | + | ||
| 1075 | + | // visitUrlFromSummary reads top URLs from analytics_monthly_top_urls for historical data. | |
| 1076 | + | func (me *PsqlDB) visitUrlFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 1077 | + | now := time.Now() | |
| 1078 | + | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1079 | + | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 802 | 1080 | ||
| 803 | - | intervals := []*db.VisitUrl{} | |
| 804 | - | rs, err := me.Db.Queryx(topUrls, opts.Origin, with, opts.UserID) | |
| 1081 | + | effectiveOrigin := opts.Origin | |
| 1082 | + | if opts.Origin.After(previousMonthStart) { | |
| 1083 | + | effectiveOrigin = previousMonthStart | |
| 1084 | + | } | |
| 1085 | + | ||
| 1086 | + | // If effective origin is in or after current month, no historical data to fetch | |
| 1087 | + | if !effectiveOrigin.Before(currentMonthStart) { | |
| 1088 | + | return nil, nil | |
| 1089 | + | } | |
| 1090 | + | ||
| 1091 | + | // Clamp origin to month boundary for summary table lookup | |
| 1092 | + | originMonthStart := time.Date(effectiveOrigin.Year(), effectiveOrigin.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1093 | + | ||
| 1094 | + | where := "" | |
| 1095 | + | args := []interface{}{opts.UserID, originMonthStart, currentMonthStart} | |
| 1096 | + | if opts.Host != "" { | |
| 1097 | + | where = "AND host = $4" | |
| 1098 | + | args = append(args, opts.Host) | |
| 1099 | + | } | |
| 1100 | + | ||
| 1101 | + | query := fmt.Sprintf(` | |
| 1102 | + | SELECT path, sum(unique_visits) as total_visits | |
| 1103 | + | FROM analytics_monthly_top_urls | |
| 1104 | + | WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code <> 404 %s | |
| 1105 | + | GROUP BY path | |
| 1106 | + | ORDER BY total_visits DESC | |
| 1107 | + | LIMIT 10`, where) | |
| 1108 | + | ||
| 1109 | + | rows, err := me.Db.Queryx(query, args...) | |
| 805 | 1110 | if err != nil { | |
| 806 | 1111 | return nil, err | |
| 807 | 1112 | } | |
| 808 | - | defer func() { _ = rs.Close() }() | |
| 1113 | + | defer func() { _ = rows.Close() }() | |
| 809 | 1114 | ||
| 810 | - | for rs.Next() { | |
| 811 | - | interval := &db.VisitUrl{} | |
| 812 | - | err := rs.Scan( | |
| 813 | - | &interval.Url, | |
| 814 | - | &interval.Count, | |
| 815 | - | ) | |
| 816 | - | if err != nil { | |
| 1115 | + | var results []*db.VisitUrl | |
| 1116 | + | for rows.Next() { | |
| 1117 | + | result := &db.VisitUrl{} | |
| 1118 | + | if err := rows.Scan(&result.Url, &result.Count); err != nil { | |
| 817 | 1119 | return nil, err | |
| 818 | 1120 | } | |
| 1121 | + | results = append(results, result) | |
| 1122 | + | } | |
| 1123 | + | return results, rows.Err() | |
| 1124 | + | } | |
| 819 | 1125 | ||
| 820 | - | intervals = append(intervals, interval) | |
| 1126 | + | // visitUrlFromRaw reads top URLs from analytics_visits for the previous and current months. | |
| 1127 | + | func (me *PsqlDB) visitUrlFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 1128 | + | now := time.Now() | |
| 1129 | + | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1130 | + | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 1131 | + | ||
| 1132 | + | where, with := visitFilterBy(opts) | |
| 1133 | + | ||
| 1134 | + | // Determine the effective start: max(origin, previousMonthStart) | |
| 1135 | + | effectiveStart := previousMonthStart | |
| 1136 | + | if opts.Origin.After(previousMonthStart) { | |
| 1137 | + | effectiveStart = opts.Origin | |
| 821 | 1138 | } | |
| 822 | - | if rs.Err() != nil { | |
| 823 | - | return nil, rs.Err() | |
| 1139 | + | ||
| 1140 | + | topUrls := fmt.Sprintf(` | |
| 1141 | + | SELECT | |
| 1142 | + | path, | |
| 1143 | + | count(DISTINCT ip_address) as path_count | |
| 1144 | + | FROM analytics_visits | |
| 1145 | + | WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status <> 404 | |
| 1146 | + | GROUP BY path | |
| 1147 | + | ORDER BY path_count DESC | |
| 1148 | + | LIMIT 10`, where) | |
| 1149 | + | ||
| 1150 | + | rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID) | |
| 1151 | + | if err != nil { | |
| 1152 | + | return nil, err | |
| 824 | 1153 | } | |
| 825 | - | return intervals, nil | |
| 1154 | + | defer func() { _ = rows.Close() }() | |
| 1155 | + | ||
| 1156 | + | var results []*db.VisitUrl | |
| 1157 | + | for rows.Next() { | |
| 1158 | + | result := &db.VisitUrl{} | |
| 1159 | + | if err := rows.Scan(&result.Url, &result.Count); err != nil { | |
| 1160 | + | return nil, err | |
| 1161 | + | } | |
| 1162 | + | results = append(results, result) | |
| 1163 | + | } | |
| 1164 | + | return results, rows.Err() | |
| 826 | 1165 | } | |
| 827 | 1166 | ||
| 828 | 1167 | func (me *PsqlDB) VisitUrlNotFound(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { |
| ... | ... | @@ -830,73 +1169,175 @@ func (me *PsqlDB) VisitUrlNotFound(opts *db.SummaryOpts) ([]*db.VisitUrl, error) | |
| 830 | 1169 | if limit == 0 { | |
| 831 | 1170 | limit = 10 | |
| 832 | 1171 | } | |
| 833 | - | where, with := visitFilterBy(opts) | |
| 834 | - | topUrls := fmt.Sprintf(`SELECT | |
| 835 | - | path, | |
| 836 | - | count(DISTINCT ip_address) as path_count | |
| 837 | - | FROM analytics_visits | |
| 838 | - | WHERE created_at >= $1 AND %s = $2 AND user_id = $3 AND path <> '' AND status = 404 | |
| 839 | - | GROUP BY path | |
| 840 | - | ORDER BY path_count DESC | |
| 841 | - | LIMIT %d`, where, limit) | |
| 842 | 1172 | ||
| 843 | - | intervals := []*db.VisitUrl{} | |
| 844 | - | rs, err := me.Db.Queryx(topUrls, opts.Origin, with, opts.UserID) | |
| 1173 | + | historical, err := me.visitUrlNotFoundFromSummary(opts, limit) | |
| 1174 | + | if err != nil { | |
| 1175 | + | return nil, fmt.Errorf("query summary 404 urls: %w", err) | |
| 1176 | + | } | |
| 1177 | + | ||
| 1178 | + | current, err := me.visitUrlNotFoundFromRaw(opts, limit) | |
| 1179 | + | if err != nil { | |
| 1180 | + | return nil, fmt.Errorf("query raw 404 urls: %w", err) | |
| 1181 | + | } | |
| 1182 | + | ||
| 1183 | + | return mergeTopUrls(historical, current), nil | |
| 1184 | + | } | |
| 1185 | + | ||
| 1186 | + | // visitUrlNotFoundFromSummary reads top 404 URLs from analytics_monthly_top_urls for historical data. | |
| 1187 | + | func (me *PsqlDB) visitUrlNotFoundFromSummary(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) { | |
| 1188 | + | now := time.Now() | |
| 1189 | + | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1190 | + | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 1191 | + | ||
| 1192 | + | effectiveOrigin := opts.Origin | |
| 1193 | + | if opts.Origin.After(previousMonthStart) { | |
| 1194 | + | effectiveOrigin = previousMonthStart | |
| 1195 | + | } | |
| 1196 | + | ||
| 1197 | + | // If effective origin is in or after current month, no historical data to fetch | |
| 1198 | + | if !effectiveOrigin.Before(currentMonthStart) { | |
| 1199 | + | return nil, nil | |
| 1200 | + | } | |
| 1201 | + | ||
| 1202 | + | // Clamp origin to month boundary for summary table lookup | |
| 1203 | + | originMonthStart := time.Date(effectiveOrigin.Year(), effectiveOrigin.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1204 | + | ||
| 1205 | + | where := "" | |
| 1206 | + | args := []interface{}{opts.UserID, originMonthStart, currentMonthStart} | |
| 1207 | + | argIdx := 4 | |
| 1208 | + | if opts.Host != "" { | |
| 1209 | + | where = "AND host = $" + fmt.Sprintf("%d", argIdx) | |
| 1210 | + | args = append(args, opts.Host) | |
| 1211 | + | } | |
| 1212 | + | ||
| 1213 | + | query := fmt.Sprintf(` | |
| 1214 | + | SELECT path, sum(unique_visits) as total_visits | |
| 1215 | + | FROM analytics_monthly_top_urls | |
| 1216 | + | WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code = 404 %s | |
| 1217 | + | GROUP BY path | |
| 1218 | + | ORDER BY total_visits DESC | |
| 1219 | + | LIMIT %d`, where, limit) | |
| 1220 | + | ||
| 1221 | + | rows, err := me.Db.Queryx(query, args...) | |
| 845 | 1222 | if err != nil { | |
| 846 | 1223 | return nil, err | |
| 847 | 1224 | } | |
| 848 | - | defer func() { _ = rs.Close() }() | |
| 1225 | + | defer func() { _ = rows.Close() }() | |
| 849 | 1226 | ||
| 850 | - | for rs.Next() { | |
| 851 | - | interval := &db.VisitUrl{} | |
| 852 | - | err := rs.Scan( | |
| 853 | - | &interval.Url, | |
| 854 | - | &interval.Count, | |
| 855 | - | ) | |
| 856 | - | if err != nil { | |
| 1227 | + | var results []*db.VisitUrl | |
| 1228 | + | for rows.Next() { | |
| 1229 | + | result := &db.VisitUrl{} | |
| 1230 | + | if err := rows.Scan(&result.Url, &result.Count); err != nil { | |
| 857 | 1231 | return nil, err | |
| 858 | 1232 | } | |
| 1233 | + | results = append(results, result) | |
| 1234 | + | } | |
| 1235 | + | return results, rows.Err() | |
| 1236 | + | } | |
| 859 | 1237 | ||
| 860 | - | intervals = append(intervals, interval) | |
| 1238 | + | // visitUrlNotFoundFromRaw reads top 404 URLs from analytics_visits for the previous and current months. | |
| 1239 | + | func (me *PsqlDB) visitUrlNotFoundFromRaw(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) { | |
| 1240 | + | now := time.Now() | |
| 1241 | + | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1242 | + | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 1243 | + | ||
| 1244 | + | where, with := visitFilterBy(opts) | |
| 1245 | + | ||
| 1246 | + | // Determine the effective start: max(origin, previousMonthStart) | |
| 1247 | + | effectiveStart := previousMonthStart | |
| 1248 | + | if opts.Origin.After(previousMonthStart) { | |
| 1249 | + | effectiveStart = opts.Origin | |
| 861 | 1250 | } | |
| 862 | - | if rs.Err() != nil { | |
| 863 | - | return nil, rs.Err() | |
| 1251 | + | ||
| 1252 | + | topUrls := fmt.Sprintf(` | |
| 1253 | + | SELECT | |
| 1254 | + | path, | |
| 1255 | + | count(DISTINCT ip_address) as path_count | |
| 1256 | + | FROM analytics_visits | |
| 1257 | + | WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status = 404 | |
| 1258 | + | GROUP BY path | |
| 1259 | + | ORDER BY path_count DESC | |
| 1260 | + | LIMIT %d`, where, limit) | |
| 1261 | + | ||
| 1262 | + | rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID) | |
| 1263 | + | if err != nil { | |
| 1264 | + | return nil, err | |
| 864 | 1265 | } | |
| 865 | - | return intervals, nil | |
| 1266 | + | defer func() { _ = rows.Close() }() | |
| 1267 | + | ||
| 1268 | + | var results []*db.VisitUrl | |
| 1269 | + | for rows.Next() { | |
| 1270 | + | result := &db.VisitUrl{} | |
| 1271 | + | if err := rows.Scan(&result.Url, &result.Count); err != nil { | |
| 1272 | + | return nil, err | |
| 1273 | + | } | |
| 1274 | + | results = append(results, result) | |
| 1275 | + | } | |
| 1276 | + | return results, rows.Err() | |
| 866 | 1277 | } | |
| 867 | 1278 | ||
| 868 | 1279 | func (me *PsqlDB) visitHost(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 869 | - | topUrls := `SELECT | |
| 870 | - | host, | |
| 871 | - | count(DISTINCT ip_address) as host_count | |
| 872 | - | FROM analytics_visits | |
| 873 | - | WHERE user_id = $1 AND host <> '' | |
| 874 | - | GROUP BY host | |
| 875 | - | ORDER BY host_count DESC` | |
| 1280 | + | historical, err := me.visitHostFromSummary(opts) | |
| 1281 | + | if err != nil { | |
| 1282 | + | return nil, fmt.Errorf("query summary hosts: %w", err) | |
| 1283 | + | } | |
| 1284 | + | ||
| 1285 | + | current, err := me.visitHostFromRaw(opts) | |
| 1286 | + | if err != nil { | |
| 1287 | + | return nil, fmt.Errorf("query raw hosts: %w", err) | |
| 1288 | + | } | |
| 1289 | + | ||
| 1290 | + | return mergeHosts(historical, current), nil | |
| 1291 | + | } | |
| 876 | 1292 | ||
| 877 | - | intervals := []*db.VisitUrl{} | |
| 878 | - | rs, err := me.Db.Queryx(topUrls, opts.UserID) | |
| 1293 | + | // visitHostFromSummary reads host data from analytics_user_sites for historical data. | |
| 1294 | + | func (me *PsqlDB) visitHostFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 1295 | + | rows, err := me.Db.Queryx(` | |
| 1296 | + | SELECT host, total_visits | |
| 1297 | + | FROM analytics_user_sites | |
| 1298 | + | WHERE user_id = $1 AND host <> '' | |
| 1299 | + | ORDER BY total_visits DESC`, opts.UserID) | |
| 879 | 1300 | if err != nil { | |
| 880 | 1301 | return nil, err | |
| 881 | 1302 | } | |
| 882 | - | defer func() { _ = rs.Close() }() | |
| 1303 | + | defer func() { _ = rows.Close() }() | |
| 883 | 1304 | ||
| 884 | - | for rs.Next() { | |
| 885 | - | interval := &db.VisitUrl{} | |
| 886 | - | err := rs.Scan( | |
| 887 | - | &interval.Url, | |
| 888 | - | &interval.Count, | |
| 889 | - | ) | |
| 890 | - | if err != nil { | |
| 1305 | + | var results []*db.VisitUrl | |
| 1306 | + | for rows.Next() { | |
| 1307 | + | result := &db.VisitUrl{} | |
| 1308 | + | if err := rows.Scan(&result.Url, &result.Count); err != nil { | |
| 891 | 1309 | return nil, err | |
| 892 | 1310 | } | |
| 1311 | + | results = append(results, result) | |
| 1312 | + | } | |
| 1313 | + | return results, rows.Err() | |
| 1314 | + | } | |
| 893 | 1315 | ||
| 894 | - | intervals = append(intervals, interval) | |
| 1316 | + | // visitHostFromRaw reads hosts from analytics_visits for the current month that aren't in summary. | |
| 1317 | + | func (me *PsqlDB) visitHostFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 1318 | + | now := time.Now() | |
| 1319 | + | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1320 | + | ||
| 1321 | + | rows, err := me.Db.Queryx(` | |
| 1322 | + | SELECT host, count(DISTINCT ip_address) as host_count | |
| 1323 | + | FROM analytics_visits | |
| 1324 | + | WHERE created_at >= $1 AND user_id = $2 AND host <> '' | |
| 1325 | + | GROUP BY host | |
| 1326 | + | ORDER BY host_count DESC`, currentMonthStart, opts.UserID) | |
| 1327 | + | if err != nil { | |
| 1328 | + | return nil, err | |
| 895 | 1329 | } | |
| 896 | - | if rs.Err() != nil { | |
| 897 | - | return nil, rs.Err() | |
| 1330 | + | defer func() { _ = rows.Close() }() | |
| 1331 | + | ||
| 1332 | + | var results []*db.VisitUrl | |
| 1333 | + | for rows.Next() { | |
| 1334 | + | result := &db.VisitUrl{} | |
| 1335 | + | if err := rows.Scan(&result.Url, &result.Count); err != nil { | |
| 1336 | + | return nil, err | |
| 1337 | + | } | |
| 1338 | + | results = append(results, result) | |
| 898 | 1339 | } | |
| 899 | - | return intervals, nil | |
| 1340 | + | return results, rows.Err() | |
| 900 | 1341 | } | |
| 901 | 1342 | ||
| 902 | 1343 | func (me *PsqlDB) VisitSummary(opts *db.SummaryOpts) (*db.SummaryVisits, error) { |
| ... | ... | @@ -0,0 +1,85 @@ | |
| 1 | + | -- analytics_user_sites: tracks every site a user has had traffic on | |
| 2 | + | CREATE TABLE IF NOT EXISTS analytics_user_sites ( | |
| 3 | + | id serial NOT NULL, | |
| 4 | + | user_id uuid NOT NULL, | |
| 5 | + | host character varying(253) NOT NULL, | |
| 6 | + | total_visits integer NOT NULL DEFAULT 0, | |
| 7 | + | last_seen date NOT NULL, | |
| 8 | + | created_at timestamp without time zone NOT NULL DEFAULT now(), | |
| 9 | + | updated_at timestamp without time zone NOT NULL DEFAULT now(), | |
| 10 | + | CONSTRAINT analytics_user_sites_pkey PRIMARY KEY (id), | |
| 11 | + | CONSTRAINT analytics_user_sites_unique UNIQUE (user_id, host), | |
| 12 | + | CONSTRAINT fk_user_sites_app_users | |
| 13 | + | FOREIGN KEY(user_id) | |
| 14 | + | REFERENCES app_users(id) | |
| 15 | + | ON DELETE CASCADE | |
| 16 | + | ON UPDATE CASCADE | |
| 17 | + | ); | |
| 18 | + | ||
| 19 | + | CREATE INDEX IF NOT EXISTS idx_user_sites_user ON analytics_user_sites (user_id); | |
| 20 | + | ||
| 21 | + | -- analytics_monthly_visits: daily unique visitor counts per host with device breakdown | |
| 22 | + | CREATE TABLE IF NOT EXISTS analytics_monthly_visits ( | |
| 23 | + | id serial NOT NULL, | |
| 24 | + | user_id uuid NOT NULL, | |
| 25 | + | host character varying(253) NOT NULL, | |
| 26 | + | visit_date date NOT NULL, | |
| 27 | + | unique_visits integer NOT NULL DEFAULT 0, | |
| 28 | + | mobile_visits integer NOT NULL DEFAULT 0, | |
| 29 | + | desktop_visits integer NOT NULL DEFAULT 0, | |
| 30 | + | created_at timestamp without time zone NOT NULL DEFAULT now(), | |
| 31 | + | CONSTRAINT analytics_monthly_visits_pkey PRIMARY KEY (id), | |
| 32 | + | CONSTRAINT analytics_monthly_visits_unique UNIQUE (user_id, host, visit_date), | |
| 33 | + | CONSTRAINT fk_monthly_visits_app_users | |
| 34 | + | FOREIGN KEY(user_id) | |
| 35 | + | REFERENCES app_users(id) | |
| 36 | + | ON DELETE CASCADE | |
| 37 | + | ON UPDATE CASCADE | |
| 38 | + | ); | |
| 39 | + | ||
| 40 | + | CREATE INDEX IF NOT EXISTS idx_monthly_visits_user_host ON analytics_monthly_visits (user_id, host); | |
| 41 | + | CREATE INDEX IF NOT EXISTS idx_monthly_visits_user_date ON analytics_monthly_visits (user_id, visit_date DESC); | |
| 42 | + | ||
| 43 | + | -- analytics_monthly_top_urls: top URLs per host per month per status code | |
| 44 | + | CREATE TABLE IF NOT EXISTS analytics_monthly_top_urls ( | |
| 45 | + | id serial NOT NULL, | |
| 46 | + | user_id uuid NOT NULL, | |
| 47 | + | host character varying(253) NOT NULL, | |
| 48 | + | month date NOT NULL, | |
| 49 | + | path character varying(2048) NOT NULL, | |
| 50 | + | unique_visits integer NOT NULL DEFAULT 0, | |
| 51 | + | status_code integer NOT NULL, | |
| 52 | + | rank integer NOT NULL, | |
| 53 | + | created_at timestamp without time zone NOT NULL DEFAULT now(), | |
| 54 | + | CONSTRAINT analytics_monthly_top_urls_pkey PRIMARY KEY (id), | |
| 55 | + | CONSTRAINT analytics_monthly_top_urls_unique UNIQUE (user_id, host, month, path, status_code), | |
| 56 | + | CONSTRAINT fk_monthly_top_urls_app_users | |
| 57 | + | FOREIGN KEY(user_id) | |
| 58 | + | REFERENCES app_users(id) | |
| 59 | + | ON DELETE CASCADE | |
| 60 | + | ON UPDATE CASCADE | |
| 61 | + | ); | |
| 62 | + | ||
| 63 | + | CREATE INDEX IF NOT EXISTS idx_monthly_top_urls_user_host_month ON analytics_monthly_top_urls (user_id, host, month); | |
| 64 | + | CREATE INDEX IF NOT EXISTS idx_monthly_top_urls_status ON analytics_monthly_top_urls (user_id, host, month, status_code, rank); | |
| 65 | + | ||
| 66 | + | -- analytics_monthly_top_referers: top referers per host per month | |
| 67 | + | CREATE TABLE IF NOT EXISTS analytics_monthly_top_referers ( | |
| 68 | + | id serial NOT NULL, | |
| 69 | + | user_id uuid NOT NULL, | |
| 70 | + | host character varying(253) NOT NULL, | |
| 71 | + | month date NOT NULL, | |
| 72 | + | referer character varying(253) NOT NULL, | |
| 73 | + | unique_visits integer NOT NULL DEFAULT 0, | |
| 74 | + | rank integer NOT NULL, | |
| 75 | + | created_at timestamp without time zone NOT NULL DEFAULT now(), | |
| 76 | + | CONSTRAINT analytics_monthly_top_referers_pkey PRIMARY KEY (id), | |
| 77 | + | CONSTRAINT analytics_monthly_top_referers_unique UNIQUE (user_id, host, month, referer), | |
| 78 | + | CONSTRAINT fk_monthly_top_referers_app_users | |
| 79 | + | FOREIGN KEY(user_id) | |
| 80 | + | REFERENCES app_users(id) | |
| 81 | + | ON DELETE CASCADE | |
| 82 | + | ON UPDATE CASCADE | |
| 83 | + | ); | |
| 84 | + | ||
| 85 | + | CREATE INDEX IF NOT EXISTS idx_monthly_referers_user_host_month ON analytics_monthly_top_referers (user_id, host, month); |