'use strict'; const fs = require('node:fs'); const path = require('node:path'); const BeijingTime = require('../js/beijing-time'); const stages = ['started', 'front', 'right', 'left', 'submitted', 'response', 'report']; const extras = ['failed', 'canceled', 'interrupted']; // Anonymous, client-reported operational telemetry, NOT detection/billing data. // One event per stage per random journey ID; cumulative snapshots tolerate loss, // duplicates and out-of-order delivery. No photos, user IDs or free-form strings. module.exports = function(file) { const journeys = new Map(); function merge(row) { let j = journeys.get(row.id); if (!j) { j = { at: row.at, events: {} }; journeys.set(row.id, j); } j.events[row.stage] = row.ms; } try { for (const line of fs.readFileSync(file, 'utf8').split('\n')) { try { const r = JSON.parse(line); if (/^[a-f0-9]{32}$/.test(r.id) && [...stages, ...extras].includes(r.stage) && Number.isFinite(r.at) && Number.isFinite(r.ms)) merge(r); } catch (_) {} } } catch (e) { if (e.code !== 'ENOENT') throw e; } function ingest(body, now = Date.now()) { if (!body || !/^[a-f0-9]{32}$/.test(body.id) || !Array.isArray(body.events) || body.events.length > 10) return false; const events = {}; for (const e of body.events) { if (!e || ![...stages, ...extras].includes(e.stage) || !Number.isInteger(e.ms) || e.ms < 0 || e.ms > 86400000 || e.stage in events) return false; events[e.stage] = e.ms; } if (events.started !== 0) return false; for (let i = 1; i < stages.length; i++) { if (stages[i] in events && (!(stages[i - 1] in events) || events[stages[i]] < events[stages[i - 1]])) return false; } const existing = journeys.get(body.id); const rows = Object.keys(events).filter(stage => !existing || !(stage in existing.events)) .map(stage => ({ id: body.id, stage, ms: events[stage], at: existing ? existing.at : now })); if (rows.length) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.appendFileSync(file, rows.map(r => JSON.stringify(r)).join('\n') + '\n'); rows.forEach(merge); } return true; } function summarize(rows) { const counts = Object.fromEntries([...stages, ...extras].map(s => [s, rows.filter(j => s in j.events).length])); const successful = rows.filter(j => 'report' in j.events); return { counts, rate: counts.started ? Math.round(counts.report / counts.started * 1000) / 10 : 0, response_without_report: rows.filter(j => 'response' in j.events && !('report' in j.events)).length, unfinished: rows.filter(j => !['report', 'failed', 'canceled'].some(s => s in j.events)).length, avg_ms: successful.length ? Math.round(successful.reduce((n, j) => n + j.events.report, 0) / successful.length) : null }; } return { ingest, stats(now = Date.now()) { const rows = [...journeys.values()]; return { since: rows.length ? rows.reduce((n, j) => Math.min(n, j.at), Infinity) : null, total: summarize(rows), today: summarize(rows.filter(j => BeijingTime.day(j.at) === BeijingTime.day(now))) }; } }; };