Structured Logs

Use @nebutra/logger for structured, tenant-aware JSON logging across the Nebutra stack.

@nebutra/logger is Nebutra's structured logging library. It writes newline-delimited JSON to stdout, automatically injects tenant context, and forwards warn and error level events to Sentry as breadcrumbs and captured exceptions.

Why Not console.log?

console.log produces unstructured strings that are difficult to search, parse, and correlate. @nebutra/logger gives you:

  • Structured JSON — every log line is a parseable object with consistent fields
  • Tenant contexttenantId, orgId, and userId are injected automatically
  • Log levels — filter noise in production by setting LOG_LEVEL=warn
  • Sentry integrationwarn and error logs become Sentry breadcrumbs; uncaught errors become Sentry issues
  • Zero console.log in production — enforced by the project's ESLint rules

Never use console.log, console.warn, or console.error in application code. Use @nebutra/logger instead. The project's Stop hook will flag console.log in any modified file before the session ends.

Basic Usage

import { logger } from "@nebutra/logger";

// Information — routine operational events
logger.info("Project created", { projectId, tenantId, userId });

// Warning — abnormal but recoverable situations
logger.warn("Quota approaching limit", {
  tenantId,
  used: 8100,
  limit: 10000,
  percentage: 81,
});

// Error — failures that require attention
logger.error("Payment processing failed", {
  error,
  invoiceId,
  tenantId,
});

Log Levels

LevelWhen to UseSentry Behaviour
traceVerbose debugging (loops, low-level I/O)Not forwarded
debugDeveloper debugging detailsNot forwarded
infoNormal operational events (created, updated, sent)Not forwarded
warnUnexpected but recoverable conditionsSentry breadcrumb
errorFailures that affect functionalitySentry breadcrumb + captured exception
fatalUnrecoverable failures requiring immediate attentionSentry event (high severity)

Configuring the Active Level

Set LOG_LEVEL to control the minimum level written to stdout:

LOG_LEVEL=debug   # development — verbose
LOG_LEVEL=info    # staging — normal
LOG_LEVEL=warn    # production — warnings and above only

The default is info when LOG_LEVEL is unset.

Structured Log Format

Each log line is a JSON object with the following fields:

{
  "level": "info",
  "time": "2026-03-31T12:34:56.789Z",
  "msg": "Project created",
  "projectId": "proj_abc123",
  "tenantId": "org_xyz789",
  "userId": "user_def456",
  "service": "api-gateway",
  "env": "production",
  "release": "v2.4.1"
}

service, env, and release are injected automatically from environment variables. You only need to supply the domain-specific fields.

Tenant Context Injection

When called from within a request that has passed through Nebutra's tenant middleware, logger automatically reads the current tenant context from AsyncLocalStorage and adds tenantId, orgId, and userId to every log line. You do not need to pass these manually:

// ✅ Preferred — tenant context is injected automatically
logger.info("Invoice generated", { invoiceId, amount });

// The emitted JSON will include tenantId and userId from the request context
// {
//   "msg": "Invoice generated",
//   "invoiceId": "inv_xxx",
//   "amount": 99.99,
//   "tenantId": "org_yyy",   ← injected automatically
//   "userId": "user_zzz"     ← injected automatically
// }

If you need to log outside of a request context (e.g. a cron job), pass tenant identifiers explicitly:

logger.info("Scheduled report generated", {
  reportId,
  tenantId: job.tenantId,
});

Error Logging

Always pass the Error object directly. The logger serialises the message, name, stack, and any additional properties:

try {
  await stripe.invoices.pay(invoiceId);
} catch (error) {
  logger.error("Stripe invoice payment failed", {
    error,
    invoiceId,
    tenantId,
  });
  throw new Error("Payment processing failed — please try again");
}

logger.error automatically calls Sentry.captureException(error) with the extra properties as context. The error will appear in Sentry's Issues tab within seconds.

Sentry Integration Detail

The logger's Sentry integration works as follows:

Logger callSentry behaviour
logger.warn(msg, ctx)Sentry.addBreadcrumb({ level: "warning", message: msg, data: ctx })
logger.error(msg, { error, ...ctx })Sentry.addBreadcrumb(...) + Sentry.captureException(error, { extra: ctx })
logger.fatal(msg, { error, ...ctx })Same as error but with level: "fatal" in Sentry

This means Sentry issues always include the breadcrumb trail of warn events that preceded the error, giving full context for debugging.

Querying Logs

In Sentry

All warn and error logs appear as breadcrumbs on Sentry issues. Navigate to any issue and open the Breadcrumbs section to see the sequence of log events that preceded the error.

In Local Development

Pipe the Next.js dev server output through jq for a formatted JSON view:

pnpm dev 2>&1 | jq '.'

Or filter by log level:

pnpm dev 2>&1 | jq 'select(.level == "error")'

In Production (stdout)

If your deployment platform supports log streaming (Vercel Log Drains, Railway, Render), configure it to ship stdout JSON to a log aggregation service.

Nebutra emits structured JSON logs to stdout. Use your deployment platform's log drain to ship them to a centralised service.

Vercel Log Drains

Configure in Vercel Dashboard → Project → Settings → Log Drains. Supported sinks:

SinkFormatUse when
DatadogJSONYou already use Datadog APM
Grafana LokiJSONSelf-hosted or Grafana Cloud
HTTP endpointJSONCustom ingest (Axiom, Better Stack, etc.)

Datadog quick-start

  1. Install the Vercel + Datadog integration from the Vercel Marketplace.
  2. Add DATADOG_API_KEY to your Vercel project environment variables.
  3. The integration automatically creates a log drain that forwards all stdout JSON to Datadog Logs.
// logs appear in Datadog with these default attributes:
// service: "nebutra", env: "production", version: <VERCEL_GIT_COMMIT_SHA>
import { logger } from "@nebutra/logger";
logger.info("order.created", { orderId, tenantId });

Grafana Loki (self-hosted)

Set up promtail or use the Grafana Cloud Logs drain:

# promtail config snippet
scrape_configs:
  - job_name: nebutra
    static_configs:
      - targets: [localhost]
        labels:
          app: nebutra
          env: production
          __path__: /var/log/nebutra/*.log

Current coverage

Log levelWhere it appears
errorSentry breadcrumbs + log drain
warnSentry breadcrumbs + log drain
infoLog drain only
debugLog drain only (not emitted in production by default)

Set NEBUTRA_LOG_LEVEL=debug temporarily in a specific environment to capture verbose output without affecting production.

Child Loggers

For libraries or modules that always emit a consistent set of context fields, create a child logger:

import { logger } from "@nebutra/logger";

const queueLogger = logger.child({ service: "queue", provider: "bullmq" });

queueLogger.info("Job enqueued", { jobId, queue: "email" });
// → { "service": "queue", "provider": "bullmq", "msg": "Job enqueued", "jobId": "...", "queue": "email", ... }

How is this guide?

Edit on GitHub

Last updated on

On this page