Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '45 KB',
limit: '45.1 KB',
},
// Vue SDK (ESM)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Test that verifies thenable objects with extra methods (like jQuery's jqXHR)
* preserve those methods when returned from Sentry.startSpan().
*
* Example case:
* const jqXHR = Sentry.startSpan({ name: "test" }, () => $.ajax(...));
* jqXHR.abort(); // Should work and not throw an error because of missing abort() method
*/

// Load jQuery from CDN
const script = document.createElement('script');
script.src = 'https://code.jquery.com/jquery-3.7.1.min.js';
script.integrity = 'sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=';
script.crossOrigin = 'anonymous';

script.onload = function () {
runTest();
};

script.onerror = function () {
window.jqXHRTestError = 'Failed to load jQuery';
window.jqXHRMethodsPreserved = false;
};

document.head.appendChild(script);

async function runTest() {
window.jqXHRAbortCalled = false;
window.jqXHRAbortResult = null;
window.jqXHRTestError = null;

try {
if (!window.jQuery) {
throw new Error('jQuery not loaded');
}

const result = Sentry.startSpan({ name: 'test-jqxhr', op: 'http.client' }, () => {
// Make a real AJAX request with jQuery
return window.jQuery.ajax({
url: 'https://httpbin.org/status/200',
method: 'GET',
timeout: 5000,
});
});

const hasAbort = typeof result.abort === 'function';
const hasReadyState = 'readyState' in result;

if (hasAbort && hasReadyState) {
try {
result.abort();
window.jqXHRAbortCalled = true;
window.jqXHRAbortResult = 'abort-successful';
window.jqXHRMethodsPreserved = true;
} catch (e) {
console.log('abort() threw an error:', e);
window.jqXHRTestError = `abort() failed: ${e.message}`;
window.jqXHRMethodsPreserved = false;
}
} else {
window.jqXHRMethodsPreserved = false;
window.jqXHRTestError = 'jqXHR methods not preserved';
}

// Since we aborted the request, it should be rejected
try {
await result;
window.jqXHRPromiseResolved = true; // Unexpected
} catch (err) {
// Expected: aborted request rejects
window.jqXHRPromiseResolved = false;
window.jqXHRPromiseRejected = true;
}
} catch (error) {
console.error('Test error:', error);
window.jqXHRTestError = error.message;
window.jqXHRMethodsPreserved = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers';

sentryTest('preserves extra methods on real jQuery jqXHR objects', async ({ getLocalTestUrl, page }) => {
if (shouldSkipTracingTest()) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });
const transactionPromise = waitForTransactionRequest(page);

await page.goto(url);

// Wait for jQuery to load
await page.waitForTimeout(1000);

const methodsPreserved = await page.evaluate(() => (window as any).jqXHRMethodsPreserved);
expect(methodsPreserved).toBe(true);

const abortCalled = await page.evaluate(() => (window as any).jqXHRAbortCalled);
expect(abortCalled).toBe(true);

const abortReturnValue = await page.evaluate(() => (window as any).jqXHRAbortResult);
expect(abortReturnValue).toBe('abort-successful');

const testError = await page.evaluate(() => (window as any).jqXHRTestError);
expect(testError).toBeNull();

const transaction = envelopeRequestParser(await transactionPromise);
expect(transaction.transaction).toBe('test-jqxhr');
expect(transaction.spans).toBeDefined();
});

sentryTest('aborted request rejects promise correctly', async ({ getLocalTestUrl, page }) => {
if (shouldSkipTracingTest()) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });
await page.goto(url);

// Wait for jQuery to load
await page.waitForTimeout(1000);

const promiseRejected = await page.evaluate(() => (window as any).jqXHRPromiseRejected);
expect(promiseRejected).toBe(true);

const promiseResolved = await page.evaluate(() => (window as any).jqXHRPromiseResolved);
expect(promiseResolved).toBe(false);
});
17 changes: 6 additions & 11 deletions packages/core/src/asyncContext/stackStrategy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Client } from '../client';
import { getDefaultCurrentScope, getDefaultIsolationScope } from '../defaultScopes';
import { Scope } from '../scope';
import { chainAndCopyPromiseLike } from '../utils/chain-and-copy-promiselike';
import { isThenable } from '../utils/is';
import { getMainCarrier, getSentryCarrier } from './../carrier';
import type { AsyncContextStrategy } from './types';
Expand Down Expand Up @@ -52,17 +53,11 @@ export class AsyncContextStack {
}

if (isThenable(maybePromiseResult)) {
// @ts-expect-error - isThenable returns the wrong type
return maybePromiseResult.then(
res => {
this._popScope();
return res;
},
e => {
this._popScope();
throw e;
},
);
return chainAndCopyPromiseLike(
Comment thread
isaacs marked this conversation as resolved.
maybePromiseResult as PromiseLike<Awaited<typeof maybePromiseResult>> & Record<string, unknown>,
() => this._popScope(),
() => this._popScope(),
) as T;
}

this._popScope();
Expand Down
55 changes: 55 additions & 0 deletions packages/core/src/utils/chain-and-copy-promiselike.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const isActualPromise = (p: unknown) =>
p instanceof Promise && !(p as unknown as ChainedPromiseLike<unknown>)[kChainedCopy];

type ChainedPromiseLike<T> = PromiseLike<T> & {
[kChainedCopy]: true;
};
const kChainedCopy = Symbol('chained PromiseLike');

/**
* Copy the properties from a decorated promiselike object onto its chained
* actual promise.
*/
export const chainAndCopyPromiseLike = <V, T extends PromiseLike<V>>(
original: T,
onSuccess: (value: V) => void,
onError: (e: unknown) => void,
): T => {
const chained = original.then(
value => {
onSuccess(value);
return value;
},
err => {
onError(err);
throw err;
},
) as T;

// if we're just dealing with "normal" Promise objects, return the chain
return isActualPromise(chained) && isActualPromise(original) ? chained : copyProps(original, chained);
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const copyProps = <T extends Record<string, any>>(original: T, chained: T): T => {
Comment thread
isaacs marked this conversation as resolved.
let mutated = false;
//oxlint-disable-next-line guard-for-in
for (const key in original) {
if (key in chained) continue;
mutated = true;
const value = original[key];
if (typeof value === 'function') {
Object.defineProperty(chained, key, {
value: (...args: unknown[]) => value.apply(original, args),
enumerable: true,
configurable: true,
writable: true,
});
} else {
(chained as Record<string, unknown>)[key] = value;
}
}

if (mutated) Object.assign(chained, { [kChainedCopy]: true });
return chained;
};
26 changes: 15 additions & 11 deletions packages/core/src/utils/handleCallbackErrors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { chainAndCopyPromiseLike } from '../utils/chain-and-copy-promiselike';
import { isThenable } from '../utils/is';

/* eslint-disable */
Expand Down Expand Up @@ -62,7 +63,12 @@ export function handleCallbackErrors<
* Maybe handle a promise rejection.
* This expects to be given a value that _may_ be a promise, or any other value.
* If it is a promise, and it rejects, it will call the `onError` callback.
* Other than this, it will generally return the given value as-is.
*
* For thenable objects with extra methods (like jQuery's jqXHR),
* this function preserves those methods by wrapping the original thenable in a Proxy
* that intercepts .then() calls to apply error handling while forwarding all other
* properties to the original object.
* This allows code like `startSpan(() => $.ajax(...)).abort()` to work correctly.
*/
function maybeHandlePromiseRejection<MaybePromise>(
value: MaybePromise,
Expand All @@ -71,21 +77,19 @@ function maybeHandlePromiseRejection<MaybePromise>(
onSuccess: (result: MaybePromise | AwaitedPromise<MaybePromise>) => void,
): MaybePromise {
if (isThenable(value)) {
// @ts-expect-error - the isThenable check returns the "wrong" type here
return value.then(
res => {
return chainAndCopyPromiseLike(
value as MaybePromise & PromiseLike<Awaited<typeof value>> & Record<string, unknown>,
result => {
onFinally();
onSuccess(res);
return res;
onSuccess(result as Awaited<MaybePromise>);
},
e => {
onError(e);
err => {
onError(err);
onFinally();
throw e;
},
);
) as MaybePromise;
}

// Non-thenable value - call callbacks immediately and return as-is
onFinally();
onSuccess(value);
return value;
Expand Down
14 changes: 14 additions & 0 deletions packages/core/test/lib/tracing/trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,20 @@ describe('startSpan', () => {
});
});

describe('AsyncContext withScope promise integrity behavior', () => {
it('preserves custom thenable methods', async () => {
const jqXHR = {
then: Promise.resolve(1).then.bind(Promise.resolve(1)),
abort: vi.fn(),
};
expect(jqXHR instanceof Promise).toBe(false);
const result = startSpan({ name: 'test' }, () => jqXHR);
expect(typeof result.abort).toBe('function');
result.abort();
expect(jqXHR.abort).toHaveBeenCalled();
});
});

it('returns a non recording span if tracing is disabled', () => {
const options = getDefaultTestClientOptions({});
client = new TestClient(options);
Expand Down
56 changes: 56 additions & 0 deletions packages/core/test/lib/utils/chain-and-copy-promiselike.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { chainAndCopyPromiseLike } from '../../../src/utils/chain-and-copy-promiselike';

describe('chain and copy promiselike objects', () => {
it('does no copying for normal promises', async () => {
const p = new Promise<number>(res => res(1));
Object.assign(p, { newProperty: true });
let success = false;
let error = false;
const q = chainAndCopyPromiseLike(
p,
() => {
success = true;
},
() => {
error = true;
},
);
expect(await q).toBe(1);
//@ts-expect-error - this is not a normal prop on Promises
expect(q.newProperty).toBe(undefined);
expect(success).toBe(true);
expect(error).toBe(false);
});

it('copies properties of non-Promise then-ables', async () => {
class FakePromise<T extends unknown> {
value: T;
constructor(value: T) {
this.value = value;
}
then(fn: (value: T) => unknown) {
const newVal = fn(this.value);
return new FakePromise(newVal);
}
}
const p = new FakePromise(1) as PromiseLike<number>;
Object.assign(p, { newProperty: true });
let success = false;
let error = false;
const q = chainAndCopyPromiseLike(
p,
() => {
success = true;
},
() => {
error = true;
},
);
expect(await q).toBe(1);
//@ts-expect-error - this is not a normal prop on FakePromises
expect(q.newProperty).toBe(true);
expect(success).toBe(true);
expect(error).toBe(false);
});
});
Loading