-
-
Save johtso/b9e69df9a2794f73886317639f87f475 to your computer and use it in GitHub Desktop.
Remix, Sentry & Cloudflare Workers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as build from "@remix-run/dev/server-build"; | |
import { createRoutes } from "@remix-run/server-runtime/dist/routes"; | |
import { Dedupe, ExtraErrorData, Transaction } from "@sentry/integrations"; | |
import { hasTracingEnabled } from "@sentry/tracing"; | |
import { Toucan } from "toucan-js"; | |
import createEventHandler from "./createEventHandler"; | |
import instrumentBuild, { getTransactionName, startRequestHandlerTransaction } from "./instrumentBuild"; | |
interface Environment { | |
__STATIC_CONTENT: KVNamespace<string>; | |
SENTRY_ENABLED: boolean; | |
SENTRY_DSN: string; | |
SENTRY_ENVIRONMENT: string; | |
SENTRY_VERSION: string | undefined; | |
} | |
const index = { | |
fetch: async (request: Request, environment: Environment, context: ExecutionContext) => { | |
const sentry = new Toucan({ | |
dsn: environment.SENTRY_ENABLED ? environment.SENTRY_DSN : "", | |
environment: environment.SENTRY_ENVIRONMENT, | |
release: environment.SENTRY_VERSION, | |
integrations: [new Dedupe(), new ExtraErrorData(), new Transaction()], | |
requestDataOptions: { | |
allowedIps: true, | |
allowedSearchParams: true, | |
}, | |
tracesSampleRate: environment.SENTRY_ENVIRONMENT === "development" ? 1 : 0.01, | |
request, | |
context, | |
}); | |
try { | |
// Wrap each Remix loader and action in a Sentry span | |
const instrumentedBuild = instrumentBuild(sentry, build); | |
const eventHandler = createEventHandler<Environment>({ | |
build: instrumentedBuild, | |
mode: process.env.NODE_ENV, | |
getLoadContext: (_, contextEnvironment) => ({ | |
SENTRY_VERSION: contextEnvironment.SENTRY_VERSION, | |
SENTRY_ENABLED: contextEnvironment.SENTRY_ENABLED, | |
SENTRY_DSN: contextEnvironment.SENTRY_DSN, | |
SENTRY_ENVIRONMENT: contextEnvironment.SENTRY_ENVIRONMENT, | |
}), | |
}); | |
// Generate the root transaction for this request | |
// Adapted from [the Remix/Express adapter](https://github.com/getsentry/sentry-javascript/blob/7f4c4ec10b97be945dab0dca1d47adb9a9954af3/packages/remix/src/utils/serverAdapters/express.ts) | |
const routes = createRoutes(instrumentedBuild.routes); | |
const options = sentry.getClient()?.getOptions(); | |
const scope = sentry.getScope(); | |
const url = new URL(request.url); | |
const [name, source] = getTransactionName(routes, url); | |
if (scope) { | |
// Even if tracing is disabled, we still want to set the route name | |
scope.setSDKProcessingMetadata({ | |
request, | |
route: { | |
path: name, | |
}, | |
}); | |
} | |
if (!options || !hasTracingEnabled(options)) { | |
return eventHandler(request, environment, context); | |
} | |
// Start the transaction, linking to an ongoing trace if necessary | |
// This is where we'll create the transaction for the first request in a chain, but if we make multiple requests as part of a load (for example, when updating the graph), we'll attach the right headers on the frontend and send them back here. | |
const transaction = startRequestHandlerTransaction(sentry, name, source, { | |
headers: { | |
"sentry-trace": request.headers.get("sentry-trace") || "", | |
baggage: request.headers.get("baggage") || "", | |
}, | |
method: request.method, | |
}); | |
const response = await eventHandler(request, environment, context); | |
transaction.setHttpStatus(response.status); | |
transaction.finish(); | |
return response; | |
} catch (error: unknown) { | |
sentry.captureException(error); | |
return new Response("Internal Server Error", { status: 500 }); | |
} | |
}, | |
}; | |
export default index; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @file Instrument the Remix build with Sentry. Adapted from [`instrumentServer`](https://github.com/getsentry/sentry-javascript/blob/b290fcae0466ecd8026c40b14d87473c130e9207/packages/remix/src/utils/instrumentServer.ts). | |
*/ | |
import { type EntryContext, type HandleDocumentRequestFunction, type ServerBuild } from "@remix-run/cloudflare"; | |
import { type AppData, isCatchResponse } from "@remix-run/react/dist/data"; | |
import { isRedirectResponse, isResponse, json } from "@remix-run/server-runtime/dist/responses"; | |
import { type RouteMatch } from "@remix-run/server-runtime/dist/routeMatching"; | |
import { matchServerRoutes } from "@remix-run/server-runtime/dist/routeMatching"; | |
import { type ActionFunction, type LoaderArgs, type LoaderFunction } from "@remix-run/server-runtime/dist/routeModules"; | |
import { type ServerRoute } from "@remix-run/server-runtime/dist/routes"; | |
import { getActiveTransaction, hasTracingEnabled } from "@sentry/tracing"; | |
import { type Transaction, type TransactionSource, type WrappedFunction } from "@sentry/types"; | |
import { | |
addExceptionMechanism, | |
baggageHeaderToDynamicSamplingContext, | |
dynamicSamplingContextToSentryBaggageHeader, | |
extractTraceparentData, | |
fill, | |
} from "@sentry/utils"; | |
import { TRPCClientError } from "@trpc/client"; | |
import { type Toucan } from "toucan-js"; | |
// Based on Remix Implementation | |
// https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/data.ts#L131-L145 | |
const extractData = async (response: Response): Promise<unknown> => { | |
const contentType = response.headers.get("Content-Type"); | |
// Cloning the response to avoid consuming the original body stream | |
const responseClone = response.clone(); | |
if (contentType && /\bapplication\/json\b/u.test(contentType)) { | |
return responseClone.json(); | |
} | |
return responseClone.text(); | |
}; | |
const extractResponseError = async (response: Response): Promise<unknown> => { | |
const contentType = response.headers.get("Content-Type"); | |
if (contentType && /\bapplication\/json\b/u.test(contentType)) { | |
const data = response.json(); | |
if ("statusText" in data) { | |
return data.statusText; | |
} | |
return data; | |
} | |
return response.text(); | |
}; | |
const captureRemixServerException = async ( | |
sentry: Toucan, | |
error: unknown, | |
name: string, | |
request: Request | |
): Promise<void> => { | |
// Skip capturing if the thrown error is not a 5xx response | |
// https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders | |
if (isResponse(error) && error.status < 500) { | |
return; | |
} | |
// Also skip capturing if the thrown error is a `TRPCClientError`, since we'll catch these on the server where we can get stack traces and causes. | |
// Consider re-enabling this once we've implemented Sentry tracing (#224) | |
if (error instanceof TRPCClientError) { | |
return; | |
} | |
// Log it to the console | |
// eslint-disable-next-line no-console -- We want to be able to inspect these errors. | |
console.error(error); | |
const exception = isResponse(error) ? await extractResponseError(error) : error; | |
sentry.withScope((scope) => { | |
const activeTransactionName = getActiveTransaction(sentry)?.name; | |
scope.setSDKProcessingMetadata({ | |
request: { | |
...request, | |
// When `route` is not defined, `RequestData` integration uses the full URL | |
route: activeTransactionName | |
? { | |
path: activeTransactionName, | |
} | |
: undefined, | |
}, | |
}); | |
scope.addEventProcessor((event) => { | |
addExceptionMechanism(event, { | |
type: "remix", | |
handled: true, | |
data: { | |
function: name, | |
}, | |
}); | |
return event; | |
}); | |
sentry.captureException(exception); | |
}); | |
}; | |
const makeWrappedDocumentRequestFunction = | |
(sentry: Toucan) => | |
(origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction => { | |
return async function ( | |
this: unknown, | |
request: Request, | |
responseStatusCode: number, | |
responseHeaders: Headers, | |
context: EntryContext | |
): Promise<Response> { | |
let response: Response; | |
const activeTransaction = getActiveTransaction(sentry); | |
const currentScope = sentry.getScope(); | |
if (!currentScope) { | |
// eslint-disable-next-line @babel/no-invalid-this -- We need to be able to pass `this` to the original function to wrap it correctly. | |
return origDocumentRequestFunction.call(this, request, responseStatusCode, responseHeaders, context); | |
} | |
try { | |
const span = activeTransaction?.startChild({ | |
op: "function.remix.document_request", | |
description: activeTransaction.name, | |
tags: { | |
method: request.method, | |
url: request.url, | |
}, | |
}); | |
// eslint-disable-next-line @babel/no-invalid-this -- We need to be able to pass `this` to the original function to wrap it correctly. | |
response = await origDocumentRequestFunction.call(this, request, responseStatusCode, responseHeaders, context); | |
span?.finish(); | |
} catch (error) { | |
await captureRemixServerException(sentry, error, "documentRequest", request); | |
throw error; | |
} | |
return response; | |
}; | |
}; | |
interface MakeWrappedDataFunction { | |
(sentry: Toucan, id: string, dataFunctionType: "action", originalFunction: ActionFunction): ActionFunction; | |
(sentry: Toucan, id: string, dataFunctionType: "loader", originalFunction: LoaderFunction): LoaderFunction; | |
} | |
const makeWrappedDataFunction: MakeWrappedDataFunction = ( | |
sentry: Toucan, | |
id: string, | |
dataFunctionType, | |
originalFunction | |
) => { | |
return async function (this: unknown, args: Parameters<typeof originalFunction>[0]): Promise<Response | AppData> { | |
let response: unknown; | |
const activeTransaction = getActiveTransaction(sentry); | |
const currentScope = sentry.getScope(); | |
if (!currentScope) { | |
// eslint-disable-next-line @babel/no-invalid-this -- We need to be able to pass `this` to the original function to wrap it correctly. | |
return originalFunction.call(this, args); | |
} | |
try { | |
const span = activeTransaction?.startChild({ | |
op: `function.remix.${dataFunctionType}`, | |
description: id, | |
tags: { | |
name: dataFunctionType, | |
}, | |
}); | |
if (span) { | |
// Assign data function to hub to be able to see `db` transactions (if any) as children. | |
currentScope.setSpan(span); | |
} | |
// eslint-disable-next-line @babel/no-invalid-this -- We need to be able to pass `this` to the original function to wrap it correctly. | |
response = (await originalFunction.call(this, args)) as unknown; | |
currentScope.setSpan(activeTransaction); | |
span?.finish(); | |
} catch (error) { | |
await captureRemixServerException(sentry, error, dataFunctionType, args.request); | |
throw error; | |
} | |
return response; | |
}; | |
}; | |
const makeWrappedAction = | |
(sentry: Toucan, id: string) => | |
(origAction: ActionFunction): ActionFunction => { | |
return makeWrappedDataFunction(sentry, id, "action", origAction); | |
}; | |
const makeWrappedLoader = | |
(sentry: Toucan, id: string) => | |
(origLoader: LoaderFunction): LoaderFunction => { | |
return makeWrappedDataFunction(sentry, id, "loader", origLoader); | |
}; | |
const getTraceAndBaggage = (sentry: Toucan): { sentryTrace?: string; sentryBaggage?: string } => { | |
const transaction = getActiveTransaction(sentry); | |
const currentScope = sentry.getScope(); | |
if (hasTracingEnabled(sentry.getClient()?.getOptions()) && currentScope) { | |
const span = currentScope.getSpan(); | |
if (span && transaction) { | |
const dynamicSamplingContext = transaction.getDynamicSamplingContext(); | |
return { | |
sentryTrace: span.toTraceparent(), | |
sentryBaggage: dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext), | |
}; | |
} | |
} | |
return {}; | |
}; | |
const makeWrappedRootLoader = | |
(sentry: Toucan) => | |
(origLoader: LoaderFunction): LoaderFunction => { | |
return async function (this: unknown, args: LoaderArgs): Promise<Response | AppData> { | |
// eslint-disable-next-line @babel/no-invalid-this -- We need to be able to pass `this` to the original function to wrap it correctly. | |
const response: object = (await origLoader.call(this, args)) as unknown as object; | |
const traceAndBaggage = getTraceAndBaggage(sentry); | |
// Note: `redirect` and `catch` responses do not have bodies to extract | |
if (isResponse(response) && !isRedirectResponse(response) && !isCatchResponse(response)) { | |
const data = await extractData(response); | |
if (typeof data === "object") { | |
return json( | |
{ ...data, ...traceAndBaggage }, | |
{ headers: response.headers, statusText: response.statusText, status: response.status } | |
); | |
} else { | |
return response; | |
} | |
} | |
return { ...response, ...traceAndBaggage }; | |
}; | |
}; | |
/** | |
* Instruments `remix` ServerBuild for performance tracing and error tracking. | |
*/ | |
const instrumentBuild = (sentry: Toucan, build: ServerBuild): ServerBuild => { | |
const routes: ServerBuild["routes"] = {}; | |
const wrappedEntry = { ...build.entry, module: { ...build.entry.module } }; | |
// Not keeping boolean flags like it's done for `requestHandler` functions, | |
// Because the build can change between build and runtime. | |
// So if there is a new `loader` or`action` or `documentRequest` after build. | |
// We should be able to wrap them, as they may not be wrapped before. | |
if (!(wrappedEntry.module.default as WrappedFunction).__sentry_original__) { | |
fill(wrappedEntry.module, "default", makeWrappedDocumentRequestFunction(sentry)); | |
} | |
for (const [id, route] of Object.entries(build.routes)) { | |
const wrappedRoute = { ...route, module: { ...route.module } }; | |
if (wrappedRoute.module.action && !(wrappedRoute.module.action as WrappedFunction).__sentry_original__) { | |
fill(wrappedRoute.module, "action", makeWrappedAction(sentry, id)); | |
} | |
if (wrappedRoute.module.loader && !(wrappedRoute.module.loader as WrappedFunction).__sentry_original__) { | |
fill(wrappedRoute.module, "loader", makeWrappedLoader(sentry, id)); | |
} | |
// Entry module should have a loader function to provide `sentry-trace` and `baggage` | |
// They will be available for the root `meta` function as `data.sentryTrace` and `data.sentryBaggage` | |
if (!wrappedRoute.parentId) { | |
if (!wrappedRoute.module.loader) { | |
wrappedRoute.module.loader = () => ({}); | |
} | |
// We want to wrap the root loader regardless of whether it's already wrapped before. | |
fill(wrappedRoute.module, "loader", makeWrappedRootLoader(sentry)); | |
} | |
routes[id] = wrappedRoute; | |
} | |
return { ...build, routes, entry: wrappedEntry }; | |
}; | |
// https://github.com/remix-run/remix/blob/97999d02493e8114c39d48b76944069d58526e8d/packages/remix-server-runtime/server.ts#L573-L586 | |
const isIndexRequestUrl = (url: URL): boolean => { | |
for (const parameter of url.searchParams.getAll("index")) { | |
// only use bare `?index` params without a value | |
// ✅ /foo?index | |
// ✅ /foo?index&index=123 | |
// ✅ /foo?index=123&index | |
// ❌ /foo?index=123 | |
if (parameter === "") { | |
return true; | |
} | |
} | |
return false; | |
}; | |
// https://github.com/remix-run/remix/blob/97999d02493e8114c39d48b76944069d58526e8d/packages/remix-server-runtime/server.ts#L588-L596 | |
const getRequestMatch = (url: URL, matches: RouteMatch<ServerRoute>[]): RouteMatch<ServerRoute> => { | |
const match = matches.slice(-1)[0]; | |
if (match === undefined) { | |
throw new Error("No match found in the array. This should never occur."); | |
} | |
if (!isIndexRequestUrl(url) && match.route.id.endsWith("/index")) { | |
const nextMatch = matches.slice(-2)[0]; | |
if (nextMatch === undefined) { | |
throw new Error("No match found in the array. This should never occur."); | |
} | |
return nextMatch; | |
} | |
return match; | |
}; | |
/** | |
* Get transaction name from routes and url | |
*/ | |
export const getTransactionName = (routes: ServerRoute[], url: URL): [string, TransactionSource] => { | |
const matches = matchServerRoutes(routes, url.pathname); | |
const match = matches && getRequestMatch(url, matches); | |
return match === null ? [url.pathname, "url"] : [match.route.id, "route"]; | |
}; | |
/** | |
* Starts a new transaction for the given request to be used by different `RequestHandler` wrappers. | |
*/ | |
export const startRequestHandlerTransaction = ( | |
sentry: Toucan, | |
name: string, | |
source: TransactionSource, | |
request: { | |
headers: { | |
"sentry-trace": string; | |
baggage: string; | |
}; | |
method: string; | |
} | |
): Transaction => { | |
// If there is a trace header set, we extract the data from it (parentSpanId, traceId, and sampling decision) | |
const traceparentData = extractTraceparentData(request.headers["sentry-trace"]); | |
const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(request.headers.baggage); | |
const transaction = sentry.startTransaction({ | |
name, | |
op: "http.server", | |
tags: { | |
method: request.method, | |
}, | |
...traceparentData, | |
metadata: { | |
source, | |
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext, | |
}, | |
}); | |
sentry.getScope()?.setSpan(transaction); | |
return transaction; | |
}; | |
export default instrumentBuild; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { json, type LoaderFunction, type MetaFunction } from "@remix-run/cloudflare"; | |
import { | |
Links, | |
LiveReload, | |
Meta, | |
Outlet, | |
Scripts, | |
ScrollRestoration, | |
useCatch, | |
useLoaderData, | |
useLocation, | |
useMatches, | |
} from "@remix-run/react"; | |
import { ExtraErrorData } from "@sentry/integrations"; | |
import { setUser, withProfiler } from "@sentry/react"; | |
import { BrowserTracing, init as initSentry, remixRouterInstrumentation, withSentry } from "@sentry/remix"; | |
import { type ReactNode, useEffect } from "react"; | |
/** | |
* Remix meta function for the HTML `<meta>` tags. | |
* | |
* Note that `sentryTrace` and `sentryBaggage` should be added in {@link instrumentBuild}. They're then read by `BrowserTracing`. | |
* | |
* @returns The meta tags for the app. | |
*/ | |
export const meta = (({ | |
data: { sentryTrace, sentryBaggage }, | |
}: { | |
data: { sentryTrace?: string; sentryBaggage?: string }; | |
}) => ({ | |
"sentry-trace": sentryTrace, | |
baggage: sentryBaggage, | |
})) satisfies MetaFunction; | |
/** | |
* Get top-level configuration from the server to pass to the client. | |
* | |
* Be extremely careful not to expose any secrets here. These won't be included in the browser bundle but clients will be able to access them via the JavaScript context. | |
* | |
* @returns Configuration object. | |
*/ | |
export const loader = (async ({ | |
request, | |
context: { SENTRY_ENABLED, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_VERSION }, | |
}) => { | |
return json({ | |
SENTRY_ENABLED, | |
SENTRY_DSN, | |
SENTRY_ENVIRONMENT, | |
SENTRY_VERSION, | |
}); | |
}) satisfies LoaderFunction; | |
const Page = ({ children }: { children: ReactNode }) => { | |
return ( | |
<html lang="en"> | |
<head> | |
<Meta /> | |
<Links /> | |
</head> | |
<body style={{ margin: 0 }}> | |
{children} | |
<ScrollRestoration /> | |
<Scripts /> | |
<LiveReload /> | |
</body> | |
</html> | |
); | |
}; | |
/** | |
* The root component for the app. | |
* | |
* This should only handle high-level wrappers & other tools, not business logic. Put that into a route. | |
* | |
* @returns JSX to render the root component. | |
*/ | |
const Root = () => { | |
const { SENTRY_ENABLED, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_VERSION } = | |
useLoaderData<typeof loader>(); | |
const location = useLocation(); | |
useEffect(() => { | |
if (SENTRY_ENABLED) { | |
initSentry({ | |
dsn: SENTRY_DSN, | |
environment: SENTRY_ENVIRONMENT, | |
release: SENTRY_VERSION, | |
tunnel: "/errors", | |
tracesSampleRate: SENTRY_ENVIRONMENT === "development" ? 1 : 0.01, | |
integrations: [ | |
new BrowserTracing({ | |
routingInstrumentation: remixRouterInstrumentation(useEffect, useLocation, useMatches), | |
// Remix makes fetch requests with the full domain rather than `/`, which means we have to allowlist using the actual domain. | |
tracePropagationTargets: [new RegExp(`^https?://${new URL(window.location.href).host}`, "u")], | |
}), | |
// Log non-native parts of the error message | |
new ExtraErrorData(), | |
], | |
}); | |
} | |
}, [SENTRY_ENABLED, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_VERSION]); | |
return ( | |
<Page> | |
<Outlet /> | |
</Page> | |
); | |
}; | |
export default withSentry(withProfiler(Root), { wrapWithErrorBoundary: false }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment