├── .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 |
3 |
4 |
5 | [](https://www.npmjs.com/package/toucan-js)
6 | [](https://www.npmjs.com/package/toucan-js)
7 | [](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 |
3 |
4 |
5 | [](https://www.npmjs.com/package/toucan-js)
6 | [](https://www.npmjs.com/package/toucan-js)
7 | [](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 |
--------------------------------------------------------------------------------