Skip to content

Commit

Permalink
feat: add hapi 17 tracing support
Browse files Browse the repository at this point in the history
  • Loading branch information
kjin committed Apr 3, 2018
1 parent b646611 commit 9e4987d
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 116 deletions.
202 changes: 122 additions & 80 deletions src/plugins/plugin-hapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,19 @@ import {parse as urlParse} from 'url';

import {PluginTypes} from '..';

import {hapi_16} from './types';
import {hapi_16, hapi_17} from './types';

type Hapi16Module = typeof hapi_16;
// Used when patching Hapi 17.
const ORIGINAL = Symbol();

const SUPPORTED_VERSIONS = '8 - 16';
type Hapi16Module = typeof hapi_16;
type Hapi17RequestExecutePrivate = {
(this: hapi_17.Request): Promise<void>;
[ORIGINAL]?: Hapi17RequestExecutePrivate;
};
type Hapi17Request = hapi_17.Request&{
_execute: Hapi17RequestExecutePrivate;
};

function getFirstHeader(req: IncomingMessage, key: string): string|null {
let headerValue = req.headers[key] || null;
Expand All @@ -34,87 +42,121 @@ function getFirstHeader(req: IncomingMessage, key: string): string|null {
return headerValue;
}

function createMiddleware(api: PluginTypes.TraceAgent):
hapi_16.ServerExtRequestHandler {
return function middleware(request, reply) {
const req = request.raw.req;
const res = request.raw.res;
const originalEnd = res.end;
const options: PluginTypes.RootSpanOptions = {
name: req.url ? (urlParse(req.url).pathname || '') : '',
url: req.url,
traceContext:
getFirstHeader(req, api.constants.TRACE_CONTEXT_HEADER_NAME),
skipFrames: 3
};
api.runInRootSpan(options, (root) => {
// Set response trace context.
const responseTraceContext = api.getResponseTraceContext(
options.traceContext || null, api.isRealSpan(root));
if (responseTraceContext) {
res.setHeader(
api.constants.TRACE_CONTEXT_HEADER_NAME, responseTraceContext);
}

if (!api.isRealSpan(root)) {
return reply.continue();
function instrument<T>(
api: PluginTypes.TraceAgent, request: hapi_16.Request|hapi_17.Request,
continueCb: () => T): T {
const req = request.raw.req;
const res = request.raw.res;
const originalEnd = res.end;
const options: PluginTypes.RootSpanOptions = {
name: req.url ? (urlParse(req.url).pathname || '') : '',
url: req.url,
traceContext: getFirstHeader(req, api.constants.TRACE_CONTEXT_HEADER_NAME),
skipFrames: 4
};
return api.runInRootSpan(options, (root) => {
// Set response trace context.
const responseTraceContext = api.getResponseTraceContext(
options.traceContext || null, api.isRealSpan(root));
if (responseTraceContext) {
res.setHeader(
api.constants.TRACE_CONTEXT_HEADER_NAME, responseTraceContext);
}

if (!api.isRealSpan(root)) {
return continueCb();
}

api.wrapEmitter(req);
api.wrapEmitter(res);

const url = `${req.headers['X-Forwarded-Proto'] || 'http'}://${
req.headers.host}${req.url}`;

// we use the path part of the url as the span name and add the full
// url as a label
// req.path would be more desirable but is not set at the time our
// middleware runs.
root.addLabel(api.labels.HTTP_METHOD_LABEL_KEY, req.method);
root.addLabel(api.labels.HTTP_URL_LABEL_KEY, url);
root.addLabel(api.labels.HTTP_SOURCE_IP, req.connection.remoteAddress);

// wrap end
res.end = function(this: ServerResponse) {
res.end = originalEnd;
const returned = res.end.apply(this, arguments);

if (request.route && request.route.path) {
root.addLabel('hapi/request.route.path', request.route.path);
}
root.addLabel(api.labels.HTTP_RESPONSE_CODE_LABEL_KEY, res.statusCode);
root.endSpan();

api.wrapEmitter(req);
api.wrapEmitter(res);

const url = `${req.headers['X-Forwarded-Proto'] || 'http'}://${
req.headers.host}${req.url}`;

// we use the path part of the url as the span name and add the full
// url as a label
// req.path would be more desirable but is not set at the time our
// middleware runs.
root.addLabel(api.labels.HTTP_METHOD_LABEL_KEY, req.method);
root.addLabel(api.labels.HTTP_URL_LABEL_KEY, url);
root.addLabel(api.labels.HTTP_SOURCE_IP, req.connection.remoteAddress);

// wrap end
res.end = function(this: ServerResponse) {
res.end = originalEnd;
const returned = res.end.apply(this, arguments);

if (request.route && request.route.path) {
root.addLabel('hapi/request.route.path', request.route.path);
}
root.addLabel(api.labels.HTTP_RESPONSE_CODE_LABEL_KEY, res.statusCode);
root.endSpan();

return returned;
};

// if the event is aborted, end the span (as res.end will not be called)
req.once('aborted', () => {
root.addLabel(api.labels.ERROR_DETAILS_NAME, 'aborted');
root.addLabel(
api.labels.ERROR_DETAILS_MESSAGE, 'client aborted the request');
root.endSpan();
});
return returned;
};

return reply.continue();
// if the event is aborted, end the span (as res.end will not be called)
req.once('aborted', () => {
root.addLabel(api.labels.ERROR_DETAILS_NAME, 'aborted');
root.addLabel(
api.labels.ERROR_DETAILS_MESSAGE, 'client aborted the request');
root.endSpan();
});
};

return continueCb();
});
}

const plugin: PluginTypes.Plugin = [{
file: '',
versions: SUPPORTED_VERSIONS,
patch: (hapi, api) => {
shimmer.wrap(hapi.Server.prototype, 'connection', (connection) => {
return function connectionTrace(this: {}) {
const server: hapi_16.Server = connection.apply(this, arguments);
server.ext('onRequest', createMiddleware(api));
return server;
};
});
},
unpatch: (hapi) => {
shimmer.unwrap(hapi.Server.prototype, 'connection');
}
} as PluginTypes.Patch<Hapi16Module>];
const plugin: PluginTypes.Plugin = [
{
versions: '8 - 16',
patch(hapi, api) {
function createMiddleware(): hapi_16.ServerExtRequestHandler {
return function handler(request, reply) {
return instrument(api, request, () => reply.continue());
};
}

shimmer.wrap(hapi.Server.prototype, 'connection', (connection) => {
return function connectionTrace(this: hapi_16.Server) {
const server = connection.apply(this, arguments);
server.ext('onRequest', createMiddleware());
return server;
};
});
},
unpatch(hapi) {
shimmer.unwrap(hapi.Server.prototype, 'connection');
}
} as PluginTypes.Patch<Hapi16Module>,
/**
* In Hapi 17, the work that is done on behalf of a request stems from
* Request#_execute. We patch that function to ensure that context is
* available in every handler.
*/
{
versions: '17',
file: 'lib/request.js',
// Request is a class name.
// tslint:disable-next-line:variable-name
patch: (Request, api) => {
// TODO(kjin): shimmer cannot wrap AsyncFunction objects.
// Once shimmer introduces this functionality, change this code to use it.
const origExecute = Request.prototype._execute;
Request.prototype._execute =
Object.assign(function _executeWrap(this: hapi_17.Request) {
return instrument(api, this, () => {
return origExecute.apply(this, arguments);
});
}, {[ORIGINAL]: origExecute});
},
// Request is a class name.
// tslint:disable-next-line:variable-name
unpatch: (Request) => {
if (Request.prototype._execute[ORIGINAL]) {
Request.prototype._execute = Request.prototype._execute[ORIGINAL]!;
}
}
} as PluginTypes.Patch<{prototype: Hapi17Request}>
];
export = plugin;
2 changes: 2 additions & 0 deletions src/plugins/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import * as connect_3 from './connect_3'; // connect@3
import * as express_4 from './express_4'; // express@4
import * as hapi_16 from './hapi_16'; // hapi@16
import * as hapi_17 from './hapi_17'; // hapi@17
import * as koa_2 from './koa_2'; // koa@2
import * as pg_7 from './pg_7'; // pg@7
import * as restify_5 from './restify_5'; // restify@5
Expand Down Expand Up @@ -88,6 +89,7 @@ export {
connect_3,
express_4,
hapi_16,
hapi_17,
koa_1,
koa_2,
pg_6,
Expand Down
28 changes: 4 additions & 24 deletions test/fixtures/plugin-fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,31 +39,11 @@
"hapi-plugin-mysql": "^3.1.3"
}
},
"hapi10": {
"dependencies": {
"hapi": "^10.5.0"
}
},
"hapi11": {
"dependencies": {
"hapi": "^11.1.4"
}
},
"hapi12": {
"dependencies": {
"hapi": "^12.1.0"
}
},
"hapi13": {
"dependencies": {
"hapi": "^13.2.1"
}
},
"hapi14": {
"dependencies": {
"hapi": "^14.0.0"
}
},
"hapi15": {
"dependencies": {
"hapi": "^15.0.0"
Expand All @@ -74,14 +54,14 @@
"hapi": "^16.0.0"
}
},
"hapi8": {
"hapi17": {
"dependencies": {
"hapi": "^8.8.1"
"hapi": "^17.0.0"
}
},
"hapi9": {
"hapi8": {
"dependencies": {
"hapi": "^9.5.1"
"hapi": "^8.8.1"
}
},
"knex0.10": {
Expand Down
2 changes: 1 addition & 1 deletion test/test-mysql-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ if (semver.satisfies(process.version, '>=4')) {
samplingRate: 0,
enhancedDatabaseReporting: true
});
Hapi = require('./plugins/fixtures/hapi13');
Hapi = require('./plugins/fixtures/hapi16');
});

it('should work with connection pool access', function(done) {
Expand Down
22 changes: 12 additions & 10 deletions test/test-trace-web-frameworks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import {assertSpanDuration, DEFAULT_SPAN_DURATION, isServerSpan, wait} from './u
import {WebFramework, WebFrameworkConstructor} from './web-frameworks/base';
import {Connect3} from './web-frameworks/connect';
import {Express4} from './web-frameworks/express';
import {Hapi12, Hapi15, Hapi16, Hapi8} from './web-frameworks/hapi';
import {Hapi17} from './web-frameworks/hapi17';
import {Hapi12, Hapi15, Hapi16, Hapi8} from './web-frameworks/hapi8_16';
import {Koa1} from './web-frameworks/koa1';
import {Koa2} from './web-frameworks/koa2';
import {Restify3, Restify4, Restify5, Restify6} from './web-frameworks/restify';
Expand All @@ -44,8 +45,9 @@ type TraceSpanStackFrames = {
const ABORTED_SPAN_RETRIES = 3;
// The list of web frameworks to test.
const FRAMEWORKS: WebFrameworkConstructor[] = [
Connect3, Express4, Hapi8, Hapi12, Hapi15, Hapi16, Koa1, Koa2, Restify3,
Restify4, Restify5, Restify6
// Connect3, Express4, Hapi8, Hapi12, Hapi15, Hapi16, Hapi17, Koa1, Koa2,
// Restify3, Restify4, Restify5, Restify6
Hapi17
];

/**
Expand Down Expand Up @@ -146,7 +148,7 @@ describe('Web framework tracing', () => {
it('accurately measures get time (1 handler)', async () => {
let recordedTime = 0;
await trace.get().runInRootSpan({name: 'outer'}, async (span) => {
assert.ok(span);
assert.ok(trace.get().isRealSpan(span));
recordedTime = Date.now();
await axios.get(`http://localhost:${port}/one-handler`);
recordedTime = Date.now() - recordedTime;
Expand All @@ -160,7 +162,7 @@ describe('Web framework tracing', () => {
it('accurately measures get time (2 handlers)', async () => {
let recordedTime = 0;
await trace.get().runInRootSpan({name: 'outer'}, async (span) => {
assert.ok(span);
assert.ok(trace.get().isRealSpan(span));
recordedTime = Date.now();
// Hit endpoint with two middlewares/handlers.
await axios.get(`http://localhost:${port}/two-handlers`);
Expand All @@ -174,7 +176,7 @@ describe('Web framework tracing', () => {

it('handles errors', async () => {
await trace.get().runInRootSpan({name: 'outer'}, async (span) => {
assert.ok(span);
assert.ok(trace.get().isRealSpan(span));
// Hit endpoint which always throws an error.
await axios.get(`http://localhost:${port}/error`, {
validateStatus: () => true // Obviates try/catch.
Expand All @@ -189,7 +191,7 @@ describe('Web framework tracing', () => {

it('doesn\'t trace ignored urls', async () => {
await trace.get().runInRootSpan({name: 'outer'}, async (span) => {
assert.ok(span);
assert.ok(trace.get().isRealSpan(span));
// Hit endpoint that always gets ignored.
await axios.get(`http://localhost:${port}/ignore-me`);
span!.endSpan();
Expand All @@ -200,7 +202,7 @@ describe('Web framework tracing', () => {

it('ends span upon client abort', async () => {
await trace.get().runInRootSpan({name: 'outer'}, async (span) => {
assert.ok(span);
assert.ok(trace.get().isRealSpan(span));
// Hit endpoint, but time out before it has a chance to respond.
// To ensure that a trace is written, also waits
await axios
Expand Down Expand Up @@ -250,7 +252,7 @@ describe('Web framework tracing', () => {

it('propagates trace context', async () => {
await trace.get().runInRootSpan({name: 'outer'}, async (span) => {
assert.ok(span);
assert.ok(trace.get().isRealSpan(span));
// Hits endpoint that will make an additional outgoing HTTP request
// (to another endpoint on the same server).
await axios.get(`http://localhost:${port}/propagate-hello`);
Expand Down Expand Up @@ -290,7 +292,7 @@ describe('Web framework tracing', () => {

beforeEach(async () => {
await trace.get().runInRootSpan({name: 'outer'}, async (span) => {
assert.ok(span);
assert.ok(trace.get().isRealSpan(span));
// Hit an endpoint with a query parameter.
await axios.get(`http://localhost:${port}/hello?this-is=dog`);
span!.endSpan();
Expand Down
Loading

0 comments on commit 9e4987d

Please sign in to comment.