From 3e424c7df45ad9185453b22a07fe503b055f1230 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 23 Jul 2024 11:19:14 -0400 Subject: [PATCH 1/5] feat(cloudflare): Add withSentry method --- packages/cloudflare/README.md | 78 ++++- packages/cloudflare/package.json | 9 +- packages/cloudflare/src/client.ts | 2 +- packages/cloudflare/src/handler.ts | 138 ++++++++ packages/cloudflare/src/index.ts | 2 + packages/cloudflare/src/integrations/fetch.ts | 10 +- packages/cloudflare/src/sdk.ts | 30 +- packages/cloudflare/src/vendor/stacktrace.ts | 69 ++++ packages/cloudflare/test/handler.test.ts | 300 ++++++++++++++++++ .../test/integrations/fetch.test.ts | 2 +- packages/cloudflare/test/sdk.test.ts | 13 + packages/utils/src/instrument/fetch.ts | 11 +- yarn.lock | 96 +++--- 13 files changed, 688 insertions(+), 72 deletions(-) create mode 100644 packages/cloudflare/src/handler.ts create mode 100644 packages/cloudflare/src/vendor/stacktrace.ts create mode 100644 packages/cloudflare/test/handler.test.ts create mode 100644 packages/cloudflare/test/sdk.test.ts diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index e85a64c490df..09794438cc2e 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -18,6 +18,80 @@ **Note: This SDK is unreleased. Please follow the [tracking GH issue](https://github.com/getsentry/sentry-javascript/issues/12620) for updates.** -## Usage +Below details the setup for the Cloudflare Workers. Cloudflare Pages support is in active development. -TODO: Add usage instructions here. +## Setup (Cloudflare Workers) + +To get started, first install the `@sentry/cloudflare` package: + +```bash +npm install @sentry/cloudflare +``` + +Then set either the `nodejs_compat` or `nodejs_als` compatibility flags in your `wrangler.toml`: + +```toml +compatibility_flags = ["nodejs_compat"] +# compatibility_flags = ["nodejs_als"] +``` + +To use this SDK, wrap your handler with the `withSentry` function. This will initialize the SDK and hook into the +environment. Note that you can turn off almost all side effects using the respective options. + +Currently only ESM handlers are supported. + +```javascript +import * as Sentry from '@sentry/cloudflare'; + +export default withSentry( + (env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env, ctx) { + return new Response('Hello World!'); + }, + } satisfies ExportedHandler +); +``` + +### Sourcemaps (Cloudflare Workers) + +Configure uploading sourcemaps via the Sentry Wizard: + +```bash +npx @sentry/wizard@latest -i sourcemaps +``` + +See more details in our [docs](https://docs.sentry.io/platforms/javascript/sourcemaps/). + +## Usage (Cloudflare Workers) + +To set context information or send manual events, use the exported functions of `@sentry/cloudflare`. Note that these +functions will require your exported handler to be wrapped in `withSentry`. + +```javascript +import * as Sentry from '@sentry/cloudflare'; + +// Set user information, as well as tags and further extras +Sentry.setExtra('battery', 0.7); +Sentry.setTag('user_mode', 'admin'); +Sentry.setUser({ id: '4711' }); + +// Add a breadcrumb for future events +Sentry.addBreadcrumb({ + message: 'My Breadcrumb', + // ... +}); + +// Capture exceptions, messages or manual events +Sentry.captureMessage('Hello, world!'); +Sentry.captureException(new Error('Good bye')); +Sentry.captureEvent({ + message: 'Manual', + stacktrace: [ + // ... + ], +}); +``` diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 6cef7b9ec7ce..c675af1d8332 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -43,11 +43,14 @@ "@sentry/types": "8.20.0", "@sentry/utils": "8.20.0" }, + "optionalDependencies": { + "@cloudflare/workers-types": "^4.x" + }, "devDependencies": { - "@cloudflare/workers-types": "^4.20240712.0", + "@cloudflare/workers-types": "^4.20240722.0", "@types/node": "^14.18.0", - "miniflare": "^3.20240701.0", - "wrangler": "^3.64.0" + "miniflare": "^3.20240718.0", + "wrangler": "^3.65.1" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 8b25d8ae6f87..22b6859d9bf8 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -16,7 +16,7 @@ export class CloudflareClient extends ServerRuntimeClient = P extends ExportedHandler ? Env : never; + +/** + * Wrapper for Cloudflare handlers. + * + * Initializes the SDK and wraps the handler with Sentry instrumentation. + * + * Automatically instruments the `fetch` method of the handler. + * + * @param optionsCallback Function that returns the options for the SDK initialization. + * @param handler {ExportedHandler} The handler to wrap. + * @returns The wrapped handler. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function withSentry>( + optionsCallback: (env: ExtractEnv) => Options, + handler: E, +): E { + setAsyncLocalStorageAsyncContextStrategy(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + if ('fetch' in handler && typeof handler.fetch === 'function' && !(handler.fetch as any).__SENTRY_INSTRUMENTED__) { + handler.fetch = new Proxy(handler.fetch, { + apply(target, thisArg, args: Parameters>>) { + const [request, env, context] = args; + return withIsolationScope(isolationScope => { + const options = optionsCallback(env); + const client = init(options); + isolationScope.setClient(client); + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.cloudflare-worker', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + ['http.request.method']: request.method, + ['url.full']: request.url, + }; + + const contentLength = request.headers.get('content-length'); + if (contentLength) { + attributes['http.request.body.size'] = parseInt(contentLength, 10); + } + + let pathname = ''; + try { + const url = new URL(request.url); + pathname = url.pathname; + attributes['server.address'] = url.hostname; + attributes['url.scheme'] = url.protocol.replace(':', ''); + } catch { + // skip + } + + addRequest(isolationScope, request); + addCloudResourceContext(isolationScope); + if (request.cf) { + addCultureContext(isolationScope, request.cf); + attributes['network.protocol.name'] = request.cf.httpProtocol; + } + + const routeName = `${request.method} ${pathname ? stripUrlQueryAndFragment(pathname) : '/'}`; + + return continueTrace( + { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, + () => { + // Note: This span will not have a duration unless I/O happens in the handler. This is + // because of how the cloudflare workers runtime works. + // See: https://developers.cloudflare.com/workers/runtime-apis/performance/ + return startSpan( + { + name: routeName, + attributes, + }, + async span => { + try { + const res = await (target.apply(thisArg, args) as ReturnType); + setHttpStatus(span, res.status); + return res; + } catch (e) { + captureException(e, { mechanism: { handled: false } }); + throw e; + } finally { + context.waitUntil(flush(2000)); + } + }, + ); + }, + ); + }); + }, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + (handler.fetch as any).__SENTRY_INSTRUMENTED__ = true; + } + + return handler; +} + +function addCloudResourceContext(isolationScope: Scope): void { + isolationScope.setContext('cloud_resource', { + 'cloud.provider': 'cloudflare', + }); +} + +function addCultureContext(isolationScope: Scope, cf: IncomingRequestCfProperties): void { + isolationScope.setContext('culture', { + timezone: cf.timezone, + }); +} + +function addRequest(isolationScope: Scope, request: Request): void { + isolationScope.setSDKProcessingMetadata({ request: winterCGRequestToRequestData(request) }); +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 46c0b9920314..6ef2b536aef4 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -84,6 +84,8 @@ export { spanToBaggageHeader, } from '@sentry/core'; +export { withSentry } from './handler'; + export { CloudflareClient } from './client'; export { getDefaultIntegrations } from './sdk'; diff --git a/packages/cloudflare/src/integrations/fetch.ts b/packages/cloudflare/src/integrations/fetch.ts index 121445448f03..4781a71a896d 100644 --- a/packages/cloudflare/src/integrations/fetch.ts +++ b/packages/cloudflare/src/integrations/fetch.ts @@ -89,18 +89,12 @@ const _fetchIntegration = ((options: Partial = {}) => { return; } - instrumentFetchRequest( - handlerData, - _shouldCreateSpan, - _shouldAttachTraceData, - spans, - 'auto.http.wintercg_fetch', - ); + instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, 'auto.http.fetch'); if (breadcrumbs) { createBreadcrumb(handlerData); } - }); + }, true); }, setup(client) { HAS_CLIENT_MAP.set(client, true); diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index a6eaa4aa9360..edc242656195 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -1,27 +1,47 @@ import { dedupeIntegration, functionToStringIntegration, + getIntegrationsToSetup, inboundFiltersIntegration, + initAndBind, linkedErrorsIntegration, requestDataIntegration, } from '@sentry/core'; import type { Integration, Options } from '@sentry/types'; +import { stackParserFromStackParserOptions } from '@sentry/utils'; +import type { CloudflareClientOptions } from './client'; +import { CloudflareClient } from './client'; import { fetchIntegration } from './integrations/fetch'; +import { makeCloudflareTransport } from './transport'; +import { defaultStackParser } from './vendor/stacktrace'; /** Get the default integrations for the Cloudflare SDK. */ -export function getDefaultIntegrations(options: Options): Integration[] { - const integrations = [ +export function getDefaultIntegrations(_options: Options): Integration[] { + return [ dedupeIntegration(), inboundFiltersIntegration(), functionToStringIntegration(), linkedErrorsIntegration(), fetchIntegration(), + requestDataIntegration(), ]; +} - if (options.sendDefaultPii) { - integrations.push(requestDataIntegration()); +/** + * Initializes the cloudflare SDK. + */ +export function init(options: Options): CloudflareClient | undefined { + if (options.defaultIntegrations === undefined) { + options.defaultIntegrations = getDefaultIntegrations(options); } - return integrations; + const clientOptions: CloudflareClientOptions = { + ...options, + stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), + integrations: getIntegrationsToSetup(options), + transport: options.transport || makeCloudflareTransport, + }; + + return initAndBind(CloudflareClient, clientOptions) as CloudflareClient; } diff --git a/packages/cloudflare/src/vendor/stacktrace.ts b/packages/cloudflare/src/vendor/stacktrace.ts new file mode 100644 index 000000000000..1a91abe26d24 --- /dev/null +++ b/packages/cloudflare/src/vendor/stacktrace.ts @@ -0,0 +1,69 @@ +// Vendored from https://github.com/robertcepa/toucan-js/blob/036568729e49d0a937de527dc32d73580d9a41b3/packages/toucan-js/src/stacktrace.ts +// MIT License + +// Copyright (c) 2022 Robert Cepa + +// 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. + +import type { StackLineParser, StackLineParserFn, StackParser } from '@sentry/types'; +import { basename, createStackParser, nodeStackLineParser } from '@sentry/utils'; + +type GetModuleFn = (filename: string | undefined) => string | undefined; + +/** + * Stack line parser for Cloudflare Workers. + * This wraps node stack parser and adjusts root paths to match with source maps. + * + */ +function workersStackLineParser(getModule?: GetModuleFn): StackLineParser { + const [arg1, arg2] = nodeStackLineParser(getModule); + + const fn: StackLineParserFn = line => { + const result = arg2(line); + if (result) { + const filename = result.filename; + // Workers runtime runs a single bundled file that is always in a virtual root + result.abs_path = filename !== undefined && !filename.startsWith('/') ? `/${filename}` : filename; + // There is no way to tell what code is in_app and what comes from dependencies (node_modules), since we have one bundled file. + // So everything is in_app, unless an error comes from runtime function (ie. JSON.parse), which is determined by the presence of filename. + result.in_app = filename !== undefined; + } + return result; + }; + + return [arg1, fn]; +} + +/** + * Gets the module from filename. + * + * @param filename + * @returns Module name + */ +export function getModule(filename: string | undefined): string | undefined { + if (!filename) { + return; + } + + // In Cloudflare Workers there is always only one bundled file + return basename(filename, '.js'); +} + +/** Cloudflare Workers stack parser */ +export const defaultStackParser: StackParser = createStackParser(workersStackLineParser(getModule)); diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts new file mode 100644 index 000000000000..80738c270a4a --- /dev/null +++ b/packages/cloudflare/test/handler.test.ts @@ -0,0 +1,300 @@ +// Note: These tests run the handler in Node.js, which is has some differences to the cloudflare workers runtime. +// Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import * as SentryCore from '@sentry/core'; +import type { Event } from '@sentry/types'; +import { CloudflareClient } from '../src/client'; +import { withSentry } from '../src/handler'; + +const MOCK_ENV = { + SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', +}; + +describe('withSentry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('gets env from handler', async () => { + const handler = { + fetch(_request, _env, _context) { + return new Response('test'); + }, + } satisfies ExportedHandler; + + const optionsCallback = vi.fn().mockReturnValue({}); + + const wrappedHandler = withSentry(optionsCallback, handler); + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + + expect(optionsCallback).toHaveBeenCalledTimes(1); + expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); + }); + + test('passes through the response from the handler', async () => { + const response = new Response('test'); + const handler = { + async fetch(_request, _env, _context) { + return response; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(() => ({}), handler); + const result = await wrappedHandler.fetch( + new Request('https://example.com'), + MOCK_ENV, + createMockExecutionContext(), + ); + + expect(result).toBe(response); + }); + + test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { + const handler = { + async fetch(_request, _env, _context) { + return new Response('test'); + }, + } satisfies ExportedHandler; + + const context = createMockExecutionContext(); + const wrappedHandler = withSentry(() => ({}), handler); + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, context); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(context.waitUntil).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + }); + + test('creates a cloudflare client and sets it on the handler', async () => { + const handler = { + async fetch(_request, _env, _context) { + expect(SentryCore.getClient() instanceof CloudflareClient).toBe(true); + return new Response('test'); + }, + } satisfies ExportedHandler; + + const context = createMockExecutionContext(); + const wrappedHandler = withSentry(() => ({}), handler); + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, context); + + expect.assertions(1); + }); + + describe('scope instrumentation', () => { + test('adds cloud resource context', async () => { + const handler = { + async fetch(_request, _env, _context) { + SentryCore.captureMessage('test'); + return new Response('test'); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + (env: any) => ({ + dsn: env.MOCK_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); + }); + + test('adds request information', async () => { + const handler = { + async fetch(_request, _env, _context) { + SentryCore.captureMessage('test'); + return new Response('test'); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + (env: any) => ({ + dsn: env.MOCK_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + expect(sentryEvent.sdkProcessingMetadata?.request).toEqual({ + headers: {}, + url: 'https://example.com/', + method: 'GET', + }); + }); + + test('adds culture context', async () => { + const handler = { + async fetch(_request, _env, _context) { + SentryCore.captureMessage('test'); + return new Response('test'); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + (env: any) => ({ + dsn: env.MOCK_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + const mockRequest = new Request('https://example.com') as any; + mockRequest.cf = { + timezone: 'UTC', + }; + await wrappedHandler.fetch(mockRequest, { ...MOCK_ENV }, createMockExecutionContext()); + expect(sentryEvent.contexts?.culture).toEqual({ timezone: 'UTC' }); + }); + }); + + describe('error instrumentation', () => { + test('captures errors thrown by the handler', async () => { + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const error = new Error('test'); + const handler = { + async fetch(_request, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(() => ({}), handler); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + try { + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { mechanism: { handled: false } }); + }); + + test('re-throws the error after capturing', async () => { + const error = new Error('test'); + const handler = { + async fetch(_request, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(() => ({}), handler); + let thrownError: Error | undefined; + try { + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + } catch (e: any) { + thrownError = e; + } + + expect(thrownError).toBe(error); + }); + }); + + describe('tracing instrumentation', () => { + test('continues trace with sentry trace and baggage', async () => { + const handler = { + async fetch(_request, _env, _context) { + SentryCore.captureMessage('test'); + return new Response('test'); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + (env: any) => ({ + dsn: env.MOCK_DSN, + tracesSampleRate: 0, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + const request = new Request('https://example.com') as any; + request.headers.set('sentry-trace', '12312012123120121231201212312012-1121201211212012-1'); + request.headers.set( + 'baggage', + 'sentry-release=2.1.12,sentry-public_key=public,sentry-trace_id=12312012123120121231201212312012,sentry-sample_rate=0.3232', + ); + await wrappedHandler.fetch(request, MOCK_ENV, createMockExecutionContext()); + expect(sentryEvent.contexts?.trace).toEqual({ + parent_span_id: '1121201211212012', + span_id: expect.any(String), + trace_id: '12312012123120121231201212312012', + }); + }); + + test('creates a span that wraps fetch handler', async () => { + const handler = { + async fetch(_request, _env, _context) { + return new Response('test'); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + (env: any) => ({ + dsn: env.MOCK_DSN, + tracesSampleRate: 1, + beforeSendTransaction(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + const request = new Request('https://example.com') as any; + request.cf = { + httpProtocol: 'HTTP/1.1', + }; + request.headers.set('content-length', '10'); + + await wrappedHandler.fetch(request, MOCK_ENV, createMockExecutionContext()); + expect(sentryEvent.transaction).toEqual('GET /'); + expect(sentryEvent.spans).toHaveLength(0); + expect(sentryEvent.contexts?.trace).toEqual({ + data: { + 'sentry.origin': 'auto.http.cloudflare-worker', + 'sentry.op': 'http.server', + 'sentry.source': 'url', + 'http.request.method': 'GET', + 'url.full': 'https://example.com/', + 'server.address': 'example.com', + 'network.protocol.name': 'HTTP/1.1', + 'url.scheme': 'https', + 'sentry.sample_rate': 1, + 'http.response.status_code': 200, + 'http.request.body.size': 10, + }, + op: 'http.server', + origin: 'auto.http.cloudflare-worker', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + }); + }); +}); + +function createMockExecutionContext(): ExecutionContext { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + }; +} diff --git a/packages/cloudflare/test/integrations/fetch.test.ts b/packages/cloudflare/test/integrations/fetch.test.ts index e4f25be8f110..3825a27b985b 100644 --- a/packages/cloudflare/test/integrations/fetch.test.ts +++ b/packages/cloudflare/test/integrations/fetch.test.ts @@ -62,7 +62,7 @@ describe('WinterCGFetch instrumentation', () => { expect.any(Function), expect.any(Function), expect.any(Object), - 'auto.http.wintercg_fetch', + 'auto.http.fetch', ); const [, shouldCreateSpan, shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts new file mode 100644 index 000000000000..cf93a2f219cf --- /dev/null +++ b/packages/cloudflare/test/sdk.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test, vi } from 'vitest'; + +import * as SentryCore from '@sentry/core'; +import { init } from '../src/sdk'; + +describe('init', () => { + test('should call initAndBind with the correct options', () => { + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); + init({}); + + expect(initAndBindSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Object)); + }); +}); diff --git a/packages/utils/src/instrument/fetch.ts b/packages/utils/src/instrument/fetch.ts index a4df9bd6f807..0ea1a4ec8d9f 100644 --- a/packages/utils/src/instrument/fetch.ts +++ b/packages/utils/src/instrument/fetch.ts @@ -18,10 +18,13 @@ type FetchResource = string | { toString(): string } | { url: string }; * Use at your own risk, this might break without changelog notice, only used internally. * @hidden */ -export function addFetchInstrumentationHandler(handler: (data: HandlerDataFetch) => void): void { +export function addFetchInstrumentationHandler( + handler: (data: HandlerDataFetch) => void, + skipNativeFetchCheck?: boolean, +): void { const type = 'fetch'; addHandler(type, handler); - maybeInstrument(type, () => instrumentFetch()); + maybeInstrument(type, () => instrumentFetch(undefined, skipNativeFetchCheck)); } /** @@ -38,8 +41,8 @@ export function addFetchEndInstrumentationHandler(handler: (data: HandlerDataFet maybeInstrument(type, () => instrumentFetch(streamHandler)); } -function instrumentFetch(onFetchResolved?: (response: Response) => void): void { - if (!supportsNativeFetch()) { +function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNativeFetchCheck: boolean = false): void { + if (skipNativeFetchCheck && !supportsNativeFetch()) { return; } diff --git a/yarn.lock b/yarn.lock index 72d28fc8f600..008cbddd40c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3839,35 +3839,35 @@ dependencies: mime "^3.0.0" -"@cloudflare/workerd-darwin-64@1.20240701.0": - version "1.20240701.0" - resolved "https://registry.yarnpkg.com/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20240701.0.tgz#064d8ded54443ac8d4181bdb2d93113f7fb63c81" - integrity sha512-XAZa4ZP+qyTn6JQQACCPH09hGZXP2lTnWKkmg5mPwT8EyRzCKLkczAf98vPP5bq7JZD/zORdFWRY0dOTap8zTQ== - -"@cloudflare/workerd-darwin-arm64@1.20240701.0": - version "1.20240701.0" - resolved "https://registry.yarnpkg.com/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20240701.0.tgz#042e49592bf9ef9e74d7f85c885cc3bda356c96c" - integrity sha512-w80ZVAgfH4UwTz7fXZtk7KmS2FzlXniuQm4ku4+cIgRTilBAuKqjpOjwUCbx5g13Gqcm9NuiHce+IDGtobRTIQ== - -"@cloudflare/workerd-linux-64@1.20240701.0": - version "1.20240701.0" - resolved "https://registry.yarnpkg.com/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20240701.0.tgz#5ff73dcd0b0615877baa0ae4fa057ea244e326f3" - integrity sha512-UWLr/Anxwwe/25nGv451MNd2jhREmPt/ws17DJJqTLAx6JxwGWA15MeitAIzl0dbxRFAJa+0+R8ag2WR3F/D6g== - -"@cloudflare/workerd-linux-arm64@1.20240701.0": - version "1.20240701.0" - resolved "https://registry.yarnpkg.com/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20240701.0.tgz#b0e5e5bf00fb41ac94f93f7dea7ffd306f468685" - integrity sha512-3kCnF9kYgov1ggpuWbgpXt4stPOIYtVmPCa7MO2xhhA0TWP6JDUHRUOsnmIgKrvDjXuXqlK16cdg3v+EWsaPJg== - -"@cloudflare/workerd-windows-64@1.20240701.0": - version "1.20240701.0" - 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.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== +"@cloudflare/workerd-darwin-64@1.20240718.0": + version "1.20240718.0" + resolved "https://registry.yarnpkg.com/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20240718.0.tgz#46f438fb86ccd4772c29db52fe1d076bc9e6ffb4" + integrity sha512-BsPZcSCgoGnufog2GIgdPuiKicYTNyO/Dp++HbpLRH+yQdX3x4aWx83M+a0suTl1xv76dO4g9aw7SIB6OSgIyQ== + +"@cloudflare/workerd-darwin-arm64@1.20240718.0": + version "1.20240718.0" + resolved "https://registry.yarnpkg.com/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20240718.0.tgz#70e1dca5de4869ef3a9b9e296e934848bca6c74f" + integrity sha512-nlr4gaOO5gcJerILJQph3+2rnas/nx/lYsuaot1ntHu4LAPBoQo1q/Pucj2cSIav4UiMzTbDmoDwPlls4Kteog== + +"@cloudflare/workerd-linux-64@1.20240718.0": + version "1.20240718.0" + resolved "https://registry.yarnpkg.com/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20240718.0.tgz#802c04a1a5729f3881c675be3d158ee06c6b1a36" + integrity sha512-LJ/k3y47pBcjax0ee4K+6ZRrSsqWlfU4lbU8Dn6u5tSC9yzwI4YFNXDrKWInB0vd7RT3w4Yqq1S6ZEbfRrqVUg== + +"@cloudflare/workerd-linux-arm64@1.20240718.0": + version "1.20240718.0" + resolved "https://registry.yarnpkg.com/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20240718.0.tgz#cebff9115d48f8d0c2649fdf86ef46b726d1841f" + integrity sha512-zBEZvy88EcAMGRGfuVtS00Yl7lJdUM9sH7i651OoL+q0Plv9kphlCC0REQPwzxrEYT1qibSYtWcD9IxQGgx2/g== + +"@cloudflare/workerd-windows-64@1.20240718.0": + version "1.20240718.0" + resolved "https://registry.yarnpkg.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20240718.0.tgz#940893e62df7f5a8ec895572b834c95c1e256fbd" + integrity sha512-YpCRvvT47XanFum7C3SedOZKK6BfVhqmwdAAVAQFyc4gsCdegZo0JkUkdloC/jwuWlbCACOG2HTADHOqyeolzQ== + +"@cloudflare/workers-types@^4.x": + version "4.20240722.0" + resolved "https://registry.yarnpkg.com/@cloudflare/workers-types/-/workers-types-4.20240722.0.tgz#f5b9579fe5ff14077ae425208af6dea74249d130" + integrity sha512-/N072r7w7jNCgciRtImOQnHODn8CNwl3NHGO2lmC5bCsgevTRNeNj6B2IjS+OgEfo0qFoBgLejISl6ot9QvGvA== "@cnakazawa/watch@^1.0.3": version "1.0.4" @@ -24153,10 +24153,10 @@ mini-css-extract-plugin@2.6.1, mini-css-extract-plugin@^2.5.2: dependencies: schema-utils "^4.0.0" -miniflare@3.20240701.0, miniflare@^3.20240701.0: - version "3.20240701.0" - resolved "https://registry.yarnpkg.com/miniflare/-/miniflare-3.20240701.0.tgz#1c23b45baa65ed199da7d94c55d93f69cb4d48d2" - integrity sha512-m9+I+7JNyqDGftCMKp9cK9pCZkK72hAL2mM9IWwhct+ZmucLBA8Uu6+rHQqA5iod86cpwOkrB2PrPA3wx9YNgw== +miniflare@3.20240718.0, miniflare@^3.20240718.0: + version "3.20240718.0" + resolved "https://registry.yarnpkg.com/miniflare/-/miniflare-3.20240718.0.tgz#41561c6620b2b15803f5b3d2e903ed3af40f3b0b" + integrity sha512-TKgSeyqPBeT8TBLxbDJOKPWlq/wydoJRHjAyDdgxbw59N6wbP8JucK6AU1vXCfu21eKhrEin77ssXOpbfekzPA== dependencies: "@cspotcode/source-map-support" "0.8.1" acorn "^8.8.0" @@ -24166,7 +24166,7 @@ miniflare@3.20240701.0, miniflare@^3.20240701.0: glob-to-regexp "^0.4.1" stoppable "^1.1.0" undici "^5.28.4" - workerd "1.20240701.0" + workerd "1.20240718.0" ws "^8.17.1" youch "^3.2.2" zod "^3.22.3" @@ -33842,16 +33842,16 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= -workerd@1.20240701.0: - version "1.20240701.0" - resolved "https://registry.yarnpkg.com/workerd/-/workerd-1.20240701.0.tgz#aaed23a54158bae4faf313c6ed48aefe4b87cd5e" - integrity sha512-qSgNVqauqzNCij9MaJLF2c2ko3AnFioVSIxMSryGbRK+LvtGr9BKBt6JOxCb24DoJASoJDx3pe3DJHBVydUiBg== +workerd@1.20240718.0: + version "1.20240718.0" + resolved "https://registry.yarnpkg.com/workerd/-/workerd-1.20240718.0.tgz#7a397d0a159f7362dc3f7b19472190a858d96f7c" + integrity sha512-w7lOLRy0XecQTg/ujTLWBiJJuoQvzB3CdQ6/8Wgex3QxFhV9Pbnh3UbwIuUfMw3OCCPQc4o7y+1P+mISAgp6yg== optionalDependencies: - "@cloudflare/workerd-darwin-64" "1.20240701.0" - "@cloudflare/workerd-darwin-arm64" "1.20240701.0" - "@cloudflare/workerd-linux-64" "1.20240701.0" - "@cloudflare/workerd-linux-arm64" "1.20240701.0" - "@cloudflare/workerd-windows-64" "1.20240701.0" + "@cloudflare/workerd-darwin-64" "1.20240718.0" + "@cloudflare/workerd-darwin-arm64" "1.20240718.0" + "@cloudflare/workerd-linux-64" "1.20240718.0" + "@cloudflare/workerd-linux-arm64" "1.20240718.0" + "@cloudflare/workerd-windows-64" "1.20240718.0" workerpool@^3.1.1: version "3.1.2" @@ -33877,10 +33877,10 @@ workerpool@^6.4.0: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.4.0.tgz#f8d5cfb45fde32fa3b7af72ad617c3369567a462" integrity sha512-i3KR1mQMNwY2wx20ozq2EjISGtQWDIfV56We+yGJ5yDs8jTwQiLLaqHlkBHITlCuJnYlVRmXegxFxZg7gqI++A== -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== +wrangler@^3.65.1: + version "3.65.1" + resolved "https://registry.yarnpkg.com/wrangler/-/wrangler-3.65.1.tgz#493bd92b504f9f056cd57bbe2d430797600c914b" + integrity sha512-Z5NyrbpGMQCpim/6VnI1im0/Weh5+CU1sdep1JbfFxHjn/Jt9K+MeUq+kCns5ubkkdRx2EYsusB/JKyX2JdJ4w== dependencies: "@cloudflare/kv-asset-handler" "0.3.4" "@esbuild-plugins/node-globals-polyfill" "^0.2.3" @@ -33889,7 +33889,7 @@ wrangler@^3.64.0: chokidar "^3.5.3" date-fns "^3.6.0" esbuild "0.17.19" - miniflare "3.20240701.0" + miniflare "3.20240718.0" nanoid "^3.3.3" path-to-regexp "^6.2.0" resolve "^1.22.8" From e10cbe3eb0a5a2ab0cdd51a2863ab555df79d3ce Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 29 Jul 2024 08:58:54 -0400 Subject: [PATCH 2/5] add extra readme context --- packages/cloudflare/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 09794438cc2e..37f0cd94f412 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -28,7 +28,8 @@ To get started, first install the `@sentry/cloudflare` package: npm install @sentry/cloudflare ``` -Then set either the `nodejs_compat` or `nodejs_als` compatibility flags in your `wrangler.toml`: +Then set either the `nodejs_compat` or `nodejs_als` compatibility flags in your `wrangler.toml`. This is because the SDK +needs access to the `AsyncLocalStorage` API to work correctly. ```toml compatibility_flags = ["nodejs_compat"] @@ -46,6 +47,7 @@ import * as Sentry from '@sentry/cloudflare'; export default withSentry( (env) => ({ dsn: env.SENTRY_DSN, + // Set tracesSampleRate to 1.0 to capture 100% of spans for tracing. tracesSampleRate: 1.0, }), { From e95408c8cc58ae00750fbf138465daca157768c3 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 29 Jul 2024 09:09:23 -0400 Subject: [PATCH 3/5] update capture exception mechanism --- packages/cloudflare/src/handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 4e988a7e539f..45eca78f9946 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -101,7 +101,7 @@ export function withSentry>( setHttpStatus(span, res.status); return res; } catch (e) { - captureException(e, { mechanism: { handled: false } }); + captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); throw e; } finally { context.waitUntil(flush(2000)); From ed92ca72ba1fe387ed57d6923e585ec51dfbb9fc Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 29 Jul 2024 09:15:30 -0400 Subject: [PATCH 4/5] test refactors --- packages/cloudflare/test/handler.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts index 80738c270a4a..e8358dd63f50 100644 --- a/packages/cloudflare/test/handler.test.ts +++ b/packages/cloudflare/test/handler.test.ts @@ -1,4 +1,4 @@ -// Note: These tests run the handler in Node.js, which is has some differences to the cloudflare workers runtime. +// Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. // Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. import { beforeEach, describe, expect, test, vi } from 'vitest'; @@ -69,9 +69,9 @@ describe('withSentry', () => { }); test('creates a cloudflare client and sets it on the handler', async () => { + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); const handler = { async fetch(_request, _env, _context) { - expect(SentryCore.getClient() instanceof CloudflareClient).toBe(true); return new Response('test'); }, } satisfies ExportedHandler; @@ -80,7 +80,8 @@ describe('withSentry', () => { const wrappedHandler = withSentry(() => ({}), handler); await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, context); - expect.assertions(1); + expect(initAndBindSpy).toHaveBeenCalledTimes(1); + expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); }); describe('scope instrumentation', () => { @@ -180,7 +181,9 @@ describe('withSentry', () => { // ignore } expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { mechanism: { handled: false } }); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { + mechanism: { handled: false, type: 'cloudflare' }, + }); }); test('re-throws the error after capturing', async () => { From e02e283681c71979cedd007c7ab1fb2814acb50c Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 29 Jul 2024 09:18:27 -0400 Subject: [PATCH 5/5] test: Update init tests --- packages/cloudflare/test/sdk.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index cf93a2f219cf..5be45e2a47f2 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -1,13 +1,17 @@ import { describe, expect, test, vi } from 'vitest'; import * as SentryCore from '@sentry/core'; +import { CloudflareClient } from '../src/client'; import { init } from '../src/sdk'; describe('init', () => { test('should call initAndBind with the correct options', () => { const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); - init({}); + const client = init({}); - expect(initAndBindSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Object)); + expect(initAndBindSpy).toHaveBeenCalledWith(CloudflareClient, expect.any(Object)); + + expect(client).toBeDefined(); + expect(client).toBeInstanceOf(CloudflareClient); }); });