diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 7716b2777cb7..5e33d56454f0 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -15,6 +15,7 @@ "dependencies": { "@langchain/langgraph": "^1.0.1", "@sentry/cloudflare": "10.33.0", + "@sentry/hono": "10.33.0", "hono": "^4.0.0" }, "devDependencies": { diff --git a/dev-packages/cloudflare-integration-tests/suites/hono/basic/index.ts b/dev-packages/cloudflare-integration-tests/suites/hono-integration/index.ts similarity index 89% rename from dev-packages/cloudflare-integration-tests/suites/hono/basic/index.ts rename to dev-packages/cloudflare-integration-tests/suites/hono-integration/index.ts index 6daae5f3f141..ee7d18338306 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono/basic/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-integration/index.ts @@ -16,7 +16,7 @@ app.get('/json', c => { }); app.get('/error', () => { - throw new Error('Test error from Hono app'); + throw new Error('Test error from Hono app (Sentry Cloudflare SDK)'); }); app.get('/hello/:name', c => { diff --git a/dev-packages/cloudflare-integration-tests/suites/hono/basic/test.ts b/dev-packages/cloudflare-integration-tests/suites/hono-integration/test.ts similarity index 89% rename from dev-packages/cloudflare-integration-tests/suites/hono/basic/test.ts rename to dev-packages/cloudflare-integration-tests/suites/hono-integration/test.ts index 727d61cca130..8a235713681c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono/basic/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-integration/test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest'; -import { eventEnvelope } from '../../../expect'; -import { createRunner } from '../../../runner'; +import { eventEnvelope } from '../../expect'; +import { createRunner } from '../../runner'; it('Hono app captures errors', async ({ signal }) => { const runner = createRunner(__dirname) @@ -14,7 +14,7 @@ it('Hono app captures errors', async ({ signal }) => { values: [ { type: 'Error', - value: 'Test error from Hono app', + value: 'Test error from Hono app (Sentry Cloudflare SDK)', stacktrace: { frames: expect.any(Array), }, diff --git a/dev-packages/cloudflare-integration-tests/suites/hono/basic/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/hono-integration/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/hono/basic/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/hono-integration/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/index.ts b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/index.ts new file mode 100644 index 000000000000..7b2e6b672425 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/index.ts @@ -0,0 +1,40 @@ +import { sentry } from '@sentry/hono/cloudflare-workers'; +import { Hono } from 'hono'; + +interface Env { + SENTRY_DSN: string; +} + +const app = new Hono<{ Bindings: Env }>(); + +app.use( + '*', + sentry(app, { + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 1.0, + debug: true, + // todo - what is going on with this + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + integrations: integrations => integrations.filter(integration => integration.name !== 'Hono'), + }), +); + +app.get('/', c => { + return c.text('Hello from Hono on Cloudflare!'); +}); + +app.get('/json', c => { + return c.json({ message: 'Hello from Hono', framework: 'hono', platform: 'cloudflare' }); +}); + +app.get('/error', () => { + throw new Error('Test error from Hono app'); +}); + +app.get('/hello/:name', c => { + const name = c.req.param('name'); + return c.text(`Hello, ${name}!`); +}); + +export default app; diff --git a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts new file mode 100644 index 000000000000..c87560ab1b0a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts @@ -0,0 +1,109 @@ +import { SDK_VERSION } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER, UUID_MATCHER } from '../../expect'; +import { createRunner } from '../../runner'; + +it('Hono app captures errors (Hono SDK)', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const [, envelopeItems] = envelope; + const [itemHeader, itemPayload] = envelopeItems[0]; + + expect(itemHeader.type).toBe('event'); + + // todo: check with function eventEnvelope + + // Validate error event structure + expect(itemPayload).toMatchObject({ + level: 'error', + platform: 'javascript', + transaction: 'GET /error', + // fixme: should be hono + sdk: { name: 'sentry.javascript.cloudflare', version: SDK_VERSION }, + // fixme: should contain trace + // trace: expect.objectContaining({ trace_id: UUID_MATCHER }), + exception: { + values: expect.arrayContaining([ + expect.objectContaining({ + type: 'Error', + value: 'Test error from Hono app', + mechanism: expect.objectContaining({ + type: 'generic', // fixme: should be 'auto.faas.hono.error_handler' + handled: true, // fixme: should be false + }), + }), + ]), + }, + request: expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/error'), + }), + }); + }) + .expect(envelope => { + const [, envelopeItems] = envelope; + const [itemHeader, itemPayload] = envelopeItems[0]; + + expect(itemHeader.type).toBe('transaction'); + + expect(itemPayload).toMatchObject({ + type: 'transaction', + platform: 'javascript', + transaction: 'GET /error', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'http.server', + status: 'internal_error', + origin: 'auto.http.cloudflare', + }, + }, + request: expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/error'), + }), + }); + }) + + .unordered() + .start(signal); + + await runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); +}); + +it('Hono app captures parametrized names', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const [, envelopeItems] = envelope; + const [itemHeader, itemPayload] = envelopeItems[0]; + + expect(itemHeader.type).toBe('transaction'); + + expect(itemPayload).toMatchObject({ + type: 'transaction', + platform: 'javascript', + transaction: 'GET /hello/:name', + contexts: { + trace: { + span_id: SHORT_UUID_MATCHER, + trace_id: UUID_MATCHER, + op: 'http.server', + status: 'ok', + origin: 'auto.http.cloudflare', + }, + }, + request: expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/hello/:name'), + }), + }); + }) + + .unordered() + .start(signal); + + await runner.makeRequest('get', '/hello/:name', { expectError: false }); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/wrangler.jsonc new file mode 100644 index 000000000000..0e4895ca598f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "name": "hono-sdk-worker", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"] +} + diff --git a/dev-packages/cloudflare-integration-tests/tsconfig.json b/dev-packages/cloudflare-integration-tests/tsconfig.json index b93dc5f57c50..7d0d293b0651 100644 --- a/dev-packages/cloudflare-integration-tests/tsconfig.json +++ b/dev-packages/cloudflare-integration-tests/tsconfig.json @@ -8,6 +8,7 @@ // global fetch available in tests in lower Node versions. "lib": ["ES2020"], "esModuleInterop": true, - "types": ["@cloudflare/workers-types"] + "types": ["@cloudflare/workers-types"], + "moduleResolution": "bundler" } } diff --git a/package.json b/package.json index c92a18b0dfe1..85860d4fd208 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "packages/feedback", "packages/gatsby", "packages/google-cloud-serverless", + "packages/hono", "packages/integration-shims", "packages/nestjs", "packages/nextjs", diff --git a/packages/hono/.eslintrc.js b/packages/hono/.eslintrc.js new file mode 100644 index 000000000000..6da218bd8641 --- /dev/null +++ b/packages/hono/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + env: { + node: true, + }, + extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-class-field-initializers': 'off', + }, +}; diff --git a/packages/hono/LICENSE b/packages/hono/LICENSE new file mode 100644 index 000000000000..0da96cd2f885 --- /dev/null +++ b/packages/hono/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Functional Software, Inc. dba Sentry + +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/hono/README.md b/packages/hono/README.md new file mode 100644 index 000000000000..418489148e97 --- /dev/null +++ b/packages/hono/README.md @@ -0,0 +1,43 @@ +

+ + Sentry + +

+ +# Official Sentry SDK for Hono (ALPHA) + +[![npm version](https://img.shields.io/npm/v/@sentry/hono.svg)](https://www.npmjs.com/package/@sentry/hono) +[![npm dm](https://img.shields.io/npm/dm/@sentry/hono.svg)](https://www.npmjs.com/package/@sentry/hono) +[![npm dt](https://img.shields.io/npm/dt/@sentry/hono.svg)](https://www.npmjs.com/package/@sentry/hono) + +## Links + +- [Official SDK Docs](https://docs.sentry.io/quickstart/) + +## Install + +To get started, first install the `@sentry/hono` package: + +```bash +npm install @sentry/hono +``` + +## Setup (Cloudflare Workers) + +### Enable Node.js compatibility + +Either set the `nodejs_als` or `nodejs_compat` compatibility flags in your `wrangler.jsonc`/`wrangler.toml` config. This is because the SDK needs access to the `AsyncLocalStorage` API to work correctly. + +```jsonc {tabTitle:JSON} {filename:wrangler.jsonc} +{ + "compatibility_flags": [ + "nodejs_als", + // "nodejs_compat" + ], +} +``` + +```toml {tabTitle:Toml} {filename:wrangler.toml} +compatibility_flags = ["nodejs_als"] +# compatibility_flags = ["nodejs_compat"] +``` diff --git a/packages/hono/package.json b/packages/hono/package.json new file mode 100644 index 000000000000..4568bf2d8449 --- /dev/null +++ b/packages/hono/package.json @@ -0,0 +1,100 @@ +{ + "name": "@sentry/hono", + "version": "10.33.0", + "description": "Official Sentry SDK for Hono (ALPHA)", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/hono", + "author": "Sentry", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./build/types/index.d.ts", + "default": "./build/esm/index.js" + }, + "require": { + "types": "./build/types/index.d.ts", + "default": "./build/cjs/index.js" + } + }, + "./cloudflare-workers": { + "import": { + "types": "./build/types/index.cloudflare.d.ts", + "default": "./build/esm/index.cloudflare.js" + }, + "require": { + "types": "./build/types/index.cloudflare.d.ts", + "default": "./build/cjs/index.cloudflare.js" + } + } + }, + "typesVersions": { + "<5.0": { + "build/types/index.d.ts": [ + "build/types-ts3.8/index.d.ts" + ], + "build/types/index.cloudflare.d.ts": [ + "build/types-ts3.8/index.cloudflare.d.ts" + ] + } + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@sentry/cloudflare": "10.33.0", + "@sentry/core": "10.33.0", + "@sentry/node": "10.33.0" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.x", + "hono": "^4.10.4" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + }, + "devDependencies": { + "@cloudflare/workers-types": "4.20250922.0", + "@types/node": "^18.19.1", + "wrangler": "4.22.0" + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", + "build:watch": "run-p build:transpile:watch build:types:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:types:watch": "tsc -p tsconfig.types.json --watch", + "build:tarball": "npm pack", + "circularDepCheck": "madge --circular src/index.ts", + "clean": "rimraf build coverage sentry-hono-*.tgz", + "fix": "eslint . --format stylish --fix", + "lint": "eslint . --format stylish", + "lint:es-compatibility": "es-check es2022 ./build/cjs/*.js && es-check es2022 ./build/esm/*.js --module", + "test": "yarn test:unit", + "test:unit": "vitest run", + "test:watch": "vitest --watch", + "yalc:publish": "yalc publish --push --sig" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false +} diff --git a/packages/hono/rollup.npm.config.mjs b/packages/hono/rollup.npm.config.mjs new file mode 100644 index 000000000000..6f491584a9d0 --- /dev/null +++ b/packages/hono/rollup.npm.config.mjs @@ -0,0 +1,21 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +const baseConfig = makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/index.cloudflare.ts'], + packageSpecificConfig: { + output: { + preserveModulesRoot: 'src', + }, + }, +}); + +const defaultExternal = baseConfig.external; +baseConfig.external = id => { + if (defaultExternal.includes(id)) { + return true; + } + // Mark all hono subpaths as external + return !!(id === 'hono' || id.startsWith('hono/')); +}; + +export default [...makeNPMConfigVariants(baseConfig)]; diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts new file mode 100644 index 000000000000..c9ccb845359b --- /dev/null +++ b/packages/hono/src/cloudflare/middleware.ts @@ -0,0 +1,24 @@ +import { withSentry } from '@sentry/cloudflare'; +import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; +import type { Context, Hono, MiddlewareHandler } from 'hono'; +import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; + +export interface HonoOptions extends Options { + context?: Context; +} + +export const sentry = (app: Hono, options: HonoOptions | undefined = {}): MiddlewareHandler => { + const isDebug = options.debug; + + isDebug && debug.log('Initialized Sentry Hono middleware (Cloudflare)'); + + withSentry(() => options, app); + + return async (context, next) => { + requestHandler(context); + + await next(); // Handler runs in between Request above ⤴ and Response below ⤵ + + responseHandler(context); + }; +}; diff --git a/packages/hono/src/index.cloudflare.ts b/packages/hono/src/index.cloudflare.ts new file mode 100644 index 000000000000..cba517e1d295 --- /dev/null +++ b/packages/hono/src/index.cloudflare.ts @@ -0,0 +1 @@ +export { sentry } from './cloudflare/middleware'; diff --git a/packages/hono/src/index.ts b/packages/hono/src/index.ts new file mode 100644 index 000000000000..bafed89f04bd --- /dev/null +++ b/packages/hono/src/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const sentryNoOp = () => {}; diff --git a/packages/hono/src/index.types.ts b/packages/hono/src/index.types.ts new file mode 100644 index 000000000000..cb0ff5c3b541 --- /dev/null +++ b/packages/hono/src/index.types.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/hono/src/shared/middlewareHandlers.ts new file mode 100644 index 000000000000..01cea49c5548 --- /dev/null +++ b/packages/hono/src/shared/middlewareHandlers.ts @@ -0,0 +1,43 @@ +import { getIsolationScope } from '@sentry/cloudflare'; +import { + getActiveSpan, + getClient, + getDefaultIsolationScope, + getRootSpan, + updateSpanName, + winterCGRequestToRequestData, +} from '@sentry/core'; +import type { Context } from 'hono'; +import { routePath } from 'hono/route'; +import { hasFetchEvent } from '../utils/hono-context'; + +/** + * todo + */ +export function requestHandler(context: Context): void { + const defaultScope = getDefaultIsolationScope(); + const currentIsolationScope = getIsolationScope(); + + const isolationScope = defaultScope === currentIsolationScope ? defaultScope : currentIsolationScope; + + isolationScope.setSDKProcessingMetadata({ + normalizedRequest: winterCGRequestToRequestData(hasFetchEvent(context) ? context.event.request : context.req.raw), + }); +} + +/** + * todo + */ +export function responseHandler(context: Context): void { + const activeSpan = getActiveSpan(); + if (activeSpan) { + activeSpan.updateName(`${context.req.method} ${routePath(context)}`); + updateSpanName(getRootSpan(activeSpan), `${context.req.method} ${routePath(context)}`); + } + + getIsolationScope().setTransactionName(`${context.req.method} ${routePath(context)}`); + + if (context.error) { + getClient()?.captureException(context.error); + } +} diff --git a/packages/hono/src/utils/hono-context.ts b/packages/hono/src/utils/hono-context.ts new file mode 100644 index 000000000000..96df44ee655a --- /dev/null +++ b/packages/hono/src/utils/hono-context.ts @@ -0,0 +1,15 @@ +import type { Context } from 'hono'; + +/** + * Checks whether the given Hono context has a fetch event. + */ +export function hasFetchEvent(c: Context): boolean { + let hasFetchEvent = true; + try { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + c.event; + } catch { + hasFetchEvent = false; + } + return hasFetchEvent; +} diff --git a/packages/hono/tsconfig.json b/packages/hono/tsconfig.json new file mode 100644 index 000000000000..ff89f0feaa23 --- /dev/null +++ b/packages/hono/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src/**/*"], + + "compilerOptions": { + "module": "esnext", + "types": ["node", "@cloudflare/workers-types"] + } +} diff --git a/packages/hono/tsconfig.test.json b/packages/hono/tsconfig.test.json new file mode 100644 index 000000000000..00cada2d8bcf --- /dev/null +++ b/packages/hono/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*", "vite.config.ts"], + + "compilerOptions": { + // other package-specific, test-specific options + } +} diff --git a/packages/hono/tsconfig.types.json b/packages/hono/tsconfig.types.json new file mode 100644 index 000000000000..65455f66bd75 --- /dev/null +++ b/packages/hono/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/hono/vite.config.ts b/packages/hono/vite.config.ts new file mode 100644 index 000000000000..b2150cd225a4 --- /dev/null +++ b/packages/hono/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vitest/config'; +import baseConfig from '../../vite/vite.config'; + +export default defineConfig({ + ...baseConfig, +});