diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aae090f76188..441a3581ccea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -838,7 +838,7 @@ jobs: # See: https://github.com/actions/runner/issues/2205 if: always() && needs.job_e2e_prepare.result == 'success' && needs.job_e2e_prepare.outputs.matrix != '{"include":[]}' needs: [job_get_metadata, job_build, job_e2e_prepare] - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 timeout-minutes: 15 env: # We just use a dummy DSN here, only send to the tunnel anyhow diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/.env b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.env new file mode 100644 index 000000000000..9b8dc350a98d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.env @@ -0,0 +1,2 @@ +SESSION_SECRET = "foo" +PUBLIC_STORE_DOMAIN="mock.shop" diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/.eslintignore b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.eslintignore new file mode 100644 index 000000000000..a362bcaa13b5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.eslintignore @@ -0,0 +1,5 @@ +build +node_modules +bin +*.d.ts +dist diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/.eslintrc.cjs b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.eslintrc.cjs new file mode 100644 index 000000000000..85eb86d14b9e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.eslintrc.cjs @@ -0,0 +1,79 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + + // Base config + extends: ['eslint:recommended'], + + overrides: [ + // React + { + files: ['**/*.{js,jsx,ts,tsx}'], + plugins: ['react', 'jsx-a11y'], + extends: [ + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + 'plugin:jsx-a11y/recommended', + ], + settings: { + react: { + version: 'detect', + }, + formComponents: ['Form'], + linkComponents: [ + { name: 'Link', linkAttribute: 'to' }, + { name: 'NavLink', linkAttribute: 'to' }, + ], + 'import/resolver': { + typescript: {}, + }, + }, + }, + + // Typescript + { + files: ['**/*.{ts,tsx}'], + plugins: ['@typescript-eslint', 'import'], + parser: '@typescript-eslint/parser', + settings: { + 'import/internal-regex': '^~/', + 'import/resolver': { + node: { + extensions: ['.ts', '.tsx'], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/recommended', 'plugin:import/typescript'], + }, + + // Node + { + files: ['.eslintrc.cjs', 'server.ts'], + env: { + node: true, + }, + }, + ], +}; diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/.gitignore b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.gitignore new file mode 100644 index 000000000000..4ee373874805 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.gitignore @@ -0,0 +1,9 @@ +node_modules +/.cache +/build +/dist +/public/build +/.mf +!.env +.shopify +storefrontapi.generated.d.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/.npmrc b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.client.tsx new file mode 100644 index 000000000000..82d9ae571fe1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.client.tsx @@ -0,0 +1,37 @@ +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import * as Sentry from '@sentry/remix/cloudflare'; +import { StrictMode, startTransition } from 'react'; +import { useEffect } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + // Could not find a working way to set the DSN in the browser side from the environment variables + dsn: 'https://public@dsn.ingest.sentry.io/1337', + debug: true, + integrations: [ + Sentry.browserTracingIntegration({ + useEffect, + useLocation, + useMatches, + }), + Sentry.replayIntegration({ + maskAllText: true, + blockAllMedia: true, + }), + ], + + tracesSampleRate: 1.0, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + tunnel: 'http://localhost:3031/', // proxy server +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx new file mode 100644 index 000000000000..afae990db239 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx @@ -0,0 +1,50 @@ +import { RemixServer } from '@remix-run/react'; +import { createContentSecurityPolicy } from '@shopify/hydrogen'; +import type { EntryContext } from '@shopify/remix-oxygen'; +import isbot from 'isbot'; +import { renderToReadableStream } from 'react-dom/server'; + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + const { nonce, header, NonceProvider } = createContentSecurityPolicy({ + connectSrc: [ + // Need to allow the proxy server to fetch the data + 'http://localhost:3031/', + ], + }); + + const body = await renderToReadableStream( + + + , + { + nonce, + signal: request.signal, + onError(error) { + // eslint-disable-next-line no-console + console.error(error); + responseStatusCode = 500; + }, + }, + ); + + if (isbot(request.headers.get('user-agent'))) { + await body.allReady; + } + + responseHeaders.set('Content-Type', 'text/html'); + responseHeaders.set('Content-Security-Policy', header); + + // Add the document policy header to enable JS profiling + // This is required for Sentry's profiling integration + responseHeaders.set('Document-Policy', 'js-profiling'); + + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/functions/_middleware.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/functions/_middleware.ts new file mode 100644 index 000000000000..de18f7ef68bb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/functions/_middleware.ts @@ -0,0 +1,12 @@ +import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages'; +import { sentryPagesPlugin } from '@sentry/cloudflare'; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore - the server build file is generated by `remix vite:build` +// eslint-disable-next-line import/no-unresolved +import * as build from '../build/server'; + +export const onRequest = [ + context => sentryPagesPlugin({ dsn: context.env.E2E_TEST_DSN, tracesSampleRate: 1.0 })(context), + createPagesFunctionHandler({ build }), +]; diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/fragments.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/fragments.ts new file mode 100644 index 000000000000..ccf430475620 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/fragments.ts @@ -0,0 +1,174 @@ +// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart +export const CART_QUERY_FRAGMENT = `#graphql + fragment Money on MoneyV2 { + currencyCode + amount + } + fragment CartLine on CartLine { + id + quantity + attributes { + key + value + } + cost { + totalAmount { + ...Money + } + amountPerQuantity { + ...Money + } + compareAtAmountPerQuantity { + ...Money + } + } + merchandise { + ... on ProductVariant { + id + availableForSale + compareAtPrice { + ...Money + } + price { + ...Money + } + requiresShipping + title + image { + id + url + altText + width + height + + } + product { + handle + title + id + vendor + } + selectedOptions { + name + value + } + } + } + } + fragment CartApiQuery on Cart { + updatedAt + id + checkoutUrl + totalQuantity + buyerIdentity { + countryCode + customer { + id + email + firstName + lastName + displayName + } + email + phone + } + lines(first: $numCartLines) { + nodes { + ...CartLine + } + } + cost { + subtotalAmount { + ...Money + } + totalAmount { + ...Money + } + totalDutyAmount { + ...Money + } + totalTaxAmount { + ...Money + } + } + note + attributes { + key + value + } + discountCodes { + code + applicable + } + } +` as const; + +const MENU_FRAGMENT = `#graphql + fragment MenuItem on MenuItem { + id + resourceId + tags + title + type + url + } + fragment ChildMenuItem on MenuItem { + ...MenuItem + } + fragment ParentMenuItem on MenuItem { + ...MenuItem + items { + ...ChildMenuItem + } + } + fragment Menu on Menu { + id + items { + ...ParentMenuItem + } + } +` as const; + +export const HEADER_QUERY = `#graphql + fragment Shop on Shop { + id + name + description + primaryDomain { + url + } + brand { + logo { + image { + url + } + } + } + } + query Header( + $country: CountryCode + $headerMenuHandle: String! + $language: LanguageCode + ) @inContext(language: $language, country: $country) { + shop { + ...Shop + } + menu(handle: $headerMenuHandle) { + ...Menu + } + } + ${MENU_FRAGMENT} +` as const; + +export const FOOTER_QUERY = `#graphql + query Footer( + $country: CountryCode + $footerMenuHandle: String! + $language: LanguageCode + ) @inContext(language: $language, country: $country) { + menu(handle: $footerMenuHandle) { + ...Menu + } + } + ${MENU_FRAGMENT} +` as const; diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/search.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/search.ts new file mode 100644 index 000000000000..d3295f1fc66a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/search.ts @@ -0,0 +1,25 @@ +import type { + PredictiveArticleFragment, + PredictiveCollectionFragment, + PredictivePageFragment, + PredictiveProductFragment, + PredictiveQueryFragment, + SearchProductFragment, +} from 'storefrontapi.generated'; + +export function applyTrackingParams( + resource: + | PredictiveQueryFragment + | SearchProductFragment + | PredictiveProductFragment + | PredictiveCollectionFragment + | PredictiveArticleFragment + | PredictivePageFragment, + params?: string, +) { + if (params) { + return resource?.trackingParameters ? `?${params}&${resource.trackingParameters}` : `?${params}`; + } else { + return resource?.trackingParameters ? `?${resource.trackingParameters}` : ''; + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/session.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/session.ts new file mode 100644 index 000000000000..80d6e7b86b52 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/session.ts @@ -0,0 +1,61 @@ +import type { HydrogenSession } from '@shopify/hydrogen'; +import { type Session, type SessionStorage, createCookieSessionStorage } from '@shopify/remix-oxygen'; + +/** + * This is a custom session implementation for your Hydrogen shop. + * Feel free to customize it to your needs, add helper methods, or + * swap out the cookie-based implementation with something else! + */ +export class AppSession implements HydrogenSession { + #sessionStorage; + #session; + + constructor(sessionStorage: SessionStorage, session: Session) { + this.#sessionStorage = sessionStorage; + this.#session = session; + } + + static async init(request: Request, secrets: string[]) { + const storage = createCookieSessionStorage({ + cookie: { + name: 'session', + httpOnly: true, + path: '/', + sameSite: 'lax', + secrets, + }, + }); + + const session = await storage.getSession(request.headers.get('Cookie')).catch(() => storage.getSession()); + + return new this(storage, session); + } + + get has() { + return this.#session.has; + } + + get get() { + return this.#session.get; + } + + get flash() { + return this.#session.flash; + } + + get unset() { + return this.#session.unset; + } + + get set() { + return this.#session.set; + } + + destroy() { + return this.#sessionStorage.destroySession(this.#session); + } + + commit() { + return this.#sessionStorage.commitSession(this.#session); + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/variants.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/variants.ts new file mode 100644 index 000000000000..0b8fbabdd528 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/variants.ts @@ -0,0 +1,41 @@ +import { useLocation } from '@remix-run/react'; +import type { SelectedOption } from '@shopify/hydrogen/storefront-api-types'; +import { useMemo } from 'react'; + +export function useVariantUrl(handle: string, selectedOptions: SelectedOption[]) { + const { pathname } = useLocation(); + + return useMemo(() => { + return getVariantUrl({ + handle, + pathname, + searchParams: new URLSearchParams(), + selectedOptions, + }); + }, [handle, selectedOptions, pathname]); +} + +export function getVariantUrl({ + handle, + pathname, + searchParams, + selectedOptions, +}: { + handle: string; + pathname: string; + searchParams: URLSearchParams; + selectedOptions: SelectedOption[]; +}) { + const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname); + const isLocalePathname = match && match.length > 0; + + const path = isLocalePathname ? `${match![0]}products/${handle}` : `/products/${handle}`; + + selectedOptions.forEach(option => { + searchParams.set(option.name, option.value); + }); + + const searchString = searchParams.toString(); + + return path + (searchString ? '?' + searchParams.toString() : ''); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx new file mode 100644 index 000000000000..ef098d204e10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx @@ -0,0 +1,279 @@ +import { + Links, + LiveReload, + Meta, + type MetaFunction, + Outlet, + Scripts, + ScrollRestoration, + type ShouldRevalidateFunction, + isRouteErrorResponse, + useLocation, + useMatches, + useRouteError, +} from '@remix-run/react'; + +import * as Sentry from '@sentry/remix/cloudflare'; +import { useNonce } from '@shopify/hydrogen'; +import type { CustomerAccessToken } from '@shopify/hydrogen/storefront-api-types'; +import { type LoaderArgs, defer } from '@shopify/remix-oxygen'; +import { useEffect } from 'react'; +import favicon from '../public/favicon.svg'; +import type { HydrogenSession } from '../server'; + +// This is important to avoid re-fetching root queries on sub-navigations +export const shouldRevalidate: ShouldRevalidateFunction = ({ formMethod, currentUrl, nextUrl }) => { + // revalidate when a mutation is performed e.g add to cart, login... + if (formMethod && formMethod !== 'GET') { + return true; + } + + // revalidate when manually revalidating via useRevalidator + if (currentUrl.toString() === nextUrl.toString()) { + return true; + } + + return false; +}; + +export function links() { + return [ + { + rel: 'preconnect', + href: 'https://cdn.shopify.com', + }, + { + rel: 'preconnect', + href: 'https://shop.app', + }, + { rel: 'icon', type: 'image/svg+xml', href: favicon }, + ]; +} + +export async function loader({ context }: LoaderArgs) { + const { storefront, session, cart } = context; + const customerAccessToken = await session.get('customerAccessToken'); + const publicStoreDomain = context.env.PUBLIC_STORE_DOMAIN; + + // validate the customer access token is valid + const { isLoggedIn, headers } = await validateCustomerAccessToken(session, customerAccessToken); + + // defer the cart query by not awaiting it + const cartPromise = cart.get(); + + // defer the footer query (below the fold) + const footerPromise = storefront.query(FOOTER_QUERY, { + cache: storefront.CacheLong(), + variables: { + footerMenuHandle: 'footer', // Adjust to your footer menu handle + }, + }); + + // await the header query (above the fold) + const headerPromise = storefront.query(HEADER_QUERY, { + cache: storefront.CacheLong(), + variables: { + headerMenuHandle: 'main-menu', // Adjust to your header menu handle + }, + }); + + return defer( + { + cart: cartPromise, + footer: footerPromise, + header: await headerPromise, + isLoggedIn, + publicStoreDomain, + }, + { headers }, + ); +} + +export const meta = ({ data }: Sentry.SentryMetaArgs>) => { + return [ + { + name: 'sentry-trace', + content: data.sentryTrace, + }, + { + name: 'baggage', + content: data.sentryBaggage, + }, + ]; +}; + +function App() { + const nonce = useNonce(); + + return ( + + + + + + + + + + + + + + + ); +} + +// Need to supply the hooks to Sentry.withSentry for hydrogen apps +export default Sentry.withSentry(App, useEffect, useLocation, useMatches); + +export function ErrorBoundary() { + const error = useRouteError(); + const [root] = useMatches(); + const nonce = useNonce(); + let errorMessage = 'Unknown error'; + let errorStatus = 500; + + // Send the error to Sentry + const eventId = Sentry.captureRemixErrorBoundaryError(error); + + if (isRouteErrorResponse(error)) { + errorMessage = error?.data?.message ?? error.data; + errorStatus = error.status; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + return ( + + + + + + + + +
+

Oops

+

{errorStatus}

+ {errorMessage && ( +
+
{errorMessage}
+
+ )} + {eventId && ( +

+ Sentry Event ID: {eventId} +

+ )} +
+ + + + + + ); +} + +/** + * Validates the customer access token and returns a boolean and headers + * @see https://shopify.dev/docs/api/storefront/latest/objects/CustomerAccessToken + * + * @example + * ```ts + * // + * const {isLoggedIn, headers} = await validateCustomerAccessToken( + * customerAccessToken, + * session, + * ); + * ``` + * */ +async function validateCustomerAccessToken(session: HydrogenSession, customerAccessToken?: CustomerAccessToken) { + let isLoggedIn = false; + const headers = new Headers(); + if (!customerAccessToken?.accessToken || !customerAccessToken?.expiresAt) { + return { isLoggedIn, headers }; + } + + const expiresAt = new Date(customerAccessToken.expiresAt).getTime(); + const dateNow = Date.now(); + const customerAccessTokenExpired = expiresAt < dateNow; + + if (customerAccessTokenExpired) { + session.unset('customerAccessToken'); + headers.append('Set-Cookie', await session.commit()); + } else { + isLoggedIn = true; + } + + return { isLoggedIn, headers }; +} + +const MENU_FRAGMENT = `#graphql + fragment MenuItem on MenuItem { + id + resourceId + tags + title + type + url + } + fragment ChildMenuItem on MenuItem { + ...MenuItem + } + fragment ParentMenuItem on MenuItem { + ...MenuItem + items { + ...ChildMenuItem + } + } + fragment Menu on Menu { + id + items { + ...ParentMenuItem + } + } +` as const; + +const HEADER_QUERY = `#graphql + fragment Shop on Shop { + id + name + description + primaryDomain { + url + } + brand { + logo { + image { + url + } + } + } + } + query Header( + $country: CountryCode + $headerMenuHandle: String! + $language: LanguageCode + ) @inContext(language: $language, country: $country) { + shop { + ...Shop + } + menu(handle: $headerMenuHandle) { + ...Menu + } + } + ${MENU_FRAGMENT} +` as const; + +const FOOTER_QUERY = `#graphql + query Footer( + $country: CountryCode + $footerMenuHandle: String! + $language: LanguageCode + ) @inContext(language: $language, country: $country) { + menu(handle: $footerMenuHandle) { + ...Menu + } + } + ${MENU_FRAGMENT} +` as const; diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/_index.tsx new file mode 100644 index 000000000000..48e9494f1c2c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/_index.tsx @@ -0,0 +1,29 @@ +import { Link, useSearchParams } from '@remix-run/react'; +import * as Sentry from '@sentry/remix/cloudflare'; + +export default function Index() { + const [searchParams] = useSearchParams(); + + if (searchParams.get('tag')) { + Sentry.setTags({ + sentry_test: searchParams.get('tag'), + }); + } + + return ( +
+ { + const eventId = Sentry.captureException(new Error('I am an error!')); + window.capturedExceptionId = eventId; + }} + /> + + navigate + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/action-formdata.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/action-formdata.tsx new file mode 100644 index 000000000000..9c029b377b08 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/action-formdata.tsx @@ -0,0 +1,17 @@ +import { Form } from '@remix-run/react'; +import { json } from '@shopify/remix-oxygen'; + +export async function action() { + return json({ message: 'success' }); +} + +export default function ActionFormData() { + return ( +
+ + + + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/client-error.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/client-error.tsx new file mode 100644 index 000000000000..81859fb3d1ba --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/client-error.tsx @@ -0,0 +1,24 @@ +import { useSearchParams } from '@remix-run/react'; +import * as Sentry from '@sentry/remix/cloudflare'; + +import { useState } from 'react'; + +export default function ErrorBoundaryCapture() { + const [searchParams] = useSearchParams(); + + if (searchParams.get('tag')) { + Sentry.setTags({ + sentry_test: searchParams.get('tag'), + }); + } + + const [count, setCount] = useState(0); + + if (count > 0) { + throw new Error('Sentry React Component Error'); + } else { + setTimeout(() => setCount(count + 1), 0); + } + + return
{count}
; +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/loader-error.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/loader-error.tsx new file mode 100644 index 000000000000..16e1ac5e83bb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/loader-error.tsx @@ -0,0 +1,16 @@ +import { useLoaderData } from '@remix-run/react'; +import type { LoaderFunction } from '@shopify/remix-oxygen'; + +export default function LoaderError() { + useLoaderData(); + + return ( +
+

Loader Error

+
+ ); +} + +export const loader: LoaderFunction = () => { + throw new Error('Loader Error'); +}; diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/navigate.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/navigate.tsx new file mode 100644 index 000000000000..7fe190a6eb77 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/navigate.tsx @@ -0,0 +1,20 @@ +import { useLoaderData } from '@remix-run/react'; +import type { LoaderFunction } from '@shopify/remix-oxygen'; + +export const loader: LoaderFunction = async ({ params: { id } }) => { + if (id === '-1') { + throw new Error('Unexpected Server Error'); + } + + return null; +}; + +export default function LoaderError() { + const data = useLoaderData(); + + return ( +
+

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

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/user.$id.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/user.$id.tsx new file mode 100644 index 000000000000..13b2e0a34d1e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/routes/user.$id.tsx @@ -0,0 +1,3 @@ +export default function User() { + return
I am a blank page
; +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/utils.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/utils.ts new file mode 100644 index 000000000000..0b8fbabdd528 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/utils.ts @@ -0,0 +1,41 @@ +import { useLocation } from '@remix-run/react'; +import type { SelectedOption } from '@shopify/hydrogen/storefront-api-types'; +import { useMemo } from 'react'; + +export function useVariantUrl(handle: string, selectedOptions: SelectedOption[]) { + const { pathname } = useLocation(); + + return useMemo(() => { + return getVariantUrl({ + handle, + pathname, + searchParams: new URLSearchParams(), + selectedOptions, + }); + }, [handle, selectedOptions, pathname]); +} + +export function getVariantUrl({ + handle, + pathname, + searchParams, + selectedOptions, +}: { + handle: string; + pathname: string; + searchParams: URLSearchParams; + selectedOptions: SelectedOption[]; +}) { + const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname); + const isLocalePathname = match && match.length > 0; + + const path = isLocalePathname ? `${match![0]}products/${handle}` : `/products/${handle}`; + + selectedOptions.forEach(option => { + searchParams.set(option.name, option.value); + }); + + const searchString = searchParams.toString(); + + return path + (searchString ? '?' + searchParams.toString() : ''); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/env.d.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/env.d.ts new file mode 100644 index 000000000000..6ae256061aa8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/env.d.ts @@ -0,0 +1,49 @@ +/// +/// +/// + +// Enhance TypeScript's built-in typings. +import '@total-typescript/ts-reset'; + +import type { CustomerAccount, HydrogenCart, HydrogenSessionData, Storefront } from '@shopify/hydrogen'; +import type { AppSession } from '~/lib/session'; + +declare global { + /** + * A global `process` object is only available during build to access NODE_ENV. + */ + const process: { env: { NODE_ENV: 'production' | 'development' } }; + + /** + * Declare expected Env parameter in fetch handler. + */ + interface Env { + SESSION_SECRET: string; + PUBLIC_STOREFRONT_API_TOKEN: string; + PRIVATE_STOREFRONT_API_TOKEN: string; + PUBLIC_STORE_DOMAIN: string; + PUBLIC_STOREFRONT_ID: string; + PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; + PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; + PUBLIC_CHECKOUT_DOMAIN: string; + } +} + +declare module '@shopify/remix-oxygen' { + /** + * Declare local additions to the Remix loader context. + */ + interface AppLoadContext { + env: Env; + cart: HydrogenCart; + storefront: Storefront; + customerAccount: CustomerAccount; + session: AppSession; + waitUntil: ExecutionContext['waitUntil']; + } + + /** + * Declare local additions to the Remix session data. + */ + interface SessionData extends HydrogenSessionData {} +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/globals.d.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/globals.d.ts new file mode 100644 index 000000000000..4130ac6a8a09 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/globals.d.ts @@ -0,0 +1,7 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + ENV: { + SENTRY_DSN: string; + }; +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json new file mode 100644 index 000000000000..e7848f486ce1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json @@ -0,0 +1,58 @@ +{ + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "shopify hydrogen build --codegen", + "dev": "shopify hydrogen dev --codegen", + "preview": "shopify hydrogen preview", + "lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .", + "typecheck": "tsc --noEmit", + "codegen": "shopify hydrogen codegen", + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "pnpm playwright test" + }, + "prettier": "@shopify/prettier-config", + "dependencies": { + "@remix-run/react": "^2.15.2", + "@remix-run/server-runtime": "^2.15.2", + "@sentry/cloudflare": "latest || *", + "@sentry/remix": "latest || *", + "@sentry/vite-plugin": "^3.1.2", + "@shopify/hydrogen": "^2025.1.0", + "@shopify/remix-oxygen": "^2.0.10", + "graphql": "^16.6.0", + "graphql-tag": "^2.12.6", + "isbot": "^3.8.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@graphql-codegen/cli": "5.0.2", + "@playwright/test": "^1.44.1", + "@remix-run/dev": "^2.15.2", + "@remix-run/eslint-config": "^2.15.2", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@shopify/cli": "^3.74.1", + "@shopify/hydrogen-codegen": "^0.3.1", + "@shopify/mini-oxygen": "^3.1.1", + "@shopify/oxygen-workers-types": "^4.1.2", + "@shopify/prettier-config": "^1.1.2", + "@tailwindcss/vite": "4.0.0-alpha.17", + "@total-typescript/ts-reset": "^0.4.2", + "@types/eslint": "^8.4.10", + "@types/react": "^18.2.22", + "@types/react-dom": "^18.2.7", + "esbuild": "0.19.12", + "eslint": "^8.20.0", + "eslint-plugin-hydrogen": "0.12.2", + "prettier": "^2.8.4", + "typescript": "^5.2.2", + "vite": "^5.1.0", + "vite-tsconfig-paths": "^4.3.1" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/remix-hydrogen/playwright.config.mjs new file mode 100644 index 000000000000..700607cc6f95 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm run preview`, + port: 3000, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/public/favicon.ico b/dev-packages/e2e-tests/test-applications/remix-hydrogen/public/favicon.ico new file mode 100644 index 000000000000..f6c649733d68 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/public/favicon.ico @@ -0,0 +1,28 @@ + + + + + diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/public/favicon.svg b/dev-packages/e2e-tests/test-applications/remix-hydrogen/public/favicon.svg new file mode 100644 index 000000000000..f6c649733d68 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/public/favicon.svg @@ -0,0 +1,28 @@ + + + + + diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/server.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/server.ts new file mode 100644 index 000000000000..6a3a889cf968 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/server.ts @@ -0,0 +1,123 @@ +import { wrapRequestHandler } from '@sentry/cloudflare/request'; +import { instrumentBuild } from '@sentry/remix/cloudflare'; +import { + cartGetIdDefault, + cartSetIdDefault, + createCartHandler, + createCustomerAccountClient, + createStorefrontClient, + storefrontRedirect, +} from '@shopify/hydrogen'; +import { type AppLoadContext, createRequestHandler, getStorefrontHeaders } from '@shopify/remix-oxygen'; +import { CART_QUERY_FRAGMENT } from '~/lib/fragments'; +import { AppSession } from '~/lib/session'; +// Virtual entry point for the app +import * as remixBuild from 'virtual:remix/server-build'; + +/** + * Export a fetch handler in module format. + */ +export default { + async fetch(request: Request, env: Env, executionContext: ExecutionContext): Promise { + return wrapRequestHandler( + { + options: { + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + tunnel: `http://localhost:3031/`, // proxy server + }, + // Need to cast to any because this is not on cloudflare + request: request as any, + context: executionContext, + }, + async () => { + try { + /** + * Open a cache instance in the worker and a custom session instance. + */ + if (!env?.SESSION_SECRET) { + throw new Error('SESSION_SECRET environment variable is not set'); + } + + const waitUntil = executionContext.waitUntil.bind(executionContext); + const [cache, session] = await Promise.all([ + caches.open('hydrogen'), + AppSession.init(request, [env.SESSION_SECRET]), + ]); + + /** + * Create Hydrogen's Storefront client. + */ + const { storefront } = createStorefrontClient({ + cache, + waitUntil, + i18n: { language: 'EN', country: 'US' }, + publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, + privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, + storeDomain: env.PUBLIC_STORE_DOMAIN, + storefrontId: env.PUBLIC_STOREFRONT_ID, + storefrontHeaders: getStorefrontHeaders(request), + }); + + /** + * Create a client for Customer Account API. + */ + const customerAccount = createCustomerAccountClient({ + waitUntil, + request, + session, + customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, + customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, + }); + + /* + * Create a cart handler that will be used to + * create and update the cart in the session. + */ + const cart = createCartHandler({ + storefront, + customerAccount, + getCartId: cartGetIdDefault(request.headers), + setCartId: cartSetIdDefault(), + cartQueryFragment: CART_QUERY_FRAGMENT, + }); + + /** + * Create a Remix request handler and pass + * Hydrogen's Storefront client to the loader context. + */ + const handleRequest = createRequestHandler({ + build: instrumentBuild(remixBuild), + mode: process.env.NODE_ENV, + getLoadContext: (): AppLoadContext => ({ + session, + storefront, + customerAccount, + cart, + env, + waitUntil, + }), + }); + + const response = await handleRequest(request); + + if (response.status === 404) { + /** + * Check for redirects only when there's a 404 from the app. + * If the redirect doesn't exist, then `storefrontRedirect` + * will pass through the 404 response. + */ + return storefrontRedirect({ request, response, storefront }); + } + + return response; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + return new Response('An unexpected error occurred', { status: 500 }); + } + }, + ); + }, +}; diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/remix-hydrogen/start-event-proxy.mjs new file mode 100644 index 000000000000..fa42041e54a3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'remix-hydrogen', +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/client-errors.test.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/client-errors.test.ts new file mode 100644 index 000000000000..747ce8677afb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/client-errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends a client-side exception to Sentry', async ({ page }) => { + const errorPromise = waitForError('remix-hydrogen', errorEvent => { + return errorEvent.exception?.values?.[0].value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorPromise; + + expect(errorEvent).toBeDefined(); +}); + +test('Sends a client-side ErrorBoundary exception to Sentry', async ({ page }) => { + const errorPromise = waitForError('remix-hydrogen', errorEvent => { + return errorEvent.exception?.values?.[0].value === 'Sentry React Component Error'; + }); + + await page.goto('/client-error'); + + const errorEvent = await errorPromise; + + expect(errorEvent).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/client-transactions.test.ts new file mode 100644 index 000000000000..918fcba1fb3d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/client-transactions.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a pageload transaction to Sentry', async ({ page }) => { + const transactionPromise = waitForTransaction('remix-hydrogen', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === 'routes/_index'; + }); + + await page.goto('/'); + + const transactionEvent = await transactionPromise; + + expect(transactionEvent).toBeDefined(); +}); + +test('Sends a navigation transaction to Sentry', async ({ page }) => { + const transactionPromise = waitForTransaction('remix-hydrogen', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === 'routes/user.$id'; + }); + + await page.goto('/'); + + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const transactionEvent = await transactionPromise; + + expect(transactionEvent).toBeDefined(); + expect(transactionEvent).toMatchObject({ + transaction: 'routes/user.$id', + }); +}); + +test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => { + await page.goto('/'); + + const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { + state: 'attached', + }); + const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { + state: 'attached', + }); + + expect(sentryTraceMetaTag).toBeTruthy(); + expect(baggageMetaTag).toBeTruthy(); +}); + +test('Renders `sentry-trace` and `baggage` meta tags for a sub-route', async ({ page }) => { + await page.goto('/user/123'); + + const sentryTraceMetaTag = await page.waitForSelector('meta[name="sentry-trace"]', { + state: 'attached', + }); + const baggageMetaTag = await page.waitForSelector('meta[name="baggage"]', { + state: 'attached', + }); + + expect(sentryTraceMetaTag).toBeTruthy(); + expect(baggageMetaTag).toBeTruthy(); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-transactions.test.ts new file mode 100644 index 000000000000..2a13d40e2c54 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-transactions.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import { uuid4 } from '@sentry/core'; + +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe.configure({ mode: 'serial' }); + +test('Sends parameterized transaction name to Sentry', async ({ page }) => { + const transactionPromise = waitForTransaction('remix-hydrogen', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto('/user/123'); + + const transaction = await transactionPromise; + + expect(transaction).toBeDefined(); + expect(transaction.transaction).toBe('GET /user/123'); +}); + +test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => { + // We use this to identify the transactions + const testTag = uuid4(); + + const httpServerTransactionPromise = waitForTransaction('remix-hydrogen', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.tags?.['sentry_test'] === testTag; + }); + + const pageLoadTransactionPromise = waitForTransaction('remix-hydrogen', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag; + }); + + page.goto(`/?tag=${testTag}`); + + const pageloadTransaction = await pageLoadTransactionPromise; + const httpServerTransaction = await httpServerTransactionPromise; + + expect(pageloadTransaction).toBeDefined(); + expect(httpServerTransaction).toBeDefined(); + + const httpServerTraceId = httpServerTransaction.contexts?.trace?.trace_id; + const httpServerSpanId = httpServerTransaction.contexts?.trace?.span_id; + const loaderSpanId = httpServerTransaction?.spans?.find(span => span.op === 'function.remix.loader')?.span_id; + + const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id; + const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id; + + expect(httpServerTransaction.transaction).toBe('GET /'); + expect(pageloadTransaction.transaction).toBe('routes/_index'); + + expect(httpServerTraceId).toBeDefined(); + expect(httpServerSpanId).toBeDefined(); + expect(loaderSpanId).toBeDefined(); + + expect(pageLoadTraceId).toEqual(httpServerTraceId); + expect(pageLoadSpanId).not.toEqual(httpServerSpanId); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/tsconfig.json b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tsconfig.json new file mode 100644 index 000000000000..dcd7c7237a90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "module": "ES2022", + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "baseUrl": ".", + "types": ["@shopify/oxygen-workers-types"], + "paths": { + "~/*": ["app/*"] + }, + "noEmit": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/vite.config.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/vite.config.ts new file mode 100644 index 000000000000..03a2f7539924 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/vite.config.ts @@ -0,0 +1,46 @@ +import { vitePlugin as remix } from '@remix-run/dev'; +import { hydrogen } from '@shopify/hydrogen/vite'; +import { oxygen } from '@shopify/mini-oxygen/vite'; +import { defineConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [ + hydrogen(), + oxygen(), + remix({ + presets: [hydrogen.preset()], + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + }, + }), + tsconfigPaths({ + // The dev server config errors are not relevant to this test app + // https://github.com/aleclarson/vite-tsconfig-paths?tab=readme-ov-file#options + ignoreConfigErrors: true, + }), + ], + build: { + // Allow a strict Content-Security-Policy + // without inlining assets as base64: + assetsInlineLimit: 0, + minify: false, + }, + ssr: { + optimizeDeps: { + /** + * Include dependencies here if they throw CJS<>ESM errors. + * For example, for the following error: + * + * > ReferenceError: module is not defined + * > at /Users/.../node_modules/example-dep/index.js:1:1 + * + * Include 'example-dep' in the array below. + * @see https://vitejs.dev/config/dep-optimization-options + */ + include: ['hoist-non-react-statics', '@sentry/remix'], + }, + }, +}); diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 9d40d3853937..5e88ecc8b6d8 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -26,6 +26,16 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } + }, + "./request": { + "import": { + "types": "./build/types/request.d.ts", + "default": "./build/esm/request.js" + }, + "require": { + "types": "./build/types/request.d.ts", + "default": "./build/cjs/request.js" + } } }, "typesVersions": { diff --git a/packages/remix/package.json b/packages/remix/package.json index 7c38b98ea854..dd72f00ee2d8 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -30,6 +30,12 @@ }, "node": "./build/cjs/index.server.js" }, + "./cloudflare": { + "import": "./build/esm/cloudflare/index.js", + "require": "./build/cjs/cloudflare/index.js", + "types": "./build/types/cloudflare/index.types.d.ts", + "default": "./build/esm/cloudflare/index.js" + }, "./import": { "import": { "default": "./build/import-hook.mjs" @@ -86,7 +92,7 @@ "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:tarball": "npm pack", - "circularDepCheck": "madge --circular src/index.server.ts", + "circularDepCheck": "madge --circular src/index.server.ts && madge --circular src/index.client.ts", "clean": "rimraf build coverage sentry-remix-*.tgz", "fix": "eslint . --format stylish --fix", "lint": "eslint . --format stylish", @@ -105,8 +111,5 @@ "volta": { "extends": "../../package.json" }, - "sideEffects": [ - "./esm/index.server.js", - "./src/index.server.ts" - ] + "sideEffects": false } diff --git a/packages/remix/rollup.npm.config.mjs b/packages/remix/rollup.npm.config.mjs index 346e043eb0f9..8ba7eac8051b 100644 --- a/packages/remix/rollup.npm.config.mjs +++ b/packages/remix/rollup.npm.config.mjs @@ -3,7 +3,13 @@ import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sent export default [ ...makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.server.ts', 'src/index.client.tsx'], + entrypoints: [ + 'src/index.server.ts', + 'src/index.client.ts', + 'src/client/index.ts', + 'src/server/index.ts', + 'src/cloudflare/index.ts', + ], packageSpecificConfig: { external: ['react-router', 'react-router-dom', 'react', 'react/jsx-runtime'], output: { diff --git a/packages/remix/src/client/index.ts b/packages/remix/src/client/index.ts new file mode 100644 index 000000000000..fc77db42ad3d --- /dev/null +++ b/packages/remix/src/client/index.ts @@ -0,0 +1,24 @@ +import { logger } from '@sentry/core'; +import { DEBUG_BUILD } from '../utils/debug-build'; + +export * from '@sentry/react'; + +export { init } from './sdk'; +export { captureRemixErrorBoundaryError } from './errors'; +export { withSentry } from './performance'; +export { browserTracingIntegration } from './browserTracingIntegration'; + +// This is a no-op function that does nothing. It's here to make sure that the +// function signature is the same as in the server SDK. +// See issue: https://github.com/getsentry/sentry-javascript/issues/9594 +/* eslint-disable @typescript-eslint/no-unused-vars */ +/** + * + */ +export async function captureRemixServerException(err: unknown, name: string, request: Request): Promise { + DEBUG_BUILD && + logger.warn( + '`captureRemixServerException` is a server-only function and should not be called in the browser. ' + + 'This function is a no-op in the browser environment.', + ); +} diff --git a/packages/remix/src/client/performance.tsx b/packages/remix/src/client/performance.tsx index 90520381dfcd..9d47aae36d4a 100644 --- a/packages/remix/src/client/performance.tsx +++ b/packages/remix/src/client/performance.tsx @@ -103,8 +103,16 @@ function startNavigationSpan(matches: RouteMatch[]): void { * @param OrigApp The Remix root to wrap * @param options The options for ErrorBoundary wrapper. */ -export function withSentry

, R extends React.ComponentType

>(OrigApp: R): R { +export function withSentry

, R extends React.ComponentType

>( + OrigApp: R, + useEffect?: UseEffect, + useLocation?: UseLocation, + useMatches?: UseMatches, + instrumentNavigation?: boolean, +): R { const SentryRoot: React.FC

= (props: P) => { + setGlobals({ useEffect, useLocation, useMatches, instrumentNavigation: instrumentNavigation || true }); + // Early return when any of the required functions is not available. if (!_useEffect || !_useLocation || !_useMatches) { DEBUG_BUILD && @@ -184,8 +192,8 @@ export function setGlobals({ useMatches?: UseMatches; instrumentNavigation?: boolean; }): void { - _useEffect = useEffect; - _useLocation = useLocation; - _useMatches = useMatches; - _instrumentNavigation = instrumentNavigation; + _useEffect = useEffect || _useEffect; + _useLocation = useLocation || _useLocation; + _useMatches = useMatches || _useMatches; + _instrumentNavigation = instrumentNavigation ?? _instrumentNavigation; } diff --git a/packages/remix/src/client/sdk.ts b/packages/remix/src/client/sdk.ts new file mode 100644 index 000000000000..21b19e1aeb24 --- /dev/null +++ b/packages/remix/src/client/sdk.ts @@ -0,0 +1,21 @@ +/* eslint-enable @typescript-eslint/no-unused-vars */ +import type { Client } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; +import { init as reactInit } from '@sentry/react'; +import type { RemixOptions } from '../utils/remixOptions'; + +/** + * Initializes the Remix SDK. + * @param options The configuration options. + * @returns The initialized SDK. + */ +export function init(options: RemixOptions): Client | undefined { + const opts = { + ...options, + environment: options.environment || process.env.NODE_ENV, + }; + + applySdkMetadata(opts, 'remix', ['remix', 'react']); + + return reactInit(opts); +} diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts new file mode 100644 index 000000000000..c86548a39aee --- /dev/null +++ b/packages/remix/src/cloudflare/index.ts @@ -0,0 +1,103 @@ +export * from '@sentry/react'; + +export { captureRemixErrorBoundaryError } from '../client/errors'; +export { withSentry } from '../client/performance'; + +import { instrumentBuild as instrumentRemixBuild, makeWrappedCreateRequestHandler } from '../server/instrumentServer'; +export { makeWrappedCreateRequestHandler }; + +/** + * Instruments a Remix build to capture errors and performance data. + * @param build The Remix build to instrument. + * @returns The instrumented Remix build. + */ +export const instrumentBuild: typeof instrumentRemixBuild = build => { + return instrumentRemixBuild(build, { + instrumentTracing: true, + }); +}; + +export type { + Breadcrumb, + BreadcrumbHint, + PolymorphicRequest, + RequestEventData, + SdkInfo, + Event, + EventHint, + ErrorEvent, + Exception, + Session, + SeverityLevel, + Span, + StackFrame, + Stacktrace, + Thread, + User, +} from '@sentry/core'; + +export { + addEventProcessor, + addBreadcrumb, + addIntegration, + captureException, + captureEvent, + captureMessage, + captureFeedback, + close, + createTransport, + lastEventId, + flush, + getClient, + isInitialized, + getCurrentScope, + getGlobalScope, + getIsolationScope, + setCurrentClient, + Scope, + SDK_VERSION, + setContext, + setExtra, + setExtras, + setTag, + setTags, + setUser, + getSpanStatusFromHttpCode, + setHttpStatus, + withScope, + withIsolationScope, + captureCheckIn, + withMonitor, + setMeasurement, + getActiveSpan, + getRootSpan, + getTraceData, + getTraceMetaTags, + startSpan, + startInactiveSpan, + startSpanManual, + startNewTrace, + suppressTracing, + withActiveSpan, + getSpanDescendants, + continueTrace, + functionToStringIntegration, + inboundFiltersIntegration, + linkedErrorsIntegration, + requestDataIntegration, + extraErrorDataIntegration, + dedupeIntegration, + rewriteFramesIntegration, + captureConsoleIntegration, + moduleMetadataIntegration, + zodErrorsIntegration, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + trpcMiddleware, + spanToJSON, + spanToTraceHeader, + spanToBaggageHeader, + updateSpanName, +} from '@sentry/core'; diff --git a/packages/remix/src/index.client.ts b/packages/remix/src/index.client.ts new file mode 100644 index 000000000000..4f1cce44fa36 --- /dev/null +++ b/packages/remix/src/index.client.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/packages/remix/src/index.client.tsx b/packages/remix/src/index.client.tsx deleted file mode 100644 index 3f6d14294978..000000000000 --- a/packages/remix/src/index.client.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* eslint-enable @typescript-eslint/no-unused-vars */ - -import type { Client } from '@sentry/core'; -import { applySdkMetadata, logger } from '@sentry/core'; -import { init as reactInit } from '@sentry/react'; -import { DEBUG_BUILD } from './utils/debug-build'; -import type { RemixOptions } from './utils/remixOptions'; - -export { browserTracingIntegration } from './client/browserTracingIntegration'; -export { captureRemixErrorBoundaryError } from './client/errors'; -export { withSentry } from './client/performance'; -export * from '@sentry/react'; - -// This is a no-op function that does nothing. It's here to make sure that the -// function signature is the same as in the server SDK. -// See issue: https://github.com/getsentry/sentry-javascript/issues/9594 -/* eslint-disable @typescript-eslint/no-unused-vars */ -export async function captureRemixServerException(err: unknown, name: string, request: Request): Promise { - DEBUG_BUILD && - logger.warn( - '`captureRemixServerException` is a server-only function and should not be called in the browser. ' + - 'This function is a no-op in the browser environment.', - ); -} - -export function init(options: RemixOptions): Client | undefined { - const opts = { - ...options, - environment: options.environment || process.env.NODE_ENV, - }; - - applySdkMetadata(opts, 'remix', ['remix', 'react']); - - return reactInit(opts); -} diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index 9084284217a9..e6c9610df417 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -1,173 +1,7 @@ -import { applySdkMetadata, logger } from '@sentry/core'; -import type { Integration } from '@sentry/core'; -import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit, isInitialized } from '@sentry/node'; - -import { DEBUG_BUILD } from './utils/debug-build'; -import { instrumentServer } from './utils/instrumentServer'; -import { httpIntegration } from './utils/integrations/http'; -import { remixIntegration } from './utils/integrations/opentelemetry'; -import type { RemixOptions } from './utils/remixOptions'; - -// We need to explicitly export @sentry/node as they end up under `default` in ESM builds -// See: https://github.com/getsentry/sentry-javascript/issues/8474 -export { - addBreadcrumb, - addEventProcessor, - addIntegration, - amqplibIntegration, - anrIntegration, - disableAnrDetectionForCallback, - captureCheckIn, - captureConsoleIntegration, - captureEvent, - captureException, - captureFeedback, - captureMessage, - captureSession, - close, - connectIntegration, - consoleIntegration, - contextLinesIntegration, - continueTrace, - createGetModuleFromFilename, - createTransport, - cron, - dedupeIntegration, - defaultStackParser, - endSession, - expressErrorHandler, - expressIntegration, - extraErrorDataIntegration, - fastifyIntegration, - flush, - functionToStringIntegration, - generateInstrumentOnce, - genericPoolIntegration, - getActiveSpan, - getAutoPerformanceIntegrations, - getClient, - getCurrentScope, - getDefaultIntegrations, - getGlobalScope, - getIsolationScope, - getRootSpan, - getSentryRelease, - getSpanDescendants, - getSpanStatusFromHttpCode, - graphqlIntegration, - hapiIntegration, - httpIntegration, - inboundFiltersIntegration, - initOpenTelemetry, - isInitialized, - knexIntegration, - kafkaIntegration, - koaIntegration, - lastEventId, - linkedErrorsIntegration, - localVariablesIntegration, - makeNodeTransport, - modulesIntegration, - mongoIntegration, - mongooseIntegration, - mysql2Integration, - mysqlIntegration, - nativeNodeFetchIntegration, - NodeClient, - nodeContextIntegration, - onUncaughtExceptionIntegration, - onUnhandledRejectionIntegration, - parameterize, - postgresIntegration, - prismaIntegration, - redisIntegration, - requestDataIntegration, - rewriteFramesIntegration, - Scope, - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - setContext, - setCurrentClient, - setExtra, - setExtras, - setHttpStatus, - setMeasurement, - setTag, - setTags, - setupConnectErrorHandler, - setupExpressErrorHandler, - setupHapiErrorHandler, - setupKoaErrorHandler, - setUser, - spanToBaggageHeader, - spanToJSON, - spanToTraceHeader, - spotlightIntegration, - startInactiveSpan, - startNewTrace, - suppressTracing, - startSession, - startSpan, - startSpanManual, - tediousIntegration, - trpcMiddleware, - updateSpanName, - withActiveSpan, - withIsolationScope, - withMonitor, - withScope, - zodErrorsIntegration, -} from '@sentry/node'; - -// Keeping the `*` exports for backwards compatibility and types -export * from '@sentry/node'; - +export * from './server'; export { - sentryHandleError, - wrapHandleErrorWithSentry, -} from './utils/instrumentServer'; - -export { captureRemixServerException } from './utils/errors'; - -export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; -export { withSentry } from './client/performance'; -export { captureRemixErrorBoundaryError } from './client/errors'; -export { browserTracingIntegration } from './client/browserTracingIntegration'; + captureRemixErrorBoundaryError, + withSentry, +} from './client'; export type { SentryMetaArgs } from './utils/types'; - -/** - * Returns the default Remix integrations. - * - * @param options The options for the SDK. - */ -export function getRemixDefaultIntegrations(options: RemixOptions): Integration[] { - return [ - ...getDefaultNodeIntegrations(options as NodeOptions).filter(integration => integration.name !== 'Http'), - httpIntegration(), - remixIntegration(), - ].filter(int => int) as Integration[]; -} - -/** Initializes Sentry Remix SDK on Node. */ -export function init(options: RemixOptions): NodeClient | undefined { - applySdkMetadata(options, 'remix', ['remix', 'node']); - - if (isInitialized()) { - DEBUG_BUILD && logger.log('SDK already initialized'); - - return; - } - - options.defaultIntegrations = getRemixDefaultIntegrations(options as NodeOptions); - - const client = nodeInit(options as NodeOptions); - - instrumentServer(); - - return client; -} diff --git a/packages/remix/src/index.types.ts b/packages/remix/src/index.types.ts index 5cfb7114bbbc..763a2747f69e 100644 --- a/packages/remix/src/index.types.ts +++ b/packages/remix/src/index.types.ts @@ -12,6 +12,7 @@ import type { RemixOptions } from './utils/remixOptions'; /** Initializes Sentry Remix SDK */ export declare function init(options: RemixOptions): Client | undefined; +export declare const browserTracingIntegration: typeof clientSdk.browserTracingIntegration; export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; diff --git a/packages/remix/src/utils/errors.ts b/packages/remix/src/server/errors.ts similarity index 94% rename from packages/remix/src/utils/errors.ts rename to packages/remix/src/server/errors.ts index 1921ee2a37f2..0a5e9f4918bc 100644 --- a/packages/remix/src/utils/errors.ts +++ b/packages/remix/src/server/errors.ts @@ -14,11 +14,11 @@ import { winterCGRequestToRequestData, } from '@sentry/core'; import type { RequestEventData, Span } from '@sentry/core'; -import { DEBUG_BUILD } from './debug-build'; -import type { RemixOptions } from './remixOptions'; -import { storeFormDataKeys } from './utils'; -import { extractData, isResponse, isRouteErrorResponse } from './vendor/response'; -import type { DataFunction, RemixRequest } from './vendor/types'; +import { DEBUG_BUILD } from '../utils/debug-build'; +import type { RemixOptions } from '../utils/remixOptions'; +import { storeFormDataKeys } from '../utils/utils'; +import { extractData, isResponse, isRouteErrorResponse } from '../utils/vendor/response'; +import type { DataFunction, RemixRequest } from '../utils/vendor/types'; /** * Captures an exception happened in the Remix server. diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts new file mode 100644 index 000000000000..44c616de3a30 --- /dev/null +++ b/packages/remix/src/server/index.ts @@ -0,0 +1,120 @@ +// We need to explicitly export @sentry/node as they end up under `default` in ESM builds +// See: https://github.com/getsentry/sentry-javascript/issues/8474 +export { + addBreadcrumb, + addEventProcessor, + addIntegration, + amqplibIntegration, + anrIntegration, + disableAnrDetectionForCallback, + captureCheckIn, + captureConsoleIntegration, + captureEvent, + captureException, + captureFeedback, + captureMessage, + captureSession, + close, + connectIntegration, + consoleIntegration, + contextLinesIntegration, + continueTrace, + createGetModuleFromFilename, + createTransport, + cron, + dedupeIntegration, + defaultStackParser, + endSession, + expressErrorHandler, + expressIntegration, + extraErrorDataIntegration, + fastifyIntegration, + flush, + functionToStringIntegration, + generateInstrumentOnce, + genericPoolIntegration, + getActiveSpan, + getAutoPerformanceIntegrations, + getClient, + getCurrentScope, + getDefaultIntegrations, + getGlobalScope, + getIsolationScope, + getRootSpan, + getSentryRelease, + getSpanDescendants, + getSpanStatusFromHttpCode, + graphqlIntegration, + hapiIntegration, + httpIntegration, + inboundFiltersIntegration, + initOpenTelemetry, + isInitialized, + knexIntegration, + kafkaIntegration, + koaIntegration, + lastEventId, + linkedErrorsIntegration, + localVariablesIntegration, + makeNodeTransport, + modulesIntegration, + mongoIntegration, + mongooseIntegration, + mysql2Integration, + mysqlIntegration, + nativeNodeFetchIntegration, + NodeClient, + nodeContextIntegration, + onUncaughtExceptionIntegration, + onUnhandledRejectionIntegration, + parameterize, + postgresIntegration, + prismaIntegration, + redisIntegration, + requestDataIntegration, + rewriteFramesIntegration, + Scope, + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setContext, + setCurrentClient, + setExtra, + setExtras, + setHttpStatus, + setMeasurement, + setTag, + setTags, + setupConnectErrorHandler, + setupExpressErrorHandler, + setupHapiErrorHandler, + setupKoaErrorHandler, + setUser, + spanToBaggageHeader, + spanToJSON, + spanToTraceHeader, + spotlightIntegration, + startInactiveSpan, + startNewTrace, + suppressTracing, + startSession, + startSpan, + startSpanManual, + tediousIntegration, + trpcMiddleware, + updateSpanName, + withActiveSpan, + withIsolationScope, + withMonitor, + withScope, + zodErrorsIntegration, +} from '@sentry/node'; + +// Keeping the `*` exports for backwards compatibility and types +export * from '@sentry/node'; + +export { init, getRemixDefaultIntegrations } from './sdk'; +export { captureRemixServerException } from './errors'; +export { sentryHandleError, wrapHandleErrorWithSentry, instrumentBuild } from './instrumentServer'; diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts similarity index 59% rename from packages/remix/src/utils/instrumentServer.ts rename to packages/remix/src/server/instrumentServer.ts index 25878becb82d..f4634805e1e1 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -1,19 +1,28 @@ -import type { RequestEventData, WrappedFunction } from '@sentry/core'; +/* eslint-disable max-lines */ +import type { RequestEventData, Span, TransactionSource, WrappedFunction } from '@sentry/core'; import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, continueTrace, fill, + getActiveSpan, getClient, + getRootSpan, getTraceData, hasSpansEnabled, isNodeEnv, loadModule, logger, + setHttpStatus, + spanToJSON, + startSpan, winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; -import { DEBUG_BUILD } from './debug-build'; -import { captureRemixServerException, errorHandleDataFunction, errorHandleDocumentRequestFunction } from './errors'; -import { extractData, isDeferredData, isResponse, isRouteErrorResponse, json } from './vendor/response'; +import { DEBUG_BUILD } from '../utils/debug-build'; +import { createRoutes, getTransactionName } from '../utils/utils'; +import { extractData, isDeferredData, isResponse, isRouteErrorResponse, json } from '../utils/vendor/response'; import type { AppData, AppLoadContext, @@ -25,8 +34,10 @@ import type { RemixRequest, RequestHandler, ServerBuild, + ServerRoute, ServerRouteManifest, -} from './vendor/types'; +} from '../utils/vendor/types'; +import { captureRemixServerException, errorHandleDataFunction, errorHandleDocumentRequestFunction } from './errors'; const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); function isRedirectResponse(response: Response): boolean { @@ -77,11 +88,16 @@ export function wrapHandleErrorWithSentry( }; } +function isCloudflareEnv(): boolean { + // eslint-disable-next-line no-restricted-globals + return navigator?.userAgent?.includes('Cloudflare'); +} + function getTraceAndBaggage(): { sentryTrace?: string; sentryBaggage?: string; } { - if (isNodeEnv()) { + if (isNodeEnv() || isCloudflareEnv()) { const traceData = getTraceData(); return { @@ -93,7 +109,7 @@ function getTraceAndBaggage(): { return {}; } -function makeWrappedDocumentRequestFunction() { +function makeWrappedDocumentRequestFunction(instrumentTracing?: boolean) { return function (origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction { return async function ( this: unknown, @@ -103,33 +119,81 @@ function makeWrappedDocumentRequestFunction() { context: EntryContext, loadContext?: Record, ): Promise { - return errorHandleDocumentRequestFunction.call(this, origDocumentRequestFunction, { + const documentRequestContext = { request, responseStatusCode, responseHeaders, context, loadContext, - }); + }; + + if (instrumentTracing) { + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan && getRootSpan(activeSpan); + + const name = rootSpan ? spanToJSON(rootSpan).description : undefined; + + return startSpan( + { + // If we don't have a root span, `onlyIfParent` will lead to the span not being created anyhow + // So we don't need to care too much about the fallback name, it's just for typing purposes.... + name: name || '', + onlyIfParent: true, + attributes: { + method: request.method, + url: request.url, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.remix', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.remix.document_request', + }, + }, + () => { + return errorHandleDocumentRequestFunction.call(this, origDocumentRequestFunction, documentRequestContext); + }, + ); + } else { + return errorHandleDocumentRequestFunction.call(this, origDocumentRequestFunction, documentRequestContext); + } }; }; } -function makeWrappedDataFunction(origFn: DataFunction, id: string, name: 'action' | 'loader'): DataFunction { +function makeWrappedDataFunction( + origFn: DataFunction, + id: string, + name: 'action' | 'loader', + instrumentTracing?: boolean, +): DataFunction { return async function (this: unknown, args: DataFunctionArgs): Promise { - return errorHandleDataFunction.call(this, origFn, name, args); + if (instrumentTracing) { + return startSpan( + { + op: `function.remix.${name}`, + name: id, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.remix', + name, + }, + }, + (span: Span) => { + return errorHandleDataFunction.call(this, origFn, name, args, span); + }, + ); + } else { + return errorHandleDataFunction.call(this, origFn, name, args); + } }; } const makeWrappedAction = - (id: string) => + (id: string, instrumentTracing?: boolean) => (origAction: DataFunction): DataFunction => { - return makeWrappedDataFunction(origAction, id, 'action'); + return makeWrappedDataFunction(origAction, id, 'action', instrumentTracing); }; const makeWrappedLoader = - (id: string) => + (id: string, instrumentTracing?: boolean) => (origLoader: DataFunction): DataFunction => { - return makeWrappedDataFunction(origLoader, id, 'loader'); + return makeWrappedDataFunction(origLoader, id, 'loader', instrumentTracing); }; function makeWrappedRootLoader() { @@ -176,7 +240,20 @@ function makeWrappedRootLoader() { }; } -function wrapRequestHandler(origRequestHandler: RequestHandler): RequestHandler { +function wrapRequestHandler( + origRequestHandler: RequestHandler, + build: + | ServerBuild + | { build: ServerBuild } + | (() => ServerBuild | { build: ServerBuild } | Promise), + options?: { + instrumentTracing?: boolean; + }, +): RequestHandler { + let resolvedBuild: ServerBuild | { build: ServerBuild }; + let name: string; + let source: TransactionSource; + return async function (this: unknown, request: RemixRequest, loadContext?: AppLoadContext): Promise { const upperCaseMethod = request.method.toUpperCase(); // We don't want to wrap OPTIONS and HEAD requests @@ -184,8 +261,25 @@ function wrapRequestHandler(origRequestHandler: RequestHandler): RequestHandler return origRequestHandler.call(this, request, loadContext); } + let resolvedRoutes: ServerRoute[] | undefined; + + if (options?.instrumentTracing) { + if (typeof build === 'function') { + resolvedBuild = await build(); + } else { + resolvedBuild = build; + } + + // check if the build is nested under `build` key + if ('build' in resolvedBuild) { + resolvedRoutes = createRoutes(resolvedBuild.build.routes); + } else { + resolvedRoutes = createRoutes(resolvedBuild.routes); + } + } + return withIsolationScope(async isolationScope => { - const options = getClient()?.getOptions(); + const clientOptions = getClient()?.getOptions(); let normalizedRequest: RequestEventData = {}; @@ -195,9 +289,16 @@ function wrapRequestHandler(origRequestHandler: RequestHandler): RequestHandler DEBUG_BUILD && logger.warn('Failed to normalize Remix request'); } + if (options?.instrumentTracing && resolvedRoutes) { + const url = new URL(request.url); + [name, source] = getTransactionName(resolvedRoutes, url); + + isolationScope.setTransactionName(name); + } + isolationScope.setSDKProcessingMetadata({ normalizedRequest }); - if (!options || !hasSpansEnabled(options)) { + if (!clientOptions || !hasSpansEnabled(clientOptions)) { return origRequestHandler.call(this, request, loadContext); } @@ -207,6 +308,33 @@ function wrapRequestHandler(origRequestHandler: RequestHandler): RequestHandler baggage: request.headers.get('baggage') || '', }, async () => { + if (options?.instrumentTracing) { + const parentSpan = getActiveSpan(); + const rootSpan = parentSpan && getRootSpan(parentSpan); + rootSpan?.updateName(name); + + return startSpan( + { + name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.remix', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + method: request.method, + }, + }, + async span => { + const res = (await origRequestHandler.call(this, request, loadContext)) as Response; + + if (isResponse(res)) { + setHttpStatus(span, res.status); + } + + return res; + }, + ); + } + return (await origRequestHandler.call(this, request, loadContext)) as Response; }, ); @@ -214,8 +342,14 @@ function wrapRequestHandler(origRequestHandler: RequestHandler): RequestHandler }; } -function instrumentBuildCallback(build: ServerBuild): ServerBuild { - const routes: ServerRouteManifest = {}; +function instrumentBuildCallback( + build: ServerBuild, + options?: { + instrumentTracing?: boolean; + }, +): ServerBuild { + const routes: ServerRouteManifest = build.routes; + const wrappedEntry = { ...build.entry, module: { ...build.entry.module } }; // Not keeping boolean flags like it's done for `requestHandler` functions, @@ -224,7 +358,7 @@ function instrumentBuildCallback(build: ServerBuild): ServerBuild { // We should be able to wrap them, as they may not be wrapped before. const defaultExport = wrappedEntry.module.default as undefined | WrappedFunction; if (defaultExport && !defaultExport.__sentry_original__) { - fill(wrappedEntry.module, 'default', makeWrappedDocumentRequestFunction()); + fill(wrappedEntry.module, 'default', makeWrappedDocumentRequestFunction(options?.instrumentTracing)); } for (const [id, route] of Object.entries(build.routes)) { @@ -232,12 +366,12 @@ function instrumentBuildCallback(build: ServerBuild): ServerBuild { const routeAction = wrappedRoute.module.action as undefined | WrappedFunction; if (routeAction && !routeAction.__sentry_original__) { - fill(wrappedRoute.module, 'action', makeWrappedAction(id)); + fill(wrappedRoute.module, 'action', makeWrappedAction(id, options?.instrumentTracing)); } const routeLoader = wrappedRoute.module.loader as undefined | WrappedFunction; if (routeLoader && !routeLoader.__sentry_original__) { - fill(wrappedRoute.module, 'loader', makeWrappedLoader(id)); + fill(wrappedRoute.module, 'loader', makeWrappedLoader(id, options?.instrumentTracing)); } // Entry module should have a loader function to provide `sentry-trace` and `baggage` @@ -254,7 +388,13 @@ function instrumentBuildCallback(build: ServerBuild): ServerBuild { routes[id] = wrappedRoute; } - return { ...build, routes, entry: wrappedEntry }; + const instrumentedBuild = { ...build, routes }; + + if (wrappedEntry) { + instrumentedBuild.entry = wrappedEntry; + } + + return instrumentedBuild; } /** @@ -262,6 +402,9 @@ function instrumentBuildCallback(build: ServerBuild): ServerBuild { */ export function instrumentBuild( build: ServerBuild | (() => ServerBuild | Promise), + options?: { + instrumentTracing?: boolean; + }, ): ServerBuild | (() => ServerBuild | Promise) { if (typeof build === 'function') { return function () { @@ -269,28 +412,28 @@ export function instrumentBuild( if (resolvedBuild instanceof Promise) { return resolvedBuild.then(build => { - return instrumentBuildCallback(build); + return instrumentBuildCallback(build, options); }); } else { - return instrumentBuildCallback(resolvedBuild); + return instrumentBuildCallback(resolvedBuild, options); } }; } else { - return instrumentBuildCallback(build); + return instrumentBuildCallback(build, options); } } -const makeWrappedCreateRequestHandler = () => +export const makeWrappedCreateRequestHandler = (options?: { instrumentTracing?: boolean }) => function (origCreateRequestHandler: CreateRequestHandlerFunction): CreateRequestHandlerFunction { return function ( this: unknown, build: ServerBuild | (() => Promise), ...args: unknown[] ): RequestHandler { - const newBuild = instrumentBuild(build); + const newBuild = instrumentBuild(build, options); const requestHandler = origCreateRequestHandler.call(this, newBuild, ...args); - return wrapRequestHandler(requestHandler); + return wrapRequestHandler(requestHandler, newBuild, options); }; }; @@ -298,7 +441,7 @@ const makeWrappedCreateRequestHandler = () => * Monkey-patch Remix's `createRequestHandler` from `@remix-run/server-runtime` * which Remix Adapters (https://remix.run/docs/en/v1/api/remix) use underneath. */ -export function instrumentServer(): void { +export function instrumentServer(options?: { instrumentTracing?: boolean }): void { const pkg = loadModule<{ createRequestHandler: CreateRequestHandlerFunction; }>('@remix-run/server-runtime', module); @@ -309,5 +452,5 @@ export function instrumentServer(): void { return; } - fill(pkg, 'createRequestHandler', makeWrappedCreateRequestHandler()); + fill(pkg, 'createRequestHandler', makeWrappedCreateRequestHandler(options)); } diff --git a/packages/remix/src/utils/integrations/http.ts b/packages/remix/src/server/integrations/http.ts similarity index 100% rename from packages/remix/src/utils/integrations/http.ts rename to packages/remix/src/server/integrations/http.ts diff --git a/packages/remix/src/utils/integrations/opentelemetry.ts b/packages/remix/src/server/integrations/opentelemetry.ts similarity index 97% rename from packages/remix/src/utils/integrations/opentelemetry.ts rename to packages/remix/src/server/integrations/opentelemetry.ts index e14d5671bb4d..7ba99421c82f 100644 --- a/packages/remix/src/utils/integrations/opentelemetry.ts +++ b/packages/remix/src/server/integrations/opentelemetry.ts @@ -3,7 +3,7 @@ import { RemixInstrumentation } from 'opentelemetry-instrumentation-remix'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, defineIntegration } from '@sentry/core'; import type { Client, IntegrationFn, Span } from '@sentry/core'; import { generateInstrumentOnce, getClient, spanToJSON } from '@sentry/node'; -import type { RemixOptions } from '../remixOptions'; +import type { RemixOptions } from '../../utils/remixOptions'; const INTEGRATION_NAME = 'Remix'; diff --git a/packages/remix/src/server/sdk.ts b/packages/remix/src/server/sdk.ts new file mode 100644 index 000000000000..65a19bebdd28 --- /dev/null +++ b/packages/remix/src/server/sdk.ts @@ -0,0 +1,42 @@ +import { applySdkMetadata, logger } from '@sentry/core'; +import type { Integration } from '@sentry/core'; +import type { NodeClient, NodeOptions } from '@sentry/node'; +import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit, isInitialized } from '@sentry/node'; + +import { DEBUG_BUILD } from '../utils/debug-build'; +import type { RemixOptions } from '../utils/remixOptions'; +import { instrumentServer } from './instrumentServer'; +import { httpIntegration } from './integrations/http'; +import { remixIntegration } from './integrations/opentelemetry'; + +/** + * Returns the default Remix integrations. + * + * @param options The options for the SDK. + */ +export function getRemixDefaultIntegrations(options: RemixOptions): Integration[] { + return [ + ...getDefaultNodeIntegrations(options as NodeOptions).filter(integration => integration.name !== 'Http'), + httpIntegration(), + remixIntegration(), + ].filter(int => int) as Integration[]; +} + +/** Initializes Sentry Remix SDK on Node. */ +export function init(options: RemixOptions): NodeClient | undefined { + applySdkMetadata(options, 'remix', ['remix', 'node']); + + if (isInitialized()) { + DEBUG_BUILD && logger.log('SDK already initialized'); + + return; + } + + options.defaultIntegrations = getRemixDefaultIntegrations(options as NodeOptions); + + const client = nodeInit(options as NodeOptions); + + instrumentServer(); + + return client; +} diff --git a/packages/remix/src/utils/vendor/types.ts b/packages/remix/src/utils/vendor/types.ts index 19e30b1a78e1..ea80085d0780 100644 --- a/packages/remix/src/utils/vendor/types.ts +++ b/packages/remix/src/utils/vendor/types.ts @@ -1,6 +1,5 @@ import type { Agent } from 'https'; /* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable max-lines */ /* eslint-disable @typescript-eslint/ban-types */ // Types vendored from @remix-run/server-runtime@1.6.0: // https://github.com/remix-run/remix/blob/f3691d51027b93caa3fd2cdfe146d7b62a6eb8f2/packages/remix-server-runtime/server.ts