From f042a10e86bd2f97498a09298f0c0cff1cc55857 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 13 Sep 2023 16:03:39 +0200 Subject: [PATCH 01/20] ref(integrations): Do not use event processor for `Debug` integration (#9014) Instead we can ensure this is called after all processing finished via a hook. This does not actually process an event, so this is much cleaner. --- packages/integrations/src/debug.ts | 22 +++---- packages/integrations/test/debug.test.ts | 83 ++++++++++++++---------- 2 files changed, 59 insertions(+), 46 deletions(-) diff --git a/packages/integrations/src/debug.ts b/packages/integrations/src/debug.ts index 1deb1f8dec17..1c2459636510 100644 --- a/packages/integrations/src/debug.ts +++ b/packages/integrations/src/debug.ts @@ -38,32 +38,32 @@ export class Debug implements Integration { /** * @inheritDoc */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - addGlobalEventProcessor((event: Event, hint: EventHint) => { - const self = getCurrentHub().getIntegration(Debug); - if (self) { - if (self._options.debugger) { + public setupOnce(_addGlobalEventProcessor: (eventProcessor: EventProcessor) => void, getCurrentHub: () => Hub): void { + const client = getCurrentHub().getClient(); + + if (client && client.on) { + client.on('beforeSendEvent', (event: Event, hint?: EventHint) => { + if (this._options.debugger) { // eslint-disable-next-line no-debugger debugger; } /* eslint-disable no-console */ consoleSandbox(() => { - if (self._options.stringify) { + if (this._options.stringify) { console.log(JSON.stringify(event, null, 2)); - if (Object.keys(hint).length) { + if (hint && Object.keys(hint).length) { console.log(JSON.stringify(hint, null, 2)); } } else { console.log(event); - if (Object.keys(hint).length) { + if (hint && Object.keys(hint).length) { console.log(hint); } } }); /* eslint-enable no-console */ - } - return event; - }); + }); + } } } diff --git a/packages/integrations/test/debug.test.ts b/packages/integrations/test/debug.test.ts index 268c03dcc4d5..953fcdb0258e 100644 --- a/packages/integrations/test/debug.test.ts +++ b/packages/integrations/test/debug.test.ts @@ -1,10 +1,33 @@ -import type { EventProcessor, Integration } from '@sentry/types'; +import type { Client, Event, EventHint, Hub, Integration } from '@sentry/types'; import { Debug } from '../src/debug'; -const mockGetCurrentHub = (getIntegrationResult: Integration) => ({ - getIntegration: jest.fn(() => getIntegrationResult), -}); +function testEventLogged(integration: Integration, testEvent?: Event, testEventHint?: EventHint) { + const callbacks: ((event: Event, hint?: EventHint) => void)[] = []; + + const client: Client = { + on(hook: string, callback: (event: Event, hint?: EventHint) => void) { + expect(hook).toEqual('beforeSendEvent'); + callbacks.push(callback); + }, + } as Client; + + function getCurrentHub() { + return { + getClient: jest.fn(() => { + return client; + }), + } as unknown as Hub; + } + + integration.setupOnce(() => {}, getCurrentHub); + + expect(callbacks.length).toEqual(1); + + if (testEvent) { + callbacks[0](testEvent, testEventHint); + } +} // Replace console log with a mock so we can check for invocations const mockConsoleLog = jest.fn(); @@ -24,56 +47,46 @@ describe('Debug integration setup should register an event processor that', () = it('logs an event', () => { const debugIntegration = new Debug(); + const testEvent = { event_id: 'some event' }; - const captureEventProcessor = (eventProcessor: EventProcessor) => { - const testEvent = { event_id: 'some event' }; - void eventProcessor(testEvent, {}); - expect(mockConsoleLog).toHaveBeenCalledTimes(1); - expect(mockConsoleLog).toBeCalledWith(testEvent); - }; + testEventLogged(debugIntegration, testEvent); - debugIntegration.setupOnce(captureEventProcessor, () => mockGetCurrentHub(debugIntegration) as any); + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toBeCalledWith(testEvent); }); it('logs an event hint if available', () => { const debugIntegration = new Debug(); - const captureEventProcessor = (eventProcessor: EventProcessor) => { - const testEvent = { event_id: 'some event' }; - const testEventHint = { event_id: 'some event hint' }; - void eventProcessor(testEvent, testEventHint); - expect(mockConsoleLog).toHaveBeenCalledTimes(2); - expect(mockConsoleLog).toBeCalledWith(testEvent); - expect(mockConsoleLog).toBeCalledWith(testEventHint); - }; + const testEvent = { event_id: 'some event' }; + const testEventHint = { event_id: 'some event hint' }; - debugIntegration.setupOnce(captureEventProcessor, () => mockGetCurrentHub(debugIntegration) as any); + testEventLogged(debugIntegration, testEvent, testEventHint); + + expect(mockConsoleLog).toHaveBeenCalledTimes(2); + expect(mockConsoleLog).toBeCalledWith(testEvent); + expect(mockConsoleLog).toBeCalledWith(testEventHint); }); it('logs events in stringified format when `stringify` option was set', () => { const debugIntegration = new Debug({ stringify: true }); + const testEvent = { event_id: 'some event' }; - const captureEventProcessor = (eventProcessor: EventProcessor) => { - const testEvent = { event_id: 'some event' }; - void eventProcessor(testEvent, {}); - expect(mockConsoleLog).toHaveBeenCalledTimes(1); - expect(mockConsoleLog).toBeCalledWith(JSON.stringify(testEvent, null, 2)); - }; + testEventLogged(debugIntegration, testEvent); - debugIntegration.setupOnce(captureEventProcessor, () => mockGetCurrentHub(debugIntegration) as any); + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toBeCalledWith(JSON.stringify(testEvent, null, 2)); }); it('logs event hints in stringified format when `stringify` option was set', () => { const debugIntegration = new Debug({ stringify: true }); - const captureEventProcessor = (eventProcessor: EventProcessor) => { - const testEvent = { event_id: 'some event' }; - const testEventHint = { event_id: 'some event hint' }; - void eventProcessor(testEvent, testEventHint); - expect(mockConsoleLog).toHaveBeenCalledTimes(2); - expect(mockConsoleLog).toBeCalledWith(JSON.stringify(testEventHint, null, 2)); - }; + const testEvent = { event_id: 'some event' }; + const testEventHint = { event_id: 'some event hint' }; + + testEventLogged(debugIntegration, testEvent, testEventHint); - debugIntegration.setupOnce(captureEventProcessor, () => mockGetCurrentHub(debugIntegration) as any); + expect(mockConsoleLog).toHaveBeenCalledTimes(2); + expect(mockConsoleLog).toBeCalledWith(JSON.stringify(testEventHint, null, 2)); }); }); From beec5be5cf90fc13d5c0000419422f51d2d75d1c Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 14 Sep 2023 06:07:58 +0200 Subject: [PATCH 02/20] feat(node): Node client extends `ServerRuntimeClient` rather than `BaseClient` (#8933) Since common server bahaviour has now moved to `ServerRuntimeClient`, the Node client can now extend that rather than the base client. This PR also moves the request session flusher to `ServerRuntimeClient` which leaves the Node client empty apart from a constructor wrapper to preserve backwards compatibilty. --- packages/core/src/server-runtime-client.ts | 93 ++++++- packages/node/package.json | 7 + packages/node/src/client.ts | 242 +----------------- packages/node/src/eventbuilder.ts | 131 ---------- .../node/src/integrations/linkederrors.ts | 4 +- packages/node/test/client.test.ts | 2 +- packages/node/test/context-lines.test.ts | 2 +- packages/node/test/eventbuilders.test.ts | 6 +- .../aggregates-disable-single-session.js | 1 + packages/node/test/stacktrace.test.ts | 5 +- 10 files changed, 116 insertions(+), 377 deletions(-) delete mode 100644 packages/node/src/eventbuilder.ts diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 7f3d5a6cf315..67d7055a1623 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -11,12 +11,13 @@ import type { SeverityLevel, TraceContext, } from '@sentry/types'; -import { eventFromMessage, eventFromUnknownInput, logger, uuid4 } from '@sentry/utils'; +import { eventFromMessage, eventFromUnknownInput, logger, resolvedSyncPromise, uuid4 } from '@sentry/utils'; import { BaseClient } from './baseclient'; import { createCheckInEnvelope } from './checkin'; import { getCurrentHub } from './hub'; import type { Scope } from './scope'; +import { SessionFlusher } from './sessionflusher'; import { addTracingExtensions, getDynamicSamplingContextFromClient } from './tracing'; export interface ServerRuntimeClientOptions extends ClientOptions { @@ -31,6 +32,8 @@ export interface ServerRuntimeClientOptions extends ClientOptions extends BaseClient { + protected _sessionFlusher: SessionFlusher | undefined; + /** * Creates a new Edge SDK instance. * @param options Configuration options for this SDK. @@ -46,7 +49,7 @@ export class ServerRuntimeClient< * @inheritDoc */ public eventFromException(exception: unknown, hint?: EventHint): PromiseLike { - return Promise.resolve(eventFromUnknownInput(getCurrentHub, this._options.stackParser, exception, hint)); + return resolvedSyncPromise(eventFromUnknownInput(getCurrentHub, this._options.stackParser, exception, hint)); } /** @@ -58,11 +61,83 @@ export class ServerRuntimeClient< level: Severity | SeverityLevel = 'info', hint?: EventHint, ): PromiseLike { - return Promise.resolve( + return resolvedSyncPromise( eventFromMessage(this._options.stackParser, message, level, hint, this._options.attachStacktrace), ); } + /** + * @inheritDoc + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + public captureException(exception: any, hint?: EventHint, scope?: Scope): string | undefined { + // Check if the flag `autoSessionTracking` is enabled, and if `_sessionFlusher` exists because it is initialised only + // when the `requestHandler` middleware is used, and hence the expectation is to have SessionAggregates payload + // sent to the Server only when the `requestHandler` middleware is used + if (this._options.autoSessionTracking && this._sessionFlusher && scope) { + const requestSession = scope.getRequestSession(); + + // Necessary checks to ensure this is code block is executed only within a request + // Should override the status only if `requestSession.status` is `Ok`, which is its initial stage + if (requestSession && requestSession.status === 'ok') { + requestSession.status = 'errored'; + } + } + + return super.captureException(exception, hint, scope); + } + + /** + * @inheritDoc + */ + public captureEvent(event: Event, hint?: EventHint, scope?: Scope): string | undefined { + // Check if the flag `autoSessionTracking` is enabled, and if `_sessionFlusher` exists because it is initialised only + // when the `requestHandler` middleware is used, and hence the expectation is to have SessionAggregates payload + // sent to the Server only when the `requestHandler` middleware is used + if (this._options.autoSessionTracking && this._sessionFlusher && scope) { + const eventType = event.type || 'exception'; + const isException = + eventType === 'exception' && event.exception && event.exception.values && event.exception.values.length > 0; + + // If the event is of type Exception, then a request session should be captured + if (isException) { + const requestSession = scope.getRequestSession(); + + // Ensure that this is happening within the bounds of a request, and make sure not to override + // Session Status if Errored / Crashed + if (requestSession && requestSession.status === 'ok') { + requestSession.status = 'errored'; + } + } + } + + return super.captureEvent(event, hint, scope); + } + + /** + * + * @inheritdoc + */ + public close(timeout?: number): PromiseLike { + if (this._sessionFlusher) { + this._sessionFlusher.close(); + } + return super.close(timeout); + } + + /** Method that initialises an instance of SessionFlusher on Client */ + public initSessionFlusher(): void { + const { release, environment } = this._options; + if (!release) { + __DEBUG_BUILD__ && logger.warn('Cannot initialise an instance of SessionFlusher if no release is provided!'); + } else { + this._sessionFlusher = new SessionFlusher(this, { + release, + environment, + }); + } + } + /** * Create a cron monitor check in and send it to Sentry. * @@ -121,6 +196,18 @@ export class ServerRuntimeClient< return id; } + /** + * Method responsible for capturing/ending a request session by calling `incrementSessionStatusCount` to increment + * appropriate session aggregates bucket + */ + protected _captureRequestSession(): void { + if (!this._sessionFlusher) { + __DEBUG_BUILD__ && logger.warn('Discarded request mode session because autoSessionTracking option was disabled'); + } else { + this._sessionFlusher.incrementSessionStatusCount(); + } + } + /** * @inheritDoc */ diff --git a/packages/node/package.json b/packages/node/package.json index 3665f74b55b3..b2368d02ac39 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -72,5 +72,12 @@ "volta": { "extends": "../../package.json" }, + "madge":{ + "detectiveOptions": { + "ts": { + "skipTypeImports": true + } + } + }, "sideEffects": false } diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 50af36448046..8a174754d1f1 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -1,28 +1,8 @@ -import type { Scope } from '@sentry/core'; -import { - addTracingExtensions, - BaseClient, - createCheckInEnvelope, - getDynamicSamplingContextFromClient, - SDK_VERSION, - SessionFlusher, -} from '@sentry/core'; -import type { - CheckIn, - DynamicSamplingContext, - Event, - EventHint, - MonitorConfig, - SerializedCheckIn, - Severity, - SeverityLevel, - TraceContext, -} from '@sentry/types'; -import { logger, resolvedSyncPromise, uuid4 } from '@sentry/utils'; +import type { ServerRuntimeClientOptions } from '@sentry/core'; +import { SDK_VERSION, ServerRuntimeClient } from '@sentry/core'; import * as os from 'os'; import { TextEncoder } from 'util'; -import { eventFromMessage, eventFromUnknownInput } from './eventbuilder'; import type { NodeClientOptions } from './types'; /** @@ -31,9 +11,7 @@ import type { NodeClientOptions } from './types'; * @see NodeClientOptions for documentation on configuration options. * @see SentryClient for usage documentation. */ -export class NodeClient extends BaseClient { - protected _sessionFlusher: SessionFlusher | undefined; - +export class NodeClient extends ServerRuntimeClient { /** * Creates a new Node SDK instance. * @param options Configuration options for this SDK. @@ -57,215 +35,13 @@ export class NodeClient extends BaseClient { ...options.transportOptions, }; - // The Node client always supports tracing - addTracingExtensions(); - - super(options); - } - - /** - * @inheritDoc - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types - public captureException(exception: any, hint?: EventHint, scope?: Scope): string | undefined { - // Check if the flag `autoSessionTracking` is enabled, and if `_sessionFlusher` exists because it is initialised only - // when the `requestHandler` middleware is used, and hence the expectation is to have SessionAggregates payload - // sent to the Server only when the `requestHandler` middleware is used - if (this._options.autoSessionTracking && this._sessionFlusher && scope) { - const requestSession = scope.getRequestSession(); - - // Necessary checks to ensure this is code block is executed only within a request - // Should override the status only if `requestSession.status` is `Ok`, which is its initial stage - if (requestSession && requestSession.status === 'ok') { - requestSession.status = 'errored'; - } - } - - return super.captureException(exception, hint, scope); - } - - /** - * @inheritDoc - */ - public captureEvent(event: Event, hint?: EventHint, scope?: Scope): string | undefined { - // Check if the flag `autoSessionTracking` is enabled, and if `_sessionFlusher` exists because it is initialised only - // when the `requestHandler` middleware is used, and hence the expectation is to have SessionAggregates payload - // sent to the Server only when the `requestHandler` middleware is used - if (this._options.autoSessionTracking && this._sessionFlusher && scope) { - const eventType = event.type || 'exception'; - const isException = - eventType === 'exception' && event.exception && event.exception.values && event.exception.values.length > 0; - - // If the event is of type Exception, then a request session should be captured - if (isException) { - const requestSession = scope.getRequestSession(); - - // Ensure that this is happening within the bounds of a request, and make sure not to override - // Session Status if Errored / Crashed - if (requestSession && requestSession.status === 'ok') { - requestSession.status = 'errored'; - } - } - } - - return super.captureEvent(event, hint, scope); - } - - /** - * - * @inheritdoc - */ - public close(timeout?: number): PromiseLike { - this._sessionFlusher?.close(); - return super.close(timeout); - } - - /** Method that initialises an instance of SessionFlusher on Client */ - public initSessionFlusher(): void { - const { release, environment } = this._options; - if (!release) { - __DEBUG_BUILD__ && logger.warn('Cannot initialise an instance of SessionFlusher if no release is provided!'); - } else { - this._sessionFlusher = new SessionFlusher(this, { - release, - environment, - }); - } - } - - /** - * @inheritDoc - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types - public eventFromException(exception: any, hint?: EventHint): PromiseLike { - return resolvedSyncPromise(eventFromUnknownInput(this._options.stackParser, exception, hint)); - } - - /** - * @inheritDoc - */ - public eventFromMessage( - message: string, - // eslint-disable-next-line deprecation/deprecation - level: Severity | SeverityLevel = 'info', - hint?: EventHint, - ): PromiseLike { - return resolvedSyncPromise( - eventFromMessage(this._options.stackParser, message, level, hint, this._options.attachStacktrace), - ); - } - - /** - * Create a cron monitor check in and send it to Sentry. - * - * @param checkIn An object that describes a check in. - * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want - * to create a monitor automatically when sending a check in. - * @returns A string representing the id of the check in. - */ - public captureCheckIn(checkIn: CheckIn, monitorConfig?: MonitorConfig, scope?: Scope): string { - const id = checkIn.status !== 'in_progress' && checkIn.checkInId ? checkIn.checkInId : uuid4(); - if (!this._isEnabled()) { - __DEBUG_BUILD__ && logger.warn('SDK not enabled, will not capture checkin.'); - return id; - } - - const options = this.getOptions(); - const { release, environment, tunnel } = options; - - const serializedCheckIn: SerializedCheckIn = { - check_in_id: id, - monitor_slug: checkIn.monitorSlug, - status: checkIn.status, - release, - environment, - }; - - if (checkIn.status !== 'in_progress') { - serializedCheckIn.duration = checkIn.duration; - } - - if (monitorConfig) { - serializedCheckIn.monitor_config = { - schedule: monitorConfig.schedule, - checkin_margin: monitorConfig.checkinMargin, - max_runtime: monitorConfig.maxRuntime, - timezone: monitorConfig.timezone, - }; - } - - const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope); - if (traceContext) { - serializedCheckIn.contexts = { - trace: traceContext, - }; - } - - const envelope = createCheckInEnvelope( - serializedCheckIn, - dynamicSamplingContext, - this.getSdkMetadata(), - tunnel, - this.getDsn(), - ); - - __DEBUG_BUILD__ && logger.info('Sending checkin:', checkIn.monitorSlug, checkIn.status); - void this._sendEnvelope(envelope); - return id; - } - - /** - * @inheritDoc - */ - protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike { - event.platform = event.platform || 'node'; - event.contexts = { - ...event.contexts, - runtime: event.contexts?.runtime || { - name: 'node', - version: global.process.version, - }, - }; - event.server_name = - event.server_name || this.getOptions().serverName || global.process.env.SENTRY_NAME || os.hostname(); - return super._prepareEvent(event, hint, scope); - } - - /** - * Method responsible for capturing/ending a request session by calling `incrementSessionStatusCount` to increment - * appropriate session aggregates bucket - */ - protected _captureRequestSession(): void { - if (!this._sessionFlusher) { - __DEBUG_BUILD__ && logger.warn('Discarded request mode session because autoSessionTracking option was disabled'); - } else { - this._sessionFlusher.incrementSessionStatusCount(); - } - } - - /** Extract trace information from scope */ - private _getTraceInfoFromScope( - scope: Scope | undefined, - ): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { - if (!scope) { - return [undefined, undefined]; - } - - const span = scope.getSpan(); - if (span) { - return [span?.transaction?.getDynamicSamplingContext(), span?.getTraceContext()]; - } - - const { traceId, spanId, parentSpanId, dsc } = scope.getPropagationContext(); - const traceContext: TraceContext = { - trace_id: traceId, - span_id: spanId, - parent_span_id: parentSpanId, + const clientOptions: ServerRuntimeClientOptions = { + ...options, + platform: 'node', + runtime: { name: 'node', version: global.process.version }, + serverName: options.serverName || global.process.env.SENTRY_NAME || os.hostname(), }; - if (dsc) { - return [dsc, traceContext]; - } - return [getDynamicSamplingContextFromClient(traceId, this, scope), traceContext]; + super(clientOptions); } } diff --git a/packages/node/src/eventbuilder.ts b/packages/node/src/eventbuilder.ts deleted file mode 100644 index f2bb1443a40f..000000000000 --- a/packages/node/src/eventbuilder.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { getCurrentHub } from '@sentry/core'; -import type { - Event, - EventHint, - Exception, - Mechanism, - Severity, - SeverityLevel, - StackFrame, - StackParser, -} from '@sentry/types'; -import { - addExceptionMechanism, - addExceptionTypeValue, - extractExceptionKeysForMessage, - isError, - isPlainObject, - normalizeToSize, -} from '@sentry/utils'; - -/** - * Extracts stack frames from the error.stack string - */ -export function parseStackFrames(stackParser: StackParser, error: Error): StackFrame[] { - return stackParser(error.stack || '', 1); -} - -/** - * Extracts stack frames from the error and builds a Sentry Exception - */ -export function exceptionFromError(stackParser: StackParser, error: Error): Exception { - const exception: Exception = { - type: error.name || error.constructor.name, - value: error.message, - }; - - const frames = parseStackFrames(stackParser, error); - if (frames.length) { - exception.stacktrace = { frames }; - } - - return exception; -} - -/** - * Builds and Event from a Exception - * @hidden - */ -export function eventFromUnknownInput(stackParser: StackParser, exception: unknown, hint?: EventHint): Event { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let ex: unknown = exception; - const providedMechanism: Mechanism | undefined = - hint && hint.data && (hint.data as { mechanism: Mechanism }).mechanism; - const mechanism: Mechanism = providedMechanism || { - handled: true, - type: 'generic', - }; - - if (!isError(exception)) { - if (isPlainObject(exception)) { - // This will allow us to group events based on top-level keys - // which is much better than creating new group when any key/value change - const message = `Non-Error exception captured with keys: ${extractExceptionKeysForMessage(exception)}`; - - const hub = getCurrentHub(); - const client = hub.getClient(); - const normalizeDepth = client && client.getOptions().normalizeDepth; - hub.configureScope(scope => { - scope.setExtra('__serialized__', normalizeToSize(exception, normalizeDepth)); - }); - - ex = (hint && hint.syntheticException) || new Error(message); - (ex as Error).message = message; - } else { - // This handles when someone does: `throw "something awesome";` - // We use synthesized Error here so we can extract a (rough) stack trace. - ex = (hint && hint.syntheticException) || new Error(exception as string); - (ex as Error).message = exception as string; - } - mechanism.synthetic = true; - } - - const event = { - exception: { - values: [exceptionFromError(stackParser, ex as Error)], - }, - }; - - addExceptionTypeValue(event, undefined, undefined); - addExceptionMechanism(event, mechanism); - - return { - ...event, - event_id: hint && hint.event_id, - }; -} - -/** - * Builds and Event from a Message - * @hidden - */ -export function eventFromMessage( - stackParser: StackParser, - message: string, - // eslint-disable-next-line deprecation/deprecation - level: Severity | SeverityLevel = 'info', - hint?: EventHint, - attachStacktrace?: boolean, -): Event { - const event: Event = { - event_id: hint && hint.event_id, - level, - message, - }; - - if (attachStacktrace && hint && hint.syntheticException) { - const frames = parseStackFrames(stackParser, hint.syntheticException); - if (frames.length) { - event.exception = { - values: [ - { - value: message, - stacktrace: { frames }, - }, - ], - }; - } - } - - return event; -} diff --git a/packages/node/src/integrations/linkederrors.ts b/packages/node/src/integrations/linkederrors.ts index 78062f708d6b..de2a6e0cc1e8 100644 --- a/packages/node/src/integrations/linkederrors.ts +++ b/packages/node/src/integrations/linkederrors.ts @@ -1,7 +1,5 @@ import type { Client, Event, EventHint, Integration } from '@sentry/types'; -import { applyAggregateErrorsToEvent } from '@sentry/utils'; - -import { exceptionFromError } from '../eventbuilder'; +import { applyAggregateErrorsToEvent, exceptionFromError } from '@sentry/utils'; const DEFAULT_KEY = 'cause'; const DEFAULT_LIMIT = 5; diff --git a/packages/node/test/client.test.ts b/packages/node/test/client.test.ts index 0ddf69105de7..ab2e69c7b33b 100644 --- a/packages/node/test/client.test.ts +++ b/packages/node/test/client.test.ts @@ -233,8 +233,8 @@ describe('NodeClient', () => { test('adds server name to event when value given in env', () => { const options = getDefaultNodeClientOptions({ dsn: PUBLIC_DSN }); - client = new NodeClient(options); process.env.SENTRY_NAME = 'foo'; + client = new NodeClient(options); const event: Event = {}; const hint: EventHint = {}; diff --git a/packages/node/test/context-lines.test.ts b/packages/node/test/context-lines.test.ts index cfdd44e8b840..25b17e29ba77 100644 --- a/packages/node/test/context-lines.test.ts +++ b/packages/node/test/context-lines.test.ts @@ -1,7 +1,7 @@ import type { StackFrame } from '@sentry/types'; +import { parseStackFrames } from '@sentry/utils'; import * as fs from 'fs'; -import { parseStackFrames } from '../src/eventbuilder'; import { ContextLines, resetFileContentCache } from '../src/integrations/contextlines'; import { defaultStackParser } from '../src/sdk'; import { getError } from './helper/error'; diff --git a/packages/node/test/eventbuilders.test.ts b/packages/node/test/eventbuilders.test.ts index 46dfc02a3c33..cf612afef508 100644 --- a/packages/node/test/eventbuilders.test.ts +++ b/packages/node/test/eventbuilders.test.ts @@ -1,7 +1,7 @@ import type { Client } from '@sentry/types'; +import { eventFromUnknownInput } from '@sentry/utils'; -import { defaultStackParser, Scope } from '../src'; -import { eventFromUnknownInput } from '../src/eventbuilder'; +import { defaultStackParser, getCurrentHub, Scope } from '../src'; const testScope = new Scope(); @@ -55,7 +55,7 @@ describe('eventFromUnknownInput', () => { }, }; - eventFromUnknownInput(defaultStackParser, deepObject); + eventFromUnknownInput(getCurrentHub, defaultStackParser, deepObject); const serializedObject = (testScope as any)._extra.__serialized__; expect(serializedObject).toBeDefined(); diff --git a/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js b/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js index 300870d21fac..dc6aa4485dcb 100644 --- a/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js +++ b/packages/node/test/manual/release-health/session-aggregates/aggregates-disable-single-session.js @@ -34,6 +34,7 @@ function makeDummyTransport() { .split('\n') .filter(l => !!l) .map(e => JSON.parse(e)); + assertSessionAggregates(sessionEnv[2], { attrs: { release: '1.1' }, aggregates: [{ crashed: 2, errored: 1, exited: 1 }], diff --git a/packages/node/test/stacktrace.test.ts b/packages/node/test/stacktrace.test.ts index f5a1b453609f..5b0f6fc52e25 100644 --- a/packages/node/test/stacktrace.test.ts +++ b/packages/node/test/stacktrace.test.ts @@ -10,7 +10,8 @@ * @license MIT */ -import { parseStackFrames } from '../src/eventbuilder'; +import { parseStackFrames } from '@sentry/utils'; + import { defaultStackParser as stackParser } from '../src/sdk'; function testBasic() { @@ -32,7 +33,7 @@ describe('Stack parsing', () => { const last = frames.length - 1; expect(frames[last].filename).toEqual(__filename); expect(frames[last].function).toEqual('testBasic'); - expect(frames[last].lineno).toEqual(17); + expect(frames[last].lineno).toEqual(18); expect(frames[last].colno).toEqual(10); }); From a65c7ef84a97ca99d980fe9d5e540de4e90341c1 Mon Sep 17 00:00:00 2001 From: Dima <35235492+Dima-Dim@users.noreply.github.com> Date: Thu, 14 Sep 2023 16:31:29 +0600 Subject: [PATCH 03/20] chore(node): upgrade cookie to ^0.5.0 (#9013) The current version of cookies includes several performance improvements: https://github.com/jshttp/cookie/releases --- packages/node/package.json | 4 ++-- yarn.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/node/package.json b/packages/node/package.json index b2368d02ac39..4cc199901670 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -27,13 +27,13 @@ "@sentry/core": "7.69.0", "@sentry/types": "7.69.0", "@sentry/utils": "7.69.0", - "cookie": "^0.4.1", + "cookie": "^0.5.0", "https-proxy-agent": "^5.0.0", "lru_map": "^0.3.3", "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { - "@types/cookie": "0.3.2", + "@types/cookie": "0.5.2", "@types/express": "^4.17.14", "@types/lru-cache": "^5.1.0", "@types/node": "~10.17.0", diff --git a/yarn.lock b/yarn.lock index 76f2f4de7581..a68cf4925469 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4867,10 +4867,10 @@ dependencies: "@types/node" "*" -"@types/cookie@0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.2.tgz#453f4b14b25da6a8ea4494842dedcbf0151deef9" - integrity sha512-aHQA072E10/8iUQsPH7mQU/KUyQBZAGzTVRCUvnSz8mSvbrYsP4xEO2RSA0Pjltolzi0j8+8ixrm//Hr4umPzw== +"@types/cookie@0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.5.2.tgz#9bf9d62c838c85a07c92fdf2334c2c14fd9c59a9" + integrity sha512-DBpRoJGKJZn7RY92dPrgoMew8xCWc2P71beqsjyhEI/Ds9mOyVmBwtekyfhpwFIVt1WrxTonFifiOZ62V8CnNA== "@types/cookie@^0.4.0", "@types/cookie@^0.4.1": version "0.4.1" From e24d7e01ed9b26dd3479998255c8f7c25becae64 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 15 Sep 2023 03:09:31 -0400 Subject: [PATCH 04/20] fix(replay): Fix typo in Replay types (#9028) --- packages/replay/src/types/replayFrame.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/replay/src/types/replayFrame.ts b/packages/replay/src/types/replayFrame.ts index 296556b6c6d0..c65544ef8631 100644 --- a/packages/replay/src/types/replayFrame.ts +++ b/packages/replay/src/types/replayFrame.ts @@ -178,13 +178,7 @@ interface ReplayRequestFrame extends ReplayBaseSpanFrame { interface ReplayResourceFrame extends ReplayBaseSpanFrame { data: ResourceData; - op: - | 'resource.css' - | 'resource.ReplayiFrame' - | 'resource.img' - | 'resource.link' - | 'resource.other' - | 'resource.script'; + op: 'resource.css' | 'resource.iframe' | 'resource.img' | 'resource.link' | 'resource.other' | 'resource.script'; } export type ReplaySpanFrame = From 2356e80c2adb2855a0de069bdf9de06b104ee5c7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 15 Sep 2023 10:07:55 +0200 Subject: [PATCH 05/20] feat(core): Introduce `processEvent` hook on `Integration` (#9017) This adds a new (optional) `processEvent` hook on the `Integration` interface, which allows to register an event processor **for the current client only**. This has actually correct semantics in that the processor will only be registered for the client that the integration is added for. This is done by adding a new `addEventProcessor` method on the client, which for now are called after all global & scope event processors. Previously, all integrations always registered a _global_ event processor, which is not really necessary. With this, we can be much more focused & also skip checking for existence of the integration on the client etc. --- packages/core/src/baseclient.ts | 16 +- packages/core/src/eventProcessors.ts | 51 +++++ packages/core/src/index.ts | 3 +- packages/core/src/integration.ts | 16 +- packages/core/src/scope.ts | 63 +----- packages/core/src/utils/prepareEvent.ts | 35 +-- packages/core/test/lib/integration.test.ts | 244 ++++++++++++++++++++- packages/types/src/client.ts | 15 ++ packages/types/src/integration.ts | 7 + 9 files changed, 370 insertions(+), 80 deletions(-) create mode 100644 packages/core/src/eventProcessors.ts diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index 1d1ad6aaa377..1a15c4bc37bd 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -12,6 +12,7 @@ import type { Event, EventDropReason, EventHint, + EventProcessor, Integration, IntegrationClass, Outcome, @@ -107,6 +108,8 @@ export abstract class BaseClient implements Client { // eslint-disable-next-line @typescript-eslint/ban-types private _hooks: Record; + private _eventProcessors: EventProcessor[]; + /** * Initializes this client instance. * @@ -119,6 +122,7 @@ export abstract class BaseClient implements Client { this._numProcessing = 0; this._outcomes = {}; this._hooks = {}; + this._eventProcessors = []; if (options.dsn) { this._dsn = makeDsn(options.dsn); @@ -280,6 +284,16 @@ export abstract class BaseClient implements Client { }); } + /** Get all installed event processors. */ + public getEventProcessors(): EventProcessor[] { + return this._eventProcessors; + } + + /** @inheritDoc */ + public addEventProcessor(eventProcessor: EventProcessor): void { + this._eventProcessors.push(eventProcessor); + } + /** * Sets up the integrations */ @@ -545,7 +559,7 @@ export abstract class BaseClient implements Client { this.emit('preprocessEvent', event, hint); - return prepareEvent(options, event, hint, scope).then(evt => { + return prepareEvent(options, event, hint, scope, this).then(evt => { if (evt === null) { return evt; } diff --git a/packages/core/src/eventProcessors.ts b/packages/core/src/eventProcessors.ts new file mode 100644 index 000000000000..4596788b9dcb --- /dev/null +++ b/packages/core/src/eventProcessors.ts @@ -0,0 +1,51 @@ +import type { Event, EventHint, EventProcessor } from '@sentry/types'; +import { getGlobalSingleton, isThenable, logger, SyncPromise } from '@sentry/utils'; + +/** + * Returns the global event processors. + */ +export function getGlobalEventProcessors(): EventProcessor[] { + return getGlobalSingleton('globalEventProcessors', () => []); +} + +/** + * Add a EventProcessor to be kept globally. + * @param callback EventProcessor to add + */ +export function addGlobalEventProcessor(callback: EventProcessor): void { + getGlobalEventProcessors().push(callback); +} + +/** + * Process an array of event processors, returning the processed event (or `null` if the event was dropped). + */ +export function notifyEventProcessors( + processors: EventProcessor[], + event: Event | null, + hint: EventHint, + index: number = 0, +): PromiseLike { + return new SyncPromise((resolve, reject) => { + const processor = processors[index]; + if (event === null || typeof processor !== 'function') { + resolve(event); + } else { + const result = processor({ ...event }, hint) as Event | null; + + __DEBUG_BUILD__ && + processor.id && + result === null && + logger.log(`Event processor "${processor.id}" dropped event`); + + if (isThenable(result)) { + void result + .then(final => notifyEventProcessors(processors, final, hint, index + 1).then(resolve)) + .then(null, reject); + } else { + void notifyEventProcessors(processors, result, hint, index + 1) + .then(resolve) + .then(null, reject); + } + } + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 67c28a3e3c57..21f7cab37505 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,7 +36,8 @@ export { } from './hub'; export { makeSession, closeSession, updateSession } from './session'; export { SessionFlusher } from './sessionflusher'; -export { addGlobalEventProcessor, Scope } from './scope'; +export { Scope } from './scope'; +export { addGlobalEventProcessor } from './eventProcessors'; export { getEnvelopeEndpointWithUrlEncodedAuth, getReportDialogEndpoint } from './api'; export { BaseClient } from './baseclient'; export { ServerRuntimeClient } from './server-runtime-client'; diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index b2c8e4547ab8..aa8968edc8dc 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -1,8 +1,8 @@ -import type { Client, Integration, Options } from '@sentry/types'; +import type { Client, Event, EventHint, Integration, Options } from '@sentry/types'; import { arrayify, logger } from '@sentry/utils'; +import { addGlobalEventProcessor } from './eventProcessors'; import { getCurrentHub } from './hub'; -import { addGlobalEventProcessor } from './scope'; declare module '@sentry/types' { interface Integration { @@ -107,10 +107,20 @@ export function setupIntegration(client: Client, integration: Integration, integ } if (client.on && typeof integration.preprocessEvent === 'function') { - const callback = integration.preprocessEvent.bind(integration); + const callback = integration.preprocessEvent.bind(integration) as typeof integration.preprocessEvent; client.on('preprocessEvent', (event, hint) => callback(event, hint, client)); } + if (client.addEventProcessor && typeof integration.processEvent === 'function') { + const callback = integration.processEvent.bind(integration) as typeof integration.processEvent; + + const processor = Object.assign((event: Event, hint: EventHint) => callback(event, hint, client), { + id: integration.name, + }); + + client.addEventProcessor(processor); + } + __DEBUG_BUILD__ && logger.log(`Integration installed: ${integration.name}`); } diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index ef6832bc773d..ba4ccac23adb 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -22,17 +22,9 @@ import type { Transaction, User, } from '@sentry/types'; -import { - arrayify, - dateTimestampInSeconds, - getGlobalSingleton, - isPlainObject, - isThenable, - logger, - SyncPromise, - uuid4, -} from '@sentry/utils'; +import { arrayify, dateTimestampInSeconds, isPlainObject, uuid4 } from '@sentry/utils'; +import { getGlobalEventProcessors, notifyEventProcessors } from './eventProcessors'; import { updateSession } from './session'; /** @@ -525,7 +517,7 @@ export class Scope implements ScopeInterface { propagationContext: this._propagationContext, }; - return this._notifyEventProcessors([...getGlobalEventProcessors(), ...this._eventProcessors], event, hint); + return notifyEventProcessors([...getGlobalEventProcessors(), ...this._eventProcessors], event, hint); } /** @@ -559,40 +551,6 @@ export class Scope implements ScopeInterface { return this._breadcrumbs; } - /** - * This will be called after {@link applyToEvent} is finished. - */ - protected _notifyEventProcessors( - processors: EventProcessor[], - event: Event | null, - hint: EventHint, - index: number = 0, - ): PromiseLike { - return new SyncPromise((resolve, reject) => { - const processor = processors[index]; - if (event === null || typeof processor !== 'function') { - resolve(event); - } else { - const result = processor({ ...event }, hint) as Event | null; - - __DEBUG_BUILD__ && - processor.id && - result === null && - logger.log(`Event processor "${processor.id}" dropped event`); - - if (isThenable(result)) { - void result - .then(final => this._notifyEventProcessors(processors, final, hint, index + 1).then(resolve)) - .then(null, reject); - } else { - void this._notifyEventProcessors(processors, result, hint, index + 1) - .then(resolve) - .then(null, reject); - } - } - }); - } - /** * This will be called on every set call. */ @@ -629,21 +587,6 @@ export class Scope implements ScopeInterface { } } -/** - * Returns the global event processors. - */ -function getGlobalEventProcessors(): EventProcessor[] { - return getGlobalSingleton('globalEventProcessors', () => []); -} - -/** - * Add a EventProcessor to be kept globally. - * @param callback EventProcessor to add - */ -export function addGlobalEventProcessor(callback: EventProcessor): void { - getGlobalEventProcessors().push(callback); -} - function generatePropagationContext(): PropagationContext { return { traceId: uuid4(), diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts index 84bfd404c56f..13a164dbaf76 100644 --- a/packages/core/src/utils/prepareEvent.ts +++ b/packages/core/src/utils/prepareEvent.ts @@ -1,7 +1,8 @@ -import type { ClientOptions, Event, EventHint, StackFrame, StackParser } from '@sentry/types'; +import type { Client, ClientOptions, Event, EventHint, StackFrame, StackParser } from '@sentry/types'; import { dateTimestampInSeconds, GLOBAL_OBJ, normalize, resolvedSyncPromise, truncate, uuid4 } from '@sentry/utils'; import { DEFAULT_ENVIRONMENT } from '../constants'; +import { notifyEventProcessors } from '../eventProcessors'; import { Scope } from '../scope'; /** @@ -26,6 +27,7 @@ export function prepareEvent( event: Event, hint: EventHint, scope?: Scope, + client?: Client, ): PromiseLike { const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = options; const prepared: Event = { @@ -74,20 +76,25 @@ export function prepareEvent( result = finalScope.applyToEvent(prepared, hint); } - return result.then(evt => { - if (evt) { - // We apply the debug_meta field only after all event processors have ran, so that if any event processors modified - // file names (e.g.the RewriteFrames integration) the filename -> debug ID relationship isn't destroyed. - // This should not cause any PII issues, since we're only moving data that is already on the event and not adding - // any new data - applyDebugMeta(evt); - } + return result + .then(evt => { + // Process client-scoped event processors + return client && client.getEventProcessors ? notifyEventProcessors(client.getEventProcessors(), evt, hint) : evt; + }) + .then(evt => { + if (evt) { + // We apply the debug_meta field only after all event processors have ran, so that if any event processors modified + // file names (e.g.the RewriteFrames integration) the filename -> debug ID relationship isn't destroyed. + // This should not cause any PII issues, since we're only moving data that is already on the event and not adding + // any new data + applyDebugMeta(evt); + } - if (typeof normalizeDepth === 'number' && normalizeDepth > 0) { - return normalizeEvent(evt, normalizeDepth, normalizeMaxBreadth); - } - return evt; - }); + if (typeof normalizeDepth === 'number' && normalizeDepth > 0) { + return normalizeEvent(evt, normalizeDepth, normalizeMaxBreadth); + } + return evt; + }); } /** diff --git a/packages/core/test/lib/integration.test.ts b/packages/core/test/lib/integration.test.ts index 14b1697b9054..f431d30b2140 100644 --- a/packages/core/test/lib/integration.test.ts +++ b/packages/core/test/lib/integration.test.ts @@ -1,6 +1,15 @@ import type { Integration, Options } from '@sentry/types'; -import { getIntegrationsToSetup } from '../../src/integration'; +import { getIntegrationsToSetup, installedIntegrations, setupIntegration } from '../../src/integration'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +function getTestClient(): TestClient { + return new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://username@domain/123', + }), + ); +} /** JSDoc */ class MockIntegration implements Integration { @@ -317,3 +326,236 @@ describe('getIntegrationsToSetup', () => { expect(integrations.map(i => i.name)).toEqual(['foo', 'Debug']); }); }); + +describe('setupIntegration', () => { + beforeEach(function () { + // Reset the (global!) list of installed integrations + installedIntegrations.splice(0, installedIntegrations.length); + }); + + it('works with a minimal integration', () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + } + + const client = getTestClient(); + const integrationIndex = {}; + const integration = new CustomIntegration(); + + setupIntegration(client, integration, integrationIndex); + + expect(integrationIndex).toEqual({ test: integration }); + expect(integration.setupOnce).toHaveBeenCalledTimes(1); + }); + + it('only calls setupOnce a single time', () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + } + + const client1 = getTestClient(); + const client2 = getTestClient(); + + const integrationIndex = {}; + const integration1 = new CustomIntegration(); + const integration2 = new CustomIntegration(); + const integration3 = new CustomIntegration(); + const integration4 = new CustomIntegration(); + + setupIntegration(client1, integration1, integrationIndex); + setupIntegration(client1, integration2, integrationIndex); + setupIntegration(client2, integration3, integrationIndex); + setupIntegration(client2, integration4, integrationIndex); + + expect(integrationIndex).toEqual({ test: integration4 }); + expect(integration1.setupOnce).toHaveBeenCalledTimes(1); + expect(integration2.setupOnce).not.toHaveBeenCalled(); + expect(integration3.setupOnce).not.toHaveBeenCalled(); + expect(integration4.setupOnce).not.toHaveBeenCalled(); + }); + + it('binds preprocessEvent for each client', () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + preprocessEvent = jest.fn(); + } + + const client1 = getTestClient(); + const client2 = getTestClient(); + + const integrationIndex = {}; + const integration1 = new CustomIntegration(); + const integration2 = new CustomIntegration(); + const integration3 = new CustomIntegration(); + const integration4 = new CustomIntegration(); + + setupIntegration(client1, integration1, integrationIndex); + setupIntegration(client1, integration2, integrationIndex); + setupIntegration(client2, integration3, integrationIndex); + setupIntegration(client2, integration4, integrationIndex); + + expect(integrationIndex).toEqual({ test: integration4 }); + expect(integration1.setupOnce).toHaveBeenCalledTimes(1); + expect(integration2.setupOnce).not.toHaveBeenCalled(); + expect(integration3.setupOnce).not.toHaveBeenCalled(); + expect(integration4.setupOnce).not.toHaveBeenCalled(); + + client1.captureEvent({ event_id: '1a' }); + client1.captureEvent({ event_id: '1b' }); + client2.captureEvent({ event_id: '2a' }); + client2.captureEvent({ event_id: '2b' }); + client2.captureEvent({ event_id: '2c' }); + + expect(integration1.preprocessEvent).toHaveBeenCalledTimes(2); + expect(integration2.preprocessEvent).toHaveBeenCalledTimes(2); + expect(integration3.preprocessEvent).toHaveBeenCalledTimes(3); + expect(integration4.preprocessEvent).toHaveBeenCalledTimes(3); + + expect(integration1.preprocessEvent).toHaveBeenLastCalledWith({ event_id: '1b' }, {}, client1); + expect(integration2.preprocessEvent).toHaveBeenLastCalledWith({ event_id: '1b' }, {}, client1); + expect(integration3.preprocessEvent).toHaveBeenLastCalledWith({ event_id: '2c' }, {}, client2); + expect(integration4.preprocessEvent).toHaveBeenLastCalledWith({ event_id: '2c' }, {}, client2); + }); + + it('allows to mutate events in preprocessEvent', async () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + preprocessEvent = jest.fn(event => { + event.event_id = 'mutated'; + }); + } + + const client = getTestClient(); + + const integrationIndex = {}; + const integration = new CustomIntegration(); + + setupIntegration(client, integration, integrationIndex); + + const sendEvent = jest.fn(); + client.sendEvent = sendEvent; + + client.captureEvent({ event_id: '1a' }); + await client.flush(); + + expect(sendEvent).toHaveBeenCalledTimes(1); + expect(sendEvent).toHaveBeenCalledWith(expect.objectContaining({ event_id: 'mutated' }), {}); + }); + + it('binds processEvent for each client', () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + processEvent = jest.fn(event => { + return event; + }); + } + + const client1 = getTestClient(); + const client2 = getTestClient(); + + const integrationIndex = {}; + const integration1 = new CustomIntegration(); + const integration2 = new CustomIntegration(); + const integration3 = new CustomIntegration(); + const integration4 = new CustomIntegration(); + + setupIntegration(client1, integration1, integrationIndex); + setupIntegration(client1, integration2, integrationIndex); + setupIntegration(client2, integration3, integrationIndex); + setupIntegration(client2, integration4, integrationIndex); + + expect(integrationIndex).toEqual({ test: integration4 }); + expect(integration1.setupOnce).toHaveBeenCalledTimes(1); + expect(integration2.setupOnce).not.toHaveBeenCalled(); + expect(integration3.setupOnce).not.toHaveBeenCalled(); + expect(integration4.setupOnce).not.toHaveBeenCalled(); + + client1.captureEvent({ event_id: '1a' }); + client1.captureEvent({ event_id: '1b' }); + client2.captureEvent({ event_id: '2a' }); + client2.captureEvent({ event_id: '2b' }); + client2.captureEvent({ event_id: '2c' }); + + expect(integration1.processEvent).toHaveBeenCalledTimes(2); + expect(integration2.processEvent).toHaveBeenCalledTimes(2); + expect(integration3.processEvent).toHaveBeenCalledTimes(3); + expect(integration4.processEvent).toHaveBeenCalledTimes(3); + + expect(integration1.processEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ event_id: '1b' }), + {}, + client1, + ); + expect(integration2.processEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ event_id: '1b' }), + {}, + client1, + ); + expect(integration3.processEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ event_id: '2c' }), + {}, + client2, + ); + expect(integration4.processEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ event_id: '2c' }), + {}, + client2, + ); + }); + + it('allows to mutate events in processEvent', async () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + processEvent = jest.fn(_event => { + return { event_id: 'mutated' }; + }); + } + + const client = getTestClient(); + + const integrationIndex = {}; + const integration = new CustomIntegration(); + + setupIntegration(client, integration, integrationIndex); + + const sendEvent = jest.fn(); + client.sendEvent = sendEvent; + + client.captureEvent({ event_id: '1a' }); + await client.flush(); + + expect(sendEvent).toHaveBeenCalledTimes(1); + expect(sendEvent).toHaveBeenCalledWith(expect.objectContaining({ event_id: 'mutated' }), {}); + }); + + it('allows to drop events in processEvent', async () => { + class CustomIntegration implements Integration { + name = 'test'; + setupOnce = jest.fn(); + processEvent = jest.fn(_event => { + return null; + }); + } + + const client = getTestClient(); + + const integrationIndex = {}; + const integration = new CustomIntegration(); + + setupIntegration(client, integration, integrationIndex); + + const sendEvent = jest.fn(); + client.sendEvent = sendEvent; + + client.captureEvent({ event_id: '1a' }); + await client.flush(); + + expect(sendEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 00361d0ada99..1b7b78066f0c 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -5,6 +5,7 @@ import type { DataCategory } from './datacategory'; import type { DsnComponents } from './dsn'; import type { DynamicSamplingContext, Envelope } from './envelope'; import type { Event, EventHint } from './event'; +import type { EventProcessor } from './eventprocessor'; import type { Integration, IntegrationClass } from './integration'; import type { ClientOptions } from './options'; import type { Scope } from './scope'; @@ -120,6 +121,20 @@ export interface Client { */ flush(timeout?: number): PromiseLike; + /** + * Adds an event processor that applies to any event processed by this client. + * + * TODO (v8): Make this a required method. + */ + addEventProcessor?(eventProcessor: EventProcessor): void; + + /** + * Get all added event processors for this client. + * + * TODO (v8): Make this a required method. + */ + getEventProcessors?(): EventProcessor[]; + /** Returns the client's instance of the given integration class, it any. */ getIntegration(integration: IntegrationClass): T | null; diff --git a/packages/types/src/integration.ts b/packages/types/src/integration.ts index 0c1feae65323..19df0b9e67c2 100644 --- a/packages/types/src/integration.ts +++ b/packages/types/src/integration.ts @@ -30,4 +30,11 @@ export interface Integration { * An optional hook that allows to preprocess an event _before_ it is passed to all other event processors. */ preprocessEvent?(event: Event, hint: EventHint | undefined, client: Client): void; + + /** + * An optional hook that allows to process an event. + * Return `null` to drop the event, or mutate the event & return it. + * This receives the client that the integration was installed for as third argument. + */ + processEvent?(event: Event, hint: EventHint | undefined, client: Client): Event | null | PromiseLike; } From d8b879af8f36d4f88edbe51b85c3a3b394345eee Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 15 Sep 2023 10:13:20 +0200 Subject: [PATCH 06/20] ref(node-experimental): Patch `startTransaction` for breadcrumbs (#9010) This refactors the code for keeping breadcrumbs on transactions in node-experimental to instead patch `startTransaction` on the hub. --- .../src/sdk/hubextensions.ts | 80 ++++++++++++++++++ packages/node-experimental/src/sdk/scope.ts | 82 ++----------------- packages/node-experimental/src/types.ts | 16 ++++ 3 files changed, 101 insertions(+), 77 deletions(-) create mode 100644 packages/node-experimental/src/sdk/hubextensions.ts diff --git a/packages/node-experimental/src/sdk/hubextensions.ts b/packages/node-experimental/src/sdk/hubextensions.ts new file mode 100644 index 000000000000..4971226fee01 --- /dev/null +++ b/packages/node-experimental/src/sdk/hubextensions.ts @@ -0,0 +1,80 @@ +import type { startTransaction } from '@sentry/core'; +import { addTracingExtensions as _addTracingExtensions, getMainCarrier } from '@sentry/core'; +import type { Breadcrumb, Hub, Transaction } from '@sentry/types'; +import { dateTimestampInSeconds } from '@sentry/utils'; + +import type { TransactionWithBreadcrumbs } from '../types'; + +const DEFAULT_MAX_BREADCRUMBS = 100; + +/** + * Add tracing extensions, ensuring a patched `startTransaction` to work with OTEL. + */ +export function addTracingExtensions(): void { + _addTracingExtensions(); + + const carrier = getMainCarrier(); + if (!carrier.__SENTRY__) { + return; + } + + carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {}; + if (carrier.__SENTRY__.extensions.startTransaction) { + carrier.__SENTRY__.extensions.startTransaction = getPatchedStartTransaction( + carrier.__SENTRY__.extensions.startTransaction as typeof startTransaction, + ); + } +} + +/** + * We patch the `startTransaction` function to ensure we create a `TransactionWithBreadcrumbs` instead of a regular `Transaction`. + */ +function getPatchedStartTransaction(_startTransaction: typeof startTransaction): typeof startTransaction { + return function (this: Hub, ...args) { + const transaction = _startTransaction.apply(this, args); + + return patchTransaction(transaction); + }; +} + +function patchTransaction(transaction: Transaction): TransactionWithBreadcrumbs { + return new Proxy(transaction as TransactionWithBreadcrumbs, { + get(target, prop, receiver) { + if (prop === 'addBreadcrumb') { + return addBreadcrumb; + } + if (prop === 'getBreadcrumbs') { + return getBreadcrumbs; + } + if (prop === '_breadcrumbs') { + const breadcrumbs = Reflect.get(target, prop, receiver); + return breadcrumbs || []; + } + return Reflect.get(target, prop, receiver); + }, + }); +} + +/** Add a breadcrumb to a transaction. */ +function addBreadcrumb(this: TransactionWithBreadcrumbs, breadcrumb: Breadcrumb, maxBreadcrumbs?: number): void { + const maxCrumbs = typeof maxBreadcrumbs === 'number' ? maxBreadcrumbs : DEFAULT_MAX_BREADCRUMBS; + + // No data has been changed, so don't notify scope listeners + if (maxCrumbs <= 0) { + return; + } + + const mergedBreadcrumb = { + timestamp: dateTimestampInSeconds(), + ...breadcrumb, + }; + + const breadcrumbs = this._breadcrumbs; + breadcrumbs.push(mergedBreadcrumb); + this._breadcrumbs = breadcrumbs.length > maxCrumbs ? breadcrumbs.slice(-maxCrumbs) : breadcrumbs; +} + +/** Get all breadcrumbs from a transaction. */ +function getBreadcrumbs(this: TransactionWithBreadcrumbs): Breadcrumb[] { + return this._breadcrumbs; +} diff --git a/packages/node-experimental/src/sdk/scope.ts b/packages/node-experimental/src/sdk/scope.ts index ab255f1d20bc..12fcc6862904 100644 --- a/packages/node-experimental/src/sdk/scope.ts +++ b/packages/node-experimental/src/sdk/scope.ts @@ -1,26 +1,9 @@ import { Scope } from '@sentry/core'; -import type { Breadcrumb, Transaction } from '@sentry/types'; -import { dateTimestampInSeconds } from '@sentry/utils'; +import type { Breadcrumb } from '@sentry/types'; +import type { TransactionWithBreadcrumbs } from '../types'; import { getActiveSpan } from './trace'; -const DEFAULT_MAX_BREADCRUMBS = 100; - -/** - * This is a fork of the base Transaction with OTEL specific stuff added. - * Note that we do not solve this via an actual subclass, but by wrapping this in a proxy when we need it - - * as we can't easily control all the places a transaction may be created. - */ -interface TransactionWithBreadcrumbs extends Transaction { - _breadcrumbs: Breadcrumb[]; - - /** Get all breadcrumbs added to this transaction. */ - getBreadcrumbs(): Breadcrumb[]; - - /** Add a breadcrumb to this transaction. */ - addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): void; -} - /** A fork of the classic scope with some otel specific stuff. */ export class OtelScope extends Scope { /** @@ -54,7 +37,7 @@ export class OtelScope extends Scope { public addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): this { const transaction = getActiveTransaction(); - if (transaction) { + if (transaction && transaction.addBreadcrumb) { transaction.addBreadcrumb(breadcrumb, maxBreadcrumbs); return this; } @@ -67,7 +50,7 @@ export class OtelScope extends Scope { */ protected _getBreadcrumbs(): Breadcrumb[] { const transaction = getActiveTransaction(); - const transactionBreadcrumbs = transaction ? transaction.getBreadcrumbs() : []; + const transactionBreadcrumbs = transaction && transaction.getBreadcrumbs ? transaction.getBreadcrumbs() : []; return this._breadcrumbs.concat(transactionBreadcrumbs); } @@ -79,60 +62,5 @@ export class OtelScope extends Scope { */ function getActiveTransaction(): TransactionWithBreadcrumbs | undefined { const activeSpan = getActiveSpan(); - const transaction = activeSpan && activeSpan.transaction; - - if (!transaction) { - return undefined; - } - - if (transactionHasBreadcrumbs(transaction)) { - return transaction; - } - - return new Proxy(transaction as TransactionWithBreadcrumbs, { - get(target, prop, receiver) { - if (prop === 'addBreadcrumb') { - return addBreadcrumb; - } - if (prop === 'getBreadcrumbs') { - return getBreadcrumbs; - } - if (prop === '_breadcrumbs') { - const breadcrumbs = Reflect.get(target, prop, receiver); - return breadcrumbs || []; - } - return Reflect.get(target, prop, receiver); - }, - }); -} - -function transactionHasBreadcrumbs(transaction: Transaction): transaction is TransactionWithBreadcrumbs { - return ( - typeof (transaction as TransactionWithBreadcrumbs).getBreadcrumbs === 'function' && - typeof (transaction as TransactionWithBreadcrumbs).addBreadcrumb === 'function' - ); -} - -/** Add a breadcrumb to a transaction. */ -function addBreadcrumb(this: TransactionWithBreadcrumbs, breadcrumb: Breadcrumb, maxBreadcrumbs?: number): void { - const maxCrumbs = typeof maxBreadcrumbs === 'number' ? maxBreadcrumbs : DEFAULT_MAX_BREADCRUMBS; - - // No data has been changed, so don't notify scope listeners - if (maxCrumbs <= 0) { - return; - } - - const mergedBreadcrumb = { - timestamp: dateTimestampInSeconds(), - ...breadcrumb, - }; - - const breadcrumbs = this._breadcrumbs; - breadcrumbs.push(mergedBreadcrumb); - this._breadcrumbs = breadcrumbs.length > maxCrumbs ? breadcrumbs.slice(-maxCrumbs) : breadcrumbs; -} - -/** Get all breadcrumbs from a transaction. */ -function getBreadcrumbs(this: TransactionWithBreadcrumbs): Breadcrumb[] { - return this._breadcrumbs; + return activeSpan && (activeSpan.transaction as TransactionWithBreadcrumbs | undefined); } diff --git a/packages/node-experimental/src/types.ts b/packages/node-experimental/src/types.ts index 107cfdb37266..65e3d905be72 100644 --- a/packages/node-experimental/src/types.ts +++ b/packages/node-experimental/src/types.ts @@ -1,5 +1,6 @@ import type { Tracer } from '@opentelemetry/api'; import type { NodeClient, NodeOptions } from '@sentry/node'; +import type { Breadcrumb, Transaction } from '@sentry/types'; export type NodeExperimentalOptions = NodeOptions; export type NodeExperimentalClientOptions = ConstructorParameters[0]; @@ -8,3 +9,18 @@ export interface NodeExperimentalClient extends NodeClient { tracer: Tracer; getOptions(): NodeExperimentalClientOptions; } + +/** + * This is a fork of the base Transaction with OTEL specific stuff added. + * Note that we do not solve this via an actual subclass, but by wrapping this in a proxy when we need it - + * as we can't easily control all the places a transaction may be created. + */ +export interface TransactionWithBreadcrumbs extends Transaction { + _breadcrumbs: Breadcrumb[]; + + /** Get all breadcrumbs added to this transaction. */ + getBreadcrumbs(): Breadcrumb[]; + + /** Add a breadcrumb to this transaction. */ + addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): void; +} From 042e7ce168952621b3d6bf2c0bafd0b7471c5f94 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 15 Sep 2023 13:52:52 +0200 Subject: [PATCH 07/20] fix(utils): Try-catch monkeypatching to handle frozen objects/functions (#9031) This try-catches any monkeypatching we do on objects, to avoid us throwing e.g. if trying to patch a frozen object. Obv. the monkey patching will _not_ actually work then, but I guess it's better to not wrap stuff than to error out in userland. I also added some tests for this! Fixes https://github.com/getsentry/sentry-javascript/issues/9030 --- .../instrumentation/setTimeout/subject.js | 5 + .../instrumentation/setTimeout/test.ts | 24 ++++ .../instrumentation/setTimeoutFrozen/init.js | 8 ++ .../setTimeoutFrozen/subject.js | 5 + .../instrumentation/setTimeoutFrozen/test.ts | 48 ++++++++ packages/browser/test/unit/index.test.ts | 11 ++ packages/utils/src/object.ts | 32 +++--- packages/utils/test/object.test.ts | 104 +++++++++++++++++- 8 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/subject.js create mode 100644 packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/test.ts create mode 100644 packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/init.js create mode 100644 packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/subject.js create mode 100644 packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/test.ts diff --git a/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/subject.js b/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/subject.js new file mode 100644 index 000000000000..cf5df02445bd --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/subject.js @@ -0,0 +1,5 @@ +function callback() { + throw new Error('setTimeout_error'); +} + +setTimeout(callback, 0); diff --git a/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/test.ts b/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/test.ts new file mode 100644 index 000000000000..808e15285cb1 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeout/test.ts @@ -0,0 +1,24 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('Instrumentation should capture errors in setTimeout', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'setTimeout_error', + mechanism: { + type: 'instrument', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }); +}); diff --git a/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/init.js b/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/init.js new file mode 100644 index 000000000000..573e4fdcb621 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + debug: true, +}); diff --git a/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/subject.js b/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/subject.js new file mode 100644 index 000000000000..29c9ca1f7594 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/subject.js @@ -0,0 +1,5 @@ +function callback() { + throw new Error('setTimeout_error'); +} + +setTimeout(Object.freeze(callback), 0); diff --git a/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/test.ts b/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/test.ts new file mode 100644 index 000000000000..91e82f8b1dcd --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/instrumentation/setTimeoutFrozen/test.ts @@ -0,0 +1,48 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest( + 'Instrumentation does not fail when using frozen callback for setTimeout', + async ({ getLocalTestPath, page }) => { + const bundleKey = process.env.PW_BUNDLE || ''; + const hasDebug = !bundleKey.includes('_min'); + + const url = await getLocalTestPath({ testDir: __dirname }); + + const logMessages: string[] = []; + + page.on('console', msg => { + logMessages.push(msg.text()); + }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + // It still captures the error + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'setTimeout_error', + mechanism: { + type: 'instrument', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }); + + // We only care about the message when debug is enabled + if (hasDebug) { + expect(logMessages).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'Sentry Logger [log]: Failed to add non-enumerable property "__sentry_wrapped__" to object function callback()', + ), + ]), + ); + } + }, +); diff --git a/packages/browser/test/unit/index.test.ts b/packages/browser/test/unit/index.test.ts index 95985a2a69d2..0b0df3c4300f 100644 --- a/packages/browser/test/unit/index.test.ts +++ b/packages/browser/test/unit/index.test.ts @@ -1,4 +1,5 @@ import { getReportDialogEndpoint, SDK_VERSION } from '@sentry/core'; +import type { WrappedFunction } from '@sentry/types'; import * as utils from '@sentry/utils'; import type { Event } from '../../src'; @@ -391,4 +392,14 @@ describe('wrap()', () => { expect(result2).toBe(42); }); + + it('should ignore frozen functions', () => { + const func = Object.freeze(() => 42); + + // eslint-disable-next-line deprecation/deprecation + wrap(func); + + expect(func()).toBe(42); + expect((func as WrappedFunction).__sentry_wrapped__).toBeUndefined(); + }); }); diff --git a/packages/utils/src/object.ts b/packages/utils/src/object.ts index 3a22e2aa49a3..e705214f950d 100644 --- a/packages/utils/src/object.ts +++ b/packages/utils/src/object.ts @@ -4,6 +4,7 @@ import type { WrappedFunction } from '@sentry/types'; import { htmlTreeAsString } from './browser'; import { isElement, isError, isEvent, isInstanceOf, isPlainObject, isPrimitive } from './is'; +import { logger } from './logger'; import { truncate } from './string'; /** @@ -28,12 +29,7 @@ export function fill(source: { [key: string]: any }, name: string, replacementFa // Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work // otherwise it'll throw "TypeError: Object.defineProperties called on non-object" if (typeof wrapped === 'function') { - try { - markFunctionWrapped(wrapped, original); - } catch (_Oo) { - // This can throw if multiple fill happens on a global object like XMLHttpRequest - // Fixes https://github.com/getsentry/sentry-javascript/issues/2043 - } + markFunctionWrapped(wrapped, original); } source[name] = wrapped; @@ -47,12 +43,16 @@ export function fill(source: { [key: string]: any }, name: string, replacementFa * @param value The value to which to set the property */ export function addNonEnumerableProperty(obj: { [key: string]: unknown }, name: string, value: unknown): void { - Object.defineProperty(obj, name, { - // enumerable: false, // the default, so we can save on bundle size by not explicitly setting it - value: value, - writable: true, - configurable: true, - }); + try { + Object.defineProperty(obj, name, { + // enumerable: false, // the default, so we can save on bundle size by not explicitly setting it + value: value, + writable: true, + configurable: true, + }); + } catch (o_O) { + __DEBUG_BUILD__ && logger.log(`Failed to add non-enumerable property "${name}" to object`, obj); + } } /** @@ -63,9 +63,11 @@ export function addNonEnumerableProperty(obj: { [key: string]: unknown }, name: * @param original the original function that gets wrapped */ export function markFunctionWrapped(wrapped: WrappedFunction, original: WrappedFunction): void { - const proto = original.prototype || {}; - wrapped.prototype = original.prototype = proto; - addNonEnumerableProperty(wrapped, '__sentry_original__', original); + try { + const proto = original.prototype || {}; + wrapped.prototype = original.prototype = proto; + addNonEnumerableProperty(wrapped, '__sentry_original__', original); + } catch (o_O) {} // eslint-disable-line no-empty } /** diff --git a/packages/utils/test/object.test.ts b/packages/utils/test/object.test.ts index 3dad523ae4ce..5deac39e8cbc 100644 --- a/packages/utils/test/object.test.ts +++ b/packages/utils/test/object.test.ts @@ -2,7 +2,17 @@ * @jest-environment jsdom */ -import { dropUndefinedKeys, extractExceptionKeysForMessage, fill, objectify, urlEncode } from '../src/object'; +import type { WrappedFunction } from '@sentry/types'; + +import { + addNonEnumerableProperty, + dropUndefinedKeys, + extractExceptionKeysForMessage, + fill, + markFunctionWrapped, + objectify, + urlEncode, +} from '../src/object'; import { testOnlyIfNodeVersionAtLeast } from './testutils'; describe('fill()', () => { @@ -315,3 +325,95 @@ describe('objectify()', () => { expect(objectifiedNonPrimtive).toBe(notAPrimitive); }); }); + +describe('addNonEnumerableProperty', () => { + it('works with a plain object', () => { + const obj: { foo?: string } = {}; + addNonEnumerableProperty(obj, 'foo', 'bar'); + expect(obj.foo).toBe('bar'); + }); + + it('works with a class', () => { + class MyClass { + public foo?: string; + } + const obj = new MyClass(); + addNonEnumerableProperty(obj as any, 'foo', 'bar'); + expect(obj.foo).toBe('bar'); + }); + + it('works with a function', () => { + const func = jest.fn(); + addNonEnumerableProperty(func as any, 'foo', 'bar'); + expect((func as any).foo).toBe('bar'); + func(); + expect(func).toHaveBeenCalledTimes(1); + }); + + it('works with an existing property object', () => { + const obj = { foo: 'before' }; + addNonEnumerableProperty(obj, 'foo', 'bar'); + expect(obj.foo).toBe('bar'); + }); + + it('works with an existing readonly property object', () => { + const obj = { foo: 'before' }; + + Object.defineProperty(obj, 'foo', { + value: 'defined', + writable: false, + }); + + addNonEnumerableProperty(obj, 'foo', 'bar'); + expect(obj.foo).toBe('bar'); + }); + + it('does not error with a frozen object', () => { + const obj = Object.freeze({ foo: 'before' }); + + addNonEnumerableProperty(obj, 'foo', 'bar'); + expect(obj.foo).toBe('before'); + }); +}); + +describe('markFunctionWrapped', () => { + it('works with a function', () => { + const originalFunc = jest.fn(); + const wrappedFunc = jest.fn(); + markFunctionWrapped(wrappedFunc, originalFunc); + + expect((wrappedFunc as WrappedFunction).__sentry_original__).toBe(originalFunc); + + wrappedFunc(); + + expect(wrappedFunc).toHaveBeenCalledTimes(1); + expect(originalFunc).not.toHaveBeenCalled(); + }); + + it('works with a frozen original function', () => { + const originalFunc = Object.freeze(jest.fn()); + const wrappedFunc = jest.fn(); + markFunctionWrapped(wrappedFunc, originalFunc); + + expect((wrappedFunc as WrappedFunction).__sentry_original__).toBe(originalFunc); + + wrappedFunc(); + + expect(wrappedFunc).toHaveBeenCalledTimes(1); + expect(originalFunc).not.toHaveBeenCalled(); + }); + + it('works with a frozen wrapped function', () => { + const originalFunc = Object.freeze(jest.fn()); + const wrappedFunc = Object.freeze(jest.fn()); + markFunctionWrapped(wrappedFunc, originalFunc); + + // Skips adding the property, but also doesn't error + expect((wrappedFunc as WrappedFunction).__sentry_original__).toBe(undefined); + + wrappedFunc(); + + expect(wrappedFunc).toHaveBeenCalledTimes(1); + expect(originalFunc).not.toHaveBeenCalled(); + }); +}); From 4a4366dfc49a3a14733f8017ab4fafc56d5b6fae Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 15 Sep 2023 18:25:32 +0200 Subject: [PATCH 08/20] feat(node): Improve non-error messages (#9026) --- .../captureException/empty-obj/test.ts | 2 +- .../integration/test/server/action.test.ts | 8 ++--- packages/utils/src/eventbuilder.ts | 25 ++++++++++++--- packages/utils/test/eventbuilder.test.ts | 32 +++++++++++++++++++ 4 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 packages/utils/test/eventbuilder.test.ts diff --git a/packages/node-integration-tests/suites/public-api/captureException/empty-obj/test.ts b/packages/node-integration-tests/suites/public-api/captureException/empty-obj/test.ts index b30b64c426fd..37d5ed8b9855 100644 --- a/packages/node-integration-tests/suites/public-api/captureException/empty-obj/test.ts +++ b/packages/node-integration-tests/suites/public-api/captureException/empty-obj/test.ts @@ -9,7 +9,7 @@ test('should capture an empty object', async () => { values: [ { type: 'Error', - value: 'Non-Error exception captured with keys: [object has no keys]', + value: 'Object captured as exception with keys: [object has no keys]', mechanism: { type: 'generic', handled: true, diff --git a/packages/remix/test/integration/test/server/action.test.ts b/packages/remix/test/integration/test/server/action.test.ts index af48c99777ce..fdeb70962a3e 100644 --- a/packages/remix/test/integration/test/server/action.test.ts +++ b/packages/remix/test/integration/test/server/action.test.ts @@ -304,8 +304,8 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada { type: 'Error', value: useV2 - ? 'Non-Error exception captured with keys: data, internal, status, statusText' - : 'Non-Error exception captured with keys: data', + ? 'Object captured as exception with keys: data, internal, status, statusText' + : 'Object captured as exception with keys: data', stacktrace: expect.any(Object), mechanism: { data: { @@ -412,8 +412,8 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada { type: 'Error', value: useV2 - ? 'Non-Error exception captured with keys: data, internal, status, statusText' - : 'Non-Error exception captured with keys: [object has no keys]', + ? 'Object captured as exception with keys: data, internal, status, statusText' + : 'Object captured as exception with keys: [object has no keys]', stacktrace: expect.any(Object), mechanism: { data: { diff --git a/packages/utils/src/eventbuilder.ts b/packages/utils/src/eventbuilder.ts index 01e217921d87..03af0b3d1905 100644 --- a/packages/utils/src/eventbuilder.ts +++ b/packages/utils/src/eventbuilder.ts @@ -39,6 +39,26 @@ export function exceptionFromError(stackParser: StackParser, error: Error): Exce return exception; } +function getMessageForObject(exception: object): string { + if ('name' in exception && typeof exception.name === 'string') { + let message = `'${exception.name}' captured as exception`; + + if ('message' in exception && typeof exception.message === 'string') { + message += ` with message '${exception.message}'`; + } + + return message; + } else if ('message' in exception && typeof exception.message === 'string') { + return exception.message; + } else { + // This will allow us to group events based on top-level keys + // which is much better than creating new group when any key/value change + return `Object captured as exception with keys: ${extractExceptionKeysForMessage( + exception as Record, + )}`; + } +} + /** * Builds and Event from a Exception * @hidden @@ -59,10 +79,6 @@ export function eventFromUnknownInput( if (!isError(exception)) { if (isPlainObject(exception)) { - // This will allow us to group events based on top-level keys - // which is much better than creating new group when any key/value change - const message = `Non-Error exception captured with keys: ${extractExceptionKeysForMessage(exception)}`; - const hub = getCurrentHub(); const client = hub.getClient(); const normalizeDepth = client && client.getOptions().normalizeDepth; @@ -70,6 +86,7 @@ export function eventFromUnknownInput( scope.setExtra('__serialized__', normalizeToSize(exception, normalizeDepth)); }); + const message = getMessageForObject(exception); ex = (hint && hint.syntheticException) || new Error(message); (ex as Error).message = message; } else { diff --git a/packages/utils/test/eventbuilder.test.ts b/packages/utils/test/eventbuilder.test.ts new file mode 100644 index 000000000000..137860b16ce4 --- /dev/null +++ b/packages/utils/test/eventbuilder.test.ts @@ -0,0 +1,32 @@ +import type { Hub } from '@sentry/types'; + +import { createStackParser, eventFromUnknownInput, nodeStackLineParser } from '../src'; + +function getCurrentHub(): Hub { + // Some fake hub to get us through + return { getClient: () => undefined, configureScope: () => {} } as unknown as Hub; +} + +const stackParser = createStackParser(nodeStackLineParser()); + +describe('eventFromUnknownInput', () => { + test('object with useless props', () => { + const event = eventFromUnknownInput(getCurrentHub, stackParser, { foo: { bar: 'baz' }, prop: 1 }); + expect(event.exception?.values?.[0].value).toBe('Object captured as exception with keys: foo, prop'); + }); + + test('object with name prop', () => { + const event = eventFromUnknownInput(getCurrentHub, stackParser, { foo: { bar: 'baz' }, name: 'BadType' }); + expect(event.exception?.values?.[0].value).toBe("'BadType' captured as exception"); + }); + + test('object with name and message props', () => { + const event = eventFromUnknownInput(getCurrentHub, stackParser, { message: 'went wrong', name: 'BadType' }); + expect(event.exception?.values?.[0].value).toBe("'BadType' captured as exception with message 'went wrong'"); + }); + + test('object with message prop', () => { + const event = eventFromUnknownInput(getCurrentHub, stackParser, { foo: { bar: 'baz' }, message: 'Some message' }); + expect(event.exception?.values?.[0].value).toBe('Some message'); + }); +}); From d32b55042239eceb46653322153ec32ecebf5fea Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Tue, 19 Sep 2023 00:02:10 +0200 Subject: [PATCH 09/20] feat: Add Bun SDK (#9029) Co-authored-by: Abhijeet Prasad --- .github/workflows/build.yml | 29 ++++++ package.json | 4 +- packages/bun/.eslintrc.js | 12 +++ packages/bun/LICENSE | 14 +++ packages/bun/README.md | 78 +++++++++++++++ packages/bun/jest.config.js | 1 + packages/bun/package.json | 71 ++++++++++++++ packages/bun/rollup.npm.config.js | 6 ++ packages/bun/scripts/install-bun.js | 49 ++++++++++ packages/bun/src/client.ts | 40 ++++++++ packages/bun/src/index.ts | 77 +++++++++++++++ packages/bun/src/sdk.ts | 94 +++++++++++++++++++ packages/bun/src/transports/index.ts | 38 ++++++++ packages/bun/src/types.ts | 68 ++++++++++++++ packages/bun/test/sdk.test.ts | 11 +++ packages/bun/tsconfig.json | 24 +++++ packages/bun/tsconfig.test.json | 12 +++ packages/bun/tsconfig.types.json | 10 ++ .../e2e-tests/verdaccio-config/config.yaml | 6 ++ scripts/node-unit-tests.ts | 1 + yarn.lock | 5 + 21 files changed, 649 insertions(+), 1 deletion(-) create mode 100644 packages/bun/.eslintrc.js create mode 100644 packages/bun/LICENSE create mode 100644 packages/bun/README.md create mode 100644 packages/bun/jest.config.js create mode 100644 packages/bun/package.json create mode 100644 packages/bun/rollup.npm.config.js create mode 100644 packages/bun/scripts/install-bun.js create mode 100644 packages/bun/src/client.ts create mode 100644 packages/bun/src/index.ts create mode 100644 packages/bun/src/sdk.ts create mode 100644 packages/bun/src/transports/index.ts create mode 100644 packages/bun/src/types.ts create mode 100644 packages/bun/test/sdk.test.ts create mode 100644 packages/bun/tsconfig.json create mode 100644 packages/bun/tsconfig.test.json create mode 100644 packages/bun/tsconfig.types.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6dc1f35ae8ba..cbc80ec59f33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -391,6 +391,34 @@ jobs: - name: Compute test coverage uses: codecov/codecov-action@v3 + job_bun_unit_tests: + name: Bun 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@v3 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + - name: Set up Bun + uses: oven-sh/setup-bun@v1 + - name: Restore caches + uses: ./.github/actions/restore-cache + env: + DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Run tests + run: | + yarn test-ci-bun + - 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] @@ -864,6 +892,7 @@ jobs: job_build, job_browser_build_tests, job_browser_unit_tests, + job_bun_unit_tests, job_node_unit_tests, job_nextjs_integration_test, job_node_integration_tests, diff --git a/package.json b/package.json index 849d79b3628a..8c50e83c69b0 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,9 @@ "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/{node,node-experimental,opentelemetry-node,serverless,nextjs,remix,gatsby,sveltekit}\" --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,overhead-metrics}\"", + "test-ci-browser": "lerna run test --ignore \"@sentry/{bun,node,node-experimental,opentelemetry-node,serverless,nextjs,remix,gatsby,sveltekit}\" --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", "yalc:publish": "lerna run yalc:publish" }, @@ -41,6 +42,7 @@ "packages/angular-ivy", "packages/browser", "packages/browser-integration-tests", + "packages/bun", "packages/core", "packages/e2e-tests", "packages/ember", diff --git a/packages/bun/.eslintrc.js b/packages/bun/.eslintrc.js new file mode 100644 index 000000000000..bec6469d0e28 --- /dev/null +++ b/packages/bun/.eslintrc.js @@ -0,0 +1,12 @@ +module.exports = { + env: { + node: true, + }, + extends: ['../../.eslintrc.js'], + 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', + }, +}; diff --git a/packages/bun/LICENSE b/packages/bun/LICENSE new file mode 100644 index 000000000000..d11896ba1181 --- /dev/null +++ b/packages/bun/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/bun/README.md b/packages/bun/README.md new file mode 100644 index 000000000000..170059f193f5 --- /dev/null +++ b/packages/bun/README.md @@ -0,0 +1,78 @@ +

+ + Sentry + +

+ +# Official Sentry SDK for Bun (Beta) + +[![npm version](https://img.shields.io/npm/v/@sentry/bun.svg)](https://www.npmjs.com/package/@sentry/bun) +[![npm dm](https://img.shields.io/npm/dm/@sentry/bun.svg)](https://www.npmjs.com/package/@sentry/bun) +[![npm dt](https://img.shields.io/npm/dt/@sentry/bun.svg)](https://www.npmjs.com/package/@sentry/bun) + +## Links + +- [Official SDK Docs](https://docs.sentry.io/quickstart/) +- [TypeDoc](http://getsentry.github.io/sentry-javascript/) + +The Sentry Bun 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 `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 +// ES5 Syntax +const Sentry = require('@sentry/bun'); +// ES6 Syntax +import * as Sentry from '@sentry/bun'; + +Sentry.init({ + dsn: '__DSN__', + // ... +}); +``` + +To set context information or send manual events, use the exported functions of `@sentry/bun`. 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: [ + // ... + ], +}); +``` + +It's not possible to capture unhandled exceptions, unhandled promise rejections now - Bun is working on adding support for it. +[Github Issue](https://github.com/oven-sh/bun/issues/5091) follow this issue. To report errors to Sentry, you have to manually try-catch and call `Sentry.captureException` in the catch block. + +```ts +import * as Sentry from '@sentry/bun'; + +try { + throw new Error('test'); +} catch (e) { + Sentry.captureException(e); +} +``` + diff --git a/packages/bun/jest.config.js b/packages/bun/jest.config.js new file mode 100644 index 000000000000..24f49ab59a4c --- /dev/null +++ b/packages/bun/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../jest/jest.config.js'); diff --git a/packages/bun/package.json b/packages/bun/package.json new file mode 100644 index 000000000000..00ef7f675c53 --- /dev/null +++ b/packages/bun/package.json @@ -0,0 +1,71 @@ +{ + "name": "@sentry/bun", + "version": "7.69.0", + "description": "Official Sentry SDK for bun", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bun", + "author": "Sentry", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "main": "build/esm/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { + "build/npm/types/index.d.ts": [ + "build/npm/types-ts3.8/index.d.ts" + ] + } + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sentry/core": "7.69.0", + "@sentry/node": "7.69.0", + "@sentry/types": "7.69.0", + "@sentry/utils": "7.69.0" + }, + "devDependencies": { + "bun-types": "latest" + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.js", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", + "build:watch": "run-p build:transpile:watch build:types:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", + "build:types:watch": "tsc -p tsconfig.types.json --watch", + "build:tarball": "ts-node ../../scripts/prepack.ts && npm pack ./build", + "circularDepCheck": "madge --circular src/index.ts", + "clean": "rimraf build coverage sentry-node-*.tgz", + "fix": "run-s fix:eslint fix:prettier", + "fix:eslint": "eslint . --format stylish --fix", + "fix:prettier": "prettier --write \"{src,test,scripts}/**/**.ts\"", + "lint": "run-s lint:prettier lint:eslint", + "lint:eslint": "eslint . --format stylish", + "lint:prettier": "prettier --check \"{src,test,scripts}/**/**.ts\"", + "install:bun": "node ./scripts/install-bun.js", + "test": "run-s install:bun test:bun", + "test:bun": "bun test", + "test:watch": "bun test --watch", + "yalc:publish": "ts-node ../../scripts/prepack.ts && yalc publish build --push" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false, + "madge":{ + "detectiveOptions": { + "ts": { + "skipTypeImports": true + } + } + } +} diff --git a/packages/bun/rollup.npm.config.js b/packages/bun/rollup.npm.config.js new file mode 100644 index 000000000000..ebbeb7063089 --- /dev/null +++ b/packages/bun/rollup.npm.config.js @@ -0,0 +1,6 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js'; + +const config = makeNPMConfigVariants(makeBaseNPMConfig()); + +// remove cjs from config array config[0].output.format == cjs +export default [config[1]]; diff --git a/packages/bun/scripts/install-bun.js b/packages/bun/scripts/install-bun.js new file mode 100644 index 000000000000..33e798b0a6d6 --- /dev/null +++ b/packages/bun/scripts/install-bun.js @@ -0,0 +1,49 @@ +/* eslint-disable no-console */ +if (process.env.CI) { + // This script is not needed in CI we install bun via GH actions + return; +} +const { exec } = require('child_process'); +const https = require('https'); + +// Define the URL of the Bash script for bun installation +const installScriptUrl = 'https://bun.sh/install'; + +// Check if bun is installed +exec('bun -version', error => { + if (error) { + console.error('bun is not installed. Installing...'); + // Download and execute the installation script + https + .get(installScriptUrl, res => { + if (res.statusCode !== 200) { + console.error(`Failed to download the installation script (HTTP ${res.statusCode})`); + process.exit(1); + } + + res.setEncoding('utf8'); + let scriptData = ''; + + res.on('data', chunk => { + scriptData += chunk; + }); + + res.on('end', () => { + // Execute the downloaded script + exec(scriptData, installError => { + if (installError) { + console.error('Failed to install bun:', installError); + process.exit(1); + } + console.log('bun has been successfully installed.'); + }); + }); + }) + .on('error', e => { + console.error('Failed to download the installation script:', e); + process.exit(1); + }); + } else { + // Bun is installed + } +}); diff --git a/packages/bun/src/client.ts b/packages/bun/src/client.ts new file mode 100644 index 000000000000..b8cebbe7463e --- /dev/null +++ b/packages/bun/src/client.ts @@ -0,0 +1,40 @@ +import type { ServerRuntimeClientOptions } from '@sentry/core'; +import { SDK_VERSION, ServerRuntimeClient } from '@sentry/core'; +import * as os from 'os'; + +import type { BunClientOptions } from './types'; + +/** + * The Sentry Bun SDK Client. + * + * @see BunClientOptions for documentation on configuration options. + * @see SentryClient for usage documentation. + */ +export class BunClient extends ServerRuntimeClient { + /** + * Creates a new Bun SDK instance. + * @param options Configuration options for this SDK. + */ + public constructor(options: BunClientOptions) { + options._metadata = options._metadata || {}; + options._metadata.sdk = options._metadata.sdk || { + name: 'sentry.javascript.bun', + packages: [ + { + name: 'npm:@sentry/bun', + version: SDK_VERSION, + }, + ], + version: SDK_VERSION, + }; + + const clientOptions: ServerRuntimeClientOptions = { + ...options, + platform: 'bun', + runtime: { name: 'bun', version: Bun.version }, + serverName: options.serverName || global.process.env.SENTRY_NAME || os.hostname(), + }; + + super(clientOptions); + } +} diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts new file mode 100644 index 000000000000..7be879568dec --- /dev/null +++ b/packages/bun/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 { TransactionNamingScheme } from '@sentry/node'; +export type { BunOptions } 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 { autoDiscoverNodePerformanceMonitoringIntegrations } from '@sentry/node'; + +export { BunClient } from './client'; +export { defaultIntegrations, init } from './sdk'; + +import { Integrations as CoreIntegrations } from '@sentry/core'; +import { Integrations as NodeIntegrations } from '@sentry/node'; + +const INTEGRATIONS = { + ...CoreIntegrations, + ...NodeIntegrations, +}; + +export { INTEGRATIONS as Integrations }; diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts new file mode 100644 index 000000000000..11c7827b3faf --- /dev/null +++ b/packages/bun/src/sdk.ts @@ -0,0 +1,94 @@ +/* eslint-disable max-lines */ +import { Integrations as CoreIntegrations } from '@sentry/core'; +import { init as initNode, Integrations as NodeIntegrations } from '@sentry/node'; + +import { BunClient } from './client'; +import { makeFetchTransport } from './transports'; +import type { BunOptions } from './types'; + +export const defaultIntegrations = [ + // Common + new CoreIntegrations.InboundFilters(), + new CoreIntegrations.FunctionToString(), + // Native Wrappers + new NodeIntegrations.Console(), + new NodeIntegrations.Http(), + new NodeIntegrations.Undici(), + // Global Handlers # TODO (waiting for https://github.com/oven-sh/bun/issues/5091) + // new NodeIntegrations.OnUncaughtException(), + // new NodeIntegrations.OnUnhandledRejection(), + // Event Info + new NodeIntegrations.ContextLines(), + // new NodeIntegrations.LocalVariables(), # does't work with Bun + new NodeIntegrations.Context(), + new NodeIntegrations.Modules(), + new NodeIntegrations.RequestData(), + // Misc + new NodeIntegrations.LinkedErrors(), +]; + +/** + * The Sentry Bun 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 + * ``` + * + * const { init } = require('@sentry/bun'); + * + * init({ + * dsn: '__DSN__', + * // ... + * }); + * ``` + * + * @example + * ``` + * + * const { configureScope } = require('@sentry/node'); + * configureScope((scope: Scope) => { + * scope.setExtra({ battery: 0.7 }); + * scope.setTag({ user_mode: 'admin' }); + * scope.setUser({ id: '4711' }); + * }); + * ``` + * + * @example + * ``` + * + * const { addBreadcrumb } = require('@sentry/node'); + * addBreadcrumb({ + * message: 'My Breadcrumb', + * // ... + * }); + * ``` + * + * @example + * ``` + * + * const Sentry = require('@sentry/node'); + * Sentry.captureMessage('Hello, world!'); + * Sentry.captureException(new Error('Good bye')); + * Sentry.captureEvent({ + * message: 'Manual', + * stacktrace: [ + * // ... + * ], + * }); + * ``` + * + * @see {@link BunOptions} for documentation on configuration options. + */ +export function init(options: BunOptions = {}): void { + options.clientClass = BunClient; + options.transport = options.transport || makeFetchTransport; + + options.defaultIntegrations = + options.defaultIntegrations === false + ? [] + : [...(Array.isArray(options.defaultIntegrations) ? options.defaultIntegrations : defaultIntegrations)]; + initNode(options); +} diff --git a/packages/bun/src/transports/index.ts b/packages/bun/src/transports/index.ts new file mode 100644 index 000000000000..96e3e99957fb --- /dev/null +++ b/packages/bun/src/transports/index.ts @@ -0,0 +1,38 @@ +import { createTransport } from '@sentry/core'; +import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types'; +import { rejectedSyncPromise } from '@sentry/utils'; + +export interface BunTransportOptions 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: BunTransportOptions): Transport { + 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/bun/src/types.ts b/packages/bun/src/types.ts new file mode 100644 index 000000000000..c62e4fe320e3 --- /dev/null +++ b/packages/bun/src/types.ts @@ -0,0 +1,68 @@ +import type { ClientOptions, Options, TracePropagationTargets } from '@sentry/types'; + +import type { BunClient } from './client'; +import type { BunTransportOptions } from './transports'; + +export interface BaseBunOptions { + /** + * 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; + + /** + * Specify a custom BunClient to be used. Must extend BunClient! + * This is not a public, supported API, but used internally only. + * + * @hidden + * */ + clientClass?: typeof BunClient; + + // 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 Bun SDK + * @see @sentry/types Options for more information. + */ +export interface BunOptions extends Options, BaseBunOptions {} + +/** + * Configuration options for the Sentry Bun SDK Client class + * @see BunClient for more information. + */ +export interface BunClientOptions extends ClientOptions, BaseBunOptions {} diff --git a/packages/bun/test/sdk.test.ts b/packages/bun/test/sdk.test.ts new file mode 100644 index 000000000000..f92ff4c59b13 --- /dev/null +++ b/packages/bun/test/sdk.test.ts @@ -0,0 +1,11 @@ +// eslint-disable-next-line import/no-unresolved +import { expect, test } from 'bun:test'; + +import { init } from '../src/index'; + +test("calling init shouldn't fail", () => { + init({ + dsn: 'https://00000000000000000000000000000000@o000000.ingest.sentry.io/0000000', + }); + expect(true).toBe(true); +}); diff --git a/packages/bun/tsconfig.json b/packages/bun/tsconfig.json new file mode 100644 index 000000000000..70c4bde02040 --- /dev/null +++ b/packages/bun/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src/**/*"], + + "compilerOptions": { + "types": ["bun-types"], + "lib": ["esnext"], + "module": "esnext", + "target": "esnext", + + // if TS 4.x or earlier + "moduleResolution": "nodenext", + + "jsx": "react-jsx", // support JSX + "allowJs": true, // allow importing `.js` from `.ts` + "esModuleInterop": true, // allow default imports for CommonJS modules + + // best practices + "strict": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + } +} diff --git a/packages/bun/tsconfig.test.json b/packages/bun/tsconfig.test.json new file mode 100644 index 000000000000..87f6afa06b86 --- /dev/null +++ b/packages/bun/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["node", "jest"] + + // other package-specific, test-specific options + } +} diff --git a/packages/bun/tsconfig.types.json b/packages/bun/tsconfig.types.json new file mode 100644 index 000000000000..65455f66bd75 --- /dev/null +++ b/packages/bun/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/e2e-tests/verdaccio-config/config.yaml b/packages/e2e-tests/verdaccio-config/config.yaml index ce2ef0bd1ada..05895a1adbed 100644 --- a/packages/e2e-tests/verdaccio-config/config.yaml +++ b/packages/e2e-tests/verdaccio-config/config.yaml @@ -50,6 +50,12 @@ packages: unpublish: $all # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/bun': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/core': access: $all publish: $all diff --git a/scripts/node-unit-tests.ts b/scripts/node-unit-tests.ts index 27238a164d37..835e83c44896 100644 --- a/scripts/node-unit-tests.ts +++ b/scripts/node-unit-tests.ts @@ -13,6 +13,7 @@ const DEFAULT_SKIP_TESTS_PACKAGES = [ '@sentry/svelte', '@sentry/replay', '@sentry/wasm', + '@sentry/bun', ]; // These packages don't support Node 8 for syntax or dependency reasons. diff --git a/yarn.lock b/yarn.lock index a68cf4925469..8b412653caec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9067,6 +9067,11 @@ builtins@^5.0.0, builtins@^5.0.1: dependencies: semver "^7.0.0" +bun-types@latest: + version "1.0.1" + resolved "https://registry.yarnpkg.com/bun-types/-/bun-types-1.0.1.tgz#8bcb10ae3a1548a39f0932fdb365f4b3a649efba" + integrity sha512-7NrXqhMIaNKmWn2dSWEQ50znMZqrN/5Z0NBMXvQTRu/+Y1CvoXRznFy0pnqLe024CeZgVdXoEpARNO1JZLAPGw== + busboy@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" From 252f45018f66e1e22973982ba06e19c77eeea8e1 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 19 Sep 2023 11:02:55 +0200 Subject: [PATCH 10/20] fix(replay): Ensure replay events go through `preprocessEvent` hook (#9034) Replay events also go through global event processors, so they should also go through this hook (replay events are not sent via the regular baseclient methods). --- packages/replay/src/util/prepareReplayEvent.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/replay/src/util/prepareReplayEvent.ts b/packages/replay/src/util/prepareReplayEvent.ts index 522e7e7689bc..4505b5b86f41 100644 --- a/packages/replay/src/util/prepareReplayEvent.ts +++ b/packages/replay/src/util/prepareReplayEvent.ts @@ -1,7 +1,7 @@ import type { Scope } from '@sentry/core'; import { prepareEvent } from '@sentry/core'; import type { IntegrationIndex } from '@sentry/core/build/types/integration'; -import type { Client, ReplayEvent } from '@sentry/types'; +import type { Client, EventHint, ReplayEvent } from '@sentry/types'; /** * Prepare a replay event & enrich it with the SDK metadata. @@ -21,11 +21,19 @@ export async function prepareReplayEvent({ typeof client._integrations === 'object' && client._integrations !== null && !Array.isArray(client._integrations) ? Object.keys(client._integrations) : undefined; + + const eventHint: EventHint = { event_id, integrations }; + + if (client.emit) { + client.emit('preprocessEvent', event, eventHint); + } + const preparedEvent = (await prepareEvent( client.getOptions(), event, - { event_id, integrations }, + eventHint, scope, + client, )) as ReplayEvent | null; // If e.g. a global event processor returned null From 69b308d1c97e8e59fa9088fe369806ebe527323b Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Tue, 19 Sep 2023 14:21:13 +0100 Subject: [PATCH 11/20] feat(remix): Add Remix 2.x release support. (#8940) Co-authored-by: Lukas Stracke --- .github/workflows/build.yml | 1 + .../create-remix-app-v2/.eslintrc.js | 4 + .../create-remix-app-v2/.gitignore | 6 + .../create-remix-app-v2/.npmrc | 2 + .../create-remix-app-v2/README.md | 61 +++++ .../create-remix-app-v2/app/entry.client.tsx | 49 +++++ .../create-remix-app-v2/app/entry.server.tsx | 133 +++++++++++ .../create-remix-app-v2/app/root.tsx | 63 ++++++ .../create-remix-app-v2/app/routes/_index.tsx | 21 ++ .../app/routes/client-error.tsx | 13 ++ .../app/routes/navigate.tsx | 20 ++ .../app/routes/user.$id.tsx | 3 + .../create-remix-app-v2/globals.d.ts | 7 + .../create-remix-app-v2/package.json | 35 +++ .../create-remix-app-v2/playwright.config.ts | 57 +++++ .../create-remix-app-v2/remix.config.js | 9 + .../create-remix-app-v2/remix.env.d.ts | 2 + .../tests/behaviour-client.test.ts | 208 ++++++++++++++++++ .../create-remix-app-v2/tsconfig.json | 22 ++ .../create-remix-app/app/entry.client.tsx | 17 +- .../create-remix-app/app/entry.server.tsx | 6 +- .../create-remix-app/app/root.tsx | 42 +++- .../create-remix-app/app/routes/_index.tsx | 39 ++-- .../app/routes/client-error.tsx | 13 ++ .../create-remix-app/app/routes/navigate.tsx | 20 ++ .../create-remix-app/app/routes/user.$id.tsx | 3 + .../create-remix-app/globals.d.ts | 7 + .../create-remix-app/package.json | 12 +- .../create-remix-app/remix.config.js | 2 + .../tests/behaviour-client.test.ts | 205 ++++++++++++++++- .../app/route-handlers/[param]/edge/route.ts | 2 +- .../app/route-handlers/[param]/error/route.ts | 2 +- packages/remix/package.json | 4 +- packages/remix/src/client/errors.tsx | 31 +-- packages/remix/src/client/performance.tsx | 19 +- packages/remix/src/index.server.ts | 3 +- packages/remix/src/utils/futureFlags.ts | 32 +++ packages/remix/src/utils/instrumentServer.ts | 196 ++++++++++------- packages/remix/src/utils/metadata.ts | 6 +- packages/remix/src/utils/vendor/response.ts | 21 +- .../test/integration/app_v2/entry.server.tsx | 6 +- 41 files changed, 1242 insertions(+), 162 deletions(-) create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/.eslintrc.js create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/.gitignore create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/.npmrc create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/README.md create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.client.tsx create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/app/root.tsx create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/_index.tsx create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/client-error.tsx create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/navigate.tsx create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/user.$id.tsx create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/globals.d.ts create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/package.json create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.ts create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/remix.env.d.ts create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/tests/behaviour-client.test.ts create mode 100644 packages/e2e-tests/test-applications/create-remix-app-v2/tsconfig.json create mode 100644 packages/e2e-tests/test-applications/create-remix-app/app/routes/client-error.tsx create mode 100644 packages/e2e-tests/test-applications/create-remix-app/app/routes/navigate.tsx create mode 100644 packages/e2e-tests/test-applications/create-remix-app/app/routes/user.$id.tsx create mode 100644 packages/e2e-tests/test-applications/create-remix-app/globals.d.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cbc80ec59f33..56f61126a45a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -815,6 +815,7 @@ jobs: 'create-react-app', 'create-next-app', 'create-remix-app', + 'create-remix-app-v2', 'nextjs-app-dir', 'react-create-hash-router', 'react-router-6-use-routes', diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/.eslintrc.js b/packages/e2e-tests/test-applications/create-remix-app-v2/.eslintrc.js new file mode 100644 index 000000000000..f2faf1470fd8 --- /dev/null +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/.eslintrc.js @@ -0,0 +1,4 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'], +}; diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/.gitignore b/packages/e2e-tests/test-applications/create-remix-app-v2/.gitignore new file mode 100644 index 000000000000..3f7bf98da3e1 --- /dev/null +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/.npmrc b/packages/e2e-tests/test-applications/create-remix-app-v2/.npmrc new file mode 100644 index 000000000000..c6b3ef9b3eaa --- /dev/null +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://localhost:4873 +@sentry-internal:registry=http://localhost:4873 diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/README.md b/packages/e2e-tests/test-applications/create-remix-app-v2/README.md new file mode 100644 index 000000000000..54336d746713 --- /dev/null +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/README.md @@ -0,0 +1,61 @@ +# Welcome to Remix! + +- [Remix Docs](https://remix.run/docs) + +## Development + +From your terminal: + +```sh +npm run dev +``` + +This starts your app in development mode, rebuilding assets on file changes. + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `remix build` + +- `build/` +- `public/build/` + +### Using a Template + +When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new +project, then copy over relevant code/assets from your current app to the new project that's pre-configured for your +target server. + +Most importantly, this means everything in the `app/` directory, but if you've further customized your current +application outside of there it may also include: + +- Any assets you've added/updated in `public/` +- Any updated versions of root files such as `.eslintrc.js`, etc. + +```sh +cd .. +# create a new project, and pick a pre-configured host +npx create-remix@latest +cd my-new-remix-app +# remove the new project's app (not the old one!) +rm -rf app +# copy your app over +cp -R ../my-old-remix-app/app app +``` diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.client.tsx b/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.client.tsx new file mode 100644 index 000000000000..605d8e792d23 --- /dev/null +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.client.tsx @@ -0,0 +1,49 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import { startTransition, StrictMode, useEffect } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import * as Sentry from '@sentry/remix'; + +Sentry.init({ + dsn: window.ENV.SENTRY_DSN, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.remixRouterInstrumentation(useEffect, useLocation, useMatches), + }), + new Sentry.Replay(), + ], + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! + // Session Replay + replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. + replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. +}); + +Sentry.addGlobalEventProcessor(event => { + if ( + event.type === 'transaction' && + (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation') + ) { + const eventId = event.event_id; + if (eventId) { + window.recordedTransactions = window.recordedTransactions || []; + window.recordedTransactions.push(eventId); + } + } + + return event; +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx b/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx new file mode 100644 index 000000000000..bce3f38ae7f8 --- /dev/null +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx @@ -0,0 +1,133 @@ +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import { PassThrough } from 'node:stream'; + +import type { AppLoadContext, EntryContext, DataFunctionArgs } from '@remix-run/node'; +import { createReadableStreamFromReadable } from '@remix-run/node'; +import { RemixServer } from '@remix-run/react'; +import { renderToPipeableStream } from 'react-dom/server'; +import * as Sentry from '@sentry/remix'; +import { installGlobals } from '@remix-run/node'; +import isbot from 'isbot'; + +installGlobals(); + +const ABORT_DELAY = 5_000; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! +}); + +export function handleError(error: unknown, { request }: DataFunctionArgs): void { + Sentry.captureRemixServerException(error, 'remix.server', request); +} + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext, +) { + return isbot(request.headers.get('user-agent')) + ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) + : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/app/root.tsx b/packages/e2e-tests/test-applications/create-remix-app-v2/app/root.tsx new file mode 100644 index 000000000000..d4b2c07516eb --- /dev/null +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/app/root.tsx @@ -0,0 +1,63 @@ +import { cssBundleHref } from '@remix-run/css-bundle'; +import { json, LinksFunction } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, + useRouteError, +} from '@remix-run/react'; +import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix'; + +export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : [])]; + +export const loader = () => { + return json({ + ENV: { + SENTRY_DSN: process.env.E2E_TEST_DSN, + }, + }); +}; + +export function ErrorBoundary() { + const error = useRouteError(); + const eventId = captureRemixErrorBoundaryError(error); + + return ( +
+ ErrorBoundary Error + {eventId} +
+ ); +} + +function App() { + const { ENV } = useLoaderData(); + + return ( + + + + +