diff --git a/packages/remix/package.json b/packages/remix/package.json index 52ff6e499983..3ded637edd0b 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -65,6 +65,8 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", + "@opentelemetry/instrumentation": "^0.57.2", + "@opentelemetry/semantic-conventions": "^1.30.0", "@remix-run/router": "1.x", "@sentry/cli": "^2.43.0", "@sentry/core": "9.17.0", @@ -72,7 +74,6 @@ "@sentry/opentelemetry": "9.17.0", "@sentry/react": "9.17.0", "glob": "^10.3.4", - "opentelemetry-instrumentation-remix": "0.8.0", "yargs": "^17.6.0" }, "devDependencies": { diff --git a/packages/remix/src/server/errors.ts b/packages/remix/src/server/errors.ts index 90359212300d..0e26242a0164 100644 --- a/packages/remix/src/server/errors.ts +++ b/packages/remix/src/server/errors.ts @@ -134,7 +134,7 @@ export async function errorHandleDataFunction( const options = getClient()?.getOptions() as RemixOptions | undefined; if (options?.sendDefaultPii && options.captureActionFormDataKeys) { - await storeFormDataKeys(args, span); + await storeFormDataKeys(args, span, options.captureActionFormDataKeys); } } diff --git a/packages/remix/src/server/integrations/opentelemetry.ts b/packages/remix/src/server/integrations/opentelemetry.ts index 42654201da18..b4ad1d28bb6a 100644 --- a/packages/remix/src/server/integrations/opentelemetry.ts +++ b/packages/remix/src/server/integrations/opentelemetry.ts @@ -1,8 +1,8 @@ import type { Client, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { generateInstrumentOnce, getClient, spanToJSON } from '@sentry/node'; -import { RemixInstrumentation } from 'opentelemetry-instrumentation-remix'; import type { RemixOptions } from '../../utils/remixOptions'; +import { RemixInstrumentation } from '../../vendor/instrumentation'; const INTEGRATION_NAME = 'Remix'; diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts index a1d878ac1314..62fee4b20d61 100644 --- a/packages/remix/src/utils/utils.ts +++ b/packages/remix/src/utils/utils.ts @@ -10,7 +10,11 @@ type ServerRouteManifest = ServerBuild['routes']; /** * */ -export async function storeFormDataKeys(args: LoaderFunctionArgs | ActionFunctionArgs, span: Span): Promise { +export async function storeFormDataKeys( + args: LoaderFunctionArgs | ActionFunctionArgs, + span: Span, + formDataKeys?: Record | undefined, +): Promise { try { // We clone the request for Remix be able to read the FormData later. const clonedRequest = args.request.clone(); @@ -21,7 +25,18 @@ export async function storeFormDataKeys(args: LoaderFunctionArgs | ActionFunctio const formData = await clonedRequest.formData(); formData.forEach((value, key) => { - span.setAttribute(`remix.action_form_data.${key}`, typeof value === 'string' ? value : '[non-string value]'); + let attrKey = key; + + if (formDataKeys?.[key]) { + if (typeof formDataKeys[key] === 'string') { + attrKey = formDataKeys[key] as string; + } + + span.setAttribute( + `remix.action_form_data.${attrKey}`, + typeof value === 'string' ? value : '[non-string value]', + ); + } }); } catch (e) { DEBUG_BUILD && logger.warn('Failed to read FormData from request', e); diff --git a/packages/remix/src/vendor/instrumentation.ts b/packages/remix/src/vendor/instrumentation.ts new file mode 100644 index 000000000000..317a17da663d --- /dev/null +++ b/packages/remix/src/vendor/instrumentation.ts @@ -0,0 +1,375 @@ +/* eslint-disable deprecation/deprecation */ +/* eslint-disable max-lines */ +/* eslint-disable jsdoc/require-jsdoc */ + +// Vendored and modified from: +// https://github.com/justindsmith/opentelemetry-instrumentations-js/blob/3b1e8c3e566e5cc3389e9c28cafce6a5ebb39600/packages/instrumentation-remix/src/instrumentation.ts + +/* + * Copyright Justin Smith + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Span } from '@opentelemetry/api'; +import opentelemetry, { SpanStatusCode } from '@opentelemetry/api'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, + isWrapped, +} from '@opentelemetry/instrumentation'; +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; +import type { Params } from '@remix-run/router'; +import type * as remixRunServerRuntime from '@remix-run/server-runtime'; +import type * as remixRunServerRuntimeData from '@remix-run/server-runtime/dist/data'; +import type * as remixRunServerRuntimeRouteMatching from '@remix-run/server-runtime/dist/routeMatching'; +import type { RouteMatch } from '@remix-run/server-runtime/dist/routeMatching'; +import type { ServerRoute } from '@remix-run/server-runtime/dist/routes'; +import { SDK_VERSION } from '@sentry/core'; + +const RemixSemanticAttributes = { + MATCH_PARAMS: 'match.params', + MATCH_ROUTE_ID: 'match.route.id', +}; + +const VERSION = SDK_VERSION; + +export interface RemixInstrumentationConfig extends InstrumentationConfig { + /** + * Mapping of FormData field to span attribute names. Appends attribute as `formData.${name}`. + * + * Provide `true` value to use the FormData field name as the attribute name, or provide + * a `string` value to map the field name to a custom attribute name. + * + * @default { _action: "actionType" } + */ + actionFormDataAttributes?: Record; +} + +const DEFAULT_CONFIG: RemixInstrumentationConfig = { + actionFormDataAttributes: { + _action: 'actionType', + }, +}; + +export class RemixInstrumentation extends InstrumentationBase { + public constructor(config: RemixInstrumentationConfig = {}) { + super('RemixInstrumentation', VERSION, Object.assign({}, DEFAULT_CONFIG, config)); + } + + public getConfig(): RemixInstrumentationConfig { + return this._config; + } + + public setConfig(config: RemixInstrumentationConfig = {}): void { + this._config = Object.assign({}, DEFAULT_CONFIG, config); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + protected init(): InstrumentationNodeModuleDefinition { + const remixRunServerRuntimeRouteMatchingFile = new InstrumentationNodeModuleFile( + '@remix-run/server-runtime/dist/routeMatching.js', + ['2.x'], + (moduleExports: typeof remixRunServerRuntimeRouteMatching) => { + // createRequestHandler + if (isWrapped(moduleExports['matchServerRoutes'])) { + this._unwrap(moduleExports, 'matchServerRoutes'); + } + this._wrap(moduleExports, 'matchServerRoutes', this._patchMatchServerRoutes()); + + return moduleExports; + }, + (moduleExports: typeof remixRunServerRuntimeRouteMatching) => { + this._unwrap(moduleExports, 'matchServerRoutes'); + }, + ); + + const remixRunServerRuntimeData_File = new InstrumentationNodeModuleFile( + '@remix-run/server-runtime/dist/data.js', + ['2.9.0 - 2.x'], + (moduleExports: typeof remixRunServerRuntimeData) => { + // callRouteLoader + if (isWrapped(moduleExports['callRouteLoader'])) { + this._unwrap(moduleExports, 'callRouteLoader'); + } + this._wrap(moduleExports, 'callRouteLoader', this._patchCallRouteLoader()); + + // callRouteAction + if (isWrapped(moduleExports['callRouteAction'])) { + this._unwrap(moduleExports, 'callRouteAction'); + } + this._wrap(moduleExports, 'callRouteAction', this._patchCallRouteAction()); + return moduleExports; + }, + (moduleExports: typeof remixRunServerRuntimeData) => { + this._unwrap(moduleExports, 'callRouteLoader'); + this._unwrap(moduleExports, 'callRouteAction'); + }, + ); + + /* + * In Remix 2.9.0, the `callXXLoaderRR` functions were renamed to `callXXLoader`. + */ + const remixRunServerRuntimeDataPre_2_9_File = new InstrumentationNodeModuleFile( + '@remix-run/server-runtime/dist/data.js', + ['2.0.0 - 2.8.x'], + ( + moduleExports: typeof remixRunServerRuntimeData & { + callRouteLoaderRR: typeof remixRunServerRuntimeData.callRouteLoader; + callRouteActionRR: typeof remixRunServerRuntimeData.callRouteAction; + }, + ) => { + // callRouteLoader + if (isWrapped(moduleExports['callRouteLoaderRR'])) { + this._unwrap(moduleExports, 'callRouteLoaderRR'); + } + this._wrap(moduleExports, 'callRouteLoaderRR', this._patchCallRouteLoader()); + + // callRouteAction + if (isWrapped(moduleExports['callRouteActionRR'])) { + this._unwrap(moduleExports, 'callRouteActionRR'); + } + this._wrap(moduleExports, 'callRouteActionRR', this._patchCallRouteAction()); + return moduleExports; + }, + ( + moduleExports: typeof remixRunServerRuntimeData & { + callRouteLoaderRR: typeof remixRunServerRuntimeData.callRouteLoader; + callRouteActionRR: typeof remixRunServerRuntimeData.callRouteAction; + }, + ) => { + this._unwrap(moduleExports, 'callRouteLoaderRR'); + this._unwrap(moduleExports, 'callRouteActionRR'); + }, + ); + + const remixRunServerRuntimeModule = new InstrumentationNodeModuleDefinition( + '@remix-run/server-runtime', + ['2.x'], + (moduleExports: typeof remixRunServerRuntime) => { + // createRequestHandler + if (isWrapped(moduleExports['createRequestHandler'])) { + this._unwrap(moduleExports, 'createRequestHandler'); + } + this._wrap(moduleExports, 'createRequestHandler', this._patchCreateRequestHandler()); + + return moduleExports; + }, + (moduleExports: typeof remixRunServerRuntime) => { + this._unwrap(moduleExports, 'createRequestHandler'); + }, + [remixRunServerRuntimeRouteMatchingFile, remixRunServerRuntimeData_File, remixRunServerRuntimeDataPre_2_9_File], + ); + + return remixRunServerRuntimeModule; + } + + private _patchMatchServerRoutes(): (original: typeof remixRunServerRuntimeRouteMatching.matchServerRoutes) => any { + return function matchServerRoutes(original) { + return function patchMatchServerRoutes( + this: any, + ...args: Parameters + ): RouteMatch[] | null { + const result = original.apply(this, args) as RouteMatch[] | null; + + const span = opentelemetry.trace.getSpan(opentelemetry.context.active()); + + const route = (result || []).slice(-1)[0]?.route; + + const routePath = route?.path; + if (span && routePath) { + span.setAttribute(SemanticAttributes.HTTP_ROUTE, routePath); + span.updateName(`remix.request ${routePath}`); + } + + const routeId = route?.id; + if (span && routeId) { + span.setAttribute(RemixSemanticAttributes.MATCH_ROUTE_ID, routeId); + } + + return result; + }; + }; + } + + private _patchCreateRequestHandler(): (original: typeof remixRunServerRuntime.createRequestHandler) => any { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const plugin = this; + return function createRequestHandler(original) { + return function patchCreateRequestHandler( + this: any, + ...args: Parameters + ): remixRunServerRuntime.RequestHandler { + const originalRequestHandler: remixRunServerRuntime.RequestHandler = original.apply(this, args); + + return (request: Request, loadContext?: remixRunServerRuntime.AppLoadContext) => { + const span = plugin.tracer.startSpan( + 'remix.request', + { + attributes: { [SemanticAttributes.CODE_FUNCTION]: 'requestHandler' }, + }, + opentelemetry.context.active(), + ); + addRequestAttributesToSpan(span, request); + + const originalResponsePromise = opentelemetry.context.with( + opentelemetry.trace.setSpan(opentelemetry.context.active(), span), + () => originalRequestHandler(request, loadContext), + ); + return originalResponsePromise + .then(response => { + addResponseAttributesToSpan(span, response); + return response; + }) + .catch(error => { + plugin._addErrorToSpan(span, error); + throw error; + }) + .finally(() => { + span.end(); + }); + }; + }; + }; + } + + private _patchCallRouteLoader(): (original: typeof remixRunServerRuntimeData.callRouteLoader) => any { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const plugin = this; + return function callRouteLoader(original) { + return function patchCallRouteLoader(this: any, ...args: Parameters): Promise { + const [params] = args; + + const span = plugin.tracer.startSpan( + `LOADER ${params.routeId}`, + { attributes: { [SemanticAttributes.CODE_FUNCTION]: 'loader' } }, + opentelemetry.context.active(), + ); + + addRequestAttributesToSpan(span, params.request); + addMatchAttributesToSpan(span, { routeId: params.routeId, params: params.params }); + + return opentelemetry.context.with(opentelemetry.trace.setSpan(opentelemetry.context.active(), span), () => { + const originalResponsePromise: Promise = original.apply(this, args); + return originalResponsePromise + .then(response => { + addResponseAttributesToSpan(span, response); + return response; + }) + .catch(error => { + plugin._addErrorToSpan(span, error); + throw error; + }) + .finally(() => { + span.end(); + }); + }); + }; + }; + } + + private _patchCallRouteAction(): (original: typeof remixRunServerRuntimeData.callRouteAction) => any { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const plugin = this; + return function callRouteAction(original) { + return async function patchCallRouteAction(this: any, ...args: Parameters): Promise { + const [params] = args; + const clonedRequest = params.request.clone(); + const span = plugin.tracer.startSpan( + `ACTION ${params.routeId}`, + { attributes: { [SemanticAttributes.CODE_FUNCTION]: 'action' } }, + opentelemetry.context.active(), + ); + + addRequestAttributesToSpan(span, clonedRequest); + addMatchAttributesToSpan(span, { routeId: params.routeId, params: params.params }); + + return opentelemetry.context.with( + opentelemetry.trace.setSpan(opentelemetry.context.active(), span), + async () => { + const originalResponsePromise: Promise = original.apply(this, args); + + return originalResponsePromise + .then(async response => { + addResponseAttributesToSpan(span, response); + + try { + const formData = await clonedRequest.formData(); + const { actionFormDataAttributes: actionFormAttributes } = plugin.getConfig(); + + formData.forEach((value: unknown, key: string) => { + if ( + actionFormAttributes?.[key] && + actionFormAttributes[key] !== false && + typeof value === 'string' + ) { + const keyName = actionFormAttributes[key] === true ? key : actionFormAttributes[key]; + span.setAttribute(`formData.${keyName}`, value.toString()); + } + }); + } catch { + // Silently continue on any error. Typically happens because the action body cannot be processed + // into FormData, in which case we should just continue. + } + + return response; + }) + .catch(async error => { + plugin._addErrorToSpan(span, error); + throw error; + }) + .finally(() => { + span.end(); + }); + }, + ); + }; + }; + } + + private _addErrorToSpan(span: Span, error: Error): void { + addErrorEventToSpan(span, error); + } +} + +const addRequestAttributesToSpan = (span: Span, request: Request): void => { + span.setAttributes({ + [SemanticAttributes.HTTP_METHOD]: request.method, + [SemanticAttributes.HTTP_URL]: request.url, + }); +}; + +const addMatchAttributesToSpan = (span: Span, match: { routeId: string; params: Params }): void => { + span.setAttributes({ + [RemixSemanticAttributes.MATCH_ROUTE_ID]: match.routeId, + }); + + Object.keys(match.params).forEach(paramName => { + span.setAttribute(`${RemixSemanticAttributes.MATCH_PARAMS}.${paramName}`, match.params[paramName] || '(undefined)'); + }); +}; + +const addResponseAttributesToSpan = (span: Span, response: Response | null): void => { + if (response) { + span.setAttributes({ + [SemanticAttributes.HTTP_STATUS_CODE]: response.status, + }); + } +}; + +const addErrorEventToSpan = (span: Span, error: Error): void => { + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); +}; diff --git a/yarn.lock b/yarn.lock index 5952c9d32733..a742b50bb685 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5427,13 +5427,6 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api-logs@0.52.1": - version "0.52.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz#52906375da4d64c206b0c4cb8ffa209214654ecc" - integrity sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A== - dependencies: - "@opentelemetry/api" "^1.0.0" - "@opentelemetry/api-logs@0.57.2": version "0.57.2" resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz#d4001b9aa3580367b40fe889f3540014f766cc87" @@ -5441,7 +5434,7 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.0.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0": +"@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== @@ -5706,18 +5699,6 @@ require-in-the-middle "^7.1.1" shimmer "^1.2.1" -"@opentelemetry/instrumentation@^0.52.1": - version "0.52.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz#2e7e46a38bd7afbf03cf688c862b0b43418b7f48" - integrity sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw== - dependencies: - "@opentelemetry/api-logs" "0.52.1" - "@types/shimmer" "^1.0.2" - import-in-the-middle "^1.8.1" - require-in-the-middle "^7.1.1" - semver "^7.5.2" - shimmer "^1.2.1" - "@opentelemetry/propagation-utils@^0.30.16": version "0.30.16" resolved "https://registry.yarnpkg.com/@opentelemetry/propagation-utils/-/propagation-utils-0.30.16.tgz#6715d0225b618ea66cf34cc3800fa3452a8475fa" @@ -5750,7 +5731,7 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz#337fb2bca0453d0726696e745f50064411f646d6" integrity sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA== -"@opentelemetry/semantic-conventions@^1.25.1", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0": +"@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0": version "1.32.0" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.32.0.tgz#a15e8f78f32388a7e4655e7f539570e40958ca3f" integrity sha512-s0OpmpQFSfMrmedAn9Lhg4KWJELHCU6uU9dtIJ28N8UGhf9Y55im5X8fEzwhwDwiSqN+ZPSNrDJF7ivf/AuRPQ== @@ -8280,7 +8261,7 @@ "@types/mime" "*" "@types/node" "*" -"@types/shimmer@^1.0.2", "@types/shimmer@^1.2.0": +"@types/shimmer@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@types/shimmer/-/shimmer-1.2.0.tgz#9b706af96fa06416828842397a70dfbbf1c14ded" integrity sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg== @@ -22474,14 +22455,6 @@ opener@^1.5.2: resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== -opentelemetry-instrumentation-remix@0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/opentelemetry-instrumentation-remix/-/opentelemetry-instrumentation-remix-0.8.0.tgz#cf917395f82b2c995ee46068d85d9fa1c95eb36f" - integrity sha512-2XhIEWfzHeQmxnzv9HzklwkgYMx4NuWwloZuVIwjUb9R28gH5j3rJPqjErTvYSyz0fLbw0gyI+gfYHKHn/v/1Q== - dependencies: - "@opentelemetry/instrumentation" "^0.52.1" - "@opentelemetry/semantic-conventions" "^1.25.1" - optional-require@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/optional-require/-/optional-require-1.0.3.tgz#275b8e9df1dc6a17ad155369c2422a440f89cb07"