package repository import ( "context" "encoding/json" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // AnalyticsRepo persists behavior events and sessions. type AnalyticsRepo struct { Pool *pgxpool.Pool } // AnalyticsEventRow is one ingest event after validation. type AnalyticsEventRow struct { SessionID string UserID uuid.UUID Name string PagePath string Props json.RawMessage ClientTS time.Time } // UpsertSession creates or refreshes a session row. func (r *AnalyticsRepo) UpsertSession( ctx context.Context, sessionID, deviceKey string, userID uuid.UUID, startedAt time.Time, ) error { _, err := r.Pool.Exec(ctx, ` INSERT INTO analytics_sessions(session_id, device_key, user_id, started_at) VALUES ($1,$2,$3,$4) ON CONFLICT (session_id) DO UPDATE SET device_key = EXCLUDED.device_key, user_id = COALESCE(EXCLUDED.user_id, analytics_sessions.user_id)`, sessionID, deviceKey, userID, startedAt, ) return err } // EndSession updates session end fields. func (r *AnalyticsRepo) EndSession( ctx context.Context, sessionID string, endedAt time.Time, exitPage string, durationMs int, ) error { _, err := r.Pool.Exec(ctx, ` UPDATE analytics_sessions SET ended_at=$2, exit_page=$3, duration_ms=$4 WHERE session_id=$1`, sessionID, endedAt, emptyToNil(exitPage), durationMs, ) return err } // InsertEvents bulk-inserts event rows. func (r *AnalyticsRepo) InsertEvents(ctx context.Context, rows []AnalyticsEventRow) error { if len(rows) == 0 { return nil } for _, row := range rows { uid := interface{}(nil) if row.UserID != uuid.Nil { uid = row.UserID } page := emptyToNil(row.PagePath) props := row.Props if len(props) == 0 { props = []byte("{}") } if _, err := r.Pool.Exec(ctx, ` INSERT INTO analytics_events(session_id, user_id, name, page_path, props, client_ts) VALUES ($1,$2,$3,$4,$5,$6)`, row.SessionID, uid, row.Name, page, props, row.ClientTS, ); err != nil { return err } } return nil } // OverviewAgg is admin overview metrics. type OverviewAgg struct { DAU int `json:"dau"` NewUsers int `json:"new_users"` Sessions int `json:"sessions"` AvgSessionMs float64 `json:"avg_session_ms"` Series []DayPoint `json:"series"` } // DayPoint is one day in a trend series. type DayPoint struct { Day string `json:"day"` DAU int `json:"dau"` Sessions int `json:"sessions"` } // PageAgg is per-page metrics. type PageAgg struct { PagePath string `json:"page_path"` PV int `json:"pv"` UV int `json:"uv"` AvgDwellMs float64 `json:"avg_dwell_ms"` ExitCount int `json:"exit_count"` } // ExitAgg is exit page ranking. type ExitAgg struct { ExitPage string `json:"exit_page"` Count int `json:"count"` } // ClickAgg is click ranking. type ClickAgg struct { ElementID string `json:"element_id"` Count int `json:"count"` } // FunnelStep is one named funnel count. type FunnelStep struct { Name string `json:"name"` Count int `json:"count"` } func (r *AnalyticsRepo) Overview(ctx context.Context, from, to time.Time) (*OverviewAgg, error) { out := &OverviewAgg{} err := r.Pool.QueryRow(ctx, ` SELECT count(DISTINCT user_id)::int FROM analytics_events WHERE received_at >= $1 AND received_at < $2 AND user_id IS NOT NULL`, from, to, ).Scan(&out.DAU) if err != nil { return nil, err } err = r.Pool.QueryRow(ctx, ` SELECT count(*)::int FROM users WHERE created_at >= $1 AND created_at < $2 AND deleted_at IS NULL`, from, to, ).Scan(&out.NewUsers) if err != nil { return nil, err } err = r.Pool.QueryRow(ctx, ` SELECT count(*)::int, COALESCE(avg(duration_ms) FILTER (WHERE duration_ms IS NOT NULL), 0)::float8 FROM analytics_sessions WHERE started_at >= $1 AND started_at < $2`, from, to, ).Scan(&out.Sessions, &out.AvgSessionMs) if err != nil { return nil, err } rows, err := r.Pool.Query(ctx, ` SELECT to_char(d, 'YYYY-MM-DD') AS day, COALESCE(( SELECT count(DISTINCT e.user_id)::int FROM analytics_events e WHERE e.received_at >= d AND e.received_at < d + interval '1 day' AND e.user_id IS NOT NULL ), 0) AS dau, COALESCE(( SELECT count(*)::int FROM analytics_sessions s WHERE s.started_at >= d AND s.started_at < d + interval '1 day' ), 0) AS sessions FROM generate_series($1::timestamptz, $2::timestamptz - interval '1 day', interval '1 day') AS d ORDER BY d`, from, to, ) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var p DayPoint if err := rows.Scan(&p.Day, &p.DAU, &p.Sessions); err != nil { return nil, err } out.Series = append(out.Series, p) } return out, rows.Err() } func (r *AnalyticsRepo) Pages(ctx context.Context, from, to time.Time) ([]PageAgg, error) { rows, err := r.Pool.Query(ctx, ` WITH views AS ( SELECT coalesce(nullif(page_path,''), props->>'page_path') AS path, count(*)::int AS pv, count(DISTINCT user_id)::int AS uv FROM analytics_events WHERE name='page_view' AND received_at >= $1 AND received_at < $2 GROUP BY 1 ), dwells AS ( SELECT coalesce(nullif(page_path,''), props->>'page_path') AS path, avg(NULLIF((props->>'dwell_ms')::float8, 'NaN'))::float8 AS avg_dwell FROM analytics_events WHERE name='page_leave' AND received_at >= $1 AND received_at < $2 GROUP BY 1 ), exits AS ( SELECT coalesce(nullif(exit_page,''), '') AS path, count(*)::int AS n FROM analytics_sessions WHERE ended_at >= $1 AND ended_at < $2 AND exit_page IS NOT NULL AND exit_page <> '' GROUP BY 1 ) SELECT coalesce(v.path, d.path, e.path) AS page_path, coalesce(v.pv, 0), coalesce(v.uv, 0), coalesce(d.avg_dwell, 0), coalesce(e.n, 0) FROM views v FULL OUTER JOIN dwells d ON v.path = d.path FULL OUTER JOIN exits e ON coalesce(v.path, d.path) = e.path WHERE coalesce(v.path, d.path, e.path) IS NOT NULL AND coalesce(v.path, d.path, e.path) <> '' ORDER BY coalesce(v.pv, 0) DESC LIMIT 50`, from, to, ) if err != nil { return nil, err } defer rows.Close() var out []PageAgg for rows.Next() { var p PageAgg if err := rows.Scan(&p.PagePath, &p.PV, &p.UV, &p.AvgDwellMs, &p.ExitCount); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } func (r *AnalyticsRepo) Exits(ctx context.Context, from, to time.Time) ([]ExitAgg, error) { rows, err := r.Pool.Query(ctx, ` SELECT exit_page, count(*)::int FROM analytics_sessions WHERE ended_at >= $1 AND ended_at < $2 AND exit_page IS NOT NULL AND exit_page <> '' GROUP BY exit_page ORDER BY count(*) DESC LIMIT 30`, from, to, ) if err != nil { return nil, err } defer rows.Close() var out []ExitAgg for rows.Next() { var e ExitAgg if err := rows.Scan(&e.ExitPage, &e.Count); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } func (r *AnalyticsRepo) Clicks(ctx context.Context, from, to time.Time) ([]ClickAgg, error) { rows, err := r.Pool.Query(ctx, ` SELECT coalesce(props->>'element_id', '') AS eid, count(*)::int FROM analytics_events WHERE name='ui_click' AND received_at >= $1 AND received_at < $2 AND coalesce(props->>'element_id','') <> '' GROUP BY 1 ORDER BY count(*) DESC LIMIT 30`, from, to, ) if err != nil { return nil, err } defer rows.Close() var out []ClickAgg for rows.Next() { var c ClickAgg if err := rows.Scan(&c.ElementID, &c.Count); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } func (r *AnalyticsRepo) Funnel(ctx context.Context, from, to time.Time, names []string) ([]FunnelStep, error) { out := make([]FunnelStep, 0, len(names)) for _, name := range names { var n int err := r.Pool.QueryRow(ctx, ` SELECT count(*)::int FROM analytics_events WHERE name=$1 AND received_at >= $2 AND received_at < $3`, name, from, to, ).Scan(&n) if err != nil { return nil, err } out = append(out, FunnelStep{Name: name, Count: n}) } return out, nil } func emptyToNil(s string) interface{} { if s == "" { return nil } return s }