Skip to content
Open
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
18 changes: 18 additions & 0 deletions packages/nuxt/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,24 @@ export type SentryNuxtModuleOptions = BuildTimeOptionsBase & {
*/
autoInjectServerSentry?: 'top-level-import' | 'experimental_dynamic-import';

/**
* Provide the resolved path to a custom Sentry client config file.
*
* If not provided, the default location (`<projectRoot>/sentry.(client|server).config.(js|ts)`) will be used to look up the config file.
* If there is no file at the default location either, the SDK won't be initialized.
*
* Resolves the full path to a file or directory, respecting Nuxt alias and extensions options.
* @example
*
* ```ts
* sentry: {
* configDir: '~/sentry-config',
* // Sentry will search for `<rootDir>/<srcDir>/sentry-config/sentry.(client|server).config.(js|ts)` files.
* }
* ```
*/
configDir?: string;

/**
* When `autoInjectServerSentry` is set to `"experimental_dynamic-import"`, the SDK will wrap your Nitro server entrypoint
* with a dynamic `import()` to ensure all dependencies can be properly instrumented. Any previous exports from the entrypoint are still exported.
Expand Down
4 changes: 2 additions & 2 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default defineNuxtModule<ModuleOptions>({
const moduleDirResolver = createResolver(import.meta.url);
const buildDirResolver = createResolver(nuxt.options.buildDir);

const clientConfigFile = findDefaultSdkInitFile('client', nuxt);
const clientConfigFile = await findDefaultSdkInitFile('client', nuxt, moduleOptions);

if (clientConfigFile) {
// Inject the client-side Sentry config file with a side effect import
Expand Down Expand Up @@ -78,7 +78,7 @@ export default defineNuxtModule<ModuleOptions>({
});
}

const serverConfigFile = findDefaultSdkInitFile('server', nuxt);
const serverConfigFile = await findDefaultSdkInitFile('server', nuxt, moduleOptions);
const isNitroV3 = (await getNitroMajorVersion()) >= 3;
const nuxtMajor = parseInt((nuxt as unknown as { _version: string })._version?.split('.')[0] ?? '3', 10);
const isMinNuxtV4 = nuxtMajor >= 4;
Expand Down
12 changes: 9 additions & 3 deletions packages/nuxt/src/vite/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Nuxt } from '@nuxt/schema';
import { consoleSandbox } from '@sentry/core';
import * as fs from 'fs';
import * as path from 'path';
import type { SentryNuxtModuleOptions } from '../common/types';
import { resolvePath } from '@nuxt/kit';

/**
* Gets the major version of the installed nitro package.
Expand All @@ -25,7 +27,11 @@ export async function getNitroMajorVersion(): Promise<number> {
* Find the default SDK init file for the given type (client or server).
* The sentry.server.config file is prioritized over the instrument.server file.
*/
export function findDefaultSdkInitFile(type: 'server' | 'client', nuxt?: Nuxt): string | undefined {
export async function findDefaultSdkInitFile(
type: 'server' | 'client',
nuxt?: Nuxt,
options?: SentryNuxtModuleOptions,
): Promise<string | undefined> {
const possibleFileExtensions = ['ts', 'js', 'mjs', 'cjs', 'mts', 'cts'];
const relativePaths: string[] = [];

Expand Down Expand Up @@ -53,9 +59,9 @@ export function findDefaultSdkInitFile(type: 'server' | 'client', nuxt?: Nuxt):
}

// As a fallback, also check CWD (left for pure compatibility)
const cwd = process.cwd();
const rootDir = options?.configDir ? await resolvePath(options.configDir, { type: 'dir' }) : process.cwd();
for (const relativePath of relativePaths) {
const fullPath = path.resolve(cwd, relativePath);
const fullPath = path.resolve(rootDir, relativePath);
if (fs.existsSync(fullPath)) {
Comment on lines 59 to 65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The configDir option is only used as a fallback. If a Sentry config file exists in the project root (a default Nuxt layer), the configDir is silently ignored.
Severity: MEDIUM

Suggested Fix

Prioritize the configDir if it is provided. Before iterating through Nuxt layers, check if options.configDir is set. If it is, search for the configuration file exclusively within that directory. The layer-based search should only be used as a fallback when configDir is not specified.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/nuxt/src/vite/utils.ts#L59-L65

Potential issue: The logic for finding the Sentry SDK init file prioritizes searching
through all Nuxt layers before considering the `configDir` option. In a standard Nuxt
application, the project root is one of the layers. Consequently, if a
`sentry.client.config.ts` or `sentry.server.config.ts` file is present in the project
root, it will be used, and the path specified in `configDir` will be silently ignored.
This contradicts the documented behavior that `configDir` should replace the default
lookup location, leading to unexpected configuration being loaded. The fallback comment
'As a fallback, also check CWD' confirms this last-resort implementation.

return fullPath;
}
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt/test/vite/buildOptions.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('Sentry Nuxt build-time options type', () => {
// --- SentryNuxtModuleOptions specific options ---
enabled: true,
autoInjectServerSentry: 'experimental_dynamic-import',
configDir: '~/custom-config',
experimental_entrypointWrappedFunctions: ['default', 'handler', 'server', 'customExport'],
unstable_sentryBundlerPluginOptions: {
// Rollup plugin options
Expand Down
78 changes: 62 additions & 16 deletions packages/nuxt/test/vite/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import {
SENTRY_WRAPPED_FUNCTIONS,
} from '../../src/vite/utils';

const resolvePathMock = vi.hoisted(() => vi.fn());

vi.mock('@nuxt/kit', () => ({
resolvePath: resolvePathMock,
}));

vi.mock('fs');

describe('findDefaultSdkInitFile', () => {
Expand All @@ -24,43 +30,83 @@ describe('findDefaultSdkInitFile', () => {

it.each(['ts', 'js', 'mjs', 'cjs', 'mts', 'cts'])(
'should return the server file path with .%s extension if it exists',
ext => {
async ext => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes(`sentry.server.config.${ext}`);
});

const result = findDefaultSdkInitFile('server');
const result = await findDefaultSdkInitFile('server');
expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`);
},
);

it.each(['ts', 'js', 'mjs', 'cjs', 'mts', 'cts'])(
'should return the client file path with .%s extension if it exists',
ext => {
async ext => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes(`sentry.client.config.${ext}`);
});

const result = findDefaultSdkInitFile('client');
const result = await findDefaultSdkInitFile('client');
expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`);
},
);

it('should return undefined if no file with specified extensions exists', () => {
it.each(['ts', 'js', 'mjs', 'cjs', 'mts', 'cts'])(
'should return a client config from a custom config root dir if it exists with .%s extension',
async ext => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes(`sentry.client.config.${ext}`);
});

const baseDir = '/users/some-user/front-example/app/config';

resolvePathMock.mockResolvedValue(baseDir);

const result = await findDefaultSdkInitFile('client', undefined, {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.client.config.${ext}`);
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);

it.each(['ts', 'js', 'mjs', 'cjs', 'mts', 'cts'])(
'should return a server config from a custom config root dir if it exists with .%s extension',
async ext => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes(`sentry.server.config.${ext}`);
});

const baseDir = '/users/some-user/front-example/app/config';

resolvePathMock.mockResolvedValue(baseDir);

const result = await findDefaultSdkInitFile('server', undefined, {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.server.config.${ext}`);
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);

it('should return undefined if no file with specified extensions exists', async () => {
vi.spyOn(fs, 'existsSync').mockReturnValue(false);

const result = findDefaultSdkInitFile('server');
const result = await findDefaultSdkInitFile('server');
expect(result).toBeUndefined();
});

it('should return undefined if no file exists', () => {
it('should return undefined if no file exists', async () => {
vi.spyOn(fs, 'existsSync').mockReturnValue(false);

const result = findDefaultSdkInitFile('server');
const result = await findDefaultSdkInitFile('server');
expect(result).toBeUndefined();
});

it('should return the server config file path if server.config and instrument exist', () => {
it('should return the server config file path if server.config and instrument exist', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return (
!(filePath instanceof URL) &&
Expand All @@ -69,11 +115,11 @@ describe('findDefaultSdkInitFile', () => {
);
});

const result = findDefaultSdkInitFile('server');
const result = await findDefaultSdkInitFile('server');
expect(result).toMatch('packages/nuxt/sentry.server.config.js');
});

it('should return the latest layer config file path if client config exists', () => {
it('should return the latest layer config file path if client config exists', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes('sentry.client.config.ts');
});
Expand All @@ -91,11 +137,11 @@ describe('findDefaultSdkInitFile', () => {
},
} as unknown as Nuxt;

const result = findDefaultSdkInitFile('client', nuxtMock);
const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
});

it('should return the latest layer config file path if server config exists', () => {
it('should return the latest layer config file path if server config exists', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return (
!(filePath instanceof URL) &&
Expand All @@ -117,11 +163,11 @@ describe('findDefaultSdkInitFile', () => {
},
} as unknown as Nuxt;

const result = findDefaultSdkInitFile('server', nuxtMock);
const result = await findDefaultSdkInitFile('server', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.server.config.ts');
});

it('should return the latest layer config file path if client config exists in former layer', () => {
it('should return the latest layer config file path if client config exists in former layer', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes('nuxt/sentry.client.config.ts');
});
Expand All @@ -139,7 +185,7 @@ describe('findDefaultSdkInitFile', () => {
},
} as unknown as Nuxt;

const result = findDefaultSdkInitFile('client', nuxtMock);
const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
});
});
Expand Down