Files

104 lines
4.5 KiB
JavaScript

// Opt-in performance diagnostics for a local JSONL file.
//
// DEBUGSPEED=true is deliberately required: sampling and wrapping DDP handlers
// adds work of its own. Query values, arguments, user ids and URLs are never
// recorded; a speed report must be safe to share with a bug report. These
// diagnostics never write to Admin Panel -> Problems.
import { Meteor } from 'meteor/meteor';
import { appendFileSync } from 'node:fs';
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
const ENABLED = process.env.DEBUGSPEED === 'true';
const LOG_FILE = process.env.DEBUGSPEED_LOG_FILE || '';
const SLOW_MS = Math.max(1, Number.parseInt(process.env.DEBUGSPEED_SLOW_MS || '250', 10));
const SAMPLE_MS = Math.max(5000, Number.parseInt(process.env.DEBUGSPEED_SAMPLE_MS || '30000', 10));
// One folded event per source per sampling window keeps the diagnostic itself
// from becoming a database workload during a traffic run.
const lastRecorded = new Map();
function recordBounded(source, detail, severity = 'low') {
const now = Date.now();
if (now - (lastRecorded.get(source) || 0) < SAMPLE_MS) return;
lastRecorded.set(source, now);
if (!LOG_FILE) return;
try {
appendFileSync(LOG_FILE, `${JSON.stringify({
timestamp: new Date(now).toISOString(),
category: 'debug-speed',
action: 'measured',
source,
detail,
severity,
})}\n`, { encoding: 'utf8', mode: 0o600 });
} catch (error) {
// Report a fixed message only; paths and error objects may expose local
// details. The diagnostic must never disrupt the measured operation.
console.error('[DEBUGSPEED] Could not append the diagnostic log.'); // eslint-disable-line no-console
}
}
function wrapHandlers(registry, kind) {
if (!registry || typeof registry !== 'object') return;
for (const [name, handler] of Object.entries(registry)) {
if (typeof handler !== 'function' || handler.__wekanDebugSpeedWrapped) continue;
const wrapped = function debugSpeedHandler(...args) {
const started = performance.now();
let result;
try {
result = handler.apply(this, args);
} catch (error) {
const ms = performance.now() - started;
if (ms >= SLOW_MS) recordBounded(`${kind}:${name}`, `${kind} ${name}: ${Math.round(ms)}ms (failed)`);
throw error;
}
if (!result || typeof result.then !== 'function') {
const ms = performance.now() - started;
if (ms >= SLOW_MS) recordBounded(`${kind}:${name}`, `${kind} ${name}: ${Math.round(ms)}ms`);
return result;
}
return result.then(value => {
const ms = performance.now() - started;
if (ms >= SLOW_MS) recordBounded(`${kind}:${name}`, `${kind} ${name}: ${Math.round(ms)}ms`);
return value;
}, error => {
const ms = performance.now() - started;
if (ms >= SLOW_MS) recordBounded(`${kind}:${name}`, `${kind} ${name}: ${Math.round(ms)}ms (failed)`);
throw error;
});
};
wrapped.__wekanDebugSpeedWrapped = true;
registry[name] = wrapped;
}
}
if (ENABLED) {
Meteor.startup(() => {
// Meteor registers all application methods/publications before startup
// callbacks run. These registries are internal, hence this is debug-only.
wrapHandlers(Meteor.server && Meteor.server.method_handlers, 'method');
wrapHandlers(Meteor.server && Meteor.server.publish_handlers, 'publication');
const loop = monitorEventLoopDelay({ resolution: 20 });
loop.enable();
let previousCpu = process.cpuUsage();
let previousAt = process.hrtime.bigint();
recordBounded('wekan.startup', `DEBUGSPEED enabled; slow=${SLOW_MS}ms sample=${SAMPLE_MS}ms database=${process.env.MONGO_URL ? 'external' : 'meteor-mongodb'}`, 'info');
Meteor.setInterval(() => {
const now = process.hrtime.bigint();
const cpu = process.cpuUsage(previousCpu);
const elapsedMicros = Number(now - previousAt) / 1000;
previousCpu = process.cpuUsage();
previousAt = now;
const memory = process.memoryUsage();
const cpuPercent = elapsedMicros > 0 ? ((cpu.user + cpu.system) / elapsedMicros) * 100 : 0;
const p99ms = Number(loop.percentile(99)) / 1e6;
const detail = `cpu=${cpuPercent.toFixed(1)}% rss=${Math.round(memory.rss / 1048576)}MiB heap=${Math.round(memory.heapUsed / 1048576)}MiB event-loop-p99=${p99ms.toFixed(1)}ms`;
recordBounded('wekan.process', detail, cpuPercent >= 100 || p99ms >= 250 ? 'medium' : 'info');
loop.reset();
}, SAMPLE_MS);
});
}