Portal implements wide events (canonical log lines) for powerful debugging and analytics. This pattern emits one context-rich event per request per service, enabling queryable logs with high cardinality and dimensionality.
Emit one context-rich event per request per service. Instead of scattering log lines throughout your handler, consolidate everything into a single structured event emitted at request completion.
Include fields with high cardinality (user IDs, request IDs - millions of unique values) and high dimensionality (many fields per event). This enables querying by specific users and answering questions you haven't anticipated yet.
Always include business context: user subscription tier, cart value, feature flags, account age. The goal is to know "a premium customer couldn't complete a $2,499 purchase" not just "checkout failed."
Environment context (commit hash, version, service name, region) is automatically included in all logs via the logger. No manual addition needed.
Use the withWideEvent wrapper to automatically handle wide event creation, timing, and emission:
import { withWideEvent, enrichWideEventWithUser, type WideEvent } from "@/shared/observability";
import { requireAuth } from "@/shared/api/utils";
export const GET = withWideEvent(
async (request: NextRequest, event: WideEvent) => {
const { userId, session } = await requireAuth(request);
// Enrich event with user context (high cardinality)
enrichWideEventWithUser(event, {
id: userId,
email: session.user.email,
role: session.user.role,
});
// Add business context as you process the request
const data = await getData(userId);
event.data_count = data.length;
event.has_premium = session.user.role === "premium";
return Response.json({ data });
}
);If you need more control, you can manually create and emit wide events:
import {
createWideEvent,
emitWideEvent,
enrichWideEventWithError,
enrichWideEventWithUser,
} from "@/shared/observability";
export async function POST(request: NextRequest) {
const startTime = Date.now();
const event = createWideEvent(request);
try {
const { userId } = await requireAuth(request);
enrichWideEventWithUser(event, { id: userId });
// Add business context
const cart = await getCart(userId);
event.cart = {
total_cents: cart.total,
item_count: cart.items.length,
};
const order = await createOrder(cart);
event.order_id = order.id;
event.status_code = 201;
event.outcome = "success";
return Response.json({ order }, { status: 201 });
} catch (error) {
enrichWideEventWithError(event, error);
event.status_code = 500;
event.outcome = "error";
throw error;
} finally {
event.duration_ms = Date.now() - startTime;
emitWideEvent(event);
}
}The logger automatically includes environment context in every log entry:
commit_hash: Git commit SHAversion: Service version (from SENTRY_RELEASE or package.json)service: Service name (defaults to "portal")environment: NODE_ENVnode_version: Node.js versionregion,instance_id, etc.: Infrastructure details (if available)
interface WideEvent {
// Request identification (high cardinality)
request_id: string;
timestamp: string;
// HTTP request context
method: string;
path: string;
pathname?: string;
user_agent?: string;
ip?: string;
// Response context
status_code?: number;
outcome?: "success" | "error";
duration_ms?: number;
// Error context (if applicable)
error?: {
type: string;
message: string;
stack?: string;
};
// Business context (enriched by handlers)
user?: {
id: string;
email?: string;
role?: string;
};
// Additional context (enriched by handlers)
[key: string]: unknown;
}- Use
withWideEventwrapper for API route handlers - Include high cardinality fields (user_id, request_id)
- Include business context (subscription tier, cart value, feature flags)
- Add context as you process the request
- Let the wrapper handle timing and emission
- Scatter multiple
log.info()calls throughout a handler - Log unstructured strings:
log.info("something happened") - Manually add environment context (it's automatic)
- Skip business context - technical logs alone aren't enough
export const PATCH = withWideEvent(
async (request: NextRequest, event: WideEvent) => {
const { userId, session } = await requireAuth(request);
enrichWideEventWithUser(event, {
id: userId,
email: session.user.email,
role: session.user.role,
});
const body = await request.json();
event.update_fields = Object.keys(body);
const updated = await updateUser(userId, body);
event.update_successful = true;
event.name_updated = body.name !== undefined;
return Response.json({ user: updated });
}
);export const GET = withWideEvent(
async (request: NextRequest, event: WideEvent) => {
await requireAdminOrStaff(request);
const { searchParams } = new URL(request.url);
const role = searchParams.get("role");
const search = searchParams.get("search");
// Add query context
event.filter_role = role || null;
event.filter_search = search || null;
const users = await getUsers({ role, search });
event.users_count = users.length;
event.has_results = users.length > 0;
return Response.json({ users });
}
);Request IDs are automatically generated and can be propagated to downstream services:
// In your route handler
const event = createWideEvent(request);
const requestId = event.request_id;
// When calling downstream services
await fetch("https://api.example.com/data", {
headers: {
"x-request-id": requestId, // Propagate the ID
},
});