Skip to content

Instantly share code, notes, and snippets.

@johtso
Forked from huw/README.md
Created February 24, 2023 15:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save johtso/b9e69df9a2794f73886317639f87f475 to your computer and use it in GitHub Desktop.
Save johtso/b9e69df9a2794f73886317639f87f475 to your computer and use it in GitHub Desktop.
Remix, Sentry & Cloudflare Workers
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;
/**
* @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;
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