diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 21cf38ccfbb2..db4d281833af 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -44,9 +44,10 @@ "@sentry/utils": "8.18.0" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20240620.0", + "@cloudflare/workers-types": "^4.20240712.0", + "@types/node": "^14.18.0", "miniflare": "^3.20240701.0", - "wrangler": "^3.63.2" + "wrangler": "^3.64.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/cloudflare/src/async.ts b/packages/cloudflare/src/async.ts new file mode 100644 index 000000000000..9662fc340a7e --- /dev/null +++ b/packages/cloudflare/src/async.ts @@ -0,0 +1,73 @@ +import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; +import type { Scope } from '@sentry/types'; + +// Need to use node: prefix for cloudflare workers compatibility +// Note: Because we are using node:async_hooks, we need to set `node_compat` in the wrangler.toml +import { AsyncLocalStorage } from 'node:async_hooks'; + +/** + * Sets the async context strategy to use AsyncLocalStorage. + * + * AsyncLocalStorage is only avalaible in the cloudflare workers runtime if you set + * compatibility_flags = ["nodejs_compat"] or compatibility_flags = ["nodejs_als"] + */ +export function setAsyncLocalStorageAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage<{ + scope: Scope; + isolationScope: Scope; + }>(); + + function getScopes(): { scope: Scope; isolationScope: Scope } { + const scopes = asyncStorage.getStore(); + + if (scopes) { + return scopes; + } + + // fallback behavior: + // if, for whatever reason, we can't find scopes on the context here, we have to fix this somehow + return { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + }; + } + + function withScope(callback: (scope: Scope) => T): T { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => { + return callback(scope); + }); + } + + function withSetScope(scope: Scope, callback: (scope: Scope) => T): T { + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => { + return callback(scope); + }); + } + + function withIsolationScope(callback: (isolationScope: Scope) => T): T { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => { + return callback(isolationScope); + }); + } + + function withSetIsolationScope(isolationScope: Scope, callback: (isolationScope: Scope) => T): T { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => { + return callback(isolationScope); + }); + } + + setAsyncContextStrategy({ + withScope, + withSetScope, + withIsolationScope, + withSetIsolationScope, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + }); +} diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts new file mode 100644 index 000000000000..8b25d8ae6f87 --- /dev/null +++ b/packages/cloudflare/src/client.ts @@ -0,0 +1,49 @@ +import type { ServerRuntimeClientOptions } from '@sentry/core'; +import { ServerRuntimeClient, applySdkMetadata } from '@sentry/core'; +import type { ClientOptions, Options } from '@sentry/types'; + +import type { CloudflareTransportOptions } from './transport'; + +/** + * The Sentry Cloudflare SDK Client. + * + * @see CloudflareClientOptions for documentation on configuration options. + * @see ServerRuntimeClient for usage documentation. + */ +export class CloudflareClient extends ServerRuntimeClient { + /** + * Creates a new Cloudflare SDK instance. + * @param options Configuration options for this SDK. + */ + public constructor(options: CloudflareClientOptions) { + applySdkMetadata(options, 'options'); + options._metadata = options._metadata || {}; + + const clientOptions: ServerRuntimeClientOptions = { + ...options, + platform: 'javascript', + // TODO: Grab version information + runtime: { name: 'cloudflare' }, + // TODO: Add server name + }; + + super(clientOptions); + } +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface BaseCloudflareOptions {} + +/** + * Configuration options for the Sentry Cloudflare SDK + * + * @see @sentry/types Options for more information. + */ +export interface CloudflareOptions extends Options, BaseCloudflareOptions {} + +/** + * Configuration options for the Sentry Cloudflare SDK Client class + * + * @see CloudflareClient for more information. + */ +export interface CloudflareClientOptions extends ClientOptions, BaseCloudflareOptions {} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index cb0ff5c3b541..46c0b9920314 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -1 +1,90 @@ -export {}; +export type { + Breadcrumb, + BreadcrumbHint, + PolymorphicRequest, + Request, + SdkInfo, + Event, + EventHint, + ErrorEvent, + Exception, + Session, + SeverityLevel, + Span, + StackFrame, + Stacktrace, + Thread, + User, +} from '@sentry/types'; +export type { AddRequestDataToEventOptions } from '@sentry/utils'; + +export type { CloudflareOptions } from './client'; + +export { + addEventProcessor, + addBreadcrumb, + addIntegration, + captureException, + captureEvent, + captureMessage, + captureFeedback, + close, + createTransport, + lastEventId, + flush, + getClient, + isInitialized, + getCurrentScope, + getGlobalScope, + getIsolationScope, + setCurrentClient, + Scope, + SDK_VERSION, + setContext, + setExtra, + setExtras, + setTag, + setTags, + setUser, + getSpanStatusFromHttpCode, + setHttpStatus, + withScope, + withIsolationScope, + captureCheckIn, + withMonitor, + setMeasurement, + getActiveSpan, + getRootSpan, + startSpan, + startInactiveSpan, + startSpanManual, + startNewTrace, + withActiveSpan, + getSpanDescendants, + continueTrace, + metrics, + functionToStringIntegration, + inboundFiltersIntegration, + linkedErrorsIntegration, + requestDataIntegration, + extraErrorDataIntegration, + debugIntegration, + dedupeIntegration, + rewriteFramesIntegration, + captureConsoleIntegration, + moduleMetadataIntegration, + zodErrorsIntegration, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + trpcMiddleware, + spanToJSON, + spanToTraceHeader, + spanToBaggageHeader, +} from '@sentry/core'; + +export { CloudflareClient } from './client'; +export { getDefaultIntegrations } from './sdk'; + +export { fetchIntegration } from './integrations/fetch'; diff --git a/packages/cloudflare/src/integrations/fetch.ts b/packages/cloudflare/src/integrations/fetch.ts new file mode 100644 index 000000000000..121445448f03 --- /dev/null +++ b/packages/cloudflare/src/integrations/fetch.ts @@ -0,0 +1,162 @@ +import { addBreadcrumb, defineIntegration, getClient, instrumentFetchRequest, isSentryRequestUrl } from '@sentry/core'; +import type { + Client, + FetchBreadcrumbData, + FetchBreadcrumbHint, + HandlerDataFetch, + IntegrationFn, + Span, +} from '@sentry/types'; +import { LRUMap, addFetchInstrumentationHandler, stringMatchesSomePattern } from '@sentry/utils'; + +const INTEGRATION_NAME = 'Fetch'; + +const HAS_CLIENT_MAP = new WeakMap(); + +export interface Options { + /** + * Whether breadcrumbs should be recorded for requests + * Defaults to true + */ + breadcrumbs: boolean; + + /** + * Function determining whether or not to create spans to track outgoing requests to the given URL. + * By default, spans will be created for all outgoing requests. + */ + shouldCreateSpanForRequest?: (url: string) => boolean; +} + +const _fetchIntegration = ((options: Partial = {}) => { + const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs; + const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; + + const _createSpanUrlMap = new LRUMap(100); + const _headersUrlMap = new LRUMap(100); + + const spans: Record = {}; + + /** Decides whether to attach trace data to the outgoing fetch request */ + function _shouldAttachTraceData(url: string): boolean { + const client = getClient(); + + if (!client) { + return false; + } + + const clientOptions = client.getOptions(); + + if (clientOptions.tracePropagationTargets === undefined) { + return true; + } + + const cachedDecision = _headersUrlMap.get(url); + if (cachedDecision !== undefined) { + return cachedDecision; + } + + const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets); + _headersUrlMap.set(url, decision); + return decision; + } + + /** Helper that wraps shouldCreateSpanForRequest option */ + function _shouldCreateSpan(url: string): boolean { + if (shouldCreateSpanForRequest === undefined) { + return true; + } + + const cachedDecision = _createSpanUrlMap.get(url); + if (cachedDecision !== undefined) { + return cachedDecision; + } + + const decision = shouldCreateSpanForRequest(url); + _createSpanUrlMap.set(url, decision); + return decision; + } + + return { + name: INTEGRATION_NAME, + setupOnce() { + addFetchInstrumentationHandler(handlerData => { + const client = getClient(); + if (!client || !HAS_CLIENT_MAP.get(client)) { + return; + } + + if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + return; + } + + instrumentFetchRequest( + handlerData, + _shouldCreateSpan, + _shouldAttachTraceData, + spans, + 'auto.http.wintercg_fetch', + ); + + if (breadcrumbs) { + createBreadcrumb(handlerData); + } + }); + }, + setup(client) { + HAS_CLIENT_MAP.set(client, true); + }, + }; +}) satisfies IntegrationFn; + +/** + * Creates spans and attaches tracing headers to fetch requests. + */ +export const fetchIntegration = defineIntegration(_fetchIntegration); + +function createBreadcrumb(handlerData: HandlerDataFetch): void { + const { startTimestamp, endTimestamp } = handlerData; + + // We only capture complete fetch requests + if (!endTimestamp) { + return; + } + + if (handlerData.error) { + const data = handlerData.fetchData; + const hint: FetchBreadcrumbHint = { + data: handlerData.error, + input: handlerData.args, + startTimestamp, + endTimestamp, + }; + + addBreadcrumb( + { + category: 'fetch', + data, + level: 'error', + type: 'http', + }, + hint, + ); + } else { + const data: FetchBreadcrumbData = { + ...handlerData.fetchData, + status_code: handlerData.response && handlerData.response.status, + }; + const hint: FetchBreadcrumbHint = { + input: handlerData.args, + response: handlerData.response, + startTimestamp, + endTimestamp, + }; + addBreadcrumb( + { + category: 'fetch', + data, + type: 'http', + }, + hint, + ); + } +} diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts new file mode 100644 index 000000000000..a6eaa4aa9360 --- /dev/null +++ b/packages/cloudflare/src/sdk.ts @@ -0,0 +1,27 @@ +import { + dedupeIntegration, + functionToStringIntegration, + inboundFiltersIntegration, + linkedErrorsIntegration, + requestDataIntegration, +} from '@sentry/core'; +import type { Integration, Options } from '@sentry/types'; + +import { fetchIntegration } from './integrations/fetch'; + +/** Get the default integrations for the Cloudflare SDK. */ +export function getDefaultIntegrations(options: Options): Integration[] { + const integrations = [ + dedupeIntegration(), + inboundFiltersIntegration(), + functionToStringIntegration(), + linkedErrorsIntegration(), + fetchIntegration(), + ]; + + if (options.sendDefaultPii) { + integrations.push(requestDataIntegration()); + } + + return integrations; +} diff --git a/packages/cloudflare/src/transport.ts b/packages/cloudflare/src/transport.ts new file mode 100644 index 000000000000..fd26b217c367 --- /dev/null +++ b/packages/cloudflare/src/transport.ts @@ -0,0 +1,104 @@ +import { createTransport } from '@sentry/core'; +import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types'; +import { SentryError } from '@sentry/utils'; + +export interface CloudflareTransportOptions extends BaseTransportOptions { + /** Fetch API init parameters. */ + fetchOptions?: RequestInit; + /** Custom headers for the transport. */ + headers?: { [key: string]: string }; +} + +const DEFAULT_TRANSPORT_BUFFER_SIZE = 30; + +/** + * This is a modified promise buffer that collects tasks until drain is called. + * We need this in the edge runtime because edge function invocations may not share I/O objects, like fetch requests + * and responses, and the normal PromiseBuffer inherently buffers stuff inbetween incoming requests. + * + * A limitation we need to be aware of is that DEFAULT_TRANSPORT_BUFFER_SIZE is the maximum amount of payloads the + * SDK can send for a given edge function invocation. + */ +export class IsolatedPromiseBuffer { + // We just have this field because the promise buffer interface requires it. + // If we ever remove it from the interface we should also remove it here. + public $: Array>; + + private _taskProducers: (() => PromiseLike)[]; + + private readonly _bufferSize: number; + + public constructor(_bufferSize = DEFAULT_TRANSPORT_BUFFER_SIZE) { + this.$ = []; + this._taskProducers = []; + this._bufferSize = _bufferSize; + } + + /** + * @inheritdoc + */ + public add(taskProducer: () => PromiseLike): PromiseLike { + if (this._taskProducers.length >= this._bufferSize) { + return Promise.reject(new SentryError('Not adding Promise because buffer limit was reached.')); + } + + this._taskProducers.push(taskProducer); + return Promise.resolve({}); + } + + /** + * @inheritdoc + */ + public drain(timeout?: number): PromiseLike { + const oldTaskProducers = [...this._taskProducers]; + this._taskProducers = []; + + return new Promise(resolve => { + const timer = setTimeout(() => { + if (timeout && timeout > 0) { + resolve(false); + } + }, timeout); + + // This cannot reject + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.all( + oldTaskProducers.map(taskProducer => + taskProducer().then(null, () => { + // catch all failed requests + }), + ), + ).then(() => { + // resolve to true if all fetch requests settled + clearTimeout(timer); + resolve(true); + }); + }); + } +} + +/** + * Creates a Transport that uses the native fetch API to send events to Sentry. + */ +export function makeCloudflareTransport(options: CloudflareTransportOptions): Transport { + function makeRequest(request: TransportRequest): PromiseLike { + const requestOptions: RequestInit = { + body: request.body, + method: 'POST', + headers: options.headers, + ...options.fetchOptions, + }; + + 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'), + }, + }; + }); + } + + return createTransport(options, makeRequest, new IsolatedPromiseBuffer(options.bufferSize)); +} diff --git a/packages/cloudflare/test/async.test.ts b/packages/cloudflare/test/async.test.ts new file mode 100644 index 000000000000..a4423e0ca434 --- /dev/null +++ b/packages/cloudflare/test/async.test.ts @@ -0,0 +1,163 @@ +import { Scope, getCurrentScope, getGlobalScope, getIsolationScope, withIsolationScope, withScope } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/utils'; +import { AsyncLocalStorage } from 'async_hooks'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; + +describe('withScope()', () => { + beforeEach(() => { + getIsolationScope().clear(); + getCurrentScope().clear(); + getGlobalScope().clear(); + + (GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage; + setAsyncLocalStorageAsyncContextStrategy(); + }); + + it('will make the passed scope the active scope within the callback', () => + new Promise(done => { + withScope(scope => { + expect(getCurrentScope()).toBe(scope); + done(); + }); + })); + + it('will pass a scope that is different from the current active isolation scope', () => + new Promise(done => { + withScope(scope => { + expect(getIsolationScope()).not.toBe(scope); + done(); + }); + })); + + it('will always make the inner most passed scope the current scope when nesting calls', () => + new Promise(done => { + withIsolationScope(_scope1 => { + withIsolationScope(scope2 => { + expect(getIsolationScope()).toBe(scope2); + done(); + }); + }); + })); + + it('forks the scope when not passing any scope', () => + new Promise(done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + withScope(scope => { + expect(getCurrentScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('forks the scope when passing undefined', () => + new Promise(done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + withScope(undefined, scope => { + expect(getCurrentScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('sets the passed in scope as active scope', () => + new Promise(done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + const customScope = new Scope(); + + withScope(customScope, scope => { + expect(getCurrentScope()).toBe(customScope); + expect(scope).toBe(customScope); + done(); + }); + })); +}); + +describe('withIsolationScope()', () => { + beforeEach(() => { + getIsolationScope().clear(); + getCurrentScope().clear(); + getGlobalScope().clear(); + (GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage; + + setAsyncLocalStorageAsyncContextStrategy(); + }); + + it('will make the passed isolation scope the active isolation scope within the callback', () => + new Promise(done => { + withIsolationScope(scope => { + expect(getIsolationScope()).toBe(scope); + done(); + }); + })); + + it('will pass an isolation scope that is different from the current active scope', () => + new Promise(done => { + withIsolationScope(scope => { + expect(getCurrentScope()).not.toBe(scope); + done(); + }); + })); + + it('will always make the inner most passed scope the current scope when nesting calls', () => + new Promise(done => { + withIsolationScope(_scope1 => { + withIsolationScope(scope2 => { + expect(getIsolationScope()).toBe(scope2); + done(); + }); + }); + })); + + it('forks the isolation scope when not passing any isolation scope', () => + new Promise(done => { + const initialScope = getIsolationScope(); + initialScope.setTag('aa', 'aa'); + + withIsolationScope(scope => { + expect(getIsolationScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('forks the isolation scope when passing undefined', () => + new Promise(done => { + const initialScope = getIsolationScope(); + initialScope.setTag('aa', 'aa'); + + withIsolationScope(undefined, scope => { + expect(getIsolationScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('sets the passed in isolation scope as active isolation scope', () => + new Promise(done => { + const initialScope = getIsolationScope(); + initialScope.setTag('aa', 'aa'); + + const customScope = new Scope(); + + withIsolationScope(customScope, scope => { + expect(getIsolationScope()).toBe(customScope); + expect(scope).toBe(customScope); + done(); + }); + })); +}); diff --git a/packages/cloudflare/test/fixtures/worker.mjs b/packages/cloudflare/test/fixtures/worker.mjs deleted file mode 100644 index 2023f7471c43..000000000000 --- a/packages/cloudflare/test/fixtures/worker.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @type {import('@cloudflare/workers-types').ExportedHandler} - */ -export default { - async fetch(_request, _env, _ctx) { - return new Response('Hello Sentry!'); - }, -}; diff --git a/packages/cloudflare/test/index.test.ts b/packages/cloudflare/test/index.test.ts deleted file mode 100644 index 30bd1f0962f6..000000000000 --- a/packages/cloudflare/test/index.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { Miniflare } from 'miniflare'; - -describe('index', () => { - test('simple test', async () => { - const mf = new Miniflare({ - scriptPath: './test/fixtures/worker.mjs', - modules: true, - port: 8787, - }); - - const res = await mf.dispatchFetch('http://localhost:8787/'); - expect(await res.text()).toBe('Hello Sentry!'); - await mf.dispose(); - }); -}); diff --git a/packages/cloudflare/test/integrations/fetch.test.ts b/packages/cloudflare/test/integrations/fetch.test.ts new file mode 100644 index 000000000000..e4f25be8f110 --- /dev/null +++ b/packages/cloudflare/test/integrations/fetch.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as sentryCore from '@sentry/core'; +import type { HandlerDataFetch, Integration } from '@sentry/types'; +import * as sentryUtils from '@sentry/utils'; +import { createStackParser } from '@sentry/utils'; + +import { CloudflareClient } from '../../src/client'; +import { fetchIntegration } from '../../src/integrations/fetch'; + +class FakeClient extends CloudflareClient { + public getIntegrationByName(name: string): T | undefined { + return name === 'Fetch' ? (fetchIntegration() as Integration as T) : undefined; + } +} + +const addFetchInstrumentationHandlerSpy = vi.spyOn(sentryUtils, 'addFetchInstrumentationHandler'); +const instrumentFetchRequestSpy = vi.spyOn(sentryCore, 'instrumentFetchRequest'); +const addBreadcrumbSpy = vi.spyOn(sentryCore, 'addBreadcrumb'); + +describe('WinterCGFetch instrumentation', () => { + let client: FakeClient; + + beforeEach(() => { + vi.clearAllMocks(); + + client = new FakeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enableTracing: true, + tracesSampleRate: 1, + integrations: [], + transport: () => ({ + send: () => Promise.resolve({}), + flush: () => Promise.resolve(true), + }), + tracePropagationTargets: ['http://my-website.com/'], + stackParser: createStackParser(), + }); + + vi.spyOn(sentryCore, 'getClient').mockImplementation(() => client); + }); + + it('should call `instrumentFetchRequest` for outgoing fetch requests', () => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + const integration = fetchIntegration(); + integration.setupOnce!(); + integration.setup!(client); + + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( + startHandlerData, + expect.any(Function), + expect.any(Function), + expect.any(Object), + 'auto.http.wintercg_fetch', + ); + + const [, shouldCreateSpan, shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; + + expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); + expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); + + expect(shouldCreateSpan('http://my-website.com/')).toBe(true); + expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(true); + }); + + it('should not instrument if client is not setup', () => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + const integration = fetchIntegration(); + integration.setupOnce!(); + // integration.setup!(client) is not called! + + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + }); + + it('should call `instrumentFetchRequest` for outgoing fetch requests to Sentry', () => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + const integration = fetchIntegration(); + integration.setupOnce!(); + integration.setup!(client); + + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'https://dsn.ingest.sentry.io/1337', method: 'POST' }, + args: ['https://dsn.ingest.sentry.io/1337'], + startTimestamp: Date.now(), + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + }); + + it('should properly apply the `shouldCreateSpanForRequest` option', () => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + const integration = fetchIntegration({ + shouldCreateSpanForRequest(url) { + return url === 'http://only-acceptable-url.com/'; + }, + }); + integration.setupOnce!(); + integration.setup!(client); + + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; + + expect(shouldCreateSpan('http://only-acceptable-url.com/')).toBe(true); + expect(shouldCreateSpan('http://my-website.com/')).toBe(false); + expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(false); + }); + + it('should create a breadcrumb for an outgoing request', () => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + const integration = fetchIntegration(); + integration.setupOnce!(); + integration.setup!(client); + + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startTimestamp = Date.now(); + const endTimestamp = Date.now() + 100; + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, + startTimestamp, + endTimestamp, + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + expect(addBreadcrumbSpy).toBeCalledWith( + { + category: 'fetch', + data: { + method: 'POST', + status_code: 201, + url: 'http://my-website.com/', + __span: expect.any(String), + }, + type: 'http', + }, + { + endTimestamp, + input: ['http://my-website.com/'], + response: { ok: true, status: 201, url: 'http://my-website.com/' }, + startTimestamp, + }, + ); + }); + + it('should not create a breadcrumb for an outgoing request if `breadcrumbs: false` is set', () => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + const integration = fetchIntegration({ breadcrumbs: false }); + integration.setupOnce!(); + integration.setup!(client); + + const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startTimestamp = Date.now(); + const endTimestamp = Date.now() + 100; + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, + startTimestamp, + endTimestamp, + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cloudflare/test/transport.test.ts b/packages/cloudflare/test/transport.test.ts new file mode 100644 index 000000000000..788785de8216 --- /dev/null +++ b/packages/cloudflare/test/transport.test.ts @@ -0,0 +1,160 @@ +import type { EventEnvelope, EventItem } from '@sentry/types'; +import { createEnvelope, serializeEnvelope } from '@sentry/utils'; +import { afterAll, describe, expect, it, vi } from 'vitest'; + +import type { CloudflareTransportOptions } from '../src/transport'; +import { IsolatedPromiseBuffer, makeCloudflareTransport } from '../src/transport'; + +const DEFAULT_EDGE_TRANSPORT_OPTIONS: CloudflareTransportOptions = { + url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7', + recordDroppedEvent: () => undefined, +}; + +const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); + +class Headers { + headers: { [key: string]: string } = {}; + get(key: string) { + return this.headers[key] || null; + } + set(key: string, value: string) { + this.headers[key] = value; + } +} + +const mockFetch = vi.fn(); + +const oldFetch = global.fetch; +global.fetch = mockFetch; + +afterAll(() => { + global.fetch = oldFetch; +}); + +describe('Edge Transport', () => { + it('calls fetch with the given URL', async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers: new Headers(), + status: 200, + text: () => Promise.resolve({}), + }), + ); + + const transport = makeCloudflareTransport(DEFAULT_EDGE_TRANSPORT_OPTIONS); + + expect(mockFetch).toHaveBeenCalledTimes(0); + await transport.send(ERROR_ENVELOPE); + await transport.flush(); + expect(mockFetch).toHaveBeenCalledTimes(1); + + expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_EDGE_TRANSPORT_OPTIONS.url, { + body: serializeEnvelope(ERROR_ENVELOPE), + method: 'POST', + }); + }); + + it('sets rate limit headers', async () => { + const headers = { + get: vi.fn(), + }; + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + text: () => Promise.resolve({}), + }), + ); + + const transport = makeCloudflareTransport(DEFAULT_EDGE_TRANSPORT_OPTIONS); + + expect(headers.get).toHaveBeenCalledTimes(0); + await transport.send(ERROR_ENVELOPE); + await transport.flush(); + + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + + it('allows for custom options to be passed in', async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers: new Headers(), + status: 200, + text: () => Promise.resolve({}), + }), + ); + + const REQUEST_OPTIONS: RequestInit = { + cf: { + minify: { + javascript: true, + }, + }, + }; + + const transport = makeCloudflareTransport({ ...DEFAULT_EDGE_TRANSPORT_OPTIONS, fetchOptions: REQUEST_OPTIONS }); + + await transport.send(ERROR_ENVELOPE); + await transport.flush(); + expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_EDGE_TRANSPORT_OPTIONS.url, { + body: serializeEnvelope(ERROR_ENVELOPE), + method: 'POST', + ...REQUEST_OPTIONS, + }); + }); +}); + +describe('IsolatedPromiseBuffer', () => { + it('should not call tasks until drained', async () => { + const ipb = new IsolatedPromiseBuffer(); + + const task1 = vi.fn(() => Promise.resolve({})); + const task2 = vi.fn(() => Promise.resolve({})); + + await ipb.add(task1); + await ipb.add(task2); + + expect(task1).not.toHaveBeenCalled(); + expect(task2).not.toHaveBeenCalled(); + + await ipb.drain(); + + expect(task1).toHaveBeenCalled(); + expect(task2).toHaveBeenCalled(); + }); + + it('should not allow adding more items than the specified limit', async () => { + const ipb = new IsolatedPromiseBuffer(3); + + const task1 = vi.fn(() => Promise.resolve({})); + const task2 = vi.fn(() => Promise.resolve({})); + const task3 = vi.fn(() => Promise.resolve({})); + const task4 = vi.fn(() => Promise.resolve({})); + + await ipb.add(task1); + await ipb.add(task2); + await ipb.add(task3); + + await expect(ipb.add(task4)).rejects.toThrowError('Not adding Promise because buffer limit was reached.'); + }); + + it('should not throw when one of the tasks throws when drained', async () => { + const ipb = new IsolatedPromiseBuffer(); + + const task1 = vi.fn(() => Promise.resolve({})); + const task2 = vi.fn(() => Promise.reject(new Error())); + + await ipb.add(task1); + await ipb.add(task2); + + await expect(ipb.drain()).resolves.toEqual(true); + + expect(task1).toHaveBeenCalled(); + expect(task2).toHaveBeenCalled(); + }); +}); diff --git a/packages/cloudflare/tsconfig.json b/packages/cloudflare/tsconfig.json index 18b3ec720bfe..ff89f0feaa23 100644 --- a/packages/cloudflare/tsconfig.json +++ b/packages/cloudflare/tsconfig.json @@ -4,6 +4,7 @@ "include": ["src/**/*"], "compilerOptions": { - "types": ["@cloudflare/workers-types"] + "module": "esnext", + "types": ["node", "@cloudflare/workers-types"] } } diff --git a/yarn.lock b/yarn.lock index e703f7baaec2..585dbe728051 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3864,10 +3864,10 @@ resolved "https://registry.yarnpkg.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20240701.0.tgz#710583329e7fef26092fdccf021e669434cc6acb" integrity sha512-6IPGITRAeS67j3BH1rN4iwYWDt47SqJG7KlZJ5bB4UaNAia4mvMBSy/p2p4vA89bbXoDRjMtEvRu7Robu6O7hQ== -"@cloudflare/workers-types@^4.20240620.0": - version "4.20240620.0" - resolved "https://registry.yarnpkg.com/@cloudflare/workers-types/-/workers-types-4.20240620.0.tgz#1e996c0b81a1dab392f0292bea036fd7bb3b73f3" - integrity sha512-CQD8YS6evRob7LChvIX3gE3zYo0KVgaLDOu1SwNP1BVIS2Sa0b+FC8S1e1hhrNN8/E4chYlVN+FDAgA4KRDUEQ== +"@cloudflare/workers-types@^4.20240712.0": + version "4.20240712.0" + resolved "https://registry.yarnpkg.com/@cloudflare/workers-types/-/workers-types-4.20240712.0.tgz#c3d512eec5f72343ba95a9acee16787d9e184ed4" + integrity sha512-C+C0ZnkRrxR2tPkZKAXwBsWEse7bWaA7iMbaG6IKaxaPTo/5ilx7Ei3BkI2izxmOJMsC05VS1eFUf95urXzhmw== "@cnakazawa/watch@^1.0.3": version "1.0.4" @@ -35021,10 +35021,10 @@ workerpool@^6.4.0: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.4.0.tgz#f8d5cfb45fde32fa3b7af72ad617c3369567a462" integrity sha512-i3KR1mQMNwY2wx20ozq2EjISGtQWDIfV56We+yGJ5yDs8jTwQiLLaqHlkBHITlCuJnYlVRmXegxFxZg7gqI++A== -wrangler@^3.63.2: - version "3.63.2" - resolved "https://registry.yarnpkg.com/wrangler/-/wrangler-3.63.2.tgz#f09ec6f26eb83bdb95a32519df2faec9f1d4c578" - integrity sha512-c7F46JtBGTIQehTOgfGbxfDMYgO9AjC70CXVSohxHiF9ajHz56HEV2k3aowhJJiP3MBB8sJMm8rdG10f5zUs+w== +wrangler@^3.64.0: + version "3.64.0" + resolved "https://registry.yarnpkg.com/wrangler/-/wrangler-3.64.0.tgz#2f2922ab6a382d9416d35c0fd9797aa61c8572e1" + integrity sha512-q2VQADJXzuOkXs9KIfPSx7UCZHBoxsqSNbJDLkc2pHpGmsyNQXsJRqjMoTg/Kls7O3K9A7EGnzGr7+Io2vE6AQ== dependencies: "@cloudflare/kv-asset-handler" "0.3.4" "@esbuild-plugins/node-globals-polyfill" "^0.2.3"