Skip to content

Commit

Permalink
feat(cloudflare): Add cloudflare sdk scaffolding (#12953)
Browse files Browse the repository at this point in the history
This PR adds basic scaffolding for the cloudflare workers SDK. Most of
this is based on `@sentry/vercel-edge`.

This adds:

1. A basic cloudflare workers client
2. A set of default integrations for the cloudflare sdk (including a
fetch based one)
3. A cloudflare transport that uses the vercel-edge transport
4. An async context strategy powered by AsyncLocalStorage

You'll notice that there is no `init` method defined or exported from
the SDK. This is on purpose! `init` for cloudflare workers will work a
bit differently than the other SDKs, so I wanted to address it
differently on purpose. You'll see what that looks like in the next PR!
  • Loading branch information
AbhiPrasad committed Jul 18, 2024
1 parent f2ef53e commit a17c8c4
Show file tree
Hide file tree
Showing 14 changed files with 1,052 additions and 37 deletions.
5 changes: 3 additions & 2 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions packages/cloudflare/src/async.ts
Original file line number Diff line number Diff line change
@@ -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<T>(callback: (scope: Scope) => T): T {
const scope = getScopes().scope.clone();
const isolationScope = getScopes().isolationScope;
return asyncStorage.run({ scope, isolationScope }, () => {
return callback(scope);
});
}

function withSetScope<T>(scope: Scope, callback: (scope: Scope) => T): T {
const isolationScope = getScopes().isolationScope.clone();
return asyncStorage.run({ scope, isolationScope }, () => {
return callback(scope);
});
}

function withIsolationScope<T>(callback: (isolationScope: Scope) => T): T {
const scope = getScopes().scope;
const isolationScope = getScopes().isolationScope.clone();
return asyncStorage.run({ scope, isolationScope }, () => {
return callback(isolationScope);
});
}

function withSetIsolationScope<T>(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,
});
}
49 changes: 49 additions & 0 deletions packages/cloudflare/src/client.ts
Original file line number Diff line number Diff line change
@@ -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<CloudflareClientOptions> {
/**
* 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<CloudflareTransportOptions>, BaseCloudflareOptions {}

/**
* Configuration options for the Sentry Cloudflare SDK Client class
*
* @see CloudflareClient for more information.
*/
export interface CloudflareClientOptions extends ClientOptions<CloudflareTransportOptions>, BaseCloudflareOptions {}
91 changes: 90 additions & 1 deletion packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
162 changes: 162 additions & 0 deletions packages/cloudflare/src/integrations/fetch.ts
Original file line number Diff line number Diff line change
@@ -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<Client, boolean>();

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<Options> = {}) => {
const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs;
const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest;

const _createSpanUrlMap = new LRUMap<string, boolean>(100);
const _headersUrlMap = new LRUMap<string, boolean>(100);

const spans: Record<string, Span> = {};

/** 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,
);
}
}
Loading

0 comments on commit a17c8c4

Please sign in to comment.