Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nextjs): Instrument cache handler #13124

Draft
wants to merge 15 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
const { withSentryConfig } = require('@sentry/nextjs');

/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
cacheHandler: require.resolve('next/dist/server/lib/incremental-cache/file-system-cache.js'),
cacheMaxMemorySize: 0,
};

module.exports = withSentryConfig(nextConfig, {
silent: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const { withSentryConfig } = require('@sentry/nextjs');

/** @type {import('next').NextConfig} */
const nextConfig = {
cacheHandler: require.resolve('next/dist/server/lib/incremental-cache/file-system-cache.js'),
cacheMaxMemorySize: 0,
experimental: {
ppr: true,
},
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"@sentry/vercel-edge": "8.34.0",
"@sentry/webpack-plugin": "2.22.3",
"chalk": "3.0.0",
"import-in-the-middle": "^1.11.0",
"resolve": "1.22.8",
"rollup": "3.29.5",
"stacktrace-parser": "^0.1.10"
Expand Down
6 changes: 6 additions & 0 deletions packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ export type NextConfigObject = {
experimental?: {
instrumentationHook?: boolean;
clientTraceMetadata?: string[];
// For Next.js 14
serverComponentsExternalPackages?: string[];
};
// For Next.js 15+
serverExternalPackages?: string[];
// Path to the Next.js cache handler module
cacheHandler?: string;
productionBrowserSourceMaps?: boolean;
};

Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,7 @@ function addValueInjectionLoader(

const serverValues = {
...isomorphicValues,
__cacheHandlerPath__: userNextConfig.cacheHandler,
// Make sure that if we have a windows path, the backslashes are interpreted as such (rather than as escape
// characters)
__rewriteFramesDistDir__: userNextConfig.distDir?.replace(/\\/g, '\\\\') || '.next',
Expand Down
19 changes: 17 additions & 2 deletions packages/nextjs/src/config/withSentryConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,30 @@ function getFinalConfigObject(
// Add the `clientTraceMetadata` experimental option based on Next.js version. The option got introduced in Next.js version 15.0.0 (actually 14.3.0-canary.64).
// Adding the option on lower versions will cause Next.js to print nasty warnings we wouldn't confront our users with.
if (nextJsVersion) {
const { major, minor } = parseSemver(nextJsVersion);
if (major !== undefined && minor !== undefined && (major >= 15 || (major === 14 && minor >= 3))) {
const { major = 0, minor = 0 } = parseSemver(nextJsVersion);
if (major >= 15 || (major === 14 && minor >= 3)) {
incomingUserNextConfigObject.experimental = incomingUserNextConfigObject.experimental || {};
incomingUserNextConfigObject.experimental.clientTraceMetadata = [
'baggage',
'sentry-trace',
...(incomingUserNextConfigObject.experimental?.clientTraceMetadata || []),
];
}

// We need to add `import-in-the-middle` to the external server modules to make sure that it's not bundled so that
// both the ESM loader hook and runtime code use the same library.
if (major >= 15) {
incomingUserNextConfigObject.serverExternalPackages = [
...(incomingUserNextConfigObject.serverExternalPackages || []),
'import-in-the-middle',
];
} else {
incomingUserNextConfigObject.experimental = incomingUserNextConfigObject.experimental || {};
incomingUserNextConfigObject.experimental.serverComponentsExternalPackages = [
...(incomingUserNextConfigObject.experimental.serverComponentsExternalPackages || []),
'import-in-the-middle',
];
}
} else {
// eslint-disable-next-line no-console
console.log(
Expand Down
73 changes: 73 additions & 0 deletions packages/nextjs/src/server/cache-instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { startSpan } from '@sentry/node';
// We need to import `* as` due to Rollup cjs interop issues
import * as iitm from 'import-in-the-middle';

type CacheHandlerClass = {
new (options: unknown): CacheHandlerClass;
get(key: string): Promise<unknown | null>;
set(key: string, data: unknown, ctx: unknown): Promise<void>;
};

function looksLikeCacheHandlerClass(obj: unknown): obj is CacheHandlerClass {
return (
typeof obj === 'function' && typeof obj.prototype === 'object' && 'get' in obj.prototype && 'set' in obj.prototype
);
}

/**
* Hooks the loading of the cache handler module at the `cacheHandlerPath` and wraps the class to add cache spans around
* the get and set methods.
*/
export function enableCacheInstrumentation(cacheHandlerPath: string): void {
// We only hook the specific path of the cache handler module
new iitm.Hook([cacheHandlerPath], exports => {
if (!looksLikeCacheHandlerClass(exports.default)) {
return;
}

// Grab the existing default export which should be the user defined cache handler
const UserCache = exports.default;

// Define a new class that extends the user defined cache handler
class SentryCacheHandlerWrapper extends UserCache {
public constructor(options: unknown) {
super(options);
}

public async get(key: string): Promise<unknown | null> {
return startSpan(
{
name: key,
attributes: { 'cache.key': [key] },
op: 'cache.get',
},
async span => {
const value = await super.get(key);
// The nextjs docs say that null is a cache miss, but we'll also consider undefined since their example
// simple cache handler returns undefined for a cache miss.
// https://nextjs.org/docs/app/building-your-application/deploying#configuring-caching
const cacheHit = value !== null && value !== undefined;
span.setAttribute('cache.hit', cacheHit);
return value;
},
);
}

public async set(key: string, data: unknown, ctx: unknown): Promise<void> {
await startSpan(
{
name: key,
attributes: { 'cache.key': [key] },
op: 'cache.put',
},
async _ => {
await super.set(key, data, ctx);
},
);
}
}

// Overwrite the default export with the new CacheHandler class
exports.default = SentryCacheHandlerWrapper;
});
}
12 changes: 12 additions & 0 deletions packages/nextjs/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ import { DEBUG_BUILD } from '../common/debug-build';
import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor';
import { getVercelEnv } from '../common/getVercelEnv';
import { isBuild } from '../common/utils/isBuild';
import { enableCacheInstrumentation } from './cache-instrumentation';
import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration';

export * from '@sentry/node';

export { captureUnderscoreErrorException } from '../common/_error';

const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
__cacheHandlerPath__?: string;
__rewriteFramesDistDir__?: string;
__sentryRewritesTunnelPath__?: string;
};
Expand Down Expand Up @@ -119,6 +121,15 @@ export function init(options: NodeOptions): NodeClient | undefined {
autoSessionTracking: false,
};

if (globalWithInjectedValues.__cacheHandlerPath__) {
enableCacheInstrumentation(globalWithInjectedValues.__cacheHandlerPath__);

// We need to force the ESM loader hooks to be registered, even if we're running in CJS mode.
if (opts.registerEsmLoaderHooks === undefined) {
opts.registerEsmLoaderHooks = true;
}
}

if (DEBUG_BUILD && opts.debug) {
logger.enable();
}
Expand All @@ -133,6 +144,7 @@ export function init(options: NodeOptions): NodeClient | undefined {
applySdkMetadata(opts, 'nextjs', ['nextjs', 'node']);

const client = nodeInit(opts);

client?.on('beforeSampling', ({ spanAttributes, spanName, parentSampled, parentContext }, samplingDecision) => {
// We allowlist the "BaseServer.handleRequest" span, since that one is responsible for App Router requests, which are actually useful for us.
// HOWEVER, that span is not only responsible for App Router requests, which is why we additionally filter for certain transactions in an
Expand Down
4 changes: 1 addition & 3 deletions packages/node/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,7 @@ function _init(
}
}

if (!isCjs() && options.registerEsmLoaderHooks !== false) {
maybeInitializeEsmLoader(options.registerEsmLoaderHooks === true ? undefined : options.registerEsmLoaderHooks);
}
maybeInitializeEsmLoader(options.registerEsmLoaderHooks);

setOpenTelemetryContextAsyncContextStrategy();

Expand Down
21 changes: 17 additions & 4 deletions packages/node/src/sdk/initOtel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import moduleModule from 'module';
import * as moduleModule from 'module';
import { DiagLogLevel, diag } from '@opentelemetry/api';
import { Resource } from '@opentelemetry/resources';
import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
Expand All @@ -12,6 +12,7 @@ import { SentryPropagator, SentrySampler, SentrySpanProcessor } from '@sentry/op
import { GLOBAL_OBJ, consoleSandbox, logger } from '@sentry/utils';
import { createAddHookMessageChannel } from 'import-in-the-middle';

import { pathToFileURL } from 'url';
import { getOpenTelemetryInstrumentationToPreload } from '../integrations/tracing';
import { SentryContextManager } from '../otel/contextManager';
import type { EsmLoaderHookOptions } from '../types';
Expand Down Expand Up @@ -53,16 +54,28 @@ function getRegisterOptions(esmHookConfig?: EsmLoaderHookOptions): RegisterOptio
}

/** Initialize the ESM loader. */
export function maybeInitializeEsmLoader(esmHookConfig?: EsmLoaderHookOptions): void {
export function maybeInitializeEsmLoader(esmHookConfig: EsmLoaderHookOptions | boolean | undefined): void {
// if it's specifically disabled by being set to false
if (esmHookConfig === false) {
return;
}

// If we're using CJS and we're not force enabled
if (isCjs() && esmHookConfig === undefined) {
return;
}

const [nodeMajor = 0, nodeMinor = 0] = process.versions.node.split('.').map(Number);

// Register hook was added in v20.6.0 and v18.19.0
if (nodeMajor >= 22 || (nodeMajor === 20 && nodeMinor >= 6) || (nodeMajor === 18 && nodeMinor >= 19)) {
// We need to work around using import.meta.url directly because jest complains about it.
const importMetaUrl =
typeof __IMPORT_META_URL_REPLACEMENT__ !== 'undefined' ? __IMPORT_META_URL_REPLACEMENT__ : undefined;
typeof __IMPORT_META_URL_REPLACEMENT__ !== 'undefined'
? __IMPORT_META_URL_REPLACEMENT__
: pathToFileURL(__filename).href;

if (!GLOBAL_OBJ._sentryEsmLoaderHookRegistered && importMetaUrl) {
if (!GLOBAL_OBJ._sentryEsmLoaderHookRegistered) {
try {
// @ts-expect-error register is available in these versions
moduleModule.register('import-in-the-middle/hook.mjs', importMetaUrl, getRegisterOptions(esmHookConfig));
Expand Down
Loading