Logging Guide
This project uses Pino for structured logging. Pino is a fast, low-overhead logging library that outputs JSON logs in production and human-readable logs in development.
Quick Start
import logger from "@/lib/utils";
// Basic logging
logger.info("User logged in");
logger.error("Database connection failed");
logger.warn("Deprecation warning");
logger.debug("Debugging information");
// Structured logging with context
logger.info({ userId: "123", action: "login" }, "User logged in");
logger.error({ err: error, userId: "123" }, "Failed to update user");
Log Levels
Logs are filtered by level based on the LOG_LEVEL environment variable:
- fatal (60): Application crash
- error (50): Error occurred, but application continues
- warn (40): Warning messages
- info (30): General information (default in production)
- debug (20): Debugging information (default in development)
- trace (10): Very detailed debugging
Configuration:
- Development:
LOG_LEVEL=debug(default) - Production:
LOG_LEVEL=info(default)
Set in your .env file:
LOG_LEVEL=debug
Structured Logging
Always include relevant context in your logs:
// ❌ Bad - No context
logger.error("Failed to update activity");
// ✅ Good - With context
logger.error(
{
err: error,
activityId: "act-123",
userId: "user-456",
organizationId: "org-789"
},
"Failed to update activity"
);
Common Context Fields
errorerror: Error object (automatically serialized)userId: User performing the actionactivityId,ropaId,organizationId: Entity IDsoperation: Operation name (e.g., "updateActivity", "deleteUser")duration: Operation duration in msrequestId: Request identifier (for tracing)
Helper Functions
logError(operation, error, context)
Convenience function for logging errors:
import { logError } from "@/lib/logger";
try {
await updateActivity(activityId);
} catch (error) {
logError("updateActivity", error, { activityId, userId });
// ... handle error
}
logOperation(operation, context, message)
Log successful operations:
import { logOperation } from "@/lib/logger";
logOperation(
"updateActivity",
{ activityId: "123", userId: "456" },
"Activity updated successfully"
);
createChildLogger(context)
Create a child logger with persistent context:
import { createChildLogger } from "@/lib/logger";
// In a request handler or service function
const requestLogger = createChildLogger({
requestId: "req-123",
userId: "user-456"
});
requestLogger.info("Processing request");
requestLogger.info({ activityId: "act-789" }, "Activity loaded");
// Both logs will include requestId and userId automatically
Examples
Service Layer
// src/services/activityService.js
import logger, { logError } from "@/lib/logger";
export const moveActivityToNewOu = async ({
ropaId,
activityId,
oldOuId,
newOuId,
}) => {
logger.debug(
{ ropaId, activityId, oldOuId, newOuId },
"Moving activity to new OU"
);
try {
// ... business logic
logger.info(
{ ropaId, activityId, oldOuId, newOuId },
"Activity moved successfully"
);
return ok(result, "Activity moved successfully");
} catch (error) {
logError("moveActivityToNewOu", error, {
ropaId,
activityId,
oldOuId,
newOuId
});
return fail(error.message);
}
};
Database Layer
// src/services/mongodb.js
import logger from "@/lib/logger";
mongoose.connection.on("error", (err) =>
logger.error({ err }, "MongoDB connection error")
);
mongoose.connection.once("connected", () => {
logger.info(
{ connectionState: mongoose.connection.readyState },
"MongoDB connected (singleton)"
);
});
Server Actions
// Server action with logging
"use server";
import logger from "@/lib/logger";
export async function updateActivityAction(formData) {
const activityId = formData.get("activityId");
logger.debug({ activityId }, "Updating activity from form");
try {
const result = await updateActivity(activityId, formData);
logger.info({ activityId, result }, "Activity updated via form");
return { success: true };
} catch (error) {
logger.error(
{ err: error, activityId },
"Failed to update activity from form"
);
return { success: false, error: error.message };
}
}
Performance Tips
- Use appropriate log levels: Debug logs are filtered out in production
- Avoid logging in hot loops: Logging has overhead, even with Pino
- Don't log sensitive data: PII, passwords, tokens, etc.
- Keep context objects small: Only include relevant fields
Production Setup
Output Format
In production, Pino outputs JSON logs:
{
"level": 50,
"time": 1703001234567,
"pid": 12345,
"hostname": "server-1",
"env": "production",
"ropaId": "ropa-123",
"activityId": "act-456",
"userId": "user-789",
"err": {
"type": "Error",
"message": "Failed to update activity",
"stack": "Error: Failed to update activity\n at ..."
},
"msg": "Failed to update activity"
}
Log Aggregation
JSON logs can be easily ingested by:
- Datadog: Use the Datadog agent
- Elasticsearch/Kibana: Use Filebeat or Logstash
- CloudWatch: Use the CloudWatch agent
- Sentry: Use Sentry's Pino transport
- LogRocket: Use LogRocket's Pino integration
Example with Sentry transport:
// src/lib/logger.js
import pino from "pino";
const logger = pino({
// ... existing config
transport: {
targets: [
{
target: "pino-pretty", // Development
level: "debug",
},
{
target: "pino-sentry-transport", // Production errors
level: "error",
options: {
sentry: {
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
},
},
},
],
},
});
Best Practices
- Log at boundaries: Service entry/exit, external calls, database operations
- Include correlation IDs: Request IDs, user IDs, entity IDs
- Log business events: User actions, state changes, important operations
- Don't log everything: Avoid noise, focus on actionable information
- Use child loggers: For request-scoped or operation-scoped context
- Test your logs: Verify logs in development before deploying
Troubleshooting
Logs not appearing in development
Check that NODE_ENV is not set to production:
echo $NODE_ENV
Too many/few logs
Adjust LOG_LEVEL:
# Show all logs
LOG_LEVEL=trace npm run dev
# Show only errors
LOG_LEVEL=error npm run dev
Client-side logging
The logger automatically falls back to console.* methods in the browser. Client-side logs are not structured and should be used sparingly.
For better client-side error tracking, consider using:
- Sentry (recommended)
- LogRocket
- Datadog RUM
Toast Logging (src/lib/ui/incidents/toasts.js)
Client components should never call toast.error or toast.success directly. Instead use the wrappers from @/lib/ui/incidents, which combine the visible notification with logging in one call:
| Function | Toast type | Pino level | Server log |
|---|---|---|---|
toastError | toast.error | error | ✅ via logToServer |
toastSuccess | toast.success | info | ✅ via logToServer |
Both functions fire a client-side pino call and invoke the logToServer Next.js Server Action so the event is captured in the structured server log (JSON in production).
toastError({ message, err?, logMessage? })
import { toastError } from "@/lib/ui/incidents";
// Simple string message
toastError({ message: result.message || "Save failed" });
// With originating error object (attached to the pino log entry as { err })
toastError({ message: error.message, err: error });
// JSX toast content — logMessage provides the plain string for the log
toastError({
message: <div style={{ textAlign: "center" }}>{t("changeOuError")}</div>,
logMessage: t("changeOuError"),
});
toastSuccess({ message, logMessageOrOptions?, toastOptions? })
The second prop is overloaded: a string is treated as a log-message override (for JSX content); an object is passed through as react-hot-toast options.
import { toastSuccess } from "@/lib/ui/incidents";
// Simple string message
toastSuccess({ message: t("saveSuccess") });
// With react-hot-toast style options (second prop is an object → treated as options)
toastSuccess({
message: t("langUpdateSuccess"),
logMessageOrOptions: { style: { textAlign: "center", whiteSpace: "pre-line" } },
});
// JSX toast with both a log string and options
toastSuccess({
message: <div style={{ textAlign: "center" }}>{t("changeOuSuccess")}</div>,
logMessageOrOptions: t("changeOuSuccess"), // string → used as log message
toastOptions: { duration: 4000 },
});
logToServer (internal)
logToServer is a Next.js "use server" action called fire-and-forget by both wrappers. It routes the event through the server-side Pino instance so it appears in structured production logs. Do not call it directly — go through toastError / toastSuccess.
Client component
└─ toastError / toastSuccess
├─ toast.error / toast.success → visible notification
├─ logger.error / logger.info → client-side pino (console in browser)
└─ logToServer(level, msg) → server-side pino (structured JSON)
Migration Checklist
When migrating existing code to use the new logger:
- Replace
console.logwithlogger.info - Replace
console.errorwithlogger.error - Replace
console.warnwithlogger.warn - Replace
console.debugwithlogger.debug - Add structured context to all logs
- Use
logError()helper in catch blocks - Add operation logging for important business logic
- Remove or update old logging comments
- Replace
toast.error(...)withtoastError({ message, err? })from@/lib/ui/incidents - Replace
toast.success(...)withtoastSuccess({ message })from@/lib/ui/incidents