lib/instrumentation/modules/http2.js (244 lines of code) (raw):
/*
* Copyright Elasticsearch B.V. and other contributors where applicable.
* Licensed under the BSD 2-Clause License; you may not use this file except in
* compliance with the BSD 2-Clause License.
*/
'use strict';
var eos = require('end-of-stream');
var shimmer = require('../shimmer');
var symbols = require('../../symbols');
var { parseUrl } = require('../../parsers');
var { getHTTPDestination } = require('../context');
// Return true iff this is an HTTP 1.0 or 1.1 request.
//
// @param {http2.Http2ServerRequest} req
function reqIsHTTP1(req) {
return (
req &&
typeof req.httpVersion === 'string' &&
req.httpVersion.startsWith('1.')
);
}
module.exports = function (http2, agent, { enabled }) {
if (agent._conf.instrumentIncomingHTTPRequests) {
agent.logger.debug('shimming http2.createServer function');
shimmer.wrap(http2, 'createServer', wrapCreateServer);
shimmer.wrap(http2, 'createSecureServer', wrapCreateServer);
}
if (!enabled) return http2;
var ins = agent._instrumentation;
agent.logger.debug('shimming http2.connect function');
shimmer.wrap(http2, 'connect', wrapConnect);
return http2;
// The `createServer` function will unpatch itself after patching
// the first server prototype it patches.
function wrapCreateServer(original) {
return function wrappedCreateServer(options, handler) {
var server = original.apply(this, arguments);
shimmer.wrap(server.constructor.prototype, 'emit', wrapEmit);
wrappedCreateServer[symbols.unwrap]();
return server;
};
}
function wrapEmit(original) {
var patched = false;
return function wrappedEmit(event, stream, headers) {
if (event === 'stream') {
if (!patched) {
patched = true;
var proto = stream.constructor.prototype;
shimmer.wrap(proto, 'pushStream', wrapPushStream);
shimmer.wrap(proto, 'respondWithFile', wrapRespondWith);
shimmer.wrap(proto, 'respondWithFD', wrapRespondWith);
shimmer.wrap(proto, 'respond', wrapHeaders);
shimmer.wrap(proto, 'end', wrapEnd);
}
agent.logger.debug(
'intercepted stream event call to http2.Server.prototype.emit',
);
const traceparent =
headers.traceparent || headers['elastic-apm-traceparent'];
const tracestate = headers.tracestate;
const trans = agent.startTransaction(null, 'request', {
childOf: traceparent,
tracestate,
});
// `trans.req` and `trans.res` are fake representations of Node.js's
// core `http.IncomingMessage` and `http.ServerResponse` objects,
// sufficient for `parsers.getContextFromRequest()` and
// `parsers.getContextFromResponse()`, respectively.
// `remoteAddress` is fetched now, rather than at stream end, because
// this `stream.session.socket` is a proxy object that can throw
// `ERR_HTTP2_SOCKET_UNBOUND` if the Http2Session has been destroyed.
trans.req = {
headers,
socket: {
remoteAddress: stream.session.socket.remoteAddress,
},
method: headers[':method'],
url: headers[':path'],
httpVersion: '2.0',
};
trans.res = {
statusCode: 200,
headersSent: false,
finished: false,
headers: null,
};
ins.bindEmitter(stream);
eos(stream, function () {
trans.end();
});
} else if (event === 'request' && reqIsHTTP1(stream)) {
// http2.createSecureServer() supports a `allowHTTP1: true` option.
// When true, an incoming client request that supports HTTP/1.x but not
// HTTP/2 will be allowed. It will result in a 'request' event being
// emitted. We wrap that here.
//
// Note that, a HTTP/2 request results in a 'stream' event (wrapped
// above) *and* a 'request' event. We do not want to wrap the
// compatibility 'request' event in this case. Hence the `reqIsHTTP1`
// guard.
const req = stream;
const res = headers;
agent.logger.debug(
'intercepted request event call to http2.Server.prototype.emit for %s',
req.url,
);
const traceparent =
req.headers.traceparent || req.headers['elastic-apm-traceparent'];
const tracestate = req.headers.tracestate;
const trans = agent.startTransaction(null, null, {
childOf: traceparent,
tracestate,
});
trans.type = 'request';
trans.req = req;
trans.res = res;
ins.bindEmitter(req);
ins.bindEmitter(res);
eos(res, function (err) {
if (trans.ended) return;
if (!err) {
trans.end();
return;
}
if (agent._conf.errorOnAbortedRequests) {
var duration = trans._timer.elapsed();
if (duration > agent._conf.abortedErrorThreshold * 1000) {
agent.captureError(
'Socket closed with active HTTP request (>' +
agent._conf.abortedErrorThreshold +
' sec)',
{
request: req,
extra: { abortTime: duration },
},
);
}
}
// Handle case where res.end is called after an error occurred on the
// stream (e.g. if the underlying socket was prematurely closed)
const end = res.end;
res.end = function () {
const result = end.apply(this, arguments);
trans.end();
return result;
};
});
}
return original.apply(this, arguments);
};
}
function updateHeaders(headers) {
var trans = agent._instrumentation.currTransaction();
if (trans && !trans.ended) {
var status = headers[':status'] || 200;
trans.result = 'HTTP ' + status.toString()[0] + 'xx';
trans.res.statusCode = status;
trans._setOutcomeFromHttpStatusCode(status);
trans.res.headers = mergeHeaders(trans.res.headers, headers);
trans.res.headersSent = true;
}
}
function wrapHeaders(original) {
return function (headers) {
updateHeaders(headers);
return original.apply(this, arguments);
};
}
function wrapRespondWith(original) {
return function (_, headers) {
updateHeaders(headers);
return original.apply(this, arguments);
};
}
function wrapEnd(original) {
return function (headers) {
var trans = agent._instrumentation.currTransaction();
// `trans.res` might be removed, because before
// https://github.com/nodejs/node/pull/20084 (e.g. in node v10.0.0) the
// 'end' event could be called multiple times for the same Http2Stream,
// and the `trans.res` ref is removed when the Transaction is ended.
if (trans && trans.res) {
trans.res.finished = true;
}
return original.apply(this, arguments);
};
}
function wrapPushStream(original) {
return function wrappedPushStream(...args) {
// Note: Break the run context so that the wrapped `stream.respond` et al
// for this pushStream do not overwrite outer transaction state.
var callback = args.pop();
args.push(agent._instrumentation.bindFunctionToEmptyRunContext(callback));
return original.apply(this, args);
};
}
function mergeHeaders(source, target) {
if (source === null) return target;
var result = Object.assign({}, target);
var keys = Object.keys(source);
for (let i = 0; i < keys.length; i++) {
var key = keys[i];
if (typeof target[key] === 'undefined') {
result[key] = source[key];
} else if (Array.isArray(target[key])) {
result[key].push(source[key]);
} else {
result[key] = [source[key]].concat(target[key]);
}
}
return result;
}
function wrapConnect(orig) {
return function (host) {
const ret = orig.apply(this, arguments);
shimmer.wrap(ret, 'request', (orig) => wrapRequest(orig, host));
return ret;
};
}
function wrapRequest(orig, host) {
return function (headers) {
agent.logger.debug('intercepted call to http2.request');
var method = headers[':method'] || 'GET';
const span = ins.createSpan(null, 'external', 'http', method, {
exitSpan: true,
});
const parentRunContext = ins.currRunContext();
var parent =
span ||
parentRunContext.currSpan() ||
parentRunContext.currTransaction();
if (parent) {
const newHeaders = Object.assign({}, headers);
parent.propagateTraceContextHeaders(
newHeaders,
function (carrier, name, value) {
carrier[name] = value;
},
);
arguments[0] = newHeaders;
}
if (!span) {
return orig.apply(this, arguments);
}
const spanRunContext = parentRunContext.enterSpan(span);
var req = ins.withRunContext(spanRunContext, orig, this, ...arguments);
ins.bindEmitterToRunContext(parentRunContext, req);
var urlObj = parseUrl(headers[':path']);
var path = urlObj.pathname;
var url = host + path;
span.name = method + ' ' + host;
var statusCode;
req.on('response', (headers) => {
statusCode = headers[':status'];
});
req.on('end', () => {
agent.logger.debug('intercepted http2 client end event');
span.setHttpContext({
method,
status_code: statusCode,
url,
});
span._setOutcomeFromHttpStatusCode(statusCode);
// The `getHTTPDestination` function might throw in case an
// invalid URL is given to the `URL()` function. Until we can
// be 100% sure this doesn't happen, we better catch it here.
// For details, see:
// https://github.com/elastic/apm-agent-nodejs/issues/1769
try {
span._setDestinationContext(getHTTPDestination(url));
} catch (e) {
agent.logger.error(
'Could not set destination context: %s',
e.message,
);
}
span.end();
});
return req;
};
}
};