feat(admin): track capture-to-report conversion funnel
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
'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))) };
|
||||
} };
|
||||
};
|
||||
Reference in New Issue
Block a user