├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── package-lock.json ├── package.json ├── src ├── index.spec.ts └── index.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_size = 2 7 | indent_style = space 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: 12 | - "14" 13 | - "*" 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - uses: actions/cache@v2 20 | with: 21 | path: ~/.npm 22 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 23 | restore-keys: | 24 | ${{ runner.os }}-node- 25 | - run: npm ci 26 | - run: npm test 27 | - uses: codecov/codecov-action@v1 28 | with: 29 | name: Node.js ${{ matrix.node-version }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | .DS_Store 4 | npm-debug.log 5 | dist/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Blake Embrey (hello@blakeembrey.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Worker Sentry 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![NPM downloads][downloads-image]][downloads-url] 5 | [![Build status][build-image]][build-url] 6 | [![Build coverage][coverage-image]][coverage-url] 7 | 8 | > Sentry client for Cloudflare Workers using `fetch` and native [V8 stack traces](https://v8.dev/docs/stack-trace-api). 9 | 10 | ## Installation 11 | 12 | ``` 13 | npm install @borderless/worker-sentry --save 14 | ``` 15 | 16 | ## Usage 17 | 18 | ```ts 19 | import { Sentry } from "@borderless/worker-sentry"; 20 | 21 | const sentry = new Sentry({ dsn: "https://123@456.ingest.sentry.io/789" }); 22 | 23 | addEventListener("fetch", (event) => { 24 | event.respondWith( 25 | handler(event.request).catch((err) => { 26 | // Extend the event lifetime until the response from Sentry has resolved. 27 | // Docs: https://developers.cloudflare.com/workers/runtime-apis/fetch-event#methods 28 | event.waitUntil( 29 | // Sends a request to Sentry and returns the response promise. 30 | sentry.captureException(err, { 31 | tags: {}, 32 | user: { 33 | ip_address: event.request.headers.get("cf-connecting-ip"), 34 | }, 35 | }) 36 | ); 37 | 38 | // Respond to the original request while the error is being logged (above). 39 | return new Response(err.message || "Internal Error", { status: 500 }); 40 | }) 41 | ); 42 | }); 43 | ``` 44 | 45 | ## License 46 | 47 | MIT 48 | 49 | [npm-image]: https://img.shields.io/npm/v/@borderless/worker-sentry 50 | [npm-url]: https://npmjs.org/package/@borderless/worker-sentry 51 | [downloads-image]: https://img.shields.io/npm/dm/@borderless/worker-sentry 52 | [downloads-url]: https://npmjs.org/package/@borderless/worker-sentry 53 | [build-image]: https://img.shields.io/github/workflow/status/borderless/worker-sentry/CI/main 54 | [build-url]: https://github.com/borderless/worker-sentry/actions/workflows/ci.yml?query=branch%3Amain 55 | [coverage-image]: https://img.shields.io/codecov/c/gh/borderless/worker-sentry 56 | [coverage-url]: https://codecov.io/gh/borderless/worker-sentry 57 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Security contact information 4 | 5 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@borderless/worker-sentry", 3 | "version": "2.0.0", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "description": "Sentry client for Cloudflare Workers using `fetch` and V8 stack traces", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/BorderlessLabs/worker-sentry.git" 12 | }, 13 | "author": { 14 | "name": "Blake Embrey", 15 | "email": "hello@blakeembrey.com", 16 | "url": "http://blakeembrey.me" 17 | }, 18 | "homepage": "https://github.com/BorderlessLabs/worker-sentry", 19 | "bugs": { 20 | "url": "https://github.com/BorderlessLabs/worker-sentry/issues" 21 | }, 22 | "main": "dist/index.js", 23 | "type": "module", 24 | "engines": { 25 | "node": ">=14" 26 | }, 27 | "scripts": { 28 | "build": "ts-scripts build", 29 | "format": "ts-scripts format", 30 | "lint": "ts-scripts lint", 31 | "prepare": "ts-scripts install", 32 | "prepublishOnly": "npm run build", 33 | "specs": "ts-scripts specs", 34 | "test": "ts-scripts test" 35 | }, 36 | "files": [ 37 | "dist/" 38 | ], 39 | "keywords": [ 40 | "worker", 41 | "fetch", 42 | "sentry", 43 | "error", 44 | "logging", 45 | "cloudflare" 46 | ], 47 | "devDependencies": { 48 | "@borderless/ts-scripts": "^0.11.0", 49 | "@jest/globals": "^28.1.0", 50 | "cross-fetch": "^3.1.5", 51 | "typescript": "^4.6.4" 52 | }, 53 | "typings": "dist/index.d.ts" 54 | } 55 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import "cross-fetch/polyfill"; 2 | import { describe, it, expect, beforeEach, jest } from "@jest/globals"; 3 | import { Sentry } from "./index"; 4 | 5 | describe("worker sentry", () => { 6 | const dsn = "https://123@456.ingest.sentry.io/789"; 7 | let sentry: Sentry; 8 | let fetch: any; 9 | 10 | beforeEach(() => { 11 | fetch = jest.fn<() => Response>(); 12 | sentry = new Sentry({ dsn, fetch, filePrefix: "" }); 13 | }); 14 | 15 | describe("with 200 response", () => { 16 | beforeEach(() => { 17 | fetch.mockResolvedValueOnce(new Response(null, { status: 200 })); 18 | }); 19 | 20 | it("should send exception to sentry", async () => { 21 | const response = await sentry.captureException(new Error("Boom!")); 22 | 23 | expect(response.status).toEqual(200); 24 | expect(fetch).toBeCalledTimes(1); 25 | 26 | const request = fetch.mock.calls[0][0]; 27 | 28 | expect(request.url).toEqual("https://sentry.io/api/789/store/"); 29 | expect(request.method).toEqual("POST"); 30 | 31 | const data = await request.json(); 32 | const exception = data.exception.values[0]; 33 | const hasException = exception.stacktrace.frames.some((x: any) => 34 | /[\\/]worker-sentry[\\/]/.test(x.filename) 35 | ); 36 | 37 | expect(hasException).toEqual(true); 38 | expect(exception.type).toEqual("Error"); 39 | expect(exception.value).toEqual("Boom!"); 40 | expect(data.platform).toEqual("javascript"); 41 | }); 42 | 43 | it("should send all properties", async () => { 44 | const response = await sentry.captureException(new Error("Boom!"), { 45 | level: "error", 46 | release: "test", 47 | dist: "test", 48 | fingerprint: [], 49 | environment: "production", 50 | serverName: "test", 51 | breadcrumbs: [{ type: "error", message: "error" }], 52 | transaction: "test", 53 | tags: { test: "test" }, 54 | extra: { test: true }, 55 | }); 56 | 57 | expect(response.status).toEqual(200); 58 | expect(fetch).toBeCalledTimes(1); 59 | 60 | const request = fetch.mock.calls[0][0]; 61 | const data = await request.json(); 62 | 63 | expect(Object.keys(data)).toEqual([ 64 | "logger", 65 | "platform", 66 | "level", 67 | "extra", 68 | "fingerprint", 69 | "exception", 70 | "tags", 71 | "user", 72 | "request", 73 | "breadcrumbs", 74 | "server_name", 75 | "transaction", 76 | "release", 77 | "dist", 78 | "environment", 79 | ]); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Request interface (e.g. `fetch`). 3 | */ 4 | export type Fetch = (request: Request) => Promise; 5 | 6 | /** 7 | * V8 call site trace. 8 | * 9 | * Ref: https://v8.dev/docs/stack-trace-api 10 | */ 11 | interface CallSite { 12 | getThis(): any; 13 | getTypeName(): string | null; 14 | getFunction(): (...args: any) => any | undefined; 15 | getFunctionName(): string | null; 16 | getMethodName(): string | null; 17 | getFileName(): string | null; 18 | getLineNumber(): number | null; 19 | getColumnNumber(): number | null; 20 | getEvalOrigin(): string | undefined; 21 | isToplevel(): boolean; 22 | isEval(): boolean; 23 | isNative(): boolean; 24 | isConstructor(): boolean; 25 | isAsync(): boolean; 26 | isPromiseAll(): boolean; 27 | getPromiseIndex(): number | null; 28 | } 29 | 30 | declare global { 31 | interface ErrorConstructor { 32 | /** 33 | * @see https://v8.dev/docs/stack-trace-api#customizing-stack-traces 34 | */ 35 | prepareStackTrace?: 36 | | ((err: Error, stackTraces: CallSite[]) => any) 37 | | undefined; 38 | captureStackTrace( 39 | targetObject: object, 40 | constructorOpt?: (...args: any[]) => any 41 | ): void; 42 | } 43 | } 44 | 45 | /** 46 | * Parse call sites from error instance. 47 | */ 48 | function getErrorStack( 49 | error: Error, 50 | constructor: (...args: any[]) => any 51 | ): CallSite[] { 52 | const prepareStackTrace = Error.prepareStackTrace; 53 | let trace: CallSite[]; 54 | 55 | Error.prepareStackTrace = (error, v8Trace) => { 56 | trace = v8Trace as CallSite[]; 57 | return prepareStackTrace?.(error, v8Trace); 58 | }; 59 | 60 | Error.captureStackTrace(error, constructor); 61 | error.stack; // Triggers `prepareStackTrace`. 62 | Error.prepareStackTrace = prepareStackTrace; 63 | 64 | return trace!; 65 | } 66 | 67 | /** 68 | * Sentry initialization options. 69 | */ 70 | export interface SentryOptions { 71 | dsn: string; 72 | fetch?: Fetch; 73 | filePrefix?: string; 74 | } 75 | 76 | /** 77 | * Sentry levels. 78 | */ 79 | export type SentryLevel = "fatal" | "error" | "warning" | "info" | "debug"; 80 | 81 | /** 82 | * Options allows while capturing an exception. 83 | * 84 | * Documentation: https://develop.sentry.dev/sdk/event-payloads/ 85 | */ 86 | export interface CaptureExceptionOptions { 87 | /** The record severity. */ 88 | level?: SentryLevel; 89 | /** An arbitrary mapping of additional metadata to store with the event. */ 90 | extra?: Record; 91 | /** A map or list of tags for this event. Each tag must be less than 200 characters. */ 92 | tags?: Record; 93 | /** The release version of the application. */ 94 | release?: string; 95 | /** The distribution of the application. */ 96 | dist?: string; 97 | /** The environment name, such as `production` or `staging`. */ 98 | environment?: string; 99 | /** Identifies the host from which the event was recorded. */ 100 | serverName?: string; 101 | /** The name of the transaction which caused this exception. */ 102 | transaction?: string; 103 | /** A list of relevant modules and their versions. */ 104 | modules?: Record; 105 | /** 106 | * An interface which describes the authenticated User for a request. 107 | * 108 | * Documentation: https://develop.sentry.dev/sdk/event-payloads/user/ 109 | */ 110 | user?: { 111 | /** The unique ID of the user. */ 112 | id?: string; 113 | /** The username of the user. */ 114 | username?: string; 115 | /** The email address of the user. */ 116 | email?: string; 117 | /** The IP of the user. */ 118 | ip?: string; 119 | }; 120 | /** A list of strings used to dictate the de-duplication of this event. */ 121 | fingerprint?: string[]; 122 | /** 123 | * The Request interface contains information on a HTTP request related to the event. 124 | * 125 | * Documentation: https://develop.sentry.dev/sdk/event-payloads/request 126 | */ 127 | request?: { 128 | /** The HTTP method of the request. */ 129 | method?: string; 130 | /** The URL of the request if available. The query string can be declared either as part of the `url`, or separately in `query_string`. */ 131 | url?: string; 132 | /** A dictionary of submitted headers. */ 133 | headers?: Record; 134 | /** The query string component of the URL. */ 135 | query?: string; 136 | /** The cookie values. */ 137 | cookies?: string | Record | [string, string][]; 138 | /** A dictionary containing environment information passed from the server. */ 139 | env?: Record; 140 | }; 141 | /** 142 | * The Breadcrumbs Interface specifies a series of application events, or "breadcrumbs", that occurred before an event. 143 | * 144 | * Documentation: https://develop.sentry.dev/sdk/event-payloads/breadcrumbs 145 | */ 146 | breadcrumbs?: Array<{ 147 | /** A timestamp representing when the breadcrumb occurred. */ 148 | timestamp?: Date; 149 | /** The type of breadcrumb. */ 150 | type?: string; 151 | /** A dotted string indicating what the crumb is or from where it comes. */ 152 | category?: string; 153 | /** If a message is provided, it is rendered as text with all whitespace preserved. Very long text might be truncated in the UI. */ 154 | message?: string; 155 | /** Arbitrary data associated with this breadcrumb. */ 156 | data?: Record; 157 | /** This defines the severity level of the breadcrumb. */ 158 | level?: SentryLevel; 159 | }>; 160 | } 161 | 162 | /** 163 | * Simple sentry client using `fetch`. 164 | */ 165 | export class Sentry { 166 | sentryUrl: URL; 167 | fetch: Fetch; 168 | filePrefix: string; 169 | 170 | constructor(options: SentryOptions) { 171 | this.sentryUrl = new URL(options.dsn); 172 | this.fetch = options.fetch ?? fetch.bind(null); 173 | this.filePrefix = options.filePrefix ?? "~/"; 174 | } 175 | 176 | /** 177 | * Sends the exception to Sentry and returns the `Response` promise. 178 | */ 179 | captureException( 180 | error: Error, 181 | options: CaptureExceptionOptions = {} 182 | ): Promise { 183 | // https://develop.sentry.dev/sdk/event-payloads/ 184 | const request = new Request( 185 | `https://sentry.io/api${this.sentryUrl.pathname}/store/`, 186 | { 187 | method: "POST", 188 | headers: { 189 | "Content-Type": "application/json", 190 | "User-Agent": "Cloudflare-Worker/1.0", 191 | "X-Sentry-Auth": `Sentry sentry_version=7, sentry_client=Cloudflare-Worker/1.0, sentry_key=${this.sentryUrl.username}`, 192 | }, 193 | /* eslint-disable @typescript-eslint/naming-convention */ 194 | body: JSON.stringify({ 195 | logger: "worker", 196 | platform: "javascript", 197 | level: options.level, 198 | extra: options.extra, 199 | fingerprint: options.fingerprint, 200 | exception: { 201 | values: [ 202 | { 203 | type: error.name, 204 | value: error.message, 205 | // Ref: https://develop.sentry.dev/sdk/event-payloads/stacktrace 206 | stacktrace: { 207 | frames: getErrorStack(error, this.captureException).map( 208 | (callSite) => ({ 209 | function: callSite.getFunctionName(), 210 | filename: this.filePrefix + callSite.getFileName(), 211 | lineno: callSite.getLineNumber(), 212 | colno: callSite.getColumnNumber(), 213 | in_app: !callSite.isNative(), 214 | vars: { 215 | this: callSite.getTypeName(), 216 | }, 217 | }) 218 | ), 219 | }, 220 | }, 221 | ], 222 | }, 223 | tags: options.tags, 224 | user: { 225 | id: options.user?.id, 226 | email: options.user?.email, 227 | username: options.user?.username, 228 | ip_address: options.user?.ip, 229 | }, 230 | request: { 231 | url: options.request?.url, 232 | method: options.request?.method, 233 | headers: options.request?.headers, 234 | query_string: options.request?.query, 235 | cookies: options.request?.cookies, 236 | env: options.request?.env, 237 | }, 238 | breadcrumbs: options.breadcrumbs, 239 | server_name: options.serverName, 240 | transaction: options.transaction, 241 | release: options.release, 242 | dist: options.dist, 243 | environment: options.environment, 244 | }), 245 | /* eslint-enable @typescript-eslint/naming-convention */ 246 | } 247 | ); 248 | 249 | return this.fetch(request); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@borderless/ts-scripts/configs/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "lib": ["ES2020", "WebWorker"], 6 | "outDir": "dist", 7 | "rootDir": "src" 8 | }, 9 | "include": ["./src/**/*"], 10 | "exclude": ["./src/**/*.spec.ts"] 11 | } 12 | --------------------------------------------------------------------------------