PageSourceSearch

https://selebox.com/js/event-log.js?otaStep7=2026-10-06-1&feedVariety=2026-09-30-1

js selebox.com collected 2026-10-07 23:57:59 UTC 8,373 bytes, 170 lines download raw bytes

1// ════════════════════════════════════════════════════════════════════════════
2// Selebox — Web event logger (Phase 3.3 of the moderation system)
3// ════════════════════════════════════════════════════════════════════════════
4// Thin client wrapper around the server-side emit RPCs:
5//   register_session_device  — on every page load
6//   record_read_event        — on chapter open / close
7//   record_view_event        — on video play / threshold-crossed / end
8//
9// Design goals:
10//   • FIRE-AND-FORGET — every call returns a Promise that resolves to
11//     `void`. Errors are swallowed and console.warned in __DEV__. The
12//     caller never has to `await` or handle errors; failed emits are
13//     just lost telemetry, never user-visible.
14//   • LAZY device_id — generated once per browser via crypto.randomUUID
15//     and stored in localStorage. No prompt, no opt-in screen; this is
16//     internal anti-fraud telemetry.
17//   • LIGHTWEIGHT — no batching, no buffer. Each event is one RPC call.
18//     Total volume per session is < 20 events; one RPC each is fine.
19//   • AUTH-AWARE — RPCs require auth.uid(). When the user is signed
20//     out, calls short-circuit before hitting the wire.
21//
22// Callers don't need to know about device_id management — just call
23// `logRead({...})` or `logView({...})` and it threads through.
24// ════════════════════════════════════════════════════════════════════════════
25
26import { supabase } from './supabase.js?otaStep7=2026-10-06-1&v=20261006-step7';
27
28
29// ── device_id ────────────────────────────────────────────────────────────
30// Generated once per browser, stored in localStorage. Survives across
31// sessions; resets when the user clears their browser data (which is
32// the correct privacy boundary — same as "this device wants to be
33// forgotten"). Returns the same UUID on every call.
34
35const DEVICE_ID_KEY = 'selebox_device_id';
36
37export function getDeviceId() {
38  try {
39    let id = localStorage.getItem(DEVICE_ID_KEY);
40    if (id && /^[0-9a-f-]{30,}$/i.test(id)) return id;
41    id = (typeof crypto !== 'undefined' && crypto.randomUUID)
42      ? crypto.randomUUID()
43      // Fallback for older browsers: timestamp + 16 hex chars.
44      : Date.now().toString(16) + '-' +
45        Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
46    localStorage.setItem(DEVICE_ID_KEY, id);
47    return id;
48  } catch {
49    // localStorage blocked (private mode, etc.). Telemetry still works
50    // but device_id is per-page-load — detection just loses one
51    // correlation axis.
52    return 'no-storage';
53  }
54}
55
56
57// ── UUID validation ──────────────────────────────────────────────────────
58// Server-side RPCs declare chapter_id / video_id / book_id as `uuid`.
59// Postgres rejects non-UUID strings with a cast error. Legacy Appwrite
60// content has 16-char hex IDs (not UUIDs); sending those silently fails
61// inside _emit's catch. Filter at the client so legacy content stops
62// triggering pointless RPC calls. Result: legacy content doesn't
63// contribute to fraud-detection signals — same as the existing
64// behavior, just without the noise.
65const _UUID_RE =
66  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
67const _isUuid = (val) => typeof val === 'string' && _UUID_RE.test(val);
68
69
70// ── Internal: fire-and-forget RPC wrapper ────────────────────────────────
71// Returns void. Errors caught + optionally logged.
72
73async function _emit(rpc, args) {
74  try {
75    // Skip when signed out — the RPCs return not_authenticated anyway,
76    // saves a round-trip.
77    const { data: { session } } = await supabase.auth.getSession();
78    if (!session?.user) return;
79    const { error } = await supabase.rpc(rpc, args);
80    if (error && typeof console !== 'undefined') {
81      // Don't spam the console for transient errors; the server's
82      // error code (e.g. invalid_event_kind) is the actionable info.
83      console.debug('[event-log]', rpc, 'error:', error.message);
84    }
85  } catch (err) {
86    // Network blip / fetch throw — swallow.
87    if (typeof console !== 'undefined') {
88      console.debug('[event-log]', rpc, 'threw:', err?.message);
89    }
90  }
91}
92
93
94// ── Public API ───────────────────────────────────────────────────────────
95
96/**
97 * Register the current device with the server. Idempotent — the
98 * server upserts on (user_id, device_id), so calling this every
99 * page load just refreshes last_seen + ip_hash.
100 *
101 * Call this on every page load that finishes auth (i.e. after
102 * supabase.auth.getSession() returns a user).
103 */
104export async function registerSessionDevice() {
105  await _emit('register_session_device', {
106    p_device_id:   getDeviceId(),
107    p_user_agent:  (navigator?.userAgent || '').slice(0, 500),
108    p_platform:    'web',
109    p_app_version: null,
110  });
111}
112
113/**
114 * Log a read event (chapter open, scroll milestone, or close).
115 *
116 * @param {Object} args
117 * @param {string} args.chapterId   UUID of the chapter (required)
118 * @param {string} [args.bookId]    UUID of the parent book (optional, faster filtering)
119 * @param {number} [args.dwellMs]   Milliseconds since chapter open
120 * @param {number} [args.scrollPct] 0-100 furthest scroll reached
121 * @param {boolean} [args.completed] True when chapter completed
122 */
123export async function logRead({ chapterId, bookId, dwellMs, scrollPct, completed } = {}) {
124  // Skip non-UUID IDs — see _isUuid comment above.
125  if (!_isUuid(chapterId)) return;
126  await _emit('record_read_event', {
127    p_chapter_id: chapterId,
128    p_book_id:    _isUuid(bookId) ? bookId : null,
129    p_dwell_ms:   dwellMs ?? null,
130    p_scroll_pct: scrollPct ?? null,
131    p_completed:  completed ?? null,
132    p_device_id:  getDeviceId(),
133  });
134}
135
136/**
137 * Log a video view event (play, pause, seek, threshold, end).
138 *
139 * @param {Object} args
140 * @param {string} args.videoId            UUID of the video (required)
141 * @param {string} args.kind               One of 'play'|'pause'|'seek'|
142 *                                         'threshold_approached'|'threshold_crossed'|'end'
143 * @param {number} [args.watchedSeconds]   Total seconds watched at event time
144 * @param {number} [args.thresholdSeconds] Only set for threshold_* events
145 */
146export async function logView({ videoId, kind, watchedSeconds, thresholdSeconds } = {}) {
147  // Same UUID gate as logRead — server expects uuid for video_id.
148  if (!_isUuid(videoId) || !kind) return;
149  await _emit('record_view_event', {
150    p_video_id:          videoId,
151    p_event_kind:        kind,
152    p_watched_seconds:   watchedSeconds ?? null,
153    p_threshold_seconds: thresholdSeconds ?? null,
154    p_device_id:         getDeviceId(),
155  });
156}
157
158/** Local staging diagnostics. Whitelist numbers; never emit content or identities.
159 * Existing read/view RPCs have fixed event kinds, so these are browser events
160 * for the release harness, not invented server event kinds or impressions.
161 */
162export function logFeedPolicy(metrics={}) {
163  const detail={policyVersion:metrics.rankingVersion===3?'web-ranking-3':'web-variety-1'};
164  for(const key of ['rankingVersion','uniqueCreators','seenCount','catchupCount','discoveryCount','selectedCount','adjacentViolations','windowViolations','deferredCount','sourceBatches','fallback','errors','latencyMs','invalidIdentity']) {
165    const value=Number(metrics[key]||0);
166    detail[key]=Number.isFinite(value)?Math.max(0,value):0;
167  }
168  if(typeof window!=='undefined')window.dispatchEvent(new CustomEvent('selebox:feed-policy',{detail}));
169  return detail;
170}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.