diff --git a/jest.config.js b/jest.config.js index 2c0510542a..fffb587f36 100644 --- a/jest.config.js +++ b/jest.config.js @@ -81,6 +81,7 @@ module.exports = { 'delivery-node', 'in-flight', 'plugin-aws-lambda', + 'plugin-azure-functions', 'plugin-express', 'plugin-koa', 'plugin-restify', diff --git a/packages/plugin-azure-functions/README.md b/packages/plugin-azure-functions/README.md new file mode 100644 index 0000000000..4aca649a50 --- /dev/null +++ b/packages/plugin-azure-functions/README.md @@ -0,0 +1,65 @@ +# @bugsnag/plugin-azure-functions + +A [@bugsnag/js](https://github.com/bugsnag/bugsnag-js) plugin for capturing errors in Azure Functions. + +# Install + +```` +yarn add @bugsnag/plugin-azure-functions +# or +npm install --save @bugsnag/plugin-azure-functions +```` + +# Usage + +To start Bugsnag with the Azure Functions integration, pass the plugin to Bugsnag.start: + +```` +const Bugsnag = require('@bugsnag/js') +const BugsnagPluginAzureFunctions = require('@bugsnag/plugin-azure-functions') + +Bugsnag.start({ + plugins: [BugsnagPluginAzureFunctions], +}) +```` + +Start handling errors in your Azure function by wrapping your handler with Bugsnag’s handler: + +```` +const bugsnagHandler = Bugsnag.getPlugin('azureFunctions').createHandler() + +const handler = async (context, req) => { + return { + status: 200, + body: JSON.stringify({ message: 'Hello, World!' }) + } +} + +module.exports = bugsnagHandler(handler) +```` + +# Automatically captured data + +Bugsnag will automatically capture the Azure function's `context` in the "Azure Functions context" tab on every error. + +# Configuration + +The Bugsnag Azure Functions plugin can be configured by passing options to createHandler. + +**flushTimeoutMs** + +Bugsnag will wait for events and sessions to be delivered before allowing the Azure function to exit. This option can be used to control the maximum amount of time to wait before timing out. + +By default, Bugsnag will timeout after 2000 milliseconds. + +```` +const bugsnagHandler = Bugsnag.getPlugin('azureFunctions').createHandler({ + flushTimeoutMs: 5000 +}) +```` + +If a timeout does occur, Bugsnag will log a warning and events & sessions may not be delivered. + +## License + +This package is free software released under the MIT License. \ No newline at end of file diff --git a/packages/plugin-azure-functions/package.json b/packages/plugin-azure-functions/package.json new file mode 100644 index 0000000000..d1f0a023a2 --- /dev/null +++ b/packages/plugin-azure-functions/package.json @@ -0,0 +1,36 @@ +{ + "name": "@bugsnag/plugin-azure-functions", + "version": "7.11.0", + "main": "dist/bugsnag-azure-functions.js", + "types": "types/bugsnag-plugin-azure-functions.d.ts", + "description": "Azure Functions support for @bugsnag/node", + "homepage": "https://www.bugsnag.com/", + "repository": { + "type": "git", + "url": "git@github.com:bugsnag/bugsnag-js.git" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "clean": "rm -fr dist && mkdir dist", + "build": "npm run clean && ../../bin/bundle src/index.js --node --standalone=BugsnagPluginAzureFunctions | ../../bin/extract-source-map dist/bugsnag-azure-functions.js", + "postversion": "npm run build" + }, + "author": "Dan Polivy", + "license": "MIT", + "dependencies": { + "@bugsnag/in-flight": "^7.11.0", + "@bugsnag/plugin-browser-session": "^7.11.0" + }, + "devDependencies": { + "@bugsnag/core": "^7.11.0" + }, + "peerDependencies": { + "@bugsnag/core": "^7.0.0" + } +} diff --git a/packages/plugin-azure-functions/src/index.js b/packages/plugin-azure-functions/src/index.js new file mode 100644 index 0000000000..74bead7358 --- /dev/null +++ b/packages/plugin-azure-functions/src/index.js @@ -0,0 +1,68 @@ +const bugsnagInFlight = require('@bugsnag/in-flight') +const clone = require('@bugsnag/core/lib/clone-client') + +const BugsnagPluginAzureFunctions = { + name: 'azureFunctions', + + load (client) { + bugsnagInFlight.trackInFlight(client) + + return { + createHandler ({ flushTimeoutMs = 2000 } = {}) { + return wrapHandler.bind(null, client, flushTimeoutMs) + } + } + } +} + +// Function which takes in the Azure Function handler and wraps it with +// a new handler that automatically captures unhandled errors +function wrapHandler (client, flushTimeoutMs, handler) { + // Reset the app duration between invocations, if the plugin is loaded + const appDurationPlugin = client.getPlugin('appDuration') + + return async function (context, ...args) { + // Get a client to be scoped to this function invocation. If sessions are enabled, use the + // resumeSession() call to get a session client, otherwise, clone the existing client. + const functionClient = client._config.autoTrackSessions ? client.resumeSession() : clone(client) + + if (appDurationPlugin) { + appDurationPlugin.reset() + } + + // Attach the client to the context + context.bugsnag = functionClient + + // Add global metadata attach the context + functionClient.addOnError(event => { + event.addMetadata('Azure Function context', context) + event.clearMetadata('Azure Function context', 'bugsnag') + }) + + try { + return await handler(context, ...args) + } catch (err) { + if (client._config.autoDetectErrors && client._config.enabledErrorTypes.unhandledExceptions) { + const handledState = { + severity: 'error', + unhandled: true, + severityReason: { type: 'unhandledException' } + } + + const event = functionClient.Event.create(err, true, handledState, 'azure functions plugin', 1) + + functionClient._notify(event) + } + + throw err + } finally { + try { + await bugsnagInFlight.flush(flushTimeoutMs) + } catch (err) { + functionClient._logger.error(`Delivery may be unsuccessful: ${err.message}`) + } + } + } +} + +module.exports = BugsnagPluginAzureFunctions diff --git a/packages/plugin-azure-functions/test/index.test.ts b/packages/plugin-azure-functions/test/index.test.ts new file mode 100644 index 0000000000..317ca985ac --- /dev/null +++ b/packages/plugin-azure-functions/test/index.test.ts @@ -0,0 +1,269 @@ +import util from 'util' +import BugsnagPluginAzureFunctions from '../src/' +import Client, { EventDeliveryPayload, SessionDeliveryPayload } from '@bugsnag/core/client' + +const createClient = (events: EventDeliveryPayload[], sessions: SessionDeliveryPayload[], config = {}) => { + const client = new Client({ apiKey: 'AN_API_KEY', plugins: [BugsnagPluginAzureFunctions], ...config }) + + // @ts-ignore the following property is not defined on the public Event interface + client.Event.__type = 'nodejs' + + // a flush failure won't throw as we don't want to crash apps if delivery takes + // too long. To avoid the unit tests passing when this happens, we make the logger + // throw on any 'error' log call + client._logger.error = (...args) => { throw new Error(util.format(args)) } + + client._delivery = { + sendEvent (payload, cb = () => {}) { + events.push(payload) + cb() + }, + sendSession (payload, cb = () => {}) { + sessions.push(payload) + cb() + } + } + + return client +} + +describe('plugin: azure functions', () => { + it('has a name', () => { + expect(BugsnagPluginAzureFunctions.name).toBe('azureFunctions') + + const client = new Client({ apiKey: 'AN_API_KEY', plugins: [BugsnagPluginAzureFunctions] }) + const plugin = client.getPlugin('azureFunctions') + + expect(plugin).toBeTruthy() + }) + + it('exports a "createHandler" function', () => { + const client = new Client({ apiKey: 'AN_API_KEY', plugins: [BugsnagPluginAzureFunctions] }) + const plugin = client.getPlugin('azureFunctions') + + expect(plugin).toMatchObject({ createHandler: expect.any(Function) }) + }) + + it('adds the context as metadata', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + + const client = createClient(events, sessions) + + const handler = async (context: any) => 'abc' + + const context = { extremely: 'contextual' } + + const plugin = client.getPlugin('azureFunctions') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + expect(await wrappedHandler(context)).toBe('abc') + + expect(client.getMetadata('Azure Function context')).toEqual(context) + }) + + it('logs an error if flush times out', async () => { + const client = new Client({ apiKey: 'AN_API_KEY', plugins: [BugsnagPluginAzureFunctions] }) + client._logger.error = jest.fn() + + client._delivery = { + sendEvent (payload, cb = () => {}) { + setTimeout(cb, 250) + }, + sendSession (payload, cb = () => {}) { + setTimeout(cb, 250) + } + } + + const handler = async () => { + client.notify('hello') + + return 'abc' + } + + const context = { extremely: 'contextual' } + + const timeoutError = new Error('flush timed out after 20ms') + + const plugin = client.getPlugin('azureFunctions') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler({ flushTimeoutMs: 20 }) + const wrappedHandler = bugsnagHandler(handler) + + expect(await wrappedHandler(context)).toBe('abc') + expect(client._logger.error).toHaveBeenCalledWith(`Delivery may be unsuccessful: ${timeoutError.message}`) + }) + + it('returns a wrapped handler that resolves to the original return value (async)', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + + const client = createClient(events, sessions) + + const handler = async () => 'abc' + + const context = { extremely: 'contextual' } + + const plugin = client.getPlugin('azureFunctions') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + expect(await handler()).toBe('abc') + expect(await wrappedHandler(context)).toBe('abc') + + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(1) + }) + + it('notifies when an error is thrown (async)', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + + const client = createClient(events, sessions) + + const error = new Error('oh no') + const handler = async (context: any) => { throw error } + + const context = { extremely: 'contextual' } + + const plugin = client.getPlugin('azureFunctions') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(0) + + await expect(() => wrappedHandler(context)).rejects.toThrow(error) + + expect(events).toHaveLength(1) + expect(events[0].events[0].errors[0].errorMessage).toBe(error.message) + + expect(sessions).toHaveLength(1) + }) + + it('does not notify when "autoDetectErrors" is false (async)', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + + const client = createClient(events, sessions, { autoDetectErrors: false }) + + const error = new Error('oh no') + const handler = async (context: any) => { throw error } + + const context = { extremely: 'contextual' } + + const plugin = client.getPlugin('azureFunctions') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(0) + + await expect(() => wrappedHandler(context)).rejects.toThrow(error) + + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(1) + }) + + it('does not notify when "unhandledExceptions" are disabled (async)', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + + const client = createClient(events, sessions, { enabledErrorTypes: { unhandledExceptions: false } }) + + const error = new Error('oh no') + const handler = async (context: any) => { throw error } + + const context = { extremely: 'contextual' } + + const plugin = client.getPlugin('azureFunctions') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(0) + + await expect(() => wrappedHandler(context)).rejects.toThrow(error) + + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(1) + }) + + it('will track sessions when "autoTrackSessions" is enabled', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + const client = createClient(events, sessions, { autoTrackSessions: true }) + + const handler = async () => 'abc' + + const context = { extremely: 'contextual' } + + const plugin = client.getPlugin('azureFunctions') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + expect(await wrappedHandler(context)).toBe('abc') + + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(1) + }) + + it('will not track sessions when "autoTrackSessions" is disabled', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + const client = createClient(events, sessions, { autoTrackSessions: false }) + + const handler = async () => 'abc' + + const context = { extremely: 'contextual' } + + const plugin = client.getPlugin('azureFunctions') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + expect(await wrappedHandler(context)).toBe('abc') + + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(0) + }) +}) diff --git a/packages/plugin-azure-functions/types/bugsnag-plugin-azure-functions.d.ts b/packages/plugin-azure-functions/types/bugsnag-plugin-azure-functions.d.ts new file mode 100644 index 0000000000..377263e148 --- /dev/null +++ b/packages/plugin-azure-functions/types/bugsnag-plugin-azure-functions.d.ts @@ -0,0 +1,23 @@ +import { Plugin, Client } from '@bugsnag/core' + +declare const BugsnagPluginAzureFunctions: Plugin +export default BugsnagPluginAzureFunctions + +type AsyncHandler = (context: any, ...args: any[]) => Promise + +export type BugsnagPluginAzureFunctionsHandler = (handler: AsyncHandler) => AsyncHandler + +export interface BugsnagPluginAzureFunctionsConfiguration { + flushTimeoutMs?: number +} + +export interface BugsnagPluginAzureFunctionsResult { + createHandler(configuration?: BugsnagPluginAzureFunctionsConfiguration): BugsnagPluginAzureFunctionsHandler +} + +// add a new call signature for the getPlugin() method that types the plugin result +declare module '@bugsnag/core' { + interface Client { + getPlugin(id: 'azureFunctions'): BugsnagPluginAzureFunctionsResult | undefined + } +} diff --git a/tsconfig.json b/tsconfig.json index 8c5176a8df..586dd95722 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -73,6 +73,7 @@ "packages/in-flight", "packages/plugin-app-duration", "packages/plugin-aws-lambda", + "packages/plugin-azure-functions", "packages/plugin-browser-context", "packages/plugin-browser-device", "packages/plugin-contextualize",