Skip to content

Commit 8241555

Browse files
committed
feat(cloudflare): Add cloudflare sdk scaffolding
1 parent 7adbec4 commit 8241555

File tree

14 files changed

+1052
-37
lines changed

14 files changed

+1052
-37
lines changed

packages/cloudflare/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,10 @@
4444
"@sentry/utils": "8.18.0"
4545
},
4646
"devDependencies": {
47-
"@cloudflare/workers-types": "^4.20240620.0",
47+
"@cloudflare/workers-types": "^4.20240712.0",
48+
"@types/node": "^14.18.0",
4849
"miniflare": "^3.20240701.0",
49-
"wrangler": "^3.63.2"
50+
"wrangler": "^3.64.0"
5051
},
5152
"scripts": {
5253
"build": "run-p build:transpile build:types",

packages/cloudflare/src/async.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core';
2+
import type { Scope } from '@sentry/types';
3+
4+
// Need to use node: prefix for cloudflare workers compatibility
5+
// Note: Because we are using node:async_hooks, we need to set `node_compat` in the wrangler.toml
6+
import { AsyncLocalStorage } from 'node:async_hooks';
7+
8+
/**
9+
* Sets the async context strategy to use AsyncLocalStorage.
10+
*
11+
* AsyncLocalStorage is only avalaible in the cloudflare workers runtime if you set
12+
* compatibility_flags = ["nodejs_compat"] or compatibility_flags = ["nodejs_als"]
13+
*/
14+
export function setAsyncLocalStorageAsyncContextStrategy(): void {
15+
const asyncStorage = new AsyncLocalStorage<{
16+
scope: Scope;
17+
isolationScope: Scope;
18+
}>();
19+
20+
function getScopes(): { scope: Scope; isolationScope: Scope } {
21+
const scopes = asyncStorage.getStore();
22+
23+
if (scopes) {
24+
return scopes;
25+
}
26+
27+
// fallback behavior:
28+
// if, for whatever reason, we can't find scopes on the context here, we have to fix this somehow
29+
return {
30+
scope: getDefaultCurrentScope(),
31+
isolationScope: getDefaultIsolationScope(),
32+
};
33+
}
34+
35+
function withScope<T>(callback: (scope: Scope) => T): T {
36+
const scope = getScopes().scope.clone();
37+
const isolationScope = getScopes().isolationScope;
38+
return asyncStorage.run({ scope, isolationScope }, () => {
39+
return callback(scope);
40+
});
41+
}
42+
43+
function withSetScope<T>(scope: Scope, callback: (scope: Scope) => T): T {
44+
const isolationScope = getScopes().isolationScope.clone();
45+
return asyncStorage.run({ scope, isolationScope }, () => {
46+
return callback(scope);
47+
});
48+
}
49+
50+
function withIsolationScope<T>(callback: (isolationScope: Scope) => T): T {
51+
const scope = getScopes().scope;
52+
const isolationScope = getScopes().isolationScope.clone();
53+
return asyncStorage.run({ scope, isolationScope }, () => {
54+
return callback(isolationScope);
55+
});
56+
}
57+
58+
function withSetIsolationScope<T>(isolationScope: Scope, callback: (isolationScope: Scope) => T): T {
59+
const scope = getScopes().scope;
60+
return asyncStorage.run({ scope, isolationScope }, () => {
61+
return callback(isolationScope);
62+
});
63+
}
64+
65+
setAsyncContextStrategy({
66+
withScope,
67+
withSetScope,
68+
withIsolationScope,
69+
withSetIsolationScope,
70+
getCurrentScope: () => getScopes().scope,
71+
getIsolationScope: () => getScopes().isolationScope,
72+
});
73+
}

packages/cloudflare/src/client.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { ServerRuntimeClientOptions } from '@sentry/core';
2+
import { ServerRuntimeClient, applySdkMetadata } from '@sentry/core';
3+
import type { ClientOptions, Options } from '@sentry/types';
4+
5+
import type { CloudflareTransportOptions } from './transport';
6+
7+
/**
8+
* The Sentry Cloudflare SDK Client.
9+
*
10+
* @see CloudflareClientOptions for documentation on configuration options.
11+
* @see ServerRuntimeClient for usage documentation.
12+
*/
13+
export class CloudflareClient extends ServerRuntimeClient<CloudflareClientOptions> {
14+
/**
15+
* Creates a new Cloudflare SDK instance.
16+
* @param options Configuration options for this SDK.
17+
*/
18+
public constructor(options: CloudflareClientOptions) {
19+
applySdkMetadata(options, 'options');
20+
options._metadata = options._metadata || {};
21+
22+
const clientOptions: ServerRuntimeClientOptions = {
23+
...options,
24+
platform: 'javascript',
25+
// TODO: Grab version information
26+
runtime: { name: 'cloudflare' },
27+
// TODO: Add server name
28+
};
29+
30+
super(clientOptions);
31+
}
32+
}
33+
34+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
35+
interface BaseCloudflareOptions {}
36+
37+
/**
38+
* Configuration options for the Sentry Cloudflare SDK
39+
*
40+
* @see @sentry/types Options for more information.
41+
*/
42+
export interface CloudflareOptions extends Options<CloudflareTransportOptions>, BaseCloudflareOptions {}
43+
44+
/**
45+
* Configuration options for the Sentry Cloudflare SDK Client class
46+
*
47+
* @see CloudflareClient for more information.
48+
*/
49+
export interface CloudflareClientOptions extends ClientOptions<CloudflareTransportOptions>, BaseCloudflareOptions {}

packages/cloudflare/src/index.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,90 @@
1-
export {};
1+
export type {
2+
Breadcrumb,
3+
BreadcrumbHint,
4+
PolymorphicRequest,
5+
Request,
6+
SdkInfo,
7+
Event,
8+
EventHint,
9+
ErrorEvent,
10+
Exception,
11+
Session,
12+
SeverityLevel,
13+
Span,
14+
StackFrame,
15+
Stacktrace,
16+
Thread,
17+
User,
18+
} from '@sentry/types';
19+
export type { AddRequestDataToEventOptions } from '@sentry/utils';
20+
21+
export type { CloudflareOptions } from './client';
22+
23+
export {
24+
addEventProcessor,
25+
addBreadcrumb,
26+
addIntegration,
27+
captureException,
28+
captureEvent,
29+
captureMessage,
30+
captureFeedback,
31+
close,
32+
createTransport,
33+
lastEventId,
34+
flush,
35+
getClient,
36+
isInitialized,
37+
getCurrentScope,
38+
getGlobalScope,
39+
getIsolationScope,
40+
setCurrentClient,
41+
Scope,
42+
SDK_VERSION,
43+
setContext,
44+
setExtra,
45+
setExtras,
46+
setTag,
47+
setTags,
48+
setUser,
49+
getSpanStatusFromHttpCode,
50+
setHttpStatus,
51+
withScope,
52+
withIsolationScope,
53+
captureCheckIn,
54+
withMonitor,
55+
setMeasurement,
56+
getActiveSpan,
57+
getRootSpan,
58+
startSpan,
59+
startInactiveSpan,
60+
startSpanManual,
61+
startNewTrace,
62+
withActiveSpan,
63+
getSpanDescendants,
64+
continueTrace,
65+
metrics,
66+
functionToStringIntegration,
67+
inboundFiltersIntegration,
68+
linkedErrorsIntegration,
69+
requestDataIntegration,
70+
extraErrorDataIntegration,
71+
debugIntegration,
72+
dedupeIntegration,
73+
rewriteFramesIntegration,
74+
captureConsoleIntegration,
75+
moduleMetadataIntegration,
76+
zodErrorsIntegration,
77+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
78+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
79+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
80+
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
81+
trpcMiddleware,
82+
spanToJSON,
83+
spanToTraceHeader,
84+
spanToBaggageHeader,
85+
} from '@sentry/core';
86+
87+
export { CloudflareClient } from './client';
88+
export { getDefaultIntegrations } from './sdk';
89+
90+
export { fetchIntegration } from './integrations/fetch';
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { addBreadcrumb, defineIntegration, getClient, instrumentFetchRequest, isSentryRequestUrl } from '@sentry/core';
2+
import type {
3+
Client,
4+
FetchBreadcrumbData,
5+
FetchBreadcrumbHint,
6+
HandlerDataFetch,
7+
IntegrationFn,
8+
Span,
9+
} from '@sentry/types';
10+
import { LRUMap, addFetchInstrumentationHandler, stringMatchesSomePattern } from '@sentry/utils';
11+
12+
const INTEGRATION_NAME = 'Fetch';
13+
14+
const HAS_CLIENT_MAP = new WeakMap<Client, boolean>();
15+
16+
export interface Options {
17+
/**
18+
* Whether breadcrumbs should be recorded for requests
19+
* Defaults to true
20+
*/
21+
breadcrumbs: boolean;
22+
23+
/**
24+
* Function determining whether or not to create spans to track outgoing requests to the given URL.
25+
* By default, spans will be created for all outgoing requests.
26+
*/
27+
shouldCreateSpanForRequest?: (url: string) => boolean;
28+
}
29+
30+
const _fetchIntegration = ((options: Partial<Options> = {}) => {
31+
const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs;
32+
const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest;
33+
34+
const _createSpanUrlMap = new LRUMap<string, boolean>(100);
35+
const _headersUrlMap = new LRUMap<string, boolean>(100);
36+
37+
const spans: Record<string, Span> = {};
38+
39+
/** Decides whether to attach trace data to the outgoing fetch request */
40+
function _shouldAttachTraceData(url: string): boolean {
41+
const client = getClient();
42+
43+
if (!client) {
44+
return false;
45+
}
46+
47+
const clientOptions = client.getOptions();
48+
49+
if (clientOptions.tracePropagationTargets === undefined) {
50+
return true;
51+
}
52+
53+
const cachedDecision = _headersUrlMap.get(url);
54+
if (cachedDecision !== undefined) {
55+
return cachedDecision;
56+
}
57+
58+
const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets);
59+
_headersUrlMap.set(url, decision);
60+
return decision;
61+
}
62+
63+
/** Helper that wraps shouldCreateSpanForRequest option */
64+
function _shouldCreateSpan(url: string): boolean {
65+
if (shouldCreateSpanForRequest === undefined) {
66+
return true;
67+
}
68+
69+
const cachedDecision = _createSpanUrlMap.get(url);
70+
if (cachedDecision !== undefined) {
71+
return cachedDecision;
72+
}
73+
74+
const decision = shouldCreateSpanForRequest(url);
75+
_createSpanUrlMap.set(url, decision);
76+
return decision;
77+
}
78+
79+
return {
80+
name: INTEGRATION_NAME,
81+
setupOnce() {
82+
addFetchInstrumentationHandler(handlerData => {
83+
const client = getClient();
84+
if (!client || !HAS_CLIENT_MAP.get(client)) {
85+
return;
86+
}
87+
88+
if (isSentryRequestUrl(handlerData.fetchData.url, client)) {
89+
return;
90+
}
91+
92+
instrumentFetchRequest(
93+
handlerData,
94+
_shouldCreateSpan,
95+
_shouldAttachTraceData,
96+
spans,
97+
'auto.http.wintercg_fetch',
98+
);
99+
100+
if (breadcrumbs) {
101+
createBreadcrumb(handlerData);
102+
}
103+
});
104+
},
105+
setup(client) {
106+
HAS_CLIENT_MAP.set(client, true);
107+
},
108+
};
109+
}) satisfies IntegrationFn;
110+
111+
/**
112+
* Creates spans and attaches tracing headers to fetch requests.
113+
*/
114+
export const fetchIntegration = defineIntegration(_fetchIntegration);
115+
116+
function createBreadcrumb(handlerData: HandlerDataFetch): void {
117+
const { startTimestamp, endTimestamp } = handlerData;
118+
119+
// We only capture complete fetch requests
120+
if (!endTimestamp) {
121+
return;
122+
}
123+
124+
if (handlerData.error) {
125+
const data = handlerData.fetchData;
126+
const hint: FetchBreadcrumbHint = {
127+
data: handlerData.error,
128+
input: handlerData.args,
129+
startTimestamp,
130+
endTimestamp,
131+
};
132+
133+
addBreadcrumb(
134+
{
135+
category: 'fetch',
136+
data,
137+
level: 'error',
138+
type: 'http',
139+
},
140+
hint,
141+
);
142+
} else {
143+
const data: FetchBreadcrumbData = {
144+
...handlerData.fetchData,
145+
status_code: handlerData.response && handlerData.response.status,
146+
};
147+
const hint: FetchBreadcrumbHint = {
148+
input: handlerData.args,
149+
response: handlerData.response,
150+
startTimestamp,
151+
endTimestamp,
152+
};
153+
addBreadcrumb(
154+
{
155+
category: 'fetch',
156+
data,
157+
type: 'http',
158+
},
159+
hint,
160+
);
161+
}
162+
}

0 commit comments

Comments
 (0)