0.2.45 - Advanced analytics, logout old versions
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
@@ -30,6 +31,108 @@ type analyticsRequest struct {
|
||||
Events []rowEventPayload `json:"events"`
|
||||
}
|
||||
|
||||
type journeyEventPayload struct {
|
||||
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"`
|
||||
ItemType string `json:"itemType"`
|
||||
Outcome string `json:"outcome"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
}
|
||||
|
||||
type journeyAnalyticsRequest struct {
|
||||
Events []journeyEventPayload `json:"events"`
|
||||
}
|
||||
|
||||
var journeyCategories = allowedAnalyticsValues("session", "navigation", "content", "search", "playback", "settings", "recommendations", "library", "profile", "notifications")
|
||||
var journeyActions = allowedAnalyticsValues(
|
||||
"journey_start", "journey_end", "screen_view", "open", "close", "select",
|
||||
"submit", "request", "start", "stop", "complete", "abandon", "change",
|
||||
"toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played",
|
||||
"mark_unplayed", "retry", "dismiss", "switch",
|
||||
)
|
||||
var journeyOutcomes = allowedAnalyticsValues("", "success", "failure", "cancelled", "completed", "abandoned")
|
||||
|
||||
func allowedAnalyticsValues(values ...string) map[string]bool {
|
||||
out := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
out[value] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleJourneyAnalytics accepts privacy-bounded journey steps. The payload's user id is
|
||||
// only a profile-switch guard: authority always comes from the bearer session.
|
||||
func (s *Server) handleJourneyAnalytics(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
var req journeyAnalyticsRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
if len(req.Events) > maxAnalyticsBatch {
|
||||
req.Events = req.Events[:maxAnalyticsBatch]
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
events := make([]store.JourneyEvent, 0, len(req.Events))
|
||||
for _, payload := range req.Events {
|
||||
if event, ok := toJourneyEvent(payload, sess.EmbyUserID, now); ok {
|
||||
events = append(events, event)
|
||||
}
|
||||
}
|
||||
if err := s.store.InsertJourneyEvents(r.Context(), events); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("journey analytics write failed", "error", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (store.JourneyEvent, bool) {
|
||||
if payload.UserID != userID || !safeAnalyticsValue(payload.JourneyID, 80) ||
|
||||
payload.Sequence < 0 || !journeyCategories[payload.Category] || !journeyActions[payload.Action] ||
|
||||
!journeyOutcomes[payload.Outcome] {
|
||||
return store.JourneyEvent{}, false
|
||||
}
|
||||
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemID, payload.ItemType}
|
||||
for _, field := range fields {
|
||||
if !safeAnalyticsValue(field, 100) {
|
||||
return store.JourneyEvent{}, false
|
||||
}
|
||||
}
|
||||
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
|
||||
return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID,
|
||||
Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
|
||||
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
|
||||
Target: payload.Target, ItemID: payload.ItemID, ItemType: payload.ItemType,
|
||||
Outcome: payload.Outcome}, true
|
||||
}
|
||||
|
||||
func safeAnalyticsValue(value string, max int) bool {
|
||||
if len(value) > max {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if !(char == '-' || char == '_' || char == '.' || char == ':' ||
|
||||
char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func analyticsOccurredAt(value string, now time.Time) time.Time {
|
||||
if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil &&
|
||||
parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) {
|
||||
return parsed.UTC()
|
||||
}
|
||||
return now
|
||||
}
|
||||
|
||||
// handleRowAnalytics accepts a batch of row engagement events from a TV.
|
||||
//
|
||||
// Fire-and-forget by design: the client does not retry, and a rejected event is never
|
||||
@@ -81,16 +184,7 @@ func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.Ro
|
||||
return store.RowEvent{}, false
|
||||
}
|
||||
|
||||
occurredAt := now
|
||||
if payload.OccurredAt != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, payload.OccurredAt); err == nil {
|
||||
// Trust the device's clock only within a sane window; TVs are notorious for
|
||||
// waking up in 1970.
|
||||
if parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) {
|
||||
occurredAt = parsed.UTC()
|
||||
}
|
||||
}
|
||||
}
|
||||
occurredAt := analyticsOccurredAt(payload.OccurredAt, now)
|
||||
|
||||
dwell := payload.DwellMs
|
||||
if dwell < 0 {
|
||||
|
||||
Reference in New Issue
Block a user