Skip to content

Commit b8104a4

Browse files
authored
Merge branch 'develop' into sig/type-linting
2 parents a508367 + 936541e commit b8104a4

87 files changed

Lines changed: 2993 additions & 920 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.size-limit.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ module.exports = [
485485
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
486486
gzip: false,
487487
brotli: false,
488-
limit: '522 KiB',
488+
limit: '540 KiB',
489489
disablePlugins: ['@size-limit/webpack'],
490490
webpack: false,
491491
modifyEsbuildConfig: function (config) {

‎MIGRATION.md‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -615,21 +615,28 @@ These changes are not caught by TypeScript. If you filter, group, or alert on sp
615615

616616
### Span name changes
617617

618-
Affected SDKs: All SDKs running in the browser.
618+
Affected SDKs: All SDKs.
619619

620620
With [span streaming](#span-streaming-is-now-the-default) enabled(the default), span names are now **low cardinality**, following the [Sentry span name conventions](https://getsentry.github.io/sentry-conventions/names/).
621621

622-
In v11, this only affects `pageload` spans. Further ops will follow in future releases.
622+
In v11, this affects `pageload` and `graphql` spans. Further ops will follow in future releases.
623623
If you [opt out of span streaming](#opting-out-of-span-streaming), span names remain unchanged.
624624

625625
The following span names were adjusted:
626626

627-
| Span op | Before | After |
628-
| ---------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
629-
| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none |
627+
| Span op | Before | After |
628+
| ---------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
629+
| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none |
630+
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
630631

631632
Some consequences to be aware of:
632633

634+
The graphql operation name and the resolver field path are supplied by the client, so they are no longer part of a span name. They remain available on the `graphql.operation.name` and `graphql.field.path` attributes.
635+
636+
Because a low-cardinality name cannot say which part of request processing a span covers, every graphql span now carries a `graphql.processing.type` attribute (`parse`, `validate`, `execute` or `resolve`). Use it to tell parse, validate and resolve spans apart. The attribute is set in both trace lifecycles.
637+
638+
For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute, as long as the option stays enabled (the default). Disabling it skips both, as before.
639+
633640
Child spans of a pageload span carry its name in their `sentry.segment.name` attribute, so that changes with it. If you group or filter spans by segment name in dashboards or alerts, update those references.
634641

635642
`ignoreSpans` is evaluated when a span **starts**, at which point a pageload span without a resolved route is already named `'Pageload'`, so filters matching a URL path no longer apply to it. Match on attributes instead:

‎dev-packages/cloudflare-integration-tests/runner.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export function createRunner(...paths: string[]) {
161161

162162
// controls whether envelopes are expected in predefined order or not
163163
let unordered = false;
164+
let failOnUnexpected = false;
164165

165166
if (!existsSync(testPath)) {
166167
throw new Error(`Test scenario not found: ${testPath}`);
@@ -195,6 +196,10 @@ export function createRunner(...paths: string[]) {
195196
unordered = true;
196197
return this;
197198
},
199+
failOnUnexpected: function () {
200+
failOnUnexpected = true;
201+
return this;
202+
},
198203
ignore: function (...types: EnvelopeItemType[]) {
199204
types.forEach(t => ignored.add(t));
200205
return this;
@@ -222,6 +227,7 @@ export function createRunner(...paths: string[]) {
222227
const expectedEnvelopeCount = expectedEnvelopes.length;
223228

224229
let envelopeCount = 0;
230+
let unexpectedEnvelopeError: Error | undefined;
225231
const envelopeWaiters: { expected: Expected; resolve: () => void; reject: (e: unknown) => void }[] = [];
226232
const {
227233
resolve: setWorkerPort,
@@ -297,6 +303,10 @@ export function createRunner(...paths: string[]) {
297303

298304
// no match found
299305
if (matchIndex < 0) {
306+
if (failOnUnexpected) {
307+
unexpectedEnvelopeError ??= new Error('Received an unexpected envelope');
308+
reject(unexpectedEnvelopeError);
309+
}
300310
return;
301311
}
302312

@@ -429,7 +439,10 @@ export function createRunner(...paths: string[]) {
429439

430440
return {
431441
completed: async function (): Promise<void> {
432-
return isComplete;
442+
await isComplete;
443+
if (unexpectedEnvelopeError) {
444+
throw unexpectedEnvelopeError;
445+
}
433446
},
434447
makeRequest: async function <T>(
435448
method: 'get' | 'post',
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
CACHE_DO: DurableObjectNamespace;
7+
NO_CACHE_DO: DurableObjectNamespace;
8+
}
9+
10+
/**
11+
* Sync KV and SQL work against the DO's own storage, which the SDK instruments into `db` spans.
12+
* Used to check that those spans still reach the transport from inside a Durable Object, where a
13+
* cached client never hits an invocation-boundary flush and has to rely on the eager drain.
14+
*/
15+
function runStorageOps(ctx: DurableObjectState): { listSize: number; rows: number } {
16+
ctx.storage.kv.put('cache-key', { hello: 'sync' });
17+
ctx.storage.kv.get('cache-key');
18+
const entries = [...ctx.storage.kv.list()];
19+
ctx.storage.kv.delete('cache-key');
20+
21+
ctx.storage.sql.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)');
22+
ctx.storage.sql.exec('INSERT INTO users (name) VALUES (?)', 'Alice');
23+
const rows = ctx.storage.sql.exec('SELECT * FROM users').toArray();
24+
25+
return { listSize: entries.length, rows: rows.length };
26+
}
27+
28+
function startDetachedWork(message: string): string {
29+
void (async () => {
30+
await new Promise(r => setTimeout(r, 3000));
31+
await Sentry.startSpan({ name: 'do.detached-task', op: 'task' }, async () => {
32+
Sentry.logger.info(`Detached log: ${message}`);
33+
Sentry.metrics.count('do.detached', 1);
34+
Sentry.captureException(new Error(message));
35+
});
36+
})();
37+
return `Detached work started: ${message}`;
38+
}
39+
40+
// DO with cacheClient: true (the default) — detached work events SHOULD be captured
41+
class CacheDurableObjectBase extends DurableObject<Env> {
42+
async echo(n: number): Promise<number> {
43+
return n;
44+
}
45+
46+
async handlerError(instanceId: string): Promise<void> {
47+
throw new Error(`Cache DO handler error from ${instanceId}`);
48+
}
49+
50+
async dedupe(errorMessage: string): Promise<string> {
51+
Sentry.captureException(new Error(errorMessage));
52+
return 'dedupe test';
53+
}
54+
55+
async scopeCheck(seed: boolean): Promise<string> {
56+
if (seed) {
57+
Sentry.setTag('seeded_tag', 'from-seeding-call');
58+
Sentry.setUser({ id: 'user-from-seeding-call' });
59+
}
60+
Sentry.captureException(new Error(seed ? 'Cache scope seed' : 'Cache scope probe'));
61+
return 'ok';
62+
}
63+
64+
async storage(): Promise<string> {
65+
const { listSize, rows } = runStorageOps(this.ctx);
66+
return `cache storage ${listSize}/${rows}`;
67+
}
68+
69+
async fetch(request: Request): Promise<Response> {
70+
const url = new URL(request.url);
71+
if (url.pathname === '/detached') {
72+
return new Response(startDetachedWork(`Detached work from cache DO ${url.searchParams.get('id')}`));
73+
}
74+
if (url.pathname === '/streaming') {
75+
const stream = new ReadableStream({
76+
start(controller) {
77+
controller.enqueue(new TextEncoder().encode('chunk1'));
78+
controller.enqueue(new TextEncoder().encode('chunk2'));
79+
controller.close();
80+
},
81+
});
82+
return new Response(stream, { headers: { 'content-type': 'text/event-stream' } });
83+
}
84+
return new Response('Cache DO');
85+
}
86+
}
87+
88+
// DO with cacheClient: false — detached work events should NOT be captured
89+
class NoCacheDurableObjectBase extends DurableObject<Env> {
90+
async handlerError(instanceId: string): Promise<void> {
91+
throw new Error(`No-cache DO handler error from ${instanceId}`);
92+
}
93+
94+
async dedupe(errorMessage: string): Promise<string> {
95+
Sentry.captureException(new Error(errorMessage));
96+
return 'dedupe test';
97+
}
98+
99+
async storage(): Promise<string> {
100+
const { listSize, rows } = runStorageOps(this.ctx);
101+
return `no-cache storage ${listSize}/${rows}`;
102+
}
103+
104+
async fetch(request: Request): Promise<Response> {
105+
const url = new URL(request.url);
106+
if (url.pathname === '/detached') {
107+
return new Response(startDetachedWork(`Detached work from no-cache DO ${url.searchParams.get('id')}`));
108+
}
109+
return new Response('No-cache DO');
110+
}
111+
}
112+
113+
export const CacheDurableObject = Sentry.instrumentDurableObjectWithSentry(
114+
(env: Env) => ({
115+
dsn: env.SENTRY_DSN,
116+
tracesSampleRate: 1,
117+
enableRpcTracePropagation: true,
118+
}),
119+
CacheDurableObjectBase,
120+
);
121+
122+
export const NoCacheDurableObject = Sentry.instrumentDurableObjectWithSentry(
123+
(env: Env) => ({
124+
dsn: env.SENTRY_DSN,
125+
tracesSampleRate: 1,
126+
cacheClient: false,
127+
enableRpcTracePropagation: true,
128+
}),
129+
NoCacheDurableObjectBase,
130+
);
131+
132+
export default Sentry.withSentry(
133+
(env: Env) => ({
134+
dsn: env.SENTRY_DSN,
135+
tracesSampleRate: 1,
136+
enableRpcTracePropagation: true,
137+
}),
138+
{
139+
async fetch(request, env, ctx) {
140+
const url = new URL(request.url);
141+
const instanceId = url.searchParams.get('id') || 'default';
142+
const errorMessage = url.searchParams.get('errorMessage') || 'Same error';
143+
144+
// Work that finishes AFTER the response: a post-response span tree plus a
145+
// log, metric and error, all registered via waitUntil. This is the worker-side
146+
// half of the #22545 lifecycle (the DO-side half is /detached).
147+
if (url.pathname === '/post-response') {
148+
ctx.waitUntil(
149+
Sentry.startSpan({ name: 'checkout.post-response', op: 'task' }, async () => {
150+
Sentry.logger.info('checkout post-response log');
151+
Sentry.metrics.count('checkout.processed', 1);
152+
await new Promise(r => setTimeout(r, 50));
153+
await Sentry.startSpan({ name: 'checkout.notify-webhook', op: 'http.client' }, async () => {
154+
await new Promise(r => setTimeout(r, 25));
155+
Sentry.captureException(new Error('Webhook delivery failed'));
156+
});
157+
}),
158+
);
159+
return new Response('checkout accepted');
160+
}
161+
162+
// Fan a single request out into N sequential DO RPC calls — every RPC span must
163+
// land in this request's trace when RPC trace propagation is on.
164+
if (url.pathname === '/burst') {
165+
const n = Math.min(Number(url.searchParams.get('n')) || 1, 20);
166+
const stub = env.CACHE_DO.get(
167+
env.CACHE_DO.idFromName(`burst-${instanceId}`),
168+
) as DurableObjectStub<CacheDurableObjectBase>;
169+
170+
let sum = 0;
171+
for (let i = 0; i < n; i++) {
172+
sum += (await stub.echo(i)) as number;
173+
}
174+
return Response.json({ calls: n, sum });
175+
}
176+
177+
// Cache DO RPC calls
178+
if (url.pathname === '/cache/handler-error') {
179+
const stub = env.CACHE_DO.get(
180+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
181+
) as DurableObjectStub<CacheDurableObjectBase>;
182+
await stub.handlerError(instanceId);
183+
}
184+
185+
if (url.pathname === '/cache/dedupe') {
186+
const stub = env.CACHE_DO.get(
187+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
188+
) as DurableObjectStub<CacheDurableObjectBase>;
189+
const result = await stub.dedupe(errorMessage);
190+
return new Response(String(result));
191+
}
192+
193+
if (url.pathname === '/cache/scope') {
194+
const stub = env.CACHE_DO.get(
195+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
196+
) as DurableObjectStub<CacheDurableObjectBase>;
197+
return new Response(await stub.scopeCheck(url.searchParams.get('seed') === '1'));
198+
}
199+
200+
// Cache DO fetch calls — detached work goes through fetch (matching the #22545 repro),
201+
// since the DO fetch handler always initializes the DO's own client
202+
if (url.pathname === '/cache/detached') {
203+
const stub = env.CACHE_DO.get(
204+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
205+
) as DurableObjectStub<CacheDurableObjectBase>;
206+
return stub.fetch(new Request(`http://do/detached?id=${instanceId}`));
207+
}
208+
209+
if (url.pathname === '/cache/storage') {
210+
const stub = env.CACHE_DO.get(
211+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
212+
) as DurableObjectStub<CacheDurableObjectBase>;
213+
return new Response(await stub.storage());
214+
}
215+
216+
if (url.pathname === '/cache/streaming') {
217+
const stub = env.CACHE_DO.get(
218+
env.CACHE_DO.idFromName(`cache-do-${instanceId}`),
219+
) as DurableObjectStub<CacheDurableObjectBase>;
220+
return stub.fetch(new Request('http://do/streaming'));
221+
}
222+
223+
// No-cache DO calls
224+
if (url.pathname === '/no-cache/handler-error') {
225+
const stub = env.NO_CACHE_DO.get(
226+
env.NO_CACHE_DO.idFromName(`no-cache-do-${instanceId}`),
227+
) as DurableObjectStub<NoCacheDurableObjectBase>;
228+
await stub.handlerError(instanceId);
229+
}
230+
231+
if (url.pathname === '/no-cache/dedupe') {
232+
const stub = env.NO_CACHE_DO.get(
233+
env.NO_CACHE_DO.idFromName(`no-cache-do-${instanceId}`),
234+
) as DurableObjectStub<NoCacheDurableObjectBase>;
235+
const result = await stub.dedupe(errorMessage);
236+
return new Response(String(result));
237+
}
238+
239+
if (url.pathname === '/no-cache/storage') {
240+
const stub = env.NO_CACHE_DO.get(
241+
env.NO_CACHE_DO.idFromName(`no-cache-do-${instanceId}`),
242+
) as DurableObjectStub<NoCacheDurableObjectBase>;
243+
return new Response(await stub.storage());
244+
}
245+
246+
if (url.pathname === '/no-cache/detached') {
247+
const stub = env.NO_CACHE_DO.get(
248+
env.NO_CACHE_DO.idFromName(`no-cache-do-${instanceId}`),
249+
) as DurableObjectStub<NoCacheDurableObjectBase>;
250+
return stub.fetch(new Request(`http://do/detached?id=${instanceId}`));
251+
}
252+
253+
return new Response('Hello World!');
254+
},
255+
} satisfies ExportedHandler<Env>,
256+
);

0 commit comments

Comments
 (0)