Skip to content
Merged
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
@@ -0,0 +1,31 @@
services:
db:
image: mysql:8.0
restart: always
container_name: e2e-tests-solidstart-mysql
# The `mysql` 2.x driver doesn't speak MySQL 8's default
# `caching_sha2_password` auth, so force the legacy plugin.
command: ['--default-authentication-plugin=mysql_native_password']
ports:
- '3306:3306'
environment:
MYSQL_ROOT_PASSWORD: docker
healthcheck:
test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker']
interval: 2s
timeout: 3s
retries: 30
start_period: 10s

redis:
image: redis:7
restart: always
container_name: e2e-tests-solidstart-redis
ports:
- '6379:6379'
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { execSync } from 'child_process';
import { dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

export default async function globalSetup() {
// Start MySQL + Redis via Docker Compose. `--wait` blocks until the
// healthchecks in docker-compose.yml pass, so the app can connect immediately.
execSync('docker compose up -d --wait', {
cwd: __dirname,
stdio: 'inherit',
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { execSync } from 'child_process';
import { dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

export default async function globalTeardown() {
execSync('docker compose down --volumes', {
cwd: __dirname,
stdio: 'inherit',
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
},
"type": "module",
"dependencies": {
"@sentry/solidstart": "file:../../packed/sentry-solidstart-packed.tgz"
"@sentry/solidstart": "file:../../packed/sentry-solidstart-packed.tgz",
"ioredis": "5.10.1",
"mysql": "^2.18.1"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ const config = getPlaywrightConfig({
port: 3030,
});

export default config;
export default {
...config,
globalSetup: './global-setup.mjs',
globalTeardown: './global-teardown.mjs',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { json } from '@solidjs/router';
import Redis from 'ioredis';

export async function GET() {
const redis = new Redis({
// Don't keep retrying forever if Redis goes away (e.g. on test teardown)
maxRetriesPerRequest: 1,
retryStrategy: () => null,
});

try {
await redis.set('test-key', 'test-value');
const value = await redis.get('test-key');
return json({ value });
} finally {
redis.disconnect();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { json } from '@solidjs/router';
import mysql from 'mysql';

export async function GET() {
const connection = mysql.createConnection({ user: 'root', password: 'docker' });
try {
await new Promise<void>((resolve, reject) => {
connection.query('SELECT 1 + 1 AS solution', err1 => {
if (err1) return reject(err1);
connection.query('SELECT NOW()', ['1', '2'], err2 => {
if (err2) return reject(err2);
resolve();
});
});
});
return json({ status: 'ok' });
} finally {
connection.end(() => {});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('Instruments ioredis automatically via build-time orchestrion', async ({ baseURL }) => {
const transactionEventPromise = waitForTransaction('solidstart', transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-ioredis')
);
});

await fetch(`${baseURL}/api/db-ioredis`);

const transactionEvent = await transactionEventPromise;
const spans = transactionEvent.spans || [];

expect(spans).toContainEqual(
expect.objectContaining({
op: 'db',
origin: 'auto.db.orchestrion.redis',
description: 'set test-key [1 other arguments]',
status: 'ok',
data: expect.objectContaining({
'db.system': 'redis',
'db.statement': 'set test-key [1 other arguments]',
}),
}),
);
expect(spans).toContainEqual(
expect.objectContaining({
op: 'db',
origin: 'auto.db.orchestrion.redis',
description: 'get test-key',
status: 'ok',
data: expect.objectContaining({
'db.system': 'redis',
'db.statement': 'get test-key',
}),
}),
);
});

test('Instruments mysql automatically via build-time orchestrion', async ({ baseURL }) => {
const transactionEventPromise = waitForTransaction('solidstart', transactionEvent => {
return (
transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-mysql')
);
});

await fetch(`${baseURL}/api/db-mysql`);

const transactionEvent = await transactionEventPromise;
const spans = transactionEvent.spans || [];

expect(spans).toContainEqual(
expect.objectContaining({
op: 'db',
origin: 'auto.db.orchestrion.mysql',
description: 'SELECT 1 + 1 AS solution',
status: 'ok',
data: expect.objectContaining({
'db.system': 'mysql',
'db.statement': 'SELECT 1 + 1 AS solution',
'db.user': 'root',
'db.connection_string': expect.any(String),
'net.peer.name': expect.any(String),
'net.peer.port': 3306,
}),
}),
);
expect(spans).toContainEqual(
expect.objectContaining({
op: 'db',
origin: 'auto.db.orchestrion.mysql',
description: 'SELECT NOW()',
status: 'ok',
data: expect.objectContaining({
'db.system': 'mysql',
'db.statement': 'SELECT NOW()',
'db.user': 'root',
'db.connection_string': expect.any(String),
'net.peer.name': expect.any(String),
'net.peer.port': 3306,
}),
}),
);
});
1 change: 1 addition & 0 deletions packages/solidstart/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"dependencies": {
"@sentry/core": "10.67.0",
"@sentry/node": "10.67.0",
"@sentry/server-utils": "10.67.0",
"@sentry/solid": "10.67.0",
"@sentry/bundler-plugins": "10.67.0"
},
Expand Down
33 changes: 32 additions & 1 deletion packages/solidstart/src/config/withSentry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { debug } from '@sentry/core';
import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config';
import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup';
import type { Nitro } from 'nitropack';
import { addSentryPluginToVite } from '../vite/sentrySolidStartVite';
import type { SentrySolidStartPluginOptions } from '../vite/types';
Expand All @@ -9,6 +11,10 @@ import {
} from './addInstrumentation';
import type { RollupConfig, SolidStartInlineConfig, SolidStartInlineServerConfig } from './types';

// ioredis requires this CommonJS helper to be bundled with it. Leaving it
// external makes Nitro resolve the default export as a namespace object.
const IORedisDependencies = ['standard-as-callback'];

const defaultSentrySolidStartPluginOptions: Omit<
SentrySolidStartPluginOptions,
'experimental_entrypointWrappedFunctions'
Expand Down Expand Up @@ -42,13 +48,25 @@ export function withSentry(
? (...args: Parameters<typeof viteConfig>) => addSentryPluginToVite(viteConfig(...args), sentryPluginOptions)
: addSentryPluginToVite(viteConfig, sentryPluginOptions);

const addBuildTimeInstrumentation = sentryPluginOptions.buildTimeInstrumentation !== false;

// Use a module so we don't override preset hooks.
const sentryNitroModule = (nitro: Nitro) => {
nitro.hooks.hook('rollup:before', async (nitro, rollupConfig) => {
// Nitro types the `rollup:before` hook's `rollupConfig.plugins` as `string[]` (plugin paths),
// but at runtime it holds resolved plugin objects we can push onto, so narrow to our own shape.
const sentryRollupConfig = rollupConfig as unknown as RollupConfig;

if (addBuildTimeInstrumentation) {
sentryRollupConfig.plugins.push(
sentryOrchestrionPlugin({ buildTimeInstrumentation: sentryPluginOptions.buildTimeInstrumentation }),
);
Comment thread
chargome marked this conversation as resolved.
}

if (sentrySolidStartPluginOptions?.autoInjectServerSentry === 'experimental_dynamic-import') {
await addDynamicImportEntryFileWrapper({
nitro,
rollupConfig: rollupConfig as unknown as RollupConfig,
rollupConfig: sentryRollupConfig,
sentryPluginOptions,
});

Expand All @@ -68,11 +86,24 @@ export function withSentry(

const existingModules = (server as SolidStartInlineServerConfig & { modules?: unknown[] }).modules || [];

// An externalized dependency never passes through the orchestrion code transform, so force-inline
// the instrumented modules. This has to be set statically on the Nitro config (not in a hook)
// because externalization is a resolution-time decision made before Rollup normalizes `external`.
let externals = (server as SolidStartInlineServerConfig & { externals?: { inline?: string[] } }).externals;
if (addBuildTimeInstrumentation) {
const existingInline = externals?.inline || [];
externals = {
...externals,
inline: [...new Set([...existingInline, ...INSTRUMENTED_MODULE_NAMES, ...IORedisDependencies])],
};
}

return {
...solidStartConfig,
vite,
server: {
...server,
externals,
modules: [...existingModules, sentryNitroModule],
},
};
Expand Down
9 changes: 9 additions & 0 deletions packages/solidstart/src/vite/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,15 @@ export type SentrySolidStartPluginOptions = {
*/
debug?: boolean;

/**
* Automatic instrumentation of server-side dependencies at build time.
*
* Set to `false` to turn it off.
*
* @default true
*/
buildTimeInstrumentation?: boolean;

/**
* The path to your `instrument.server.ts|js` file.
* e.g. `./src/instrument.server.ts`
Expand Down
54 changes: 54 additions & 0 deletions packages/solidstart/test/config/withSentry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ vi.mock('../../src/config/addInstrumentation', () => ({
addSentryTopImport: (...args: unknown[]) => addSentryTopImportMock(...args),
}));

// Mirror the real plugin's contract: `buildTimeInstrumentation: false` yields the inert variant.
const orchestrionRollupMock = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => ({
name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-plugin',
}));
vi.mock('@sentry/server-utils/orchestrion/rollup', () => ({
sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionRollupMock(options),
}));
vi.mock('@sentry/server-utils/orchestrion/config', () => ({
INSTRUMENTED_MODULE_NAMES: ['mysql', 'ioredis'],
}));

beforeEach(() => {
vi.clearAllMocks();
});
Expand Down Expand Up @@ -173,4 +184,47 @@ describe('withSentry()', () => {
expect(modules[0]).toBe(existingModule);
expect(typeof modules[1]).toBe('function');
});

describe('orchestrion build-time instrumentation', () => {
it('pushes the orchestrion plugin into the rollup config by default', async () => {
const config = withSentry(solidStartConfig, {});
const { hookFn } = callSentryNitroModule(config);
const plugins: Array<{ name: string }> = [];
await hookFn(nitroOptions, { plugins });
expect(orchestrionRollupMock).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined });
expect(plugins.map(plugin => plugin.name)).toContain('sentry-orchestrion-plugin');
});

it('force-inlines the instrumented modules into server.externals by default', () => {
const config = withSentry(solidStartConfig, {});
const externals = (config?.server as { externals?: { inline?: string[] } })?.externals;
expect(externals?.inline).toEqual(['mysql', 'ioredis', 'standard-as-callback']);
});

it('preserves and dedupes existing inline externals', () => {
const config = withSentry(
{
...solidStartConfig,
server: {
...solidStartConfig.server,
externals: { inline: ['ioredis', 'custom-dependency'] },
},
} as Parameters<typeof withSentry>[0],
{},
);
const externals = (config?.server as { externals?: { inline?: string[] } })?.externals;
expect(externals?.inline).toEqual(['ioredis', 'custom-dependency', 'mysql', 'standard-as-callback']);
});

it('adds an inert orchestrion plugin and skips externals when buildTimeInstrumentation is false', async () => {
const config = withSentry(solidStartConfig, { buildTimeInstrumentation: false });
const { hookFn } = callSentryNitroModule(config);
const plugins: Array<{ name: string }> = [];
await hookFn(nitroOptions, { plugins });
expect(orchestrionRollupMock).not.toHaveBeenCalled();
expect(plugins).toHaveLength(0);
const externals = (config?.server as { externals?: { inline?: string[] } })?.externals;
expect(externals).toBeUndefined();
});
});
});
Loading