@contrast/route-metrics
Version:
`route-metrics` allows server performance, exclusive of network time, to be compared on a route-by-route basis. It was created to compare server performance with and without `@contrast/agent` being loaded and active.
208 lines (187 loc) • 8.03 kB
JavaScript
'use strict';
const {isMainThread} = require('node:worker_threads');
const package_json = require('../package.json');
//
// this is the startup code. it's only run in the main thread.
//
// it sets up the writer, the patcher, and listeners for all the events that
// will be written to the log file.
//
module.exports = function initRouteMetrics({type}) {
if (!isMainThread) {
// for some reason, main is invoked in the worker thread after it's
// invoked in the main thread. i guess we could treat this as a meta-
// esm-type and enabled the patcher in the worker thread, but there
// is not communications channel then, so we can't forward messages
// to the main thread. this may not be the best solution, but the optimal
// solution is for the user to --import the esm version. maybe this
// should only be an esm module? would really simplify things.
return;
}
// it's only one thing, but use an array so it's possible to add a user-
// specified agent at runtime (at some point in the future).
const AGENTS = ['@contrast/agent'];
// node 16.19.1 made -r @contrast/agent not accessible via require patching
// and it's not visible to patching or loader hooks when using --import, so
// look at execArgv. it's a hack, but it's more straightforward than the way
// it was done before (look for things required by the agent). This approach
// also has no cost after startup.
// https://github.com/nodejs/node/commit/b02d895137
const agentsToBeEmitted = [];
// let's find @contrast/agent if it's there.
for (let i = 0; i < process.execArgv.length; i++) {
if (['-r', '--require', '--import'].includes(process.execArgv[i])) {
const ix = AGENTS.indexOf(process.execArgv[i + 1]);
if (ix >= 0) {
agentsToBeEmitted.push(AGENTS[ix]);
}
}
}
//
// startup code. all code in this file is executed in the main thread.
// thread-specific code is in other files, e.g., setup-patcher.js.
//
const makeHeader = require('./writer/make-header');
//
// get the configuration and create the log file writer
//
const {config, errors} = require('./config/agent-config').get();
const Writer = require('./writer/make-writer');
const options = {
streamOptions: {
// if logging all loads this generates many log entries very fast. the
// end var is the highWaterMark multiplier if set: e.g., 1 means 1MB, 2
// means 2097152, etc. if not, default of 16K is usually fine. 0 is the
// default value for LOG_ALL_LOADS.
highWaterMark: config.LOG_ALL_LOADS ? config.LOG_ALL_LOADS * 1048576 : undefined
}
};
const writer = new Writer(config.LOG_FILE, options);
//
// write the header to the log file, then write any errors detected
// in the configuration. nothing is written to the console if the
// log file is writable.
//
const {header, errors: headerErrors} = makeHeader(package_json.version, config);
writer.write({type: 'header'}, header);
if (errors.unknown.length) {
writer.write({type: 'unknown-config-items'}, errors.unknown.join(', '));
}
if (errors.invalid.length) {
writer.write({type: 'invalid-config-values'}, errors.invalid.join(', '));
}
if (headerErrors.length) {
writer.write({type: 'header-errors'}, headerErrors.join(', '));
}
//
// routes are emitted by the patched http/https/http2 code. type 'route' used
// to be known as 'metric' but that was too generic and confusing.
//
const routeListener = (m) => { // main only
writer.write({type: 'route'}, m);
};
const routeEmitter = require('./route-emitter');
routeEmitter.on('route', routeListener);
//
// timeseries capture data that is sampled on a regular interval, unlike
// patches, loads, and routes, which are generated by events.
//
// the always-enabled cpu and memory time series
function tsCallback(cpuAndMem) {
writer.write({type: 'proc'}, cpuAndMem);
}
const timeSeriesOptions = {tsCallback};
// optional GC time series
if (config.GARBAGE_COLLECTION) {
timeSeriesOptions.gcCallback = function gcListener(gcStats) {
writer.write({type: 'gc'}, gcStats);
};
}
// optional event loop time series
if (config.EVENTLOOP) {
timeSeriesOptions.elCallback = function elListener(elPercentiles) {
writer.write({type: 'eventloop'}, elPercentiles);
};
}
// undocumented interval setting, primarily for testing.
let interval = process.env.CSI_ROUTE_METRICS_TIME_SERIES_INTERVAL || 1000;
interval = Number(interval);
const TimeSeries = require('./time-series');
// eslint-disable-next-line no-unused-vars
const timeSeries = new TimeSeries(interval, timeSeriesOptions);
for (const agent of agentsToBeEmitted) {
writer.write({type: 'patch'}, {name: agent});
}
//
// setup the patcher for the main thread. it will also be set up in the
// loader thread, but the loader thread event listeners just augment the
// events then forward them to the main thread to be written to the log file.
//
// THIS IS DONE LAST SO WE DON'T SEE ALL OF OUR OWN LOADS.
//
const patchListener = (m) => {
// if it's an error make it concise; there's only one place in each thread
// where files are patched, so there's no need for a stack trace.
// a stack trace.
if (m instanceof Error) {
const {code, message} = m;
const patchError = {};
if (code) {
patchError.code = code;
}
patchError.message = message;
writer.write({type: 'patch-error'}, patchError);
return;
}
// normal case - just write the log
writer.write({type: 'patch'}, m);
};
let loadListener;
if (config.LOG_ALL_LOADS) {
// listen for load events and write them to the log. a file being
// loaded will emit either a load or a patch event, not both. packages
// preloaded by node, whether using --require or --import, will emit
// a patch event (currently only @contrast/agent is checked).
loadListener = (m) => {
writer.write({type: 'load'}, m);
};
}
const setupPatcher = require('./setup-patcher');
setupPatcher(patchListener, loadListener);
// this should always be true now; this mostly serves as delineation that the
// following code is all about setting up and handling the loader thread.
if (type === 'esm') {
// function to receive messages from the loader thread. all it does is pass
// them to the writer. the esm initialize() hook enables patching and
// listens for the same 'patch' and 'load' events as above. but the hooks
// in the ESM loader thread just forward the event to the main thread, as
// a message, where portMessageHandler() receives it and writes it to the
// log file.
//
// why? so we don't try to write to the same file from multiple threads.
// why does the ESM loader thread add a timestamp? so the timestamp doesn't
// include the port transfer time.
async function portMessageHandler(message) {
// log it? yes, but slightly more complex because we're receiving the
// message from the loader thread which added a threadId and timestamp.
const {type, ts, tid, m: entry} = message;
await writer.write({type, ts, tid}, entry);
}
// ideally we'd wait on this, but once register has been called the hooks are
// in place and nothing remains to be done.
(async() => {
const transferData = {
app_dir: header.app_dir,
// this probably should pass the entire config to the loader thread but
// at this time, this is the only item needed. it's used by patcher.
// N.B. it's used as an integer in the loader thread even though it does
// get assigned to process.env too.
env: {
CSI_RM_LOG_ALL_LOADS: Number(process.env.CSI_RM_LOG_ALL_LOADS) || 0,
},
};
const registerHooks = (await import('./esm-hooks/index.mjs')).default;
await registerHooks(transferData, portMessageHandler);
})();
}
};