├── .changeset ├── README.md └── config.json ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── examples ├── wrangler-basic │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── handler.ts │ │ └── index.ts │ ├── tsconfig.json │ └── wrangler.toml ├── wrangler-esbuild │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── esbuild.config.js │ ├── package.json │ ├── src │ │ ├── handler.ts │ │ └── index.ts │ ├── tsconfig.json │ └── wrangler.toml ├── wrangler-rollup │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── handler.ts │ │ └── index.ts │ ├── tsconfig.json │ └── wrangler.toml ├── wrangler-vite │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── handler.ts │ │ └── index.ts │ ├── tsconfig.json │ ├── vite.config.ts │ └── wrangler.toml └── wrangler-webpack │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── handler.ts │ └── index.ts │ ├── tsconfig.json │ ├── webpack.config.js │ └── wrangler.toml ├── package.json ├── packages ├── config-jest │ ├── CHANGELOG.md │ ├── miniflare.config.ts │ └── package.json ├── config-typescript │ ├── CHANGELOG.md │ ├── base.json │ ├── node.json │ ├── package.json │ ├── workers-app.json │ └── workers-library.json ├── eslint-config-base │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── index.js │ └── package.json └── toucan-js │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── jest.config.ts │ ├── package.json │ ├── rollup.config.js │ ├── src │ ├── client.ts │ ├── eventBuilder.ts │ ├── index.ts │ ├── integration.ts │ ├── integrations │ │ ├── index.ts │ │ ├── linkedErrors.ts │ │ ├── requestData.ts │ │ └── zod │ │ │ ├── integration.ts │ │ │ ├── zoderrors.spec.ts │ │ │ └── zoderrors.ts │ ├── sdk.ts │ ├── stacktrace.ts │ ├── transports │ │ ├── fetch.ts │ │ ├── index.ts │ │ └── types.ts │ ├── types.ts │ └── utils.ts │ ├── test │ ├── __snapshots__ │ │ └── index.spec.ts.snap │ ├── global.d.ts │ ├── helpers.ts │ ├── index.spec.ts │ └── tsconfig.json │ └── tsconfig.json ├── renovate.json ├── tsconfig.json ├── turbo.json └── yarn.lock /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | test 3 | webpack.config.js 4 | esbuild.config.js 5 | rollup.config.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['base'], 4 | }; 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # All files will always have LF line endings on checkout. 2 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | if: ${{ github.repository_owner == 'robertcepa' }} 13 | name: Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout Repo 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 16.18 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 16.18 23 | 24 | - name: Install Dependencies 25 | run: yarn 26 | 27 | - name: Create Release Pull Request or Publish to npm 28 | id: changesets 29 | uses: changesets/action@v1 30 | with: 31 | publish: yarn release 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | install-build-test: 11 | name: 'Test' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout Repo 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Node.js 16.18 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 16.18 21 | 22 | - name: Install Dependencies 23 | run: yarn 24 | 25 | - name: Build & Test & Lint 26 | run: yarn turbo run build test lint 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .turbo 4 | .cache 5 | yarn-error.log -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | dist 3 | node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true, 4 | "organizeImportsSkipDestructiveCodeActions": true 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 | 5 | [![npm version](https://img.shields.io/npm/v/toucan-js)](https://www.npmjs.com/package/toucan-js) 6 | [![npm version](https://img.shields.io/npm/dw/toucan-js)](https://www.npmjs.com/package/toucan-js) 7 | [![npm version](https://img.shields.io/npm/types/toucan-js)](https://www.npmjs.com/package/toucan-js) 8 | 9 | # toucan-js 10 | 11 | **Toucan** is a [Sentry](https://docs.sentry.io/) client for [Cloudflare Workers](https://developers.cloudflare.com/workers/) written in TypeScript. 12 | 13 | - **Reliable**: In Cloudflare Workers isolate model, it is inadvisable to [set or mutate global state within the event handler](https://developers.cloudflare.com/workers/about/how-it-works). Toucan was created with Workers' concurrent model in mind. No race-conditions, no undelivered logs, no nonsense metadata in Sentry. 14 | - **Flexible:** Supports `fetch` and `scheduled` Workers, their `.mjs` equivalents, and `Durable Objects`. 15 | - **Familiar API:** Follows [Sentry unified API guidelines](https://develop.sentry.dev/sdk/unified-api/). 16 | 17 | ## Documentation 18 | 19 | See [toucan-js](packages/toucan-js/) package. 20 | 21 | ## Examples 22 | 23 | This repository provides starters written in TypeScript, with [source maps](https://docs.sentry.io/platforms/javascript/sourcemaps/) support and local live reloading experience using [open source Cloudflare Workers runtime](https://github.com/cloudflare/workerd). 24 | 25 | - [wrangler-basic](examples/wrangler-basic/) 26 | - [wrangler-esbuild](examples/wrangler-esbuild/) 27 | - [wrangler-rollup](examples/wrangler-rollup/) 28 | - [wrangler-vite](examples/wrangler-vite/) 29 | - [wrangler-webpack](examples/wrangler-webpack/) 30 | -------------------------------------------------------------------------------- /examples/wrangler-basic/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['base'], 4 | }; 5 | -------------------------------------------------------------------------------- /examples/wrangler-basic/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # wrangler-basic 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [c29ddfa] 8 | - toucan-js@4.0.0 9 | 10 | ## 1.0.1 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [adf151f] 15 | - toucan-js@3.0.0 16 | -------------------------------------------------------------------------------- /examples/wrangler-basic/README.md: -------------------------------------------------------------------------------- 1 | # wrangler-basic starter 2 | 3 | This is an official `toucan-js` starter that uses [wrangler](https://github.com/cloudflare/wrangler2) to manage workflows and bundle code, and [sentry-cli](https://github.com/getsentry/sentry-cli) to upload sourcemaps to Sentry. 4 | 5 | ## Prerequisites 6 | 7 | - [yarn](https://yarnpkg.com/getting-started/install) installed globally. 8 | - Run `yarn` to install all packages in this project. 9 | - [Cloudflare](https://dash.cloudflare.com/sign-up) account. 10 | - Run `yarn wrangler login` to associate `wrangler` with your Cloudflare account. 11 | - [Sentry](https://sentry.io/) account. 12 | - Create a new Sentry project. Choose `javascript` as the platform. 13 | 14 | ## Client setup 15 | 16 | You will need to obtain [Sentry DSN](https://docs.sentry.io/product/sentry-basics/dsn-explainer/) for your Sentry project. Once you have it, update `SENTRY_DSN` variable in `wrangler.toml` with your value. 17 | 18 | ```toml 19 | [vars] 20 | SENTRY_DSN = "https://123:456@testorg.ingest.sentry.io/123" 21 | ``` 22 | 23 | ## Sourcemaps setup 24 | 25 | If you want to upload sourcemaps, you need to obtain 3 values and set them as environment variables. 26 | 27 | ```javascript 28 | // This is your organization slug, you can find it in Settings in Sentry dashboard 29 | export SENTRY_ORG="..." 30 | // Your project name 31 | export SENTRY_PROJECT="..." 32 | // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ and need `project:releases` and `org:read` scopes 33 | export SENTRY_AUTH_TOKEN="..." 34 | ``` 35 | 36 | ## Deployment 37 | 38 | ``` 39 | yarn deploy 40 | ``` 41 | 42 | ## Development 43 | 44 | ``` 45 | yarn start 46 | ``` 47 | 48 | Runs the worker locally. 49 | -------------------------------------------------------------------------------- /examples/wrangler-basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wrangler-basic", 3 | "version": "1.0.2", 4 | "dependencies": { 5 | "toucan-js": "^4.0.0" 6 | }, 7 | "devDependencies": { 8 | "wrangler": "2.5.0", 9 | "config-typescript": "*", 10 | "eslint-config-base": "*", 11 | "@sentry/cli": "^2.9.0" 12 | }, 13 | "private": true, 14 | "scripts": { 15 | "start": "wrangler dev --experimental-local", 16 | "deploy": "wrangler publish && yarn create-sentry-release", 17 | "build": "wrangler publish --dry-run --outdir=dist", 18 | "create-sentry-release": "yarn sentry-cli releases new \"1.0.0\" --finalize && yarn sentry-cli releases files \"1.0.0\" upload-sourcemaps ./dist", 19 | "lint": "eslint src" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/wrangler-basic/src/handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A function that throws exception. 3 | */ 4 | export function handler() { 5 | JSON.parse('not json'); 6 | } 7 | -------------------------------------------------------------------------------- /examples/wrangler-basic/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Toucan } from 'toucan-js'; 2 | import { handler } from './handler'; 3 | 4 | type Env = { 5 | SENTRY_DSN: string; 6 | }; 7 | 8 | export default { 9 | async fetch(request, env, context): Promise { 10 | const sentry = new Toucan({ 11 | dsn: env.SENTRY_DSN, 12 | release: '1.0.0', 13 | context, 14 | request, 15 | }); 16 | 17 | try { 18 | handler(); 19 | return new Response('Hello!'); 20 | } catch (e) { 21 | sentry.captureException(e); 22 | 23 | return new Response('Something went wrong! Team has been notified.', { 24 | status: 500, 25 | }); 26 | } 27 | }, 28 | } as ExportedHandler; 29 | -------------------------------------------------------------------------------- /examples/wrangler-basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config-typescript/workers-app.json", 3 | "include": ["."], 4 | "exclude": ["dist", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/wrangler-basic/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "wrangler-basic" 2 | main = "src/index.ts" 3 | compatibility_date = "2022-11-11" 4 | 5 | [vars] 6 | SENTRY_DSN = "" 7 | -------------------------------------------------------------------------------- /examples/wrangler-esbuild/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['base'], 4 | }; 5 | -------------------------------------------------------------------------------- /examples/wrangler-esbuild/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # wrangler-esbuild 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [c29ddfa] 8 | - toucan-js@4.0.0 9 | 10 | ## 1.0.1 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [adf151f] 15 | - toucan-js@3.0.0 16 | -------------------------------------------------------------------------------- /examples/wrangler-esbuild/README.md: -------------------------------------------------------------------------------- 1 | # wrangler-esbuild starter 2 | 3 | This is an official `toucan-js` starter that uses [wrangler](https://github.com/cloudflare/wrangler2) to manage workflows, [esbuild](https://esbuild.github.io/) to bundle code, and [@sentry/esbuild-plugin](https://github.com/getsentry/sentry-javascript-bundler-plugins/tree/main/packages/esbuild-plugin) to upload sourcemaps to Sentry. 4 | 5 | ## Prerequisites 6 | 7 | - [yarn](https://yarnpkg.com/getting-started/install) installed globally. 8 | - Run `yarn` to install all packages in this project. 9 | - [Cloudflare](https://dash.cloudflare.com/sign-up) account. 10 | - Run `yarn wrangler login` to associate `wrangler` with your Cloudflare account. 11 | - [Sentry](https://sentry.io/) account. 12 | - Create a new Sentry project. Choose `javascript` as the platform. 13 | 14 | ## Client setup 15 | 16 | You will need to obtain [Sentry DSN](https://docs.sentry.io/product/sentry-basics/dsn-explainer/) for your Sentry project. Once you have it, update `SENTRY_DSN` variable in `wrangler.toml` with your value. 17 | 18 | ```toml 19 | [vars] 20 | SENTRY_DSN = "https://123:456@testorg.ingest.sentry.io/123" 21 | ``` 22 | 23 | ## Sourcemaps setup 24 | 25 | If you want to upload sourcemaps, you need to obtain 3 values and set them as environment variables. 26 | 27 | ```javascript 28 | // This is your organization slug, you can find it in Settings in Sentry dashboard 29 | export SENTRY_ORG="..." 30 | // Your project name 31 | export SENTRY_PROJECT="..." 32 | // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ and need `project:releases` and `org:read` scopes 33 | export SENTRY_AUTH_TOKEN="..." 34 | ``` 35 | 36 | ## Deployment 37 | 38 | ``` 39 | yarn deploy 40 | ``` 41 | 42 | ## Development 43 | 44 | ``` 45 | yarn start 46 | ``` 47 | 48 | Runs the worker locally. 49 | -------------------------------------------------------------------------------- /examples/wrangler-esbuild/esbuild.config.js: -------------------------------------------------------------------------------- 1 | const { default: sentryEsbuildPlugin } = require('@sentry/esbuild-plugin'); 2 | 3 | require('esbuild') 4 | .build({ 5 | entryPoints: ['./src/index.ts'], 6 | outdir: './dist', 7 | bundle: true, 8 | sourcemap: true, // Source map generation must be turned on 9 | format: 'esm', 10 | plugins: 11 | process.env.SENTRY_ORG && 12 | process.env.SENTRY_PROJECT && 13 | process.env.SENTRY_AUTH_TOKEN 14 | ? [ 15 | sentryEsbuildPlugin({ 16 | org: process.env.SENTRY_ORG, 17 | project: process.env.SENTRY_PROJECT, 18 | include: './dist', 19 | // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ 20 | // and need `project:releases` and `org:read` scopes 21 | authToken: process.env.SENTRY_AUTH_TOKEN, 22 | }), 23 | ] 24 | : undefined, 25 | }) 26 | .catch(() => process.exit(1)); 27 | -------------------------------------------------------------------------------- /examples/wrangler-esbuild/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wrangler-esbuild", 3 | "version": "1.0.2", 4 | "dependencies": { 5 | "toucan-js": "^4.0.0" 6 | }, 7 | "devDependencies": { 8 | "wrangler": "2.5.0", 9 | "config-typescript": "*", 10 | "eslint-config-base": "*", 11 | "esbuild": "^0.15.16", 12 | "@sentry/esbuild-plugin": "^0.2.3" 13 | }, 14 | "private": true, 15 | "scripts": { 16 | "start": "wrangler dev --experimental-local", 17 | "deploy": "wrangler publish && yarn create-sentry-release", 18 | "build": "wrangler publish --dry-run --outdir=dist", 19 | "create-sentry-release": "yarn sentry-cli releases new \"1.0.0\" --finalize && yarn sentry-cli releases files \"1.0.0\" upload-sourcemaps ./dist", 20 | "lint": "eslint src" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/wrangler-esbuild/src/handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A function that throws exception. 3 | */ 4 | export function handler() { 5 | JSON.parse('not json'); 6 | } 7 | -------------------------------------------------------------------------------- /examples/wrangler-esbuild/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Toucan } from 'toucan-js'; 2 | import { handler } from './handler'; 3 | 4 | type Env = { 5 | SENTRY_DSN: string; 6 | }; 7 | 8 | export default { 9 | async fetch(request, env, context): Promise { 10 | const sentry = new Toucan({ 11 | dsn: env.SENTRY_DSN, 12 | context, 13 | request, 14 | }); 15 | 16 | try { 17 | handler(); 18 | return new Response('Hello!'); 19 | } catch (e) { 20 | sentry.captureException(e); 21 | 22 | return new Response('Something went wrong! Team has been notified.', { 23 | status: 500, 24 | }); 25 | } 26 | }, 27 | } as ExportedHandler; 28 | -------------------------------------------------------------------------------- /examples/wrangler-esbuild/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config-typescript/workers-app.json", 3 | "include": ["."], 4 | "exclude": ["dist", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/wrangler-esbuild/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "wrangler-esbuild" 2 | main = "dist/index.js" 3 | compatibility_date = "2022-11-11" 4 | no_bundle = true 5 | 6 | [vars] 7 | SENTRY_DSN = "" 8 | 9 | [build] 10 | command = "node esbuild.config.js" 11 | -------------------------------------------------------------------------------- /examples/wrangler-rollup/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['base'], 4 | }; 5 | -------------------------------------------------------------------------------- /examples/wrangler-rollup/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # wrangler-rollup 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [c29ddfa] 8 | - toucan-js@4.0.0 9 | 10 | ## 1.0.1 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [adf151f] 15 | - toucan-js@3.0.0 16 | -------------------------------------------------------------------------------- /examples/wrangler-rollup/README.md: -------------------------------------------------------------------------------- 1 | # wrangler-rollup starter 2 | 3 | This is an official `toucan-js` starter that uses [wrangler](https://github.com/cloudflare/wrangler2) to manage workflows, [rollup](https://rollupjs.org/guide/en/) to bundle code, and [@sentry/rollup-plugin](https://github.com/getsentry/sentry-javascript-bundler-plugins/tree/main/packages/rollup-plugin) to upload sourcemaps to Sentry. 4 | 5 | ## Prerequisites 6 | 7 | - [yarn](https://yarnpkg.com/getting-started/install) installed globally. 8 | - Run `yarn` to install all packages in this project. 9 | - [Cloudflare](https://dash.cloudflare.com/sign-up) account. 10 | - Run `yarn wrangler login` to associate `wrangler` with your Cloudflare account. 11 | - [Sentry](https://sentry.io/) account. 12 | - Create a new Sentry project. Choose `javascript` as the platform. 13 | 14 | ## Client setup 15 | 16 | You will need to obtain [Sentry DSN](https://docs.sentry.io/product/sentry-basics/dsn-explainer/) for your Sentry project. Once you have it, update `SENTRY_DSN` variable in `wrangler.toml` with your value. 17 | 18 | ```toml 19 | [vars] 20 | SENTRY_DSN = "https://123:456@testorg.ingest.sentry.io/123" 21 | ``` 22 | 23 | ## Sourcemaps setup 24 | 25 | If you want to upload sourcemaps, you need to obtain 3 values and set them as environment variables. 26 | 27 | ```javascript 28 | // This is your organization slug, you can find it in Settings in Sentry dashboard 29 | export SENTRY_ORG="..." 30 | // Your project name 31 | export SENTRY_PROJECT="..." 32 | // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ and need `project:releases` and `org:read` scopes 33 | export SENTRY_AUTH_TOKEN="..." 34 | ``` 35 | 36 | ## Deployment 37 | 38 | ``` 39 | yarn deploy 40 | ``` 41 | 42 | ## Development 43 | 44 | ``` 45 | yarn start 46 | ``` 47 | 48 | Runs the worker locally. 49 | -------------------------------------------------------------------------------- /examples/wrangler-rollup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wrangler-rollup", 3 | "version": "1.0.2", 4 | "dependencies": { 5 | "toucan-js": "^4.0.0" 6 | }, 7 | "devDependencies": { 8 | "wrangler": "2.5.0", 9 | "config-typescript": "*", 10 | "eslint-config-base": "*", 11 | "rollup": "^3.5.1", 12 | "rollup-plugin-typescript2": "^0.34.1", 13 | "@rollup/plugin-node-resolve": "^15.0.1", 14 | "@rollup/plugin-commonjs": "^25.0.3", 15 | "@sentry/rollup-plugin": "^0.2.3" 16 | }, 17 | "private": true, 18 | "scripts": { 19 | "start": "wrangler dev --experimental-local", 20 | "deploy": "wrangler publish && yarn create-sentry-release", 21 | "build": "wrangler publish --dry-run", 22 | "create-sentry-release": "yarn sentry-cli releases new \"1.0.0\" --finalize && yarn sentry-cli releases files \"1.0.0\" upload-sourcemaps ./dist", 23 | "lint": "eslint src" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/wrangler-rollup/rollup.config.js: -------------------------------------------------------------------------------- 1 | const { nodeResolve } = require('@rollup/plugin-node-resolve'); 2 | const { default: sentryRollupPlugin } = require('@sentry/rollup-plugin'); 3 | const typescript = require('rollup-plugin-typescript2'); 4 | const commonjs = require('@rollup/plugin-commonjs'); 5 | 6 | module.exports = [ 7 | { 8 | input: 'src/index.ts', 9 | plugins: [ 10 | nodeResolve(), 11 | commonjs(), 12 | typescript({ 13 | tsconfigOverride: { 14 | include: ['./src/**/*'], 15 | compilerOptions: { 16 | rootDir: 'src', 17 | outDir: 'dist', 18 | }, 19 | }, 20 | }), 21 | ...(process.env.SENTRY_ORG && 22 | process.env.SENTRY_PROJECT && 23 | process.env.SENTRY_AUTH_TOKEN 24 | ? [ 25 | sentryRollupPlugin({ 26 | org: process.env.SENTRY_ORG, 27 | project: process.env.SENTRY_PROJECT, 28 | include: './dist', 29 | // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ 30 | // and need `project:releases` and `org:read` scopes 31 | authToken: process.env.SENTRY_AUTH_TOKEN, 32 | }), 33 | ] 34 | : []), 35 | ], 36 | output: [{ sourcemap: true, dir: './dist', format: 'es' }], 37 | }, 38 | ]; 39 | -------------------------------------------------------------------------------- /examples/wrangler-rollup/src/handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A function that throws exception. 3 | */ 4 | export function handler() { 5 | JSON.parse('not json'); 6 | } 7 | -------------------------------------------------------------------------------- /examples/wrangler-rollup/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Toucan } from 'toucan-js'; 2 | import { handler } from './handler'; 3 | 4 | type Env = { 5 | SENTRY_DSN: string; 6 | }; 7 | 8 | export default { 9 | async fetch(request, env, context): Promise { 10 | const sentry = new Toucan({ 11 | dsn: env.SENTRY_DSN, 12 | context, 13 | request, 14 | }); 15 | 16 | try { 17 | handler(); 18 | return new Response('Hello!'); 19 | } catch (e) { 20 | sentry.captureException(e); 21 | 22 | return new Response('Something went wrong! Team has been notified.', { 23 | status: 500, 24 | }); 25 | } 26 | }, 27 | } as ExportedHandler; 28 | -------------------------------------------------------------------------------- /examples/wrangler-rollup/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config-typescript/workers-app.json", 3 | "include": ["."], 4 | "exclude": ["dist", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/wrangler-rollup/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "wrangler-rollup" 2 | main = "dist/index.js" 3 | compatibility_date = "2022-11-11" 4 | no_bundle = true 5 | 6 | [vars] 7 | SENTRY_DSN = "" 8 | 9 | [build] 10 | command = "yarn rollup -c" 11 | -------------------------------------------------------------------------------- /examples/wrangler-vite/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['base'], 4 | }; 5 | -------------------------------------------------------------------------------- /examples/wrangler-vite/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # wrangler-vite 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [c29ddfa] 8 | - toucan-js@4.0.0 9 | 10 | ## 1.0.1 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [adf151f] 15 | - toucan-js@3.0.0 16 | -------------------------------------------------------------------------------- /examples/wrangler-vite/README.md: -------------------------------------------------------------------------------- 1 | # wrangler-vite starter 2 | 3 | This is an official `toucan-js` starter that uses [wrangler](https://github.com/cloudflare/wrangler2) to manage workflows, [vite](https://vitejs.dev/) to bundle code, and [@sentry/vite-plugin](https://github.com/getsentry/sentry-javascript-bundler-plugins/tree/main/packages/vite-plugin) to upload sourcemaps to Sentry. 4 | 5 | ## Prerequisites 6 | 7 | - [yarn](https://yarnpkg.com/getting-started/install) installed globally. 8 | - Run `yarn` to install all packages in this project. 9 | - [Cloudflare](https://dash.cloudflare.com/sign-up) account. 10 | - Run `yarn wrangler login` to associate `wrangler` with your Cloudflare account. 11 | - [Sentry](https://sentry.io/) account. 12 | - Create a new Sentry project. Choose `javascript` as the platform. 13 | 14 | ## Client setup 15 | 16 | You will need to obtain [Sentry DSN](https://docs.sentry.io/product/sentry-basics/dsn-explainer/) for your Sentry project. Once you have it, update `SENTRY_DSN` variable in `wrangler.toml` with your value. 17 | 18 | ```toml 19 | [vars] 20 | SENTRY_DSN = "https://123:456@testorg.ingest.sentry.io/123" 21 | ``` 22 | 23 | ## Sourcemaps setup 24 | 25 | If you want to upload sourcemaps, you need to obtain 3 values and set them as environment variables. 26 | 27 | ```javascript 28 | // This is your organization slug, you can find it in Settings in Sentry dashboard 29 | export SENTRY_ORG="..." 30 | // Your project name 31 | export SENTRY_PROJECT="..." 32 | // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ and need `project:releases` and `org:read` scopes 33 | export SENTRY_AUTH_TOKEN="..." 34 | ``` 35 | 36 | ## Deployment 37 | 38 | ``` 39 | yarn deploy 40 | ``` 41 | 42 | ## Development 43 | 44 | ``` 45 | yarn start 46 | ``` 47 | 48 | Runs the worker locally. 49 | -------------------------------------------------------------------------------- /examples/wrangler-vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wrangler-vite", 3 | "version": "1.0.2", 4 | "dependencies": { 5 | "toucan-js": "^4.0.0" 6 | }, 7 | "devDependencies": { 8 | "wrangler": "2.5.0", 9 | "config-typescript": "*", 10 | "eslint-config-base": "*", 11 | "vite": "^3.2.5", 12 | "@sentry/vite-plugin": "^0.2.3" 13 | }, 14 | "private": true, 15 | "scripts": { 16 | "start": "wrangler dev --experimental-local", 17 | "deploy": "wrangler publish", 18 | "build": "wrangler publish --dry-run", 19 | "lint": "eslint src" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/wrangler-vite/src/handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A function that throws exception. 3 | */ 4 | export function handler() { 5 | JSON.parse('not json'); 6 | } 7 | -------------------------------------------------------------------------------- /examples/wrangler-vite/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Toucan } from 'toucan-js'; 2 | import { handler } from './handler'; 3 | 4 | type Env = { 5 | SENTRY_DSN: string; 6 | }; 7 | 8 | export default { 9 | async fetch(request, env, context): Promise { 10 | const sentry = new Toucan({ 11 | dsn: env.SENTRY_DSN, 12 | context, 13 | request, 14 | }); 15 | 16 | try { 17 | handler(); 18 | return new Response('Hello!'); 19 | } catch (e) { 20 | sentry.captureException(e); 21 | 22 | return new Response('Something went wrong! Team has been notified.', { 23 | status: 500, 24 | }); 25 | } 26 | }, 27 | } as ExportedHandler; 28 | -------------------------------------------------------------------------------- /examples/wrangler-vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config-typescript/workers-app.json", 3 | "include": ["."], 4 | "exclude": ["dist", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/wrangler-vite/vite.config.ts: -------------------------------------------------------------------------------- 1 | import sentryVitePlugin from '@sentry/vite-plugin'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | build: { 6 | sourcemap: true, 7 | lib: { 8 | entry: 'src/index.ts', 9 | formats: ['es'], 10 | fileName: 'index', 11 | }, 12 | }, 13 | plugins: 14 | process.env.SENTRY_ORG && 15 | process.env.SENTRY_PROJECT && 16 | process.env.SENTRY_AUTH_TOKEN 17 | ? [ 18 | sentryVitePlugin({ 19 | org: process.env.SENTRY_ORG, 20 | project: process.env.SENTRY_PROJECT, 21 | include: './dist', 22 | // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ 23 | // and need `project:releases` and `org:read` scopes 24 | authToken: process.env.SENTRY_AUTH_TOKEN, 25 | ext: ['mjs', 'map'], 26 | }), 27 | ] 28 | : undefined, 29 | }); 30 | -------------------------------------------------------------------------------- /examples/wrangler-vite/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "wrangler-vite" 2 | main = "dist/index.mjs" 3 | compatibility_date = "2022-11-11" 4 | no_bundle = true 5 | 6 | [vars] 7 | SENTRY_DSN = "" 8 | 9 | [build] 10 | command = "yarn vite build" 11 | -------------------------------------------------------------------------------- /examples/wrangler-webpack/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['base'], 4 | }; 5 | -------------------------------------------------------------------------------- /examples/wrangler-webpack/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # wrangler-webpack 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [c29ddfa] 8 | - toucan-js@4.0.0 9 | 10 | ## 1.0.1 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [adf151f] 15 | - toucan-js@3.0.0 16 | -------------------------------------------------------------------------------- /examples/wrangler-webpack/README.md: -------------------------------------------------------------------------------- 1 | # wrangler-webpack starter 2 | 3 | This is an official `toucan-js` starter that uses [wrangler](https://github.com/cloudflare/wrangler2) to manage workflows, [webpack](https://webpack.js.org/) to bundle code, and [@sentry/webpack-plugin](https://github.com/getsentry/sentry-javascript-bundler-plugins/tree/main/packages/webpack-plugin) to upload sourcemaps to Sentry. 4 | 5 | ## Prerequisites 6 | 7 | - [yarn](https://yarnpkg.com/getting-started/install) installed globally. 8 | - Run `yarn` to install all packages in this project. 9 | - [Cloudflare](https://dash.cloudflare.com/sign-up) account. 10 | - Run `yarn wrangler login` to associate `wrangler` with your Cloudflare account. 11 | - [Sentry](https://sentry.io/) account. 12 | - Create a new Sentry project. Choose `javascript` as the platform. 13 | 14 | ## Client setup 15 | 16 | You will need to obtain [Sentry DSN](https://docs.sentry.io/product/sentry-basics/dsn-explainer/) for your Sentry project. Once you have it, update `SENTRY_DSN` variable in `wrangler.toml` with your value. 17 | 18 | ```toml 19 | [vars] 20 | SENTRY_DSN = "https://123:456@testorg.ingest.sentry.io/123" 21 | ``` 22 | 23 | ## Sourcemaps setup 24 | 25 | If you want to upload sourcemaps, you need to obtain 3 values and set them as environment variables. 26 | 27 | ```javascript 28 | // This is your organization slug, you can find it in Settings in Sentry dashboard 29 | export SENTRY_ORG="..." 30 | // Your project name 31 | export SENTRY_PROJECT="..." 32 | // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ and need `project:releases` and `org:read` scopes 33 | export SENTRY_AUTH_TOKEN="..." 34 | ``` 35 | 36 | ## Deployment 37 | 38 | ``` 39 | yarn deploy 40 | ``` 41 | 42 | ## Development 43 | 44 | ``` 45 | yarn start 46 | ``` 47 | 48 | Runs the worker locally. 49 | -------------------------------------------------------------------------------- /examples/wrangler-webpack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wrangler-webpack", 3 | "version": "1.0.2", 4 | "dependencies": { 5 | "toucan-js": "^4.0.0" 6 | }, 7 | "devDependencies": { 8 | "wrangler": "2.5.0", 9 | "webpack": "^5.75.0", 10 | "webpack-cli": "^5.0.0", 11 | "@sentry/webpack-plugin": "^1.20.0", 12 | "ts-loader": "^9.4.2", 13 | "config-typescript": "*", 14 | "eslint-config-base": "*" 15 | }, 16 | "private": true, 17 | "scripts": { 18 | "start": "wrangler dev --experimental-local", 19 | "deploy": "wrangler publish", 20 | "build": "wrangler publish --dry-run", 21 | "lint": "eslint src" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/wrangler-webpack/src/handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A function that throws exception. 3 | */ 4 | export function handler() { 5 | JSON.parse('not json'); 6 | } 7 | -------------------------------------------------------------------------------- /examples/wrangler-webpack/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Toucan } from 'toucan-js'; 2 | import { handler } from './handler'; 3 | 4 | type Env = { 5 | SENTRY_DSN: string; 6 | }; 7 | 8 | export default { 9 | async fetch(request, env, context): Promise { 10 | const sentry = new Toucan({ 11 | dsn: env.SENTRY_DSN, 12 | context, 13 | request, 14 | }); 15 | 16 | try { 17 | handler(); 18 | return new Response('Hello!'); 19 | } catch (e) { 20 | sentry.captureException(e); 21 | 22 | return new Response('Something went wrong! Team has been notified.', { 23 | status: 500, 24 | }); 25 | } 26 | }, 27 | } as ExportedHandler; 28 | -------------------------------------------------------------------------------- /examples/wrangler-webpack/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config-typescript/workers-app.json", 3 | "include": ["."], 4 | "exclude": ["dist", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/wrangler-webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const SentryWebpackPlugin = require('@sentry/webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: './src/index.ts', 6 | mode: 'none', 7 | devtool: 'source-map', 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.tsx?$/, 12 | use: 'ts-loader', 13 | exclude: /node_modules/, 14 | }, 15 | ], 16 | }, 17 | resolve: { 18 | extensions: ['.tsx', '.ts', '.js'], 19 | }, 20 | experiments: { 21 | outputModule: true, 22 | }, 23 | plugins: 24 | process.env.SENTRY_ORG && 25 | process.env.SENTRY_PROJECT && 26 | process.env.SENTRY_AUTH_TOKEN 27 | ? [ 28 | new SentryWebpackPlugin({ 29 | org: process.env.SENTRY_ORG, 30 | project: process.env.SENTRY_PROJECT, 31 | // Auth tokens can be obtained from https://sentry.io/settings/account/api/auth-tokens/ 32 | // and need `project:releases` and `org:read` scopes 33 | authToken: process.env.SENTRY_AUTH_TOKEN, 34 | include: './dist', 35 | }), 36 | ] 37 | : undefined, 38 | output: { 39 | module: true, 40 | filename: 'index.js', 41 | path: path.resolve(__dirname, 'dist'), 42 | library: { 43 | type: 'module', 44 | }, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /examples/wrangler-webpack/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "wrangler-webpack" 2 | main = "dist/index.js" 3 | compatibility_date = "2022-11-11" 4 | no_bundle = true 5 | 6 | [vars] 7 | SENTRY_DSN = "" 8 | 9 | [build] 10 | command = "yarn webpack" 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "packages/*", 5 | "examples/*" 6 | ], 7 | "scripts": { 8 | "build": "turbo run build", 9 | "clean": "turbo run clean", 10 | "dev": "turbo run dev --no-cache --parallel --continue", 11 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 12 | "lint": "turbo run lint", 13 | "test": "turbo run test", 14 | "release": "turbo run build test lint && yarn changeset publish" 15 | }, 16 | "devDependencies": { 17 | "@changesets/cli": "^2.25.2", 18 | "prettier": "^3.0.0", 19 | "prettier-plugin-organize-imports": "^4.0.0", 20 | "turbo": "^1.6.3", 21 | "typescript": "5.1.6" 22 | }, 23 | "engines": { 24 | "node": ">=16.18" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/config-jest/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # config-jest 2 | 3 | ## 0.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 86c2be8: Update dependency miniflare to v2.12.1 8 | - d697e6f: Update dependency ts-jest to v29.0.5 9 | -------------------------------------------------------------------------------- /packages/config-jest/miniflare.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from 'ts-jest'; 2 | 3 | const jestConfig: JestConfigWithTsJest = { 4 | preset: 'ts-jest/presets/default-esm', 5 | testEnvironment: 'miniflare', 6 | testEnvironmentOptions: { 7 | modules: 'true', 8 | }, 9 | moduleNameMapper: { 10 | '^(\\.{1,2}/.*)\\.js$': '$1', 11 | }, 12 | transform: { 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | tsconfig: './test/tsconfig.json', 17 | useESM: true, 18 | }, 19 | ], 20 | }, 21 | }; 22 | 23 | export default jestConfig; 24 | -------------------------------------------------------------------------------- /packages/config-jest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "config-jest", 3 | "version": "0.0.1", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "devDependencies": { 10 | "ts-jest": "29.1.1" 11 | }, 12 | "peerDependencies": { 13 | "jest": "29.6.1", 14 | "miniflare": "3.20240701.0", 15 | "jest-environment-miniflare": "2.11.0" 16 | }, 17 | "scripts": { 18 | "lint": "eslint" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/config-typescript/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # config-typescript 2 | 3 | ## 0.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 34fc6d0: Update dependency @cloudflare/workers-types to v3.19.0 8 | -------------------------------------------------------------------------------- /packages/config-typescript/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationMap": true, 7 | "noImplicitThis": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "alwaysStrict": true, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "sourceMap": true, 14 | "preserveConstEnums": true, 15 | "moduleResolution": "node", 16 | "allowSyntheticDefaultImports": true, 17 | "esModuleInterop": true, 18 | "resolveJsonModule": true, 19 | "lib": ["ESNext"] 20 | }, 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/config-typescript/node.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "NodeJS", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "types": ["node"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/config-typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "config-typescript", 3 | "version": "0.0.1", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "dependencies": { 10 | "@cloudflare/workers-types": "4.20230307.0" 11 | }, 12 | "scripts": { 13 | "lint": "eslint" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/config-typescript/workers-app.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Workers Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "declaration": false, 7 | "declarationMap": false, 8 | "module": "ESNext", 9 | "target": "ESNext", 10 | "types": ["@cloudflare/workers-types"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/config-typescript/workers-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Workers Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "types": ["@cloudflare/workers-types"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/eslint-config-base/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['base'], 4 | env: { 5 | node: true, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/eslint-config-base/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # eslint-config-base 2 | 3 | ## 0.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 199b3c7: Update dependency eslint-config-turbo to ^0.0.9 8 | -------------------------------------------------------------------------------- /packages/eslint-config-base/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['@typescript-eslint'], 4 | extends: [ 5 | 'eslint:recommended', 6 | 'turbo', 7 | 'prettier', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 10 | ], 11 | parserOptions: { 12 | project: ['./tsconfig.json'], 13 | }, 14 | rules: { 15 | '@typescript-eslint/require-await': 'off', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/eslint-config-base/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-config-base", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "eslint": "^8.29.0", 9 | "eslint-config-prettier": "^9.0.0", 10 | "eslint-config-turbo": "^1.0.0", 11 | "@typescript-eslint/eslint-plugin": "^6.0.0", 12 | "@typescript-eslint/parser": "^6.0.0" 13 | }, 14 | "publishConfig": { 15 | "access": "public" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/toucan-js/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['base'], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/toucan-js/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # toucan-js 2 | 3 | ## 4.1.1 4 | 5 | ### Patch Changes 6 | 7 | - 134c1a4: chore: Export Zod errors integration and add upstream improvements 8 | 9 | - Adds improvements based on feedback I got while PR'ing this to sentry-javascript: https://github.com/getsentry/sentry-javascript/pull/15111 10 | - Exports zodErrorsIntegration in the root index.ts (missed this in the original PR) 11 | 12 | ## 4.1.0 13 | 14 | ### Minor Changes 15 | 16 | - a0138f7: feat: Add zodErrorsIntegration 17 | 18 | This integration improves the format of errors recorded to Sentry when using Zod 19 | 20 | ## 4.0.0 21 | 22 | ### Major Changes 23 | 24 | - c29ddfa: This release upgrades the underlying Sentry SDKs to v8. 25 | 26 | - Toucan now extends [ScopeClass](https://github.com/getsentry/sentry-javascript/blob/master/packages/core/src/scope.ts) instead of Hub. 27 | - Class-based integrations have been removed in Sentry v8. Toucan adapts to this change by renaming: 28 | - `Dedupe` integration to `dedupeIntegration` 29 | - `ExtraErrorData` integration to `extraErrorDataIntegration` 30 | - `RewriteFrames` integration to `rewriteFramesIntegration` 31 | - `SessionTiming` integration to `sessionTimingIntegration` 32 | - `LinkedErrors` integration to `linkedErrorsIntegration` 33 | - `RequestData` integration to `requestDataIntegration` 34 | - Additionally, `Transaction` integration is no longer provided. 35 | - Toucan instance can now be deeply copied using `Toucan.clone()`. 36 | 37 | Refer to [Sentry v8 release notes](https://github.com/getsentry/sentry-javascript/releases/tag/8.0.0) and [Sentry v7->v8](https://github.com/getsentry/sentry-javascript/blob/8.0.0/MIGRATION.md) for additional context. 38 | 39 | ## 3.4.0 40 | 41 | ### Minor Changes 42 | 43 | - 2c75016: Update sentry dependencies to 7.112.2 44 | 45 | ## 3.3.1 46 | 47 | ### Patch Changes 48 | 49 | - 9a23541: Update sentry dependencies to v7.76.0 50 | - 8d7b40f: fix: query parameter with camelCase key becomes undefined 51 | 52 | ## 3.3.0 53 | 54 | ### Minor Changes 55 | 56 | - 948583b: Add support for Cron check-ins 57 | 58 | ### Patch Changes 59 | 60 | - 65ba3f4: Update sentry dependencies to v7.70.0 61 | 62 | ## 3.2.3 63 | 64 | ### Patch Changes 65 | 66 | - c7c0b64: Update sentry dependencies to v7.65.0 67 | 68 | ## 3.2.2 69 | 70 | ### Patch Changes 71 | 72 | - 19eaef2: Update sentry dependencies to v7.63.0 73 | 74 | ## 3.2.1 75 | 76 | ### Patch Changes 77 | 78 | - 321ecdd: Update sentry dependencies to v7.61.0 79 | 80 | ## 3.2.0 81 | 82 | ### Minor Changes 83 | 84 | - 58abdc4: You can now wrap fetch by passing fetcher to transportOptions. 85 | - 87e50c9: Add setEnabled method 86 | - 66f08ca: The following integrations are now re-exported from `toucan-js` for type compatibility: `Dedupe`, `ExtraErrorData`, `RewriteFrames`, `SessionTiming`, `Transaction`. 87 | 88 | ### Patch Changes 89 | 90 | - ec953a9: Update sentry-javascript monorepo to v7.43.0 91 | - df4eeea: Update sentry-javascript monorepo to v7.42.0 92 | - 86c2be8: Update dependency miniflare to v2.12.1 93 | - 17b22f1: Update dependency rollup to v3.19.1 94 | - cde3c15: Update dependency @rollup/plugin-commonjs to v24 95 | - ac869b6: Update dependency @rollup/plugin-commonjs to v23.0.7 96 | - d697e6f: Update dependency ts-jest to v29.0.5 97 | - c478f14: Update dependency @rollup/plugin-replace to v5.0.2 98 | 99 | ## 3.1.0 100 | 101 | ### Minor Changes 102 | 103 | - 5f6cea5: Update Sentry dependencies to 7.28.1 104 | 105 | ## 3.0.0 106 | 107 | ### Major Changes 108 | 109 | - adf151f: This is a complete rewrite of `toucan-js`. The goal of this update is to reuse more components from [@sentry/core](https://github.com/getsentry/sentry-javascript/tree/master/packages/core), fix long-standing issues with source maps, and provide starters using various bundlers (esbuild, rollup, vite, webpack). 110 | 111 | The good news is that `toucan-js` now supports pretty much all SDK options and methods provided in official Sentry SDKs for JavaScript that you all are used to and love. 112 | 113 | The bad news is that I may fail to document all breaking changes, because `toucan-js` now delegates to a lot of code written by someone else. So use this release with caution. :) 114 | 115 | - All methods and options available on [Hub](https://github.com/getsentry/sentry-javascript/blob/master/packages/core/src/hub.ts) that previously weren't available or didn't work are now supported. This includes integrations! 116 | - On integrations: some integrations from [@sentry/integrations](https://github.com/getsentry/sentry-javascript/tree/master/packages/integrations) might not work because they use globals, or modify global runtime methods (such as console.log). Refer to [README file](https://github.com/robertcepa/toucan-js) that documents all supported integrations. 117 | - This monorepo now provides quick starts written in TypeScript, with [source maps](https://docs.sentry.io/platforms/javascript/sourcemaps/) support and local live reloading experience using [open source Cloudflare Workers runtime](https://github.com/cloudflare/workerd). 118 | 119 | - [wrangler-basic](examples/wrangler-basic/) 120 | - [wrangler-esbuild](examples/wrangler-esbuild/) 121 | - [wrangler-rollup](examples/wrangler-rollup/) 122 | - [wrangler-vite](examples/wrangler-vite/) 123 | - [wrangler-webpack](examples/wrangler-webpack/) 124 | 125 | - `Toucan` client is no longer a default export. It is now a named export. 126 | 127 | Before: 128 | 129 | ```typescript 130 | import Toucan from 'toucan-js'; 131 | ``` 132 | 133 | After: 134 | 135 | ```typescript 136 | import { Toucan } from 'toucan-js'; 137 | ``` 138 | 139 | - `OtherOptions` type has been removed. Use `Options` type instead. `Options` type isn't a discriminated union anymore and contains all types. 140 | 141 | - `event` option has been removed. Use `context` option instead. 142 | - `context.request` is no longer used to track request data. If you want to track request data, use top-level `request` option instead. 143 | - `allowedCookies`, `allowedHeaders`, `allowedSearchParams` are no longer top level options, but options on new `RequestData` integration that is exported from the SDK. The Toucan client provides a shortcut for these options as `requestDataOptions` top level option. To migrate, either move them to `requestDataOptions`, or pass them to `RequestData` integration. Additionally, they now support boolean values, where `true` allows everything, and `false` denies everything. 144 | 145 | Before: 146 | 147 | ```typescript 148 | import Toucan from 'toucan-js'; 149 | 150 | const sentry = new Toucan({ 151 | dsn: '...', 152 | context, 153 | allowedCookies: ['myCookie'], 154 | allowedHeaders: ['user-agent'], 155 | allowedSearchParams: ['utm-source'], 156 | }); 157 | ``` 158 | 159 | After (option 1): 160 | 161 | ```typescript 162 | import { Toucan } from 'toucan-js'; 163 | 164 | const sentry = new Toucan({ 165 | dsn: '...', 166 | context, 167 | requestDataOptions: { 168 | allowedCookies: ['myCookie'], 169 | allowedHeaders: ['user-agent'], 170 | allowedSearchParams: ['utm-source'], 171 | }, 172 | }); 173 | ``` 174 | 175 | After (option 2): 176 | 177 | ```typescript 178 | import { Toucan, RequestData } from 'toucan-js'; 179 | 180 | const sentry = new Toucan({ 181 | dsn: '...', 182 | context, 183 | integrations: [new RequestData({{ 184 | allowedCookies: ['myCookie'], 185 | allowedHeaders: ['user-agent'], 186 | allowedSearchParams: ['utm-source'], 187 | }})], 188 | }); 189 | ``` 190 | 191 | - `tracesSampleRate` and `tracesSampler` options no longer affect Sentry events. They only affect transactions. If you want to sample sentry events, use `sampleRate` option. Refer to https://docs.sentry.io/platforms/javascript/configuration/sampling/ for more information. 192 | - `pkg` option has been removed. 193 | - `rewriteFrames` option has been removed. To migrate, use `RewriteFrames` integration from [@sentry/integrations](https://github.com/getsentry/sentry-javascript/tree/master/packages/integrations). 194 | 195 | Before 196 | 197 | ```typescript 198 | import Toucan from 'toucan-js'; 199 | 200 | const sentry = new Toucan({ 201 | dsn: '...', 202 | context, 203 | rewriteFrames: { 204 | root: '/', 205 | }, 206 | }); 207 | ``` 208 | 209 | After 210 | 211 | ```typescript 212 | import { RewriteFrames } from '@sentry/integrations'; 213 | import { Toucan } from 'toucan-js'; 214 | 215 | const sentry = new Toucan({ 216 | dsn: '...', 217 | context, 218 | integrations: [new RewriteFrames({ root: '/' })], 219 | }); 220 | ``` 221 | -------------------------------------------------------------------------------- /packages/toucan-js/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Robert Cepa 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/toucan-js/README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 | 5 | [![npm version](https://img.shields.io/npm/v/toucan-js)](https://www.npmjs.com/package/toucan-js) 6 | [![npm version](https://img.shields.io/npm/dw/toucan-js)](https://www.npmjs.com/package/toucan-js) 7 | [![npm version](https://img.shields.io/npm/types/toucan-js)](https://www.npmjs.com/package/toucan-js) 8 | 9 | # toucan-js 10 | 11 | **Toucan** is a [Sentry](https://docs.sentry.io/) client for [Cloudflare Workers](https://developers.cloudflare.com/workers/) written in TypeScript. 12 | 13 | - **Reliable**: In Cloudflare Workers isolate model, it is inadvisable to [set or mutate global state within the event handler](https://developers.cloudflare.com/workers/about/how-it-works). Toucan was created with Workers' concurrent model in mind. No race-conditions, no undelivered logs, no nonsense metadata in Sentry. 14 | - **Flexible:** Supports `fetch` and `scheduled` Workers, their `.mjs` equivalents, and `Durable Objects`. 15 | - **Familiar API:** Follows [Sentry unified API guidelines](https://develop.sentry.dev/sdk/unified-api/). 16 | 17 | ## Features 18 | 19 | This SDK provides all options and methods of [ScopeClass](https://github.com/getsentry/sentry-javascript/blob/master/packages/core/src/scope.ts) and additionally: 20 | 21 | ### Additional constructor options 22 | 23 | | Option | Type | Description | 24 | | ------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 25 | | context | Context | This can be any object that contains [waitUntil](https://developers.cloudflare.com/workers/about/tips/fetch-event-lifecycle/). It can be [FetchEvent](https://developers.cloudflare.com/workers/runtime-apis/fetch-event), [ScheduledEvent](https://developers.cloudflare.com/workers/runtime-apis/scheduled-event), [DurableObjectState](https://developers.cloudflare.com/workers/runtime-apis/durable-objects), or [.mjs context](https://community.cloudflare.com/t/2021-4-15-workers-runtime-release-notes/261917). | 26 | | request | Request | If set, the SDK will send information about incoming requests to Sentry. By default, only the request method and request origin + pathname are sent. If you want to include more data, you need to use `requestDataOptions` option. | 27 | | requestDataOptions | RequestDataOptions | Object containing allowlist for specific parts of request. Refer to sensitive data section below. | 28 | 29 | ### Constructor options overrides 30 | 31 | #### Transport options 32 | 33 | On top of base `transportOptions` you can pass additional configuration: 34 | 35 | | Option | Type | Description | 36 | | ------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 37 | | headers | Record | Custom headers passed to fetch. | 38 | | fetcher | typeof fetch | Custom fetch function. This can be useful for tests or when the global `fetch` used by `toucan-js` doesn't satisfy your use-cases. Note that custom fetcher must conform to `fetch` interface. | 39 | 40 | ### Additional methods 41 | 42 | - `Toucan.setEnabled(enabled: boolean): void`: Can be used to disable and again enable the SDK later in your code. 43 | - `Toucan.setRequestBody(body: unknown): void`: Attaches request body to future events. `body` can be anything serializable. 44 | 45 | ## Integrations 46 | 47 | You can use custom integrations to enhance `toucan-js` as you would any other Sentry SDK. Some integrations are provided in various [Sentry packages](https://github.com/getsentry/sentry-javascript/tree/develop/packages), and you can also write your own! To ensure an integration will work properly in `toucan-js`, it must: 48 | 49 | - not enhance or wrap global runtime methods (such as `console.log`). 50 | - not use runtime APIs that aren't available in Cloudflare Workers (NodeJS runtime functions, `window` object, etc...). 51 | 52 | Supported integrations from [@sentry/core](https://github.com/getsentry/sentry-javascript/tree/develop/packages/core) are re-exported from `toucan-js`: 53 | 54 | - [dedupeIntegration](https://github.com/getsentry/sentry-javascript/blob/master/packages/integrations/src/dedupe.ts) 55 | - [extraErrorDataIntegration](https://github.com/getsentry/sentry-javascript/blob/master/packages/integrations/src/extraerrordata.ts) 56 | - [rewriteFramesIntegration](https://github.com/getsentry/sentry-javascript/blob/master/packages/integrations/src/rewriteframes.ts) 57 | - [sessionTimingIntegration](https://github.com/getsentry/sentry-javascript/blob/master/packages/integrations/src/sessiontiming.ts) 58 | 59 | `toucan-js` also provides 2 integrations that are enabled by default, but are provided if you need to reconfigure them: 60 | 61 | - [linkedErrorsIntegration](src/integrations/linkedErrors.ts) 62 | - [requestDataIntegration](src/integrations/requestData.ts) 63 | - [zodErrorsIntegration](src/integrations/zod/zoderrors.ts) 64 | 65 | ### Custom integration example: 66 | 67 | ```ts 68 | import { Toucan, rewriteFramesIntegration } from 'toucan-js'; 69 | 70 | type Env = { 71 | SENTRY_DSN: string; 72 | }; 73 | 74 | export default { 75 | async fetch(request, env, context): Promise { 76 | const sentry = new Toucan({ 77 | dsn: env.SENTRY_DSN, 78 | context, 79 | request, 80 | integrations: [rewriteFramesIntegration({ root: '/' })], 81 | }); 82 | 83 | ... 84 | }, 85 | } as ExportedHandler; 86 | ``` 87 | 88 | ## Sensitive data 89 | 90 | By default, Toucan does not send any request data that might contain [PII (Personally Identifiable Information)](https://docs.sentry.io/data-management/sensitive-data/) to Sentry. 91 | 92 | This includes: 93 | 94 | - request headers 95 | - request cookies 96 | - request search params 97 | - request body 98 | - user's IP address (read from `CF-Connecting-Ip` header) 99 | 100 | You will need to explicitly allow these data using: 101 | 102 | - `allowedHeaders` option (array of headers or Regex or boolean) 103 | - `allowedCookies` option (array of cookies or Regex or boolean) 104 | - `allowedSearchParams` option (array of search params or Regex or boolean) 105 | - `allowedIps` option (array of search params or Regex or boolean) 106 | 107 | These options are available on [RequestData](src/integrations/requestData.ts) integration or `requestDataOptions` option (which is passed down to [RequestData](src/integrations/requestData.ts) automatically). 108 | -------------------------------------------------------------------------------- /packages/toucan-js/jest.config.ts: -------------------------------------------------------------------------------- 1 | import miniflareConfig from 'config-jest/miniflare.config'; 2 | 3 | export default miniflareConfig; 4 | -------------------------------------------------------------------------------- /packages/toucan-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "toucan-js", 3 | "sideEffects": false, 4 | "version": "4.1.1", 5 | "description": "Cloudflare Workers client for Sentry", 6 | "main": "dist/index.cjs.js", 7 | "module": "dist/index.esm.js", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "build": "rollup -c", 11 | "dev": "rollup -c -w", 12 | "test": "yarn build && node --experimental-vm-modules ../../node_modules/jest/bin/jest.js", 13 | "lint": "eslint src" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/robertcepa/toucan-js.git" 18 | }, 19 | "keywords": [ 20 | "debugging", 21 | "errors", 22 | "exceptions", 23 | "logging", 24 | "sentry", 25 | "toucan", 26 | "toucan-js", 27 | "cloudflare", 28 | "workers", 29 | "serverless" 30 | ], 31 | "dependencies": { 32 | "@sentry/core": "8.9.2", 33 | "@sentry/utils": "8.9.2", 34 | "@sentry/types": "8.9.2" 35 | }, 36 | "devDependencies": { 37 | "@rollup/plugin-commonjs": "26.0.1", 38 | "@rollup/plugin-node-resolve": "15.1.0", 39 | "@rollup/plugin-replace": "5.0.2", 40 | "rollup": "3.26.3", 41 | "rollup-plugin-typescript2": "0.35.0", 42 | "eslint-config-base": "*", 43 | "config-typescript": "*", 44 | "config-jest": "*", 45 | "@types/jest": "29.5.3", 46 | "jest": "29.6.1", 47 | "miniflare": "3.20240701.0", 48 | "jest-environment-miniflare": "2.10.0", 49 | "ts-jest": "29.1.1", 50 | "ts-node": "10.9.1" 51 | }, 52 | "author": "robertcepa@icloud.com", 53 | "license": "MIT", 54 | "bugs": { 55 | "url": "https://github.com/robertcepa/toucan-js/issues" 56 | }, 57 | "homepage": "https://github.com/robertcepa/toucan-js#readme", 58 | "files": [ 59 | "dist", 60 | "LICENSE", 61 | "README.md" 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /packages/toucan-js/rollup.config.js: -------------------------------------------------------------------------------- 1 | const replace = require('@rollup/plugin-replace'); 2 | const typescript = require('rollup-plugin-typescript2'); 3 | 4 | const pkg = require('./package.json'); 5 | 6 | const makeExternalPredicate = (externalArr) => { 7 | if (externalArr.length === 0) { 8 | return () => false; 9 | } 10 | const pattern = new RegExp(`^(${externalArr.join('|')})($|/)`); 11 | return (id) => pattern.test(id); 12 | }; 13 | 14 | module.exports = [ 15 | // CommonJS (for Node) and ES module (for bundlers) build. 16 | { 17 | input: 'src/index.ts', 18 | external: makeExternalPredicate([ 19 | ...Object.keys(pkg.dependencies || {}), 20 | ...Object.keys(pkg.peerDependencies || {}), 21 | ]), 22 | plugins: [ 23 | replace({ 24 | __name__: pkg.name, 25 | __version__: pkg.version, 26 | }), 27 | typescript({ 28 | tsconfigOverride: { 29 | include: ['./src/**/*'], 30 | compilerOptions: { 31 | rootDir: 'src', 32 | outDir: 'dist', 33 | }, 34 | }, 35 | }), // so Rollup can convert TypeScript to JavaScript 36 | ], 37 | output: [ 38 | { file: pkg.main, format: 'cjs' }, 39 | { file: pkg.module, format: 'es' }, 40 | ], 41 | }, 42 | ]; 43 | -------------------------------------------------------------------------------- /packages/toucan-js/src/client.ts: -------------------------------------------------------------------------------- 1 | import type { Scope } from '@sentry/core'; 2 | import { ServerRuntimeClient } from '@sentry/core'; 3 | import type { Event, EventHint, SeverityLevel } from '@sentry/types'; 4 | import { resolvedSyncPromise } from '@sentry/utils'; 5 | import { eventFromMessage, eventFromUnknownInput } from './eventBuilder'; 6 | import { setupIntegrations } from './integration'; 7 | import type { Toucan } from './sdk'; 8 | import type { ToucanClientOptions } from './types'; 9 | import { setOnOptional } from './utils'; 10 | 11 | /** 12 | * The Cloudflare Workers SDK Client. 13 | */ 14 | export class ToucanClient extends ServerRuntimeClient { 15 | /** 16 | * Some functions need to access the scope (Toucan instance) this client is bound to, 17 | * but calling 'getCurrentHub()' is unsafe because it uses globals. 18 | * So we store a reference to the Hub after binding to it and provide it to methods that need it. 19 | */ 20 | #sdk: Toucan | null = null; 21 | 22 | #integrationsInitialized: boolean = false; 23 | 24 | /** 25 | * Creates a new Toucan SDK instance. 26 | * @param options Configuration options for this SDK. 27 | */ 28 | public constructor(options: ToucanClientOptions) { 29 | options._metadata = options._metadata || {}; 30 | options._metadata.sdk = options._metadata.sdk || { 31 | name: '__name__', 32 | packages: [ 33 | { 34 | name: 'npm:' + '__name__', 35 | version: '__version__', 36 | }, 37 | ], 38 | version: '__version__', 39 | }; 40 | 41 | super(options); 42 | } 43 | 44 | /** 45 | * By default, integrations are stored in a global. We want to store them in a local instance because they may have contextual data, such as event request. 46 | */ 47 | public setupIntegrations(): void { 48 | if (this._isEnabled() && !this.#integrationsInitialized && this.#sdk) { 49 | this._integrations = setupIntegrations( 50 | this._options.integrations, 51 | this.#sdk, 52 | ); 53 | this.#integrationsInitialized = true; 54 | } 55 | } 56 | 57 | public eventFromException( 58 | exception: unknown, 59 | hint?: EventHint, 60 | ): PromiseLike { 61 | return resolvedSyncPromise( 62 | eventFromUnknownInput( 63 | this.#sdk, 64 | this._options.stackParser, 65 | exception, 66 | hint, 67 | ), 68 | ); 69 | } 70 | 71 | public eventFromMessage( 72 | message: string, 73 | level: SeverityLevel = 'info', 74 | hint?: EventHint, 75 | ): PromiseLike { 76 | return resolvedSyncPromise( 77 | eventFromMessage( 78 | this._options.stackParser, 79 | message, 80 | level, 81 | hint, 82 | this._options.attachStacktrace, 83 | ), 84 | ); 85 | } 86 | 87 | protected _prepareEvent( 88 | event: Event, 89 | hint: EventHint, 90 | scope?: Scope, 91 | ): PromiseLike { 92 | event.platform = event.platform || 'javascript'; 93 | 94 | if (this.getOptions().request) { 95 | // Set 'request' on sdkProcessingMetadata to be later processed by RequestData integration 96 | event.sdkProcessingMetadata = setOnOptional(event.sdkProcessingMetadata, [ 97 | 'request', 98 | this.getOptions().request, 99 | ]); 100 | } 101 | 102 | if (this.getOptions().requestData) { 103 | // Set 'requestData' on sdkProcessingMetadata to be later processed by RequestData integration 104 | event.sdkProcessingMetadata = setOnOptional(event.sdkProcessingMetadata, [ 105 | 'requestData', 106 | this.getOptions().requestData, 107 | ]); 108 | } 109 | 110 | return super._prepareEvent(event, hint, scope); 111 | } 112 | 113 | public getSdk() { 114 | return this.#sdk; 115 | } 116 | 117 | public setSdk(sdk: Toucan) { 118 | this.#sdk = sdk; 119 | } 120 | 121 | /** 122 | * Sets the request body context on all future events. 123 | * 124 | * @param body Request body. 125 | * @example 126 | * const body = await request.text(); 127 | * toucan.setRequestBody(body); 128 | */ 129 | public setRequestBody(body: unknown) { 130 | this.getOptions().requestData = body; 131 | } 132 | 133 | /** 134 | * Enable/disable the SDK. 135 | * 136 | * @param enabled 137 | */ 138 | public setEnabled(enabled: boolean): void { 139 | this.getOptions().enabled = enabled; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /packages/toucan-js/src/eventBuilder.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Event, 3 | EventHint, 4 | Exception, 5 | Mechanism, 6 | SeverityLevel, 7 | StackFrame, 8 | StackParser, 9 | } from '@sentry/types'; 10 | import { 11 | addExceptionMechanism, 12 | addExceptionTypeValue, 13 | extractExceptionKeysForMessage, 14 | isError, 15 | isPlainObject, 16 | normalizeToSize, 17 | } from '@sentry/utils'; 18 | import type { Toucan } from './sdk'; 19 | import { containsMechanism } from './utils'; 20 | 21 | /** 22 | * Extracts stack frames from the error.stack string 23 | */ 24 | export function parseStackFrames( 25 | stackParser: StackParser, 26 | error: Error, 27 | ): StackFrame[] { 28 | return stackParser(error.stack || '', 1); 29 | } 30 | 31 | /** 32 | * There are cases where stacktrace.message is an Event object 33 | * https://github.com/getsentry/sentry-javascript/issues/1949 34 | * In this specific case we try to extract stacktrace.message.error.message 35 | */ 36 | function extractMessage(ex: Error & { message: { error?: Error } }): string { 37 | const message = ex && ex.message; 38 | if (!message) { 39 | return 'No error message'; 40 | } 41 | if (message.error && typeof message.error.message === 'string') { 42 | return message.error.message; 43 | } 44 | return message; 45 | } 46 | 47 | /** 48 | * Extracts stack frames from the error and builds a Sentry Exception 49 | */ 50 | export function exceptionFromError( 51 | stackParser: StackParser, 52 | error: Error, 53 | ): Exception { 54 | const exception: Exception = { 55 | type: error.name || error.constructor.name, 56 | value: extractMessage(error), 57 | }; 58 | 59 | const frames = parseStackFrames(stackParser, error); 60 | 61 | if (frames.length) { 62 | exception.stacktrace = { frames }; 63 | } 64 | 65 | if (exception.type === undefined && exception.value === '') { 66 | exception.value = 'Unrecoverable error caught'; 67 | } 68 | 69 | return exception; 70 | } 71 | 72 | /** 73 | * Builds and Event from a Exception 74 | */ 75 | export function eventFromUnknownInput( 76 | sdk: Toucan | null, 77 | stackParser: StackParser, 78 | exception: unknown, 79 | hint?: EventHint, 80 | ): Event { 81 | let ex: Error; 82 | const providedMechanism: Mechanism | undefined = 83 | hint && hint.data && containsMechanism(hint.data) 84 | ? hint.data.mechanism 85 | : undefined; 86 | const mechanism: Mechanism = providedMechanism ?? { 87 | handled: true, 88 | type: 'generic', 89 | }; 90 | 91 | if (!isError(exception)) { 92 | if (isPlainObject(exception)) { 93 | // This will allow us to group events based on top-level keys 94 | // which is much better than creating new group when any key/value change 95 | const message = `Non-Error exception captured with keys: ${extractExceptionKeysForMessage( 96 | exception, 97 | )}`; 98 | 99 | const client = sdk?.getClient(); 100 | const normalizeDepth = client && client.getOptions().normalizeDepth; 101 | sdk?.setExtra( 102 | '__serialized__', 103 | normalizeToSize(exception, normalizeDepth), 104 | ); 105 | 106 | ex = (hint && hint.syntheticException) || new Error(message); 107 | ex.message = message; 108 | } else { 109 | // This handles when someone does: `throw "something awesome";` 110 | // We use synthesized Error here so we can extract a (rough) stack trace. 111 | ex = (hint && hint.syntheticException) || new Error(exception as string); 112 | ex.message = exception as string; 113 | } 114 | mechanism.synthetic = true; 115 | } else { 116 | ex = exception; 117 | } 118 | 119 | const event = { 120 | exception: { 121 | values: [exceptionFromError(stackParser, ex)], 122 | }, 123 | }; 124 | 125 | addExceptionTypeValue(event, undefined, undefined); 126 | addExceptionMechanism(event, mechanism); 127 | 128 | return { 129 | ...event, 130 | event_id: hint && hint.event_id, 131 | }; 132 | } 133 | 134 | /** 135 | * Builds and Event from a Message 136 | */ 137 | export function eventFromMessage( 138 | stackParser: StackParser, 139 | message: string, 140 | level: SeverityLevel = 'info', 141 | hint?: EventHint, 142 | attachStacktrace?: boolean, 143 | ): Event { 144 | const event: Event = { 145 | event_id: hint && hint.event_id, 146 | level, 147 | message, 148 | }; 149 | 150 | if (attachStacktrace && hint && hint.syntheticException) { 151 | const frames = parseStackFrames(stackParser, hint.syntheticException); 152 | if (frames.length) { 153 | event.exception = { 154 | values: [ 155 | { 156 | value: message, 157 | stacktrace: { frames }, 158 | }, 159 | ], 160 | }; 161 | } 162 | } 163 | 164 | return event; 165 | } 166 | -------------------------------------------------------------------------------- /packages/toucan-js/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | linkedErrorsIntegration, 3 | requestDataIntegration, 4 | dedupeIntegration, 5 | extraErrorDataIntegration, 6 | rewriteFramesIntegration, 7 | sessionTimingIntegration, 8 | zodErrorsIntegration 9 | } from './integrations'; 10 | export type { LinkedErrorsOptions, RequestDataOptions } from './integrations'; 11 | export { Toucan } from './sdk'; 12 | export type { Options } from './types'; 13 | -------------------------------------------------------------------------------- /packages/toucan-js/src/integration.ts: -------------------------------------------------------------------------------- 1 | import { IntegrationIndex } from '@sentry/core/types/integration'; 2 | import type { EventHint, Event, Integration } from '@sentry/types'; 3 | import type { Toucan } from './sdk'; 4 | 5 | /** 6 | * Installs integrations on the current scope. 7 | * 8 | * @param integrations array of integration instances 9 | */ 10 | export function setupIntegrations( 11 | integrations: Integration[], 12 | sdk: Toucan, 13 | ): IntegrationIndex { 14 | const integrationIndex: IntegrationIndex = {}; 15 | 16 | integrations.forEach((integration) => { 17 | integrationIndex[integration.name] = integration; 18 | 19 | // `setupOnce` is only called the first time 20 | if (typeof integration.setupOnce === 'function') { 21 | integration.setupOnce(); 22 | } 23 | 24 | const client = sdk.getClient(); 25 | 26 | if (!client) { 27 | return; 28 | } 29 | 30 | // `setup` is run for each client 31 | if (typeof integration.setup === 'function') { 32 | integration.setup(client); 33 | } 34 | 35 | if (typeof integration.preprocessEvent === 'function') { 36 | const callback = integration.preprocessEvent.bind(integration); 37 | client.on('preprocessEvent', (event, hint) => 38 | callback(event, hint, client), 39 | ); 40 | } 41 | 42 | if (typeof integration.processEvent === 'function') { 43 | const callback = integration.processEvent.bind(integration); 44 | 45 | const processor = Object.assign( 46 | (event: Event, hint: EventHint) => callback(event, hint, client), 47 | { 48 | id: integration.name, 49 | }, 50 | ); 51 | 52 | client.addEventProcessor(processor); 53 | } 54 | }); 55 | 56 | return integrationIndex; 57 | } 58 | -------------------------------------------------------------------------------- /packages/toucan-js/src/integrations/index.ts: -------------------------------------------------------------------------------- 1 | export * from './linkedErrors'; 2 | export * from './requestData'; 3 | export { zodErrorsIntegration } from './zod/zoderrors'; 4 | export { 5 | dedupeIntegration, 6 | extraErrorDataIntegration, 7 | rewriteFramesIntegration, 8 | sessionTimingIntegration, 9 | } from '@sentry/core'; 10 | -------------------------------------------------------------------------------- /packages/toucan-js/src/integrations/linkedErrors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Event, 3 | EventHint, 4 | Exception, 5 | ExtendedError, 6 | StackParser, 7 | } from '@sentry/types'; 8 | import { isInstanceOf } from '@sentry/utils'; 9 | import { defineIntegration } from '@sentry/core'; 10 | 11 | import { exceptionFromError } from '../eventBuilder'; 12 | 13 | const DEFAULT_LIMIT = 5; 14 | 15 | export type LinkedErrorsOptions = { 16 | limit: number; 17 | }; 18 | 19 | export const linkedErrorsIntegration = defineIntegration( 20 | (options: LinkedErrorsOptions = { limit: DEFAULT_LIMIT }) => { 21 | return { 22 | name: 'LinkedErrors', 23 | processEvent: (event, hint, client) => { 24 | return handler( 25 | client.getOptions().stackParser, 26 | options.limit, 27 | event, 28 | hint, 29 | ); 30 | }, 31 | }; 32 | }, 33 | ); 34 | 35 | function handler( 36 | parser: StackParser, 37 | limit: number, 38 | event: Event, 39 | hint?: EventHint, 40 | ): Event | null { 41 | if ( 42 | !event.exception || 43 | !event.exception.values || 44 | !hint || 45 | !isInstanceOf(hint.originalException, Error) 46 | ) { 47 | return event; 48 | } 49 | const linkedErrors = walkErrorTree( 50 | parser, 51 | limit, 52 | hint.originalException as ExtendedError, 53 | ); 54 | event.exception.values = [...linkedErrors, ...event.exception.values]; 55 | return event; 56 | } 57 | 58 | export function walkErrorTree( 59 | parser: StackParser, 60 | limit: number, 61 | error: ExtendedError, 62 | stack: Exception[] = [], 63 | ): Exception[] { 64 | if (!isInstanceOf(error.cause, Error) || stack.length + 1 >= limit) { 65 | return stack; 66 | } 67 | 68 | const exception = exceptionFromError(parser, error.cause as ExtendedError); 69 | return walkErrorTree(parser, limit, error.cause as ExtendedError, [ 70 | exception, 71 | ...stack, 72 | ]); 73 | } 74 | -------------------------------------------------------------------------------- /packages/toucan-js/src/integrations/requestData.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@sentry/types'; 2 | import { defineIntegration } from '@sentry/core'; 3 | import type { Request as EventRequest } from '@sentry/types'; 4 | 5 | type Allowlist = string[] | RegExp | boolean; 6 | 7 | export type RequestDataOptions = { 8 | allowedHeaders?: Allowlist; 9 | allowedCookies?: Allowlist; 10 | allowedSearchParams?: Allowlist; 11 | allowedIps?: Allowlist; 12 | }; 13 | 14 | const defaultRequestDataOptions: RequestDataOptions = { 15 | allowedHeaders: ['CF-RAY', 'CF-Worker'], 16 | }; 17 | 18 | export const requestDataIntegration = defineIntegration( 19 | (userOptions: RequestDataOptions = {}) => { 20 | const options = { ...defaultRequestDataOptions, ...userOptions }; 21 | 22 | return { 23 | name: 'RequestData', 24 | preprocessEvent: (event) => { 25 | const { sdkProcessingMetadata } = event; 26 | 27 | if (!sdkProcessingMetadata) { 28 | return event; 29 | } 30 | 31 | if ( 32 | 'request' in sdkProcessingMetadata && 33 | sdkProcessingMetadata.request instanceof Request 34 | ) { 35 | event.request = toEventRequest( 36 | sdkProcessingMetadata.request, 37 | options, 38 | ); 39 | event.user = toEventUser( 40 | event.user ?? {}, 41 | sdkProcessingMetadata.request, 42 | options, 43 | ); 44 | } 45 | 46 | if ('requestData' in sdkProcessingMetadata) { 47 | if (event.request) { 48 | event.request.data = sdkProcessingMetadata.requestData as unknown; 49 | } else { 50 | event.request = { 51 | data: sdkProcessingMetadata.requestData as unknown, 52 | }; 53 | } 54 | } 55 | 56 | return event; 57 | }, 58 | }; 59 | }, 60 | ); 61 | 62 | /** 63 | * Applies allowlists on existing user object. 64 | * 65 | * @param user 66 | * @param request 67 | * @param options 68 | * @returns New copy of user 69 | */ 70 | function toEventUser( 71 | user: User, 72 | request: Request, 73 | options: RequestDataOptions, 74 | ): User | undefined { 75 | const ip_address = request.headers.get('CF-Connecting-IP'); 76 | const { allowedIps } = options; 77 | 78 | const newUser: User = { ...user }; 79 | 80 | if ( 81 | !('ip_address' in user) && // If ip_address is already set from explicitly called setUser, we don't want to overwrite it 82 | ip_address && 83 | allowedIps !== undefined && 84 | testAllowlist(ip_address, allowedIps) 85 | ) { 86 | newUser.ip_address = ip_address; 87 | } 88 | 89 | return Object.keys(newUser).length > 0 ? newUser : undefined; 90 | } 91 | 92 | /** 93 | * Converts data from fetch event's Request to Sentry Request used in Sentry Event 94 | * 95 | * @param request Native Request object 96 | * @param options Integration options 97 | * @returns Sentry Request object 98 | */ 99 | function toEventRequest( 100 | request: Request, 101 | options: RequestDataOptions, 102 | ): EventRequest { 103 | // Build cookies 104 | const cookieString = request.headers.get('cookie'); 105 | let cookies: Record | undefined = undefined; 106 | if (cookieString) { 107 | try { 108 | cookies = parseCookie(cookieString); 109 | } catch (e) { 110 | // Cookie string failed to parse, no need to do anything 111 | } 112 | } 113 | 114 | const headers: Record = {}; 115 | 116 | // Build headers (omit cookie header, because we used it in the previous step) 117 | for (const [k, v] of request.headers.entries()) { 118 | if (k !== 'cookie') { 119 | headers[k] = v; 120 | } 121 | } 122 | 123 | const eventRequest: EventRequest = { 124 | method: request.method, 125 | cookies, 126 | headers, 127 | }; 128 | 129 | try { 130 | const url = new URL(request.url); 131 | eventRequest.url = `${url.protocol}//${url.hostname}${url.pathname}`; 132 | eventRequest.query_string = url.search; 133 | } catch (e) { 134 | // `new URL` failed, let's try to split URL the primitive way 135 | const qi = request.url.indexOf('?'); 136 | if (qi < 0) { 137 | // no query string 138 | eventRequest.url = request.url; 139 | } else { 140 | eventRequest.url = request.url.substr(0, qi); 141 | eventRequest.query_string = request.url.substr(qi + 1); 142 | } 143 | } 144 | 145 | // Let's try to remove sensitive data from incoming Request 146 | const { allowedHeaders, allowedCookies, allowedSearchParams } = options; 147 | 148 | if (allowedHeaders !== undefined && eventRequest.headers) { 149 | eventRequest.headers = applyAllowlistToObject( 150 | eventRequest.headers, 151 | allowedHeaders, 152 | ); 153 | if (Object.keys(eventRequest.headers).length === 0) { 154 | delete eventRequest.headers; 155 | } 156 | } else { 157 | delete eventRequest.headers; 158 | } 159 | 160 | if (allowedCookies !== undefined && eventRequest.cookies) { 161 | eventRequest.cookies = applyAllowlistToObject( 162 | eventRequest.cookies, 163 | allowedCookies, 164 | ); 165 | if (Object.keys(eventRequest.cookies).length === 0) { 166 | delete eventRequest.cookies; 167 | } 168 | } else { 169 | delete eventRequest.cookies; 170 | } 171 | 172 | if (allowedSearchParams !== undefined) { 173 | const params = Object.fromEntries( 174 | new URLSearchParams(eventRequest.query_string), 175 | ); 176 | const allowedParams = new URLSearchParams(); 177 | 178 | Object.keys(applyAllowlistToObject(params, allowedSearchParams)).forEach( 179 | (allowedKey) => { 180 | allowedParams.set(allowedKey, params[allowedKey]); 181 | }, 182 | ); 183 | 184 | eventRequest.query_string = allowedParams.toString(); 185 | } else { 186 | delete eventRequest.query_string; 187 | } 188 | 189 | return eventRequest; 190 | } 191 | 192 | type Target = Record; 193 | 194 | type Predicate = (item: string) => boolean; 195 | 196 | /** 197 | * Helper function that tests 'allowlist' on string. 198 | * 199 | * @param target 200 | * @param allowlist 201 | * @returns True if target is allowed. 202 | */ 203 | function testAllowlist(target: string, allowlist: Allowlist): boolean { 204 | if (typeof allowlist === 'boolean') { 205 | return allowlist; 206 | } else if (allowlist instanceof RegExp) { 207 | return allowlist.test(target); 208 | } else if (Array.isArray(allowlist)) { 209 | const allowlistLowercased = allowlist.map((item) => item.toLowerCase()); 210 | 211 | return allowlistLowercased.includes(target); 212 | } else { 213 | return false; 214 | } 215 | } 216 | 217 | /** 218 | * Helper function that applies 'allowlist' to target's entries. 219 | * 220 | * @param target 221 | * @param allowlist 222 | * @returns New object with allowed keys. 223 | */ 224 | function applyAllowlistToObject(target: Target, allowlist: Allowlist): Target { 225 | let predicate: Predicate = () => false; 226 | 227 | if (typeof allowlist === 'boolean') { 228 | return allowlist ? target : {}; 229 | } else if (allowlist instanceof RegExp) { 230 | predicate = (item: string) => allowlist.test(item); 231 | } else if (Array.isArray(allowlist)) { 232 | const allowlistLowercased = allowlist.map((item) => item.toLowerCase()); 233 | 234 | predicate = (item: string) => 235 | allowlistLowercased.includes(item.toLowerCase()); 236 | } else { 237 | return {}; 238 | } 239 | 240 | return Object.keys(target) 241 | .filter(predicate) 242 | .reduce((allowed, key) => { 243 | allowed[key] = target[key]; 244 | return allowed; 245 | }, {}); 246 | } 247 | 248 | type ParsedCookie = Record; 249 | 250 | /** 251 | * Converts cookie string to an object. 252 | * 253 | * @param cookieString 254 | * @returns Object of cookie entries, or empty object if something went wrong during the conversion. 255 | */ 256 | function parseCookie(cookieString: string): ParsedCookie { 257 | if (typeof cookieString !== 'string') { 258 | return {}; 259 | } 260 | 261 | try { 262 | return cookieString 263 | .split(';') 264 | .map((part) => part.split('=')) 265 | .reduce((acc, [cookieKey, cookieValue]) => { 266 | acc[decodeURIComponent(cookieKey.trim())] = decodeURIComponent( 267 | cookieValue.trim(), 268 | ); 269 | return acc; 270 | }, {}); 271 | } catch { 272 | return {}; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /packages/toucan-js/src/integrations/zod/integration.ts: -------------------------------------------------------------------------------- 1 | import type { Integration, IntegrationFn } from '@sentry/types'; 2 | 3 | /** 4 | * Define an integration function that can be used to create an integration instance. 5 | * Note that this by design hides the implementation details of the integration, as they are considered internal. 6 | * 7 | * Inlined from https://github.com/getsentry/sentry-javascript/blob/develop/packages/core/src/integration.ts#L165 8 | */ 9 | export function defineIntegration( 10 | fn: Fn, 11 | ): (...args: Parameters) => Integration { 12 | return fn; 13 | } 14 | -------------------------------------------------------------------------------- /packages/toucan-js/src/integrations/zod/zoderrors.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, test } from '@jest/globals'; 2 | import { z } from 'zod'; 3 | 4 | import { 5 | flattenIssue, 6 | flattenIssuePath, 7 | formatIssueMessage, 8 | } from './zoderrors'; 9 | 10 | describe('flattenIssue()', () => { 11 | it('flattens path field', () => { 12 | const zodError = z 13 | .object({ 14 | foo: z.string().min(1), 15 | nested: z.object({ 16 | bar: z.literal('baz'), 17 | }), 18 | }) 19 | .safeParse({ 20 | foo: '', 21 | nested: { 22 | bar: 'not-baz', 23 | }, 24 | }).error; 25 | if (zodError === undefined) { 26 | throw new Error('zodError is undefined'); 27 | } 28 | 29 | // Original zod error 30 | expect(zodError.issues).toMatchInlineSnapshot(` 31 | [ 32 | { 33 | "code": "too_small", 34 | "exact": false, 35 | "inclusive": true, 36 | "message": "String must contain at least 1 character(s)", 37 | "minimum": 1, 38 | "path": [ 39 | "foo", 40 | ], 41 | "type": "string", 42 | }, 43 | { 44 | "code": "invalid_literal", 45 | "expected": "baz", 46 | "message": "Invalid literal value, expected "baz"", 47 | "path": [ 48 | "nested", 49 | "bar", 50 | ], 51 | "received": "not-baz", 52 | }, 53 | ] 54 | `); 55 | 56 | const issues = zodError.issues; 57 | expect(issues.length).toBe(2); 58 | 59 | // Format it for use in Sentry 60 | expect(issues.map(flattenIssue)).toMatchInlineSnapshot(` 61 | [ 62 | { 63 | "code": "too_small", 64 | "exact": false, 65 | "inclusive": true, 66 | "keys": undefined, 67 | "message": "String must contain at least 1 character(s)", 68 | "minimum": 1, 69 | "path": "foo", 70 | "type": "string", 71 | "unionErrors": undefined, 72 | }, 73 | { 74 | "code": "invalid_literal", 75 | "expected": "baz", 76 | "keys": undefined, 77 | "message": "Invalid literal value, expected "baz"", 78 | "path": "nested.bar", 79 | "received": "not-baz", 80 | "unionErrors": undefined, 81 | }, 82 | ] 83 | `); 84 | }); 85 | 86 | it('flattens keys field to string', () => { 87 | const zodError = z 88 | .object({ 89 | foo: z.string().min(1), 90 | }) 91 | .strict() 92 | .safeParse({ 93 | foo: 'bar', 94 | extra_key_abc: 'hello', 95 | extra_key_def: 'world', 96 | }).error; 97 | if (zodError === undefined) { 98 | throw new Error('zodError is undefined'); 99 | } 100 | 101 | // Original zod error 102 | expect(zodError.issues).toMatchInlineSnapshot(` 103 | [ 104 | { 105 | "code": "unrecognized_keys", 106 | "keys": [ 107 | "extra_key_abc", 108 | "extra_key_def", 109 | ], 110 | "message": "Unrecognized key(s) in object: 'extra_key_abc', 'extra_key_def'", 111 | "path": [], 112 | }, 113 | ] 114 | `); 115 | 116 | const issues = zodError.issues; 117 | expect(issues.length).toBe(1); 118 | 119 | // Format it for use in Sentry 120 | const formattedIssue = flattenIssue(issues[0]); 121 | 122 | // keys is now a string rather than array. 123 | // Note: path is an empty string because the issue is at the root. 124 | // TODO: Maybe somehow make it clearer that this is at the root? 125 | expect(formattedIssue).toMatchInlineSnapshot(` 126 | { 127 | "code": "unrecognized_keys", 128 | "keys": "["extra_key_abc","extra_key_def"]", 129 | "message": "Unrecognized key(s) in object: 'extra_key_abc', 'extra_key_def'", 130 | "path": "", 131 | "unionErrors": undefined, 132 | } 133 | `); 134 | expect(typeof formattedIssue.keys === 'string').toBe(true); 135 | }); 136 | }); 137 | 138 | describe('flattenIssuePath()', () => { 139 | it('returns single path', () => { 140 | expect(flattenIssuePath(['foo'])).toBe('foo'); 141 | }); 142 | 143 | it('flattens nested string paths', () => { 144 | expect(flattenIssuePath(['foo', 'bar'])).toBe('foo.bar'); 145 | }); 146 | 147 | it('uses placeholder for path index within array', () => { 148 | expect(flattenIssuePath([0, 'foo', 1, 'bar'])).toBe( 149 | '.foo..bar', 150 | ); 151 | }); 152 | }); 153 | 154 | describe('formatIssueMessage()', () => { 155 | it('adds invalid keys to message', () => { 156 | const zodError = z 157 | .object({ 158 | foo: z.string().min(1), 159 | nested: z.object({ 160 | bar: z.literal('baz'), 161 | }), 162 | }) 163 | .safeParse({ 164 | foo: '', 165 | nested: { 166 | bar: 'not-baz', 167 | }, 168 | }).error; 169 | if (zodError === undefined) { 170 | throw new Error('zodError is undefined'); 171 | } 172 | 173 | const message = formatIssueMessage(zodError); 174 | expect(message).toMatchInlineSnapshot( 175 | `"Failed to validate keys: foo, nested.bar"`, 176 | ); 177 | }); 178 | 179 | describe('adds expected type if root variable is invalid', () => { 180 | test('object', () => { 181 | const zodError = z 182 | .object({ 183 | foo: z.string().min(1), 184 | }) 185 | .safeParse(123).error; 186 | if (zodError === undefined) { 187 | throw new Error('zodError is undefined'); 188 | } 189 | 190 | // Original zod error 191 | expect(zodError.issues).toMatchInlineSnapshot(` 192 | [ 193 | { 194 | "code": "invalid_type", 195 | "expected": "object", 196 | "message": "Expected object, received number", 197 | "path": [], 198 | "received": "number", 199 | }, 200 | ] 201 | `); 202 | 203 | const message = formatIssueMessage(zodError); 204 | expect(message).toMatchInlineSnapshot(`"Failed to validate object"`); 205 | }); 206 | 207 | test('number', () => { 208 | const zodError = z.number().safeParse('123').error; 209 | if (zodError === undefined) { 210 | throw new Error('zodError is undefined'); 211 | } 212 | 213 | // Original zod error 214 | expect(zodError.issues).toMatchInlineSnapshot(` 215 | [ 216 | { 217 | "code": "invalid_type", 218 | "expected": "number", 219 | "message": "Expected number, received string", 220 | "path": [], 221 | "received": "string", 222 | }, 223 | ] 224 | `); 225 | 226 | const message = formatIssueMessage(zodError); 227 | expect(message).toMatchInlineSnapshot(`"Failed to validate number"`); 228 | }); 229 | 230 | test('string', () => { 231 | const zodError = z.string().safeParse(123).error; 232 | if (zodError === undefined) { 233 | throw new Error('zodError is undefined'); 234 | } 235 | 236 | // Original zod error 237 | expect(zodError.issues).toMatchInlineSnapshot(` 238 | [ 239 | { 240 | "code": "invalid_type", 241 | "expected": "string", 242 | "message": "Expected string, received number", 243 | "path": [], 244 | "received": "number", 245 | }, 246 | ] 247 | `); 248 | 249 | const message = formatIssueMessage(zodError); 250 | expect(message).toMatchInlineSnapshot(`"Failed to validate string"`); 251 | }); 252 | 253 | test('array', () => { 254 | const zodError = z.string().array().safeParse('123').error; 255 | if (zodError === undefined) { 256 | throw new Error('zodError is undefined'); 257 | } 258 | 259 | // Original zod error 260 | expect(zodError.issues).toMatchInlineSnapshot(` 261 | [ 262 | { 263 | "code": "invalid_type", 264 | "expected": "array", 265 | "message": "Expected array, received string", 266 | "path": [], 267 | "received": "string", 268 | }, 269 | ] 270 | `); 271 | 272 | const message = formatIssueMessage(zodError); 273 | expect(message).toMatchInlineSnapshot(`"Failed to validate array"`); 274 | }); 275 | 276 | test('wrong type in array', () => { 277 | const zodError = z.string().array().safeParse([123]).error; 278 | if (zodError === undefined) { 279 | throw new Error('zodError is undefined'); 280 | } 281 | 282 | // Original zod error 283 | expect(zodError.issues).toMatchInlineSnapshot(` 284 | [ 285 | { 286 | "code": "invalid_type", 287 | "expected": "string", 288 | "message": "Expected string, received number", 289 | "path": [ 290 | 0, 291 | ], 292 | "received": "number", 293 | }, 294 | ] 295 | `); 296 | 297 | const message = formatIssueMessage(zodError); 298 | expect(message).toMatchInlineSnapshot( 299 | `"Failed to validate keys: "`, 300 | ); 301 | }); 302 | }); 303 | }); 304 | -------------------------------------------------------------------------------- /packages/toucan-js/src/integrations/zod/zoderrors.ts: -------------------------------------------------------------------------------- 1 | // Adapted from: https://github.com/getsentry/sentry-javascript/blob/develop/packages/core/src/integrations/zoderrors.ts 2 | 3 | import { isError, truncate } from '@sentry/utils'; 4 | 5 | import { defineIntegration } from './integration'; 6 | 7 | import type { Event, EventHint } from '@sentry/types'; 8 | 9 | const INTEGRATION_NAME = 'ZodErrors'; 10 | const DEFAULT_LIMIT = 10; 11 | 12 | interface ZodErrorsOptions { 13 | /** 14 | * Limits the number of Zod errors inlined in each Sentry event. 15 | * 16 | * @default 10 17 | */ 18 | limit?: number; 19 | /** 20 | * Save full list of Zod issues as an attachment in Sentry 21 | * 22 | * @default false 23 | */ 24 | saveAttachments?: boolean; 25 | } 26 | 27 | function originalExceptionIsZodError( 28 | originalException: unknown, 29 | ): originalException is ZodError { 30 | return ( 31 | isError(originalException) && 32 | originalException.name === 'ZodError' && 33 | Array.isArray((originalException as ZodError).issues) 34 | ); 35 | } 36 | 37 | /** 38 | * Simplified ZodIssue type definition 39 | */ 40 | interface ZodIssue { 41 | path: (string | number)[]; 42 | message?: string; 43 | expected?: unknown; 44 | received?: unknown; 45 | unionErrors?: unknown[]; 46 | keys?: unknown[]; 47 | invalid_literal?: unknown; 48 | } 49 | 50 | interface ZodError extends Error { 51 | issues: ZodIssue[]; 52 | } 53 | 54 | type SingleLevelZodIssue = { 55 | [P in keyof T]: T[P] extends string | number | undefined 56 | ? T[P] 57 | : T[P] extends unknown[] 58 | ? string | undefined 59 | : unknown; 60 | }; 61 | 62 | /** 63 | * Formats child objects or arrays to a string 64 | * that is preserved when sent to Sentry. 65 | * 66 | * Without this, we end up with something like this in Sentry: 67 | * 68 | * [ 69 | * [Object], 70 | * [Object], 71 | * [Object], 72 | * [Object] 73 | * ] 74 | */ 75 | export function flattenIssue(issue: ZodIssue): SingleLevelZodIssue { 76 | return { 77 | ...issue, 78 | path: 79 | 'path' in issue && Array.isArray(issue.path) 80 | ? issue.path.join('.') 81 | : undefined, 82 | keys: 'keys' in issue ? JSON.stringify(issue.keys) : undefined, 83 | unionErrors: 84 | 'unionErrors' in issue ? JSON.stringify(issue.unionErrors) : undefined, 85 | }; 86 | } 87 | 88 | /** 89 | * Takes ZodError issue path array and returns a flattened version as a string. 90 | * This makes it easier to display paths within a Sentry error message. 91 | * 92 | * Array indexes are normalized to reduce duplicate entries 93 | * 94 | * @param path ZodError issue path 95 | * @returns flattened path 96 | * 97 | * @example 98 | * flattenIssuePath([0, 'foo', 1, 'bar']) // -> '.foo..bar' 99 | */ 100 | export function flattenIssuePath(path: Array): string { 101 | return path 102 | .map((p) => { 103 | if (typeof p === 'number') { 104 | return ''; 105 | } else { 106 | return p; 107 | } 108 | }) 109 | .join('.'); 110 | } 111 | 112 | /** 113 | * Zod error message is a stringified version of ZodError.issues 114 | * This doesn't display well in the Sentry UI. Replace it with something shorter. 115 | */ 116 | export function formatIssueMessage(zodError: ZodError): string { 117 | const errorKeyMap = new Set(); 118 | for (const iss of zodError.issues) { 119 | const issuePath = flattenIssuePath(iss.path); 120 | if (issuePath.length > 0) { 121 | errorKeyMap.add(issuePath); 122 | } 123 | } 124 | 125 | const errorKeys = Array.from(errorKeyMap); 126 | if (errorKeys.length === 0) { 127 | // If there are no keys, then we're likely validating the root 128 | // variable rather than a key within an object. This attempts 129 | // to extract what type it was that failed to validate. 130 | // For example, z.string().parse(123) would return "string" here. 131 | let rootExpectedType = 'variable'; 132 | if (zodError.issues.length > 0) { 133 | const iss = zodError.issues[0]; 134 | if ( 135 | iss !== undefined && 136 | 'expected' in iss && 137 | typeof iss.expected === 'string' 138 | ) { 139 | rootExpectedType = iss.expected; 140 | } 141 | } 142 | return `Failed to validate ${rootExpectedType}`; 143 | } 144 | return `Failed to validate keys: ${truncate(errorKeys.join(', '), 100)}`; 145 | } 146 | 147 | /** 148 | * Applies ZodError issues to an event extra and replaces the error message 149 | */ 150 | export function applyZodErrorsToEvent( 151 | limit: number, 152 | event: Event, 153 | saveAttachments: boolean = false, 154 | hint: EventHint, 155 | ): Event { 156 | if ( 157 | !event.exception?.values || 158 | !hint.originalException || 159 | !originalExceptionIsZodError(hint.originalException) || 160 | hint.originalException.issues.length === 0 161 | ) { 162 | return event; 163 | } 164 | 165 | try { 166 | const issuesToFlatten = saveAttachments 167 | ? hint.originalException.issues 168 | : hint.originalException.issues.slice(0, limit); 169 | const flattenedIssues = issuesToFlatten.map(flattenIssue); 170 | 171 | if (saveAttachments) { 172 | // Sometimes having the full error details can be helpful. 173 | // Attachments have much higher limits, so we can include the full list of issues. 174 | if (!Array.isArray(hint.attachments)) { 175 | hint.attachments = []; 176 | } 177 | hint.attachments.push({ 178 | filename: 'zod_issues.json', 179 | data: JSON.stringify({ 180 | issues: flattenedIssues, 181 | }), 182 | }); 183 | } 184 | 185 | return { 186 | ...event, 187 | exception: { 188 | ...event.exception, 189 | values: [ 190 | { 191 | ...event.exception.values[0], 192 | value: formatIssueMessage(hint.originalException), 193 | }, 194 | ...event.exception.values.slice(1), 195 | ], 196 | }, 197 | extra: { 198 | ...event.extra, 199 | 'zoderror.issues': flattenedIssues.slice(0, limit), 200 | }, 201 | }; 202 | } catch (e) { 203 | // Hopefully we never throw errors here, but record it 204 | // with the event just in case. 205 | return { 206 | ...event, 207 | extra: { 208 | ...event.extra, 209 | 'zoderrors sentry integration parse error': { 210 | message: 211 | 'an exception was thrown while processing ZodError within applyZodErrorsToEvent()', 212 | error: 213 | e instanceof Error 214 | ? `${e.name}: ${e.message}\n${e.stack}` 215 | : 'unknown', 216 | }, 217 | }, 218 | }; 219 | } 220 | } 221 | 222 | /** 223 | * Sentry integration to process Zod errors, making them easier to work with in Sentry. 224 | */ 225 | export const zodErrorsIntegration = defineIntegration( 226 | (options: ZodErrorsOptions = {}) => { 227 | const limit = options.limit ?? DEFAULT_LIMIT; 228 | 229 | return { 230 | name: INTEGRATION_NAME, 231 | processEvent(originalEvent, hint): Event { 232 | const processedEvent = applyZodErrorsToEvent( 233 | limit, 234 | originalEvent, 235 | options.saveAttachments, 236 | hint, 237 | ); 238 | return processedEvent; 239 | }, 240 | }; 241 | }, 242 | ); 243 | -------------------------------------------------------------------------------- /packages/toucan-js/src/sdk.ts: -------------------------------------------------------------------------------- 1 | import { Scope, getIntegrationsToSetup } from '@sentry/core'; 2 | import { stackParserFromStackParserOptions } from '@sentry/utils'; 3 | import { ToucanClient } from './client'; 4 | import { 5 | linkedErrorsIntegration, 6 | requestDataIntegration, 7 | zodErrorsIntegration, 8 | } from './integrations'; 9 | import { defaultStackParser } from './stacktrace'; 10 | import { makeFetchTransport } from './transports'; 11 | import type { Options } from './types'; 12 | import { getSentryRelease } from './utils'; 13 | import type { Breadcrumb, CheckIn, MonitorConfig } from '@sentry/types'; 14 | 15 | /** 16 | * The Cloudflare Workers SDK. 17 | */ 18 | export class Toucan extends Scope { 19 | #options: Options; 20 | 21 | constructor(options: Options) { 22 | super(); 23 | 24 | options.defaultIntegrations = 25 | options.defaultIntegrations === false 26 | ? [] 27 | : [ 28 | ...(Array.isArray(options.defaultIntegrations) 29 | ? options.defaultIntegrations 30 | : [ 31 | requestDataIntegration(options.requestDataOptions), 32 | linkedErrorsIntegration(), 33 | zodErrorsIntegration(), 34 | ]), 35 | ]; 36 | 37 | if (options.release === undefined) { 38 | const detectedRelease = getSentryRelease(); 39 | if (detectedRelease !== undefined) { 40 | options.release = detectedRelease; 41 | } 42 | } 43 | 44 | this.#options = options; 45 | 46 | this.attachNewClient(); 47 | } 48 | 49 | /** 50 | * Creates new ToucanClient and links it to this instance. 51 | */ 52 | protected attachNewClient() { 53 | const client = new ToucanClient({ 54 | ...this.#options, 55 | transport: makeFetchTransport, 56 | integrations: getIntegrationsToSetup(this.#options), 57 | stackParser: stackParserFromStackParserOptions( 58 | this.#options.stackParser || defaultStackParser, 59 | ), 60 | transportOptions: { 61 | ...this.#options.transportOptions, 62 | context: this.#options.context, 63 | }, 64 | }); 65 | 66 | this.setClient(client); 67 | client.setSdk(this); 68 | client.setupIntegrations(); 69 | } 70 | 71 | /** 72 | * Sets the request body context on all future events. 73 | * 74 | * @param body Request body. 75 | * @example 76 | * const body = await request.text(); 77 | * toucan.setRequestBody(body); 78 | */ 79 | setRequestBody(body: unknown) { 80 | this.getClient()?.setRequestBody(body); 81 | } 82 | 83 | /** 84 | * Enable/disable the SDK. 85 | * 86 | * @param enabled 87 | */ 88 | setEnabled(enabled: boolean): void { 89 | this.getClient()?.setEnabled(enabled); 90 | } 91 | 92 | /** 93 | * Create a cron monitor check in and send it to Sentry. 94 | * 95 | * @param checkIn An object that describes a check in. 96 | * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want 97 | * to create a monitor automatically when sending a check in. 98 | */ 99 | captureCheckIn( 100 | checkIn: CheckIn, 101 | monitorConfig?: MonitorConfig, 102 | scope?: Scope, 103 | ): string { 104 | if (checkIn.status === 'in_progress') { 105 | this.setContext('monitor', { slug: checkIn.monitorSlug }); 106 | } 107 | 108 | const client = this.getClient() as ToucanClient; 109 | return client.captureCheckIn(checkIn, monitorConfig, scope); 110 | } 111 | 112 | /** 113 | * Add a breadcrumb to the current scope. 114 | */ 115 | addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs: number = 100): this { 116 | const client = this.getClient() as ToucanClient; 117 | const max = client.getOptions().maxBreadcrumbs || maxBreadcrumbs; 118 | 119 | return super.addBreadcrumb(breadcrumb, max); 120 | } 121 | 122 | /** 123 | * Clone all data from this instance into a new Toucan instance. 124 | * 125 | * @override 126 | * @returns New Toucan instance. 127 | */ 128 | clone(): Toucan { 129 | // Create new scope using the same options 130 | const toucan = new Toucan({ ...this.#options }); 131 | 132 | // And copy all the scope data 133 | toucan._breadcrumbs = [...this._breadcrumbs]; 134 | toucan._tags = { ...this._tags }; 135 | toucan._extra = { ...this._extra }; 136 | toucan._contexts = { ...this._contexts }; 137 | toucan._user = this._user; 138 | toucan._level = this._level; 139 | toucan._session = this._session; 140 | toucan._transactionName = this._transactionName; 141 | toucan._fingerprint = this._fingerprint; 142 | toucan._eventProcessors = [...this._eventProcessors]; 143 | toucan._requestSession = this._requestSession; 144 | toucan._attachments = [...this._attachments]; 145 | toucan._sdkProcessingMetadata = { ...this._sdkProcessingMetadata }; 146 | toucan._propagationContext = { ...this._propagationContext }; 147 | toucan._lastEventId = this._lastEventId; 148 | 149 | return toucan; 150 | } 151 | 152 | /** 153 | * Creates a new scope with and executes the given operation within. 154 | * The scope is automatically removed once the operation 155 | * finishes or throws. 156 | */ 157 | withScope(callback: (scope: Toucan) => T): T { 158 | const toucan = this.clone(); 159 | return callback(toucan); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /packages/toucan-js/src/stacktrace.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | StackLineParser, 3 | StackLineParserFn, 4 | StackParser, 5 | } from '@sentry/types'; 6 | import { 7 | basename, 8 | createStackParser, 9 | nodeStackLineParser, 10 | } from '@sentry/utils'; 11 | 12 | type GetModuleFn = (filename: string | undefined) => string | undefined; 13 | 14 | /** 15 | * Stack line parser for Cloudflare Workers. 16 | * This wraps node stack parser and adjusts root paths to match with source maps. 17 | * 18 | */ 19 | function workersStackLineParser(getModule?: GetModuleFn): StackLineParser { 20 | const [arg1, arg2] = nodeStackLineParser(getModule); 21 | 22 | const fn: StackLineParserFn = (line) => { 23 | const result = arg2(line); 24 | if (result) { 25 | const filename = result.filename; 26 | // Workers runtime runs a single bundled file that is always in a virtual root 27 | result.abs_path = 28 | filename !== undefined && !filename.startsWith('/') 29 | ? `/${filename}` 30 | : filename; 31 | // There is no way to tell what code is in_app and what comes from dependencies (node_modules), since we have one bundled file. 32 | // So everything is in_app, unless an error comes from runtime function (ie. JSON.parse), which is determined by the presence of filename. 33 | result.in_app = filename !== undefined; 34 | } 35 | return result; 36 | }; 37 | 38 | return [arg1, fn]; 39 | } 40 | 41 | /** 42 | * Gets the module from filename. 43 | * 44 | * @param filename 45 | * @returns Module name 46 | */ 47 | export function getModule(filename: string | undefined): string | undefined { 48 | if (!filename) { 49 | return; 50 | } 51 | 52 | // In Cloudflare Workers there is always only one bundled file 53 | return basename(filename, '.js'); 54 | } 55 | 56 | /** Cloudflare Workers stack parser */ 57 | export const defaultStackParser: StackParser = createStackParser( 58 | workersStackLineParser(getModule), 59 | ); 60 | -------------------------------------------------------------------------------- /packages/toucan-js/src/transports/fetch.ts: -------------------------------------------------------------------------------- 1 | import { createTransport } from '@sentry/core'; 2 | import type { 3 | Transport, 4 | TransportMakeRequestResponse, 5 | TransportRequest, 6 | } from '@sentry/types'; 7 | import { rejectedSyncPromise } from '@sentry/utils'; 8 | import type { FetchTransportOptions } from './types'; 9 | 10 | /** 11 | * Creates a Transport that uses native fetch. This transport automatically extends the Workers lifetime with 'waitUntil'. 12 | */ 13 | export function makeFetchTransport(options: FetchTransportOptions): Transport { 14 | function makeRequest({ 15 | body, 16 | }: TransportRequest): PromiseLike { 17 | try { 18 | const fetchFn = options.fetcher ?? fetch; 19 | const request = fetchFn(options.url, { 20 | method: 'POST', 21 | headers: options.headers, 22 | body, 23 | }).then((response) => { 24 | return { 25 | statusCode: response.status, 26 | headers: { 27 | 'retry-after': response.headers.get('Retry-After'), 28 | 'x-sentry-rate-limits': response.headers.get( 29 | 'X-Sentry-Rate-Limits', 30 | ), 31 | }, 32 | }; 33 | }); 34 | 35 | /** 36 | * Call waitUntil to extend Workers Event lifetime 37 | */ 38 | if (options.context) { 39 | options.context.waitUntil(request); 40 | } 41 | 42 | return request; 43 | } catch (e) { 44 | return rejectedSyncPromise(e); 45 | } 46 | } 47 | 48 | return createTransport(options, makeRequest); 49 | } 50 | -------------------------------------------------------------------------------- /packages/toucan-js/src/transports/index.ts: -------------------------------------------------------------------------------- 1 | export { makeFetchTransport } from './fetch'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /packages/toucan-js/src/transports/types.ts: -------------------------------------------------------------------------------- 1 | import type { BaseTransportOptions } from '@sentry/types'; 2 | import type { Context } from '../types'; 3 | 4 | export type FetchTransportOptions = BaseTransportOptions & { 5 | /** 6 | * Custom headers passed to fetch. 7 | */ 8 | headers?: Record; 9 | 10 | /** 11 | * Cloudflare Workers context. 12 | */ 13 | context?: Context; 14 | 15 | /** 16 | * Custom fetch function. 17 | */ 18 | fetcher?: typeof fetch; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/toucan-js/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ClientOptions, Options as CoreOptions } from '@sentry/types'; 2 | import type { RequestDataOptions } from './integrations'; 3 | import type { FetchTransportOptions } from './transports'; 4 | 5 | export type BaseOptions = { 6 | context?: Context; 7 | request?: Request; 8 | requestDataOptions?: RequestDataOptions; 9 | }; 10 | 11 | type BaseClientOptions = { 12 | request?: Request; 13 | requestData?: unknown; 14 | }; 15 | 16 | /** 17 | * Configuration options for Toucan class 18 | */ 19 | export type Options = CoreOptions & BaseOptions; 20 | 21 | /** 22 | * Configuration options for the SDK Client class 23 | */ 24 | export type ToucanClientOptions = ClientOptions & 25 | BaseClientOptions; 26 | 27 | export type Context = { 28 | waitUntil: ExecutionContext['waitUntil']; 29 | request?: Request; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/toucan-js/src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Mechanism } from '@sentry/types'; 2 | import { GLOBAL_OBJ } from '@sentry/utils'; 3 | 4 | export function isObject(value: unknown): value is object { 5 | return typeof value === 'object' && value !== null; 6 | } 7 | 8 | export function isMechanism(value: unknown): value is Mechanism { 9 | return ( 10 | isObject(value) && 11 | 'handled' in value && 12 | typeof value.handled === 'boolean' && 13 | 'type' in value && 14 | typeof value.type === 'string' 15 | ); 16 | } 17 | 18 | export function containsMechanism( 19 | value: unknown, 20 | ): value is { mechanism: Mechanism } { 21 | return ( 22 | isObject(value) && 'mechanism' in value && isMechanism(value['mechanism']) 23 | ); 24 | } 25 | 26 | /** 27 | * Tries to find release in a global 28 | */ 29 | export function getSentryRelease(): string | undefined { 30 | // Most of the plugins from https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/ inject SENTRY_RELEASE global to the bundle 31 | if (GLOBAL_OBJ.SENTRY_RELEASE && GLOBAL_OBJ.SENTRY_RELEASE.id) { 32 | return GLOBAL_OBJ.SENTRY_RELEASE.id; 33 | } 34 | } 35 | 36 | /** 37 | * Creates an entry on existing object and returns it, or creates a new object with the entry if it doesn't exist. 38 | * 39 | * @param target 40 | * @param entry 41 | * @returns Object with new entry. 42 | */ 43 | export function setOnOptional< 44 | Target extends { [key: string | number | symbol]: unknown }, 45 | Key extends keyof Target, 46 | >(target: Target | undefined, entry: [Key, Target[Key]]): Target { 47 | if (target !== undefined) { 48 | target[entry[0]] = entry[1]; 49 | return target; 50 | } else { 51 | return { [entry[0]]: entry[1] } as Target; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/toucan-js/test/__snapshots__/index.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Toucan captureException Error with cause 1`] = ` 4 | { 5 | "contexts": ObjectContaining { 6 | "trace": ObjectContaining { 7 | "span_id": Any, 8 | "trace_id": Any, 9 | }, 10 | }, 11 | "environment": "production", 12 | "event_id": Any, 13 | "exception": ObjectContaining { 14 | "values": ArrayContaining [ 15 | ObjectContaining { 16 | "stacktrace": ObjectContaining { 17 | "frames": ArrayContaining [ 18 | ObjectContaining { 19 | "abs_path": Any, 20 | "colno": Any, 21 | "filename": Any, 22 | "function": Any, 23 | "in_app": Any, 24 | "lineno": Any, 25 | "module": Any, 26 | }, 27 | ], 28 | }, 29 | "type": "Error", 30 | "value": "original error", 31 | }, 32 | ObjectContaining { 33 | "mechanism": ObjectContaining { 34 | "handled": true, 35 | "type": "generic", 36 | }, 37 | "stacktrace": ObjectContaining { 38 | "frames": ArrayContaining [ 39 | ObjectContaining { 40 | "abs_path": Any, 41 | "colno": Any, 42 | "filename": Any, 43 | "function": Any, 44 | "in_app": Any, 45 | "lineno": Any, 46 | "module": Any, 47 | }, 48 | ], 49 | }, 50 | "type": "Error", 51 | "value": "outer error with cause", 52 | }, 53 | ], 54 | }, 55 | "platform": "javascript", 56 | "sdk": ObjectContaining { 57 | "integrations": [ 58 | "RequestData", 59 | "LinkedErrors", 60 | "ZodErrors", 61 | ], 62 | "name": "toucan-js", 63 | "packages": ArrayContaining [ 64 | ObjectContaining { 65 | "name": "npm:toucan-js", 66 | "version": Any, 67 | }, 68 | ], 69 | "version": Any, 70 | }, 71 | "timestamp": Any, 72 | } 73 | `; 74 | 75 | exports[`Toucan captureException captureException: primitive 1`] = ` 76 | { 77 | "contexts": ObjectContaining { 78 | "trace": ObjectContaining { 79 | "span_id": Any, 80 | "trace_id": Any, 81 | }, 82 | }, 83 | "environment": "production", 84 | "event_id": Any, 85 | "exception": ObjectContaining { 86 | "values": ArrayContaining [ 87 | ObjectContaining { 88 | "mechanism": ObjectContaining { 89 | "handled": true, 90 | "synthetic": true, 91 | "type": "generic", 92 | }, 93 | "stacktrace": ObjectContaining { 94 | "frames": ArrayContaining [ 95 | ObjectContaining { 96 | "abs_path": Any, 97 | "colno": Any, 98 | "filename": Any, 99 | "function": Any, 100 | "in_app": Any, 101 | "lineno": Any, 102 | "module": Any, 103 | }, 104 | ], 105 | }, 106 | "type": "Error", 107 | "value": "test", 108 | }, 109 | ], 110 | }, 111 | "platform": "javascript", 112 | "sdk": ObjectContaining { 113 | "integrations": [ 114 | "RequestData", 115 | "LinkedErrors", 116 | "ZodErrors", 117 | ], 118 | "name": "toucan-js", 119 | "packages": ArrayContaining [ 120 | ObjectContaining { 121 | "name": "npm:toucan-js", 122 | "version": Any, 123 | }, 124 | ], 125 | "version": Any, 126 | }, 127 | "timestamp": Any, 128 | } 129 | `; 130 | 131 | exports[`Toucan captureException captureException: primitive 2`] = ` 132 | { 133 | "contexts": ObjectContaining { 134 | "trace": ObjectContaining { 135 | "span_id": Any, 136 | "trace_id": Any, 137 | }, 138 | }, 139 | "environment": "production", 140 | "event_id": Any, 141 | "exception": ObjectContaining { 142 | "values": ArrayContaining [ 143 | ObjectContaining { 144 | "mechanism": ObjectContaining { 145 | "handled": true, 146 | "synthetic": true, 147 | "type": "generic", 148 | }, 149 | "stacktrace": ObjectContaining { 150 | "frames": ArrayContaining [ 151 | ObjectContaining { 152 | "abs_path": Any, 153 | "colno": Any, 154 | "filename": Any, 155 | "function": Any, 156 | "in_app": Any, 157 | "lineno": Any, 158 | "module": Any, 159 | }, 160 | ], 161 | }, 162 | "type": "Error", 163 | "value": true, 164 | }, 165 | ], 166 | }, 167 | "platform": "javascript", 168 | "sdk": ObjectContaining { 169 | "integrations": [ 170 | "RequestData", 171 | "LinkedErrors", 172 | "ZodErrors", 173 | ], 174 | "name": "toucan-js", 175 | "packages": ArrayContaining [ 176 | ObjectContaining { 177 | "name": "npm:toucan-js", 178 | "version": Any, 179 | }, 180 | ], 181 | "version": Any, 182 | }, 183 | "timestamp": Any, 184 | } 185 | `; 186 | 187 | exports[`Toucan captureException captureException: primitive 3`] = ` 188 | { 189 | "contexts": ObjectContaining { 190 | "trace": ObjectContaining { 191 | "span_id": Any, 192 | "trace_id": Any, 193 | }, 194 | }, 195 | "environment": "production", 196 | "event_id": Any, 197 | "exception": ObjectContaining { 198 | "values": ArrayContaining [ 199 | ObjectContaining { 200 | "mechanism": ObjectContaining { 201 | "handled": true, 202 | "synthetic": true, 203 | "type": "generic", 204 | }, 205 | "stacktrace": ObjectContaining { 206 | "frames": ArrayContaining [ 207 | ObjectContaining { 208 | "abs_path": Any, 209 | "colno": Any, 210 | "filename": Any, 211 | "function": Any, 212 | "in_app": Any, 213 | "lineno": Any, 214 | "module": Any, 215 | }, 216 | ], 217 | }, 218 | "type": "Error", 219 | "value": 10, 220 | }, 221 | ], 222 | }, 223 | "platform": "javascript", 224 | "sdk": ObjectContaining { 225 | "integrations": [ 226 | "RequestData", 227 | "LinkedErrors", 228 | "ZodErrors", 229 | ], 230 | "name": "toucan-js", 231 | "packages": ArrayContaining [ 232 | ObjectContaining { 233 | "name": "npm:toucan-js", 234 | "version": Any, 235 | }, 236 | ], 237 | "version": Any, 238 | }, 239 | "timestamp": Any, 240 | } 241 | `; 242 | 243 | exports[`Toucan captureException object 1`] = ` 244 | { 245 | "contexts": ObjectContaining { 246 | "trace": ObjectContaining { 247 | "span_id": Any, 248 | "trace_id": Any, 249 | }, 250 | }, 251 | "environment": "production", 252 | "event_id": Any, 253 | "exception": ObjectContaining { 254 | "values": ArrayContaining [ 255 | ObjectContaining { 256 | "mechanism": ObjectContaining { 257 | "handled": true, 258 | "synthetic": true, 259 | "type": "generic", 260 | }, 261 | "stacktrace": ObjectContaining { 262 | "frames": ArrayContaining [ 263 | ObjectContaining { 264 | "abs_path": Any, 265 | "colno": Any, 266 | "filename": Any, 267 | "function": Any, 268 | "in_app": Any, 269 | "lineno": Any, 270 | "module": Any, 271 | }, 272 | ], 273 | }, 274 | "type": "Error", 275 | "value": "Non-Error exception captured with keys: bar, foo", 276 | }, 277 | ], 278 | }, 279 | "extra": { 280 | "__serialized__": { 281 | "bar": "baz", 282 | "foo": "test", 283 | }, 284 | }, 285 | "platform": "javascript", 286 | "sdk": ObjectContaining { 287 | "integrations": [ 288 | "RequestData", 289 | "LinkedErrors", 290 | "ZodErrors", 291 | ], 292 | "name": "toucan-js", 293 | "packages": ArrayContaining [ 294 | ObjectContaining { 295 | "name": "npm:toucan-js", 296 | "version": Any, 297 | }, 298 | ], 299 | "version": Any, 300 | }, 301 | "timestamp": Any, 302 | } 303 | `; 304 | 305 | exports[`Toucan captureException runtime thrown Error 1`] = ` 306 | { 307 | "contexts": ObjectContaining { 308 | "trace": ObjectContaining { 309 | "span_id": Any, 310 | "trace_id": Any, 311 | }, 312 | }, 313 | "environment": "production", 314 | "event_id": Any, 315 | "exception": ObjectContaining { 316 | "values": ArrayContaining [ 317 | ObjectContaining { 318 | "mechanism": ObjectContaining { 319 | "handled": true, 320 | "type": "generic", 321 | }, 322 | "stacktrace": ObjectContaining { 323 | "frames": ArrayContaining [ 324 | ObjectContaining { 325 | "abs_path": Any, 326 | "colno": Any, 327 | "filename": Any, 328 | "function": Any, 329 | "in_app": Any, 330 | "lineno": Any, 331 | "module": Any, 332 | }, 333 | ], 334 | }, 335 | "type": "SyntaxError", 336 | "value": "Unexpected token a in JSON at position 0", 337 | }, 338 | ], 339 | }, 340 | "platform": "javascript", 341 | "sdk": ObjectContaining { 342 | "integrations": [ 343 | "RequestData", 344 | "LinkedErrors", 345 | "ZodErrors", 346 | ], 347 | "name": "toucan-js", 348 | "packages": ArrayContaining [ 349 | ObjectContaining { 350 | "name": "npm:toucan-js", 351 | "version": Any, 352 | }, 353 | ], 354 | "version": Any, 355 | }, 356 | "timestamp": Any, 357 | } 358 | `; 359 | 360 | exports[`Toucan captureMessage sends correct body to Sentry 1`] = ` 361 | { 362 | "result": ArrayContaining [ 363 | ObjectContaining { 364 | "event_id": Any, 365 | "sdk": ObjectContaining { 366 | "name": "toucan-js", 367 | "version": Any, 368 | }, 369 | "sent_at": Any, 370 | }, 371 | ObjectContaining { 372 | "type": "event", 373 | }, 374 | ObjectContaining { 375 | "contexts": ObjectContaining { 376 | "trace": ObjectContaining { 377 | "span_id": Any, 378 | "trace_id": Any, 379 | }, 380 | }, 381 | "event_id": Any, 382 | "sdk": ObjectContaining { 383 | "integrations": [ 384 | "RequestData", 385 | "LinkedErrors", 386 | "ZodErrors", 387 | ], 388 | "name": "toucan-js", 389 | "packages": ArrayContaining [ 390 | ObjectContaining { 391 | "name": "npm:toucan-js", 392 | "version": Any, 393 | }, 394 | ], 395 | "version": Any, 396 | }, 397 | "timestamp": Any, 398 | }, 399 | ], 400 | } 401 | `; 402 | 403 | exports[`Toucan general invalid URL does not fail 1`] = ` 404 | { 405 | "contexts": ObjectContaining { 406 | "trace": ObjectContaining { 407 | "span_id": Any, 408 | "trace_id": Any, 409 | }, 410 | }, 411 | "environment": "production", 412 | "event_id": Any, 413 | "level": "info", 414 | "message": "test", 415 | "platform": "javascript", 416 | "sdk": ObjectContaining { 417 | "integrations": [ 418 | "RequestData", 419 | "LinkedErrors", 420 | "ZodErrors", 421 | ], 422 | "name": "toucan-js", 423 | "packages": ArrayContaining [ 424 | ObjectContaining { 425 | "name": "npm:toucan-js", 426 | "version": Any, 427 | }, 428 | ], 429 | "version": Any, 430 | }, 431 | "timestamp": Any, 432 | } 433 | `; 434 | 435 | exports[`Toucan stacktraces attachStacktrace = true sends stacktrace with captureMessage 1`] = ` 436 | { 437 | "contexts": ObjectContaining { 438 | "trace": ObjectContaining { 439 | "span_id": Any, 440 | "trace_id": Any, 441 | }, 442 | }, 443 | "environment": "production", 444 | "event_id": Any, 445 | "exception": ObjectContaining { 446 | "values": ArrayContaining [ 447 | ObjectContaining { 448 | "stacktrace": ObjectContaining { 449 | "frames": ArrayContaining [ 450 | ObjectContaining { 451 | "abs_path": Any, 452 | "colno": Any, 453 | "filename": Any, 454 | "function": Any, 455 | "in_app": Any, 456 | "lineno": Any, 457 | "module": Any, 458 | }, 459 | ], 460 | }, 461 | "value": "message with stacktrace", 462 | }, 463 | ], 464 | }, 465 | "level": "info", 466 | "message": "message with stacktrace", 467 | "platform": "javascript", 468 | "sdk": ObjectContaining { 469 | "integrations": [ 470 | "RequestData", 471 | "LinkedErrors", 472 | "ZodErrors", 473 | ], 474 | "name": "toucan-js", 475 | "packages": ArrayContaining [ 476 | ObjectContaining { 477 | "name": "npm:toucan-js", 478 | "version": Any, 479 | }, 480 | ], 481 | "version": Any, 482 | }, 483 | "timestamp": Any, 484 | } 485 | `; 486 | -------------------------------------------------------------------------------- /packages/toucan-js/test/global.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Taken from https://github.com/cloudflare/miniflare/blob/64e1b263371805d649afb22142e119bfaf473ba3/packages/shared-test-environment/src/globals.ts as a workaround for https://github.com/cloudflare/miniflare/issues/338 3 | */ 4 | 5 | import { FetchEvent, kWaitUntil, ScheduledEvent } from '@miniflare/core'; 6 | import { 7 | DurableObjectId, 8 | DurableObjectState, 9 | DurableObjectStorage, 10 | } from '@miniflare/durable-objects'; 11 | import { Awaitable, Context } from '@miniflare/shared'; 12 | import { MockAgent } from 'undici'; 13 | 14 | declare global { 15 | class ExecutionContext { 16 | [kWaitUntil]: Promise[]; 17 | 18 | passThroughOnException(): void; 19 | 20 | waitUntil(promise: Promise): void; 21 | } 22 | function getMiniflareBindings(): Bindings; 23 | function getMiniflareDurableObjectStorage( 24 | id: DurableObjectId, 25 | ): Promise; 26 | function getMiniflareDurableObjectState( 27 | id: DurableObjectId, 28 | ): Promise; 29 | function runWithMiniflareDurableObjectGates( 30 | state: DurableObjectState, 31 | closure: () => Awaitable, 32 | ): Promise; 33 | function getMiniflareFetchMock(): MockAgent; 34 | function getMiniflareWaitUntil( 35 | event: FetchEvent | ScheduledEvent | ExecutionContext, 36 | ): Promise; 37 | function flushMiniflareDurableObjectAlarms( 38 | ids: DurableObjectId[], 39 | ): Promise; 40 | } 41 | -------------------------------------------------------------------------------- /packages/toucan-js/test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | const realMathRandom = Math.random; 4 | let mathRandomReturnValues: number[] = []; 5 | let mathRandomReturnValuesCurrentIndex = -1; 6 | 7 | export const mockFetch = () => { 8 | return jest.fn(async () => new Response()); 9 | }; 10 | 11 | export const mockMathRandom = (returnValues: number[]) => { 12 | if (returnValues.length === 0) 13 | jest.fn(() => { 14 | return Math.random(); 15 | }); 16 | 17 | mathRandomReturnValues = returnValues; 18 | 19 | Math.random = jest.fn(() => { 20 | // Simulate ring array 21 | mathRandomReturnValuesCurrentIndex = 22 | mathRandomReturnValuesCurrentIndex + 1 >= mathRandomReturnValues.length 23 | ? 0 24 | : mathRandomReturnValuesCurrentIndex + 1; 25 | 26 | return mathRandomReturnValues[mathRandomReturnValuesCurrentIndex]; 27 | }); 28 | }; 29 | 30 | export const resetMathRandom = () => { 31 | Math.random = realMathRandom; 32 | mathRandomReturnValuesCurrentIndex = -1; 33 | mathRandomReturnValues = []; 34 | }; 35 | 36 | const realConsole = console; 37 | export const mockConsole = () => { 38 | console = { 39 | ...realConsole, 40 | log: jest.fn(() => {}), 41 | warn: jest.fn(() => {}), 42 | error: jest.fn(() => {}), 43 | }; 44 | }; 45 | 46 | export const resetConsole = () => { 47 | console = realConsole; 48 | }; 49 | -------------------------------------------------------------------------------- /packages/toucan-js/test/index.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Event, Session } from '@sentry/types'; 2 | import { defineIntegration } from '@sentry/core'; 3 | import { Toucan } from 'toucan-js'; 4 | import { 5 | mockConsole, 6 | mockFetch, 7 | mockMathRandom, 8 | resetConsole, 9 | resetMathRandom, 10 | } from './helpers'; 11 | 12 | const VALID_DSN = 'https://123:456@testorg.ingest.sentry.io/123'; 13 | 14 | // This is the default buffer size 15 | const DEFAULT_BUFFER_SIZE = 30; 16 | 17 | const DEFAULT_INTEGRATIONS = ['RequestData', 'LinkedErrors', 'ZodErrors']; 18 | 19 | /** 20 | * We don't care about exact values of pseudorandomized and time-related properties, as long as they match the type we accept them. 21 | */ 22 | const GENERIC_EVENT_BODY_MATCHER = { 23 | contexts: expect.objectContaining({ 24 | trace: expect.objectContaining({ 25 | span_id: expect.any(String), 26 | trace_id: expect.any(String), 27 | }), 28 | }), 29 | event_id: expect.any(String), 30 | timestamp: expect.any(Number), 31 | sdk: expect.objectContaining({ 32 | integrations: DEFAULT_INTEGRATIONS, 33 | name: 'toucan-js', 34 | version: expect.any(String), 35 | packages: expect.arrayContaining([ 36 | expect.objectContaining({ 37 | name: 'npm:toucan-js', 38 | version: expect.any(String), 39 | }), 40 | ]), 41 | }), 42 | } as const; 43 | 44 | const PARSED_ENVELOPE_MATCHER = [ 45 | expect.objectContaining({ 46 | event_id: expect.any(String), 47 | sdk: expect.objectContaining({ 48 | name: 'toucan-js', 49 | version: expect.any(String), 50 | }), 51 | sent_at: expect.any(String), 52 | }), 53 | expect.objectContaining({ 54 | type: 'event', 55 | }), 56 | expect.objectContaining(GENERIC_EVENT_BODY_MATCHER), 57 | ]; 58 | 59 | /** 60 | * Additionally for exceptions, we don't care about the actual values in stacktraces because they can change as we update this file. 61 | * This matcher is created dynamically depending on Error context. 62 | */ 63 | function createExceptionBodyMatcher( 64 | errors: { 65 | value: string | number | boolean; 66 | type?: string; 67 | filenameMatcher?: jest.Expect['any']; 68 | abs_pathMatcher?: jest.Expect['any']; 69 | synthetic?: boolean; 70 | mechanism?: boolean; 71 | }[], 72 | ) { 73 | return { 74 | ...GENERIC_EVENT_BODY_MATCHER, 75 | exception: expect.objectContaining({ 76 | values: expect.arrayContaining( 77 | errors.map( 78 | ( 79 | { 80 | value, 81 | type, 82 | filenameMatcher, 83 | abs_pathMatcher, 84 | synthetic, 85 | mechanism = true, 86 | }, 87 | index, 88 | ) => { 89 | return expect.objectContaining({ 90 | ...(errors.length - 1 === index && mechanism 91 | ? { 92 | mechanism: expect.objectContaining({ 93 | handled: true, 94 | type: 'generic', 95 | ...(synthetic ? { synthetic: true } : {}), 96 | }), 97 | } 98 | : {}), 99 | stacktrace: expect.objectContaining({ 100 | frames: expect.arrayContaining([ 101 | expect.objectContaining({ 102 | colno: expect.any(Number), 103 | filename: filenameMatcher ?? expect.any(String), 104 | function: expect.any(String), 105 | in_app: expect.any(Boolean), 106 | module: expect.any(String), 107 | lineno: expect.any(Number), 108 | abs_path: abs_pathMatcher ?? expect.any(String), 109 | }), 110 | ]), 111 | }), 112 | ...(type !== undefined ? { type } : {}), 113 | value, 114 | }); 115 | }, 116 | ), 117 | ), 118 | }), 119 | }; 120 | } 121 | 122 | type MockRequestInfo = { 123 | text: () => Promise; 124 | json: () => Promise>; 125 | parseEnvelope: () => Promise[]>; // All items in Envelope (https://develop.sentry.dev/sdk/envelopes/) 126 | envelopePayload: () => Promise>; // Last item in Envelope (https://develop.sentry.dev/sdk/envelopes/) 127 | headers: Record; 128 | method: string; 129 | origin: string; 130 | path: string; 131 | }; 132 | 133 | describe('Toucan', () => { 134 | let requests: MockRequestInfo[] = []; 135 | let context: ExecutionContext; 136 | 137 | beforeAll(() => { 138 | // Get correctly set up `MockAgent` 139 | const fetchMock = getMiniflareFetchMock(); 140 | 141 | // Throw when no matching mocked request is found 142 | // (see https://undici.nodejs.org/#/docs/api/MockAgent?id=mockagentdisablenetconnect) 143 | fetchMock.disableNetConnect(); 144 | 145 | const origin = fetchMock.get('https://testorg.ingest.sentry.io'); 146 | 147 | // (see https://undici.nodejs.org/#/docs/api/MockPool?id=mockpoolinterceptoptions) 148 | origin 149 | .intercept({ 150 | method: () => true, 151 | path: (path) => path.includes('/envelope/'), 152 | }) 153 | .reply(200, (opts) => { 154 | // Hack around https://github.com/nodejs/undici/issues/1756 155 | // Once fixed, we can simply return opts.body and read it from mock Response body 156 | 157 | let bodyText: string | undefined; 158 | 159 | const text = async () => { 160 | if (bodyText) return bodyText; 161 | 162 | const buffers = []; 163 | for await (const data of opts.body) { 164 | buffers.push(data); 165 | } 166 | bodyText = Buffer.concat(buffers).toString('utf8'); 167 | 168 | return bodyText; 169 | }; 170 | 171 | const parseEnvelope = async () => { 172 | return (await text()) 173 | .split('\n') 174 | .map((jsonLine) => JSON.parse(jsonLine)); 175 | }; 176 | 177 | requests.push({ 178 | text, 179 | json: async () => { 180 | return JSON.parse(await text()); 181 | }, 182 | parseEnvelope, 183 | envelopePayload: async () => { 184 | const envelope = await parseEnvelope(); 185 | return envelope[envelope.length - 1]; 186 | }, 187 | headers: opts.headers as Record, 188 | method: opts.method, 189 | origin: opts.origin, 190 | path: opts.path, 191 | }); 192 | 193 | // Return information about Request that we can use in tests (https://undici.nodejs.org/#/docs/best-practices/mocking-request?id=reply-with-data-based-on-request) 194 | return opts; 195 | }) 196 | .persist(); 197 | }); 198 | 199 | beforeEach(() => { 200 | context = new ExecutionContext(); 201 | mockConsole(); 202 | }); 203 | 204 | afterEach(() => { 205 | requests = []; 206 | resetConsole(); 207 | }); 208 | 209 | describe('general', () => { 210 | test('disabled mode', async () => { 211 | const toucan = new Toucan({ 212 | dsn: '', 213 | context, 214 | }); 215 | 216 | toucan.captureMessage('test1'); 217 | toucan.captureMessage('test2'); 218 | toucan.captureMessage('test3'); 219 | toucan.captureMessage('test4'); 220 | 221 | const waitUntilResults = await getMiniflareWaitUntil(context); 222 | 223 | expect(waitUntilResults.length).toBe(0); 224 | }); 225 | 226 | test('disabled mode when no dsn is provided', async () => { 227 | const toucan = new Toucan({ 228 | context, 229 | }); 230 | 231 | toucan.captureMessage('test1'); 232 | toucan.captureMessage('test2'); 233 | toucan.captureMessage('test3'); 234 | toucan.captureMessage('test4'); 235 | 236 | const waitUntilResults = await getMiniflareWaitUntil(context); 237 | 238 | expect(waitUntilResults.length).toBe(0); 239 | expect(requests.length).toBe(0); 240 | }); 241 | 242 | test('disable / enable', async () => { 243 | const toucan = new Toucan({ 244 | dsn: VALID_DSN, 245 | context, 246 | }); 247 | 248 | // Sent 249 | toucan.captureMessage('test 1'); 250 | 251 | toucan.setEnabled(false); 252 | 253 | // Not sent 254 | toucan.captureMessage('test 2'); 255 | toucan.captureMessage('test 3'); 256 | toucan.captureMessage('test 4'); 257 | toucan.captureException(new Error()); 258 | toucan.captureException(new Error()); 259 | toucan.captureException(new Error()); 260 | 261 | toucan.setEnabled(true); 262 | 263 | // Sent 264 | toucan.captureMessage('test 5'); 265 | 266 | const waitUntilResults = await getMiniflareWaitUntil(context); 267 | 268 | expect(waitUntilResults.length).toBe(2); 269 | expect(requests.length).toBe(2); 270 | }); 271 | 272 | test('invalid URL does not fail', async () => { 273 | const toucan = new Toucan({ 274 | dsn: VALID_DSN, 275 | context, 276 | request: { url: 'garbage?query%', headers: new Headers() } as Request, 277 | requestDataOptions: { 278 | allowedSearchParams: new RegExp('.*'), // don't censor query string 279 | }, 280 | }); 281 | 282 | toucan.captureMessage('test'); 283 | 284 | const waitUntilResults = await getMiniflareWaitUntil(context); 285 | 286 | expect(waitUntilResults.length).toBe(1); 287 | expect(requests.length).toBe(1); 288 | 289 | const requestBody = await requests[0].envelopePayload(); 290 | 291 | expect(requestBody).toMatchSnapshot(GENERIC_EVENT_BODY_MATCHER); 292 | }); 293 | 294 | test('pass custom headers in transportOptions', async () => { 295 | const toucan = new Toucan({ 296 | dsn: VALID_DSN, 297 | transportOptions: { 298 | headers: { 299 | 'X-Custom-Header': '1', 300 | }, 301 | }, 302 | context, 303 | }); 304 | toucan.captureMessage('test'); 305 | 306 | const waitUntilResults = await getMiniflareWaitUntil(context); 307 | 308 | expect(waitUntilResults.length).toBe(1); 309 | expect(requests.length).toBe(1); 310 | 311 | expect(requests[0].headers['x-custom-header']).toEqual('1'); 312 | }); 313 | 314 | test('custom fetcher', async () => { 315 | const fetcher = mockFetch(); 316 | const toucan = new Toucan({ 317 | dsn: VALID_DSN, 318 | transportOptions: { 319 | fetcher, 320 | }, 321 | context, 322 | }); 323 | toucan.captureMessage('test'); 324 | 325 | const waitUntilResults = await getMiniflareWaitUntil(context); 326 | 327 | expect(waitUntilResults.length).toBe(1); 328 | expect(requests.length).toBe(0); 329 | expect(fetcher.mock.calls.length).toBe(1); 330 | }); 331 | 332 | test('unhandled exception in SDK options does not explode the worker', async () => { 333 | const toucan = new Toucan({ 334 | dsn: VALID_DSN, 335 | context, 336 | beforeSend: (event) => { 337 | // intentionally do something unintentional 338 | JSON.parse('not json'); 339 | return event; 340 | }, 341 | }); 342 | 343 | expect(() => toucan.captureMessage('test')).not.toThrowError(); 344 | }); 345 | }); 346 | 347 | describe('captureMessage', () => { 348 | // This is testing everything that should use 'waitUntil' to guarantee test completion. 349 | test(`triggers waitUntil'd fetch`, async () => { 350 | const toucan = new Toucan({ 351 | dsn: VALID_DSN, 352 | context, 353 | }); 354 | 355 | toucan.captureMessage('test'); 356 | 357 | const waitUntilResults = await getMiniflareWaitUntil(context); 358 | 359 | expect(waitUntilResults.length).toBe(1); 360 | expect(requests.length).toBe(1); 361 | }); 362 | 363 | // This is testing Durable Objects where all async tasks complete by default without having to call 'waitUntil'. 364 | test('triggers naked fetch', async () => { 365 | const toucan = new Toucan({ 366 | dsn: VALID_DSN, 367 | }); 368 | 369 | toucan.captureMessage('test'); 370 | 371 | const waitUntilResults = await getMiniflareWaitUntil(context); 372 | 373 | expect(waitUntilResults.length).toBe(0); 374 | expect(requests.length).toBe(1); 375 | }); 376 | 377 | // This is testing everything that should use 'waitUntil' to guarantee test completion. 378 | test(`sends correct body to Sentry`, async () => { 379 | const toucan = new Toucan({ 380 | dsn: VALID_DSN, 381 | context, 382 | }); 383 | 384 | toucan.captureMessage('test'); 385 | 386 | const waitUntilResults = await getMiniflareWaitUntil(context); 387 | 388 | expect(waitUntilResults.length).toBe(1); 389 | expect(requests.length).toBe(1); 390 | 391 | // Must store in {result: Matcher} because Matchers don't work with arrays 392 | // https://github.com/facebook/jest/issues/9079 393 | expect({ result: await requests[0].parseEnvelope() }).toMatchSnapshot({ 394 | result: expect.arrayContaining(PARSED_ENVELOPE_MATCHER), 395 | }); 396 | }); 397 | }); 398 | 399 | describe('captureException', () => { 400 | // This is testing everything that should use 'waitUntil' to guarantee test completion. 401 | test(`triggers waitUntil'd fetch`, async () => { 402 | const toucan = new Toucan({ 403 | dsn: VALID_DSN, 404 | context, 405 | }); 406 | 407 | toucan.captureException(new Error()); 408 | 409 | const waitUntilResults = await getMiniflareWaitUntil(context); 410 | 411 | expect(waitUntilResults.length).toBe(1); 412 | expect(requests.length).toBe(1); 413 | }); 414 | 415 | // This is testing Durable Objects where all async tasks complete by default without having to call 'waitUntil'. 416 | test('triggers naked fetch', async () => { 417 | const toucan = new Toucan({ 418 | dsn: VALID_DSN, 419 | }); 420 | 421 | toucan.captureMessage('test'); 422 | 423 | const waitUntilResults = await getMiniflareWaitUntil(context); 424 | 425 | expect(waitUntilResults.length).toBe(0); 426 | expect(requests.length).toBe(1); 427 | }); 428 | 429 | test('runtime thrown Error', async () => { 430 | const toucan = new Toucan({ 431 | dsn: VALID_DSN, 432 | context, 433 | }); 434 | 435 | try { 436 | JSON.parse('abc'); 437 | } catch (e) { 438 | toucan.captureException(e); 439 | } 440 | 441 | const waitUntilResults = await getMiniflareWaitUntil(context); 442 | 443 | expect(waitUntilResults.length).toBe(1); 444 | expect(requests.length).toBe(1); 445 | expect(await requests[0].envelopePayload()).toMatchSnapshot( 446 | createExceptionBodyMatcher([ 447 | { 448 | value: 'Unexpected token a in JSON at position 0', 449 | type: 'SyntaxError', 450 | }, 451 | ]), 452 | ); 453 | }); 454 | 455 | test('Error with cause', async () => { 456 | const toucan = new Toucan({ 457 | dsn: VALID_DSN, 458 | context, 459 | }); 460 | 461 | try { 462 | try { 463 | throw new Error('original error'); 464 | } catch (cause) { 465 | throw new Error('outer error with cause', { cause }); 466 | } 467 | } catch (e) { 468 | toucan.captureException(e); 469 | } 470 | 471 | const waitUntilResults = await getMiniflareWaitUntil(context); 472 | 473 | expect(waitUntilResults.length).toBe(1); 474 | expect(requests.length).toBe(1); 475 | expect(await requests[0].envelopePayload()).toMatchSnapshot( 476 | createExceptionBodyMatcher([ 477 | { value: 'original error', type: 'Error' }, 478 | { value: 'outer error with cause', type: 'Error' }, 479 | ]), 480 | ); 481 | }); 482 | 483 | test('object', async () => { 484 | const toucan = new Toucan({ 485 | dsn: VALID_DSN, 486 | context, 487 | }); 488 | 489 | try { 490 | throw { foo: 'test', bar: 'baz' }; 491 | } catch (e) { 492 | toucan.captureException(e); 493 | } 494 | 495 | const waitUntilResults = await getMiniflareWaitUntil(context); 496 | 497 | expect(waitUntilResults.length).toBe(1); 498 | expect(requests.length).toBe(1); 499 | expect(await requests[0].envelopePayload()).toMatchSnapshot( 500 | createExceptionBodyMatcher([ 501 | { 502 | value: 'Non-Error exception captured with keys: bar, foo', 503 | type: 'Error', 504 | synthetic: true, 505 | }, 506 | ]), 507 | ); 508 | }); 509 | 510 | test('captureException: primitive', async () => { 511 | const toucan = new Toucan({ 512 | dsn: VALID_DSN, 513 | context, 514 | }); 515 | 516 | try { 517 | throw 'test'; 518 | } catch (e) { 519 | toucan.captureException(e); 520 | } 521 | 522 | try { 523 | throw true; 524 | } catch (e) { 525 | toucan.captureException(e); 526 | } 527 | 528 | try { 529 | throw 10; 530 | } catch (e) { 531 | toucan.captureException(e); 532 | } 533 | 534 | const waitUntilResults = await getMiniflareWaitUntil(context); 535 | 536 | expect(waitUntilResults.length).toBe(3); 537 | expect(requests.length).toBe(3); 538 | expect(await requests[0].envelopePayload()).toMatchSnapshot( 539 | createExceptionBodyMatcher([ 540 | { 541 | value: 'test', 542 | type: 'Error', 543 | synthetic: true, 544 | }, 545 | ]), 546 | ); 547 | expect(await requests[1].envelopePayload()).toMatchSnapshot( 548 | createExceptionBodyMatcher([ 549 | { 550 | value: true, 551 | type: 'Error', 552 | synthetic: true, 553 | }, 554 | ]), 555 | ); 556 | 557 | expect(await requests[2].envelopePayload()).toMatchSnapshot( 558 | createExceptionBodyMatcher([ 559 | { 560 | value: 10, 561 | type: 'Error', 562 | synthetic: true, 563 | }, 564 | ]), 565 | ); 566 | }); 567 | }); 568 | 569 | describe('captureCheckIn', () => { 570 | test(`is sent`, async () => { 571 | const toucan = new Toucan({ 572 | dsn: VALID_DSN, 573 | context, 574 | }); 575 | 576 | const checkInId = toucan.captureCheckIn({ 577 | monitorSlug: 'my_job', 578 | status: 'in_progress', 579 | }); 580 | 581 | toucan.captureCheckIn({ 582 | checkInId: checkInId, 583 | monitorSlug: 'my_job', 584 | status: 'error', 585 | }); 586 | 587 | const waitUntilResults = await getMiniflareWaitUntil(context); 588 | 589 | expect(waitUntilResults.length).toBe(2); 590 | expect(requests.length).toBe(2); 591 | 592 | const requestBody = await requests[0].envelopePayload(); 593 | 594 | expect(requestBody).toEqual({ 595 | check_in_id: checkInId, 596 | monitor_slug: 'my_job', 597 | status: 'in_progress', 598 | }); 599 | 600 | const requestBody2 = await requests[1].envelopePayload(); 601 | 602 | expect(requestBody2).toEqual({ 603 | check_in_id: checkInId, 604 | monitor_slug: 'my_job', 605 | status: 'error', 606 | }); 607 | 608 | expect(requestBody.check_in_id).toBe(requestBody2.check_in_id); 609 | }); 610 | 611 | test(`is linked to errors`, async () => { 612 | const toucan = new Toucan({ 613 | dsn: VALID_DSN, 614 | context, 615 | }); 616 | 617 | const checkInId = toucan.captureCheckIn({ 618 | monitorSlug: 'my_job', 619 | status: 'in_progress', 620 | }); 621 | 622 | toucan.captureMessage('test'); 623 | 624 | const waitUntilResults = await getMiniflareWaitUntil(context); 625 | 626 | expect(waitUntilResults.length).toBe(2); 627 | expect(requests.length).toBe(2); 628 | 629 | const checkInRequestBody = await requests[0].envelopePayload(); 630 | 631 | expect(checkInRequestBody).toEqual({ 632 | check_in_id: checkInId, 633 | monitor_slug: 'my_job', 634 | status: 'in_progress', 635 | }); 636 | 637 | const errorRequestBody = await requests[1].envelopePayload(); 638 | 639 | expect(errorRequestBody.contexts.monitor).toEqual({ slug: 'my_job' }); 640 | }); 641 | }); 642 | 643 | describe('addBreadcrumb', () => { 644 | test('captures last 100 breadcrumbs by default', async () => { 645 | const toucan = new Toucan({ 646 | dsn: VALID_DSN, 647 | context, 648 | }); 649 | 650 | for (let i = 0; i < 200; i++) { 651 | toucan.addBreadcrumb({ message: 'test', data: { index: i } }); 652 | } 653 | toucan.captureMessage('test'); 654 | 655 | const waitUntilResults = await getMiniflareWaitUntil(context); 656 | 657 | expect(waitUntilResults.length).toBe(1); 658 | expect(requests.length).toBe(1); 659 | 660 | const requestBody = await requests[0].envelopePayload(); 661 | 662 | // Should have sent only 100 last breadcrums (100 is default) 663 | expect(requestBody.breadcrumbs.length).toBe(100); 664 | expect(requestBody.breadcrumbs[0].data.index).toBe(100); 665 | expect(requestBody.breadcrumbs[99].data.index).toBe(199); 666 | }); 667 | 668 | test('maxBreadcrumbs option to override max breadcumb count', async () => { 669 | const toucan = new Toucan({ 670 | dsn: VALID_DSN, 671 | context, 672 | maxBreadcrumbs: 20, 673 | }); 674 | 675 | for (let i = 0; i < 200; i++) { 676 | toucan.addBreadcrumb({ message: 'test', data: { index: i } }); 677 | } 678 | toucan.captureMessage('test'); 679 | 680 | const waitUntilResults = await getMiniflareWaitUntil(context); 681 | 682 | expect(waitUntilResults.length).toBe(1); 683 | expect(requests.length).toBe(1); 684 | 685 | const requestBody = await requests[0].envelopePayload(); 686 | 687 | // Should have sent only 20 last breadcrums 688 | expect(requestBody.breadcrumbs.length).toBe(20); 689 | expect(requestBody.breadcrumbs[0].data.index).toBe(180); 690 | expect(requestBody.breadcrumbs[19].data.index).toBe(199); 691 | }); 692 | }); 693 | 694 | describe('setUser', () => { 695 | test('provides user info', async () => { 696 | const toucan = new Toucan({ 697 | dsn: VALID_DSN, 698 | context, 699 | }); 700 | 701 | toucan.setUser({ id: 'testid', email: 'test@gmail.com' }); 702 | toucan.captureMessage('test'); 703 | 704 | const waitUntilResults = await getMiniflareWaitUntil(context); 705 | 706 | expect(waitUntilResults.length).toBe(1); 707 | expect(requests.length).toBe(1); 708 | 709 | const requestBody = await requests[0].envelopePayload(); 710 | 711 | expect(requestBody.user).toEqual({ 712 | id: 'testid', 713 | email: 'test@gmail.com', 714 | }); 715 | }); 716 | }); 717 | 718 | describe('tags', () => { 719 | test('setTag adds tag to request', async () => { 720 | const toucan = new Toucan({ 721 | dsn: VALID_DSN, 722 | context, 723 | }); 724 | 725 | toucan.setTag('foo', 'bar'); 726 | toucan.captureMessage('test'); 727 | 728 | const waitUntilResults = await getMiniflareWaitUntil(context); 729 | 730 | expect(waitUntilResults.length).toBe(1); 731 | expect(requests.length).toBe(1); 732 | 733 | const requestBody = await requests[0].envelopePayload(); 734 | 735 | expect(requestBody.tags).toEqual({ 736 | foo: 'bar', 737 | }); 738 | }); 739 | 740 | test('setTags adds tags to request', async () => { 741 | const toucan = new Toucan({ 742 | dsn: VALID_DSN, 743 | context, 744 | }); 745 | 746 | toucan.setTags({ foo: 'bar', bar: 'baz' }); 747 | toucan.captureMessage('test'); 748 | 749 | const waitUntilResults = await getMiniflareWaitUntil(context); 750 | 751 | expect(waitUntilResults.length).toBe(1); 752 | expect(requests.length).toBe(1); 753 | 754 | const requestBody = await requests[0].envelopePayload(); 755 | 756 | expect(requestBody.tags).toEqual({ foo: 'bar', bar: 'baz' }); 757 | }); 758 | }); 759 | 760 | describe('extras', () => { 761 | test('setExtra adds extra to request', async () => { 762 | const toucan = new Toucan({ 763 | dsn: VALID_DSN, 764 | context, 765 | }); 766 | 767 | toucan.setExtra('foo', 'bar'); 768 | toucan.captureMessage('test'); 769 | 770 | const waitUntilResults = await getMiniflareWaitUntil(context); 771 | 772 | expect(waitUntilResults.length).toBe(1); 773 | expect(requests.length).toBe(1); 774 | 775 | const requestBody = await requests[0].envelopePayload(); 776 | 777 | expect(requestBody.extra).toEqual({ 778 | foo: 'bar', 779 | }); 780 | }); 781 | 782 | test('setExtras adds extra to request', async () => { 783 | const toucan = new Toucan({ 784 | dsn: VALID_DSN, 785 | context, 786 | }); 787 | 788 | toucan.setExtras({ foo: 'bar', bar: 'baz' }); 789 | toucan.captureMessage('test'); 790 | 791 | const waitUntilResults = await getMiniflareWaitUntil(context); 792 | 793 | expect(waitUntilResults.length).toBe(1); 794 | expect(requests.length).toBe(1); 795 | 796 | const requestBody = await requests[0].envelopePayload(); 797 | 798 | expect(requestBody.extra).toEqual({ foo: 'bar', bar: 'baz' }); 799 | }); 800 | }); 801 | 802 | describe('request', () => { 803 | test('no request PII captured by default', async () => { 804 | const request = new Request('https://myworker.workers.dev', { 805 | method: 'POST', 806 | body: JSON.stringify({ foo: 'bar' }), 807 | }); 808 | 809 | const toucan = new Toucan({ 810 | dsn: VALID_DSN, 811 | context, 812 | request, 813 | }); 814 | 815 | toucan.captureMessage('test'); 816 | 817 | const waitUntilResults = await getMiniflareWaitUntil(context); 818 | 819 | expect(waitUntilResults.length).toBe(1); 820 | expect(requests.length).toBe(1); 821 | 822 | const requestBody = await requests[0].envelopePayload(); 823 | 824 | expect(requestBody.request).toEqual({ 825 | method: 'POST', 826 | url: 'https://myworker.workers.dev/', 827 | }); 828 | expect(requestBody.request.cookies).toBeUndefined(); 829 | expect(requestBody.request.data).toBeUndefined(); 830 | expect(requestBody.request.headers).toBeUndefined(); 831 | expect(requestBody.request.query_string).toBeUndefined(); 832 | expect(requestBody.user).toBeUndefined(); 833 | }); 834 | 835 | test('request body captured after setRequestBody call', async () => { 836 | const request = new Request('https://myworker.workers.dev', { 837 | method: 'POST', 838 | body: JSON.stringify({ foo: 'bar' }), 839 | }); 840 | 841 | const toucan = new Toucan({ 842 | dsn: VALID_DSN, 843 | context, 844 | request, 845 | }); 846 | 847 | toucan.setRequestBody(await request.json()); 848 | toucan.captureMessage('test'); 849 | 850 | const waitUntilResults = await getMiniflareWaitUntil(context); 851 | 852 | expect(waitUntilResults.length).toBe(1); 853 | expect(requests.length).toBe(1); 854 | 855 | const requestBody = await requests[0].envelopePayload(); 856 | 857 | expect(requestBody.request).toEqual({ 858 | data: { foo: 'bar' }, 859 | method: 'POST', 860 | url: 'https://myworker.workers.dev/', 861 | }); 862 | }); 863 | 864 | test('allowlists', async () => { 865 | const request = new Request( 866 | 'https://example.com?foo=bar&bar=baz&baz=bam', 867 | { 868 | method: 'POST', 869 | headers: { 870 | 'Content-Type': 'application/json', 871 | 'X-Foo': 'Foo', 872 | 'X-Bar': 'Bar', 873 | 'User-Agent': 874 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0', 875 | cookie: 'foo=bar; fo=bar; bar=baz', 876 | 'CF-Connecting-Ip': '255.255.255.255', 877 | }, 878 | body: JSON.stringify({ foo: 'bar', bar: 'baz' }), 879 | }, 880 | ); 881 | 882 | const toucan = new Toucan({ 883 | dsn: VALID_DSN, 884 | context, 885 | request, 886 | requestDataOptions: { 887 | allowedCookies: /^fo/, 888 | allowedHeaders: ['user-agent', 'X-Foo'], 889 | allowedSearchParams: ['foo', 'bar'], 890 | allowedIps: true, 891 | }, 892 | }); 893 | 894 | toucan.captureMessage('test'); 895 | 896 | const waitUntilResults = await getMiniflareWaitUntil(context); 897 | 898 | expect(waitUntilResults.length).toBe(1); 899 | expect(requests.length).toBe(1); 900 | 901 | const requestBody = await requests[0].envelopePayload(); 902 | 903 | expect(requestBody.request).toEqual({ 904 | cookies: { fo: 'bar', foo: 'bar' }, 905 | headers: { 906 | 'user-agent': 907 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0', 908 | 'x-foo': 'Foo', 909 | }, 910 | method: 'POST', 911 | query_string: 'foo=bar&bar=baz', 912 | url: 'https://example.com/', 913 | }); 914 | expect(requestBody.user).toEqual({ ip_address: '255.255.255.255' }); 915 | }); 916 | 917 | test('beforeSend runs after allowlists and setRequestBody', async () => { 918 | const request = new Request( 919 | 'https://example.com?foo=bar&bar=baz&baz=bam', 920 | { 921 | method: 'POST', 922 | headers: { 923 | 'Content-Type': 'application/json', 924 | 'X-Foo': 'Foo', 925 | 'X-Bar': 'Bar', 926 | 'User-Agent': 927 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0', 928 | cookie: 'foo=bar; fo=bar; bar=baz', 929 | }, 930 | body: JSON.stringify({ foo: 'bar', bar: 'baz' }), 931 | }, 932 | ); 933 | 934 | const toucan = new Toucan({ 935 | dsn: VALID_DSN, 936 | context, 937 | request, 938 | requestDataOptions: { 939 | allowedCookies: /^fo/, 940 | allowedHeaders: ['user-agent', 'X-Foo'], 941 | allowedSearchParams: ['foo', 'bar'], 942 | }, 943 | // beforeSend is provided - allowlists above should be ignored. 944 | beforeSend: (event) => { 945 | delete event.request?.cookies; 946 | delete event.request?.query_string; 947 | if (event.request) { 948 | event.request.headers = { 949 | 'X-Foo': 'Bar', 950 | }; 951 | event.request.data = undefined; 952 | } 953 | return event; 954 | }, 955 | }); 956 | 957 | toucan.setRequestBody(await request.json()); 958 | toucan.captureMessage('test'); 959 | 960 | const waitUntilResults = await getMiniflareWaitUntil(context); 961 | 962 | expect(waitUntilResults.length).toBe(1); 963 | expect(requests.length).toBe(1); 964 | 965 | const requestBody = await requests[0].envelopePayload(); 966 | 967 | expect(requestBody.request).toEqual({ 968 | headers: { 969 | 'X-Foo': 'Bar', 970 | }, 971 | method: 'POST', 972 | url: 'https://example.com/', 973 | }); 974 | }); 975 | }); 976 | 977 | describe('stacktraces', () => { 978 | test('attachStacktrace = true sends stacktrace with captureMessage', async () => { 979 | const toucan = new Toucan({ 980 | dsn: VALID_DSN, 981 | context, 982 | attachStacktrace: true, 983 | }); 984 | 985 | toucan.captureMessage('message with stacktrace'); 986 | 987 | const waitUntilResults = await getMiniflareWaitUntil(context); 988 | 989 | expect(waitUntilResults.length).toBe(1); 990 | expect(requests.length).toBe(1); 991 | 992 | const requestBody = await requests[0].envelopePayload(); 993 | 994 | expect(requestBody).toMatchSnapshot( 995 | createExceptionBodyMatcher([ 996 | { 997 | value: 'message with stacktrace', 998 | mechanism: false, 999 | }, 1000 | ]), 1001 | ); 1002 | }); 1003 | }); 1004 | 1005 | describe('fingerprinting', () => { 1006 | test('setFingerprint sets fingerprint on request', async () => { 1007 | const toucan = new Toucan({ 1008 | dsn: VALID_DSN, 1009 | context, 1010 | }); 1011 | 1012 | toucan.setFingerprint(['{{ default }}', 'https://example.com']); 1013 | toucan.captureMessage('test'); 1014 | 1015 | const waitUntilResults = await getMiniflareWaitUntil(context); 1016 | 1017 | expect(waitUntilResults.length).toBe(1); 1018 | expect(requests.length).toBe(1); 1019 | 1020 | const requestBody = await requests[0].envelopePayload(); 1021 | 1022 | expect(requestBody.fingerprint).toEqual([ 1023 | '{{ default }}', 1024 | 'https://example.com', 1025 | ]); 1026 | }); 1027 | }); 1028 | 1029 | describe('sampling', () => { 1030 | test('sampleRate = 0 should send 0% of events', async () => { 1031 | const toucan = new Toucan({ 1032 | dsn: VALID_DSN, 1033 | context, 1034 | sampleRate: 0, 1035 | }); 1036 | 1037 | for (let i = 0; i < 1000; i++) { 1038 | toucan.captureMessage('test'); 1039 | } 1040 | 1041 | const waitUntilResults = await getMiniflareWaitUntil(context); 1042 | 1043 | expect(waitUntilResults.length).toBe(0); 1044 | expect(requests.length).toBe(0); 1045 | }); 1046 | 1047 | test('sampleRate = 1 should send 100% of events', async () => { 1048 | const toucan = new Toucan({ 1049 | dsn: VALID_DSN, 1050 | context, 1051 | sampleRate: 1, 1052 | }); 1053 | 1054 | for (let i = 0; i < DEFAULT_BUFFER_SIZE; i++) { 1055 | toucan.captureMessage('test'); 1056 | } 1057 | 1058 | const waitUntilResults = await getMiniflareWaitUntil(context); 1059 | 1060 | expect(waitUntilResults.length).toBe(DEFAULT_BUFFER_SIZE); 1061 | expect(requests.length).toBe(DEFAULT_BUFFER_SIZE); 1062 | }); 1063 | 1064 | test('sampleRate = 0.5 should send 50% of events', async () => { 1065 | // Make Math.random always return 0, 0.9, 0, 0.9 ... 1066 | mockMathRandom([0, 0.9]); 1067 | 1068 | const toucan = new Toucan({ 1069 | dsn: VALID_DSN, 1070 | context, 1071 | sampleRate: 0.5, 1072 | }); 1073 | 1074 | for (let i = 0; i < 10; i++) { 1075 | toucan.captureMessage('test'); 1076 | } 1077 | 1078 | const waitUntilResults = await getMiniflareWaitUntil(context); 1079 | 1080 | expect(waitUntilResults.length).toBe(5); 1081 | expect(requests.length).toBe(5); 1082 | 1083 | resetMathRandom(); 1084 | }); 1085 | 1086 | test('sampleRate set to invalid value does not explode the SDK', async () => { 1087 | mockMathRandom([0, 0.9]); 1088 | 1089 | const toucan = new Toucan({ 1090 | dsn: VALID_DSN, 1091 | context, 1092 | // @ts-expect-error Testing invalid runtime value 1093 | sampleRate: 'hello', 1094 | }); 1095 | 1096 | for (let i = 0; i < 10; i++) { 1097 | toucan.captureMessage('test'); 1098 | } 1099 | 1100 | const waitUntilResults = await getMiniflareWaitUntil(context); 1101 | 1102 | expect(waitUntilResults.length).toBe(10); 1103 | expect(requests.length).toBe(10); 1104 | 1105 | resetMathRandom(); 1106 | }); 1107 | }); 1108 | 1109 | describe('cloning', () => { 1110 | test('clone', async () => { 1111 | const toucan = new Toucan({ dsn: VALID_DSN, context }); 1112 | 1113 | toucan.addBreadcrumb({ message: 'test' }); 1114 | toucan.setTag('foo', 'bar'); 1115 | toucan.setExtra('foo', 'bar'); 1116 | toucan.setContext('foo', { foo: 'bar' }); 1117 | toucan.setUser({ email: 'foo@bar.com' }); 1118 | toucan.setLevel('debug'); 1119 | toucan.setSession({ ipAddress: '1.1.1.1' } as Session); 1120 | toucan.setTransactionName('foo'); 1121 | toucan.setFingerprint(['foo']); 1122 | toucan.addEventProcessor((event) => { 1123 | // Verifying 'extra' added by event processor allows us to verify that event processors get cloned as well 1124 | if (event.extra) event.extra['bar'] = 'baz'; 1125 | return event; 1126 | }); 1127 | toucan.setRequestSession({ status: 'ok' }); 1128 | toucan.setSDKProcessingMetadata({ foo: 'bar' }); 1129 | toucan.setPropagationContext({ spanId: 'foo', traceId: 'bar' }); 1130 | toucan.setLastEventId('foo'); 1131 | 1132 | const toucanClone1 = toucan.clone(); 1133 | const toucanClone2 = toucan.clone(); 1134 | const toucanClone3 = toucanClone2.clone(); 1135 | 1136 | // Verify we always create new client instance during Toucan.clone() 1137 | expect(toucan.getClient()).not.toBe(toucanClone1.getClient()); 1138 | expect(toucan.getClient()).not.toBe(toucanClone2.getClient()); 1139 | expect(toucan.getClient()).not.toBe(toucanClone3.getClient()); 1140 | expect(toucanClone1.getClient()).not.toBe(toucanClone2.getClient()); 1141 | expect(toucanClone1.getClient()).not.toBe(toucanClone3.getClient()); 1142 | expect(toucanClone2.getClient()).not.toBe(toucanClone3.getClient()); 1143 | 1144 | // Capture an exception on original and cloned instances and verify the metadata in payloads are the same 1145 | toucan.captureException(new Error('error!')); 1146 | toucanClone1.captureException(new Error('error!')); 1147 | toucanClone2.captureException(new Error('error!')); 1148 | toucanClone3.captureException(new Error('error!')); 1149 | 1150 | const waitUntilResults = await getMiniflareWaitUntil(context); 1151 | 1152 | expect(waitUntilResults.length).toBe(4); 1153 | expect(requests.length).toBe(4); 1154 | 1155 | const assertEnvelopePayloadsEqual = ( 1156 | p1: Record, 1157 | p2: Record, 1158 | ) => { 1159 | const assertPropertyExistsAndEqual = (propertyName: string) => { 1160 | expect(propertyName in p1).toBe(true); 1161 | expect(p1[propertyName]).toBeTruthy(); 1162 | 1163 | expect(propertyName in p2).toBe(true); 1164 | expect(p2[propertyName]).toBeTruthy(); 1165 | 1166 | expect(p1[propertyName]).toStrictEqual(p2[propertyName]); 1167 | }; 1168 | 1169 | assertPropertyExistsAndEqual('breadcrumbs'); 1170 | assertPropertyExistsAndEqual('tags'); 1171 | assertPropertyExistsAndEqual('extra'); 1172 | assertPropertyExistsAndEqual('contexts'); 1173 | assertPropertyExistsAndEqual('user'); 1174 | assertPropertyExistsAndEqual('level'); 1175 | assertPropertyExistsAndEqual('transaction'); 1176 | assertPropertyExistsAndEqual('fingerprint'); 1177 | }; 1178 | 1179 | assertEnvelopePayloadsEqual( 1180 | await requests[0].envelopePayload(), 1181 | await requests[1].envelopePayload(), 1182 | ); 1183 | assertEnvelopePayloadsEqual( 1184 | await requests[0].envelopePayload(), 1185 | await requests[2].envelopePayload(), 1186 | ); 1187 | assertEnvelopePayloadsEqual( 1188 | await requests[0].envelopePayload(), 1189 | await requests[3].envelopePayload(), 1190 | ); 1191 | assertEnvelopePayloadsEqual( 1192 | await requests[1].envelopePayload(), 1193 | await requests[2].envelopePayload(), 1194 | ); 1195 | assertEnvelopePayloadsEqual( 1196 | await requests[1].envelopePayload(), 1197 | await requests[3].envelopePayload(), 1198 | ); 1199 | assertEnvelopePayloadsEqual( 1200 | await requests[2].envelopePayload(), 1201 | await requests[3].envelopePayload(), 1202 | ); 1203 | }); 1204 | }); 1205 | 1206 | describe('scope', () => { 1207 | test('withScope', async () => { 1208 | const toucan = new Toucan({ 1209 | dsn: VALID_DSN, 1210 | context, 1211 | }); 1212 | 1213 | toucan.setExtra('foo', 'bar'); 1214 | 1215 | // Simple case 1216 | toucan.withScope((scope) => { 1217 | scope.setExtra('bar', 'baz'); 1218 | //expected {"foo": "bar", "bar": "baz"} 1219 | scope.captureMessage('test withScope simple'); 1220 | }); 1221 | 1222 | // Nested case 1223 | toucan.withScope((scope) => { 1224 | scope.setExtra('bar', 'baz'); 1225 | scope.withScope((scope) => { 1226 | scope.setExtra('baz', 'bam'); 1227 | // expected {"foo": "bar", "bar": "baz", "baz": "bam"} 1228 | scope.captureMessage('test withScope nested'); 1229 | }); 1230 | // expected {"foo": "bar", "bar": "baz"} 1231 | scope.captureMessage('test withScope nested'); 1232 | }); 1233 | 1234 | // expected {"foo": "bar"} 1235 | toucan.captureMessage('test'); 1236 | 1237 | const waitUntilResults = await getMiniflareWaitUntil(context); 1238 | 1239 | expect(waitUntilResults.length).toBe(4); 1240 | expect(requests.length).toBe(4); 1241 | 1242 | expect((await requests[0].envelopePayload()).extra).toStrictEqual({ 1243 | foo: 'bar', 1244 | bar: 'baz', 1245 | }); 1246 | expect((await requests[1].envelopePayload()).extra).toStrictEqual({ 1247 | foo: 'bar', 1248 | bar: 'baz', 1249 | baz: 'bam', 1250 | }); 1251 | expect((await requests[2].envelopePayload()).extra).toStrictEqual({ 1252 | foo: 'bar', 1253 | bar: 'baz', 1254 | }); 1255 | expect((await requests[3].envelopePayload()).extra).toStrictEqual({ 1256 | foo: 'bar', 1257 | }); 1258 | }); 1259 | }); 1260 | 1261 | describe('integrations', () => { 1262 | const messageScrubberIntegration = defineIntegration( 1263 | (scrubMessage: string) => { 1264 | return { 1265 | name: 'MessageScrubber', 1266 | processEvent: (event: Event) => { 1267 | event.message = scrubMessage; 1268 | 1269 | return event; 1270 | }, 1271 | }; 1272 | }, 1273 | ); 1274 | 1275 | test('empty custom integrations do not delete default integrations', async () => { 1276 | const toucan = new Toucan({ 1277 | dsn: VALID_DSN, 1278 | context, 1279 | integrations: [], 1280 | }); 1281 | 1282 | toucan.captureMessage('test'); 1283 | 1284 | const waitUntilResults = await getMiniflareWaitUntil(context); 1285 | 1286 | expect(waitUntilResults.length).toBe(1); 1287 | expect(requests.length).toBe(1); 1288 | 1289 | const requestBody = await requests[0].envelopePayload(); 1290 | 1291 | expect(requestBody.sdk.integrations).toEqual(DEFAULT_INTEGRATIONS); 1292 | }); 1293 | 1294 | test('custom integrations merge with default integrations', async () => { 1295 | const toucan = new Toucan({ 1296 | dsn: VALID_DSN, 1297 | context, 1298 | integrations: [messageScrubberIntegration('[redacted]')], 1299 | }); 1300 | 1301 | toucan.captureMessage('test'); 1302 | 1303 | const waitUntilResults = await getMiniflareWaitUntil(context); 1304 | 1305 | expect(waitUntilResults.length).toBe(1); 1306 | expect(requests.length).toBe(1); 1307 | 1308 | const requestBody = await requests[0].envelopePayload(); 1309 | 1310 | expect(requestBody.sdk.integrations).toEqual([ 1311 | ...DEFAULT_INTEGRATIONS, 1312 | 'MessageScrubber', 1313 | ]); 1314 | expect(requestBody.message).toBe('[redacted]'); 1315 | }); 1316 | 1317 | test('integrations do not use globals', async () => { 1318 | const toucan1 = new Toucan({ 1319 | dsn: VALID_DSN, 1320 | context, 1321 | integrations: [messageScrubberIntegration('[redacted-1]')], 1322 | }); 1323 | 1324 | toucan1.captureMessage('test'); 1325 | 1326 | const toucan2 = new Toucan({ 1327 | dsn: VALID_DSN, 1328 | context, 1329 | integrations: [messageScrubberIntegration('[redacted-2]')], 1330 | }); 1331 | 1332 | toucan2.captureMessage('test'); 1333 | 1334 | toucan1.captureMessage('test'); 1335 | toucan2.captureMessage('test'); 1336 | 1337 | const waitUntilResults = await getMiniflareWaitUntil(context); 1338 | 1339 | expect(waitUntilResults.length).toBe(4); 1340 | expect(requests.length).toBe(4); 1341 | 1342 | expect((await requests[0].envelopePayload()).message).toBe( 1343 | '[redacted-1]', 1344 | ); 1345 | expect((await requests[1].envelopePayload()).message).toBe( 1346 | '[redacted-2]', 1347 | ); 1348 | expect((await requests[2].envelopePayload()).message).toBe( 1349 | '[redacted-1]', 1350 | ); 1351 | expect((await requests[3].envelopePayload()).message).toBe( 1352 | '[redacted-2]', 1353 | ); 1354 | }); 1355 | }); 1356 | }); 1357 | -------------------------------------------------------------------------------- /packages/toucan-js/test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "types": ["@cloudflare/workers-types", "jest"] 6 | }, 7 | "include": ["**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/toucan-js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config-typescript/workers-library.json", 3 | "include": ["."], 4 | "exclude": ["dist", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "packageRules": [ 5 | { 6 | "matchPackagePatterns": ["*"], 7 | "matchUpdateTypes": ["minor", "patch"], 8 | "groupName": "all non-major dependencies", 9 | "schedule": ["after 10:00pm on monday", "before 04:00am on tuesday"] 10 | }, 11 | { 12 | "matchPackagePatterns": ["^@sentry/.*"], 13 | "groupName": "sentry dependencies", 14 | "schedule": ["at any time"] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "config-typescript/node.json", 3 | "include": ["."], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "outputs": ["dist/**", "build/**"], 6 | "dependsOn": ["^build"] 7 | }, 8 | "test": { 9 | "outputs": ["coverage/**"], 10 | "dependsOn": ["^build"] 11 | }, 12 | "toucan-js#test": { 13 | "outputs": ["coverage/**"], 14 | "dependsOn": ["build"] 15 | }, 16 | "lint": { 17 | "outputs": [], 18 | "dependsOn": ["^build"] 19 | }, 20 | "dev": { 21 | "cache": false 22 | }, 23 | "clean": { 24 | "cache": false 25 | } 26 | }, 27 | "globalEnv": ["SENTRY_ORG", "SENTRY_PROJECT", "SENTRY_AUTH_TOKEN"] 28 | } 29 | --------------------------------------------------------------------------------