Skip to content

Commit 3aa3944

Browse files
committed
feat(solidstart): Add withSentry config wrapper to enable building instrumentation files
1 parent 2f53df7 commit 3aa3944

File tree

11 files changed

+353
-6
lines changed

11 files changed

+353
-6
lines changed

packages/solidstart/.eslintrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ module.exports = {
1111
},
1212
},
1313
{
14-
files: ['src/vite/**', 'src/server/**'],
14+
files: ['src/vite/**', 'src/server/**', 'src/config/**'],
1515
rules: {
1616
'@sentry-internal/sdk/no-optional-chaining': 'off',
1717
'@sentry-internal/sdk/no-nullish-coalescing': 'off',
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { consoleSandbox } from '@sentry/utils';
4+
import type { Nitro } from './types';
5+
6+
/**
7+
* Adds the built `instrument.server.js` file to the output directory.
8+
*
9+
* This will no-op if no `instrument.server.js` file was found in the
10+
* build directory. Make sure the `sentrySolidStartVite` plugin was
11+
* added to `app.config.ts` to enable building the instrumentation file.
12+
*/
13+
export async function addInstrumentationFileToBuild(nitro: Nitro): Promise<void> {
14+
const { buildDir, serverDir } = nitro.options.output;
15+
const source = path.join(buildDir, 'build', 'ssr', 'instrument.server.js');
16+
const destination = path.join(serverDir, 'instrument.server.mjs');
17+
18+
try {
19+
await fs.promises.access(source, fs.constants.F_OK);
20+
await fs.promises.copyFile(source, destination);
21+
22+
consoleSandbox(() => {
23+
// eslint-disable-next-line no-console
24+
console.log(`[Sentry SolidStart withSentry] Successfully created ${destination}.`);
25+
});
26+
} catch (error) {
27+
consoleSandbox(() => {
28+
// eslint-disable-next-line no-console
29+
console.warn(`[Sentry SolidStart withSentry] Failed to create ${destination}.`, error);
30+
});
31+
}
32+
}
33+
34+
/**
35+
* Adds an `instrument.server.mjs` import to the top of the server entry file.
36+
*
37+
* This is meant as an escape hatch and should only be used in environments where
38+
* it's not possible to `--import` the file instead as it comes with a limited
39+
* tracing experience, only collecting http traces.
40+
*/
41+
export async function experimental_addInstrumentationFileTopLevelImportToServerEntry(
42+
serverDir: string,
43+
preset: string,
44+
): Promise<void> {
45+
// other presets ('node-server' or 'vercel') have an index.mjs
46+
const presetsWithServerFile = ['netlify'];
47+
const instrumentationFile = path.join(serverDir, 'instrument.server.mjs');
48+
const serverEntryFileName = presetsWithServerFile.includes(preset) ? 'server.mjs' : 'index.mjs';
49+
const serverEntryFile = path.join(serverDir, serverEntryFileName);
50+
51+
try {
52+
await fs.promises.access(instrumentationFile, fs.constants.F_OK);
53+
} catch (error) {
54+
consoleSandbox(() => {
55+
// eslint-disable-next-line no-console
56+
console.warn(
57+
`[Sentry SolidStart withSentry] Tried to add \`${instrumentationFile}\` as top level import to \`${serverEntryFile}\`.`,
58+
error,
59+
);
60+
});
61+
return;
62+
}
63+
64+
try {
65+
const content = await fs.promises.readFile(serverEntryFile, 'utf-8');
66+
const updatedContent = `import './instrument.server.mjs';\n${content}`;
67+
await fs.promises.writeFile(serverEntryFile, updatedContent);
68+
69+
consoleSandbox(() => {
70+
// eslint-disable-next-line no-console
71+
console.log(
72+
`[Sentry SolidStart withSentry] Added \`${instrumentationFile}\` as top level import to \`${serverEntryFile}\`.`,
73+
);
74+
});
75+
} catch (error) {
76+
// eslint-disable-next-line no-console
77+
console.warn(
78+
`[Sentry SolidStart withSentry] An error occurred when trying to add \`${instrumentationFile}\` as top level import to \`${serverEntryFile}\`.`,
79+
error,
80+
);
81+
}
82+
}

packages/solidstart/src/config/index.ts

Whitespace-only changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Types to avoid pulling in extra dependencies
2+
// These are non-exhaustive
3+
export type Nitro = {
4+
options: {
5+
buildDir: string;
6+
output: {
7+
buildDir: string;
8+
serverDir: string;
9+
};
10+
preset: string;
11+
};
12+
};
13+
14+
export type SolidStartInlineConfig = {
15+
server?: {
16+
hooks?: {
17+
close?: () => unknown;
18+
'rollup:before'?: (nitro: Nitro) => unknown;
19+
};
20+
};
21+
};
22+
23+
export type SentrySolidStartConfigOptions = {
24+
/**
25+
* Enabling basic server tracing can be used for environments where modifying the node option `--import` is not possible.
26+
* However, enabling this option only supports limited tracing instrumentation. Only http traces will be collected (but no database-specific traces etc.).
27+
*
28+
* If this option is `true`, the Sentry SDK will import the instrumentation.server.ts|js file at the top of the server entry file to load the SDK on the server.
29+
*
30+
* **DO NOT** enable this option if you've already added the node option `--import` in your node start script. This would initialize Sentry twice on the server-side and leads to unexpected issues.
31+
*
32+
* @default false
33+
*/
34+
experimental_basicServerTracing?: boolean;
35+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {
2+
addInstrumentationFileToBuild,
3+
experimental_addInstrumentationFileTopLevelImportToServerEntry,
4+
} from './addInstrumentation';
5+
import type { SentrySolidStartConfigOptions, SolidStartInlineConfig } from './types';
6+
7+
export const withSentry = (
8+
solidStartConfig: SolidStartInlineConfig = {},
9+
sentrySolidStartConfigOptions: SentrySolidStartConfigOptions = {},
10+
): SolidStartInlineConfig => {
11+
const server = solidStartConfig.server || {};
12+
const hooks = server.hooks || {};
13+
14+
let serverDir: string;
15+
let buildPreset: string;
16+
17+
return {
18+
...solidStartConfig,
19+
server: {
20+
...server,
21+
hooks: {
22+
...hooks,
23+
async close() {
24+
if (sentrySolidStartConfigOptions.experimental_basicServerTracing) {
25+
await experimental_addInstrumentationFileTopLevelImportToServerEntry(serverDir, buildPreset);
26+
}
27+
28+
// Run user provided hook
29+
if (hooks.close) {
30+
hooks.close();
31+
}
32+
},
33+
async 'rollup:before'(nitro) {
34+
serverDir = nitro.options.output.serverDir;
35+
buildPreset = nitro.options.preset;
36+
37+
await addInstrumentationFileToBuild(nitro);
38+
39+
// Run user provided hook
40+
if (hooks['rollup:before']) {
41+
hooks['rollup:before'](nitro);
42+
}
43+
},
44+
},
45+
},
46+
};
47+
};
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { consoleSandbox } from '@sentry/utils';
4+
import type { Plugin, UserConfig } from 'vite';
5+
6+
/**
7+
* A Sentry plugin for SolidStart to build the server
8+
* `instrument.server.ts` file.
9+
*/
10+
export function makeBuildInstrumentationFilePlugin(
11+
instrumentationFilePath: string = './src/instrument.server.ts',
12+
): Plugin {
13+
return {
14+
name: 'sentry-solidstart-build-instrumentation-file',
15+
apply: 'build',
16+
enforce: 'post',
17+
async config(config: UserConfig, { command }) {
18+
const router = (config as UserConfig & { router: { target: string; name: string; root: string } }).router;
19+
const build = config.build || {};
20+
const rollupOptions = build.rollupOptions || {};
21+
const input = [...((rollupOptions.input || []) as string[])];
22+
23+
// plugin runs for client, server and sever-fns, we only want to run it for the server once.
24+
if (command !== 'build' || router.target !== 'server' || router.name === 'server-fns') {
25+
return config;
26+
}
27+
28+
try {
29+
await fs.promises.access(instrumentationFilePath, fs.constants.F_OK);
30+
} catch (error) {
31+
consoleSandbox(() => {
32+
// eslint-disable-next-line no-console
33+
console.warn(
34+
`[Sentry SolidStart Plugin] Could not access \`${instrumentationFilePath}\`, please make sure it exists.`,
35+
error,
36+
);
37+
});
38+
return config;
39+
}
40+
41+
input.push(path.join(router.root, instrumentationFilePath));
42+
43+
return {
44+
...config,
45+
build: {
46+
...build,
47+
rollupOptions: {
48+
...rollupOptions,
49+
input,
50+
},
51+
},
52+
};
53+
},
54+
};
55+
}

packages/solidstart/src/vite/sentrySolidStartVite.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Plugin } from 'vite';
2+
import { makeBuildInstrumentationFilePlugin } from './buildInstrumentationFile';
23
import { makeSourceMapsVitePlugin } from './sourceMaps';
34
import type { SentrySolidStartPluginOptions } from './types';
45

@@ -8,6 +9,8 @@ import type { SentrySolidStartPluginOptions } from './types';
89
export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {}): Plugin[] => {
910
const sentryPlugins: Plugin[] = [];
1011

12+
sentryPlugins.push(makeBuildInstrumentationFilePlugin(options.instrumentation));
13+
1114
if (process.env.NODE_ENV !== 'development') {
1215
if (options.sourceMapsUploadOptions?.enabled ?? true) {
1316
sentryPlugins.push(...makeSourceMapsVitePlugin(options));

packages/solidstart/src/vite/sourceMaps.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function makeSourceMapsVitePlugin(options: SentrySolidStartPluginOptions)
2222
if (!sourceMapsUploadOptions?.filesToDeleteAfterUpload) {
2323
// eslint-disable-next-line no-console
2424
console.warn(
25-
`[Sentry SolidStart PLugin] We recommend setting the \`sourceMapsUploadOptions.filesToDeleteAfterUpload\` option to clean up source maps after uploading.
25+
`[Sentry SolidStart Plugin] We recommend setting the \`sourceMapsUploadOptions.filesToDeleteAfterUpload\` option to clean up source maps after uploading.
2626
[Sentry SolidStart Plugin] Otherwise, source maps might be deployed to production, depending on your configuration`,
2727
);
2828
}

packages/solidstart/src/vite/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,12 @@ export type SentrySolidStartPluginOptions = {
125125
* Enabling this will give you, for example logs about source maps.
126126
*/
127127
debug?: boolean;
128+
129+
/**
130+
* The path to your `instrumentation.server.ts|js` file.
131+
* e.g. './src/instrumentation.server.ts`
132+
*
133+
* Defaults to: `./src/instrumentation.server.ts`
134+
*/
135+
instrumentation?: string;
128136
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { UserConfig } from 'vite';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { makeBuildInstrumentationFilePlugin } from '../../src/vite/buildInstrumentationFile';
4+
5+
const fsAccessMock = vi.fn();
6+
7+
vi.mock('fs', async () => {
8+
const actual = await vi.importActual('fs');
9+
return {
10+
...actual,
11+
promises: {
12+
// @ts-expect-error this exists
13+
...actual.promises,
14+
access: () => fsAccessMock(),
15+
},
16+
};
17+
});
18+
19+
const consoleWarnSpy = vi.spyOn(console, 'warn');
20+
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
});
24+
25+
describe('makeBuildInstrumentationFilePlugin()', () => {
26+
const viteConfig: UserConfig & { router: { target: string; name: string; root: string } } = {
27+
router: {
28+
target: 'server',
29+
name: 'ssr',
30+
root: '/some/project/path',
31+
},
32+
build: {
33+
rollupOptions: {
34+
input: ['/path/to/entry1.js', '/path/to/entry2.js'],
35+
},
36+
},
37+
};
38+
39+
it('returns a plugin to set `sourcemaps` to `true`', () => {
40+
const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin();
41+
42+
expect(buildInstrumentationFilePlugin.name).toEqual('sentry-solidstart-build-instrumentation-file');
43+
expect(buildInstrumentationFilePlugin.apply).toEqual('build');
44+
expect(buildInstrumentationFilePlugin.enforce).toEqual('post');
45+
expect(buildInstrumentationFilePlugin.config).toEqual(expect.any(Function));
46+
});
47+
48+
it('adds the instrumentation file for server builds', async () => {
49+
const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin();
50+
const config = await buildInstrumentationFilePlugin.config(viteConfig, { command: 'build' });
51+
expect(config.build.rollupOptions.input).toContain('/some/project/path/src/instrument.server.ts');
52+
});
53+
54+
it('adds the correct instrumentation file', async () => {
55+
const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin('./src/myapp/instrument.server.ts');
56+
const config = await buildInstrumentationFilePlugin.config(viteConfig, { command: 'build' });
57+
expect(config.build.rollupOptions.input).toContain('/some/project/path/src/myapp/instrument.server.ts');
58+
});
59+
60+
it("doesn't add the instrumentation file for server function builds", async () => {
61+
const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin();
62+
const config = await buildInstrumentationFilePlugin.config(
63+
{
64+
...viteConfig,
65+
router: {
66+
...viteConfig.router,
67+
name: 'server-fns',
68+
},
69+
},
70+
{ command: 'build' },
71+
);
72+
expect(config.build.rollupOptions.input).not.toContain('/some/project/path/src/instrument.server.ts');
73+
});
74+
75+
it("doesn't add the instrumentation file for client builds", async () => {
76+
const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin();
77+
const config = await buildInstrumentationFilePlugin.config(
78+
{
79+
...viteConfig,
80+
router: {
81+
...viteConfig.router,
82+
target: 'client',
83+
},
84+
},
85+
{ command: 'build' },
86+
);
87+
expect(config.build.rollupOptions.input).not.toContain('/some/project/path/src/instrument.server.ts');
88+
});
89+
90+
it("doesn't add the instrumentation file when serving", async () => {
91+
const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin();
92+
const config = await buildInstrumentationFilePlugin.config(viteConfig, { command: 'serve' });
93+
expect(config.build.rollupOptions.input).not.toContain('/some/project/path/src/instrument.server.ts');
94+
});
95+
96+
it("doesn't modify the config if the instrumentation file doesn't exist", async () => {
97+
fsAccessMock.mockRejectedValueOnce(undefined);
98+
const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin();
99+
const config = await buildInstrumentationFilePlugin.config(viteConfig, { command: 'build' });
100+
expect(config).toEqual(viteConfig);
101+
});
102+
103+
it("logs a warning if the instrumentation file doesn't exist", async () => {
104+
const error = new Error("File doesn't exist.");
105+
fsAccessMock.mockRejectedValueOnce(error);
106+
const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin();
107+
const config = await buildInstrumentationFilePlugin.config(viteConfig, { command: 'build' });
108+
expect(config).toEqual(viteConfig);
109+
expect(consoleWarnSpy).toHaveBeenCalledWith(
110+
'[Sentry SolidStart Plugin] Could not access `./src/instrument.server.ts`, please make sure it exists.',
111+
error,
112+
);
113+
});
114+
});

0 commit comments

Comments
 (0)