-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(cloudflare): Add
withSentry
method (#13025)
Before reviewing this patch, I recommend reading through a writeup I did: #13007 This PR adds `withSentry`, a method that wraps your cloudflare worker handler to add Sentry instrumentation. The writeup above explains why we need to do this over just a regular `Sentry.init` call. The implementation of `withSentry` is fairly straightforward, wrapping the fetch handler in the cloudflare worker with: 1. `withIsolationScope` to isolate it from other concurrent requests 2. helpers to update scope with relevant contexts/request 3. `continueTrace` to continue distributed tracing 4. `startSpan` to track spans Usage looks something like so: ```ts 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<Env>, ); ``` Next step here is to add more robust e2e tests, and then release an initial version!
- Loading branch information
1 parent
03257e0
commit d2ab51c
Showing
13 changed files
with
697 additions
and
72 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
import type { | ||
ExportedHandler, | ||
ExportedHandlerFetchHandler, | ||
IncomingRequestCfProperties, | ||
} from '@cloudflare/workers-types'; | ||
import { | ||
SEMANTIC_ATTRIBUTE_SENTRY_OP, | ||
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, | ||
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, | ||
captureException, | ||
continueTrace, | ||
flush, | ||
setHttpStatus, | ||
startSpan, | ||
withIsolationScope, | ||
} from '@sentry/core'; | ||
import type { Options, Scope, SpanAttributes } from '@sentry/types'; | ||
import { stripUrlQueryAndFragment, winterCGRequestToRequestData } from '@sentry/utils'; | ||
import { setAsyncLocalStorageAsyncContextStrategy } from './async'; | ||
import { init } from './sdk'; | ||
|
||
/** | ||
* Extract environment generic from exported handler. | ||
*/ | ||
type ExtractEnv<P> = P extends ExportedHandler<infer Env> ? 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<E extends ExportedHandler<any>>( | ||
optionsCallback: (env: ExtractEnv<E>) => 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<ExportedHandlerFetchHandler<ExtractEnv<E>>>) { | ||
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<typeof target>); | ||
setHttpStatus(span, res.status); | ||
return res; | ||
} catch (e) { | ||
captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); | ||
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) }); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
Oops, something went wrong.