diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9f1440515c4f..3191f92adefb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -379,7 +379,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v3 with: - node-version: ${{ env.DEFAULT_NODE_VERSION }} + node-version-file: 'package.json' - name: Restore caches uses: ./.github/actions/restore-cache env: @@ -406,7 +406,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v3 with: - node-version: ${{ matrix.node }} + node-version-file: 'package.json' - name: Set up Bun uses: oven-sh/setup-bun@v1 - name: Restore caches @@ -419,6 +419,38 @@ jobs: - name: Compute test coverage uses: codecov/codecov-action@v3 + job_deno_unit_tests: + name: Deno Unit Tests + needs: [job_get_metadata, job_build] + timeout-minutes: 10 + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + steps: + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v4 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version-file: 'package.json' + - name: Set up Deno + uses: denoland/setup-deno@v1.1.3 + with: + deno-version: v1.37.1 + - name: Restore caches + uses: ./.github/actions/restore-cache + env: + DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Run tests + run: | + cd packages/deno + yarn build + yarn test + - name: Compute test coverage + uses: codecov/codecov-action@v3 + job_node_unit_tests: name: Node (${{ matrix.node }}) Unit Tests needs: [job_get_metadata, job_build] @@ -895,6 +927,7 @@ jobs: job_browser_build_tests, job_browser_unit_tests, job_bun_unit_tests, + job_deno_unit_tests, job_node_unit_tests, job_nextjs_integration_test, job_node_integration_tests, diff --git a/.gitignore b/.gitignore index 777b23658572..d6eee47e4eed 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ jest/transformers/*.js # node tarballs packages/*/sentry-*.tgz .nxcache +# The Deno types are downloaded before building +packages/deno/lib.deno.d.ts # logs yarn-error.log diff --git a/.vscode/extensions.json b/.vscode/extensions.json index da74f03528af..3ad96b1733d5 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,6 +4,7 @@ "recommendations": [ "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", - "augustocdias.tasks-shell-input" - ], + "augustocdias.tasks-shell-input", + "denoland.vscode-deno" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index d3c8a08448c6..96bd2dfb42b9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,5 +28,6 @@ { "mode": "auto" } - ] + ], + "deno.enablePaths": ["packages/deno/test"] } diff --git a/package.json b/package.json index 9546ebf2f9df..b5cbf9ddc38e 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "postpublish": "lerna run --stream --concurrency 1 postpublish", "test": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\" test", "test:unit": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\" test:unit", - "test-ci-browser": "lerna run test --ignore \"@sentry/{bun,node,node-experimental,opentelemetry-node,serverless,nextjs,remix,gatsby,sveltekit,vercel-edge}\" --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\"", + "test-ci-browser": "lerna run test --ignore \"@sentry/{bun,deno,node,node-experimental,opentelemetry-node,serverless,nextjs,remix,gatsby,sveltekit,vercel-edge}\" --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\"", "test-ci-node": "ts-node ./scripts/node-unit-tests.ts", "test-ci-bun": "lerna run test --scope @sentry/bun", "test:update-snapshots": "lerna run test:update-snapshots", @@ -45,6 +45,7 @@ "packages/browser-integration-tests", "packages/bun", "packages/core", + "packages/deno", "packages/e2e-tests", "packages/ember", "packages/eslint-config-sdk", diff --git a/packages/deno/.eslintrc.js b/packages/deno/.eslintrc.js new file mode 100644 index 000000000000..b92652708339 --- /dev/null +++ b/packages/deno/.eslintrc.js @@ -0,0 +1,18 @@ +module.exports = { + extends: ['../../.eslintrc.js'], + ignorePatterns: ['lib.deno.d.ts', 'scripts/*.mjs'], + rules: { + '@sentry-internal/sdk/no-optional-chaining': 'off', + '@sentry-internal/sdk/no-nullish-coalescing': 'off', + '@sentry-internal/sdk/no-unsupported-es6-methods': 'off', + '@sentry-internal/sdk/no-class-field-initializers': 'off', + }, + overrides: [ + { + files: ['./test/*.ts'], + rules: { + 'import/no-unresolved': 'off', + }, + }, + ], +}; diff --git a/packages/deno/LICENSE b/packages/deno/LICENSE new file mode 100644 index 000000000000..d11896ba1181 --- /dev/null +++ b/packages/deno/LICENSE @@ -0,0 +1,14 @@ +Copyright (c) 2023 Sentry (https://sentry.io) and individual contributors. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/deno/README.md b/packages/deno/README.md new file mode 100644 index 000000000000..5be987a249af --- /dev/null +++ b/packages/deno/README.md @@ -0,0 +1,64 @@ +

+ + Sentry + +

+ +# Official Sentry SDK for Deno (Beta) + +[![npm version](https://img.shields.io/npm/v/@sentry/deno.svg)](https://www.npmjs.com/package/@sentry/deno) +[![npm dm](https://img.shields.io/npm/dm/@sentry/deno.svg)](https://www.npmjs.com/package/@sentry/deno) +[![npm dt](https://img.shields.io/npm/dt/@sentry/deno.svg)](https://www.npmjs.com/package/@sentry/deno) + +## Links + +- [Official SDK Docs](https://docs.sentry.io/quickstart/) +- [TypeDoc](http://getsentry.github.io/sentry-javascript/) + +The Sentry Deno SDK is in beta. Please help us improve the SDK by [reporting any issues or giving us feedback](https://github.com/getsentry/sentry-javascript/issues). + +## Usage + +To use this SDK, call `Sentry.init(options)` as early as possible in the main entry module. This will initialize the SDK and +hook into the environment. Note that you can turn off almost all side effects using the respective options. + +```javascript +import * as Sentry from 'npm:@sentry/deno'; + +Sentry.init({ + dsn: '__DSN__', + // ... +}); +``` + +To set context information or send manual events, use the exported functions of `@sentry/deno`. Note that these +functions will not perform any action before you have called `init()`: + +```javascript +// Set user information, as well as tags and further extras +Sentry.configureScope(scope => { + scope.setExtra('battery', 0.7); + scope.setTag('user_mode', 'admin'); + scope.setUser({ id: '4711' }); + // scope.clear(); +}); + +// Add a breadcrumb for future events +Sentry.addBreadcrumb({ + message: 'My Breadcrumb', + // ... +}); + +// Capture exceptions, messages or manual events +Sentry.captureMessage('Hello, world!'); +Sentry.captureException(new Error('Good bye')); +Sentry.captureEvent({ + message: 'Manual', + stacktrace: [ + // ... + ], +}); +``` + + + diff --git a/packages/deno/jest.config.js b/packages/deno/jest.config.js new file mode 100644 index 000000000000..24f49ab59a4c --- /dev/null +++ b/packages/deno/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../jest/jest.config.js'); diff --git a/packages/deno/package.json b/packages/deno/package.json new file mode 100644 index 000000000000..7ee06d1874dc --- /dev/null +++ b/packages/deno/package.json @@ -0,0 +1,61 @@ +{ + "name": "@sentry/deno", + "version": "7.73.0", + "description": "Official Sentry SDK for Deno", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/deno", + "author": "Sentry", + "license": "MIT", + "main": "build/index.js", + "module": "build/index.js", + "types": "build/index.d.ts", + "private": true, + "dependencies": { + "@sentry/core": "7.73.0", + "@sentry/browser": "7.73.0", + "@sentry/types": "7.73.0", + "@sentry/utils": "7.73.0", + "lru_map": "^0.3.3" + }, + "devDependencies": { + "@types/node": "20.8.2", + "@rollup/plugin-commonjs": "^25.0.5", + "@rollup/plugin-typescript": "^11.1.5", + "rollup-plugin-dts": "^6.1.0" + }, + "scripts": { + "deno-types": "node ./scripts/download-deno-types.mjs", + "build": "run-s build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "yarn deno-types && rollup -c rollup.config.js", + "build:types": "run-s deno-types build:types:tsc build:types:bundle", + "build:types:tsc": "tsc -p tsconfig.types.json", + "build:types:bundle": "rollup -c rollup.types.config.js", + "circularDepCheck": "madge --circular src/index.ts", + "clean": "rimraf build coverage", + "prefix": "yarn deno-types", + "fix": "run-s fix:eslint fix:prettier", + "fix:eslint": "eslint . --format stylish --fix", + "fix:prettier": "prettier --write \"{src,test,scripts}/**/**.ts\"", + "prelint": "yarn deno-types", + "lint": "run-s lint:prettier lint:eslint", + "lint:eslint": "eslint . --format stylish", + "lint:prettier": "prettier --check \"{src,test,scripts}/**/**.ts\"", + "install:deno": "node ./scripts/install-deno.mjs", + "test": "run-s deno-types install:deno test:types test:unit", + "test:types": "deno check ./build/index.js", + "test:unit": "deno test --allow-read --allow-run", + "test:unit:update": "deno test --allow-read --allow-write --allow-run -- --update" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false, + "madge": { + "detectiveOptions": { + "ts": { + "skipTypeImports": true + } + } + } +} diff --git a/packages/deno/rollup.config.js b/packages/deno/rollup.config.js new file mode 100644 index 000000000000..48123037a596 --- /dev/null +++ b/packages/deno/rollup.config.js @@ -0,0 +1,24 @@ +import nodeResolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import sucrase from '@rollup/plugin-sucrase'; + +export default { + input: ['src/index.ts'], + output: { + dir: 'build', + sourcemap: true, + preserveModules: false, + strict: false, + freeze: false, + interop: 'auto', + format: 'esm', + banner: '/// ', + }, + plugins: [ + nodeResolve({ + extensions: ['.mjs', '.js', '.json', '.node', '.ts', '.tsx'], + }), + commonjs(), + sucrase({ transforms: ['typescript'] }), + ], +}; diff --git a/packages/deno/rollup.types.config.js b/packages/deno/rollup.types.config.js new file mode 100644 index 000000000000..d8123b6c5cd3 --- /dev/null +++ b/packages/deno/rollup.types.config.js @@ -0,0 +1,17 @@ +import dts from 'rollup-plugin-dts'; + +export default { + input: './build/index.d.ts', + output: [{ file: 'build/index.d.ts', format: 'es' }], + plugins: [ + dts({ respectExternal: true }), + // The bundled types contain a declaration for the __DEBUG_BUILD__ global + // This can result in errors about duplicate global declarations so we strip it out! + { + name: 'strip-global', + renderChunk(code) { + return { code: code.replace(/declare global \{\s*const __DEBUG_BUILD__: boolean;\s*\}/g, '') }; + }, + }, + ], +}; diff --git a/packages/deno/scripts/download-deno-types.mjs b/packages/deno/scripts/download-deno-types.mjs new file mode 100644 index 000000000000..33bdfcf5ebb7 --- /dev/null +++ b/packages/deno/scripts/download-deno-types.mjs @@ -0,0 +1,7 @@ +import { writeFileSync, existsSync } from 'fs'; +import { download } from './download.mjs'; + +if (!existsSync('lib.deno.d.ts')) { + const code = await download('https://github.com/denoland/deno/releases/download/v1.37.1/lib.deno.d.ts'); + writeFileSync('lib.deno.d.ts', code); +} diff --git a/packages/deno/scripts/download.mjs b/packages/deno/scripts/download.mjs new file mode 100644 index 000000000000..25bcc39f583a --- /dev/null +++ b/packages/deno/scripts/download.mjs @@ -0,0 +1,10 @@ +/** Download a url to a string */ +export async function download(url) { + try { + return await fetch(url).then(res => res.text()); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to download', url, e); + process.exit(1); + } +} diff --git a/packages/deno/scripts/install-deno.mjs b/packages/deno/scripts/install-deno.mjs new file mode 100644 index 000000000000..aa7235a278ff --- /dev/null +++ b/packages/deno/scripts/install-deno.mjs @@ -0,0 +1,26 @@ +import { execSync } from 'child_process'; + +import { download } from './download.mjs'; + +try { + execSync('deno --version', { stdio: 'inherit' }); +} catch (_) { + // eslint-disable-next-line no-console + console.error('Deno is not installed. Installing...'); + if (process.platform === 'win32') { + // TODO + // eslint-disable-next-line no-console + console.error('Please install Deno manually: https://docs.deno.com/runtime/manual/getting_started/installation'); + process.exit(1); + } else { + const script = await download('https://deno.land/x/install/install.sh'); + + try { + execSync(script, { stdio: 'inherit' }); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to install Deno', e); + process.exit(1); + } + } +} diff --git a/packages/deno/src/client.ts b/packages/deno/src/client.ts new file mode 100644 index 000000000000..3eb7db655428 --- /dev/null +++ b/packages/deno/src/client.ts @@ -0,0 +1,44 @@ +import type { ServerRuntimeClientOptions } from '@sentry/core'; +import { SDK_VERSION, ServerRuntimeClient } from '@sentry/core'; + +import type { DenoClientOptions } from './types'; + +function getHostName(): string | undefined { + const result = Deno.permissions.querySync({ name: 'sys', kind: 'hostname' }); + return result.state === 'granted' ? Deno.hostname() : undefined; +} + +/** + * The Sentry Deno SDK Client. + * + * @see DenoClientOptions for documentation on configuration options. + * @see SentryClient for usage documentation. + */ +export class DenoClient extends ServerRuntimeClient { + /** + * Creates a new Deno SDK instance. + * @param options Configuration options for this SDK. + */ + public constructor(options: DenoClientOptions) { + options._metadata = options._metadata || {}; + options._metadata.sdk = options._metadata.sdk || { + name: 'sentry.javascript.deno', + packages: [ + { + name: 'denoland:sentry', + version: SDK_VERSION, + }, + ], + version: SDK_VERSION, + }; + + const clientOptions: ServerRuntimeClientOptions = { + ...options, + platform: 'deno', + runtime: { name: 'deno', version: Deno.version.deno }, + serverName: options.serverName || getHostName(), + }; + + super(clientOptions); + } +} diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts new file mode 100644 index 000000000000..6a92adea2513 --- /dev/null +++ b/packages/deno/src/index.ts @@ -0,0 +1,77 @@ +export type { + Breadcrumb, + BreadcrumbHint, + PolymorphicRequest, + Request, + SdkInfo, + Event, + EventHint, + Exception, + Session, + // eslint-disable-next-line deprecation/deprecation + Severity, + SeverityLevel, + Span, + StackFrame, + Stacktrace, + Thread, + Transaction, + User, +} from '@sentry/types'; +export type { AddRequestDataToEventOptions } from '@sentry/utils'; + +export type { DenoOptions } from './types'; + +export { + addGlobalEventProcessor, + addBreadcrumb, + captureException, + captureEvent, + captureMessage, + close, + configureScope, + createTransport, + extractTraceparentData, + flush, + getActiveTransaction, + getHubFromCarrier, + getCurrentHub, + Hub, + lastEventId, + makeMain, + runWithAsyncContext, + Scope, + startTransaction, + SDK_VERSION, + setContext, + setExtra, + setExtras, + setTag, + setTags, + setUser, + spanStatusfromHttpCode, + trace, + withScope, + captureCheckIn, + setMeasurement, + getActiveSpan, + startSpan, + startInactiveSpan, + startSpanManual, +} from '@sentry/core'; +export type { SpanStatusType } from '@sentry/core'; + +export { DenoClient } from './client'; + +export { defaultIntegrations, init } from './sdk'; + +import { Integrations as CoreIntegrations } from '@sentry/core'; + +import * as DenoIntegrations from './integrations'; + +const INTEGRATIONS = { + ...CoreIntegrations, + ...DenoIntegrations, +}; + +export { INTEGRATIONS as Integrations }; diff --git a/packages/deno/src/integrations/context.ts b/packages/deno/src/integrations/context.ts new file mode 100644 index 000000000000..49269c81be4e --- /dev/null +++ b/packages/deno/src/integrations/context.ts @@ -0,0 +1,64 @@ +import type { Event, EventProcessor, Integration } from '@sentry/types'; + +function getOSName(): string { + switch (Deno.build.os) { + case 'darwin': + return 'macOS'; + case 'linux': + return 'Linux'; + case 'windows': + return 'Windows'; + default: + return Deno.build.os; + } +} + +function getOSRelease(): string | undefined { + return Deno.permissions.querySync({ name: 'sys', kind: 'osRelease' }).state === 'granted' + ? Deno.osRelease() + : undefined; +} + +async function denoRuntime(event: Event): Promise { + event.contexts = { + ...{ + app: { + app_start_time: new Date(Date.now() - performance.now()).toISOString(), + }, + device: { + arch: Deno.build.arch, + // eslint-disable-next-line no-restricted-globals + processor_count: navigator.hardwareConcurrency, + }, + os: { + name: getOSName(), + version: getOSRelease(), + }, + v8: { + name: 'v8', + version: Deno.version.v8, + }, + typescript: { + name: 'TypeScript', + version: Deno.version.typescript, + }, + }, + ...event.contexts, + }; + + return event; +} + +/** Adds Electron context to events. */ +export class DenoContext implements Integration { + /** @inheritDoc */ + public static id = 'DenoContext'; + + /** @inheritDoc */ + public name: string = DenoContext.id; + + /** @inheritDoc */ + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void { + addGlobalEventProcessor(async (event: Event) => denoRuntime(event)); + } +} diff --git a/packages/deno/src/integrations/contextlines.ts b/packages/deno/src/integrations/contextlines.ts new file mode 100644 index 000000000000..47cd3a09218d --- /dev/null +++ b/packages/deno/src/integrations/contextlines.ts @@ -0,0 +1,116 @@ +import type { Event, EventProcessor, Integration, StackFrame } from '@sentry/types'; +import { addContextToFrame } from '@sentry/utils'; +import { LRUMap } from 'lru_map'; + +const FILE_CONTENT_CACHE = new LRUMap(100); +const DEFAULT_LINES_OF_CONTEXT = 7; + +/** + * Resets the file cache. Exists for testing purposes. + * @hidden + */ +export function resetFileContentCache(): void { + FILE_CONTENT_CACHE.clear(); +} + +/** + * Reads file contents and caches them in a global LRU cache. + * + * @param filename filepath to read content from. + */ +async function readSourceFile(filename: string): Promise { + const cachedFile = FILE_CONTENT_CACHE.get(filename); + // We have a cache hit + if (cachedFile !== undefined) { + return cachedFile; + } + + let content: string | null = null; + try { + content = await Deno.readTextFile(filename); + } catch (_) { + // + } + + FILE_CONTENT_CACHE.set(filename, content); + return content; +} + +interface ContextLinesOptions { + /** + * Sets the number of context lines for each frame when loading a file. + * Defaults to 7. + * + * Set to 0 to disable loading and inclusion of source files. + */ + frameContextLines?: number; +} + +/** Add node modules / packages to the event */ +export class ContextLines implements Integration { + /** + * @inheritDoc + */ + public static id = 'ContextLines'; + + /** + * @inheritDoc + */ + public name: string = ContextLines.id; + + public constructor(private readonly _options: ContextLinesOptions = {}) {} + + /** Get's the number of context lines to add */ + private get _contextLines(): number { + return this._options.frameContextLines !== undefined ? this._options.frameContextLines : DEFAULT_LINES_OF_CONTEXT; + } + + /** + * @inheritDoc + */ + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void { + addGlobalEventProcessor(event => this.addSourceContext(event)); + } + + /** Processes an event and adds context lines */ + public async addSourceContext(event: Event): Promise { + if (this._contextLines > 0 && event.exception && event.exception.values) { + for (const exception of event.exception.values) { + if (exception.stacktrace && exception.stacktrace.frames) { + await this.addSourceContextToFrames(exception.stacktrace.frames); + } + } + } + + return event; + } + + /** Adds context lines to frames */ + public async addSourceContextToFrames(frames: StackFrame[]): Promise { + const contextLines = this._contextLines; + + for (const frame of frames) { + // Only add context if we have a filename and it hasn't already been added + if (frame.filename && frame.in_app && frame.context_line === undefined) { + const permission = await Deno.permissions.query({ + name: 'read', + path: frame.filename, + }); + + if (permission.state == 'granted') { + const sourceFile = await readSourceFile(frame.filename); + + if (sourceFile) { + try { + const lines = sourceFile.split('\n'); + addContextToFrame(lines, frame, contextLines); + } catch (_) { + // anomaly, being defensive in case + // unlikely to ever happen in practice but can definitely happen in theory + } + } + } + } + } + } +} diff --git a/packages/deno/src/integrations/globalhandlers.ts b/packages/deno/src/integrations/globalhandlers.ts new file mode 100644 index 000000000000..7e4d2e003673 --- /dev/null +++ b/packages/deno/src/integrations/globalhandlers.ts @@ -0,0 +1,165 @@ +import type { ServerRuntimeClient } from '@sentry/core'; +import { flush, getCurrentHub } from '@sentry/core'; +import type { Event, EventHint, Hub, Integration, Primitive, StackParser } from '@sentry/types'; +import { addExceptionMechanism, eventFromUnknownInput, isPrimitive } from '@sentry/utils'; + +type GlobalHandlersIntegrationsOptionKeys = 'error' | 'unhandledrejection'; + +/** JSDoc */ +type GlobalHandlersIntegrations = Record; + +let isExiting = false; + +/** Global handlers */ +export class GlobalHandlers implements Integration { + /** + * @inheritDoc + */ + public static id = 'GlobalHandlers'; + + /** + * @inheritDoc + */ + public name: string = GlobalHandlers.id; + + /** JSDoc */ + private readonly _options: GlobalHandlersIntegrations; + + /** + * Stores references functions to installing handlers. Will set to undefined + * after they have been run so that they are not used twice. + */ + private _installFunc: Record void) | undefined> = { + error: installGlobalErrorHandler, + unhandledrejection: installGlobalUnhandledRejectionHandler, + }; + + /** JSDoc */ + public constructor(options?: GlobalHandlersIntegrations) { + this._options = { + error: true, + unhandledrejection: true, + ...options, + }; + } + /** + * @inheritDoc + */ + public setupOnce(): void { + const options = this._options; + + // We can disable guard-for-in as we construct the options object above + do checks against + // `this._installFunc` for the property. + // eslint-disable-next-line guard-for-in + for (const key in options) { + const installFunc = this._installFunc[key as GlobalHandlersIntegrationsOptionKeys]; + if (installFunc && options[key as GlobalHandlersIntegrationsOptionKeys]) { + installFunc(); + this._installFunc[key as GlobalHandlersIntegrationsOptionKeys] = undefined; + } + } + } +} + +function installGlobalErrorHandler(): void { + globalThis.addEventListener('error', data => { + if (isExiting) { + return; + } + + const [hub, stackParser] = getHubAndOptions(); + const { message, error } = data; + + const event = eventFromUnknownInput(getCurrentHub, stackParser, error || message); + + event.level = 'fatal'; + + addMechanismAndCapture(hub, error, event, 'error'); + + // Stop the app from exiting for now + data.preventDefault(); + isExiting = true; + + void flush().then(() => { + // rethrow to replicate Deno default behavior + throw error; + }); + }); +} + +function installGlobalUnhandledRejectionHandler(): void { + globalThis.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => { + if (isExiting) { + return; + } + + const [hub, stackParser] = getHubAndOptions(); + let error = e; + + // dig the object of the rejection out of known event types + try { + if ('reason' in e) { + error = e.reason; + } + } catch (_oO) { + // no-empty + } + + const event = isPrimitive(error) + ? eventFromRejectionWithPrimitive(error) + : eventFromUnknownInput(getCurrentHub, stackParser, error, undefined); + + event.level = 'fatal'; + + addMechanismAndCapture(hub, error as unknown as Error, event, 'unhandledrejection'); + + // Stop the app from exiting for now + e.preventDefault(); + isExiting = true; + + void flush().then(() => { + // rethrow to replicate Deno default behavior + throw error; + }); + }); +} + +/** + * Create an event from a promise rejection where the `reason` is a primitive. + * + * @param reason: The `reason` property of the promise rejection + * @returns An Event object with an appropriate `exception` value + */ +function eventFromRejectionWithPrimitive(reason: Primitive): Event { + return { + exception: { + values: [ + { + type: 'UnhandledRejection', + // String() is needed because the Primitive type includes symbols (which can't be automatically stringified) + value: `Non-Error promise rejection captured with value: ${String(reason)}`, + }, + ], + }, + }; +} + +function addMechanismAndCapture(hub: Hub, error: EventHint['originalException'], event: Event, type: string): void { + addExceptionMechanism(event, { + handled: false, + type, + }); + hub.captureEvent(event, { + originalException: error, + }); +} + +function getHubAndOptions(): [Hub, StackParser] { + const hub = getCurrentHub(); + const client = hub.getClient(); + const options = (client && client.getOptions()) || { + stackParser: () => [], + attachStacktrace: false, + }; + return [hub, options.stackParser]; +} diff --git a/packages/deno/src/integrations/index.ts b/packages/deno/src/integrations/index.ts new file mode 100644 index 000000000000..97e439649bfc --- /dev/null +++ b/packages/deno/src/integrations/index.ts @@ -0,0 +1,4 @@ +export { DenoContext } from './context'; +export { GlobalHandlers } from './globalhandlers'; +export { NormalizePaths } from './normalizepaths'; +export { ContextLines } from './contextlines'; diff --git a/packages/deno/src/integrations/normalizepaths.ts b/packages/deno/src/integrations/normalizepaths.ts new file mode 100644 index 000000000000..bf8a3986c93d --- /dev/null +++ b/packages/deno/src/integrations/normalizepaths.ts @@ -0,0 +1,100 @@ +import type { Event, EventProcessor, Integration } from '@sentry/types'; +import { createStackParser, dirname, nodeStackLineParser } from '@sentry/utils'; + +function appRootFromErrorStack(error: Error): string | undefined { + // We know at the other end of the stack from here is the entry point that called 'init' + // We assume that this stacktrace will traverse the root of the app + const frames = createStackParser(nodeStackLineParser())(error.stack || ''); + + const paths = frames + // We're only interested in frames that are in_app with filenames + .filter(f => f.in_app && f.filename) + .map( + f => + (f.filename as string) + .replace(/^[A-Z]:/, '') // remove Windows-style prefix + .replace(/\\/g, '/') // replace all `\` instances with `/` + .split('/') + .filter(seg => seg !== ''), // remove empty segments + ) as string[][]; + + if (paths.length == 0) { + return undefined; + } + + if (paths.length == 1) { + // Assume the single file is in the root + return dirname(paths[0].join('/')); + } + + // Iterate over the paths and bail out when they no longer have a common root + let i = 0; + while (paths[0][i] && paths.every(w => w[i] === paths[0][i])) { + i++; + } + + return paths[0].slice(0, i).join('/'); +} + +function getCwd(): string | undefined { + // We don't want to prompt for permissions so we only get the cwd if + // permissions are already granted + const permission = Deno.permissions.querySync({ name: 'read', path: './' }); + + try { + if (permission.state == 'granted') { + return Deno.cwd(); + } + } catch (_) { + // + } + + return undefined; +} + +// Cached here +let appRoot: string | undefined; + +function getAppRoot(error: Error): string | undefined { + if (appRoot === undefined) { + appRoot = getCwd() || appRootFromErrorStack(error); + } + + return appRoot; +} + +/** Normalises paths to the app root directory. */ +export class NormalizePaths implements Integration { + /** @inheritDoc */ + public static id = 'NormalizePaths'; + + /** @inheritDoc */ + public name: string = NormalizePaths.id; + + /** @inheritDoc */ + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void { + // This error.stack hopefully contains paths that traverse the app cwd + const error = new Error(); + + addGlobalEventProcessor((event: Event): Event | null => { + const appRoot = getAppRoot(error); + + if (appRoot) { + for (const exception of event.exception?.values || []) { + for (const frame of exception.stacktrace?.frames || []) { + if (frame.filename && frame.in_app) { + const startIndex = frame.filename.indexOf(appRoot); + + if (startIndex > -1) { + const endIndex = startIndex + appRoot.length; + frame.filename = `app://${frame.filename.substring(endIndex)}`; + } + } + } + } + } + + return event; + }); + } +} diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts new file mode 100644 index 000000000000..cff16148453e --- /dev/null +++ b/packages/deno/src/sdk.ts @@ -0,0 +1,102 @@ +import { Breadcrumbs, Dedupe, LinkedErrors } from '@sentry/browser'; +import type { ServerRuntimeClientOptions } from '@sentry/core'; +import { getIntegrationsToSetup, initAndBind, Integrations as CoreIntegrations } from '@sentry/core'; +import type { StackParser } from '@sentry/types'; +import { createStackParser, nodeStackLineParser, stackParserFromStackParserOptions } from '@sentry/utils'; + +import { DenoClient } from './client'; +import { ContextLines, DenoContext, GlobalHandlers, NormalizePaths } from './integrations'; +import { makeFetchTransport } from './transports'; +import type { DenoOptions } from './types'; + +export const defaultIntegrations = [ + // Common + new CoreIntegrations.InboundFilters(), + new CoreIntegrations.FunctionToString(), + // From Browser + new Dedupe(), + new LinkedErrors(), + new Breadcrumbs({ + dom: false, + history: false, + xhr: false, + }), + // Deno Specific + new DenoContext(), + new ContextLines(), + new NormalizePaths(), + new GlobalHandlers(), +]; + +const defaultStackParser: StackParser = createStackParser(nodeStackLineParser()); + +/** + * The Sentry Deno SDK Client. + * + * To use this SDK, call the {@link init} function as early as possible in the + * main entry module. To set context information or send manual events, use the + * provided methods. + * + * @example + * ``` + * + * import { init } from 'npm:@sentry/deno'; + * + * init({ + * dsn: '__DSN__', + * // ... + * }); + * ``` + * + * @example + * ``` + * + * import { configureScope } from 'npm:@sentry/deno'; + * configureScope((scope: Scope) => { + * scope.setExtra({ battery: 0.7 }); + * scope.setTag({ user_mode: 'admin' }); + * scope.setUser({ id: '4711' }); + * }); + * ``` + * + * @example + * ``` + * + * import { addBreadcrumb } from 'npm:@sentry/deno'; + * addBreadcrumb({ + * message: 'My Breadcrumb', + * // ... + * }); + * ``` + * + * @example + * ``` + * + * import * as Sentry from 'npm:@sentry/deno'; + * Sentry.captureMessage('Hello, world!'); + * Sentry.captureException(new Error('Good bye')); + * Sentry.captureEvent({ + * message: 'Manual', + * stacktrace: [ + * // ... + * ], + * }); + * ``` + * + * @see {@link DenoOptions} for documentation on configuration options. + */ +export function init(options: DenoOptions = {}): void { + options.defaultIntegrations = + options.defaultIntegrations === false + ? [] + : [...(Array.isArray(options.defaultIntegrations) ? options.defaultIntegrations : defaultIntegrations)]; + + const clientOptions: ServerRuntimeClientOptions = { + ...options, + stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), + integrations: getIntegrationsToSetup(options), + transport: options.transport || makeFetchTransport, + }; + + initAndBind(DenoClient, clientOptions); +} diff --git a/packages/deno/src/transports/index.ts b/packages/deno/src/transports/index.ts new file mode 100644 index 000000000000..62da327c5d83 --- /dev/null +++ b/packages/deno/src/transports/index.ts @@ -0,0 +1,46 @@ +import { createTransport } from '@sentry/core'; +import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types'; +import { rejectedSyncPromise } from '@sentry/utils'; + +export interface DenoTransportOptions extends BaseTransportOptions { + /** Custom headers for the transport. Used by the XHRTransport and FetchTransport */ + headers?: { [key: string]: string }; +} + +/** + * Creates a Transport that uses the Fetch API to send events to Sentry. + */ +export function makeFetchTransport(options: DenoTransportOptions): Transport { + const url = new URL(options.url); + + if (Deno.permissions.querySync({ name: 'net', host: url.host }).state !== 'granted') { + // eslint-disable-next-line no-console + console.warn(`Sentry SDK requires 'net' permission to send events. +Run with '--allow-net=${url.host}' to grant the requires permissions.`); + } + + function makeRequest(request: TransportRequest): PromiseLike { + const requestOptions: RequestInit = { + body: request.body, + method: 'POST', + referrerPolicy: 'origin', + headers: options.headers, + }; + + try { + return fetch(options.url, requestOptions).then(response => { + return { + statusCode: response.status, + headers: { + 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), + 'retry-after': response.headers.get('Retry-After'), + }, + }; + }); + } catch (e) { + return rejectedSyncPromise(e); + } + } + + return createTransport(options, makeRequest); +} diff --git a/packages/deno/src/types.ts b/packages/deno/src/types.ts new file mode 100644 index 000000000000..50310589666a --- /dev/null +++ b/packages/deno/src/types.ts @@ -0,0 +1,59 @@ +import type { ClientOptions, Options, TracePropagationTargets } from '@sentry/types'; + +import type { DenoTransportOptions } from './transports'; + +export interface BaseDenoOptions { + /** + * List of strings/regex controlling to which outgoing requests + * the SDK will attach tracing headers. + * + * By default the SDK will attach those headers to all outgoing + * requests. If this option is provided, the SDK will match the + * request URL of outgoing requests against the items in this + * array, and only attach tracing headers if a match was found. + * + * @example + * ```js + * Sentry.init({ + * tracePropagationTargets: ['api.site.com'], + * }); + * ``` + */ + tracePropagationTargets?: TracePropagationTargets; + + /** Sets an optional server name (device name) */ + serverName?: string; + + // TODO (v8): Remove this in v8 + /** + * @deprecated Moved to constructor options of the `Http` and `Undici` integration. + * @example + * ```js + * Sentry.init({ + * integrations: [ + * new Sentry.Integrations.Http({ + * tracing: { + * shouldCreateSpanForRequest: (url: string) => false, + * } + * }); + * ], + * }); + * ``` + */ + shouldCreateSpanForRequest?(this: void, url: string): boolean; + + /** Callback that is executed when a fatal global error occurs. */ + onFatalError?(this: void, error: Error): void; +} + +/** + * Configuration options for the Sentry Deno SDK + * @see @sentry/types Options for more information. + */ +export interface DenoOptions extends Options, BaseDenoOptions {} + +/** + * Configuration options for the Sentry Deno SDK Client class + * @see DenoClient for more information. + */ +export interface DenoClientOptions extends ClientOptions, BaseDenoOptions {} diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap new file mode 100644 index 000000000000..749d3ce9d238 --- /dev/null +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -0,0 +1,222 @@ +export const snapshot = {}; + +snapshot[`captureException 1`] = ` +{ + contexts: { + app: { + app_start_time: "{{time}}", + }, + device: { + arch: "{{arch}}", + processor_count: 0, + }, + os: { + name: "{{platform}}", + }, + runtime: { + name: "deno", + version: "1.37.1", + }, + trace: { + span_id: "{{id}}", + trace_id: "{{id}}", + }, + typescript: { + name: "TypeScript", + version: "{{version}}", + }, + v8: { + name: "v8", + version: "{{version}}", + }, + }, + environment: "production", + event_id: "{{id}}", + exception: { + values: [ + { + mechanism: { + handled: true, + type: "generic", + }, + stacktrace: { + frames: [ + { + colno: 20, + filename: "ext:cli/40_testing.js", + function: "outerWrapped", + in_app: false, + lineno: 488, + }, + { + colno: 33, + filename: "ext:cli/40_testing.js", + function: "exitSanitizer", + in_app: false, + lineno: 474, + }, + { + colno: 31, + filename: "ext:cli/40_testing.js", + function: "resourceSanitizer", + in_app: false, + lineno: 425, + }, + { + colno: 33, + filename: "ext:cli/40_testing.js", + function: "asyncOpSanitizer", + in_app: false, + lineno: 192, + }, + { + colno: 11, + filename: "ext:cli/40_testing.js", + function: "innerWrapped", + in_app: false, + lineno: 543, + }, + { + colno: 24, + context_line: " hub.captureException(something());", + filename: "app:///test/mod.test.ts", + function: "", + in_app: true, + lineno: 43, + post_context: [ + "", + " await delay(200);", + " await assertSnapshot(t, ev);", + "});", + "", + "Deno.test('captureMessage', async t => {", + " let ev: Event | undefined;", + ], + pre_context: [ + " ev = event;", + " });", + "", + " function something() {", + " return new Error('Some unhandled error');", + " }", + "", + ], + }, + { + colno: 12, + context_line: " return new Error('Some unhandled error');", + filename: "app:///test/mod.test.ts", + function: "something", + in_app: true, + lineno: 40, + post_context: [ + " }", + "", + " hub.captureException(something());", + "", + " await delay(200);", + " await assertSnapshot(t, ev);", + "});", + ], + pre_context: [ + "Deno.test('captureException', async t => {", + " let ev: Event | undefined;", + " const [hub] = getTestClient(event => {", + " ev = event;", + " });", + "", + " function something() {", + ], + }, + ], + }, + type: "Error", + value: "Some unhandled error", + }, + ], + }, + platform: "deno", + sdk: { + integrations: [ + "InboundFilters", + "FunctionToString", + "Dedupe", + "LinkedErrors", + "Breadcrumbs", + "DenoContext", + "ContextLines", + "NormalizePaths", + "GlobalHandlers", + ], + name: "sentry.javascript.deno", + packages: [ + { + name: "denoland:sentry", + version: "{{version}}", + }, + ], + version: "{{version}}", + }, + timestamp: 0, +} +`; + +snapshot[`captureMessage 1`] = ` +{ + contexts: { + app: { + app_start_time: "{{time}}", + }, + device: { + arch: "{{arch}}", + processor_count: 0, + }, + os: { + name: "{{platform}}", + }, + runtime: { + name: "deno", + version: "1.37.1", + }, + trace: { + span_id: "{{id}}", + trace_id: "{{id}}", + }, + typescript: { + name: "TypeScript", + version: "{{version}}", + }, + v8: { + name: "v8", + version: "{{version}}", + }, + }, + environment: "production", + event_id: "{{id}}", + level: "info", + message: "Some error message", + platform: "deno", + sdk: { + integrations: [ + "InboundFilters", + "FunctionToString", + "Dedupe", + "LinkedErrors", + "Breadcrumbs", + "DenoContext", + "ContextLines", + "NormalizePaths", + "GlobalHandlers", + ], + name: "sentry.javascript.deno", + packages: [ + { + name: "denoland:sentry", + version: "{{version}}", + }, + ], + version: "{{version}}", + }, + timestamp: 0, +} +`; diff --git a/packages/deno/test/example.ts b/packages/deno/test/example.ts new file mode 100644 index 000000000000..6f93bd288afd --- /dev/null +++ b/packages/deno/test/example.ts @@ -0,0 +1,14 @@ +import * as Sentry from '../build/index.js'; + +Sentry.init({ + dsn: 'https://1234@some-domain.com/4505526893805568', +}); + +Sentry.addBreadcrumb({ message: 'My Breadcrumb' }); + +// eslint-disable-next-line no-console +console.log('App has started'); + +setTimeout(() => { + Deno.exit(); +}, 1_000); diff --git a/packages/deno/test/mod.test.ts b/packages/deno/test/mod.test.ts new file mode 100644 index 000000000000..1724125415cd --- /dev/null +++ b/packages/deno/test/mod.test.ts @@ -0,0 +1,76 @@ +import { assertEquals } from 'https://deno.land/std@0.202.0/assert/assert_equals.ts'; +import { assertSnapshot } from 'https://deno.land/std@0.202.0/testing/snapshot.ts'; +import type { Event, Integration } from 'npm:@sentry/types'; +import { createStackParser, nodeStackLineParser } from 'npm:@sentry/utils'; + +import { defaultIntegrations, DenoClient, Hub, Scope } from '../build/index.js'; +import { getNormalizedEvent } from './normalize.ts'; +import { makeTestTransport } from './transport.ts'; + +function getTestClient(callback: (event?: Event) => void, integrations: Integration[] = []): [Hub, DenoClient] { + const client = new DenoClient({ + dsn: 'https://233a45e5efe34c47a3536797ce15dafa@nothing.here/5650507', + debug: true, + integrations: [...defaultIntegrations, ...integrations], + stackParser: createStackParser(nodeStackLineParser()), + transport: makeTestTransport(envelope => { + callback(getNormalizedEvent(envelope)); + }), + }); + + const scope = new Scope(); + const hub = new Hub(client, scope); + + return [hub, client]; +} + +function delay(time: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, time); + }); +} + +Deno.test('captureException', async t => { + let ev: Event | undefined; + const [hub] = getTestClient(event => { + ev = event; + }); + + function something() { + return new Error('Some unhandled error'); + } + + hub.captureException(something()); + + await delay(200); + await assertSnapshot(t, ev); +}); + +Deno.test('captureMessage', async t => { + let ev: Event | undefined; + const [hub] = getTestClient(event => { + ev = event; + }); + + hub.captureMessage('Some error message'); + + await delay(200); + await assertSnapshot(t, ev); +}); + +Deno.test('App runs without errors', async _ => { + const cmd = new Deno.Command('deno', { + args: ['run', '--allow-net=some-domain.com', './test/example.ts'], + stdout: 'piped', + stderr: 'piped', + }); + + const output = await cmd.output(); + assertEquals(output.success, true); + + const td = new TextDecoder(); + const outString = td.decode(output.stdout); + const errString = td.decode(output.stderr); + assertEquals(outString, 'App has started\n'); + assertEquals(errString, ''); +}); diff --git a/packages/deno/test/normalize.ts b/packages/deno/test/normalize.ts new file mode 100644 index 000000000000..b36cf4ac52a9 --- /dev/null +++ b/packages/deno/test/normalize.ts @@ -0,0 +1,207 @@ +/* eslint-disable complexity */ +import type { Envelope, Event, Session, Transaction } from 'npm:@sentry/types'; +import { forEachEnvelopeItem } from 'npm:@sentry/utils'; + +type EventOrSession = Event | Transaction | Session; + +export function getNormalizedEvent(envelope: Envelope): Event | undefined { + let event: Event | undefined; + + forEachEnvelopeItem(envelope, item => { + const [headers, body] = item; + + if (headers.type === 'event') { + event = body as Event; + } + }); + + return normalize(event) as Event | undefined; +} + +export function normalize(event: EventOrSession | undefined): EventOrSession | undefined { + if (event === undefined) { + return undefined; + } + + if (eventIsSession(event)) { + return normalizeSession(event as Session); + } else { + return normalizeEvent(event as Event); + } +} + +export function eventIsSession(data: EventOrSession): boolean { + return !!(data as Session)?.sid; +} + +/** + * Normalizes a session so that in can be compared to an expected event + * + * All properties that are timestamps, versions, ids or variables that may vary + * by platform are replaced with placeholder strings + */ +function normalizeSession(session: Session): Session { + if (session.sid) { + session.sid = '{{id}}'; + } + + if (session.started) { + session.started = 0; + } + + if (session.timestamp) { + session.timestamp = 0; + } + + if (session.duration) { + session.duration = 0; + } + + return session; +} + +/** + * Normalizes an event so that in can be compared to an expected event + * + * All properties that are timestamps, versions, ids or variables that may vary + * by platform are replaced with placeholder strings + */ +function normalizeEvent(event: Event): Event { + if (event.sdk?.version) { + event.sdk.version = '{{version}}'; + } + + if (event?.sdk?.packages) { + for (const pkg of event?.sdk?.packages) { + if (pkg.version) { + pkg.version = '{{version}}'; + } + } + } + + if (event.contexts?.app?.app_start_time) { + event.contexts.app.app_start_time = '{{time}}'; + } + + if (event.contexts?.typescript?.version) { + event.contexts.typescript.version = '{{version}}'; + } + + if (event.contexts?.v8?.version) { + event.contexts.v8.version = '{{version}}'; + } + + if (event.contexts?.deno) { + if (event.contexts.deno?.version) { + event.contexts.deno.version = '{{version}}'; + } + if (event.contexts.deno?.target) { + event.contexts.deno.target = '{{target}}'; + } + } + + if (event.contexts?.device?.arch) { + event.contexts.device.arch = '{{arch}}'; + } + + if (event.contexts?.device?.memory_size) { + event.contexts.device.memory_size = 0; + } + + if (event.contexts?.device?.free_memory) { + event.contexts.device.free_memory = 0; + } + + if (event.contexts?.device?.processor_count) { + event.contexts.device.processor_count = 0; + } + + if (event.contexts?.device?.processor_frequency) { + event.contexts.device.processor_frequency = 0; + } + + if (event.contexts?.device?.cpu_description) { + event.contexts.device.cpu_description = '{{cpu}}'; + } + + if (event.contexts?.device?.screen_resolution) { + event.contexts.device.screen_resolution = '{{screen}}'; + } + + if (event.contexts?.device?.screen_density) { + event.contexts.device.screen_density = 1; + } + + if (event.contexts?.device?.language) { + event.contexts.device.language = '{{language}}'; + } + + if (event.contexts?.os?.name) { + event.contexts.os.name = '{{platform}}'; + } + + if (event.contexts?.os?.version) { + event.contexts.os.version = '{{version}}'; + } + + if (event.contexts?.trace) { + event.contexts.trace.span_id = '{{id}}'; + event.contexts.trace.trace_id = '{{id}}'; + delete event.contexts.trace.tags; + } + + if (event.start_timestamp) { + event.start_timestamp = 0; + } + + if (event.exception?.values?.[0].stacktrace?.frames) { + // Exlcude Deno frames since these may change between versions + event.exception.values[0].stacktrace.frames = event.exception.values[0].stacktrace.frames.filter( + frame => !frame.filename?.includes('deno:'), + ); + } + + event.timestamp = 0; + // deno-lint-ignore no-explicit-any + if ((event as any).start_timestamp) { + // deno-lint-ignore no-explicit-any + (event as any).start_timestamp = 0; + } + + event.event_id = '{{id}}'; + + if (event.spans) { + for (const span of event.spans) { + // deno-lint-ignore no-explicit-any + const spanAny = span as any; + + if (spanAny.span_id) { + spanAny.span_id = '{{id}}'; + } + + if (spanAny.parent_span_id) { + spanAny.parent_span_id = '{{id}}'; + } + + if (spanAny.start_timestamp) { + spanAny.start_timestamp = 0; + } + + if (spanAny.timestamp) { + spanAny.timestamp = 0; + } + + if (spanAny.trace_id) { + spanAny.trace_id = '{{id}}'; + } + } + } + + if (event.breadcrumbs) { + for (const breadcrumb of event.breadcrumbs) { + breadcrumb.timestamp = 0; + } + } + + return event; +} diff --git a/packages/deno/test/transport.ts b/packages/deno/test/transport.ts new file mode 100644 index 000000000000..2eaeed6eeef6 --- /dev/null +++ b/packages/deno/test/transport.ts @@ -0,0 +1,30 @@ +import { createTransport } from 'npm:@sentry/core'; +import type { + BaseTransportOptions, + Envelope, + Transport, + TransportMakeRequestResponse, + TransportRequest, +} from 'npm:@sentry/types'; +import { parseEnvelope } from 'npm:@sentry/utils'; + +export interface TestTransportOptions extends BaseTransportOptions { + callback: (envelope: Envelope) => void; +} + +/** + * Creates a Transport that uses the Fetch API to send events to Sentry. + */ +export function makeTestTransport(callback: (envelope: Envelope) => void) { + return (options: BaseTransportOptions): Transport => { + async function doCallback(request: TransportRequest): Promise { + await callback(parseEnvelope(request.body, new TextEncoder(), new TextDecoder())); + + return Promise.resolve({ + statusCode: 200, + }); + } + + return createTransport(options, doCallback); + }; +} diff --git a/packages/deno/tsconfig.build.json b/packages/deno/tsconfig.build.json new file mode 100644 index 000000000000..87025d5676c5 --- /dev/null +++ b/packages/deno/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "include": ["./lib.deno.d.ts", "src/**/*"], + "compilerOptions": { + "outDir": "build", + "lib": ["esnext"], + "module": "esnext", + "target": "esnext", + "declaration": true, + "declarationMap": false + } +} diff --git a/packages/deno/tsconfig.json b/packages/deno/tsconfig.json new file mode 100644 index 000000000000..fdd107c1ed78 --- /dev/null +++ b/packages/deno/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./lib.deno.d.ts", "src/**/*", "example.ts"], + "compilerOptions": { + "lib": ["esnext"], + "module": "esnext", + "target": "esnext" + } +} diff --git a/packages/deno/tsconfig.test.json b/packages/deno/tsconfig.test.json new file mode 100644 index 000000000000..548e94149758 --- /dev/null +++ b/packages/deno/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["./lib.deno.d.ts", "test/**/*"], + "compilerOptions": { + "lib": ["esnext"], + "module": "esnext", + "target": "esnext" + } +} diff --git a/packages/deno/tsconfig.types.json b/packages/deno/tsconfig.types.json new file mode 100644 index 000000000000..d6d1e9a548c9 --- /dev/null +++ b/packages/deno/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build" + } +} diff --git a/scripts/node-unit-tests.ts b/scripts/node-unit-tests.ts index dc923c8bf9a5..8824cee77d66 100644 --- a/scripts/node-unit-tests.ts +++ b/scripts/node-unit-tests.ts @@ -22,6 +22,7 @@ const DEFAULT_SKIP_TESTS_PACKAGES = [ '@sentry/replay', '@sentry/wasm', '@sentry/bun', + '@sentry/deno', ]; const SKIP_TEST_PACKAGES: Record = { diff --git a/yarn.lock b/yarn.lock index d52033c73e40..b8f2e0477bbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4832,6 +4832,18 @@ magic-string "^0.25.7" resolve "^1.17.0" +"@rollup/plugin-commonjs@^25.0.5": + version "25.0.5" + resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.5.tgz#0bac8f985a5de151b4b09338847f8c7f20a28a29" + integrity sha512-xY8r/A9oisSeSuLCTfhssyDjo9Vp/eDiRLXkg1MXCcEEgEjPmLU+ZyDB20OOD0NlyDa/8SGbK5uIggF5XTx77w== + dependencies: + "@rollup/pluginutils" "^5.0.1" + commondir "^1.0.1" + estree-walker "^2.0.2" + glob "^8.0.3" + is-reference "1.2.1" + magic-string "^0.27.0" + "@rollup/plugin-json@^4.0.0", "@rollup/plugin-json@^4.1.0": version "4.1.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3" @@ -4891,6 +4903,14 @@ "@rollup/pluginutils" "^4.1.1" sucrase "^3.20.0" +"@rollup/plugin-typescript@^11.1.5": + version "11.1.5" + resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-11.1.5.tgz#039c763bf943a5921f3f42be255895e75764cb91" + integrity sha512-rnMHrGBB0IUEv69Q8/JGRD/n4/n6b3nfpufUu26axhUcboUzv/twfZU8fIBbTOphRAe0v8EyxzeDpKXqGHfyDA== + dependencies: + "@rollup/pluginutils" "^5.0.1" + resolve "^1.22.1" + "@rollup/plugin-typescript@^8.3.1": version "8.5.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-8.5.0.tgz#7ea11599a15b0a30fa7ea69ce3b791d41b862515" @@ -5993,6 +6013,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.38.tgz#f8bb07c371ccb1903f3752872c89f44006132947" integrity sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g== +"@types/node@20.8.2": + version "20.8.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.2.tgz#d76fb80d87d0d8abfe334fc6d292e83e5524efc4" + integrity sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w== + "@types/node@^10.1.0", "@types/node@~10.17.0": version "10.17.60" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b" @@ -20053,7 +20078,7 @@ magic-string@^0.30.0: dependencies: "@jridgewell/sourcemap-codec" "^1.4.13" -magic-string@^0.30.3: +magic-string@^0.30.3, magic-string@^0.30.4: version "0.30.4" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.4.tgz#c2c683265fc18dda49b56fc7318d33ca0332c98c" integrity sha512-Q/TKtsC5BPm0kGqgBIF9oXAs/xEf2vRKiIB4wCRQTJOQIByZ1d+NnUOotvJOvNpi5RNIgVOMC3pOuaP1ZTDlVg== @@ -26520,6 +26545,15 @@ rollup-plugin-cleanup@3.2.1: js-cleanup "^1.2.0" rollup-pluginutils "^2.8.2" +rollup-plugin-dts@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/rollup-plugin-dts/-/rollup-plugin-dts-6.1.0.tgz#56e9c5548dac717213c6a4aa9df523faf04f75ae" + integrity sha512-ijSCPICkRMDKDLBK9torss07+8dl9UpY9z1N/zTeA1cIqdzMlpkV3MOOC7zukyvQfDyxa1s3Dl2+DeiP/G6DOw== + dependencies: + magic-string "^0.30.4" + optionalDependencies: + "@babel/code-frame" "^7.22.13" + rollup-plugin-license@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/rollup-plugin-license/-/rollup-plugin-license-2.6.1.tgz#20f15cc37950f362f8eefdc6e3a2e659d0cad9eb"