Remix Application Logging Integration
Send structured logs from Remix loaders and actions to LogTide with server-side request tracing and error boundary capture.
Remix runs loaders and actions on the server, making it a natural fit for structured logging. This guide shows you how to add LogTide to Remix for automatic request tracing, loader/action logging, and error boundary capture.
Why use LogTide with Remix?
- Server-side first: Loaders and actions run on the server — log them with full context
- Request tracing: Correlate logs across nested route loaders in a single page load
- Error boundaries: Capture errors with the route context that triggered them
- Action audit trail: Log form submissions and mutations with user context
- Non-blocking: Async batching keeps loader response times fast
Prerequisites
- Node.js 18+ or 20+
- Remix 2.x
- LogTide instance with a DSN or API key
Installation
npm install @logtide/sdk-node
Quick Start
Initialize LogTide
// app/lib/logtide.server.ts
import { LogTideClient } from '@logtide/sdk-node';
export const logtide = new LogTideClient({
apiUrl: process.env.LOGTIDE_API_URL!,
apiKey: process.env.LOGTIDE_API_KEY!,
globalMetadata: { environment: process.env.NODE_ENV },
});
The .server.ts suffix ensures this module is never bundled into client code.
Use in Loaders
// app/routes/users.$id.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { logtide } from '~/lib/logtide.server';
export async function loader({ params, request }: LoaderFunctionArgs) {
const traceId = request.headers.get('x-trace-id') ?? crypto.randomUUID();
logtide.info('remix-app', 'Loading user profile', {
userId: params.id,
traceId,
});
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) {
logtide.warn('remix-app', 'User not found', { userId: params.id, traceId });
throw new Response('Not found', { status: 404 });
}
return json({ user }, { headers: { 'X-Trace-Id': traceId } });
}
Use in Actions
// app/routes/settings.tsx
import { redirect, type ActionFunctionArgs } from '@remix-run/node';
import { logtide } from '~/lib/logtide.server';
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request);
const userId = session.get('userId');
const formData = await request.formData();
logtide.info('remix-app', 'Settings updated', {
userId,
fields: [...formData.keys()],
});
await updateSettings(userId, Object.fromEntries(formData));
return redirect('/settings');
}
Request Tracing Middleware
For automatic logging of all requests, create a middleware using Remix’s entry.server.tsx:
// app/entry.server.tsx
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
import { renderToPipeableStream } from 'react-dom/server';
import { logtide } from '~/lib/logtide.server';
const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: any,
) {
const url = new URL(request.url);
const start = performance.now();
const traceId = request.headers.get('x-trace-id') ?? crypto.randomUUID();
// Skip static assets
if (url.pathname.startsWith('/build/') || url.pathname === '/favicon.ico') {
return defaultHandler(request, responseStatusCode, responseHeaders, remixContext);
}
return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
{
onShellReady() {
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
responseHeaders.set('X-Trace-Id', traceId);
const durationMs = Math.round(performance.now() - start);
logtide.info('remix-app', `${request.method} ${url.pathname} ${responseStatusCode}`, {
method: request.method,
path: url.pathname,
statusCode: responseStatusCode,
durationMs,
traceId,
});
resolve(new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}));
pipe(body);
},
onShellError(error) {
logtide.error('remix-app', 'Remix shell render error', {
error: String(error),
path: url.pathname,
traceId,
});
reject(error);
},
onError(error) {
responseStatusCode = 500;
logtide.error('remix-app', 'Remix render error', {
error: String(error),
path: url.pathname,
traceId,
});
},
},
);
setTimeout(abort, ABORT_DELAY);
});
}
Error Boundary Logging
Root Error Boundary
// app/root.tsx
import { isRouteErrorResponse, useRouteError } from '@remix-run/react';
export function ErrorBoundary() {
const error = useRouteError();
// Server-side logging happens in entry.server.tsx
// This component handles the client-side rendering
if (isRouteErrorResponse(error)) {
return (
<div className="error-page">
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div className="error-page">
<h1>Something went wrong</h1>
<p>Please try again later.</p>
</div>
);
}
Server-Side Error Logging
Catch errors in loaders/actions with a utility:
// app/lib/with-logging.server.ts
import { logtide } from '~/lib/logtide.server';
export function withLogging<T>(
routeName: string,
handler: (args: any) => Promise<T>,
) {
return async (args: any) => {
const start = performance.now();
const traceId = args.request.headers.get('x-trace-id') ?? crypto.randomUUID();
try {
const result = await handler(args);
const durationMs = Math.round(performance.now() - start);
logtide.info('remix-app', `${routeName} completed`, { durationMs, traceId });
return result;
} catch (error) {
logtide.error('remix-app', `${routeName} failed`, {
error: error instanceof Error ? error.message : String(error),
errorType: error instanceof Error ? error.constructor.name : 'Unknown',
traceId,
});
throw error;
}
};
}
// Usage
export const loader = withLogging('users.$id', async ({ params }: LoaderFunctionArgs) => {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) throw new Response('Not found', { status: 404 });
return json({ user });
});
Session and Auth Context
// app/lib/logtide.server.ts (extended)
import { type Session } from '@remix-run/node';
export function logWithSession(session: Session) {
const userId = session.get('userId');
const role = session.get('role');
return {
info(message: string, metadata?: Record<string, unknown>) {
logtide.info('remix-app', message, { userId, role, ...metadata });
},
warn(message: string, metadata?: Record<string, unknown>) {
logtide.warn('remix-app', message, { userId, role, ...metadata });
},
error(message: string, metadata?: Record<string, unknown>) {
logtide.error('remix-app', message, { userId, role, ...metadata });
},
};
}
// Usage in loader/action
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request);
const log = logWithSession(session);
log.info('Deleting account', { reason: 'user_requested' });
// ...
}
Docker Deployment
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/build ./build
COPY --from=build /app/public ./public
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev
ENV NODE_ENV=production
ENV LOGTIDE_API_URL=""
ENV LOGTIDE_API_KEY=""
EXPOSE 3000
CMD ["npm", "start"]
# docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- LOGTIDE_API_URL=${LOGTIDE_API_URL}
- LOGTIDE_API_KEY=${LOGTIDE_API_KEY}
- SESSION_SECRET=${SESSION_SECRET}
Performance
| Metric | Value |
|---|---|
| Loader overhead | <0.5ms per log call |
| Memory overhead | ~8MB |
| Batch network calls | 1 per 100 logs |
| SSR impact | Negligible (async shipping) |
Troubleshooting
Logs not appearing
- Ensure
logtide.server.tshas the.server.tssuffix — without it, the client bundle may try to import server-only code - Check credentials are set:
echo $LOGTIDE_API_URLandecho $LOGTIDE_API_KEY - Add
await logtide.close()to your server’s graceful shutdown handler
”Cannot use import statement outside a module”
Ensure your tsconfig.json has "module": "ESNext" and your Remix config supports ESM.
Logs from nested loaders appear out of order
Remix runs nested route loaders in parallel. Use the traceId to correlate them:
traceId:abc123 → root loader
traceId:abc123 → users layout loader
traceId:abc123 → users.$id loader
Next Steps
- Node.js SDK Reference - Full SDK documentation
- Docker Integration - Container deployments
- Incident Response - Production debugging with traces
Frequently Asked Questions
How do I add LogTide logging to a Remix application?
Install @logtide/sdk-node, create a logtide.server.ts file using the .server.ts suffix so it is excluded from the client bundle, initialize LogTideClient with your apiUrl and apiKey, then call logtide.info or logtide.error directly inside your loader and action functions.
Do I need to change every loader and action to get request logging?
No. You can add automatic request logging to all routes by instrumenting entry.server.tsx, which captures the method, path, status code, and duration for every server-rendered request. For per-route control the guide also provides a withLogging wrapper you can apply selectively.
How does LogTide handle logs from nested Remix route loaders?
Remix runs nested route loaders in parallel, so logs can appear out of order. The recommended pattern is to generate or propagate a traceId from the x-trace-id request header in each loader and include it in every log call, allowing you to filter by traceId in LogTide to reconstruct a full page load.
What is the performance overhead of LogTide in a Remix application?
Each log call adds under 0.5 milliseconds of overhead, memory usage increases by approximately 8 MB, and logs are shipped in batches of up to 100 per network request using async shipping so SSR response times are not affected.