Files
juzi/h5/lib/capture-funnel.js
T

57 lines
3.1 KiB
JavaScript

'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))) };
} };
};