marketplace-logger
v1.1.1
Published
Structured, isomorphic logging library for the marketplace platform. Runs in Node.js (NestJS backend), Next.js server-side (Server Components, Route Handlers, Middleware), and the browser (Client Components) without any changes to the import.
Downloads
916
Readme
@rush/logging
Structured, isomorphic logging library for the marketplace platform. Runs in Node.js (NestJS backend), Next.js server-side (Server Components, Route Handlers, Middleware), and the browser (Client Components) without any changes to the import.
Installation
pnpm i marketplace-loggerCore concepts
Every log entry is a JSON object written to stdout (Node.js) or console.log (browser) with a fixed shape that log aggregation pipelines (Datadog, CloudWatch, etc.) can parse reliably.
{
"level": "INFO",
"category": "Payment",
"timestamp": "2026-04-29T14:32:00.000Z",
"caller_function": "PaymentService.createIntent",
"step": "stripe_call",
"message": "Creating Stripe PaymentIntent",
"traceId": "a1b2c3d4-...",
"service": "marketplace-backend",
"duration": 142,
"metadata": { "amount": 50000, "currency": "usd" }
}The library is built around three primitives:
| Export | What it does |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| BaseStructuredLogger | Base class — extend it to create your logger; constructor takes serviceName, a traceIdResolver function, and an optional writer |
| traceStorage | Isomorphic wrapper over AsyncLocalStorage — propagates traceId across async calls on the server; no-op stub in the browser |
| startTimer() | Returns a function that, when called, returns elapsed milliseconds — use it to populate duration |
Quick start
1. Extend BaseStructuredLogger
You never instantiate BaseStructuredLogger directly. Create a subclass that provides:
- The service name — identifies the emitting application in log aggregators
- A
traceIdResolver— a function returning the currenttraceIdornull - (Optional) A
writer— defaults toprocess.stdout.write; override for browser environments
import { BaseStructuredLogger, traceStorage } from '@org/logging';
class MyLogger extends BaseStructuredLogger {
constructor() {
super('my-service', () => traceStorage.getStore()?.traceId ?? null);
}
}
export const logger = new MyLogger();2. Log something
import { LogCategory } from '@org/logging';
logger.info({
category: LogCategory.Payment,
callerFunction: 'CheckoutService.createOrder',
step: 'validate_cart',
message: 'Cart validation passed',
metadata: { itemCount: 3 },
});
logger.error({
category: LogCategory.Blockchain,
callerFunction: 'TokenService.deliver',
step: 'static_call',
message: 'Simulation failed',
metadata: { reason: 'insufficient balance' },
});
logger.critical({
category: LogCategory.Blockchain,
callerFunction: 'TokenService.deliver',
step: 'onchain_transfer',
message: 'Transfer halted due to unrecoverable error',
metadata: { error: new Error('nonce too low') },
});3. Measure duration
import { startTimer, LogCategory } from '@org/logging';
const elapsed = startTimer();
await stripe.paymentIntents.create({ ... });
logger.info({
category: LogCategory.Payment,
callerFunction: 'PaymentService.createIntent',
step: 'stripe_api_call',
message: 'PaymentIntent created',
duration: elapsed(), // milliseconds
});API reference
BaseStructuredLogger<TCategory>
class BaseStructuredLogger<TCategory extends string = string> {
constructor(
serviceName: string,
traceIdResolver: () => string | null,
writer?: (json: string) => void, // default: process.stdout.write
environment?: AppEnvironment, // default: "production"
);
info(params: LogParams<TCategory>): void;
debug(params: LogParams<TCategory>): void; // no-op when environment === "production"
warn(params: LogParams<TCategory>): void;
error(params: LogParams<TCategory>): void;
critical(params: LogParams<TCategory>): void;
}AppEnvironment
type AppEnvironment = "development" | "staging" | "production";Controls whether debug() calls are emitted. When environment is "production", calls to debug() are silently dropped. Any other value enables them. The default is "production" — if omitted, debug logs are suppressed.
The generic parameter `TCategory` constrains which category values the logger accepts. It defaults to `string` (permissive). Pass a narrower type to get compile-time exhaustiveness — see [Extending LogCategory](#extending-logcategory).
### `LogParams<TCategory>`
```ts
interface LogParams<TCategory extends string = LogCategory> {
category: TCategory; // required — domain classification
callerFunction: string; // required — "ClassName.methodName"
step: string; // required — granular step within the function
message: string; // required — human-readable description
duration?: number; // optional — milliseconds, use with startTimer()
metadata?: Record<string, unknown>; // optional — any structured context
}
Metadata notes:
- `Error` values inside `metadata` are serialized to `{ name, message, stack, cause? }`.LogLevel
enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARNING = 'WARNING',
ERROR = 'ERROR',
CRITICAL = 'CRITICAL',
}LogCategory
LogCategory is a const object (not an enum), which makes it spreadable and therefore extensible by consumers — see Extending LogCategory.
const LogCategory = {
// Shared — backend + frontend
Blockchain: 'Blockchain',
BuyBack: 'BuyBack',
P2P: 'P2P',
Wallet: 'Wallet',
Payment: 'Payment',
Timing: 'Timing',
// Frontend
UserInteraction: 'UserInteraction', // clicks, form submissions, CTAs
Navigation: 'Navigation', // route changes, page views
ApiCall: 'ApiCall', // HTTP/WS calls from the client
ClientError: 'ClientError', // uncaught exceptions, error boundaries
Auth: 'Auth', // login, logout, token refresh
WalletConnection: 'WalletConnection', // connect/disconnect, network switch
Marketplace: 'Marketplace', // NFT/listing views, search, filters
Checkout: 'Checkout', // cart, checkout steps, order confirmation
UI: 'UI', // toasts, modals, notifications
Experiment: 'Experiment', // feature flags, A/B tests
} as const;
type LogCategory = typeof LogCategory[keyof typeof LogCategory];traceStorage
const traceStorage: {
getStore(): TraceContext | undefined;
run<T>(store: TraceContext, fn: () => T): T;
};
interface TraceContext {
traceId: string;
}On Node.js this is backed by AsyncLocalStorage — any getStore() call inside the run() callback (and anywhere down the async call chain) will return the stored context. On the browser it is a no-op stub: run() calls fn() immediately and getStore() always returns undefined.
startTimer
function startTimer(): () => number;Captures performance.now() at call time. The returned function computes Math.round(now - start) in milliseconds. Works in both Node.js and browser.
Integration guides
NestJS
Create a logger service and a middleware that seeds the trace context at request boundaries. Neither file belongs in this library — they live in the backend repo.
structured-logger.service.ts
import { Injectable } from '@nestjs/common';
import { BaseStructuredLogger, traceStorage } from '@org/logging';
import type { AppEnvironment } from '@org/logging';
@Injectable()
export class StructuredLoggerService extends BaseStructuredLogger {
constructor() {
super(
'marketplace-backend',
() => traceStorage.getStore()?.traceId ?? null,
undefined,
process.env.APP_ENV as AppEnvironment ?? 'production',
);
}
}trace-id.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
import { traceStorage } from '@org/logging';
@Injectable()
export class TraceIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const traceId = (req.headers['x-trace-id'] as string) ?? randomUUID();
res.setHeader('x-trace-id', traceId);
traceStorage.run({ traceId }, () => next());
}
}logging.module.ts
import { Global, MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { StructuredLoggerService } from './structured-logger.service';
import { TraceIdMiddleware } from './trace-id.middleware';
@Global()
@Module({
providers: [StructuredLoggerService],
exports: [StructuredLoggerService],
})
export class LoggingModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(TraceIdMiddleware).forRoutes('*');
}
}Inject StructuredLoggerService anywhere in the application — because the module is @Global(), no need to import LoggingModule in each feature module.
Next.js (App Router)
Next.js runs code in three distinct environments. Use a different logger instance for each.
Next.js Middleware — propagate traceId as a header
The Edge Runtime does not support AsyncLocalStorage. Propagate the trace ID via a request header so Server Components can read it.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest) {
const traceId = req.headers.get('x-trace-id') ?? crypto.randomUUID();
const res = NextResponse.next();
res.headers.set('x-trace-id', traceId);
res.headers.set('x-forwarded-trace-id', traceId); // readable by Server Components
return res;
}Server Components and Route Handlers
// lib/logger.server.ts
import { BaseStructuredLogger, traceStorage } from '@org/logging';
import type { AppEnvironment } from '@org/logging';
import { headers } from 'next/headers';
class NextServerLogger extends BaseStructuredLogger {
constructor() {
super(
'marketplace-frontend',
() =>
traceStorage.getStore()?.traceId ??
headers().get('x-forwarded-trace-id') ??
null,
undefined,
process.env.APP_ENV as AppEnvironment ?? 'production',
);
}
}
export const logger = new NextServerLogger();// app/api/properties/route.ts
import { logger } from '@/lib/logger.server';
import { LogCategory } from '@org/logging';
export async function GET() {
logger.info({
category: LogCategory.Payment,
callerFunction: 'GET /api/properties',
step: 'handler_start',
message: 'Fetching property list',
});
}Client Components
The writer is overridden to use console.log because process.stdout is not available in the browser. The traceId is read from sessionStorage, where it is seeded by the root layout (see below).
// lib/logger.client.ts
import { BaseStructuredLogger } from '@org/logging';
import type { AppEnvironment } from '@org/logging';
class NextClientLogger extends BaseStructuredLogger {
constructor() {
super(
'marketplace-frontend-client',
() => sessionStorage.getItem('traceId'),
(json) => console.log(json),
process.env.NEXT_PUBLIC_APP_ENV as AppEnvironment ?? 'production',
);
}
}
export const clientLogger = new NextClientLogger();// app/components/BuyButton.tsx
'use client';
import { clientLogger } from '@/lib/logger.client';
import { LogCategory } from '@org/logging';
export function BuyButton() {
const handleClick = () => {
clientLogger.info({
category: LogCategory.Payment,
callerFunction: 'BuyButton.handleClick',
step: 'user_interaction',
message: 'User initiated purchase',
});
};
return <button onClick={handleClick}>Buy</button>;
}Seeding traceId from server into the browser
Add this to your root layout so that client-side logs share the same traceId as the server-side logs for the same page load:
// app/layout.tsx
import { headers } from 'next/headers';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const traceId = headers().get('x-forwarded-trace-id') ?? '';
return (
<html>
<body>
<script
dangerouslySetInnerHTML={{
__html: `sessionStorage.setItem('traceId','${traceId}')`,
}}
/>
{children}
</body>
</html>
);
}Log output format
All log entries are emitted as a single-line JSON string. The fields are fixed — do not change field names without coordinating with log aggregation pipelines.
| Field | Type | Always present | Description |
| ----------------- | ---------------- | -------------- | ------------------------------------------------- |
| level | LogLevel | ✅ | DEBUG | INFO | WARNING | ERROR |
| category | LogCategory | ✅ | Domain classification |
| timestamp | string | ✅ | ISO 8601 UTC |
| caller_function | string | ✅ | "ClassName.methodName" |
| step | string | ✅ | Granular step within the function |
| message | string | ✅ | Human-readable description |
| traceId | string \| null | ✅ | Request trace ID; null if no context |
| service | string | ✅ | Emitting application name |
| duration | number | ❌ | Elapsed milliseconds — only when provided |
| metadata | object | ❌ | Arbitrary structured context — only when provided |
Extending LogCategory
LogCategory is a const object, so you can spread it and add your own values. The logger's generic parameter TCategory then constrains the allowed categories to exactly your extended set — no string escape hatch, full autocomplete.
1. Define your extended categories
// lib/log-category.ts
import { LogCategory } from '@org/logging';
export const AppLogCategory = {
...LogCategory,
// Add your own
NFT: 'NFT',
Analytics: 'Analytics',
LiveAuction: 'LiveAuction',
} as const;
export type AppLogCategory = typeof AppLogCategory[keyof typeof AppLogCategory];2. Create a typed logger subclass
// lib/logger.client.ts
import { BaseStructuredLogger } from '@org/logging';
import type { AppEnvironment } from '@org/logging';
class AppLogger extends BaseStructuredLogger<AppLogCategory> {
constructor() {
super(
'marketplace-frontend',
() => sessionStorage.getItem('traceId'),
(json) => console.log(json),
process.env.NEXT_PUBLIC_APP_ENV as AppEnvironment ?? 'production',
);
}
}
export const logger = new AppLogger();3. Use it — TypeScript enforces only valid categories
import { logger } from '@/lib/logger.client';
import { AppLogCategory } from '@/lib/log-category';
// ✅ Built-in categories still work
logger.info({ category: AppLogCategory.Payment, ... });
// ✅ Your custom categories work
logger.info({ category: AppLogCategory.NFT, ... });
logger.info({ category: AppLogCategory.LiveAuction, ... });
// ❌ Compile error — typos and unknown categories are caught
logger.info({ category: 'Inventado', ... });Note: if you do not pass a type parameter to
BaseStructuredLogger, the default isstring— the logger accepts any string ascategory, which is permissive but loses type safety. Always pass your category type when creating a subclass.
Stability guarantees
The following are considered breaking changes and will be released as a major version:
- Any change to
StructuredLogEntryfield names (log aggregation pipelines depend on these) - Any change to the
BaseStructuredLoggerconstructor signature - Removing or renaming any export from the public
index.ts - Removing or renaming any existing key in
LogCategoryor any value inLogLevel
Contributing
pnpm install
pnpm build # compile to dist/
pnpm test # run unit tests
pnpm lint # TypeScript type checkCommits follow Conventional Commits. Versioning follows semver.
