feat(admin): track capture-to-report conversion funnel

This commit is contained in:
xsl
2026-10-16 15:45:13 +08:00
parent 03a8907b4e
commit 9ff7a582af
8 changed files with 222 additions and 0 deletions
@@ -0,0 +1,9 @@
# 拍照至报告页统计
后台「统计概览」新增累计及今日拍摄流程漏斗:开始拍照 → 正面拍完 → 右脸拍完 → 三张拍完 → 提交检测 → 收到成功结果 → 实际进入报告页。
每次点击「开始拍照」生成随机流程 ID,按流程及阶段去重;不是人数。只有报告渲染、切屏且报告页处于 active 状态后才计入成功进入,浏览历史报告与 mock 演示不计入。今日按流程开始时间的北京时间归属。显示全程转化率、成功进入平均耗时、收到成功结果但尚未进入、明确失败、主动取消、后台中断、尚未完成。中断可与其他状态重叠,未完成包括仍在进行中的流程,不能直接视为失败。
数据从上线开始积累,不回填历史。匿名埋点存于受静态访问保护的 `h5/data/capture-funnel.jsonl`,仅包含随机流程 ID、固定阶段、客户端耗时、服务端接收时间。不保存账号、照片、档案或任意错误文本。已登录游客也可统计,不保存游客身份。接口校验同源及会话;客户端上报仅作为运营观测,不能作为计费或检测成功的权威依据。
每次发送累计阶段快照,支持重复及乱序到达;使用 keepalive,失败不阻断拍摄。断网、关闭页面仍可能漏报,尚未提供离线重传;成功率为已收到的观测值。没有增加检测调用,没有改变报告结论和多脸规则。
+5
View File
@@ -103,6 +103,11 @@
<!-- ─── 统计概览(文档 4.5) ─── --> <!-- ─── 统计概览(文档 4.5) ─── -->
<section id="tab-stats" class="panel"> <section id="tab-stats" class="panel">
<div class="cards" id="stat-cards"></div> <div class="cards" id="stat-cards"></div>
<div class="block">
<h3>拍照 → 报告页转化(上线后新流程)</h3>
<div id="capture-funnel"></div>
<p class="hint">按每次点击开始拍照的流程去重,不是人数;进入成功仅在本次报告渲染并切屏后上报,历史报告浏览与演示不计入。今日按开始拍照的北京时间归属。客户端埋点可能受断网、关闭页面影响,未完成不等于检测失败。</p>
</div>
<div class="grid2"> <div class="grid2">
<div class="block"> <div class="block">
<h3>三维度档位分布</h3> <h3>三维度档位分布</h3>
+18
View File
@@ -169,6 +169,23 @@ function loadRecords() {
} }
/* ═══════════ 失败与异常 ═══════════ */ /* ═══════════ 失败与异常 ═══════════ */
function renderCaptureFunnel(st) {
var funnel = st.capture_funnel;
var panel = $('#capture-funnel');
if (funnel && panel) {
var labels = [['started','开始拍照'],['front','正面拍完'],['right','右脸拍完'],['left','三张拍完'],['submitted','提交检测'],['response','收到成功结果'],['report','进入报告页']];
var html = '<table><thead><tr><th>阶段</th><th>累计流程</th><th>今日流程</th></tr></thead><tbody>';
labels.forEach(function (row) { html += '<tr><td>' + row[1] + '</td><td>' + funnel.total.counts[row[0]] + '</td><td>' + funnel.today.counts[row[0]] + '</td></tr>'; });
html += '</tbody></table>';
html += '<p class="hint">首次采集:' + (funnel.since ? fmtTime(new Date(funnel.since).toISOString()) : '暂无数据') + '</p>';
[['total','累计'],['today','今日']].forEach(function (row) {
var f = funnel[row[0]];
html += '<p>' + row[1] + ':进入报告转化率 ' + f.rate + '% · 成功进入平均耗时 ' + (f.avg_ms == null ? '—' : (f.avg_ms / 1000).toFixed(1) + ' 秒') +
' · 成功结果但未进入 ' + f.response_without_report + ' · 明确失败 ' + f.counts.failed + ' · 主动取消 ' + f.counts.canceled + ' · 发生后台中断 ' + f.counts.interrupted + ' · 尚未完成 ' + f.unfinished + '</p>';
});
panel.innerHTML = html;
}
}
function loadFails() { function loadFails() {
api('/api/admin/stats').then(function (st) { api('/api/admin/stats').then(function (st) {
var box = $('#fail-dist'); var box = $('#fail-dist');
@@ -228,6 +245,7 @@ function doRetry(id) {
var PIE_COLORS = ['#2563eb', '#2fa84f', '#f0a020', '#8a5cf6', '#d93026']; var PIE_COLORS = ['#2563eb', '#2fa84f', '#f0a020', '#8a5cf6', '#d93026'];
function loadStats() { function loadStats() {
api('/api/admin/stats').then(function (st) { api('/api/admin/stats').then(function (st) {
renderCaptureFunnel(st);
var cards = [ var cards = [
['今日检测', st.today_total], ['今日检测', st.today_total],
['累计检测', st.total], ['累计检测', st.total],
+31
View File
@@ -4,6 +4,26 @@
var $ = function (sel, root) { return (root || document).querySelector(sel); }; var $ = function (sel, root) { return (root || document).querySelector(sel); };
var captureJourney = null;
function beginCaptureJourney() {
captureJourney = null;
if (IS_MOCK) return;
try {
var bytes = new Uint8Array(16); window.crypto.getRandomValues(bytes);
captureJourney = { id: Array.from(bytes).map(function (b) { return ('0' + b.toString(16)).slice(-2); }).join(''), started: Date.now(), events: [] };
trackCapture('started');
} catch (_) {} // Telemetry must never block capture on old kernels.
}
function trackCapture(stage) {
try {
var j = captureJourney;
if (!j || IS_MOCK || j.events.some(function (e) { return e.stage === stage || e.stage === 'report' || e.stage === 'failed' || e.stage === 'canceled'; })) return;
j.events.push({ stage: stage, ms: stage === 'started' ? 0 : Math.min(86400000, Math.max(j.events.length ? j.events[j.events.length - 1].ms : 0, Date.now() - j.started)) });
fetch('api/capture-funnel', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: j.id, events: j.events }), keepalive: true }).catch(function () {});
} catch (_) {}
}
/* camera-outline-pure-begin */ /* camera-outline-pure-begin */
// Round the exported polygon's corners without changing its position/size. // Round the exported polygon's corners without changing its position/size.
// Quadratic control points stay within the original corner's convex hull. // Quadratic control points stay within the original corner's convex hull.
@@ -250,6 +270,7 @@
// 退出拍照流程:作废未决快门/分析回调、掐断播报、释放摄像头 // 退出拍照流程:作废未决快门/分析回调、掐断播报、释放摄像头
function leaveCamera() { function leaveCamera() {
trackCapture('canceled');
camSeq++; camSeq++;
voice.stop(true); voice.stop(true);
closeOverlay('p-analyzing'); closeOverlay('p-analyzing');
@@ -1063,6 +1084,7 @@
// 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步 // 照片就绪(已通过校验)后推进流程:存图→「拍摄完成」→300ms 过渡→下一步;三步严格按顺序,本步合格才进下一步
function onPhotoCaptured(dataUrl) { function onPhotoCaptured(dataUrl) {
trackCapture(['front', 'right', 'left'][camStep]);
shutterLock = true; // 推进窗口上锁(停顿期间不检测、不接受快门),直到下一步/送检 shutterLock = true; // 推进窗口上锁(停顿期间不检测、不接受快门),直到下一步/送检
photos[camStep] = dataUrl; photos[camStep] = dataUrl;
voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废 voice.play(VOICE.shot, { cut: true }); // 「拍摄完成」:已拍下,本步未播完的引导作废
@@ -1162,6 +1184,7 @@
// 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2]) // 三张拍完:按视角重排后送检(正脸=photos[0],左脸=photos[1],右脸=photos[2])
function submitPhotos(seq) { function submitPhotos(seq) {
trackCapture('submitted');
camStep = 0; camStep = 0;
photos = photosByView(photos); photos = photosByView(photos);
openOverlay('p-analyzing'); openOverlay('p-analyzing');
@@ -1176,6 +1199,7 @@
var animationComplete = scanCycle.promise; var animationComplete = scanCycle.promise;
requestAnalyze(photos, seq, profile, function (errMsg, payload) { requestAnalyze(photos, seq, profile, function (errMsg, payload) {
if (seq !== camSeq) return; // 流程已被重置:丢弃 if (seq !== camSeq) return; // 流程已被重置:丢弃
trackCapture(errMsg ? 'failed' : 'response');
// A fast successful response waits for one actual visible animation cycle; // A fast successful response waits for one actual visible animation cycle;
// cancellation/errors never get held hostage by the presentation animation. // cancellation/errors never get held hostage by the presentation animation.
(errMsg ? Promise.resolve() : animationComplete).then(function () { (errMsg ? Promise.resolve() : animationComplete).then(function () {
@@ -1199,6 +1223,7 @@
}); });
} catch (e) { vm = null; } } catch (e) { vm = null; }
if (!vm) { if (!vm) {
trackCapture('failed');
toast('报告生成失败,请重新拍摄'); toast('报告生成失败,请重新拍摄');
showScreen('s-profile'); showScreen('s-profile');
popHistorySilently(); popHistorySilently();
@@ -1210,6 +1235,10 @@
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员'); else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存 photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机 showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
if (currentScreen === 's-report' && $('#s-report').classList.contains('active')) trackCapture('report');
}).catch(function (error) {
trackCapture('failed');
console.error('Report presentation failed', error);
}); });
}); });
} }
@@ -1863,6 +1892,7 @@
}, },
'close-photoguide': function () { closeOverlay('p-photoguide'); }, 'close-photoguide': function () { closeOverlay('p-photoguide'); },
'start-camera': function () { 'start-camera': function () {
beginCaptureJourney();
closeOverlay('p-photoguide'); closeOverlay('p-photoguide');
camSeq++; // 使旧的快门/分析回调失效 camSeq++; // 使旧的快门/分析回调失效
camStep = 0; camStep = 0;
@@ -1992,6 +2022,7 @@
/* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */ /* ─── 页面隐藏/来电生命周期:隐藏即停相机与全部定时器,回前台从当前步恢复(保留 photos[] 与 camStep) ─── */
function handleCamHidden() { function handleCamHidden() {
if (currentScreen !== 's-cam') return; if (currentScreen !== 's-cam') return;
trackCapture('interrupted');
camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出) camSeq++; // 使未决的快门/推进/分析回调失效(白屏定时器醒来即自洁退出)
voice.stop(true); voice.stop(true);
shutterLock = false; shutterLock = false;
+56
View File
@@ -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))) };
} };
};
+9
View File
@@ -29,6 +29,7 @@ const ReportUI = require('./js/report-ui.js');
const BeijingTime = require('./js/beijing-time.js'); const BeijingTime = require('./js/beijing-time.js');
const CaptureDiagnostics = require('./lib/capture-diagnostics.js'); const CaptureDiagnostics = require('./lib/capture-diagnostics.js');
const WrinkleViews = require('./lib/wrinkle-views.js'); const WrinkleViews = require('./lib/wrinkle-views.js');
const captureFunnel = require('./lib/capture-funnel.js')(path.join(DATA_ROOT, 'capture-funnel.jsonl'));
const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts')); const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts'));
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */ /* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
@@ -751,6 +752,7 @@ function handleStats(req, res) {
fail_total: failed.length, fail_total: failed.length,
success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0, success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0,
avg_cost_ms: avgCost, avg_cost_ms: avgCost,
capture_funnel: captureFunnel.stats(now),
fail_dist, fail_dist,
dim_dist, dim_dist,
group_dist, group_dist,
@@ -874,6 +876,13 @@ async function handleAdmin(req, res, pathname, url) {
http.createServer(async (req, res) => { http.createServer(async (req, res) => {
const pathname = req.url.split('?')[0]; const pathname = req.url.split('?')[0];
if (await accounts.route(req, res, pathname)) return; if (await accounts.route(req, res, pathname)) return;
if (req.method === 'POST' && pathname === '/api/capture-funnel') {
if (!accounts.sameOrigin(req) || !accounts.session(req)) return sendJson(res, 403, { ok: false });
try {
const accepted = captureFunnel.ingest(JSON.parse((await readBody(req, 4096)).toString('utf8')));
return sendJson(res, accepted ? 200 : 400, { ok: accepted });
} catch (e) { return sendJson(res, e.message === 'TOO_LARGE' ? 413 : e instanceof SyntaxError ? 400 : 500, { ok: false }); }
}
if (req.method === 'POST' && pathname === '/api/skin-analyze') return handleAnalyze(req, res); if (req.method === 'POST' && pathname === '/api/skin-analyze') return handleAnalyze(req, res);
if (req.method === 'GET' && pathname === '/api/health') { if (req.method === 'GET' && pathname === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
+48
View File
@@ -0,0 +1,48 @@
// Isolated server/browser; detection is an in-memory fixture, never a paid call.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {spawn,execSync} from 'node:child_process';
import {fileURLToPath,pathToFileURL} from 'node:url';
const {chromium}=await import(pathToFileURL(execSync('npm root -g',{encoding:'utf8'}).trim()+'/playwright/index.mjs').href);
const h5=fileURLToPath(new URL('../',import.meta.url));
const root=fs.mkdtempSync(path.join(os.tmpdir(),'juzi-funnel-http-'));
const child=spawn(process.execPath,['server.js'],{cwd:h5,env:{...process.env,PORT:'18138',HOST:'127.0.0.1',JUZI_DATA_ROOT:root,
SKIN_API_KEY:'fixture',SKIN_API_SECRET:'fixture',PUBLIC_BASE_URL:'',WECHAT_APP_ID:'',WECHAT_APP_SECRET:''},stdio:['ignore','pipe','pipe']});
let browser;
try {
await new Promise((resolve,reject)=>{const t=setTimeout(()=>reject(Error('startup timeout')),10000);child.stdout.on('data',b=>{if(b.toString().includes('已启动')){clearTimeout(t);resolve();}});child.once('exit',()=>{clearTimeout(t);reject(Error('server exit'));});});
const base='http://127.0.0.1:18138/';
assert.equal((await fetch(base+'api/capture-funnel',{method:'POST',body:'{}'})).status,403);
browser=await chromium.launch({headless:true});const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
const source=fs.readFileSync(path.join(h5,'js/app.js'),'utf8');
await page.route('**/js/app.js*',r=>r.fulfill({contentType:'text/javascript',body:source.replace(/\}\)\(\);\s*$/,`
window.__journeyTest = { run: function(fail) {
beginCaptureJourney(); ['front','right','left'].forEach(trackCapture);
photos=window.MOCK_FIXTURE.photos.slice();showScreen('s-cam');
requestAnalyze=function(ph,seq,p,done){done(null,window.MOCK_FIXTURE);};
if(fail)renderReport=function(){throw Error('fixture render failure');};
submitPhotos(camSeq);stopScanCycle();
}, history: function(){showScreen('s-report');} };
})();`)}));
await page.goto(base);
await page.evaluate(async()=>{await fetch('api/auth/browser',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({consent:true})});});
await page.reload();await page.waitForFunction(()=>AccountClient.user());
await page.addScriptTag({url:base+'assets/mock/report-fixture.js'});
await page.evaluate(()=>window.__journeyTest.run(false));
await page.waitForSelector('#s-report.active');
await page.waitForFunction(async()=>{const d=await(await fetch('api/admin/stats')).json();return d.capture_funnel.total.counts.report===1;});
await page.evaluate(()=>window.__journeyTest.history());
await page.evaluate(()=>window.__journeyTest.run(true));
await page.waitForFunction(async()=>{const d=await(await fetch('api/admin/stats')).json();return d.capture_funnel.total.counts.failed===1;});
const stats=await(await fetch(base+'api/admin/stats')).json();
assert.equal(stats.capture_funnel.total.counts.started,2);assert.equal(stats.capture_funnel.total.counts.report,1);
assert.equal(stats.capture_funnel.total.response_without_report,1);assert.equal(stats.capture_funnel.total.rate,50);
assert.equal((await fetch(base+'data/capture-funnel.jsonl')).status,403);
assert.equal(await page.evaluate(async()=> (await fetch('api/capture-funnel',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})).status),400);
await page.goto(base+'admin.html');await page.locator('[data-tab="stats"]').click();
await page.waitForFunction(()=>document.querySelector('#capture-funnel').textContent.includes('50%'));
assert((await page.locator('#capture-funnel').textContent()).includes('成功结果但未进入 1'));
assert.deepEqual(errors,[]);console.log('PASS: session-gated ingestion, actual report activation vs render failure, history excluded, admin funnel render, private storage; no paid API');
} finally {if(browser)await browser.close();child.kill();}
+46
View File
@@ -0,0 +1,46 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const create = require('../lib/capture-funnel');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-funnel-test-'));
const file = path.join(dir, 'events.jsonl');
const funnel = create(file);
const today = Date.parse('2026-10-16T02:00:00Z'), yesterday = today - 86400000;
const stages = ['started','front','right','left','submitted','response','report'];
const body = (id, n) => ({ id: id.repeat(32), events: stages.slice(0,n).map((stage,i)=>({stage,ms:i*1000})) });
assert(funnel.ingest(body('a',7), yesterday));
assert(funnel.ingest(body('a',2), today)); // late old snapshot cannot regress
assert(funnel.ingest(body('a',7), today)); // retries do not inflate
assert(funnel.ingest(body('b',6), today));
assert(funnel.ingest({...body('c',1),events:[{stage:'started',ms:0},{stage:'failed',ms:500}]},today));
assert(funnel.ingest({...body('d',1),events:[{stage:'started',ms:0},{stage:'canceled',ms:600}]},today));
assert(funnel.ingest({...body('e',1),events:[{stage:'started',ms:0},{stage:'interrupted',ms:700}]},today));
assert.equal(funnel.ingest({id:'x',events:[]}),false);
assert.equal(funnel.ingest({id:'f'.repeat(32),events:[{stage:'started',ms:0},{stage:'report',ms:20}]}),false);
assert.equal(funnel.ingest({id:'f'.repeat(32),events:[{stage:'started',ms:0},{stage:'front',ms:-1}]}),false);
const stats=funnel.stats(today);
assert.equal(stats.total.counts.started,5); assert.equal(stats.total.counts.report,1);
assert.equal(stats.total.rate,20);assert.equal(stats.total.avg_ms,6000);
assert.equal(stats.today.counts.started,4);assert.equal(stats.today.counts.report,0);
assert.equal(stats.total.response_without_report,1);assert.equal(stats.total.unfinished,2);
assert.deepEqual(create(file).stats(today),stats); // durable after service restart
const rows=fs.readFileSync(file,'utf8').trim().split('\n').map(JSON.parse);
assert.equal(rows.length,19);assert.deepEqual(Object.keys(rows[0]),['id','stage','ms','at']);
// Run actual browser telemetry helpers in isolation: no real network, no paid API.
const source=fs.readFileSync(path.join(__dirname,'../js/app.js'),'utf8');
const helper=source.slice(source.indexOf(' var captureJourney ='), source.indexOf(' /* camera-outline-pure-begin */'));
const calls=[];const ctx={ IS_MOCK:false,Uint8Array,Array,Date,Math,JSON,
window:{crypto:require('node:crypto').webcrypto},fetch(url,opts){calls.push({url,opts});return Promise.resolve({ok:true});} };
vm.createContext(ctx);vm.runInContext(helper,ctx);
ctx.beginCaptureJourney(); stages.slice(1).forEach(s=>ctx.trackCapture(s));
ctx.trackCapture('report');ctx.trackCapture('canceled');
assert.equal(calls.length,7);assert.equal(calls[6].url,'api/capture-funnel');
assert.equal(calls[6].opts.keepalive,true);assert(funnel.ingest(JSON.parse(calls[6].opts.body),today));
ctx.IS_MOCK=true;ctx.beginCaptureJourney();ctx.trackCapture('started');assert.equal(calls.length,7);
ctx.IS_MOCK=false;ctx.beginCaptureJourney();ctx.trackCapture('canceled');ctx.trackCapture('front');assert.equal(calls.length,9);
ctx.fetch=()=>{throw Error('unavailable');};assert.doesNotThrow(()=>ctx.beginCaptureJourney());
assert(source.indexOf("trackCapture('report')")>source.indexOf("showScreen('s-report', 'replace')"));
console.log('PASS: deduplicated durable anonymous funnel, cumulative/out-of-order delivery, Beijing cohorts, terminal/missing states, mock exclusion, nonblocking browser telemetry');