0.2.6-dev: traffic counters — HTTP body bytes by category (assets / audio / code / data) and WebSocket wire / payload bytes since the start at GET /healthz?traffic=1, and the page's own downloads (Resource Timing transferSize) as a traffic line in the 问题反馈 diagnostics (host bandwidth layer 0.4); golden: unchanged
This commit is contained in:
1 parent
05b0dba8b5
commit
be8d382c05
14 files changed
+516
-23
No files matched your search
@@ -0,0 +1,41 @@
|
||||
// shared/assetPaths.js: the content-hash segment of asset URLs (split / insert / strip) and the traffic categories the
|
||||
// server's counters and the browser's diagnostics share (host bandwidth, layers 0 and 1).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { splitHashedPath, hashedPath, unhashedUrl, trafficCategory, TRAFFIC_CATEGORIES, ASSET_HASH_HEX, HASH_SEGMENT_RE } from '../shared/assetPaths.js';
|
||||
|
||||
const H = '0123456789abcdef';
|
||||
|
||||
test('the hash segment: split off, inserted after the root, removed again', () => {
|
||||
assert.equal(ASSET_HASH_HEX, 16);
|
||||
assert.ok(HASH_SEGMENT_RE.test(`~${H}`));
|
||||
assert.deepEqual(splitHashedPath(`/assets/~${H}/char/avatar/a.png`), { hash: H, path: '/assets/char/avatar/a.png' });
|
||||
assert.deepEqual(splitHashedPath(`/fonts/~${H}/f.woff2`), { hash: H, path: '/fonts/f.woff2' });
|
||||
assert.deepEqual(splitHashedPath(`/media/~${H}/bgm/a`), { hash: H, path: '/media/bgm/a' });
|
||||
for (const p of ['/assets/char/a.png', `/assets/~${H.toUpperCase()}/a.png`, `/assets/~${H}0/a.png`, `/assets/~${H}`, `/data/~${H}/x.json`,
|
||||
`/assets/x/~${H}/a.png`, '/assets/~zz/a.png', '']) {
|
||||
assert.deepEqual(splitHashedPath(p), { hash: null, path: p }, p);
|
||||
}
|
||||
assert.equal(hashedPath('/assets/char/a.png', H), `/assets/~${H}/char/a.png`);
|
||||
assert.equal(hashedPath('/media/bgm/a', H), `/media/~${H}/bgm/a`);
|
||||
assert.equal(hashedPath('/fonts/f.woff2', H), `/fonts/~${H}/f.woff2`);
|
||||
assert.equal(hashedPath('/data/assets.json', H), '/data/assets.json', 'not a hashed root');
|
||||
assert.equal(hashedPath('/assets/a.png', null), '/assets/a.png');
|
||||
assert.equal(hashedPath('/assets/a.png', 'nothex'), '/assets/a.png');
|
||||
assert.equal(hashedPath(`/assets/~${H}/a.png`, 'fedcba9876543210'), `/assets/~${H}/a.png`, 'never two segments');
|
||||
assert.equal(hashedPath('/assets/', H), '/assets/');
|
||||
assert.equal(unhashedUrl(`/assets/~${H}/audio/sfx/b_char_dead.mp3`), '/assets/audio/sfx/b_char_dead.mp3');
|
||||
assert.equal(unhashedUrl(null), null);
|
||||
});
|
||||
|
||||
test('traffic categories: audio, assets, code, data, other — the hash segment does not matter', () => {
|
||||
assert.deepEqual([...TRAFFIC_CATEGORIES], ['assets', 'audio', 'code', 'data', 'other']);
|
||||
const cases = {
|
||||
'/media/bgm/act1': 'audio', [`/media/~${H}/bgm/act1`]: 'audio', '/assets/audio/bgm/a.mp3': 'audio', [`/assets/~${H}/audio/sfx/a.mp3`]: 'audio',
|
||||
'/assets/char/avatar/a.png': 'assets', [`/assets/~${H}/spine/op/x/front/x.skel`]: 'assets', '/fonts/f.woff2': 'assets', '/icons/app.svg': 'assets',
|
||||
'/packs/foo/art/a.png': 'assets', '/packs/index.json': 'data', '/data/assets.json': 'data', '/data/i18n/en.json': 'data', '/i18n/en.json': 'data',
|
||||
'/manifest.json': 'data', '/': 'code', '/index.html': 'code', '/js/main.js': 'code', '/css/theme.css': 'code', '/vendor/pixi.min.js': 'code',
|
||||
'/sim/Battle.js': 'code', '/shared/protocol.js': 'code', '/data.js': 'code', '/healthz': 'other', '/favicon.ico': 'other',
|
||||
};
|
||||
for (const [p, cat] of Object.entries(cases)) assert.equal(trafficCategory(p), cat, p);
|
||||
});
|
||||
+51
-1
@@ -5,7 +5,7 @@ import { test, describe, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
recordError, errorLog, errorCount, clearErrorLog, messageOf, stackOf, stripHosts, redact, redactValues, secretsOf, buildDiagnostics,
|
||||
setBattleSource, currentBattle, LOG_MAX, STACK_LINES,
|
||||
setBattleSource, currentBattle, LOG_MAX, STACK_LINES, recordResourceEntry, trafficSummary, trafficLine, clearTraffic, installTrafficMeter,
|
||||
} from '../public/js/diag.js';
|
||||
import { APP_VERSION } from '../shared/constants.js';
|
||||
|
||||
@@ -184,3 +184,53 @@ describe('the report', () => {
|
||||
setBattleSource(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page\'s traffic (host bandwidth layer 0)', () => {
|
||||
beforeEach(() => clearTraffic());
|
||||
const ORIGIN = 'http://192.168.1.5:3000';
|
||||
|
||||
test('Resource Timing entries sum by category: bytes on the wire, a cache hit counted as cached, other sites apart', () => {
|
||||
recordResourceEntry({ name: `${ORIGIN}/assets/~0123456789abcdef/spine/op/x/front/x.skel`, transferSize: 120000, decodedBodySize: 400000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/assets/char/avatar/a.png`, transferSize: 0, decodedBodySize: 50000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/media/~0123456789abcdef/bgm/m_bat`, transferSize: 2000000, decodedBodySize: 2000000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/js/main.js`, transferSize: 9000, decodedBodySize: 30000 }, ORIGIN);
|
||||
recordResourceEntry({ name: `${ORIGIN}/data/assets.json`, transferSize: 300, decodedBodySize: 1300000 }, ORIGIN);
|
||||
recordResourceEntry({ name: 'https://fonts.gstatic.com/s/x.woff2', transferSize: 50000 }, ORIGIN);
|
||||
recordResourceEntry({ name: 'data:image/png;base64,AAAA', transferSize: 0 }, ORIGIN);
|
||||
recordResourceEntry({ name: 'blob:http://192.168.1.5:3000/1234', transferSize: 0 }, ORIGIN);
|
||||
recordResourceEntry(null, ORIGIN);
|
||||
const t = trafficSummary();
|
||||
assert.deepEqual(t.assets, { requests: 2, bytes: 120000, cached: 1 });
|
||||
assert.deepEqual(t.audio, { requests: 1, bytes: 2000000, cached: 0 });
|
||||
assert.deepEqual(t.code, { requests: 1, bytes: 9000, cached: 0 });
|
||||
assert.deepEqual(t.data, { requests: 1, bytes: 300, cached: 0 }, 'a 304 costs its headers only');
|
||||
assert.deepEqual(t.external, { requests: 1, bytes: 50000, cached: 0 });
|
||||
assert.deepEqual(t.total, { requests: 5, bytes: 2129300, cached: 1 }, 'the host total leaves other sites out');
|
||||
assert.deepEqual(Object.keys(t), ['assets', 'audio', 'code', 'data', 'external', 'total']);
|
||||
assert.equal(trafficLine(t), 'assets 120 KB (2, 1 cached) | audio 2.0 MB (1) | code 9 KB (1) | data 300 B (1) | external 50 KB (1) | host total 2.1 MB (5)');
|
||||
});
|
||||
|
||||
test('the report carries the traffic line; nothing recorded → no line', () => {
|
||||
recordResourceEntry({ name: `${ORIGIN}/js/main.js`, transferSize: 1500 }, ORIGIN);
|
||||
const text = buildDiagnostics({ state: {}, now: 0, pageStart: 0, traffic: trafficSummary() });
|
||||
assert.match(text, /\ntraffic: code 2 KB \(1\) \| host total 2 KB \(1\)\n/);
|
||||
clearTraffic();
|
||||
assert.ok(!/traffic:/.test(buildDiagnostics({ state: {}, now: 0, pageStart: 0, traffic: trafficSummary() })));
|
||||
assert.ok(!/traffic:/.test(buildDiagnostics({ state: {}, now: 0, pageStart: 0 })));
|
||||
});
|
||||
|
||||
test('installTrafficMeter reads the navigation entry and observes the resources (buffered), once; false without the API', () => {
|
||||
let observed = null;
|
||||
class PO {
|
||||
constructor(fn) { this.fn = fn; }
|
||||
observe(o) { observed = o; this.fn({ getEntries: () => [{ name: `${ORIGIN}/vendor/pixi.min.js`, transferSize: 140000 }] }); }
|
||||
}
|
||||
const win = { PerformanceObserver: PO, location: { origin: ORIGIN }, performance: { getEntriesByType: (t) => (t === 'navigation' ? [{ name: `${ORIGIN}/`, transferSize: 7000 }] : []) } };
|
||||
assert.equal(installTrafficMeter({}), false);
|
||||
assert.equal(installTrafficMeter(win), true);
|
||||
assert.deepEqual(observed, { type: 'resource', buffered: true });
|
||||
assert.deepEqual(trafficSummary().code, { requests: 2, bytes: 147000, cached: 0 });
|
||||
assert.equal(installTrafficMeter(win), true, 'already installed');
|
||||
assert.equal(trafficSummary().code.requests, 2, 'not twice');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// The server's traffic counters (server/http/traffic.js, GET /healthz?traffic=1; host bandwidth layer 0): the body
|
||||
// bytes every HTTP response wrote, by category, 304s apart, and the WebSocket's wire and payload bytes both ways.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { startServer } from '../server/index.js';
|
||||
import { createTrafficCounter } from '../server/http/traffic.js';
|
||||
import { TestClient } from './helpers/wsClient.js';
|
||||
|
||||
function get(port, p, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: '127.0.0.1', port, path: p, headers, agent: false }, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('HTTP: body bytes by category as written (compressed), 304s counted, /healthz reports them only when asked', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-traffic-'));
|
||||
const w = (rel, content) => { fs.mkdirSync(path.dirname(path.join(tmp, rel)), { recursive: true }); fs.writeFileSync(path.join(tmp, rel), content); };
|
||||
const js = `// app\n${'export const x = 1;\n'.repeat(400)}`;
|
||||
const png = randomBytes(3000);
|
||||
const mp3 = randomBytes(5000);
|
||||
w('public/index.html', '<!doctype html><p>hi</p>');
|
||||
w('public/js/app.js', js);
|
||||
w('public/assets/img/a.png', png);
|
||||
w('public/assets/audio/bgm/a.mp3', mp3);
|
||||
w('data/config.json', JSON.stringify({ a: 1 }));
|
||||
w('shared/x.js', 'export const y = 2;');
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(tmp, 'public'), dataDir: path.join(tmp, 'data'), sharedDir: path.join(tmp, 'shared') });
|
||||
try {
|
||||
const plain = await get(srv.port, '/health');
|
||||
assert.equal(plain.status, 404);
|
||||
const gz = await get(srv.port, '/js/app.js', { 'accept-encoding': 'gzip' });
|
||||
assert.ok(gz.body.length < js.length, 'compressed');
|
||||
await get(srv.port, '/js/app.js', { 'if-none-match': (await get(srv.port, '/js/app.js')).headers.etag });
|
||||
await get(srv.port, '/assets/img/a.png');
|
||||
await get(srv.port, '/media/bgm/a');
|
||||
await get(srv.port, '/data/config.json');
|
||||
const before = await get(srv.port, '/healthz');
|
||||
assert.equal(JSON.parse(before.body).traffic, undefined, 'plain /healthz stays as it was');
|
||||
const t = JSON.parse((await get(srv.port, '/healthz?traffic=1')).body).traffic;
|
||||
assert.equal(t.http.code.requests, 3);
|
||||
assert.equal(t.http.code.bytes, gz.body.length + js.length, 'what was written: the gzip body, the plain body, nothing for the 304');
|
||||
assert.equal(t.http.code.notModified, 1);
|
||||
assert.deepEqual(t.http.assets, { requests: 1, bytes: png.length, notModified: 0 });
|
||||
assert.deepEqual(t.http.audio, { requests: 1, bytes: mp3.length, notModified: 0 });
|
||||
assert.equal(t.http.data.requests, 1);
|
||||
assert.equal(t.http.other.requests, 2, 'the 404 page and the first /healthz');
|
||||
assert.ok(t.http.other.bytes > 0);
|
||||
assert.equal(t.http.total.requests, 8);
|
||||
assert.equal(typeof t.since, 'string');
|
||||
assert.equal(srv.traffic.snapshot().http.total.requests, 9, 'the traffic request itself once it has finished');
|
||||
} finally {
|
||||
await srv.close();
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('WebSocket: wire bytes both ways (after permessage-deflate) and the payloads, live and after the close', async () => {
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
try {
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
await c.hello('流量计数');
|
||||
const live = srv.traffic.snapshot().ws;
|
||||
assert.equal(live.connections, 1);
|
||||
assert.equal(live.open, 1);
|
||||
assert.ok(live.messagesOut >= 1 && live.payloadOut > 0, JSON.stringify(live));
|
||||
assert.ok(live.messagesIn >= 1 && live.payloadIn > 0);
|
||||
assert.ok(live.wireOut > 0 && live.wireIn > 0);
|
||||
await c.close();
|
||||
for (let i = 0; i < 50 && srv.traffic.snapshot().ws.open; i++) await new Promise((r) => setTimeout(r, 20));
|
||||
const done = srv.traffic.snapshot().ws;
|
||||
assert.equal(done.open, 0);
|
||||
assert.ok(done.wireOut >= live.wireOut, 'the closed socket\'s bytes stay in the totals');
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('the counter on its own: a string body in another encoding, an aborted response, the payload of a fragmented message', () => {
|
||||
const tc = createTrafficCounter({ now: () => 1000 });
|
||||
const listeners = {};
|
||||
const res = { statusCode: 200, write() { return true; }, end() {}, once(ev, fn) { listeners[ev] = fn; } };
|
||||
tc.meter(res, '/data/x.json');
|
||||
res.write('aGk=', 'base64');
|
||||
res.end('é');
|
||||
listeners.close();
|
||||
const s = tc.snapshot();
|
||||
assert.deepEqual(s.http.data, { requests: 1, bytes: 2 + 2, notModified: 0 });
|
||||
tc.wsSent('{"t":"pong"}');
|
||||
tc.wsReceived([Buffer.alloc(3), Buffer.alloc(4)]);
|
||||
const ws = tc.snapshot().ws;
|
||||
assert.equal(ws.payloadOut, 12);
|
||||
assert.equal(ws.payloadIn, 7);
|
||||
assert.equal(tc.snapshot().seconds, 0);
|
||||
});
|
||||
Reference in new issue
Block a user