feat(core): add honeybadger support (#21574)

* feat(core): add honeybadger support

* feat(honeybadger): configure Honeybadger to disable uncaught error tracking

* feat(tests): add info logger mock to app-bootstrap tests

* test: fix mock value

* test: mock honeybadger as well
This commit is contained in:
Tony 2026-04-01 01:10:12 +08:00 committed by GitHub
parent 222322befc
commit 1fe4e8a4ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 318 additions and 4 deletions

View File

@ -8,6 +8,14 @@ vi.mock('@/utils/logger', () => ({
},
}));
vi.mock('@honeybadger-io/js', () => ({
default: {
configure: vi.fn(),
notify: vi.fn(),
setContext: vi.fn(),
},
}));
describe('app-bootstrap', () => {
it('logs uncaught exceptions', async () => {
const before = new Set(process.listeners('uncaughtException'));

View File

@ -10,6 +10,7 @@ import antiHotlink from '@/middleware/anti-hotlink';
import cache from '@/middleware/cache';
import debug from '@/middleware/debug';
import header from '@/middleware/header';
import honeybadger from '@/middleware/honeybadger';
import mLogger from '@/middleware/logger';
import parameter from '@/middleware/parameter';
import sentry from '@/middleware/sentry';
@ -35,6 +36,7 @@ app.use(
);
app.use(mLogger);
app.use(trace);
app.use(honeybadger);
app.use(sentry);
app.use(accessControl);
app.use(debug);

View File

@ -52,6 +52,7 @@ app.use(mLogger);
app.use(trace);
// Heavy middleware excluded in Worker build:
// - honeybadger: @honeybadger-io/js
// - sentry: @sentry/node
// - antiHotlink: cheerio
// - parameter: cheerio, sanitize-html, @jocmp/mercury-parser

View File

@ -48,6 +48,8 @@ type ConfigEnvKeys =
| 'OTEL_SECONDS_BUCKET'
| 'OTEL_MILLISECONDS_BUCKET'
| 'SHOW_LOGGER_TIMESTAMP'
| 'HONEYBADGER_API_KEY'
| 'ERROR_TRACKING_ROUTE_TIMEOUT'
| 'SENTRY'
| 'SENTRY_ROUTE_TIMEOUT'
| 'ENABLE_REMOTE_DEBUGGING'
@ -306,10 +308,13 @@ export type Config = {
milliseconds_bucket?: string;
};
showLoggerTimestamp?: boolean;
honeybadger: {
apiKey?: string;
};
sentry: {
dsn?: string;
routeTimeout: number;
};
errorTrackingRouteTimeout: number;
enableRemoteDebugging?: boolean;
// feed config
hotlink: {
@ -798,10 +803,13 @@ const calculateValue = () => {
milliseconds_bucket: envs.OTEL_MILLISECONDS_BUCKET || '10,20,50,100,250,500,1000,5000,15000',
},
showLoggerTimestamp: toBoolean(envs.SHOW_LOGGER_TIMESTAMP, false),
honeybadger: {
apiKey: envs.HONEYBADGER_API_KEY,
},
sentry: {
dsn: envs.SENTRY,
routeTimeout: toInt(envs.SENTRY_ROUTE_TIMEOUT, 30000),
},
errorTrackingRouteTimeout: toInt(envs.ERROR_TRACKING_ROUTE_TIMEOUT || envs.SENTRY_ROUTE_TIMEOUT, 30000),
enableRemoteDebugging: toBoolean(envs.ENABLE_REMOTE_DEBUGGING, false),
// feed config
hotlink: {

View File

@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest';
const notify = vi.fn();
vi.mock('@honeybadger-io/js', () => ({
default: { notify },
}));
vi.mock('@sentry/node', () => ({
withScope: vi.fn(),
captureException: vi.fn(),
}));
vi.mock('hono/route', () => ({
routePath: () => '/test/path',
}));
vi.mock('@/utils/logger', () => ({
default: {
error: vi.fn(),
},
}));
vi.mock('@/utils/otel', () => ({
requestMetric: {
error: vi.fn(),
},
}));
describe('error handler honeybadger', () => {
it('sends errors to honeybadger when enabled', async () => {
process.env.HONEYBADGER_API_KEY = 'hbp_test_key';
vi.resetModules();
// Re-mock after resetModules
vi.doMock('@honeybadger-io/js', () => ({
default: { notify },
}));
vi.doMock('@sentry/node', () => ({
withScope: vi.fn(),
captureException: vi.fn(),
}));
vi.doMock('hono/route', () => ({
routePath: () => '/test/path',
}));
vi.doMock('@/utils/logger', () => ({
default: { error: vi.fn() },
}));
vi.doMock('@/utils/otel', () => ({
requestMetric: { error: vi.fn() },
}));
const { errorHandler } = await import('@/errors');
const ctx = {
req: {
path: '/test/path',
method: 'GET',
query: () => 'json',
},
res: {
status: 500,
headers: new Headers(),
},
status: vi.fn(),
header: vi.fn(),
json: (payload: unknown) => payload,
html: (payload: unknown) => payload,
};
errorHandler(new Error('boom'), ctx as any);
expect(notify).toHaveBeenCalledWith(expect.any(Error), {
context: { name: 'test' },
});
delete process.env.HONEYBADGER_API_KEY;
});
});

View File

@ -1,3 +1,4 @@
import Honeybadger from '@honeybadger-io/js';
import * as Sentry from '@sentry/node';
import type { ErrorHandler, NotFoundHandler } from 'hono';
import { routePath } from 'hono/route';
@ -36,6 +37,12 @@ export const errorHandler: ErrorHandler = (error, ctx) => {
hasMatchedRoute && debug.errorRoutes[matchedRoute]++;
setDebugInfo(debug);
if (config.honeybadger.apiKey) {
Honeybadger.notify(error, {
context: { name: requestPath.split('/')[1] },
});
}
if (config.sentry.dsn) {
Sentry.withScope((scope) => {
scope.setTag('name', requestPath.split('/')[1]);

View File

@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
afterEach(() => {
vi.resetModules();
vi.restoreAllMocks();
vi.unmock('@/config');
vi.unmock('@/utils/helpers');
vi.unmock('@/utils/logger');
vi.unmock('@honeybadger-io/js');
});
describe('honeybadger middleware', () => {
const loadMiddleware = async () => {
const honeybadger = {
configure: vi.fn(),
setContext: vi.fn(),
notify: vi.fn(),
};
const logger = {
info: vi.fn(),
};
const getRouteNameFromPath = vi.fn((path: string) => `route:${path}`);
vi.doMock('@honeybadger-io/js', () => ({
default: honeybadger,
}));
vi.doMock('@/utils/logger', () => ({
default: logger,
}));
vi.doMock('@/utils/helpers', () => ({
getRouteNameFromPath,
}));
vi.doMock('@/config', () => ({
config: {
honeybadger: {
apiKey: 'hbp_test_key',
},
errorTrackingRouteTimeout: 50,
nodeName: 'node-a',
},
}));
const { default: middleware } = await import('@/middleware/honeybadger');
return { middleware, honeybadger, logger, getRouteNameFromPath };
};
it('initializes honeybadger and captures slow routes', async () => {
const { middleware, honeybadger, logger, getRouteNameFromPath } = await loadMiddleware();
expect(honeybadger.configure).toHaveBeenCalledWith({
apiKey: 'hbp_test_key',
enableUncaught: false,
});
expect(honeybadger.setContext).toHaveBeenCalledWith({ node_name: 'node-a' });
expect(logger.info).toHaveBeenCalledWith('Honeybadger inited.');
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValueOnce(0).mockReturnValueOnce(100);
await middleware({ req: { path: '/test/slow' } } as any, async () => {});
expect(getRouteNameFromPath).toHaveBeenCalledWith('/test/slow');
expect(honeybadger.notify).toHaveBeenCalledTimes(1);
expect(honeybadger.notify).toHaveBeenCalledWith(expect.any(Error), {
context: { name: 'route:/test/slow' },
});
});
});

View File

@ -0,0 +1,28 @@
import Honeybadger from '@honeybadger-io/js';
import type { MiddlewareHandler } from 'hono';
import { config } from '@/config';
import { getRouteNameFromPath } from '@/utils/helpers';
import logger from '@/utils/logger';
if (config.honeybadger.apiKey) {
Honeybadger.configure({
apiKey: config.honeybadger.apiKey,
enableUncaught: false,
});
Honeybadger.setContext({ node_name: config.nodeName });
logger.info('Honeybadger inited.');
}
const middleware: MiddlewareHandler = async (ctx, next) => {
const time = Date.now();
await next();
if (config.honeybadger.apiKey && Date.now() - time >= config.errorTrackingRouteTimeout) {
Honeybadger.notify(new Error('Route Timeout'), {
context: { name: getRouteNameFromPath(ctx.req.path) },
});
}
};
export default middleware;

View File

@ -36,8 +36,8 @@ describe('sentry middleware', () => {
config: {
sentry: {
dsn: 'https://sentry.example/123',
routeTimeout: 50,
},
errorTrackingRouteTimeout: 50,
nodeName: 'node-a',
},
}));

View File

@ -17,7 +17,7 @@ if (config.sentry.dsn) {
const middleware: MiddlewareHandler = async (ctx, next) => {
const time = Date.now();
await next();
if (config.sentry.dsn && Date.now() - time >= config.sentry.routeTimeout) {
if (config.sentry.dsn && Date.now() - time >= config.errorTrackingRouteTimeout) {
Sentry.withScope((scope) => {
scope.setTag('name', getRouteNameFromPath(ctx.req.path));
Sentry.captureException(new Error('Route Timeout'));

6
lib/shims/honeybadger.ts Normal file
View File

@ -0,0 +1,6 @@
// No-op shim for @honeybadger-io/js in Cloudflare Workers
export default {
configure: () => {},
notify: () => {},
setContext: () => {},
};

View File

@ -59,6 +59,7 @@
"@bbob/html": "4.3.1",
"@bbob/plugin-helper": "4.3.1",
"@bbob/preset-html5": "4.3.1",
"@honeybadger-io/js": "6.12.3",
"@hono/node-server": "1.19.12",
"@hono/zod-openapi": "1.2.4",
"@jocmp/mercury-parser": "3.0.7",

View File

@ -40,6 +40,9 @@ importers:
'@bbob/preset-html5':
specifier: 4.3.1
version: 4.3.1
'@honeybadger-io/js':
specifier: 6.12.3
version: 6.12.3
'@hono/node-server':
specifier: 1.19.12
version: 1.19.12(hono@4.12.8)
@ -1074,6 +1077,15 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.9.0
'@honeybadger-io/core@6.7.2':
resolution: {integrity: sha512-4+hyrFI0S/Eni2cBgO40Lqyft5hyNXjgnuh1EbeH457kty8g4YatJYuHBffmrjwLiZHgFbWXRGv7SlG+NehC5Q==}
engines: {node: '>=14'}
'@honeybadger-io/js@6.12.3':
resolution: {integrity: sha512-CL+9A8tGpjawsJEKE74P94hmimHRsq+B90i5N/pXNgnJbgZG40bs9yZpfn2z3w0+3z9wtH12D2RqBjxsGm9DBg==}
engines: {node: '>=14'}
hasBin: true
'@hono/node-server@1.19.12':
resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==}
engines: {node: '>=18.14.1'}
@ -2472,6 +2484,9 @@ packages:
'@types/aes-js@3.1.4':
resolution: {integrity: sha512-v3D66IptpUqh+pHKVNRxY8yvp2ESSZXe0rTzsGdzUhEwag7ljVfgCllkWv2YgiYXDhWFBrEywll4A5JToyTNFA==}
'@types/aws-lambda@8.10.161':
resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==}
'@types/babel__preset-env@7.10.0':
resolution: {integrity: sha512-LS8hRb/8TQir2f8W9/s5enDtrRS2F/6fsdkVw5ePHp6Q8SrSJHOGtWnP93ryaYMmg2du03vOsiGrl5mllz4uDA==}
@ -2481,6 +2496,9 @@ packages:
'@types/bluebird@3.5.42':
resolution: {integrity: sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/caseless@0.12.5':
resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==}
@ -2514,6 +2532,12 @@ packages:
'@types/etag@1.8.4':
resolution: {integrity: sha512-f1z/UMth8gQ6636NBqhFmJ3zES7EuDcUnV6K1gl1osHp+85KPKX+VixYWUpqLkw1fftCagyHJjJOZjZkEi2rHw==}
'@types/express-serve-static-core@5.1.1':
resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==}
'@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
'@types/fs-extra@11.0.4':
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
@ -2523,6 +2547,9 @@ packages:
'@types/http-cache-semantics@4.2.0':
resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/js-beautify@1.14.3':
resolution: {integrity: sha512-FMbQHz+qd9DoGvgLHxeqqVPaNRffpIu5ZjozwV8hf9JAGpIOzuAf4wGbRSo8LNITHqGjmmVjaMggTT5P4v4IHg==}
@ -2577,6 +2604,12 @@ packages:
'@types/pg@8.15.6':
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
'@types/qs@6.15.0':
resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==}
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/request-promise@4.1.51':
resolution: {integrity: sha512-qVcP9Fuzh9oaAh8oPxiSoWMFGnWKkJDknnij66vi09Yiy62bsSDqtd+fG5kIM9wLLgZsRP3Y6acqj9O/v2ZtRw==}
@ -2586,6 +2619,12 @@ packages:
'@types/sanitize-html@2.16.1':
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
@ -4355,6 +4394,9 @@ packages:
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-nd@1.0.0:
resolution: {integrity: sha512-8TIp0HZAY0VVrwRQJJPb4+nOTSPoOWZeEKBTLizUfQO4oym5Fc/MKqN8vEbLCxcyxDf2vwNxOQ1q84O49GWPyQ==}
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
@ -5419,6 +5461,10 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
stacktrace-parser@0.1.11:
resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
engines: {node: '>=6'}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
@ -5732,6 +5778,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
type-fest@0.7.1:
resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==}
engines: {node: '>=8'}
type-fest@3.13.1:
resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==}
engines: {node: '>=14.16'}
@ -6664,6 +6714,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@honeybadger-io/core@6.7.2':
dependencies:
json-nd: 1.0.0
stacktrace-parser: 0.1.11
'@honeybadger-io/js@6.12.3':
dependencies:
'@honeybadger-io/core': 6.7.2
'@types/aws-lambda': 8.10.161
'@types/express': 5.0.6
'@hono/node-server@1.19.12(hono@4.12.8)':
dependencies:
hono: 4.12.8
@ -7895,12 +7956,19 @@ snapshots:
'@types/aes-js@3.1.4': {}
'@types/aws-lambda@8.10.161': {}
'@types/babel__preset-env@7.10.0': {}
'@types/bezier-js@4.1.3': {}
'@types/bluebird@3.5.42': {}
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 25.5.0
'@types/caseless@0.12.5': {}
'@types/chai@5.2.3':
@ -7935,6 +8003,19 @@ snapshots:
dependencies:
'@types/node': 25.5.0
'@types/express-serve-static-core@5.1.1':
dependencies:
'@types/node': 25.5.0
'@types/qs': 6.15.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express@5.0.6':
dependencies:
'@types/body-parser': 1.19.6
'@types/express-serve-static-core': 5.1.1
'@types/serve-static': 2.2.0
'@types/fs-extra@11.0.4':
dependencies:
'@types/jsonfile': 6.1.4
@ -7944,6 +8025,8 @@ snapshots:
'@types/http-cache-semantics@4.2.0': {}
'@types/http-errors@2.0.5': {}
'@types/js-beautify@1.14.3': {}
'@types/jsdom@28.0.1':
@ -8005,6 +8088,10 @@ snapshots:
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/qs@6.15.0': {}
'@types/range-parser@1.2.7': {}
'@types/request-promise@4.1.51':
dependencies:
'@types/bluebird': 3.5.42
@ -8021,6 +8108,15 @@ snapshots:
dependencies:
htmlparser2: 10.1.0
'@types/send@1.2.1':
dependencies:
'@types/node': 25.5.0
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 25.5.0
'@types/statuses@2.0.6': {}
'@types/tedious@4.0.14':
@ -9920,6 +10016,8 @@ snapshots:
json-buffer@3.0.1: {}
json-nd@1.0.0: {}
json-parse-even-better-errors@2.3.1: {}
json-schema-traverse@0.4.1: {}
@ -11302,6 +11400,10 @@ snapshots:
stackback@0.0.2: {}
stacktrace-parser@0.1.11:
dependencies:
type-fest: 0.7.1
standard-as-callback@2.1.0: {}
statuses@2.0.2: {}
@ -11618,6 +11720,8 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
type-fest@0.7.1: {}
type-fest@3.13.1: {}
type-fest@4.41.0: {}

View File

@ -89,6 +89,7 @@ export default defineConfig({
'node:child_process': path.resolve('./lib/shims/node-child-process.ts'),
'dotenv/config': path.resolve('./lib/shims/dotenv-config.ts'),
'@sentry/node': path.resolve('./lib/shims/sentry-node.ts'),
'@honeybadger-io/js': path.resolve('./lib/shims/honeybadger.ts'),
'xxhash-wasm': path.resolve('./lib/shims/xxhash-wasm.ts'),
// Routes file with Worker-specific build (match relative import from lib/)
'../assets/build/routes.js': path.resolve('./assets/build/routes-worker.js'),