From 209e7139409574156092497587eda079c8db28b9 Mon Sep 17 00:00:00 2001 From: Yousif Ahmed Date: Fri, 14 Nov 2025 15:36:36 +0000 Subject: [PATCH 1/5] feat(plugin-cloudflare-workers): add basic cloudflare workers plugin --- jest.config.js | 3 +- .../plugin-cloudflare-workers/LICENSE.txt | 19 + packages/plugin-cloudflare-workers/README.md | 7 + .../plugin-cloudflare-workers/package.json | 31 ++ .../plugin-cloudflare-workers/src/index.js | 99 ++++++ .../test/index.test.ts | 334 ++++++++++++++++++ .../bugsnag-plugin-cloudflare-workers.d.ts | 27 ++ 7 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-cloudflare-workers/LICENSE.txt create mode 100644 packages/plugin-cloudflare-workers/README.md create mode 100644 packages/plugin-cloudflare-workers/package.json create mode 100644 packages/plugin-cloudflare-workers/src/index.js create mode 100644 packages/plugin-cloudflare-workers/test/index.test.ts create mode 100644 packages/plugin-cloudflare-workers/types/bugsnag-plugin-cloudflare-workers.d.ts diff --git a/jest.config.js b/jest.config.js index 19f82304ad..0894377088 100644 --- a/jest.config.js +++ b/jest.config.js @@ -90,7 +90,8 @@ module.exports = { 'plugin-node-in-project', 'plugin-node-device', 'plugin-node-surrounding-code', - 'plugin-node-uncaught-exception' + 'plugin-node-uncaught-exception', + 'plugin-cloudflare-workers' ], { testEnvironment: 'node' }), diff --git a/packages/plugin-cloudflare-workers/LICENSE.txt b/packages/plugin-cloudflare-workers/LICENSE.txt new file mode 100644 index 0000000000..ddc0631e24 --- /dev/null +++ b/packages/plugin-cloudflare-workers/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) Bugsnag, https://www.bugsnag.com/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/plugin-cloudflare-workers/README.md b/packages/plugin-cloudflare-workers/README.md new file mode 100644 index 0000000000..730a681641 --- /dev/null +++ b/packages/plugin-cloudflare-workers/README.md @@ -0,0 +1,7 @@ +# @bugsnag/plugin-cloudflare-workers + +A [@bugsnag/js](https://github.com/bugsnag/bugsnag-js) plugin for capturing errors in Cloudflare Workers. + +## License + +This package is free software released under the MIT License. See [LICENSE.txt](./LICENSE.txt) for details. diff --git a/packages/plugin-cloudflare-workers/package.json b/packages/plugin-cloudflare-workers/package.json new file mode 100644 index 0000000000..738ef18a2f --- /dev/null +++ b/packages/plugin-cloudflare-workers/package.json @@ -0,0 +1,31 @@ +{ + "name": "@bugsnag/plugin-cloudflare-workers", + "version": "8.6.0", + "main": "src/index.js", + "types": "types/bugsnag-plugin-cloudflare-workers.d.ts", + "description": "Cloudflare Workers support for @bugsnag/js", + "homepage": "https://www.bugsnag.com/", + "repository": { + "type": "git", + "url": "git@github.com:bugsnag/bugsnag-js.git" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "src", + "types" + ], + "author": "Bugsnag", + "license": "MIT", + "dependencies": { + "@bugsnag/in-flight": "^8.6.0", + "@bugsnag/plugin-browser-session": "^8.6.0" + }, + "devDependencies": { + "@bugsnag/core": "^8.6.0" + }, + "peerDependencies": { + "@bugsnag/core": "^8.0.0" + } +} \ No newline at end of file diff --git a/packages/plugin-cloudflare-workers/src/index.js b/packages/plugin-cloudflare-workers/src/index.js new file mode 100644 index 0000000000..40ec1f7055 --- /dev/null +++ b/packages/plugin-cloudflare-workers/src/index.js @@ -0,0 +1,99 @@ +const bugsnagInFlight = require('@bugsnag/in-flight') +const BugsnagPluginBrowserSession = require('@bugsnag/plugin-browser-session') + +const SERVER_PLUGIN_NAMES = ['express', 'koa', 'restify', 'hono'] +const isServerPluginLoaded = client => SERVER_PLUGIN_NAMES.some(name => client.getPlugin(name)) + +const BugsnagPluginCloudflareWorkers = { + name: 'cloudflareWorkers', + + load(client) { + bugsnagInFlight.trackInFlight(client) + client._loadPlugin(BugsnagPluginBrowserSession) + + // Reset the app duration between invocations, if the plugin is loaded + const appDurationPlugin = client.getPlugin('appDuration') + + if (appDurationPlugin) { + appDurationPlugin.reset() + } + + return { + createHandler({ flushTimeoutMs = 2000 } = {}) { + return wrapHandler.bind(null, client, flushTimeoutMs) + } + } + } +} + +function wrapHandler(client, flushTimeoutMs, handler) { + return async function (request, env, ctx) { + // Add request metadata + if (request) { + client.addMetadata('Cloudflare Workers request', { + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()) + }) + } + + // Add environment metadata if available + if (env) { + // Only include serializable properties from env + const envMetadata = {} + for (const key in env) { + const value = env[key] + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + envMetadata[key] = value + } + } + if (Object.keys(envMetadata).length > 0) { + client.addMetadata('Cloudflare Workers environment', envMetadata) + } + } + + // Track sessions if autoTrackSessions is enabled and no server plugin is loaded + if (client._config.autoTrackSessions && !isServerPluginLoaded(client)) { + client.startSession() + } + + try { + return await handler(request, env, ctx) + } catch (err) { + if (client._config.autoDetectErrors && client._config.enabledErrorTypes.unhandledExceptions) { + const handledState = { + severity: 'error', + unhandled: true, + severityReason: { type: 'unhandledException' } + } + + const event = client.Event.create(err, true, handledState, 'cloudflare workers plugin', 1) + + client._notify(event) + } + + throw err + } finally { + // Use ctx.waitUntil to ensure flush completes even after response is returned + // This is critical for Cloudflare Workers as they can terminate immediately + if (ctx && typeof ctx.waitUntil === 'function') { + ctx.waitUntil( + bugsnagInFlight.flush(flushTimeoutMs).catch(err => { + client._logger.error(`Delivery may be unsuccessful: ${err.message}`) + }) + ) + } else { + try { + await bugsnagInFlight.flush(flushTimeoutMs) + } catch (err) { + client._logger.error(`Delivery may be unsuccessful: ${err.message}`) + } + } + } + } +} + +module.exports = BugsnagPluginCloudflareWorkers + +// add a default export for ESM modules without interop +module.exports.default = module.exports diff --git a/packages/plugin-cloudflare-workers/test/index.test.ts b/packages/plugin-cloudflare-workers/test/index.test.ts new file mode 100644 index 0000000000..0f14544a82 --- /dev/null +++ b/packages/plugin-cloudflare-workers/test/index.test.ts @@ -0,0 +1,334 @@ +/** + * @jest-environment node + */ +import util from 'util' +import BugsnagPluginCloudflareWorkers from '../src/' +import Client, { EventDeliveryPayload, SessionDeliveryPayload } from '@bugsnag/core/client' + +// Mock Request and Response for Node.js environment +class MockRequest { + url: string + method: string + headers: Map + + constructor (url: string, init: { method?: string, headers?: Record } = {}) { + this.url = url + this.method = init.method || 'GET' + this.headers = new Map(Object.entries(init.headers || {})) + } +} + +class MockResponse { + private body: string + + constructor (body: string) { + this.body = body + } + + async text () { + return this.body + } +} + +// Mock ExecutionContext for Cloudflare Workers +const createMockExecutionContext = () => { + const promises: Promise[] = [] + return { + waitUntil: (promise: Promise) => { promises.push(promise) }, + passThroughOnException: () => {}, + // Helper for tests to wait for all promises registered with waitUntil + _waitForAllPromises: () => Promise.all(promises) + } +} + +// @ts-ignore +global.Request = MockRequest +// @ts-ignore +global.Response = MockResponse + +const createClient = (events: EventDeliveryPayload[], sessions: SessionDeliveryPayload[], config = {}) => { + const client = new Client({ apiKey: 'AN_API_KEY', plugins: [BugsnagPluginCloudflareWorkers], ...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: cloudflare workers', () => { + it('has a name', () => { + expect(BugsnagPluginCloudflareWorkers.name).toBe('cloudflareWorkers') + + const client = new Client({ apiKey: 'AN_API_KEY', plugins: [BugsnagPluginCloudflareWorkers] }) + const plugin = client.getPlugin('cloudflareWorkers') + + expect(plugin).toBeTruthy() + }) + + it('exports a "createHandler" function', () => { + const client = new Client({ apiKey: 'AN_API_KEY', plugins: [BugsnagPluginCloudflareWorkers] }) + const plugin = client.getPlugin('cloudflareWorkers') + + expect(plugin).toMatchObject({ createHandler: expect.any(Function) }) + }) + + it('adds the request as metadata', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + + const client = createClient(events, sessions) + + const handler = async (request: Request, env: any, ctx: any) => new Response('Hello World!') + + const request = new Request('https://example.com/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + const env = { TEST_VAR: 'test-value' } + const ctx = createMockExecutionContext() + + const plugin = client.getPlugin('cloudflareWorkers') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + const response = await wrappedHandler(request, env, ctx) + expect(await response.text()).toBe('Hello World!') + + const metadata = client.getMetadata('Cloudflare Workers request') + expect(metadata).toMatchObject({ + url: 'https://example.com/test', + method: 'POST' + }) + }) + + it('logs an error if flush times out', async () => { + const client = new Client({ apiKey: 'AN_API_KEY', plugins: [BugsnagPluginCloudflareWorkers] }) + 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 new Response('Hello World!') + } + + const request = new Request('https://example.com/test') + const env = {} + const ctx = createMockExecutionContext() + + const timeoutError = new Error('flush timed out after 20ms') + + const plugin = client.getPlugin('cloudflareWorkers') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler({ flushTimeoutMs: 20 }) + const wrappedHandler = bugsnagHandler(handler) + + const response = await wrappedHandler(request, env, ctx) + + // Wait for promises registered with ctx.waitUntil to complete + await ctx._waitForAllPromises().catch(() => {}) + + expect(await response.text()).toBe('Hello World!') + expect(client._logger.error).toHaveBeenCalledWith(`Delivery may be unsuccessful: ${timeoutError.message}`) + }) + + it('returns a wrapped handler that resolves to the original return value', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + + const client = createClient(events, sessions) + + const handler = async () => new Response('Hello World!') + + const request = new Request('https://example.com/test') + const env = {} + const ctx = createMockExecutionContext() + + const plugin = client.getPlugin('cloudflareWorkers') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + const response = await wrappedHandler(request, env, ctx) + expect(await response.text()).toBe('Hello World!') + + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(1) + }) + + it('notifies when an error is thrown', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + + const client = createClient(events, sessions) + + const err = new Error('badness') + const handler = async () => { + throw err + } + + const request = new Request('https://example.com/test') + const env = {} + const ctx = createMockExecutionContext() + + const plugin = client.getPlugin('cloudflareWorkers') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + await expect(wrappedHandler(request, env, ctx)).rejects.toThrow(err) + + // Wait for promises registered with ctx.waitUntil to complete + await ctx._waitForAllPromises() + + expect(events).toHaveLength(1) + + const event = events[0].events[0] + // @ts-ignore + expect(event.errors[0].errorMessage).toBe('badness') + expect(event.unhandled).toBe(true) + + expect(sessions).toHaveLength(1) + }) + + it('does not notify when autoDetectErrors=false', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + + const client = createClient(events, sessions, { autoDetectErrors: false }) + + const err = new Error('badness') + const handler = async () => { + throw err + } + + const request = new Request('https://example.com/test') + const env = {} + const ctx = createMockExecutionContext() + + const plugin = client.getPlugin('cloudflareWorkers') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + await expect(wrappedHandler(request, env, ctx)).rejects.toThrow(err) + + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(1) + }) + + it('does not notify when enabledErrorTypes.unhandledExceptions=false', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + + const client = createClient(events, sessions, { + enabledErrorTypes: { + unhandledExceptions: false, + unhandledRejections: true + } + }) + + const err = new Error('badness') + const handler = async () => { + throw err + } + + const request = new Request('https://example.com/test') + const env = {} + const ctx = createMockExecutionContext() + + const plugin = client.getPlugin('cloudflareWorkers') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + await expect(wrappedHandler(request, env, ctx)).rejects.toThrow(err) + + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(1) + }) + + it('captures environment metadata', async () => { + const events: EventDeliveryPayload[] = [] + const sessions: SessionDeliveryPayload[] = [] + + const client = createClient(events, sessions) + + const handler = async () => new Response('Hello World!') + + const request = new Request('https://example.com/test') + const env = { + API_KEY: 'secret-key', + NUMERIC_VAR: 123, + BOOLEAN_VAR: true + } + const ctx = createMockExecutionContext() + + const plugin = client.getPlugin('cloudflareWorkers') + + if (!plugin) { + throw new Error('Plugin was not loaded!') + } + + const bugsnagHandler = plugin.createHandler() + const wrappedHandler = bugsnagHandler(handler) + + await wrappedHandler(request, env, ctx) + + const metadata = client.getMetadata('Cloudflare Workers environment') + expect(metadata).toEqual({ + API_KEY: 'secret-key', + NUMERIC_VAR: 123, + BOOLEAN_VAR: true + }) + }) +}) diff --git a/packages/plugin-cloudflare-workers/types/bugsnag-plugin-cloudflare-workers.d.ts b/packages/plugin-cloudflare-workers/types/bugsnag-plugin-cloudflare-workers.d.ts new file mode 100644 index 0000000000..b5176fc624 --- /dev/null +++ b/packages/plugin-cloudflare-workers/types/bugsnag-plugin-cloudflare-workers.d.ts @@ -0,0 +1,27 @@ +import { Plugin, Client } from '@bugsnag/core' + +declare const BugsnagPluginCloudflareWorkers: Plugin +export default BugsnagPluginCloudflareWorkers + +interface ExecutionContext { + waitUntil(promise: Promise): void +} + +type CloudflareWorkersHandler = (request: Request, env: any, ctx: ExecutionContext) => Promise + +export type BugsnagPluginCloudflareWorkersHandler = (handler: CloudflareWorkersHandler) => CloudflareWorkersHandler + +export interface BugsnagPluginCloudflareWorkersConfiguration { + flushTimeoutMs?: number +} + +export interface BugsnagPluginCloudflareWorkersResult { + createHandler(configuration?: BugsnagPluginCloudflareWorkersConfiguration): BugsnagPluginCloudflareWorkersHandler +} + +// add a new call signature for the getPlugin() method that types the plugin result +declare module '@bugsnag/core' { + interface Client { + getPlugin(id: 'cloudflareWorkers'): BugsnagPluginCloudflareWorkersResult | undefined + } +} From e0e41e157ad0eab6972241ef86e11824e496926e Mon Sep 17 00:00:00 2001 From: Yousif Ahmed Date: Mon, 15 Dec 2025 17:14:03 +0000 Subject: [PATCH 2/5] refactor(plugin-cloudflare-workers): rework request metadata handling --- .../plugin-cloudflare-workers/src/index.js | 67 ++++++++++++------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/packages/plugin-cloudflare-workers/src/index.js b/packages/plugin-cloudflare-workers/src/index.js index 40ec1f7055..5cb02702e3 100644 --- a/packages/plugin-cloudflare-workers/src/index.js +++ b/packages/plugin-cloudflare-workers/src/index.js @@ -4,10 +4,41 @@ const BugsnagPluginBrowserSession = require('@bugsnag/plugin-browser-session') const SERVER_PLUGIN_NAMES = ['express', 'koa', 'restify', 'hono'] const isServerPluginLoaded = client => SERVER_PLUGIN_NAMES.some(name => client.getPlugin(name)) +const extractRequestInfo = (request) => { + if (!request) return {} + + const url = new URL(request.url) + + const info = { + url: request.url, + path: url.pathname, + httpMethod: request.method, + headers: Object.fromEntries(request.headers), + query: url.searchParams.size > 0 ? Object.fromEntries(url.searchParams) : undefined, + clientIp: request.headers.get('Cf-Connecting-IP') || request.headers.get('X-Forwarded-For') || undefined + } + + return info +} + +const getRequestAndMetadataFromReq = (request) => { + const requestInfo = extractRequestInfo(request) + + return { + metadata: requestInfo, + request: { + clientIp: requestInfo.clientIp, + headers: requestInfo.headers, + httpMethod: requestInfo.httpMethod, + url: requestInfo.url + } + } +} + const BugsnagPluginCloudflareWorkers = { name: 'cloudflareWorkers', - load(client) { + load (client) { bugsnagInFlight.trackInFlight(client) client._loadPlugin(BugsnagPluginBrowserSession) @@ -19,37 +50,23 @@ const BugsnagPluginCloudflareWorkers = { } return { - createHandler({ flushTimeoutMs = 2000 } = {}) { + createHandler ({ flushTimeoutMs = 2000 } = {}) { return wrapHandler.bind(null, client, flushTimeoutMs) } } } } -function wrapHandler(client, flushTimeoutMs, handler) { +function wrapHandler (client, flushTimeoutMs, handler) { return async function (request, env, ctx) { - // Add request metadata - if (request) { - client.addMetadata('Cloudflare Workers request', { - url: request.url, - method: request.method, - headers: Object.fromEntries(request.headers.entries()) - }) - } - - // Add environment metadata if available - if (env) { - // Only include serializable properties from env - const envMetadata = {} - for (const key in env) { - const value = env[key] - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - envMetadata[key] = value - } - } - if (Object.keys(envMetadata).length > 0) { - client.addMetadata('Cloudflare Workers environment', envMetadata) - } + // Add request metadata via onError callback so server plugins can override + // Only add metadata if no server plugin is loaded + if (!isServerPluginLoaded(client)) { + client.addOnError((event) => { + const { metadata, request: requestData } = getRequestAndMetadataFromReq(request) + event.request = { ...event.request, ...requestData } + event.addMetadata('request', metadata) + }, true) } // Track sessions if autoTrackSessions is enabled and no server plugin is loaded From 84a453878b65345944e7d4d1785d8a6b7a23a589 Mon Sep 17 00:00:00 2001 From: Yousif Ahmed Date: Mon, 15 Dec 2025 17:14:53 +0000 Subject: [PATCH 3/5] refactor(plugin-cloudflare-workers): rework type declarations --- .../types/bugsnag-plugin-cloudflare-workers.d.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/plugin-cloudflare-workers/types/bugsnag-plugin-cloudflare-workers.d.ts b/packages/plugin-cloudflare-workers/types/bugsnag-plugin-cloudflare-workers.d.ts index b5176fc624..dbeefe11d9 100644 --- a/packages/plugin-cloudflare-workers/types/bugsnag-plugin-cloudflare-workers.d.ts +++ b/packages/plugin-cloudflare-workers/types/bugsnag-plugin-cloudflare-workers.d.ts @@ -1,22 +1,11 @@ -import { Plugin, Client } from '@bugsnag/core' - +import { Plugin } from '@bugsnag/core' declare const BugsnagPluginCloudflareWorkers: Plugin export default BugsnagPluginCloudflareWorkers - -interface ExecutionContext { - waitUntil(promise: Promise): void -} - -type CloudflareWorkersHandler = (request: Request, env: any, ctx: ExecutionContext) => Promise - -export type BugsnagPluginCloudflareWorkersHandler = (handler: CloudflareWorkersHandler) => CloudflareWorkersHandler - export interface BugsnagPluginCloudflareWorkersConfiguration { flushTimeoutMs?: number } - export interface BugsnagPluginCloudflareWorkersResult { - createHandler(configuration?: BugsnagPluginCloudflareWorkersConfiguration): BugsnagPluginCloudflareWorkersHandler + createHandler(configuration?: BugsnagPluginCloudflareWorkersConfiguration): (handler: T) => T } // add a new call signature for the getPlugin() method that types the plugin result From a285fbde5b3996b69597ac4d481b98d90ecd176d Mon Sep 17 00:00:00 2001 From: Yousif Ahmed Date: Mon, 15 Dec 2025 17:15:20 +0000 Subject: [PATCH 4/5] test(plugin-cloudflare-workers): rework unit tests --- jest.config.js | 9 +- package-lock.json | 44 ++- .../plugin-cloudflare-workers/package.json | 5 +- .../test/index.test.ts | 262 ++++++++---------- .../plugin-cloudflare-workers/test/setup.ts | 1 + 5 files changed, 174 insertions(+), 147 deletions(-) create mode 100644 packages/plugin-cloudflare-workers/test/setup.ts diff --git a/jest.config.js b/jest.config.js index 0894377088..dcb9bbd721 100644 --- a/jest.config.js +++ b/jest.config.js @@ -90,8 +90,7 @@ module.exports = { 'plugin-node-in-project', 'plugin-node-device', 'plugin-node-surrounding-code', - 'plugin-node-uncaught-exception', - 'plugin-cloudflare-workers' + 'plugin-node-uncaught-exception' ], { testEnvironment: 'node' }), @@ -132,6 +131,10 @@ module.exports = { clearMocks: true, modulePathIgnorePatterns: ['.verdaccio', 'fixtures'] }), - project('react native cli', ['react-native-cli'], { testEnvironment: 'node' }) + project('react native cli', ['react-native-cli'], { testEnvironment: 'node' }), + project('cloudflare-workers', ['plugin-cloudflare-workers'], { + testEnvironment: 'node', + setupFilesAfterEnv: ['/packages/plugin-cloudflare-workers/test/setup.ts'] + }) ] } diff --git a/package-lock.json b/package-lock.json index f4dcf470d9..6328a177cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2716,6 +2716,10 @@ "resolved": "packages/plugin-client-ip", "link": true }, + "node_modules/@bugsnag/plugin-cloudflare-workers": { + "resolved": "packages/plugin-cloudflare-workers", + "link": true + }, "node_modules/@bugsnag/plugin-console-breadcrumbs": { "resolved": "packages/plugin-console-breadcrumbs", "link": true @@ -2941,6 +2945,13 @@ "resolved": "packages/web-worker", "link": true }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20251213.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251213.0.tgz", + "integrity": "sha512-PJAGdKfU7hs39C2YOFNLTdrfdqG6rbaVj5UuI306zS+TPokiskRLEgUXKqS6avN9Uu9Nyuf2a0hqoumLQCnJlQ==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, "node_modules/@cnakazawa/watch": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.3.tgz", @@ -52977,6 +52988,22 @@ "@bugsnag/core": "^8.0.0" } }, + "packages/plugin-cloudflare-workers": { + "name": "@bugsnag/plugin-cloudflare-workers", + "version": "8.6.0", + "license": "MIT", + "dependencies": { + "@bugsnag/in-flight": "^8.6.0", + "@bugsnag/plugin-browser-session": "^8.6.0" + }, + "devDependencies": { + "@bugsnag/core": "^8.6.0", + "@cloudflare/workers-types": "^4.20251213.0" + }, + "peerDependencies": { + "@bugsnag/core": "^8.0.0" + } + }, "packages/plugin-console-breadcrumbs": { "name": "@bugsnag/plugin-console-breadcrumbs", "version": "8.6.0", @@ -53002,7 +53029,6 @@ "packages/plugin-electron-app": { "name": "@bugsnag/plugin-electron-app", "version": "8.7.0", - "hasInstallScript": true, "license": "MIT", "dependencies": { "bindings": "^1.5.0" @@ -53044,7 +53070,6 @@ "packages/plugin-electron-client-state-persistence": { "name": "@bugsnag/plugin-electron-client-state-persistence", "version": "8.6.0", - "hasInstallScript": true, "license": "MIT", "dependencies": { "bindings": "^1.5.0" @@ -59724,6 +59749,15 @@ "@bugsnag/core": "^8.6.0" } }, + "@bugsnag/plugin-cloudflare-workers": { + "version": "file:packages/plugin-cloudflare-workers", + "requires": { + "@bugsnag/core": "^8.6.0", + "@bugsnag/in-flight": "^8.6.0", + "@bugsnag/plugin-browser-session": "^8.6.0", + "@cloudflare/workers-types": "^4.20251213.0" + } + }, "@bugsnag/plugin-console-breadcrumbs": { "version": "file:packages/plugin-console-breadcrumbs", "requires": { @@ -60260,6 +60294,12 @@ } } }, + "@cloudflare/workers-types": { + "version": "4.20251213.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251213.0.tgz", + "integrity": "sha512-PJAGdKfU7hs39C2YOFNLTdrfdqG6rbaVj5UuI306zS+TPokiskRLEgUXKqS6avN9Uu9Nyuf2a0hqoumLQCnJlQ==", + "dev": true + }, "@cnakazawa/watch": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.3.tgz", diff --git a/packages/plugin-cloudflare-workers/package.json b/packages/plugin-cloudflare-workers/package.json index 738ef18a2f..aab6da2065 100644 --- a/packages/plugin-cloudflare-workers/package.json +++ b/packages/plugin-cloudflare-workers/package.json @@ -23,9 +23,10 @@ "@bugsnag/plugin-browser-session": "^8.6.0" }, "devDependencies": { - "@bugsnag/core": "^8.6.0" + "@bugsnag/core": "^8.6.0", + "@cloudflare/workers-types": "^4.20251213.0" }, "peerDependencies": { "@bugsnag/core": "^8.0.0" } -} \ No newline at end of file +} diff --git a/packages/plugin-cloudflare-workers/test/index.test.ts b/packages/plugin-cloudflare-workers/test/index.test.ts index 0f14544a82..3430943f6d 100644 --- a/packages/plugin-cloudflare-workers/test/index.test.ts +++ b/packages/plugin-cloudflare-workers/test/index.test.ts @@ -1,62 +1,34 @@ -/** - * @jest-environment node - */ -import util from 'util' import BugsnagPluginCloudflareWorkers from '../src/' import Client, { EventDeliveryPayload, SessionDeliveryPayload } from '@bugsnag/core/client' +import type { Request as CloudflareRequest, Response as CloudflareResponse, ExecutionContext, ExportedHandler, IncomingRequestCfProperties } from '@cloudflare/workers-types' -// Mock Request and Response for Node.js environment -class MockRequest { - url: string - method: string - headers: Map - - constructor (url: string, init: { method?: string, headers?: Record } = {}) { - this.url = url - this.method = init.method || 'GET' - this.headers = new Map(Object.entries(init.headers || {})) - } +// Extended ExecutionContext type for testing with helper method +interface MockExecutionContext extends ExecutionContext { + _waitForAllPromises: () => Promise } -class MockResponse { - private body: string - - constructor (body: string) { - this.body = body - } - - async text () { - return this.body - } +// Example Env interface for testing type inference +interface Env { + SOME_VALUE?: string } // Mock ExecutionContext for Cloudflare Workers -const createMockExecutionContext = () => { - const promises: Promise[] = [] +const createMockExecutionContext = (): MockExecutionContext => { + const promises: Array> = [] return { waitUntil: (promise: Promise) => { promises.push(promise) }, - passThroughOnException: () => {}, + // Helper for tests to wait for all promises registered with waitUntil _waitForAllPromises: () => Promise.all(promises) - } + } as unknown as MockExecutionContext } -// @ts-ignore -global.Request = MockRequest -// @ts-ignore -global.Response = MockResponse - const createClient = (events: EventDeliveryPayload[], sessions: SessionDeliveryPayload[], config = {}) => { const client = new Client({ apiKey: 'AN_API_KEY', plugins: [BugsnagPluginCloudflareWorkers], ...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) @@ -88,20 +60,13 @@ describe('plugin: cloudflare workers', () => { expect(plugin).toMatchObject({ createHandler: expect.any(Function) }) }) - it('adds the request as metadata', async () => { + it('adds the request as metadata when an error occurs', async () => { const events: EventDeliveryPayload[] = [] const sessions: SessionDeliveryPayload[] = [] const client = createClient(events, sessions) - const handler = async (request: Request, env: any, ctx: any) => new Response('Hello World!') - - const request = new Request('https://example.com/test', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }) - const env = { TEST_VAR: 'test-value' } - const ctx = createMockExecutionContext() + const err = new Error('test error') const plugin = client.getPlugin('cloudflareWorkers') @@ -110,15 +75,54 @@ describe('plugin: cloudflare workers', () => { } const bugsnagHandler = plugin.createHandler() - const wrappedHandler = bugsnagHandler(handler) - const response = await wrappedHandler(request, env, ctx) - expect(await response.text()).toBe('Hello World!') + const exportedHandler: ExportedHandler = { + fetch: bugsnagHandler(async (request, env, ctx) => { + throw err + }) + } + + const request = new Request('https://example.com/test?foo=bar&baz=qux', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cf-Connecting-IP': '203.0.113.1' + } + }) as unknown as CloudflareRequest> + const env = {} + const ctx = createMockExecutionContext() + + await expect(exportedHandler.fetch?.(request, env, ctx)).rejects.toThrow(err) + + // Wait for promises registered with ctx.waitUntil to complete + await ctx._waitForAllPromises() + + expect(events).toHaveLength(1) - const metadata = client.getMetadata('Cloudflare Workers request') - expect(metadata).toMatchObject({ - url: 'https://example.com/test', - method: 'POST' + const event = events[0].events[0] + expect(event.request).toMatchObject({ + url: 'https://example.com/test?foo=bar&baz=qux', + httpMethod: 'POST', + clientIp: '203.0.113.1', + headers: { + 'content-type': 'application/json', + 'cf-connecting-ip': '203.0.113.1' + } + }) + // @ts-ignore + expect(event._metadata?.request).toMatchObject({ + url: 'https://example.com/test?foo=bar&baz=qux', + path: '/test', + httpMethod: 'POST', + clientIp: '203.0.113.1', + query: { + foo: 'bar', + baz: 'qux' + }, + headers: { + 'content-type': 'application/json', + 'cf-connecting-ip': '203.0.113.1' + } }) }) @@ -135,16 +139,6 @@ describe('plugin: cloudflare workers', () => { } } - const handler = async () => { - client.notify('hello') - - return new Response('Hello World!') - } - - const request = new Request('https://example.com/test') - const env = {} - const ctx = createMockExecutionContext() - const timeoutError = new Error('flush timed out after 20ms') const plugin = client.getPlugin('cloudflareWorkers') @@ -154,14 +148,24 @@ describe('plugin: cloudflare workers', () => { } const bugsnagHandler = plugin.createHandler({ flushTimeoutMs: 20 }) - const wrappedHandler = bugsnagHandler(handler) - const response = await wrappedHandler(request, env, ctx) - + const exportedHandler: ExportedHandler = { + fetch: bugsnagHandler((request, env, ctx) => { + client.notify('hello') + return new Response('Hello World!') as unknown as CloudflareResponse + }) + } + + const request = new Request('https://example.com/test') as unknown as CloudflareRequest> + const env = {} + const ctx = createMockExecutionContext() + + const response = await exportedHandler.fetch?.(request, env, ctx) + // Wait for promises registered with ctx.waitUntil to complete await ctx._waitForAllPromises().catch(() => {}) - - expect(await response.text()).toBe('Hello World!') + + expect(await response?.text()).toBe('Hello World!') expect(client._logger.error).toHaveBeenCalledWith(`Delivery may be unsuccessful: ${timeoutError.message}`) }) @@ -171,12 +175,6 @@ describe('plugin: cloudflare workers', () => { const client = createClient(events, sessions) - const handler = async () => new Response('Hello World!') - - const request = new Request('https://example.com/test') - const env = {} - const ctx = createMockExecutionContext() - const plugin = client.getPlugin('cloudflareWorkers') if (!plugin) { @@ -184,10 +182,23 @@ describe('plugin: cloudflare workers', () => { } const bugsnagHandler = plugin.createHandler() - const wrappedHandler = bugsnagHandler(handler) - const response = await wrappedHandler(request, env, ctx) - expect(await response.text()).toBe('Hello World!') + const exportedHandler: ExportedHandler = { + fetch: bugsnagHandler(async (request, env, ctx) => { + // Access env property to verify type inference works correctly + const value = env.SOME_VALUE + return new Response(`Hello World! ${value}`) as unknown as CloudflareResponse + }) + } + + const request = new Request('https://example.com/test') as unknown as CloudflareRequest> + const env = { + SOME_VALUE: 'test value' + } + const ctx = createMockExecutionContext() + + const response = await exportedHandler.fetch?.(request, env, ctx) + expect(await response?.text()).toBe('Hello World! test value') expect(events).toHaveLength(0) expect(sessions).toHaveLength(1) @@ -200,13 +211,6 @@ describe('plugin: cloudflare workers', () => { const client = createClient(events, sessions) const err = new Error('badness') - const handler = async () => { - throw err - } - - const request = new Request('https://example.com/test') - const env = {} - const ctx = createMockExecutionContext() const plugin = client.getPlugin('cloudflareWorkers') @@ -215,9 +219,18 @@ describe('plugin: cloudflare workers', () => { } const bugsnagHandler = plugin.createHandler() - const wrappedHandler = bugsnagHandler(handler) - await expect(wrappedHandler(request, env, ctx)).rejects.toThrow(err) + const exportedHandler: ExportedHandler = { + fetch: bugsnagHandler(async (request, env, ctx) => { + throw err + }) + } + + const request = new Request('https://example.com/test') as unknown as CloudflareRequest> + const env = {} + const ctx = createMockExecutionContext() + + await expect(exportedHandler.fetch?.(request, env, ctx)).rejects.toThrow(err) // Wait for promises registered with ctx.waitUntil to complete await ctx._waitForAllPromises() @@ -239,13 +252,6 @@ describe('plugin: cloudflare workers', () => { const client = createClient(events, sessions, { autoDetectErrors: false }) const err = new Error('badness') - const handler = async () => { - throw err - } - - const request = new Request('https://example.com/test') - const env = {} - const ctx = createMockExecutionContext() const plugin = client.getPlugin('cloudflareWorkers') @@ -254,9 +260,18 @@ describe('plugin: cloudflare workers', () => { } const bugsnagHandler = plugin.createHandler() - const wrappedHandler = bugsnagHandler(handler) - await expect(wrappedHandler(request, env, ctx)).rejects.toThrow(err) + const exportedHandler: ExportedHandler = { + fetch: bugsnagHandler(async (request, env, ctx) => { + throw err + }) + } + + const request = new Request('https://example.com/test') as unknown as CloudflareRequest> + const env = {} + const ctx = createMockExecutionContext() + + await expect(exportedHandler.fetch?.(request, env, ctx)).rejects.toThrow(err) expect(events).toHaveLength(0) expect(sessions).toHaveLength(1) @@ -274,13 +289,6 @@ describe('plugin: cloudflare workers', () => { }) const err = new Error('badness') - const handler = async () => { - throw err - } - - const request = new Request('https://example.com/test') - const env = {} - const ctx = createMockExecutionContext() const plugin = client.getPlugin('cloudflareWorkers') @@ -289,46 +297,20 @@ describe('plugin: cloudflare workers', () => { } const bugsnagHandler = plugin.createHandler() - const wrappedHandler = bugsnagHandler(handler) - await expect(wrappedHandler(request, env, ctx)).rejects.toThrow(err) - - expect(events).toHaveLength(0) - expect(sessions).toHaveLength(1) - }) - - it('captures environment metadata', async () => { - const events: EventDeliveryPayload[] = [] - const sessions: SessionDeliveryPayload[] = [] - - const client = createClient(events, sessions) - - const handler = async () => new Response('Hello World!') - - const request = new Request('https://example.com/test') - const env = { - API_KEY: 'secret-key', - NUMERIC_VAR: 123, - BOOLEAN_VAR: true + const exportedHandler: ExportedHandler = { + fetch: bugsnagHandler(async (request, env, ctx) => { + throw err + }) } - const ctx = createMockExecutionContext() - const plugin = client.getPlugin('cloudflareWorkers') - - if (!plugin) { - throw new Error('Plugin was not loaded!') - } - - const bugsnagHandler = plugin.createHandler() - const wrappedHandler = bugsnagHandler(handler) + const request = new Request('https://example.com/test') as unknown as CloudflareRequest> + const env = {} + const ctx = createMockExecutionContext() - await wrappedHandler(request, env, ctx) + await expect(exportedHandler.fetch?.(request, env, ctx)).rejects.toThrow(err) - const metadata = client.getMetadata('Cloudflare Workers environment') - expect(metadata).toEqual({ - API_KEY: 'secret-key', - NUMERIC_VAR: 123, - BOOLEAN_VAR: true - }) + expect(events).toHaveLength(0) + expect(sessions).toHaveLength(1) }) }) diff --git a/packages/plugin-cloudflare-workers/test/setup.ts b/packages/plugin-cloudflare-workers/test/setup.ts new file mode 100644 index 0000000000..a9a4348b20 --- /dev/null +++ b/packages/plugin-cloudflare-workers/test/setup.ts @@ -0,0 +1 @@ +import 'whatwg-fetch' From 689a80b342a692b780190f91b565ca00c490c828 Mon Sep 17 00:00:00 2001 From: Yousif Ahmed Date: Mon, 15 Dec 2025 17:42:59 +0000 Subject: [PATCH 5/5] docs(plugin-cloudflare-workers): add changelog entry # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be0a2fee18..57487c61ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +(plugin-cloudflare-workers): Add initial support for Cloudflare Workers [#2643](https://github.com/bugsnag/bugsnag-js/pull/2643) + ### Fixed (plugin-server-session) Delay session tracker initialization until first use [#2642](https://github.com/bugsnag/bugsnag-js/pull/2642)