Skip to content
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
@@ -0,0 +1,42 @@
import { createMiddleware } from '@tanstack/react-start';
import { wrapMiddlewareWithSentry, wrapMiddlewareListWithSentry } from '@sentry/tanstackstart-react';

// Global request middleware - runs on every request
const globalRequestMiddleware = createMiddleware().server(async ({ next }) => {
console.log('Global request middleware executed');
return next();
});

// Global function middleware - runs on every server function
const globalFunctionMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => {
console.log('Global function middleware executed');
return next();
});

// Server function specific middleware
const serverFnMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => {
console.log('Server function middleware executed');
return next();
});

// Server route specific request middleware
const serverRouteRequestMiddleware = createMiddleware().server(async ({ next }) => {
console.log('Server route request middleware executed');
return next();
});

// Wrap global request middleware
export const wrappedGlobalRequestMiddleware = wrapMiddlewareWithSentry(globalRequestMiddleware, {
name: 'globalRequestMiddleware',
});

// Wrap global function middleware
export const wrappedGlobalFunctionMiddleware = wrapMiddlewareWithSentry(globalFunctionMiddleware, {
name: 'globalFunctionMiddleware',
});

// Wrap server function middleware using list wrapper
export const [wrappedServerFnMiddleware, wrappedServerRouteRequestMiddleware] = wrapMiddlewareListWithSentry({
serverFnMiddleware,
serverRouteRequestMiddleware,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router';
import { wrappedServerRouteRequestMiddleware } from '../middleware';

export const Route = createFileRoute('/api/test-middleware')({
server: {
middleware: [wrappedServerRouteRequestMiddleware],
handlers: {
GET: async () => {
return { message: 'Server route middleware test' };
},
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { wrappedServerFnMiddleware } from '../middleware';

// Server function with specific middleware (also gets global function middleware)
const serverFnWithMiddleware = createServerFn()
.middleware([wrappedServerFnMiddleware])
.handler(async () => {
console.log('Server function with specific middleware executed');
return { message: 'Server function middleware test' };
});

// Server function without specific middleware (only gets global function middleware)
const serverFnWithoutMiddleware = createServerFn().handler(async () => {
console.log('Server function without specific middleware executed');
return { message: 'Global middleware only test' };
});

export const Route = createFileRoute('/test-middleware')({
component: TestMiddleware,
});

function TestMiddleware() {
return (
<div>
<h1>Test Middleware Page</h1>
<button
id="server-fn-middleware-btn"
type="button"
onClick={async () => {
await serverFnWithMiddleware();
}}
>
Call server function with middleware
</button>
<button
id="server-fn-global-only-btn"
type="button"
onClick={async () => {
await serverFnWithoutMiddleware();
}}
>
Call server function (global middleware only)
</button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createStart } from '@tanstack/react-start';
import { wrappedGlobalRequestMiddleware, wrappedGlobalFunctionMiddleware } from './middleware';

export const startInstance = createStart(() => {
return {
requestMiddleware: [wrappedGlobalRequestMiddleware],
functionMiddleware: [wrappedGlobalFunctionMiddleware],
};
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('Sends spans for server function specific middleware', async ({ page }) => {
const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
!!transactionEvent?.transaction?.startsWith('GET /_serverFn')
);
});

await page.goto('/test-middleware');
await expect(page.locator('#server-fn-middleware-btn')).toBeVisible();
await page.locator('#server-fn-middleware-btn').click();

const transactionEvent = await transactionEventPromise;

expect(Array.isArray(transactionEvent?.spans)).toBe(true);

// Check for the server function specific middleware span
expect(transactionEvent?.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'serverFnMiddleware',
op: 'middleware.tanstackstart',
origin: 'manual.middleware.tanstackstart',
data: expect.objectContaining({
'sentry.op': 'middleware.tanstackstart',
'sentry.origin': 'manual.middleware.tanstackstart',
}),
status: 'ok',
}),
]),
);
});

test('Sends spans for global function middleware', async ({ page }) => {
const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
!!transactionEvent?.transaction?.startsWith('GET /_serverFn')
);
});

await page.goto('/test-middleware');
await expect(page.locator('#server-fn-global-only-btn')).toBeVisible();
await page.locator('#server-fn-global-only-btn').click();

const transactionEvent = await transactionEventPromise;

expect(Array.isArray(transactionEvent?.spans)).toBe(true);

// Check for the global function middleware span
expect(transactionEvent?.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'globalFunctionMiddleware',
op: 'middleware.tanstackstart',
origin: 'manual.middleware.tanstackstart',
status: 'ok',
}),
]),
);
});

test('Sends spans for global request middleware on page load', async ({ page }) => {
const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
transactionEvent?.transaction === 'GET /test-middleware'
);
});

await page.goto('/test-middleware');

const transactionEvent = await transactionEventPromise;

expect(Array.isArray(transactionEvent?.spans)).toBe(true);

// Check for the global request middleware span
expect(transactionEvent?.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'globalRequestMiddleware',
op: 'middleware.tanstackstart',
origin: 'manual.middleware.tanstackstart',
status: 'ok',
}),
]),
);
});

test('Sends spans for server route specific request middleware', async ({ page }) => {
const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
transactionEvent?.transaction === 'GET /api/test-middleware'
);
});

await page.goto('/api/test-middleware');

const transactionEvent = await transactionEventPromise;

expect(Array.isArray(transactionEvent?.spans)).toBe(true);

// Check for the server route specific request middleware span
expect(transactionEvent?.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'serverRouteRequestMiddleware',
op: 'middleware.tanstackstart',
origin: 'manual.middleware.tanstackstart',
status: 'ok',
}),
]),
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,18 @@ test('Sends a server function transaction for a nested server function only if i
]),
);

// Verify that the auto span is the parent of the nested span
const autoSpan = transactionEvent?.spans?.find(
(span: { op?: string; origin?: string }) =>
span.op === 'function.tanstackstart' && span.origin === 'auto.function.tanstackstart.server',
// Verify that the globalFunctionMiddleware span is the parent of the nested span
// (middleware spans are inserted between the auto function span and user code)
const functionMiddlewareSpan = transactionEvent?.spans?.find(
(span: { description?: string; origin?: string }) =>
span.description === 'globalFunctionMiddleware' && span.origin === 'manual.middleware.tanstackstart',
);
const nestedSpan = transactionEvent?.spans?.find(
(span: { description?: string; origin?: string }) =>
span.description === 'testNestedLog' && span.origin === 'manual',
);

expect(autoSpan).toBeDefined();
expect(functionMiddlewareSpan).toBeDefined();
expect(nestedSpan).toBeDefined();
expect(nestedSpan?.parent_span_id).toBe(autoSpan?.span_id);
expect(nestedSpan?.parent_span_id).toBe(functionMiddlewareSpan?.span_id);
});
33 changes: 33 additions & 0 deletions packages/tanstackstart-react/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,36 @@
export * from '@sentry/react';

export { init } from './sdk';

type TanStackMiddleware = {
options: { type: string; server: (...args: unknown[]) => unknown };
middleware: (...args: unknown[]) => unknown;
inputValidator: (...args: unknown[]) => unknown;
client: (...args: unknown[]) => unknown;
server: (...args: unknown[]) => unknown;
};

type MiddlewareWrapperOptions = {
name: string;
};

/**
* No-op stub for client-side builds.
* The actual implementation is server-only, but this stub allows isomorphic code
* that imports these functions to build successfully on the client.
*/
export function wrapMiddlewareWithSentry<T extends TanStackMiddleware>(
middleware: T,
_options: MiddlewareWrapperOptions,
): T {
return middleware;
}

/**
* No-op stub for client-side builds.
* The actual implementation is server-only, but this stub allows isomorphic code
* that imports these functions to build successfully on the client.
*/
export function wrapMiddlewareListWithSentry<T extends TanStackMiddleware>(middlewares: Record<string, T>): T[] {
return Object.values(middlewares);
}
3 changes: 3 additions & 0 deletions packages/tanstackstart-react/src/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegra
export declare const OpenFeatureIntegrationHook: typeof clientSdk.OpenFeatureIntegrationHook;
export declare const statsigIntegration: typeof clientSdk.statsigIntegration;
export declare const unleashIntegration: typeof clientSdk.unleashIntegration;

export declare const wrapMiddlewareWithSentry: typeof serverSdk.wrapMiddlewareWithSentry;
export declare const wrapMiddlewareListWithSentry: typeof serverSdk.wrapMiddlewareListWithSentry;
1 change: 1 addition & 0 deletions packages/tanstackstart-react/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from '@sentry/node';

export { init } from './sdk';
export { wrapFetchWithSentry } from './wrapFetchWithSentry';
export { wrapMiddlewareWithSentry, wrapMiddlewareListWithSentry } from './middleware';

/**
* A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors
Expand Down
87 changes: 87 additions & 0 deletions packages/tanstackstart-react/src/server/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { addNonEnumerableProperty } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/node';

type TanStackMiddleware = {
options?: { server?: (...args: unknown[]) => unknown };
SENTRY_WRAPPED?: boolean;
};

type MiddlewareWrapperOptions = {
name: string;
};

/**
* Wraps a TanStack Start middleware with Sentry instrumentation to create spans.
*
* @example
* ```ts
* import { wrapMiddlewareWithSentry } from '@sentry/tanstackstart-react';
* import { createMiddleware } from '@tanstack/react-start';
*
* const authMiddleware = wrapMiddlewareWithSentry(
* createMiddleware().server(async ({ next }) => {
* // auth logic
* return next();
* }),
* { name: 'authMiddleware' }
* );
* ```
*
* @param middleware - The TanStack Start middleware to wrap
* @param options - Options for the wrapper, including the span name
* @returns The wrapped middleware with Sentry instrumentation
*/
export function wrapMiddlewareWithSentry(
middleware: TanStackMiddleware,
options: MiddlewareWrapperOptions,
): TanStackMiddleware {
if (middleware.SENTRY_WRAPPED) {
return middleware;
}

if (middleware.options?.server) {
middleware.options.server = new Proxy(middleware.options.server, {
apply: (target, thisArg, args) => {
return startSpan(
{
op: 'middleware.tanstackstart',
name: options.name,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual.middleware.tanstackstart',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.tanstackstart',
},
},
() => target.apply(thisArg, args),
);
},
});
}

addNonEnumerableProperty(middleware, 'SENTRY_WRAPPED', true);
return middleware;
}

/**
* Wraps multiple TanStack Start middlewares with Sentry instrumentation.
* Object keys are used as span names to avoid users having to specify this manually.
*
* @example
* ```ts
* import { wrapMiddlewareListWithSentry } from '@sentry/tanstackstart-react';
*
* const wrappedMiddlewares = wrapMiddlewareListWithSentry({
* authMiddleware,
* loggingMiddleware,
* });
*
* createServerFn().middleware(wrappedMiddlewares)
* ```
*
* @param middlewares - An object containing middlewares
* @returns An array of wrapped middlewares
*/
export function wrapMiddlewareListWithSentry(middlewares: Record<string, TanStackMiddleware>): TanStackMiddleware[] {
return Object.entries(middlewares).map(([name, middleware]) => {
return wrapMiddlewareWithSentry(middleware, { name });
});
}
Loading