>) => {
+ return [
+ {
+ env: data.ENV,
+ },
+ {
+ name: 'sentry-trace',
+ content: data.sentryTrace,
+ },
+ {
+ name: 'baggage',
+ content: data.sentryBaggage,
+ },
+ ];
+};
+
+function App() {
+ const nonce = useNonce();
+ const {ENV} = useLoaderData();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default App;
+// export default withSentry(App);
+
+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 = 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 && (
+
+ )}
+ {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..8c787ebd7c2f
--- /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';
+
+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..f4d6ec9c4f0a
--- /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';
+
+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/instrument.server.mjs b/dev-packages/e2e-tests/test-applications/remix-hydrogen/instrument.server.mjs
new file mode 100644
index 000000000000..92117269dfad
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/instrument.server.mjs
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/remix';
+
+Sentry.init({
+ // TODO: replace with your Sentry DSN
+ dsn: process.env.E2E_TEST_DSN,
+
+ // Set tracesSampleRate to 1.0 to capture 100%
+ // of transactions for performance monitoring.
+ // We recommend adjusting this value in production
+ tracesSampleRate: 1.0,
+ autoInstrumentRemix: true,
+});
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..c4f717e10925
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json
@@ -0,0 +1,59 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "type": "module",
+ "scripts": {
+ "build": "shopify hydrogen build",
+ "dev": "shopify hydrogen dev",
+ "start": "shopify hydrogen preview --build --verbose",
+ "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.9.2",
+ "@remix-run/server-runtime": "^2.9.2",
+ "@sentry/remix": "latest || *",
+ "@sentry/cloudflare": "latest || *",
+ "@shopify/cli": "3.60.0",
+ "@shopify/cli-hydrogen": "^8.1.1",
+ "@shopify/hydrogen": "2024.4.7",
+ "@shopify/remix-oxygen": "^2.0.4",
+ "graphql": "^16.6.0",
+ "graphql-tag": "^2.12.6",
+ "isbot": "^3.8.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.44.1",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@sentry/types": "latest || *",
+ "@sentry/utils": "latest || *",
+ "@graphql-codegen/cli": "5.0.2",
+ "@remix-run/dev": "2.9.2",
+ "@remix-run/eslint-config": "2.9.2",
+ "@shopify/hydrogen-codegen": "^0.3.1",
+ "@shopify/mini-oxygen": "^3.0.3",
+ "@shopify/oxygen-workers-types": "^4.0.0",
+ "@shopify/prettier-config": "^1.1.2",
+ "@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..345f7af9aa83
--- /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 start`,
+ port: 3000,
+});
+
+export default config;
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..73b913ca7301
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/server.ts
@@ -0,0 +1,109 @@
+// import './instrument.server.mjs';
+
+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';
+// @ts-ignore
+// 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 {
+ 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: 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/storefrontapi.generated.d.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/storefrontapi.generated.d.ts
new file mode 100644
index 000000000000..17d99fb5bfe5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/storefrontapi.generated.d.ts
@@ -0,0 +1,153 @@
+/* eslint-disable eslint-comments/disable-enable-pair */
+/* eslint-disable eslint-comments/no-unlimited-disable */
+/* eslint-disable */
+import type * as StorefrontAPI from '@shopify/hydrogen/storefront-api-types';
+
+export type MoneyFragment = Pick;
+
+export type CartLineFragment = Pick & {
+ attributes: Array>;
+ cost: {
+ totalAmount: Pick;
+ amountPerQuantity: Pick;
+ compareAtAmountPerQuantity?: StorefrontAPI.Maybe>;
+ };
+ merchandise: Pick & {
+ compareAtPrice?: StorefrontAPI.Maybe>;
+ price: Pick;
+ image?: StorefrontAPI.Maybe>;
+ product: Pick;
+ selectedOptions: Array>;
+ };
+};
+
+export type CartApiQueryFragment = Pick<
+ StorefrontAPI.Cart,
+ 'updatedAt' | 'id' | 'checkoutUrl' | 'totalQuantity' | 'note'
+> & {
+ buyerIdentity: Pick & {
+ customer?: StorefrontAPI.Maybe<
+ Pick
+ >;
+ };
+ lines: {
+ nodes: Array<
+ Pick & {
+ attributes: Array>;
+ cost: {
+ totalAmount: Pick;
+ amountPerQuantity: Pick;
+ compareAtAmountPerQuantity?: StorefrontAPI.Maybe>;
+ };
+ merchandise: Pick & {
+ compareAtPrice?: StorefrontAPI.Maybe>;
+ price: Pick;
+ image?: StorefrontAPI.Maybe>;
+ product: Pick;
+ selectedOptions: Array>;
+ };
+ }
+ >;
+ };
+ cost: {
+ subtotalAmount: Pick;
+ totalAmount: Pick;
+ totalDutyAmount?: StorefrontAPI.Maybe>;
+ totalTaxAmount?: StorefrontAPI.Maybe>;
+ };
+ attributes: Array>;
+ discountCodes: Array>;
+};
+
+export type MenuItemFragment = Pick;
+
+export type ChildMenuItemFragment = Pick<
+ StorefrontAPI.MenuItem,
+ 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url'
+>;
+
+export type ParentMenuItemFragment = Pick<
+ StorefrontAPI.MenuItem,
+ 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url'
+> & {
+ items: Array>;
+};
+
+export type MenuFragment = Pick & {
+ items: Array<
+ Pick & {
+ items: Array>;
+ }
+ >;
+};
+
+export type ShopFragment = Pick & {
+ primaryDomain: Pick;
+ brand?: StorefrontAPI.Maybe<{
+ logo?: StorefrontAPI.Maybe<{
+ image?: StorefrontAPI.Maybe>;
+ }>;
+ }>;
+};
+
+export type HeaderQueryVariables = StorefrontAPI.Exact<{
+ country?: StorefrontAPI.InputMaybe;
+ headerMenuHandle: StorefrontAPI.Scalars['String']['input'];
+ language?: StorefrontAPI.InputMaybe;
+}>;
+
+export type HeaderQuery = {
+ shop: Pick & {
+ primaryDomain: Pick;
+ brand?: StorefrontAPI.Maybe<{
+ logo?: StorefrontAPI.Maybe<{
+ image?: StorefrontAPI.Maybe>;
+ }>;
+ }>;
+ };
+ menu?: StorefrontAPI.Maybe<
+ Pick & {
+ items: Array<
+ Pick & {
+ items: Array>;
+ }
+ >;
+ }
+ >;
+};
+
+export type FooterQueryVariables = StorefrontAPI.Exact<{
+ country?: StorefrontAPI.InputMaybe;
+ footerMenuHandle: StorefrontAPI.Scalars['String']['input'];
+ language?: StorefrontAPI.InputMaybe;
+}>;
+
+export type FooterQuery = {
+ menu?: StorefrontAPI.Maybe<
+ Pick & {
+ items: Array<
+ Pick & {
+ items: Array>;
+ }
+ >;
+ }
+ >;
+};
+
+interface GeneratedQueryTypes {
+ '#graphql\n fragment Shop on Shop {\n id\n name\n description\n primaryDomain {\n url\n }\n brand {\n logo {\n image {\n url\n }\n }\n }\n }\n query Header(\n $country: CountryCode\n $headerMenuHandle: String!\n $language: LanguageCode\n ) @inContext(language: $language, country: $country) {\n shop {\n ...Shop\n }\n menu(handle: $headerMenuHandle) {\n ...Menu\n }\n }\n #graphql\n fragment MenuItem on MenuItem {\n id\n resourceId\n tags\n title\n type\n url\n }\n fragment ChildMenuItem on MenuItem {\n ...MenuItem\n }\n fragment ParentMenuItem on MenuItem {\n ...MenuItem\n items {\n ...ChildMenuItem\n }\n }\n fragment Menu on Menu {\n id\n items {\n ...ParentMenuItem\n }\n }\n\n': {
+ return: HeaderQuery;
+ variables: HeaderQueryVariables;
+ };
+ '#graphql\n query Footer(\n $country: CountryCode\n $footerMenuHandle: String!\n $language: LanguageCode\n ) @inContext(language: $language, country: $country) {\n menu(handle: $footerMenuHandle) {\n ...Menu\n }\n }\n #graphql\n fragment MenuItem on MenuItem {\n id\n resourceId\n tags\n title\n type\n url\n }\n fragment ChildMenuItem on MenuItem {\n ...MenuItem\n }\n fragment ParentMenuItem on MenuItem {\n ...MenuItem\n items {\n ...ChildMenuItem\n }\n }\n fragment Menu on Menu {\n id\n items {\n ...ParentMenuItem\n }\n }\n\n': {
+ return: FooterQuery;
+ variables: FooterQueryVariables;
+ };
+}
+
+interface GeneratedMutationTypes {}
+
+declare module '@shopify/hydrogen' {
+ interface StorefrontQueries extends GeneratedQueryTypes {}
+ interface StorefrontMutations extends GeneratedMutationTypes {}
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-client.test.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-client.test.ts
new file mode 100644
index 000000000000..aecc2fa8c983
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-client.test.ts
@@ -0,0 +1,192 @@
+import { expect, test } from '@playwright/test';
+
+const EVENT_POLLING_TIMEOUT = 90_000;
+
+const authToken = process.env.E2E_TEST_AUTH_TOKEN;
+const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG;
+const sentryTestProject = process.env.E2E_TEST_SENTRY_PROJECT;
+
+test('Sends a client-side exception to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId);
+ const exceptionEventId = await exceptionIdHandle.jsonValue();
+
+ console.log(`Polling for error eventId: ${exceptionEventId}`);
+
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+});
+
+test('Sends a pageload transaction to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ const recordedTransactionsHandle = await page.waitForFunction(() => {
+ if (window.recordedTransactions && window.recordedTransactions?.length >= 1) {
+ return window.recordedTransactions;
+ } else {
+ return undefined;
+ }
+ });
+ const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue();
+
+ if (recordedTransactionEventIds === undefined) {
+ throw new Error("Application didn't record any transaction event IDs.");
+ }
+
+ let hadPageLoadTransaction = false;
+
+ console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`);
+
+ await Promise.all(
+ recordedTransactionEventIds.map(async transactionEventId => {
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.contexts.trace.op === 'pageload') {
+ hadPageLoadTransaction = true;
+ }
+ }
+
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+ }),
+ );
+
+ expect(hadPageLoadTransaction).toBe(true);
+});
+
+test('Sends a navigation transaction to Sentry', async ({ page }) => {
+ await page.goto('/');
+
+ // Give pageload transaction time to finish
+ await page.waitForTimeout(4000);
+
+ const linkElement = page.locator('id=navigation');
+ await linkElement.click();
+
+ const recordedTransactionsHandle = await page.waitForFunction(() => {
+ if (window.recordedTransactions && window.recordedTransactions?.length >= 2) {
+ return window.recordedTransactions;
+ } else {
+ return undefined;
+ }
+ });
+ const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue();
+
+ if (recordedTransactionEventIds === undefined) {
+ throw new Error("Application didn't record any transaction event IDs.");
+ }
+
+ let hadPageNavigationTransaction = false;
+
+ console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`);
+
+ await Promise.all(
+ recordedTransactionEventIds.map(async transactionEventId => {
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.contexts.trace.op === 'navigation') {
+ hadPageNavigationTransaction = true;
+ }
+ }
+
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+ }),
+ );
+
+ expect(hadPageNavigationTransaction).toBe(true);
+});
+
+test('Sends a client-side ErrorBoundary exception to Sentry', async ({ page }) => {
+ await page.goto('/client-error');
+
+ const exceptionIdHandle = await page.waitForSelector('#event-id');
+ const exceptionEventId = await exceptionIdHandle.textContent();
+
+ console.log(`Polling for error eventId: ${exceptionEventId}`);
+
+ await expect
+ .poll(
+ async () => {
+ const response = await fetch(
+ `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`,
+ { headers: { Authorization: `Bearer ${authToken}` } },
+ );
+ return response.status;
+ },
+ {
+ timeout: EVENT_POLLING_TIMEOUT,
+ },
+ )
+ .toBe(200);
+});
+
+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/behaviour-server.test.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-server.test.ts
new file mode 100644
index 000000000000..292d827c783e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-server.test.ts
@@ -0,0 +1,158 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
+import { uuid4 } from '@sentry/utils';
+
+test('Sends a loader error to Sentry', async ({ page }) => {
+ const loaderErrorPromise = waitForError('create-remix-app-express', errorEvent => {
+ return errorEvent.exception.values[0].value === 'Loader Error';
+ });
+
+ await page.goto('/loader-error');
+
+ const loaderError = await loaderErrorPromise;
+
+ expect(loaderError).toBeDefined();
+});
+
+test('Sends form data with action error to Sentry', async ({ page }) => {
+ await page.goto('/action-formdata');
+
+ await page.fill('input[name=text]', 'test');
+ await page.setInputFiles('input[type=file]', {
+ name: 'file.txt',
+ mimeType: 'text/plain',
+ buffer: Buffer.from('this is test'),
+ });
+
+ await page.locator('button[type=submit]').click();
+
+ const formdataActionTransaction = waitForTransaction('create-remix-app-express', transactionEvent => {
+ return transactionEvent?.spans?.some(span => span.data && span.data['code.function'] === 'action');
+ });
+
+ const actionSpan = (await formdataActionTransaction).spans.find(
+ span => span.data && span.data['code.function'] === 'action',
+ );
+
+ expect(actionSpan).toBeDefined();
+ expect(actionSpan.op).toBe('action.remix');
+ expect(actionSpan.data).toMatchObject({
+ 'formData.text': 'test',
+ 'formData.file': 'file.txt',
+ });
+});
+
+test('Sends a loader span to Sentry', async ({ page }) => {
+ const loaderTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => {
+ return transactionEvent?.spans?.some(span => span.data && span.data['code.function'] === 'loader');
+ });
+
+ await page.goto('/');
+
+ const loaderSpan = (await loaderTransactionPromise).spans.find(
+ span => span.data && span.data['code.function'] === 'loader',
+ );
+
+ expect(loaderSpan).toBeDefined();
+ expect(loaderSpan.op).toBe('loader.remix');
+});
+
+test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => {
+ // We use this to identify the transactions
+ const testTag = uuid4();
+
+ const httpServerTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'http.server' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ const pageLoadTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'pageload' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ page.goto(`/client-error?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.data && span.data['code.function'] === 'loader',
+ )?.span_id;
+
+ const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
+ const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
+ const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
+
+ expect(httpServerTransaction.transaction).toBe('GET client-error');
+ expect(pageloadTransaction.transaction).toBe('routes/client-error');
+
+ expect(httpServerTraceId).toBeDefined();
+ expect(httpServerSpanId).toBeDefined();
+
+ expect(pageLoadTraceId).toEqual(httpServerTraceId);
+ expect(pageLoadParentSpanId).toEqual(loaderSpanId);
+ expect(pageLoadSpanId).not.toEqual(httpServerSpanId);
+});
+
+test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => {
+ // We use this to identify the transactions
+ const testTag = uuid4();
+
+ const httpServerTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ transactionEvent.contexts?.trace?.op === 'http.server' &&
+ transactionEvent.tags?.['sentry_test'] === testTag
+ );
+ });
+
+ const pageLoadTransactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => {
+ return (
+ transactionEvent.type === 'transaction' &&
+ 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 loaderSpan = httpServerTransaction?.spans?.find(span => span.data && span.data['code.function'] === 'loader');
+ const loaderSpanId = loaderSpan?.span_id;
+ const loaderParentSpanId = loaderSpan?.parent_span_id;
+
+ const pageLoadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
+ const pageLoadSpanId = pageloadTransaction.contexts?.trace?.span_id;
+ const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id;
+
+ expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/');
+ expect(pageloadTransaction.transaction).toBe('routes/_index');
+
+ expect(httpServerTraceId).toBeDefined();
+ expect(httpServerSpanId).toBeDefined();
+
+ expect(loaderParentSpanId).toEqual(httpServerSpanId);
+ expect(pageLoadTraceId).toEqual(httpServerTraceId);
+ expect(pageLoadParentSpanId).toEqual(loaderSpanId);
+ 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/remix/src/index.client.tsx b/packages/remix/src/index.client.tsx
index 615287bed17b..100b9b5a3c1b 100644
--- a/packages/remix/src/index.client.tsx
+++ b/packages/remix/src/index.client.tsx
@@ -27,6 +27,7 @@ export async function captureRemixServerException(
}
/* eslint-enable @typescript-eslint/no-unused-vars */
+export { setTags, captureException } from '@sentry/react';
export * from '@sentry/react';
export function init(options: RemixOptions): Client | undefined {