0.2.45 - Advanced analytics, logout old versions
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user