-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(remix): Add Remix server SDK (#5269)
Adds server side SDK for error tracking / performance tracing of Remix. - Uses Node SDK underneath. - For tracing, monkey-patches `createRequestHandler` from `@remix-run/server-runtime` which apparently is used by all server-side adapters of Remix. - `action` and `loader` functions are patched as parameters of `createRequestHandler`. Co-authored-by: Abhijeet Prasad <[email protected]>
- Loading branch information
1 parent
a1c79cd
commit 45818f3
Showing
7 changed files
with
307 additions
and
10 deletions.
There are no files selected for viewing
This file contains 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
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
/* eslint-disable import/export */ | ||
import { configureScope, getCurrentHub, init as nodeInit } from '@sentry/node'; | ||
import { logger } from '@sentry/utils'; | ||
|
||
import { instrumentServer } from './utils/instrumentServer'; | ||
import { buildMetadata } from './utils/metadata'; | ||
import { RemixOptions } from './utils/remixOptions'; | ||
|
||
export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; | ||
export { remixRouterInstrumentation, withSentryRouteTracing } from './performance/client'; | ||
export { BrowserTracing, Integrations } from '@sentry/tracing'; | ||
export * from '@sentry/node'; | ||
|
||
function sdkAlreadyInitialized(): boolean { | ||
const hub = getCurrentHub(); | ||
return !!hub.getClient(); | ||
} | ||
|
||
/** Initializes Sentry Remix SDK on Node. */ | ||
export function init(options: RemixOptions): void { | ||
buildMetadata(options, ['remix', 'node']); | ||
|
||
if (sdkAlreadyInitialized()) { | ||
__DEBUG_BUILD__ && logger.log('SDK already initialized'); | ||
|
||
return; | ||
} | ||
|
||
instrumentServer(); | ||
|
||
nodeInit(options); | ||
|
||
configureScope(scope => { | ||
scope.setTag('runtime', 'node'); | ||
}); | ||
} |
This file was deleted.
Oops, something went wrong.
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,197 @@ | ||
import { captureException, configureScope, getCurrentHub, startTransaction } from '@sentry/node'; | ||
import { getActiveTransaction } from '@sentry/tracing'; | ||
import { addExceptionMechanism, fill, loadModule, logger } from '@sentry/utils'; | ||
|
||
// Types vendored from @remix-run/[email protected]: | ||
// https://github.com/remix-run/remix/blob/f3691d51027b93caa3fd2cdfe146d7b62a6eb8f2/packages/remix-server-runtime/server.ts | ||
type AppLoadContext = unknown; | ||
type AppData = unknown; | ||
type RequestHandler = (request: Request, loadContext?: AppLoadContext) => Promise<Response>; | ||
type CreateRequestHandlerFunction = (build: ServerBuild, mode?: string) => RequestHandler; | ||
type ServerRouteManifest = RouteManifest<Omit<ServerRoute, 'children'>>; | ||
type Params<Key extends string = string> = { | ||
readonly [key in Key]: string | undefined; | ||
}; | ||
|
||
interface Route { | ||
index?: boolean; | ||
caseSensitive?: boolean; | ||
id: string; | ||
parentId?: string; | ||
path?: string; | ||
} | ||
|
||
interface ServerRouteModule { | ||
action?: DataFunction; | ||
headers?: unknown; | ||
loader?: DataFunction; | ||
} | ||
|
||
interface ServerRoute extends Route { | ||
children: ServerRoute[]; | ||
module: ServerRouteModule; | ||
} | ||
|
||
interface RouteManifest<Route> { | ||
[routeId: string]: Route; | ||
} | ||
|
||
interface ServerBuild { | ||
entry: { | ||
module: ServerEntryModule; | ||
}; | ||
routes: ServerRouteManifest; | ||
assets: unknown; | ||
} | ||
|
||
interface HandleDocumentRequestFunction { | ||
(request: Request, responseStatusCode: number, responseHeaders: Headers, context: Record<symbol, unknown>): | ||
| Promise<Response> | ||
| Response; | ||
} | ||
|
||
interface HandleDataRequestFunction { | ||
(response: Response, args: DataFunctionArgs): Promise<Response> | Response; | ||
} | ||
|
||
interface ServerEntryModule { | ||
default: HandleDocumentRequestFunction; | ||
handleDataRequest?: HandleDataRequestFunction; | ||
} | ||
|
||
interface DataFunctionArgs { | ||
request: Request; | ||
context: AppLoadContext; | ||
params: Params; | ||
} | ||
|
||
interface DataFunction { | ||
(args: DataFunctionArgs): Promise<Response> | Response | Promise<AppData> | AppData; | ||
} | ||
|
||
function makeWrappedDataFunction(origFn: DataFunction, name: 'action' | 'loader'): DataFunction { | ||
return async function (this: unknown, args: DataFunctionArgs): Promise<Response | AppData> { | ||
let res: Response | AppData; | ||
const activeTransaction = getActiveTransaction(); | ||
const currentScope = getCurrentHub().getScope(); | ||
|
||
if (!activeTransaction || !currentScope) { | ||
return origFn.call(this, args); | ||
} | ||
|
||
try { | ||
const span = activeTransaction.startChild({ | ||
op: `remix.server.${name}`, | ||
description: activeTransaction.name, | ||
tags: { | ||
name, | ||
}, | ||
}); | ||
|
||
if (span) { | ||
// Assign data function to hub to be able to see `db` transactions (if any) as children. | ||
currentScope.setSpan(span); | ||
} | ||
|
||
res = await origFn.call(this, args); | ||
|
||
currentScope.setSpan(activeTransaction); | ||
span.finish(); | ||
} catch (err) { | ||
configureScope(scope => { | ||
scope.addEventProcessor(event => { | ||
addExceptionMechanism(event, { | ||
type: 'instrument', | ||
handled: true, | ||
data: { | ||
function: name, | ||
}, | ||
}); | ||
|
||
return event; | ||
}); | ||
}); | ||
|
||
captureException(err); | ||
|
||
// Rethrow for other handlers | ||
throw err; | ||
} | ||
|
||
return res; | ||
}; | ||
} | ||
|
||
function makeWrappedAction(origAction: DataFunction): DataFunction { | ||
return makeWrappedDataFunction(origAction, 'action'); | ||
} | ||
|
||
function makeWrappedLoader(origAction: DataFunction): DataFunction { | ||
return makeWrappedDataFunction(origAction, 'loader'); | ||
} | ||
|
||
function wrapRequestHandler(origRequestHandler: RequestHandler): RequestHandler { | ||
return async function (this: unknown, request: Request, loadContext?: unknown): Promise<Response> { | ||
const currentScope = getCurrentHub().getScope(); | ||
const transaction = startTransaction({ | ||
name: request.url, | ||
op: 'http.server', | ||
tags: { | ||
method: request.method, | ||
}, | ||
}); | ||
|
||
if (transaction) { | ||
currentScope?.setSpan(transaction); | ||
} | ||
|
||
const res = (await origRequestHandler.call(this, request, loadContext)) as Response; | ||
|
||
transaction?.setHttpStatus(res.status); | ||
transaction?.finish(); | ||
|
||
return res; | ||
}; | ||
} | ||
|
||
function makeWrappedCreateRequestHandler( | ||
origCreateRequestHandler: CreateRequestHandlerFunction, | ||
): CreateRequestHandlerFunction { | ||
return function (this: unknown, build: ServerBuild, mode: string | undefined): RequestHandler { | ||
const routes: ServerRouteManifest = {}; | ||
|
||
for (const [id, route] of Object.entries(build.routes)) { | ||
const wrappedRoute = { ...route, module: { ...route.module } }; | ||
|
||
if (wrappedRoute.module.action) { | ||
fill(wrappedRoute.module, 'action', makeWrappedAction); | ||
} | ||
|
||
if (wrappedRoute.module.loader) { | ||
fill(wrappedRoute.module, 'loader', makeWrappedLoader); | ||
} | ||
|
||
routes[id] = wrappedRoute; | ||
} | ||
|
||
const requestHandler = origCreateRequestHandler.call(this, { ...build, routes }, mode); | ||
|
||
return wrapRequestHandler(requestHandler); | ||
}; | ||
} | ||
|
||
/** | ||
* 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 { | ||
const pkg = loadModule<{ createRequestHandler: CreateRequestHandlerFunction }>('@remix-run/server-runtime'); | ||
|
||
if (!pkg) { | ||
__DEBUG_BUILD__ && logger.warn('Remix SDK was unable to require `@remix-run/server-runtime` package.'); | ||
|
||
return; | ||
} | ||
|
||
fill(pkg, 'createRequestHandler', makeWrappedCreateRequestHandler); | ||
} |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
import { NodeOptions } from '@sentry/node'; | ||
import { BrowserOptions } from '@sentry/react'; | ||
import { Options } from '@sentry/types'; | ||
|
||
export type RemixOptions = Options | BrowserOptions; | ||
export type RemixOptions = Options | BrowserOptions | NodeOptions; |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import * as SentryNode from '@sentry/node'; | ||
import { getCurrentHub } from '@sentry/node'; | ||
import { getGlobalObject } from '@sentry/utils'; | ||
|
||
import { init } from '../src/index.server'; | ||
|
||
const global = getGlobalObject(); | ||
|
||
const nodeInit = jest.spyOn(SentryNode, 'init'); | ||
|
||
describe('Server init()', () => { | ||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
global.__SENTRY__.hub = undefined; | ||
}); | ||
|
||
it('inits the Node SDK', () => { | ||
expect(nodeInit).toHaveBeenCalledTimes(0); | ||
init({}); | ||
expect(nodeInit).toHaveBeenCalledTimes(1); | ||
expect(nodeInit).toHaveBeenLastCalledWith( | ||
expect.objectContaining({ | ||
_metadata: { | ||
sdk: { | ||
name: 'sentry.javascript.remix', | ||
version: expect.any(String), | ||
packages: [ | ||
{ | ||
name: 'npm:@sentry/remix', | ||
version: expect.any(String), | ||
}, | ||
{ | ||
name: 'npm:@sentry/node', | ||
version: expect.any(String), | ||
}, | ||
], | ||
}, | ||
}, | ||
}), | ||
); | ||
}); | ||
|
||
it("doesn't reinitialize the node SDK if already initialized", () => { | ||
expect(nodeInit).toHaveBeenCalledTimes(0); | ||
init({}); | ||
expect(nodeInit).toHaveBeenCalledTimes(1); | ||
init({}); | ||
expect(nodeInit).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('sets runtime on scope', () => { | ||
const currentScope = getCurrentHub().getScope(); | ||
|
||
// @ts-ignore need access to protected _tags attribute | ||
expect(currentScope._tags).toEqual({}); | ||
|
||
init({}); | ||
|
||
// @ts-ignore need access to protected _tags attribute | ||
expect(currentScope._tags).toEqual({ runtime: 'node' }); | ||
}); | ||
}); |