diff --git a/API.md b/API.md index ff749d6c0..da9942beb 100755 --- a/API.md +++ b/API.md @@ -5030,3 +5030,104 @@ const plugin = { } }; ``` + +## Diagnostic Channels + +Diagnostic Channels allows Hapi to report events to other modules. This is useful for APM vendors to collect data from Hapi for diagnostics purposes. +This not intended to be used in your own server or plugin, use [extension methods](#server.ext()) or [events](#server.events) instead. + +See [the official documentation](https://nodejs.org/docs/latest/api/diagnostics_channel.html) for more information. + +### `hapi.onServer` + +This event is sent when a new server is created. The [`server`](#server) object is passed as the message. + +```js +const DC = require('diagnostics_channel'); +const channel = DC.channel('hapi.onServer'); + +channel.subscribe((server) => { + + // Do something with the server + console.log(server.version); +}); +``` + +### `hapi.onRoute` + +This event is sent when a new route is added to the server. The [`route`](#request.route) object is passed as the message. +Similar to `server.events.on('route', (route) => {})`. + +```js +const DC = require('diagnostics_channel'); +const channel = DC.channel('hapi.onRoute'); + +channel.subscribe((route) => { + + // Do something with the route + console.log(route.path); +}); +``` + +### `hapi.onRequest` + +This event is sent when a request is generated by the framework. The [`request`](#request) object is passed as the message. +Similar to `server.events.on('request', (request) => {})`. + +```js +const DC = require('diagnostics_channel'); +const channel = DC.channel('hapi.onRequest'); + +channel.subscribe((request) => { + + // Do something with the request + console.log(request.info.id); +}); +``` + +### `hapi.onResponse` + +This event is sent after the response is sent back to the client. The [`response`](#request.response) object is passed as the message. +Similar to `server.events.on('response', ({ response }) => {})`. + +```js +const DC = require('diagnostics_channel'); +const channel = DC.channel('hapi.onResponse'); + +channel.subscribe((response) => { + + // Do something with the response + console.log(response.statusCode); +}); +``` + +### `hapi.onRequestLifecycle` + +This event is sent after the response is matched to a route. The [`request`](#request) object is passed as the message. + +```js +const DC = require('diagnostics_channel'); +const channel = DC.channel('hapi.onRequestLifecycle'); + +channel.subscribe((request) => { + + // Do something with the request + console.log(request.route); +}); +``` + +### `hapi.onError` + +This event is sent when a request responded with a 500 status code. An object containing the [`request`](#request) object and the error is passed as the message. +Similar to `server.events.on({ name: 'request', channels: 'error' }, (request, { error }) => {})`. + +```js +const DC = require('diagnostics_channel'); +const channel = DC.channel('hapi.onError'); + +channel.subscribe(({ request, error }) => { + + // Do something with the request or the error + console.error(error); +}); +``` diff --git a/lib/channels.js b/lib/channels.js new file mode 100644 index 000000000..05c80e343 --- /dev/null +++ b/lib/channels.js @@ -0,0 +1,15 @@ +'use strict'; + +const DC = require('diagnostics_channel'); + +exports.server = DC.channel('hapi.onServer'); + +exports.route = DC.channel('hapi.onRoute'); + +exports.response = DC.channel('hapi.onResponse'); + +exports.request = DC.channel('hapi.onRequest'); + +exports.requestLifecycle = DC.channel('hapi.onRequestLifecycle'); + +exports.error = DC.channel('hapi.onError'); diff --git a/lib/core.js b/lib/core.js index 6ab2f9bdd..ca6c3e609 100755 --- a/lib/core.js +++ b/lib/core.js @@ -17,6 +17,7 @@ const Podium = require('@hapi/podium'); const Statehood = require('@hapi/statehood'); const Auth = require('./auth'); +const Channels = require('./channels'); const Compression = require('./compression'); const Config = require('./config'); const Cors = require('./cors'); @@ -510,6 +511,8 @@ exports = module.exports = internals.Core = class { const request = Request.generate(this.root, req, res, options); + Channels.request.publish(request); + // Track socket request processing state if (this.settings.operations.cleanStop && diff --git a/lib/request.js b/lib/request.js index 6efaa1511..52ebebe3d 100755 --- a/lib/request.js +++ b/lib/request.js @@ -8,6 +8,7 @@ const Bounce = require('@hapi/bounce'); const Hoek = require('@hapi/hoek'); const Podium = require('@hapi/podium'); +const Channels = require('./channels'); const Cors = require('./cors'); const Toolkit = require('./toolkit'); const Transmit = require('./transmit'); @@ -361,6 +362,8 @@ exports = module.exports = internals.Request = class { async _lifecycle() { + Channels.requestLifecycle.publish(this); + for (const func of this._route._cycle) { if (this._isReplied) { return; @@ -497,6 +500,8 @@ exports = module.exports = internals.Request = class { if (this.response.statusCode === 500 && this.response._error) { + Channels.error.publish({ request: this, error: this.response._error }); + const tags = this.response._error.isDeveloperError ? ['internal', 'implementation', 'error'] : ['internal', 'error']; this._log(tags, this.response._error, 'error'); } @@ -508,6 +513,8 @@ exports = module.exports = internals.Request = class { this.info.completed = Date.now(); + Channels.response.publish(this.response); + this._core.events.emit('response', this); if (this._route._extensions.onPostResponse.nodes) { diff --git a/lib/server.js b/lib/server.js index d2a5d5bf7..37571d79d 100755 --- a/lib/server.js +++ b/lib/server.js @@ -4,6 +4,7 @@ const Hoek = require('@hapi/hoek'); const Shot = require('@hapi/shot'); const Teamwork = require('@hapi/teamwork'); +const Channels = require('./channels'); const Config = require('./config'); const Core = require('./core'); const Cors = require('./cors'); @@ -83,6 +84,8 @@ internals.Server = class { } core.registerServer(this); + + Channels.server.publish(this); } _clone(name) { @@ -531,7 +534,10 @@ internals.Server = class { route.params = record.params; } + Channels.route.publish(route.public); + this.events.emit('route', route.public); + Cors.options(route.public, server); } diff --git a/test/channels.js b/test/channels.js new file mode 100644 index 000000000..fd47ad483 --- /dev/null +++ b/test/channels.js @@ -0,0 +1,197 @@ +'use strict'; + +const DC = require('diagnostics_channel'); + +const Code = require('@hapi/code'); +const Lab = require('@hapi/lab'); +const Hoek = require('@hapi/hoek'); + +const Hapi = require('..'); + +const { describe, it } = exports.lab = Lab.script(); +const expect = Code.expect; + +describe('DiagnosticChannel', () => { + + describe('hapi.onServer', () => { + + const channel = DC.channel('hapi.onServer'); + + it('server should be exposed on creation through the channel hapi.onServer', async () => { + + let server; + + const exposedServer = await new Promise((resolve) => { + + channel.subscribe(resolve); + + server = Hapi.server(); + }); + + expect(exposedServer).to.shallow.equal(server); + }); + }); + + describe('hapi.onRoute', () => { + + const channel = DC.channel('hapi.onRoute'); + + it('route should be exposed on creation through the channel hapi.onRoute', async () => { + + const server = Hapi.server(); + + const exposedRoute = await new Promise((resolve) => { + + channel.subscribe(resolve); + + server.route({ + method: 'GET', + path: '/', + options: { app: { x: 'o' } }, + handler: () => 'ok' + }); + }); + + expect(exposedRoute).to.be.an.object(); + expect(exposedRoute.settings.app.x).to.equal('o'); + }); + }); + + describe('hapi.onResponse', () => { + + const channel = DC.channel('hapi.onResponse'); + + it('response should be exposed on creation through the channel hapi.onResponse', async () => { + + const server = Hapi.server(); + + server.route({ method: 'GET', path: '/', handler: () => 'ok' }); + + const event = new Promise((resolve) => { + + channel.subscribe(resolve); + }); + + const response = await server.inject('/'); + const responseExposed = await event; + expect(response.request.response).to.shallow.equal(responseExposed); + }); + }); + + describe('hapi.onRequest', () => { + + const channel = DC.channel('hapi.onRequest'); + + it('request should be exposed on creation through the channel hapi.onRequest', async () => { + + const server = Hapi.server(); + + server.route({ method: 'GET', path: '/', handler: () => 'ok' }); + + const event = new Promise((resolve) => { + + channel.subscribe(resolve); + }); + + const response = await server.inject('/'); + const requestExposed = await event; + expect(response.request).to.shallow.equal(requestExposed); + }); + + it('request should not have been routed when hapi.onRequest is triggered', async () => { + + const server = Hapi.server(); + + server.route({ method: 'GET', path: '/test/{p}', handler: () => 'ok' }); + + server.ext('onRequest', async (request, h) => { + + await Hoek.wait(10); + return h.continue; + }); + + const event = new Promise((resolve) => { + + channel.subscribe(resolve); + }); + + const request = server.inject('/test/foo'); + const requestExposed = await event; + expect(requestExposed.params).to.be.null(); + + const response = await request; + expect(response.request).to.shallow.equal(requestExposed); + }); + }); + + describe('hapi.onRequestLifecycle', () => { + + const channel = DC.channel('hapi.onRequestLifecycle'); + + it('request should be exposed after routing through the channel hapi.onRequestLifecycle', async () => { + + const server = Hapi.server(); + + server.route({ + method: 'POST', + path: '/test/{p}', + options: { app: { x: 'o' } }, + handler: () => 'ok' + }); + + server.ext('onPreAuth', async (request, h) => { + + await Hoek.wait(10); + return h.continue; + }); + + const event = new Promise((resolve) => { + + channel.subscribe(resolve); + }); + + const request = server.inject({ method: 'POST', url: '/test/foo', payload: '{"a":"b"}' }); + const requestExposed = await event; + expect(requestExposed.params).to.be.an.object(); + expect(requestExposed.params.p).to.equal('foo'); + expect(requestExposed.route).to.be.an.object(); + expect(requestExposed.route.settings.app.x).to.equal('o'); + expect(requestExposed.payload).to.be.undefined(); + + const response = await request; + expect(response.request).to.shallow.equal(requestExposed); + expect(response.request.payload).to.be.an.object(); + expect(response.request.payload.a).to.equal('b'); + }); + }); + + describe('hapi.onError', () => { + + const channel = DC.channel('hapi.onError'); + + it('should expose the request as well as the error object when an error happens during the request lifetime', async () => { + + const server = Hapi.server(); + + server.route({ + method: 'GET', + path: '/', + handler: () => { + + throw new Error('some error message'); + } + }); + + const event = new Promise((resolve) => { + + channel.subscribe(resolve); + }); + + const response = await server.inject('/'); + const { request: requestExposed, error: errorExposed } = await event; + expect(response.request).to.shallow.equal(requestExposed); + expect(errorExposed).to.be.an.instanceof(Error); + expect(errorExposed.message).to.equal('some error message'); + }); + }); +});