Skip to content

Commit 7fed03d

Browse files
committed
Merge branch 'develop' into manual-master-develop-sync
2 parents 2bd0c99 + 2930499 commit 7fed03d

File tree

11 files changed

+410
-2
lines changed

11 files changed

+410
-2
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
// Force this so that the initial sampleRand is consistent
6+
Math.random = () => 0.45;
7+
8+
Sentry.init({
9+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
10+
integrations: [Sentry.browserTracingIntegration()],
11+
tracePropagationTargets: ['http://sentry-test-site.example'],
12+
tracesSampler: ({ name }) => {
13+
if (name === 'new-trace') {
14+
return 0.9;
15+
}
16+
17+
return 0.5;
18+
},
19+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const newTraceBtn = document.getElementById('newTrace');
2+
newTraceBtn.addEventListener('click', async () => {
3+
Sentry.startNewTrace(() => {
4+
// We want to ensure the new trace is sampled, so we force the sample_rand to a value above 0.9
5+
Sentry.getCurrentScope().setPropagationContext({
6+
...Sentry.getCurrentScope().getPropagationContext(),
7+
sampleRand: 0.85,
8+
});
9+
Sentry.startSpan({ op: 'ui.interaction.click', name: 'new-trace' }, async () => {
10+
await fetch('http://sentry-test-site.example');
11+
});
12+
});
13+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="newTrace">new Trace</button>
8+
</body>
9+
</html>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { expect } from '@playwright/test';
2+
import { sentryTest } from '../../../../utils/fixtures';
3+
import type { EventAndTraceHeader } from '../../../../utils/helpers';
4+
import {
5+
eventAndTraceHeaderRequestParser,
6+
getFirstSentryEnvelopeRequest,
7+
shouldSkipTracingTest,
8+
waitForTransactionRequest,
9+
} from '../../../../utils/helpers';
10+
11+
sentryTest(
12+
'new trace started with `startNewTrace` is sampled according to the `tracesSampler`',
13+
async ({ getLocalTestUrl, page }) => {
14+
if (shouldSkipTracingTest()) {
15+
sentryTest.skip();
16+
}
17+
18+
const url = await getLocalTestUrl({ testDir: __dirname });
19+
20+
await page.route('http://sentry-test-site.example/**', route => {
21+
return route.fulfill({
22+
status: 200,
23+
contentType: 'application/json',
24+
body: JSON.stringify({}),
25+
});
26+
});
27+
28+
const [pageloadEvent, pageloadTraceHeaders] = await getFirstSentryEnvelopeRequest<EventAndTraceHeader>(
29+
page,
30+
url,
31+
eventAndTraceHeaderRequestParser,
32+
);
33+
34+
const pageloadTraceContext = pageloadEvent.contexts?.trace;
35+
36+
expect(pageloadEvent.type).toEqual('transaction');
37+
38+
expect(pageloadTraceContext).toMatchObject({
39+
op: 'pageload',
40+
trace_id: expect.stringMatching(/^[0-9a-f]{32}$/),
41+
span_id: expect.stringMatching(/^[0-9a-f]{16}$/),
42+
data: {
43+
'sentry.sample_rate': 0.5,
44+
},
45+
});
46+
expect(pageloadTraceContext).not.toHaveProperty('parent_span_id');
47+
48+
expect(pageloadTraceHeaders).toEqual({
49+
environment: 'production',
50+
public_key: 'public',
51+
sample_rate: '0.5',
52+
sampled: 'true',
53+
trace_id: pageloadTraceContext?.trace_id,
54+
sample_rand: '0.45',
55+
});
56+
57+
const transactionPromise = waitForTransactionRequest(page, event => {
58+
return event.transaction === 'new-trace';
59+
});
60+
61+
await page.locator('#newTrace').click();
62+
63+
const [newTraceTransactionEvent, newTraceTransactionTraceHeaders] = eventAndTraceHeaderRequestParser(
64+
await transactionPromise,
65+
);
66+
67+
const newTraceTransactionTraceContext = newTraceTransactionEvent.contexts?.trace;
68+
expect(newTraceTransactionTraceContext).toMatchObject({
69+
op: 'ui.interaction.click',
70+
trace_id: expect.stringMatching(/^[0-9a-f]{32}$/),
71+
span_id: expect.stringMatching(/^[0-9a-f]{16}$/),
72+
data: {
73+
'sentry.sample_rate': 0.9,
74+
},
75+
});
76+
77+
expect(newTraceTransactionTraceHeaders).toEqual({
78+
environment: 'production',
79+
public_key: 'public',
80+
sample_rate: '0.9',
81+
sampled: 'true',
82+
trace_id: newTraceTransactionTraceContext?.trace_id,
83+
transaction: 'new-trace',
84+
sample_rand: '0.85',
85+
});
86+
87+
expect(newTraceTransactionTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id);
88+
},
89+
);

packages/cloudflare/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"access": "public"
5050
},
5151
"dependencies": {
52-
"@sentry/core": "9.32.0"
52+
"@sentry/core": "9.32.0",
53+
"@opentelemetry/api": "^1.9.0"
5354
},
5455
"peerDependencies": {
5556
"@cloudflare/workers-types": "^4.x"

packages/cloudflare/src/client.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ interface BaseCloudflareOptions {
3636
* @default true
3737
*/
3838
enableDedupe?: boolean;
39+
40+
/**
41+
* The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility
42+
* via a custom trace provider.
43+
* This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry.
44+
* HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope.
45+
* This should be good enough for many, but not all integrations.
46+
*
47+
* If you want to opt-out of setting up the OpenTelemetry compatibility tracer, set this to `true`.
48+
*
49+
* @default false
50+
*/
51+
skipOpenTelemetrySetup?: boolean;
3952
}
4053

4154
/**
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api';
2+
import { trace } from '@opentelemetry/api';
3+
import { startInactiveSpan, startSpanManual } from '@sentry/core';
4+
5+
/**
6+
* Set up a mock OTEL tracer to allow inter-op with OpenTelemetry emitted spans.
7+
* This is not perfect but handles easy/common use cases.
8+
*/
9+
export function setupOpenTelemetryTracer(): void {
10+
trace.setGlobalTracerProvider(new SentryCloudflareTraceProvider());
11+
}
12+
13+
class SentryCloudflareTraceProvider implements TracerProvider {
14+
private readonly _tracers: Map<string, Tracer> = new Map();
15+
16+
public getTracer(name: string, version?: string, options?: { schemaUrl?: string }): Tracer {
17+
const key = `${name}@${version || ''}:${options?.schemaUrl || ''}`;
18+
if (!this._tracers.has(key)) {
19+
this._tracers.set(key, new SentryCloudflareTracer());
20+
}
21+
22+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
23+
return this._tracers.get(key)!;
24+
}
25+
}
26+
27+
class SentryCloudflareTracer implements Tracer {
28+
public startSpan(name: string, options?: SpanOptions): Span {
29+
return startInactiveSpan({
30+
name,
31+
...options,
32+
attributes: {
33+
...options?.attributes,
34+
'sentry.cloudflare_tracer': true,
35+
},
36+
});
37+
}
38+
39+
/**
40+
* NOTE: This does not handle `context` being passed in. It will always put spans on the current scope.
41+
*/
42+
public startActiveSpan<F extends (span: Span) => unknown>(name: string, fn: F): ReturnType<F>;
43+
public startActiveSpan<F extends (span: Span) => unknown>(name: string, options: SpanOptions, fn: F): ReturnType<F>;
44+
public startActiveSpan<F extends (span: Span) => unknown>(
45+
name: string,
46+
options: SpanOptions,
47+
context: Context,
48+
fn: F,
49+
): ReturnType<F>;
50+
public startActiveSpan<F extends (span: Span) => unknown>(
51+
name: string,
52+
options: unknown,
53+
context?: unknown,
54+
fn?: F,
55+
): ReturnType<F> {
56+
const opts = (typeof options === 'object' && options !== null ? options : {}) as SpanOptions;
57+
58+
const spanOpts = {
59+
name,
60+
...opts,
61+
attributes: {
62+
...opts.attributes,
63+
'sentry.cloudflare_tracer': true,
64+
},
65+
};
66+
67+
const callback = (
68+
typeof options === 'function'
69+
? options
70+
: typeof context === 'function'
71+
? context
72+
: typeof fn === 'function'
73+
? fn
74+
: // eslint-disable-next-line @typescript-eslint/no-empty-function
75+
() => {}
76+
) as F;
77+
78+
// In OTEL the semantic matches `startSpanManual` because spans are not auto-ended
79+
return startSpanManual(spanOpts, callback) as ReturnType<F>;
80+
}
81+
}

packages/cloudflare/src/sdk.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import type { CloudflareClientOptions, CloudflareOptions } from './client';
1414
import { CloudflareClient } from './client';
1515
import { fetchIntegration } from './integrations/fetch';
16+
import { setupOpenTelemetryTracer } from './opentelemetry/tracer';
1617
import { makeCloudflareTransport } from './transport';
1718
import { defaultStackParser } from './vendor/stacktrace';
1819

@@ -50,5 +51,16 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined {
5051
transport: options.transport || makeCloudflareTransport,
5152
};
5253

54+
/**
55+
* The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility
56+
* via a custom trace provider.
57+
* This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry.
58+
* HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope.
59+
* This should be good enough for many, but not all integrations.
60+
*/
61+
if (!options.skipOpenTelemetrySetup) {
62+
setupOpenTelemetryTracer();
63+
}
64+
5365
return initAndBind(CloudflareClient, clientOptions) as CloudflareClient;
5466
}

0 commit comments

Comments
 (0)