Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
745 changes: 745 additions & 0 deletions src/bootstrap/sentry-defer.ts

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions src/components/checkout-failure-banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* watches on_hold subscriptions, this one fires on return-URL status.
*/

import * as Sentry from '@sentry/browser';
import { enqueueSentryCall } from '@/bootstrap/sentry-defer';
import {
clearCheckoutAttempt,
loadCheckoutAttempt,
Expand All @@ -32,10 +32,10 @@ const BANNER_ID = 'checkout-failure-banner';
export function showCheckoutFailureBanner(rawStatus: string): void {
if (document.getElementById(BANNER_ID)) return;

Sentry.captureMessage('Dodo checkout declined', {
enqueueSentryCall((s) => s.captureMessage('Dodo checkout declined', {
level: 'warning',
tags: { component: 'dodo-checkout', status: rawStatus },
});
}));

const banner = document.createElement('div');
banner.id = BANNER_ID;
Expand Down
612 changes: 28 additions & 584 deletions src/main.ts

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions src/services/auth-state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as Sentry from '@sentry/browser';
import { enqueueSentryCall } from '@/bootstrap/sentry-defer';
import { getCurrentClerkUser, scheduleClerkLoad, subscribeClerk } from './clerk';

/** Minimal user profile exposed to UI components. */
Expand All @@ -21,10 +21,10 @@ let _currentSession: AuthSession = { user: null, isPending: true };
function snapshotSession(): AuthSession {
const cu = getCurrentClerkUser();
if (!cu) {
Sentry.setUser(null);
enqueueSentryCall((s) => s.setUser(null));
return { user: null, isPending: false };
}
Sentry.setUser({ id: cu.id });
enqueueSentryCall((s) => s.setUser({ id: cu.id }));
return {
user: {
id: cu.id,
Expand Down
27 changes: 12 additions & 15 deletions src/services/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* Follows the same lazy reactive pattern as entitlements.ts.
*/

import * as Sentry from '@sentry/browser';
import { enqueueSentryCall } from '@/bootstrap/sentry-defer';
import { getConvexClient, getConvexApi } from './convex-client';
import { extractBillingErrorKind } from './_billing-error';

Expand Down Expand Up @@ -84,10 +84,11 @@ export async function initSubscriptionWatch(_userId?: string): Promise<void> {
} catch (err) {
console.error('[billing] Failed to initialize subscription watch:', err);
// Do not rethrow -- billing service failure must not break the dashboard
Sentry.captureException(
normalizeCaughtError('initSubscriptionWatch', err),
const initErr = normalizeCaughtError('initSubscriptionWatch', err);
enqueueSentryCall((s) => s.captureException(
initErr,
{ tags: { component: 'dodo-billing', action: 'initSubscriptionWatch' } },
);
));
}
}

Expand Down Expand Up @@ -215,17 +216,13 @@ export async function openBillingPortal(
const level: 'warning' | 'error' = isNoCustomer ? 'warning' : 'error';
const log = level === 'warning' ? console.warn : console.error;
log('[billing] Failed to get customer portal URL:', err);
Sentry.captureException(
normalizeCaughtError('openBillingPortal', err),
{
tags: {
component: 'dodo-billing',
action: 'openBillingPortal',
...(kind ? { billing_error_kind: kind } : {}),
},
level,
},
);
const portalErr = normalizeCaughtError('openBillingPortal', err);
const portalTags = {
component: 'dodo-billing',
action: 'openBillingPortal',
...(kind ? { billing_error_kind: kind } : {}),
};
enqueueSentryCall((s) => s.captureException(portalErr, { tags: portalTags, level }));
if (isNoCustomer) {
closeReserved();
return { outcome: 'no-customer' };
Expand Down
24 changes: 12 additions & 12 deletions src/services/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* UI code calls startCheckout(productId) -- everything else is internal.
*/

import * as Sentry from '@sentry/browser';
import { enqueueSentryCall } from '@/bootstrap/sentry-defer';
import { DodoPayments } from 'dodopayments-checkout';
import type { CheckoutEvent } from 'dodopayments-checkout';
import { openBillingPortal, prereserveBillingPortalTab } from './billing';
Expand Down Expand Up @@ -225,29 +225,29 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
successFired = true;
stopWatchdog();

Sentry.addBreadcrumb({
enqueueSentryCall((s) => s.addBreadcrumb({
category: 'checkout',
message: `terminal success (${reason})`,
level: 'info',
data: { reason },
});
}));
if (reason === 'watchdog') {
// Counter-signal so Dodo's wallet-return deadlock prevalence is
// measurable in Sentry. `info` level, not `error`, per
// feedback_sentry_level_expected_user_states.
Sentry.captureMessage('Dodo wallet-return deadlock — watchdog resolved', {
enqueueSentryCall((s) => s.captureMessage('Dodo wallet-return deadlock — watchdog resolved', {
level: 'info',
tags: { component: 'dodo-checkout', code: 'watchdog_resolved' },
});
}));
}

try {
onSuccessCallback?.();
} catch (err) {
console.error('[checkout] onSuccessCallback threw:', err);
Sentry.captureException(err, {
enqueueSentryCall((s) => s.captureException(err, {
tags: { component: 'dodo-checkout', action: 'on-success' },
});
}));
}
// Terminal success: clear both keys. LAST_CHECKOUT_ATTEMPT_KEY
// is no longer needed (no retry context required); PENDING is
Expand Down Expand Up @@ -360,7 +360,7 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
}
case 'checkout.error':
console.error('[checkout] Overlay error:', event.data?.message);
Sentry.captureMessage(`Dodo checkout overlay error: ${event.data?.message || 'unknown'}`, { level: 'error', tags: { component: 'dodo-checkout' } });
enqueueSentryCall((s) => s.captureMessage(`Dodo checkout overlay error: ${event.data?.message || 'unknown'}`, { level: 'error', tags: { component: 'dodo-checkout' } }));
// Release the user if their overlay surfaces an error. The
// deadlock bug (payment-link 404 + render loop) never reaches
// this branch — it traps inside their iframe — but any error
Expand Down Expand Up @@ -889,9 +889,9 @@ function reportCheckoutError(
};
if (!shouldSkipSentryForAction(context.action)) {
if (caught) {
Sentry.captureException(caught, payload);
enqueueSentryCall((s) => s.captureException(caught, payload));
} else {
Sentry.captureMessage(`Checkout error: ${error.code}`, payload);
enqueueSentryCall((s) => s.captureMessage(`Checkout error: ${error.code}`, payload));
}
}
const logger = level === 'info' ? console.info : console.error;
Expand Down Expand Up @@ -1085,10 +1085,10 @@ export function showCheckoutSuccess(
_currentBannerCleanup = null;
currentState = 'timeout';
setBannerText(banner, 'timeout', currentMaskedEmail);
Sentry.captureMessage('Checkout entitlement-activation timeout', {
enqueueSentryCall((s) => s.captureMessage('Checkout entitlement-activation timeout', {
level: 'warning',
tags: { component: 'dodo-checkout', action: 'entitlement-timeout' },
});
}));
}, EXTENDED_UNLOCK_TIMEOUT_MS);

const unsubscribe = onEntitlementChange(() => {
Expand Down
12 changes: 6 additions & 6 deletions src/services/premium-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
* API-key holders (step 1) and tester-key holders (step 2) are unaffected
* — those keys travel via X-WorldMonitor-Key which works on any path.
*/
import * as Sentry from '@sentry/browser';
import { enqueueSentryCall } from '@/bootstrap/sentry-defer';
import { PREMIUM_RPC_PATHS } from '@/shared/premium-paths';

/**
Expand Down Expand Up @@ -61,11 +61,11 @@ function reportServerError(res: Response, input: RequestInfo | URL): void {
// transient drowning genuine origin 5xx in the error dashboard
// (WORLDMONITOR-RG). Genuine origin 5xx (500-511) stay at `error`.
const isCloudflareEdgeError = res.status >= 520 && res.status <= 527;
Sentry.captureMessage(`API ${res.status}: ${path}`, {
level: isCloudflareEdgeError ? 'warning' : 'error',
tags: { kind: isCloudflareEdgeError ? 'api_cf_5xx' : 'api_5xx' },
extra: { path, status: res.status },
});
const message = `API ${res.status}: ${path}`;
const level: 'warning' | 'error' = isCloudflareEdgeError ? 'warning' : 'error';
const tags = { kind: isCloudflareEdgeError ? 'api_cf_5xx' : 'api_5xx' };
const extra = { path, status: res.status };
enqueueSentryCall((s) => s.captureMessage(message, { level, tags, extra }));
} catch { /* ignore URL parse errors */ }
}

Expand Down
13 changes: 8 additions & 5 deletions tests/sentry-beforesend.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));

// Extract the beforeSend function body from main.ts source.
// We parse it as a standalone function to avoid importing Sentry/App bootstrap.
const mainSrc = readFileSync(resolve(__dirname, '../src/main.ts'), 'utf-8');
// Extract the beforeSend function body from src/bootstrap/sentry-defer.ts.
// Sentry.init({...}) was moved out of main.ts when init was deferred off the
// critical path (#3994 / PR-4005); the beforeSend closure now lives inside
// the build factory in sentry-defer.ts. We parse it as a standalone function
// to avoid importing Sentry/App bootstrap.
const mainSrc = readFileSync(resolve(__dirname, '../src/bootstrap/sentry-defer.ts'), 'utf-8');

// Extract everything between `beforeSend(event) {` and the matching closing `},`
const bsStart = mainSrc.indexOf('beforeSend(event) {');
assert.ok(bsStart !== -1, 'beforeSend must exist in src/main.ts');
assert.ok(bsStart !== -1, 'beforeSend must exist in src/bootstrap/sentry-defer.ts');
let braceDepth = 0;
let bsEnd = -1;
for (let i = bsStart + 'beforeSend(event) '.length; i < mainSrc.length; i++) {
Expand All @@ -32,7 +35,7 @@ const fnBody = mainSrc.slice(bsStart + 'beforeSend(event) '.length, bsEnd)
// Extract the THIRD_PARTY_FETCH_HOST_ALLOWLIST Set so the test harness can evaluate
// beforeSend with the same allowlist the real module has.
const tpMatch = mainSrc.match(/const THIRD_PARTY_FETCH_HOST_ALLOWLIST = new Set\(\[[^\]]*\]\);/);
assert.ok(tpMatch, 'THIRD_PARTY_FETCH_HOST_ALLOWLIST must be defined in src/main.ts');
assert.ok(tpMatch, 'THIRD_PARTY_FETCH_HOST_ALLOWLIST must be defined in src/bootstrap/sentry-defer.ts');

// Build a callable version. Input: a Sentry-shaped event object. Returns event or null.
// eslint-disable-next-line no-new-func
Expand Down
Loading