diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be11f358dc08..5f09f9587c00 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -976,8 +976,7 @@ jobs: runs-on: ubuntu-20.04 timeout-minutes: 30 if: | - contains(github.event.pull_request.labels.*.name, 'ci-overhead-measurements') || - needs.job_get_metadata.outputs.is_develop == 'true' + contains(github.event.pull_request.labels.*.name, 'ci-overhead-measurements') steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 815650e0d8c5..41aebacf874f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.86.0 + +- feat(core): Use SDK_VERSION for hub API version (#9732) +- feat(nextjs): Emit warning if your app directory doesn't have a global-error.js file (#9753) +- feat(node): Add cloudflare pages commit sha (#9751) +- feat(remix): Bump @sentry/cli to 2.22.3 (#9741) +- fix(nextjs): Don't accidentally trigger static generation bailout (#9749) +- fix(node): Guard `process.env.NODE_ENV` access in Spotlight integration (#9748) +- fix(utils): Fix XHR instrumentation early return (#9770) +- ref(remix): Rework Error Handling (#9725) + ## 7.85.0 - feat(core): Add `addEventProcessor` method (#9554) diff --git a/docs/using-yalc.md b/docs/using-yalc.md index e75255a9cd72..f5f4d331ecb1 100644 --- a/docs/using-yalc.md +++ b/docs/using-yalc.md @@ -43,7 +43,7 @@ to add the local SDK package to your project. ### My changes are not applied to the test project -Did you run `yarn build && yarn publish:yalc` after making your changes? +Did you run `yarn build && yarn yalc:publish` after making your changes? ### My test project uses Vite and I still don't see changes diff --git a/packages/astro/README.md b/packages/astro/README.md index e0247bf2d55b..696b5f948ab0 100644 --- a/packages/astro/README.md +++ b/packages/astro/README.md @@ -14,9 +14,9 @@ - [Official SDK Docs](https://docs.sentry.io/platforms/javascript/guides/astro/) -## Experimental Note +## SDK Status -This SDK is experimental and in Alpha state. Breaking changes can occurr at any time. +This SDK is in Beta and not yet fully stable. If you have feedback or encounter any bugs, feel free to [open an issue](https://github.com/getsentry/sentry-javascript/issues/new/choose). ## General @@ -58,9 +58,9 @@ SENTRY_AUTH_TOKEN="your-token" ### Server Instrumentation -For Astro apps configured for (hybrid) Server Side Rendering (SSR), the Sentry integration will automatically add middleware to your server to instrument incoming requests **if you're using Astro 3.5.0 or newer**. +For Astro apps configured for (hybrid) Server Side Rendering (SSR), the Sentry integration will automatically add middleware to your server to instrument incoming requests **if you're using Astro 3.5.2 or newer**. -If you're using Astro <3.5.0, complete the setup by adding the Sentry middleware to your `src/middleware.js` file: +If you're using Astro <3.5.2, complete the setup by adding the Sentry middleware to your `src/middleware.js` file: ```javascript // src/middleware.js diff --git a/packages/core/src/hub.ts b/packages/core/src/hub.ts index b4a69364d04a..8306d033cd75 100644 --- a/packages/core/src/hub.ts +++ b/packages/core/src/hub.ts @@ -26,6 +26,7 @@ import { DEFAULT_ENVIRONMENT } from './constants'; import { DEBUG_BUILD } from './debug-build'; import { Scope } from './scope'; import { closeSession, makeSession, updateSession } from './session'; +import { SDK_VERSION } from './version'; /** * API compatibility version of this hub. @@ -35,7 +36,7 @@ import { closeSession, makeSession, updateSession } from './session'; * * @hidden */ -export const API_VERSION = 4; +export const API_VERSION = parseFloat(SDK_VERSION); /** * Default maximum number of breadcrumbs added to an event. Can be overwritten diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx b/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx index f4e586f2e557..78e0e8940eb5 100644 --- a/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx @@ -24,9 +24,7 @@ Sentry.init({ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! }); -export function handleError(error: unknown, { request }: DataFunctionArgs): void { - Sentry.captureRemixServerException(error, 'remix.server', request); -} +export const handleError = Sentry.wrapRemixHandleError; export default function handleRequest( request: Request, diff --git a/packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx b/packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx index 3d618e754244..f0c451dcaf55 100644 --- a/packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx +++ b/packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx @@ -21,9 +21,7 @@ Sentry.init({ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! }); -export function handleError(error: unknown, { request }: DataFunctionArgs): void { - Sentry.captureRemixServerException(error, 'remix.server', request); -} +export const handleError = Sentry.wrapRemixHandleError; export default function handleRequest( request: Request, diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/static/route.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/static/route.ts new file mode 100644 index 000000000000..174fb171780e --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/static/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ result: 'static response' }); +} + +// This export makes it so that this route is always dynamically rendered (i.e Sentry will trace) +export const revalidate = 0; + +// This export makes it so that this route will throw an error if the Request object is accessed in some way. +export const dynamic = 'error'; diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts index 75a07d79b2c4..560a5911522f 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts @@ -94,3 +94,10 @@ test.describe('Edge runtime', () => { expect(routehandlerError.contexts?.runtime?.name).toBe('vercel-edge'); }); }); + +test('should not crash route handlers that are configured with `export const dynamic = "error"`', async ({ + request, +}) => { + const response = await request.get('/route-handlers/static'); + expect(await response.json()).toStrictEqual({ result: 'static response' }); +}); diff --git a/packages/nextjs/src/common/types.ts b/packages/nextjs/src/common/types.ts index 9e3218959b34..ffca3dc8ff61 100644 --- a/packages/nextjs/src/common/types.ts +++ b/packages/nextjs/src/common/types.ts @@ -18,22 +18,19 @@ export type ServerComponentContext = { }; export interface RouteHandlerContext { - // TODO(v8): Remove - /** - * @deprecated The SDK will automatically pick up the method from the incoming Request object instead. - */ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; parameterizedRoute: string; // TODO(v8): Remove /** - * @deprecated The SDK will automatically pick up the `sentry-trace` header from the incoming Request object instead. + * @deprecated pass a complete `Headers` object with the `headers` field instead. */ sentryTraceHeader?: string; // TODO(v8): Remove /** - * @deprecated The SDK will automatically pick up the `baggage` header from the incoming Request object instead. + * @deprecated pass a complete `Headers` object with the `headers` field instead. */ baggageHeader?: string; + headers?: WebFetchHeaders; } export type VercelCronsConfig = { path?: string; schedule?: string }[] | undefined; diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index c03bb3db0dbf..95ba1cfb5e26 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -1,5 +1,5 @@ import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core'; -import { tracingContextFromHeaders, winterCGRequestToRequestData } from '@sentry/utils'; +import { tracingContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; import { isRedirectNavigationError } from './nextNavigationErrorUtils'; import type { RouteHandlerContext } from './types'; @@ -15,23 +15,16 @@ export function wrapRouteHandlerWithSentry any>( ): (...args: Parameters) => ReturnType extends Promise ? ReturnType : Promise> { addTracingExtensions(); // eslint-disable-next-line deprecation/deprecation - const { method, parameterizedRoute, baggageHeader, sentryTraceHeader } = context; + const { method, parameterizedRoute, baggageHeader, sentryTraceHeader, headers } = context; return new Proxy(routeHandler, { apply: (originalFunction, thisArg, args) => { return runWithAsyncContext(async () => { const hub = getCurrentHub(); const currentScope = hub.getScope(); - let req: Request | undefined; - let reqMethod: string | undefined; - if (args[0] instanceof Request) { - req = args[0]; - reqMethod = req.method; - } - const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders( - sentryTraceHeader, - baggageHeader, + sentryTraceHeader ?? headers?.get('sentry-trace') ?? undefined, + baggageHeader ?? headers?.get('baggage'), ); currentScope.setPropagationContext(propagationContext); @@ -40,11 +33,13 @@ export function wrapRouteHandlerWithSentry any>( res = await trace( { op: 'http.server', - name: `${reqMethod ?? method} ${parameterizedRoute}`, + name: `${method} ${parameterizedRoute}`, status: 'ok', ...traceparentData, metadata: { - request: req ? winterCGRequestToRequestData(req) : undefined, + request: { + headers: headers ? winterCGHeadersToDict(headers) : undefined, + }, source: 'route', dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, }, diff --git a/packages/nextjs/src/config/templates/routeHandlerWrapperTemplate.ts b/packages/nextjs/src/config/templates/routeHandlerWrapperTemplate.ts index b09d35c98b4b..e8fafc18139d 100644 --- a/packages/nextjs/src/config/templates/routeHandlerWrapperTemplate.ts +++ b/packages/nextjs/src/config/templates/routeHandlerWrapperTemplate.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/nextjs'; +import type { WebFetchHeaders } from '@sentry/types'; // @ts-expect-error Because we cannot be sure if the RequestAsyncStorage module exists (it is not part of the Next.js public // API) we use a shim if it doesn't exist. The logic for this is in the wrapping loader. import { requestAsyncStorage } from '__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__'; @@ -34,12 +35,14 @@ function wrapHandler(handler: T, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | ' apply: (originalFunction, thisArg, args) => { let sentryTraceHeader: string | undefined | null = undefined; let baggageHeader: string | undefined | null = undefined; + let headers: WebFetchHeaders | undefined = undefined; // We try-catch here just in case the API around `requestAsyncStorage` changes unexpectedly since it is not public API try { const requestAsyncStore = requestAsyncStorage.getStore(); sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace') ?? undefined; baggageHeader = requestAsyncStore?.headers.get('baggage') ?? undefined; + headers = requestAsyncStore?.headers; } catch (e) { /** empty */ } @@ -50,6 +53,7 @@ function wrapHandler(handler: T, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | ' parameterizedRoute: '__ROUTE__', sentryTraceHeader, baggageHeader, + headers, }).apply(thisArg, args); }, }); diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 6f9b27d56423..96cb0d3ae14e 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -40,6 +40,7 @@ let showedMissingOrgSlugErrorMsg = false; let showedMissingProjectSlugErrorMsg = false; let showedHiddenSourceMapsWarningMsg = false; let showedMissingCliBinaryWarningMsg = false; +let showedMissingGlobalErrorWarningMsg = false; // TODO: merge default SentryWebpackPlugin ignore with their SentryWebpackPlugin ignore or ignoreFile // TODO: merge default SentryWebpackPlugin include with their SentryWebpackPlugin include @@ -328,6 +329,24 @@ export function constructWebpackConfigFunction( }); } + if (appDirPath) { + const hasGlobalErrorFile = ['global-error.js', 'global-error.jsx', 'global-error.ts', 'global-error.tsx'].some( + globalErrorFile => fs.existsSync(path.join(appDirPath!, globalErrorFile)), + ); + + if (!hasGlobalErrorFile && !showedMissingGlobalErrorWarningMsg) { + // eslint-disable-next-line no-console + console.log( + `${chalk.yellow( + 'warn', + )} - It seems like you don't have a global error handler set up. It is recommended that you add a ${chalk.cyan( + 'global-error.js', + )} file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router`, + ); + showedMissingGlobalErrorWarningMsg = true; + } + } + // The SDK uses syntax (ES6 and ES6+ features like object spread) which isn't supported by older browsers. For users // who want to support such browsers, `transpileClientSDK` allows them to force the SDK code to go through the same // transpilation that their code goes through. We don't turn this on by default because it increases bundle size diff --git a/packages/node/src/integrations/spotlight.ts b/packages/node/src/integrations/spotlight.ts index cef0c27e2a4a..55fabc284cad 100644 --- a/packages/node/src/integrations/spotlight.ts +++ b/packages/node/src/integrations/spotlight.ts @@ -41,8 +41,8 @@ export class Spotlight implements Integration { * Sets up forwarding envelopes to the Spotlight Sidecar */ public setup(client: Client): void { - if (process.env.NODE_ENV !== 'development') { - logger.warn("[Spotlight] It seems you're not in dev mode. Do you really want to have Spoltight enabled?"); + if (typeof process === 'object' && process.env && process.env.NODE_ENV !== 'development') { + logger.warn("[Spotlight] It seems you're not in dev mode. Do you really want to have Spotlight enabled?"); } connectToSpotlight(client, this._options); } diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts index 6e25375c6eff..5ef42128d9ab 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -235,6 +235,8 @@ export function getSentryRelease(fallback?: string): string | undefined { process.env.ZEIT_GITHUB_COMMIT_SHA || process.env.ZEIT_GITLAB_COMMIT_SHA || process.env.ZEIT_BITBUCKET_COMMIT_SHA || + // Cloudflare Pages - https://developers.cloudflare.com/pages/platform/build-configuration/#environment-variables + process.env.CF_PAGES_COMMIT_SHA || fallback ); } diff --git a/packages/node/test/integrations/spotlight.test.ts b/packages/node/test/integrations/spotlight.test.ts index 71af071cbf3c..266d64b5710a 100644 --- a/packages/node/test/integrations/spotlight.test.ts +++ b/packages/node/test/integrations/spotlight.test.ts @@ -138,7 +138,7 @@ describe('Spotlight', () => { integration.setup(client); expect(loggerSpy).toHaveBeenCalledWith( - expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spoltight enabled?"), + expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), ); process.env.NODE_ENV = oldEnvValue; @@ -152,9 +152,41 @@ describe('Spotlight', () => { integration.setup(client); expect(loggerSpy).not.toHaveBeenCalledWith( - expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spoltight enabled?"), + expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), ); process.env.NODE_ENV = oldEnvValue; }); + + it('handles `process` not being available', () => { + const originalProcess = process; + + // @ts-expect-error - TS complains but we explicitly wanna test this + delete global.process; + + const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' }); + integration.setup(client); + + expect(loggerSpy).not.toHaveBeenCalledWith( + expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), + ); + + global.process = originalProcess; + }); + + it('handles `process.env` not being available', () => { + const originalEnv = process.env; + + // @ts-expect-error - TS complains but we explicitly wanna test this + delete process.env; + + const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' }); + integration.setup(client); + + expect(loggerSpy).not.toHaveBeenCalledWith( + expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spotlight enabled?"), + ); + + process.env = originalEnv; + }); }); diff --git a/packages/remix/package.json b/packages/remix/package.json index 4c2042eee8cb..9a2d94a1e7a9 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -27,7 +27,7 @@ "access": "public" }, "dependencies": { - "@sentry/cli": "^2.21.2", + "@sentry/cli": "^2.22.3", "@sentry/core": "7.85.0", "@sentry/node": "7.85.0", "@sentry/react": "7.85.0", diff --git a/packages/remix/src/client/errors.tsx b/packages/remix/src/client/errors.tsx index ef56ce124c61..b46f5fde069a 100644 --- a/packages/remix/src/client/errors.tsx +++ b/packages/remix/src/client/errors.tsx @@ -1,8 +1,7 @@ import { captureException } from '@sentry/core'; -import { isNodeEnv, isString } from '@sentry/utils'; +import { isNodeEnv } from '@sentry/utils'; -import { isRouteErrorResponse } from '../utils/vendor/response'; -import type { ErrorResponse } from '../utils/vendor/types'; +import { isResponse } from '../utils/vendor/response'; /** * Captures an error that is thrown inside a Remix ErrorBoundary. @@ -11,57 +10,26 @@ import type { ErrorResponse } from '../utils/vendor/types'; * @returns void */ export function captureRemixErrorBoundaryError(error: unknown): string | undefined { - let eventId: string | undefined; - const isClientSideRuntimeError = !isNodeEnv() && error instanceof Error; - // We only capture `ErrorResponse`s that are 5xx errors. - const isRemixErrorResponse = isRouteErrorResponse(error) && error.status >= 500; - // Server-side errors apart from `ErrorResponse`s also appear here without their stacktraces. - // So, we only capture: - // 1. `ErrorResponse`s - // 2. Client-side runtime errors here, - // And other server-side errors captured in `handleError` function where stacktraces are available. - if (isRemixErrorResponse || isClientSideRuntimeError) { - const eventData = isRemixErrorResponse - ? { - function: 'ErrorResponse', - ...getErrorData(error), - } - : { - function: 'ReactError', - }; - - const actualError = isRemixErrorResponse ? getExceptionToCapture(error) : error; - - eventId = captureException(actualError, { - mechanism: { - type: 'instrument', - handled: false, - data: eventData, - }, - }); - } - - return eventId; -} - -function getErrorData(error: ErrorResponse): object { - if (isString(error.data)) { - return { - error: error.data, - }; + // Server-side errors also appear here without their stacktraces. + // So, we only capture client-side runtime errors here. + // ErrorResponses that are 5xx errors captured at loader / action level by `captureRemixRouteError` function, + // And other server-side errors captured in `handleError` function where stacktraces are available. + // + // We don't want to capture: + // - Response Errors / Objects [They are originated and handled on the server-side] + // - SSR Errors [They are originated and handled on the server-side] + // - Anything without a stacktrace [Remix trims the stacktrace of the errors that are thrown on the server-side] + if (isResponse(error) || isNodeEnv() || !(error instanceof Error)) { + return; } - return error.data; -} - -function getExceptionToCapture(error: ErrorResponse): string | ErrorResponse { - if (isString(error.data)) { - return error.data; - } - - if (error.statusText) { - return error.statusText; - } - - return error; + return captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'ReactError', + }, + }, + }); } diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index 8a85ed16913f..12fc10b522cf 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -59,7 +59,7 @@ export { // Keeping the `*` exports for backwards compatibility and types export * from '@sentry/node'; -export { captureRemixServerException } from './utils/instrumentServer'; +export { captureRemixServerException, wrapRemixHandleError } from './utils/instrumentServer'; export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; export { remixRouterInstrumentation, withSentry } from './client/performance'; export { captureRemixErrorBoundaryError } from './client/errors'; diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index 47d48f4a9d9c..7e98dc123858 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -8,8 +8,10 @@ import { dynamicSamplingContextToSentryBaggageHeader, fill, isNodeEnv, + isPrimitive, loadModule, logger, + objectify, tracingContextFromHeaders, } from '@sentry/utils'; @@ -70,6 +72,28 @@ async function extractResponseError(response: Response): Promise { return responseData; } +/** + * Sentry utility to be used in place of `handleError` function of Remix v2 + * Remix Docs: https://remix.run/docs/en/main/file-conventions/entry.server#handleerror + * + * Should be used in `entry.server` like: + * + * export const handleError = Sentry.wrapRemixHandleError + */ +export function wrapRemixHandleError(err: unknown, { request }: DataFunctionArgs): void { + // We are skipping thrown responses here as they are handled by + // `captureRemixServerException` at loader / action level + // We don't want to capture them twice. + // This function if only for capturing unhandled server-side exceptions. + // https://remix.run/docs/en/main/file-conventions/entry.server#thrown-responses + // https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders + if (isResponse(err) || isRouteErrorResponse(err)) { + return; + } + + void captureRemixServerException(err, 'remix.server.handleError', request); +} + /** * Captures an exception happened in the Remix server. * @@ -107,7 +131,9 @@ export async function captureRemixServerException(err: unknown, name: string, re DEBUG_BUILD && logger.warn('Failed to normalize Remix request'); } - captureException(isResponse(err) ? await extractResponseError(err) : err, scope => { + const objectifiedErr = objectify(err); + + captureException(isResponse(objectifiedErr) ? await extractResponseError(objectifiedErr) : objectifiedErr, scope => { const activeTransactionName = getActiveTransaction()?.name; scope.setSDKProcessingMetadata({ @@ -138,7 +164,7 @@ export async function captureRemixServerException(err: unknown, name: string, re }); } -function makeWrappedDocumentRequestFunction(remixVersion: number) { +function makeWrappedDocumentRequestFunction(remixVersion?: number) { return function (origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction { return async function ( this: unknown, @@ -149,7 +175,6 @@ function makeWrappedDocumentRequestFunction(remixVersion: number) { loadContext?: Record, ): Promise { let res: Response; - const activeTransaction = getActiveTransaction(); try { @@ -174,7 +199,12 @@ function makeWrappedDocumentRequestFunction(remixVersion: number) { span?.finish(); } catch (err) { - if (!FUTURE_FLAGS?.v2_errorBoundary && remixVersion !== 2) { + const isRemixV1 = !FUTURE_FLAGS?.v2_errorBoundary && remixVersion !== 2; + + // This exists to capture the server-side rendering errors on Remix v1 + // On Remix v2, we capture SSR errors at `handleError` + // We also skip primitives here, as we can't dedupe them, and also we don't expect any primitive SSR errors. + if (isRemixV1 && !isPrimitive(err)) { await captureRemixServerException(err, 'documentRequest', request); } @@ -217,7 +247,12 @@ function makeWrappedDataFunction( currentScope.setSpan(activeTransaction); span?.finish(); } catch (err) { - if (!FUTURE_FLAGS?.v2_errorBoundary && remixVersion !== 2) { + const isRemixV2 = FUTURE_FLAGS?.v2_errorBoundary || remixVersion === 2; + + // On Remix v2, we capture all unexpected errors (except the `Route Error Response`s / Thrown Responses) in `handleError` function. + // This is both for consistency and also avoid duplicates such as primitives like `string` or `number` being captured twice. + // Remix v1 does not have a `handleError` function, so we capture all errors here. + if (isRemixV2 ? isResponse(err) : true) { await captureRemixServerException(err, name, args.request); } @@ -240,7 +275,10 @@ const makeWrappedLoader = return makeWrappedDataFunction(origLoader, id, 'loader', remixVersion); }; -function getTraceAndBaggage(): { sentryTrace?: string; sentryBaggage?: string } { +function getTraceAndBaggage(): { + sentryTrace?: string; + sentryBaggage?: string; +} { const transaction = getActiveTransaction(); const currentScope = getCurrentHub().getScope(); @@ -287,7 +325,11 @@ function makeWrappedRootLoader(remixVersion: number) { if (typeof data === 'object') { return json( { ...data, ...traceAndBaggage, remixVersion }, - { headers: res.headers, statusText: res.statusText, status: res.status }, + { + headers: res.headers, + statusText: res.statusText, + status: res.status, + }, ); } else { DEBUG_BUILD && logger.warn('Skipping injection of trace and baggage as the response body is not an object'); @@ -498,7 +540,9 @@ function makeWrappedCreateRequestHandler( * which Remix Adapters (https://remix.run/docs/en/v1/api/remix) use underneath. */ export function instrumentServer(): void { - const pkg = loadModule<{ createRequestHandler: CreateRequestHandlerFunction }>('@remix-run/server-runtime'); + const pkg = loadModule<{ + createRequestHandler: CreateRequestHandlerFunction; + }>('@remix-run/server-runtime'); if (!pkg) { DEBUG_BUILD && logger.warn('Remix SDK was unable to require `@remix-run/server-runtime` package.'); diff --git a/packages/remix/src/utils/vendor/types.ts b/packages/remix/src/utils/vendor/types.ts index e92dbc0f99af..de1a20fcbff1 100644 --- a/packages/remix/src/utils/vendor/types.ts +++ b/packages/remix/src/utils/vendor/types.ts @@ -108,7 +108,12 @@ export type DeferredData = { }; export interface MetaFunction { - (args: { data: AppData; parentsData: RouteData; params: Params; location: Location }): HtmlMetaDescriptor; + (args: { + data: AppData; + parentsData: RouteData; + params: Params; + location: Location; + }): HtmlMetaDescriptor; } export interface HtmlMetaDescriptor { @@ -143,7 +148,11 @@ export interface LoaderFunction { } export interface HeadersFunction { - (args: { loaderHeaders: Headers; parentHeaders: Headers; actionHeaders: Headers }): Headers | HeadersInit; + (args: { + loaderHeaders: Headers; + parentHeaders: Headers; + actionHeaders: Headers; + }): Headers | HeadersInit; } export interface ServerRouteModule extends EntryRouteModule { diff --git a/packages/remix/test/integration/app_v1/routes/server-side-unexpected-errors/$id.tsx b/packages/remix/test/integration/app_v1/routes/server-side-unexpected-errors/$id.tsx new file mode 100644 index 000000000000..fc595b18c3b3 --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/server-side-unexpected-errors/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/server-side-unexpected-errors.$id'; +export { default } from '../../../common/routes/server-side-unexpected-errors.$id'; diff --git a/packages/remix/test/integration/app_v1/routes/ssr-error.tsx b/packages/remix/test/integration/app_v1/routes/ssr-error.tsx new file mode 100644 index 000000000000..627f7e126871 --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/ssr-error.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/ssr-error'; +export { default } from '../../common/routes/ssr-error'; diff --git a/packages/remix/test/integration/app_v2/entry.server.tsx b/packages/remix/test/integration/app_v2/entry.server.tsx index 3b18492eb62b..f9205ecf89b2 100644 --- a/packages/remix/test/integration/app_v2/entry.server.tsx +++ b/packages/remix/test/integration/app_v2/entry.server.tsx @@ -11,9 +11,7 @@ Sentry.init({ autoSessionTracking: false, }); -export function handleError(error: unknown, { request }: DataFunctionArgs): void { - Sentry.captureRemixServerException(error, 'remix.server', request); -} +export const handleError = Sentry.wrapRemixHandleError; export default function handleRequest( request: Request, diff --git a/packages/remix/test/integration/app_v2/routes/server-side-unexpected-errors.$id.tsx b/packages/remix/test/integration/app_v2/routes/server-side-unexpected-errors.$id.tsx new file mode 100644 index 000000000000..d9571c68ddd5 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/server-side-unexpected-errors.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/server-side-unexpected-errors.$id'; +export { default } from '../../common/routes/server-side-unexpected-errors.$id'; diff --git a/packages/remix/test/integration/app_v2/routes/ssr-error.tsx b/packages/remix/test/integration/app_v2/routes/ssr-error.tsx new file mode 100644 index 000000000000..627f7e126871 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/ssr-error.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/ssr-error'; +export { default } from '../../common/routes/ssr-error'; diff --git a/packages/remix/test/integration/common/routes/action-json-response.$id.tsx b/packages/remix/test/integration/common/routes/action-json-response.$id.tsx index 2bcc44588a0c..1cd4d65df54e 100644 --- a/packages/remix/test/integration/common/routes/action-json-response.$id.tsx +++ b/packages/remix/test/integration/common/routes/action-json-response.$id.tsx @@ -2,7 +2,7 @@ import { ActionFunction, LoaderFunction, json, redirect } from '@remix-run/node' import { useActionData } from '@remix-run/react'; export const loader: LoaderFunction = async ({ params: { id } }) => { - if (id === '-1') { + if (id === '-100') { throw new Error('Unexpected Server Error'); } @@ -16,7 +16,7 @@ export const action: ActionFunction = async ({ params: { id } }) => { if (id === '-2') { // Note: This GET request triggers the `Loader` of the URL, not the `Action`. - throw redirect('/action-json-response/-1'); + throw redirect('/action-json-response/-100'); } if (id === '-3') { diff --git a/packages/remix/test/integration/common/routes/server-side-unexpected-errors.$id.tsx b/packages/remix/test/integration/common/routes/server-side-unexpected-errors.$id.tsx new file mode 100644 index 000000000000..a7d73e29a4ff --- /dev/null +++ b/packages/remix/test/integration/common/routes/server-side-unexpected-errors.$id.tsx @@ -0,0 +1,27 @@ +import { ActionFunction, LoaderFunction, json, redirect } from '@remix-run/node'; +import { useActionData } from '@remix-run/react'; + +export const action: ActionFunction = async ({ params: { id } }) => { + // Throw string + if (id === '-1') { + throw 'Thrown String Error'; + } + + // Throw object + if (id === '-2') { + throw { + message: 'Thrown Object Error', + statusCode: 500, + }; + } +}; + +export default function ActionJSONResponse() { + const data = useActionData(); + + return ( +
+

{data && data.test ? data.test : 'Not Found'}

+
+ ); +} diff --git a/packages/remix/test/integration/common/routes/ssr-error.tsx b/packages/remix/test/integration/common/routes/ssr-error.tsx new file mode 100644 index 000000000000..d171f73bcd9c --- /dev/null +++ b/packages/remix/test/integration/common/routes/ssr-error.tsx @@ -0,0 +1,7 @@ +export default function SSRError() { + const data = ['err'].map(err => { + throw new Error('Sentry SSR Test Error'); + }); + + return
{data}
; +} diff --git a/packages/remix/test/integration/test/client/ssr-error.test.ts b/packages/remix/test/integration/test/client/ssr-error.test.ts new file mode 100644 index 000000000000..45d62a62a51f --- /dev/null +++ b/packages/remix/test/integration/test/client/ssr-error.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from '@playwright/test'; +import { countEnvelopes } from './utils/helpers'; + +test('should not report an SSR error on client side.', async ({ page }) => { + const count = await countEnvelopes(page, { url: '/ssr-error', envelopeType: 'event' }); + + expect(count).toBe(0); +}); diff --git a/packages/remix/test/integration/test/server/action.test.ts b/packages/remix/test/integration/test/server/action.test.ts index 7cc99ded78d3..c20ba38f5658 100644 --- a/packages/remix/test/integration/test/server/action.test.ts +++ b/packages/remix/test/integration/test/server/action.test.ts @@ -81,7 +81,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'remix.server' : 'action', + function: useV2 ? 'remix.server.handleError' : 'action', }, handled: false, type: 'instrument', @@ -140,7 +140,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada }); }); - it('handles a thrown 500 response', async () => { + it('handles an error-throwing redirection target', async () => { const env = await RemixTestEnv.init(adapter); const url = `${env.url}/action-json-response/-2`; @@ -201,7 +201,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'remix.server' : 'loader', + function: useV2 ? 'remix.server.handleError' : 'loader', }, handled: false, type: 'instrument', @@ -254,7 +254,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'ErrorResponse' : 'action', + function: 'action', }, handled: false, type: 'instrument', @@ -303,13 +303,11 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada values: [ { type: 'Error', - value: useV2 - ? 'Object captured as exception with keys: data, internal, status, statusText' - : 'Object captured as exception with keys: data', + value: 'Object captured as exception with keys: data', stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'ErrorResponse' : 'action', + function: 'action', }, handled: false, type: 'instrument', @@ -362,7 +360,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'ErrorResponse' : 'action', + function: 'action', }, handled: false, type: 'instrument', @@ -411,13 +409,117 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada values: [ { type: 'Error', - value: useV2 - ? 'Object captured as exception with keys: data, internal, status, statusText' - : 'Object captured as exception with keys: [object has no keys]', + value: 'Object captured as exception with keys: [object has no keys]', stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'ErrorResponse' : 'action', + function: 'action', + }, + handled: false, + type: 'instrument', + }, + }, + ], + }, + }); + }); + + it('handles thrown string (primitive) from an action', async () => { + const env = await RemixTestEnv.init(adapter); + const url = `${env.url}/server-side-unexpected-errors/-1`; + + const envelopes = await env.getMultipleEnvelopeRequest({ + url, + count: 2, + method: 'post', + envelopeType: ['event', 'transaction'], + }); + + const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction'); + const [event] = envelopes.filter(envelope => envelope[1].type === 'event'); + + assertSentryTransaction(transaction[2], { + contexts: { + trace: { + op: 'http.server', + status: 'internal_error', + tags: { + method: 'POST', + 'http.status_code': '500', + }, + data: { + 'http.response.status_code': 500, + }, + }, + }, + tags: { + transaction: `routes/server-side-unexpected-errors${useV2 ? '.' : '/'}$id`, + }, + }); + + assertSentryEvent(event[2], { + exception: { + values: [ + { + type: 'Error', + value: 'Thrown String Error', + stacktrace: expect.any(Object), + mechanism: { + data: { + function: useV2 ? 'remix.server.handleError' : 'action', + }, + handled: false, + type: 'instrument', + }, + }, + ], + }, + }); + }); + + it('handles thrown object from an action', async () => { + const env = await RemixTestEnv.init(adapter); + const url = `${env.url}/server-side-unexpected-errors/-2`; + + const envelopes = await env.getMultipleEnvelopeRequest({ + url, + count: 2, + method: 'post', + envelopeType: ['event', 'transaction'], + }); + + const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction'); + const [event] = envelopes.filter(envelope => envelope[1].type === 'event'); + + assertSentryTransaction(transaction[2], { + contexts: { + trace: { + op: 'http.server', + status: 'internal_error', + tags: { + method: 'POST', + 'http.status_code': '500', + }, + data: { + 'http.response.status_code': 500, + }, + }, + }, + tags: { + transaction: `routes/server-side-unexpected-errors${useV2 ? '.' : '/'}$id`, + }, + }); + + assertSentryEvent(event[2], { + exception: { + values: [ + { + type: 'Error', + value: 'Thrown Object Error', + stacktrace: expect.any(Object), + mechanism: { + data: { + function: useV2 ? 'remix.server.handleError' : 'action', }, handled: false, type: 'instrument', diff --git a/packages/remix/test/integration/test/server/loader.test.ts b/packages/remix/test/integration/test/server/loader.test.ts index c3f60dcb66ee..b6d4fc402ea7 100644 --- a/packages/remix/test/integration/test/server/loader.test.ts +++ b/packages/remix/test/integration/test/server/loader.test.ts @@ -39,7 +39,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'remix.server' : 'loader', + function: useV2 ? 'remix.server.handleError' : 'loader', }, handled: false, type: 'instrument', @@ -78,7 +78,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada }); }); - it('handles a thrown 500 response', async () => { + it('handles an error-throwing redirection target', async () => { const env = await RemixTestEnv.init(adapter); const url = `${env.url}/loader-json-response/-1`; @@ -138,7 +138,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'remix.server' : 'loader', + function: useV2 ? 'remix.server.handleError' : 'loader', }, handled: false, type: 'instrument', diff --git a/packages/remix/test/integration/test/server/ssr.test.ts b/packages/remix/test/integration/test/server/ssr.test.ts new file mode 100644 index 000000000000..46c345cc5a73 --- /dev/null +++ b/packages/remix/test/integration/test/server/ssr.test.ts @@ -0,0 +1,46 @@ +import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from './utils/helpers'; + +const useV2 = process.env.REMIX_VERSION === '2'; + +describe('Server Side Rendering', () => { + it('correctly reports a server side rendering error', async () => { + const env = await RemixTestEnv.init('builtin'); + const url = `${env.url}/ssr-error`; + const envelopes = await env.getMultipleEnvelopeRequest({ url, count: 2, envelopeType: ['transaction', 'event'] }); + const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction'); + const [event] = envelopes.filter(envelope => envelope[1].type === 'event'); + + assertSentryTransaction(transaction[2], { + contexts: { + trace: { + status: 'internal_error', + tags: { + 'http.status_code': '500', + }, + data: { + 'http.response.status_code': 500, + }, + }, + }, + }); + + assertSentryEvent(event[2], { + exception: { + values: [ + { + type: 'Error', + value: 'Sentry SSR Test Error', + stacktrace: expect.any(Object), + mechanism: { + data: { + function: useV2 ? 'remix.server.handleError' : 'documentRequest', + }, + handled: false, + type: 'instrument', + }, + }, + ], + }, + }); + }); +}); diff --git a/packages/utils/src/instrument/xhr.ts b/packages/utils/src/instrument/xhr.ts index eb3ec8e158d5..bef77659b3ee 100644 --- a/packages/utils/src/instrument/xhr.ts +++ b/packages/utils/src/instrument/xhr.ts @@ -46,7 +46,7 @@ export function instrumentXHR(): void { const url = parseUrl(args[1]); if (!method || !url) { - return; + return originalOpen.apply(this, args); } this[SENTRY_XHR_DATA_KEY] = { @@ -124,7 +124,7 @@ export function instrumentXHR(): void { const sentryXhrData = this[SENTRY_XHR_DATA_KEY]; if (!sentryXhrData) { - return; + return originalSend.apply(this, args); } if (args[0] !== undefined) { diff --git a/yarn.lock b/yarn.lock index 10db5d1c22c6..ef0bcb3c6b02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5048,15 +5048,6 @@ semver "7.3.2" semver-intersect "1.4.0" -"@sentry-internal/feedback@0.0.1-alpha.17": - version "0.0.1-alpha.17" - resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-0.0.1-alpha.17.tgz#98e7477152111efe18febc07e7d6064d32340711" - integrity sha512-PCYiA0tQU8NWOVeZf8eRxptkI5JMro+9jQT1pLNyRG9vhzYYXurepalUgeY8qbMREdaFk5p0N9DmkJHDiOW1mg== - dependencies: - "@sentry/core" "7.84.0" - "@sentry/types" "7.84.0" - "@sentry/utils" "7.84.0" - "@sentry-internal/rrdom@2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@sentry-internal/rrdom/-/rrdom-2.2.0.tgz#edc292e55263e1e2541a9ed282ff998aae272a0a" @@ -5118,6 +5109,41 @@ magic-string "0.27.0" unplugin "1.0.1" +"@sentry/cli-darwin@2.22.3": + version "2.22.3" + resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.22.3.tgz#d81f6a1b2060d20adb1da7e65e1c57e050e8ffcc" + integrity sha512-A1DwFTffg3+fF68qujaJI07dk/1H1pRuihlvS5WQ9sD7nQLnXZGoLUht4eULixhDzZYinWHKkcWzQ6k40UTvNA== + +"@sentry/cli-linux-arm64@2.22.3": + version "2.22.3" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.22.3.tgz#9bdd3f3a441017b82fdbf0986fdf3b2e19ceda0c" + integrity sha512-PnBPb4LJ+A2LlqLjtVFn4mEizcVdxBSLZvB85pEGzq9DRXjZ6ZEuGWFHTVnWvjd79TB/s0me29QnLc3n4B6lgA== + +"@sentry/cli-linux-arm@2.22.3": + version "2.22.3" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.22.3.tgz#74ae1d9bf8a9334e155412fc66c770573b264d7c" + integrity sha512-mDtLVbqbCu/5b/v2quTAMzY/atGlJVvrqO2Wvpro0Jb/LYhn7Y1pVBdoXEDcnOX82/pseFkLT8PFfq/OcezPhA== + +"@sentry/cli-linux-i686@2.22.3": + version "2.22.3" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.22.3.tgz#8e077d2f48c6c9a791e2db303c55bd4b3f47e2f8" + integrity sha512-wxvbpQ2hiw4hwJWfJMp7K45BV40nXL62f91jLuftFXIbieKX1Li57NNKNu2JUVn7W1bJxkwz/PKGGTXSgeJlRw== + +"@sentry/cli-linux-x64@2.22.3": + version "2.22.3" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.22.3.tgz#c3d653032105043b5e72202812c2b85dbbfbabbb" + integrity sha512-0GxsYNO5GyRWifeOpng+MmdUFZRA64bgA1n1prsEsXnoeLcm3Zj4Q63hBZmiwz9Qbhf5ibohkpf94a7dI7pv3A== + +"@sentry/cli-win32-i686@2.22.3": + version "2.22.3" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.22.3.tgz#836baaa8af96b5249c753d2f0b5c111d4c850e4a" + integrity sha512-YERPsd7ClBrxKcmCUw+ZrAvQfbyIZFrqh269hgDuXFodpsB7LPGnI33ilo0uzmKdq2vGppTb6Z3gf1Rbq0Hadg== + +"@sentry/cli-win32-x64@2.22.3": + version "2.22.3" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.22.3.tgz#c450136539e8860d46434a932383005cffd89136" + integrity sha512-NUh56xWvgJo2KuC9lI6o6nTPXdzbpQUB4qGwJ73L9NP3HT2P1I27jtHyrC2zlXTVlYE23gQZGrL3wgW4Jy80QA== + "@sentry/cli@^1.74.4": version "1.74.6" resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.74.6.tgz#c4f276e52c6f5e8c8d692845a965988068ebc6f5" @@ -5165,6 +5191,25 @@ proxy-from-env "^1.1.0" which "^2.0.2" +"@sentry/cli@^2.22.3": + version "2.22.3" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.22.3.tgz#e28613885c30979f4760de7365e5d30d5a32d51d" + integrity sha512-VFHdtrHsMyTRSZhDLeMyXvit7xB4e81KugIEwMve95c7h5HO672bfmCcM/403CAugj4NzvQ+IR2NKF/2SsEPlg== + dependencies: + https-proxy-agent "^5.0.0" + node-fetch "^2.6.7" + progress "^2.0.3" + proxy-from-env "^1.1.0" + which "^2.0.2" + optionalDependencies: + "@sentry/cli-darwin" "2.22.3" + "@sentry/cli-linux-arm" "2.22.3" + "@sentry/cli-linux-arm64" "2.22.3" + "@sentry/cli-linux-i686" "2.22.3" + "@sentry/cli-linux-x64" "2.22.3" + "@sentry/cli-win32-i686" "2.22.3" + "@sentry/cli-win32-x64" "2.22.3" + "@sentry/vite-plugin@^0.6.1": version "0.6.1" resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-0.6.1.tgz#31eb744e8d87b1528eed8d41433647727a62e7c0"