57 lines
3.1 KiB
JavaScript
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))) };
|
|
} };
|
|
};
|