Skip to content

Instantly share code, notes, and snippets.

@trentm
Last active November 24, 2021 00:40
Show Gist options
  • Save trentm/4b8c54a8bdb1c1eba2ade871253860f6 to your computer and use it in GitHub Desktop.
Save trentm/4b8c54a8bdb1c1eba2ade871253860f6 to your computer and use it in GitHub Desktop.
// See: https://gist.github.com/trentm/4b8c54a8bdb1c1eba2ade871253860f6
//
// A very simple mock apm-server.
//
// It will listen on the default 8200 port, respond 200 to any request with a
// `{}` body, and will log (in Bunyan format) the HTTP request and response.
// Importantly that log will include the *request body*, which is useful to
// see what the APM Agent is sending to the server.
//
// Usage:
// npm install -g bunyan # doesn't have to be installed globally
// node mockapmserver.js | bunyan
//
const http = require('http');
const url = require('url');
const util = require('util');
const zlib = require('zlib');
const bunyan = require('bunyan');
const OPT_PRETTY_PRINT_REQUEST_BODY = true
// Render text tree for transactions+spans in a single intake request.
const OPT_RENDER_WATERFALL = true
const OPT_HANG_ON_RESPONSE = false
const OPT_QUEUE_IS_FULL = false
const MAX_REQ_BODY_LENTGH = 2 * 10240
const MAX_RES_BODY_LENTGH = 1024
const log = bunyan.createLogger({
name: 'mockapmserver',
serializers: {
err: bunyan.stdSerializers.err,
req: function (req) {
if (!req || !req.connection)
return req;
let repr = {
method: req.method,
// Accept `req.originalUrl` for expressjs usage.
// https://expressjs.com/en/api.html#req.originalUrl
url: req.originalUrl || req.url,
headers: req.headers,
remoteAddress: req.connection.remoteAddress,
remotePort: req.connection.remotePort,
bodyLength: req.body && req.body.length
};
if (req.body && req.body.length > MAX_REQ_BODY_LENTGH) {
repr.body = req.body.slice(0, MAX_REQ_BODY_LENTGH) +
util.format(' ... (clipped at %d, full length is %d chars)',
MAX_REQ_BODY_LENTGH, req.body.length);
} else if (OPT_PRETTY_PRINT_REQUEST_BODY) {
if (typeof req.body === 'string' && req.body.startsWith('{')) {
try {
repr.body = req.body
.split(/\n/g)
.map(line => line.trim() && JSON.stringify(JSON.parse(line), null, 4))
.join('\n')
} catch {
repr.body = req.body
}
}
} else {
repr.body = req.body
}
if (req.body && OPT_RENDER_WATERFALL) {
repr.waterfall = '\n' + renderWaterfall(req.body)
}
return repr;
},
res: function (res) {
if (!res || !res.statusCode) {
return res;
}
let repr = {
statusCode: res.statusCode,
header: res._header
};
if (typeof(res._data) === 'string') {
repr.bodyLength = res._data.length
if (res._data.length > MAX_RES_BODY_LENTGH) {
repr.body = res._data.slice(0, MAX_RES_BODY_LENTGH) +
util.format(' ... (clipped at %d, full length is %d chars)',
MAX_RES_BODY_LENTGH, res._data.length);
} else {
repr.body = res._data
}
}
return repr
}
},
level: 'debug',
stream: process.stdout
});
// // LineStream
// var stream = require('stream');
// function LineStream(opts) {
// if (!(this instanceof LineStream)) {
// throw new Error('use `new LineStream(opts)`');
// }
// stream.Transform.call(this, opts);
// this._buf = '';
// };
// util.inherits(LineStream, stream.Transform);
// LineStream.prototype._transform = function(chunk, encoding, done) {
// // XXX need toString? Can we do this with raw Buffer for perf?
// var data = this._buf + chunk.toString('utf8');
// var lines = data.split(/\r?\n|\r(?!\n)/);
// this._buf = lines.pop();
// for (var i = 0; i < lines.length; i++) {
// this.push(lines[i] + '\n');
// }
// done();
// };
// LineStream.prototype._flush = function(done) {
// if (this._buf) {
// this.push(this._buf);
// }
// done();
// };
function renderWaterfall(body) {
const OPT_EXTRA_FIELDS = false
function addChild(span, childSpan) {
if (!span.children) {
span.children = [childSpan]
} else {
span.children.push(childSpan)
}
}
function renderError(error, prefix) {
let r = `${prefix}error ${error.id.slice(0, 6)} "${error.exception.message}"`;
if (OPT_EXTRA_FIELDS) {
r += ` (type=${error.exception.type}, parent_id=${error.parent_id})`;
}
(error.children || []).forEach(s => {
r += '\n' + renderSpanOrError(s, ' ' + prefix)
})
return r
}
function renderSpanOrError(spanOrError, prefix) {
if (spanOrError.name === undefined) {
return renderError(spanOrError, prefix)
} else {
return renderSpan(spanOrError, prefix)
}
}
function renderSpan(span, prefix) {
let r = `${prefix}span ${span.id.slice(0, 6)} "${span.name}"`;
if (OPT_EXTRA_FIELDS) {
r += ` (parent_id=${span.parent_id})`;
}
(span.children || []).forEach(s => {
r += '\n' + renderSpanOrError(s, ' ' + prefix)
})
return r
}
function renderTx(tx) {
let r = `transaction ${tx.id.slice(0, 6)} "${tx.name}"`;
if (OPT_EXTRA_FIELDS) {
r += ` (trace_id=${tx.trace_id})`;
}
(tx.children || []).forEach(s => {
r += '\n' + renderSpanOrError(s, '`- ')
})
tx.orphans.forEach(s => {
r += '\n' + renderSpanOrError(s, 'ORPHAN: ')
})
return r
}
const events = body
.split(/\n/g)
.filter(line => line.trim(line))
.map(JSON.parse)
const allSpansAndErrors = events
.filter(e => e.span || e.error)
.map(e => e.span || e.error)
.sort((a, b) => a.timestamp - b.timestamp)
const txs = events.filter(e => e.transaction)
.map(e => e.transaction)
.sort((a, b) => a.timestamp - b.timestamp)
.map(tx => {
tx.orphans = []
const spans = allSpansAndErrors.filter(s => s.trace_id === tx.trace_id)
const spanOrErrorFromId = { [tx.id]: tx }
spans.forEach(s => { spanOrErrorFromId[s.id] = s })
spans.forEach(s => {
const parent = spanOrErrorFromId[s.parent_id]
if (parent) {
addChild(parent, s)
} else {
tx.orphans.push(s)
}
})
return tx
})
const rendering = txs
.map(tx => renderTx(tx))
.join('\n')
return rendering
}
const server = http.createServer(function (req, res) {
// return // respond nevermore
var parsedUrl = url.parse(req.url);
var instream = req;
if (req.headers['content-encoding'] === 'gzip') {
instream = req.pipe(zlib.createGunzip())
} else {
instream.setEncoding('utf8');
}
// // Look at individual *lines* stream in on a chunked request.
// lstream = new LineStream()
// lstream.on('data', function (line) {
// console.warn('XXX line', line)
// })
if (OPT_QUEUE_IS_FULL) {
// APM server "queue is full" error response example from:
// https://www.elastic.co/guide/en/apm/server/current/events-api.html#events-api-errors
setTimeout(function () {
res.writeHead(503, {
'Content-Type': 'application/json'
})
resBody = JSON.stringify({
"errors": [
{
"message": "<json-schema-err>",
"document": "<ndjson-obj>"
},{
"message": "<json-schema-err>",
"document": "<ndjson-obj>"
},{
"message": "<json-decoding-err>",
"document": "<ndjson-obj>"
},{
"message": "queue is full"
},
],
"accepted": 2320
})
res.end(resBody)
res._data = resBody; // for audit logging
log.info({req, res}, 'request')
}, 2000)
}
let body = '';
instream.on('data', (chunk) => {
//console.warn('XXX got a chunk: %d bytes', chunk.length, Buffer.byteLength(chunk))
body += chunk;
// lstream.write(chunk)
});
instream.on('end', function () {
req.body = body;
var resBody;
// TODO: add support for '/' path for server version detection
// as done by https://github.com/elastic/apm-agent-python/pull/1194
if (parsedUrl.pathname === '/config/v1/agents') {
// Central config mocking.
if (process.env.MOCK_CENTRAL_CONFIG_FAILS) {
resBody = '{"ok":false,"message":"The requested resource is currently unavailable."}\n'
res.writeHead(503)
} else if (process.env.MOCK_CENTRAL_CONFIG_INVALID_JSON) {
res.writeHead(200)
resBody = '{"log_level":'
} else {
res.writeHead(200)
// resBody = '{"log_level": "debug"}'
resBody = '{}'
}
} else if (req.method === 'POST' && parsedUrl.pathname === '/intake/v2/events') {
resBody = '{}'
res.writeHead(202)
} else if (req.method === 'POST' && parsedUrl.pathname === '/intake/v2/profile') {
resBody = '{"accepted": 1}'
res.writeHead(202)
}
if (OPT_HANG_ON_RESPONSE) { // Actually respond. Set to false to hang the request.
log.warn('intentionally hanging response')
} else {
res.end(resBody);
}
res._data = resBody; // for audit logging
log.info({req, res}, 'request')
});
})
let PORT = 8200; // default APM server port
//PORT = 8201;
//PORT = 80
server.listen(PORT, function () {
log.info('listening', server.address())
})
@trentm
Copy link
Author

trentm commented Nov 17, 2020

Here is what an example run looks like.

  • start the mockapmserver.js
  • run some node.js code that is using the APM agent instrumentation
% node mockapmserver.js | bunyan
[2020-11-17T23:26:15.550Z]  INFO: mockapmserver/72396 on pink.local: listening { address: '::', family: 'IPv6', port: 8200 }
[2020-11-17T23:26:22.084Z]  INFO: mockapmserver/72396 on pink.local: request (req.remoteAddress=::ffff:127.0.0.1, req.remotePort=51241, req.body="")
    GET /config/v1/agents?service.name=elastic-apm-node&service.environment=development HTTP/1.1
    accept: application/json
    user-agent: elasticapm-node/3.8.0 elastic-apm-http-client/9.4.1 node/10.23.0
    host: localhost:8200
    connection: close
    --
    HTTP/1.1 200 OK
    Date: Tue, 17 Nov 2020 23:26:22 GMT
    Connection: close
    Content-Length: 2
[2020-11-17T23:26:32.111Z]  INFO: mockapmserver/72396 on pink.local: request (req.remoteAddress=::ffff:127.0.0.1, req.remotePort=51242)
    POST /intake/v2/events HTTP/1.1
    accept: application/json
    user-agent: elasticapm-node/3.8.0 elastic-apm-http-client/9.4.1 node/10.23.0
    content-type: application/x-ndjson
    content-encoding: gzip
    host: localhost:8200
    connection: close
    transfer-encoding: chunked

    {"metadata":{"service":{"name":"elastic-apm-node","environment":"development","runtime":{"name":"node","version":"10.23.0"},"language":{"name":"javascript"},"agent":{"name":"nodejs","version":"3.8.0"},"version":"3.8.0"},"process":{"pid":72398,"ppid":66381,"title":"node","argv":["/Users/trentm/.nvm/versions/node/v10.23.0/bin/node","/Users/trentm/el/apm-agent-nodejs2/syncspans.js"]},"system":{"hostname":"pink.local","architecture":"x64","platform":"darwin"}}}
    {"metricset":{"samples":{"system.cpu.total.norm.pct":{"value":0.11111111111111116},"system.memory.total":{"value":68719476736},"system.memory.actual.free":{"value":23393570816},"system.process.cpu.total.norm.pct":{"value":0.06355853209734874},"system.process.cpu.system.norm.pct":{"value":0.008916196910273235},"system.process.cpu.user.norm.pct":{"value":0.054642335187075515},"system.process.memory.rss.bytes":{"value":35115008},"nodejs.handles.active":{"value":4},"nodejs.requests.active":{"value":1},"nodejs.eventloop.delay.ns":{"value":0},"nodejs.memory.heap.allocated.bytes":{"value":20692992},"nodejs.memory.heap.used.bytes":{"value":11905728},"nodejs.memory.external.bytes":{"value":376993},"nodejs.memory.arrayBuffers.bytes":{"value":0},"nodejs.eventloop.delay.avg.ms":{"value":0}},"timestamp":1605655582080000,"tags":{"hostname":"pink.local","env":"development"}}}
    {"transaction":{"name":"GET unknown route","type":"request","result":"HTTP 2xx","id":"eb302bb4f07a3835","trace_id":"952dfc3577d5b235affe4e67af8fb01c","subtype":null,"action":null,"duration":10.282,"timestamp":1605655585043073,"sampled":true,"context":{"user":{},"tags":{},"custom":{},"request":{"http_version":"1.1","method":"GET","url":{"raw":"/","protocol":"http:","hostname":"localhost","port":"8080","pathname":"/","full":"http://localhost:8080/"},"socket":{"remote_address":"::1","encrypted":false},"headers":{"host":"localhost:8080","user-agent":"curl/7.64.1","accept":"*/*"}},"response":{"status_code":200,"headers":{"date":"Tue, 17 Nov 2020 23:26:25 GMT","connection":"keep-alive","transfer-encoding":"chunked"}}},"sync":false,"span_count":{"started":3},"outcome":"success"}}
    {"span":{"name":"This is span 1","type":"custom","id":"56ac98c9cbe208e9","transaction_id":"eb302bb4f07a3835","parent_id":"eb302bb4f07a3835","trace_id":"952dfc3577d5b235affe4e67af8fb01c","subtype":null,"action":null,"timestamp":1605655585046817,"duration":2.263,"stacktrace":[{"filename":"lib/instrumentation/transaction.js","lineno":112,"function":"Transaction.startSpan","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/instrumentation/transaction.js"},{"filename":"lib/instrumentation/index.js","lineno":283,"function":"Instrumentation.startSpan","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/instrumentation/index.js"},{"filename":"lib/agent.js","lineno":107,"function":"Agent.startSpan","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/agent.js"},{"filename":"syncspans.js","lineno":8,"function":"requestListener","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/syncspans.js"},{"filename":"events.js","lineno":198,"function":"emit","library_frame":true,"abs_path":"events.js"},{"filename":"lib/instrumentation/http-shared.js","lineno":67,"function":"Server.emit","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/instrumentation/http-shared.js"},{"filename":"_http_server.js","lineno":695,"function":"parserOnIncoming","library_frame":true,"abs_path":"_http_server.js"},{"filename":"_http_common.js","lineno":111,"function":"parserOnHeadersComplete","library_frame":true,"abs_path":"_http_common.js"}],"sync":false,"outcome":"unknown"}}
    {"span":{"name":"This is span 2","type":"custom","id":"b9de1ce364c5d2fc","transaction_id":"eb302bb4f07a3835","parent_id":"eb302bb4f07a3835","trace_id":"952dfc3577d5b235affe4e67af8fb01c","subtype":null,"action":null,"timestamp":1605655585049898,"duration":0.284,"stacktrace":[{"filename":"lib/instrumentation/transaction.js","lineno":112,"function":"Transaction.startSpan","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/instrumentation/transaction.js"},{"filename":"lib/instrumentation/index.js","lineno":283,"function":"Instrumentation.startSpan","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/instrumentation/index.js"},{"filename":"lib/agent.js","lineno":107,"function":"Agent.startSpan","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/agent.js"},{"filename":"syncspans.js","lineno":10,"function":"requestListener","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/syncspans.js"},{"filename":"events.js","lineno":198,"function":"emit","library_frame":true,"abs_path":"events.js"},{"filename":"lib/instrumentation/http-shared.js","lineno":67,"function":"Server.emit","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/instrumentation/http-shared.js"},{"filename":"_http_server.js","lineno":695,"function":"parserOnIncoming","library_frame":true,"abs_path":"_http_server.js"},{"filename":"_http_common.js","lineno":111,"function":"parserOnHeadersComplete","library_frame":true,"abs_path":"_http_common.js"}],"sync":false,"outcome":"unknown"}}
    {"span":{"name":"This is span 3","type":"custom","id":"c943f752d2c671d9","transaction_id":"eb302bb4f07a3835","parent_id":"eb302bb4f07a3835","trace_id":"952dfc3577d5b235affe4e67af8fb01c","subtype":null,"action":null,"timestamp":1605655585050290,"duration":0.176,"stacktrace":[{"filename":"lib/instrumentation/transaction.js","lineno":112,"function":"Transaction.startSpan","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/instrumentation/transaction.js"},{"filename":"lib/instrumentation/index.js","lineno":283,"function":"Instrumentation.startSpan","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/instrumentation/index.js"},{"filename":"lib/agent.js","lineno":107,"function":"Agent.startSpan","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/agent.js"},{"filename":"syncspans.js","lineno":12,"function":"requestListener","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/syncspans.js"},{"filename":"events.js","lineno":198,"function":"emit","library_frame":true,"abs_path":"events.js"},{"filename":"lib/instrumentation/http-shared.js","lineno":67,"function":"Server.emit","library_frame":false,"abs_path":"/Users/trentm/el/apm-agent-nodejs2/lib/instrumentation/http-shared.js"},{"filename":"_http_server.js","lineno":695,"function":"parserOnIncoming","library_frame":true,"abs_path":"_http_server.js"},{"filename":"_http_common.js","lineno":111,"function":"parserOnHeadersComplete","library_frame":true,"abs_path":"_http_common.js"}],"sync":false,"outcome":"unknown"}}

    --
    HTTP/1.1 200 OK
    Date: Tue, 17 Nov 2020 23:26:32 GMT
    Connection: close
    Content-Length: 2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment