0.2.45 - Advanced analytics, logout old versions

This commit is contained in:
ponzischeme89
2026-08-10 20:24:22 +12:00
parent 63f0768507
commit 56c1167382
32 changed files with 1125 additions and 452 deletions
+177 -4
View File
@@ -58,6 +58,45 @@ type RowEvent struct {
DwellMs int
}
// JourneyEvent is one significant step through the app. All descriptive fields are
// controlled vocabulary; ItemID is the only content identity retained.
type JourneyEvent struct {
ID int64 `json:"id"`
OccurredAt time.Time `json:"occurredAt"`
UserID string `json:"userId"`
JourneyID string `json:"journeyId"`
Sequence int `json:"sequence"`
Category string `json:"category"`
Action string `json:"action"`
Screen string `json:"screen"`
Feature string `json:"feature"`
Source string `json:"source"`
Target string `json:"target"`
ItemID string `json:"itemId,omitempty"`
ItemType string `json:"itemType,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
type AnalyticsUser struct {
UserID string `json:"userId"`
Username string `json:"username"`
Events int64 `json:"events"`
Journeys int64 `json:"journeys"`
LastActiveAt time.Time `json:"lastActiveAt"`
}
type FeatureStat struct {
Feature string `json:"feature"`
Uses int64 `json:"uses"`
LastUsedAt time.Time `json:"lastUsedAt"`
}
type PathStat struct {
From string `json:"from"`
To string `json:"to"`
Count int64 `json:"count"`
}
// Event kinds. Impressions say a row was drawn; focus says the remote actually landed
// on it and for how long; select says something was opened from it.
const (
@@ -141,6 +180,137 @@ func (s *Store) InsertRowEvents(ctx context.Context, events []RowEvent) error {
return nil
}
func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent) error {
if len(events) == 0 {
return nil
}
batch := &pgx.Batch{}
for _, event := range events {
batch.Queue(`
INSERT INTO journey_events
(occurred_at, emby_user_id, journey_id, sequence, category, action, screen,
feature, source, target, item_id, item_type, outcome)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`,
event.OccurredAt, event.UserID, event.JourneyID, event.Sequence,
event.Category, event.Action, event.Screen, event.Feature, event.Source,
event.Target, event.ItemID, event.ItemType, event.Outcome)
}
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
for range events {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("store: insert journey events: %w", err)
}
}
return nil
}
func (s *Store) AnalyticsUsers(ctx context.Context, since time.Time) ([]AnalyticsUser, error) {
rows, err := s.pool.Query(ctx, `
SELECT je.emby_user_id,
coalesce((array_agg(s.username ORDER BY s.last_seen_at DESC)
FILTER (WHERE s.username IS NOT NULL))[1], ''),
count(DISTINCT je.id), count(DISTINCT je.journey_id), max(je.occurred_at)
FROM journey_events je
LEFT JOIN sessions s ON s.emby_user_id = je.emby_user_id
WHERE je.occurred_at >= $1
GROUP BY je.emby_user_id ORDER BY max(je.occurred_at) DESC`, since)
if err != nil {
return nil, fmt.Errorf("store: analytics users: %w", err)
}
defer rows.Close()
out := []AnalyticsUser{}
for rows.Next() {
var value AnalyticsUser
if err := rows.Scan(&value.UserID, &value.Username, &value.Events, &value.Journeys, &value.LastActiveAt); err != nil {
return nil, err
}
out = append(out, value)
}
return out, rows.Err()
}
func (s *Store) UserFeatureStats(ctx context.Context, userID string, since time.Time) ([]FeatureStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT feature, count(*), max(occurred_at) FROM journey_events
WHERE emby_user_id=$1 AND occurred_at >= $2 AND feature <> ''
AND action NOT IN ('screen_view', 'journey_start', 'journey_end')
GROUP BY feature ORDER BY count(*) DESC, feature`, userID, since)
if err != nil {
return nil, fmt.Errorf("store: user feature stats: %w", err)
}
defer rows.Close()
out := []FeatureStat{}
for rows.Next() {
var v FeatureStat
if err := rows.Scan(&v.Feature, &v.Uses, &v.LastUsedAt); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) ([]PathStat, error) {
rows, err := s.pool.Query(ctx, `
WITH ordered AS (
SELECT id, journey_id, sequence, action, occurred_at,
coalesce(nullif(target,''), nullif(screen,''), feature) AS node,
lag(coalesce(nullif(target,''), nullif(screen,''), feature)) OVER
(PARTITION BY journey_id ORDER BY sequence, occurred_at, id) AS previous
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
), path_steps AS (
SELECT previous AS from_node, node AS to_node FROM ordered
WHERE previous IS NOT NULL AND node IS NOT NULL AND previous <> node
), last_steps AS (
SELECT DISTINCT ON (journey_id) journey_id, node, action, occurred_at
FROM ordered ORDER BY journey_id, sequence DESC, occurred_at DESC, id DESC
), all_steps AS (
SELECT from_node, to_node FROM path_steps
UNION ALL
SELECT node, 'abandoned' FROM last_steps
WHERE action <> 'journey_end' AND node <> ''
AND occurred_at < now() - interval '30 minutes'
)
SELECT from_node, to_node, count(*) FROM all_steps
GROUP BY from_node, to_node ORDER BY count(*) DESC, from_node, to_node LIMIT 20`, userID, since)
if err != nil {
return nil, fmt.Errorf("store: user paths: %w", err)
}
defer rows.Close()
out := []PathStat{}
for rows.Next() {
var v PathStat
if err := rows.Scan(&v.From, &v.To, &v.Count); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time.Time, limit int) ([]JourneyEvent, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action,
screen, feature, source, target, item_id, item_type, outcome
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit)
if err != nil {
return nil, fmt.Errorf("store: user journey events: %w", err)
}
defer rows.Close()
out := []JourneyEvent{}
for rows.Next() {
var v JourneyEvent
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemID, &v.ItemType, &v.Outcome); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// RowStats aggregates engagement since a point in time, busiest row first.
//
// Dwell is the interesting number: impressions only say a row was on screen, whereas
@@ -182,11 +352,14 @@ func (s *Store) RowStats(ctx context.Context, since time.Time) ([]RowStat, error
// at read time, so nothing is preserved once the events go — which is the point: this is
// engagement telemetry for tuning rows, not a permanent record of what people watched.
func (s *Store) PruneRowEvents(ctx context.Context, olderThan time.Duration) (int64, error) {
tag, err := s.pool.Exec(ctx,
`DELETE FROM row_events WHERE occurred_at < now() - $1::interval`,
fmt.Sprintf("%d seconds", int64(olderThan.Seconds())))
interval := fmt.Sprintf("%d seconds", int64(olderThan.Seconds()))
rows, err := s.pool.Exec(ctx, `DELETE FROM row_events WHERE occurred_at < now() - $1::interval`, interval)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
journeys, err := s.pool.Exec(ctx, `DELETE FROM journey_events WHERE occurred_at < now() - $1::interval`, interval)
if err != nil {
return rows.RowsAffected(), err
}
return rows.RowsAffected() + journeys.RowsAffected(), nil
}
+28
View File
@@ -155,6 +155,34 @@ CREATE TABLE IF NOT EXISTS row_events (
CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC);
CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC);
-- Significant, user-scoped app journeys. Values are deliberately categorical: content
-- names, search terms, setting values and other free text do not belong in this table.
-- journey_id is generated by the client for one foreground visit; emby_user_id is always
-- taken from the authenticated gateway session rather than trusted from the payload.
CREATE TABLE IF NOT EXISTS journey_events (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
emby_user_id TEXT NOT NULL,
journey_id TEXT NOT NULL,
sequence INT NOT NULL DEFAULT 0,
category TEXT NOT NULL,
action TEXT NOT NULL,
screen TEXT NOT NULL DEFAULT '',
feature TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT '',
item_id TEXT NOT NULL DEFAULT '',
item_type TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
ON journey_events (emby_user_id, journey_id, sequence);
CREATE INDEX IF NOT EXISTS journey_events_user_time_idx
ON journey_events (emby_user_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS journey_events_feature_time_idx
ON journey_events (feature, occurred_at DESC);
-- Search terms are retained separately from row engagement so they can inform future
-- ranking/recommendation work without coupling that analysis to rendered rows.
CREATE TABLE IF NOT EXISTS search_history (