f4b404's picture
Upload folder using huggingface_hub
f55c62a verified
Raw
History Blame Contribute Delete
13.8 kB
import { createHmac } from 'crypto';
import { getDb } from '../db/db.js';
import { sql, join } from '../db/sql.js';
import { config } from '../config/index.js';
import { createLogger } from '../logging/logger.js';
import { TaskManager } from '../tasks/index.js';
const logger = createLogger('analytics');
export type AnalyticsResource =
| 'stream'
| 'catalog'
| 'addon_catalog'
| 'meta'
| 'subtitle'
| 'manifest';
export type AnalyticsStatus = 'ok' | 'error' | 'empty';
export type AnalyticsErrorStage =
| 'manifest'
| 'catalog'
| 'meta'
| 'stream'
| 'subtitle'
| 'addon_catalog';
export type AnalyticsErrorKind =
| 'timeout'
| 'http_4xx'
| 'http_5xx'
| 'network'
| 'parse'
| 'empty';
export type AnalyticsDisposition =
| 'merged'
| 'cut_off'
| 'not_started'
| 'error';
export type AnalyticsServiceBreakdown = Record<
string,
{ ok: number; cached: number; uncached: number }
>;
export interface AnalyticsEvent {
ts: number;
event_type: string;
resource?: AnalyticsResource | null;
/** HMAC(SECRET_KEY, configUuid) — never the raw UUID. */
uuid_hash?: string | null;
addon_id?: string | null;
addon_instance_hash?: string | null;
preset_id?: string | null;
url_overridden?: boolean;
status?: AnalyticsStatus | null;
error_stage?: AnalyticsErrorStage | null;
error_kind?: AnalyticsErrorKind | null;
latency_ms?: number | null;
result_count?: number | null;
final_count?: number | null;
/** Per-user `addon_contribution`: how this addon's request ended (see {@link AnalyticsDisposition}). */
disposition?: AnalyticsDisposition | null;
/** Per-user `addon_contribution`: services attributed to this addon's surviving streams. */
service_breakdown?: AnalyticsServiceBreakdown | null;
/** Per-user `addon_contribution`: the addon's user-set display name at request time. */
addon_name?: string | null;
/** Global `config_feature` events: which dimension is being sampled. */
feature_dim?: 'service' | 'formatter' | 'preset' | null;
/** Global `config_feature` events: dimension key (e.g. `realdebrid`, `custom`, `torrentio`). */
feature_key?: string | null;
/** Anonymised client IP — IPv4 first 3 octets / IPv6 first 3 hextets only. Never a full address. */
ip_prefix?: string | null;
}
/**
* Reduce a client IP to a coarse, non-identifying prefix: the first 3 octets
* for IPv4 (`a.b.c.x`) or the first 3 hextets for IPv6 (`a:b:c::/48`). The host
* portion is dropped so a single address can never be recovered.
*/
export function anonymizeIp(ip: string | undefined | null): string | null {
if (!ip) return null;
const trimmed = ip.replace(/^::ffff:/i, '').trim();
if (trimmed.includes('.')) {
const parts = trimmed.split('.');
if (parts.length !== 4 || parts.some((p) => p === '')) return null;
return `${parts[0]}.${parts[1]}.${parts[2]}.x`;
}
if (trimmed.includes(':')) {
const parts = trimmed.split(':').filter(Boolean);
if (parts.length === 0) return null;
return parts.slice(0, 3).join(':') + '::/48';
}
return null;
}
const MAX_BUFFER = 50_000;
let buffer: AnalyticsEvent[] = [];
let flushTimer: NodeJS.Timeout | null = null;
let rollupTimer: NodeJS.Timeout | null = null;
function disabled(): boolean {
return config.analytics.enabled === false;
}
export function userAnalyticsEnabled(): boolean {
return !disabled() && config.analytics.userAnalyticsEnabled === true;
}
const featureSampledToday = new Set<string>();
let featureSampledDayKey = '';
function dayKeyFromMs(ms: number): string {
return new Date(ms).toISOString().slice(0, 10);
}
export function sampleConfigFeatures(args: {
uuid: string;
serviceIds: string[];
formatterId: string | null;
presetTypes: string[];
}): void {
if (!userAnalyticsEnabled()) return;
const { uuid, serviceIds, formatterId, presetTypes } = args;
if (!uuid) return;
let uuidHash: string;
try {
uuidHash = hmac(uuid);
} catch {
return;
}
const day = dayKeyFromMs(Date.now());
if (day !== featureSampledDayKey) {
featureSampledToday.clear();
featureSampledDayKey = day;
}
const dedupKey = `${uuidHash}:${day}`;
if (featureSampledToday.has(dedupKey)) return;
featureSampledToday.add(dedupKey);
const emitOne = (
feature_dim: 'service' | 'formatter' | 'preset',
feature_key: string
) => {
track({
event_type: 'config_feature',
uuid_hash: uuidHash,
feature_dim,
feature_key,
});
};
for (const id of new Set(serviceIds)) {
if (id) emitOne('service', id);
}
if (formatterId) emitOne('formatter', formatterId);
for (const t of new Set(presetTypes)) {
if (t) emitOne('preset', t);
}
}
/** HMAC a value with the session secret. Used for `uuid_hash`. */
export function hmac(value: string): string {
return createHmac('sha256', config.bootstrap.secretKey)
.update(value)
.digest('hex')
.slice(0, 32);
}
/** Hot path: enqueue an event. Never throws, never awaits. */
export function track(ev: Omit<AnalyticsEvent, 'ts'>): void {
if (disabled()) return;
if (buffer.length >= MAX_BUFFER) return; // shed load rather than grow unbounded
buffer.push({ ...ev, ts: Date.now() });
}
/** Map a caught error at a resource stage to a consistent classification. */
export function classifyAddonError(
stage: AnalyticsErrorStage,
err: unknown
): { error_stage: AnalyticsErrorStage; error_kind: AnalyticsErrorKind } {
let kind: AnalyticsErrorKind = 'network';
const msg = (
err instanceof Error ? err.message : String(err ?? '')
).toLowerCase();
const status =
(err as { status?: number; statusCode?: number })?.status ??
(err as { statusCode?: number })?.statusCode;
if (typeof status === 'number') {
if (status >= 500) kind = 'http_5xx';
else if (status >= 400) kind = 'http_4xx';
} else if (msg.includes('timeout') || msg.includes('etimedout')) {
kind = 'timeout';
} else if (
msg.includes('json') ||
msg.includes('parse') ||
msg.includes('unexpected token')
) {
kind = 'parse';
}
return { error_stage: stage, error_kind: kind };
}
async function flush(): Promise<void> {
if (buffer.length === 0) return;
const batch = buffer;
buffer = [];
try {
const db = getDb();
const rows = batch.map((e) => {
const serviceBreakdown =
e.service_breakdown && Object.keys(e.service_breakdown).length > 0
? JSON.stringify(e.service_breakdown)
: null;
return sql`(${e.ts}, ${e.event_type}, ${e.resource ?? null}, ${e.uuid_hash ?? null}, ${e.addon_id ?? null}, ${e.addon_instance_hash ?? null}, ${e.preset_id ?? null}, ${e.url_overridden ?? false}, ${e.status ?? null}, ${e.error_stage ?? null}, ${e.error_kind ?? null}, ${e.latency_ms ?? null}, ${e.result_count ?? null}, ${e.final_count ?? null}, ${e.disposition ?? null}, ${serviceBreakdown}, ${e.addon_name ?? null}, ${e.feature_dim ?? null}, ${e.feature_key ?? null}, ${e.ip_prefix ?? null})`;
});
// Chunk to keep parameter counts sane.
const CHUNK = 500;
for (let i = 0; i < rows.length; i += CHUNK) {
const slice = rows.slice(i, i + CHUNK);
await db.exec(
sql`INSERT INTO analytics_events
(ts, event_type, resource, uuid_hash, addon_id, addon_instance_hash, preset_id, url_overridden, status, error_stage, error_kind, latency_ms, result_count, final_count, disposition, service_breakdown, addon_name, feature_dim, feature_key, ip_prefix)
VALUES ${join(slice)}`
);
}
} catch (err) {
logger.warn({ err, dropped: batch.length }, 'analytics flush failed');
}
}
function dayKey(ms: number): string {
return new Date(ms).toISOString().slice(0, 10);
}
/**
* Nightly rollup: (re)compute daily aggregates for the last 2 days, then prune
* raw events / daily rows past their retention settings.
*/
export async function runRollup(): Promise<void> {
const db = getDb();
const now = Date.now();
const dayMs = 86_400_000;
const days = [dayKey(now), dayKey(now - dayMs)];
for (const day of days) {
const start = new Date(day + 'T00:00:00.000Z').getTime();
const end = start + dayMs;
// dimension=resource
const byRes = await db.query<{
key: string;
c: number | string;
ls: number | string;
lc: number | string;
}>(
sql`SELECT resource AS key, COUNT(*) AS c,
COALESCE(SUM(latency_ms),0) AS ls,
COUNT(latency_ms) AS lc
FROM analytics_events
WHERE ts >= ${start} AND ts < ${end} AND resource IS NOT NULL
GROUP BY resource`
);
// dimension=preset (marketplace defaults only)
const byPreset = await db.query<{
key: string;
c: number | string;
ls: number | string;
lc: number | string;
}>(
sql`SELECT preset_id AS key, COUNT(*) AS c,
COALESCE(SUM(latency_ms),0) AS ls,
COUNT(latency_ms) AS lc
FROM analytics_events
WHERE ts >= ${start} AND ts < ${end}
AND preset_id IS NOT NULL AND url_overridden = false
GROUP BY preset_id`
);
// dimension=preset_error (kind)
const byErr = await db.query<{ key: string; c: number | string }>(
sql`SELECT (preset_id || '|' || error_kind) AS key, COUNT(*) AS c
FROM analytics_events
WHERE ts >= ${start} AND ts < ${end}
AND preset_id IS NOT NULL AND url_overridden = false
AND status = 'error' AND error_kind IS NOT NULL
GROUP BY preset_id, error_kind`
);
// dimension=custom (overridden bucket)
const custom = await db.query<{ c: number | string }>(
sql`SELECT COUNT(*) AS c FROM analytics_events
WHERE ts >= ${start} AND ts < ${end} AND url_overridden = true`
);
// dimension=feature:{service|formatter|preset} - counts unique users per
// (day, feature_key).
const byFeature = await db.query<{
dim: string;
key: string;
c: number | string;
}>(
sql`SELECT ('feature:' || feature_dim) AS dim, feature_key AS key,
COUNT(DISTINCT uuid_hash) AS c
FROM analytics_events
WHERE ts >= ${start} AND ts < ${end}
AND event_type = 'config_feature'
AND feature_dim IS NOT NULL AND feature_key IS NOT NULL
GROUP BY feature_dim, feature_key`
);
const upserts: Array<{
dim: string;
key: string;
c: number;
ls: number;
lc: number;
}> = [];
for (const r of byRes)
upserts.push({
dim: 'resource',
key: r.key,
c: Number(r.c),
ls: Number(r.ls),
lc: Number(r.lc),
});
for (const r of byPreset)
upserts.push({
dim: 'preset',
key: r.key,
c: Number(r.c),
ls: Number(r.ls),
lc: Number(r.lc),
});
for (const r of byErr)
upserts.push({
dim: 'preset_error',
key: r.key,
c: Number(r.c),
ls: 0,
lc: 0,
});
if (Number(custom[0]?.c ?? 0) > 0)
upserts.push({
dim: 'custom',
key: 'custom',
c: Number(custom[0].c),
ls: 0,
lc: 0,
});
for (const r of byFeature)
upserts.push({
dim: r.dim,
key: r.key,
c: Number(r.c),
ls: 0,
lc: 0,
});
await db.tx(async (tx) => {
await tx.exec(sql`DELETE FROM analytics_daily WHERE day = ${day}`);
for (const u of upserts) {
await tx.exec(
sql`INSERT INTO analytics_daily (day, dimension, key, count, latency_sum, latency_count)
VALUES (${day}, ${u.dim}, ${u.key}, ${u.c}, ${u.ls}, ${u.lc})
ON CONFLICT (day, dimension, key) DO UPDATE SET
count = excluded.count,
latency_sum = excluded.latency_sum,
latency_count = excluded.latency_count`
);
}
});
}
// Prune by retention.
const eventDays = Number(config.analytics.eventRetentionDays) || 7;
const dailyDays = Number(config.analytics.dailyRetentionDays) || 90;
const eventCutoff = now - eventDays * dayMs;
const dailyCutoff = dayKey(now - dailyDays * dayMs);
await db.exec(sql`DELETE FROM analytics_events WHERE ts < ${eventCutoff}`);
await db.exec(sql`DELETE FROM analytics_daily WHERE day < ${dailyCutoff}`);
logger.debug('analytics rollup complete');
}
/**
* Register the flusher + nightly rollup with the central TaskManager. No
* standalone setInterval is kept — the registry owns the schedule.
*/
export function startAnalytics(): void {
TaskManager.register({
id: 'analytics-flush',
label: 'Analytics flush',
description: 'Batch-insert buffered request analytics into the database.',
category: 'analytics',
kind: 'scheduled',
intervalMs: 5000,
enabled: true,
destructive: false,
multiReplica: 'all',
run: async () => {
await flush();
},
});
TaskManager.register({
id: 'analytics-rollup',
label: 'Analytics rollup',
description:
'Aggregate raw events into daily rollups and prune by retention.',
category: 'analytics',
kind: 'scheduled',
intervalMs: 6 * 60 * 60 * 1000,
enabled: true,
destructive: false,
multiReplica: 'single',
run: async () => {
await runRollup();
},
});
// First rollup shortly after boot so the dashboard isn't empty.
setTimeout(
() => void TaskManager.runNow('analytics-rollup').catch(() => undefined),
30_000
).unref?.();
}
/** Flush remaining events (called on graceful shutdown). */
export async function stopAnalytics(): Promise<void> {
flushTimer = rollupTimer = null;
await flush();
}
export { flush as flushAnalyticsNow };