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