DEV Community

PerNilsson3147
PerNilsson3147

Posted on

Node.js Structured Logs 2026: SaaS Notification Request Trace and PII Masking

Log one structured event for each notification delivery attempt, attach request and trace context, pseudonymize the customer identifier, and record usage dimensions that explain the attempt's cost. That is the smallest useful design for a customer-support SaaS trying to answer two questions at once: why did this notification fail, and which workflow incurred the spend?

TL;DR: keep request_id, trace_id, and delivery_attempt_id as separate fields; derive a stable, non-reversible actor_key instead of logging a raw user ID; allowlist event fields before serialization; and put cost inputs such as channel, template class, payload bytes, and attempt number on the delivery event. Do not log message bodies, email addresses, authorization headers, or provider responses wholesale. JSON is the transport shape, not the schema.

The tempting first version is console.log({ request, response }). It is quick to ship and expensive to live with. A failed email can leave a customer address, support text, headers, and a large response object in storage, while still omitting the one identifier needed to connect the enqueue request to the delivery worker. The better experiment is narrower: can an operator reconstruct one failed delivery and attribute its resource use from a small, explicit event?

What should a SaaS application log for each structured notification request?

A useful event describes a state transition, not every object available at that line of code. For a delivery attempt, the state transition is straightforward: a queued notification was handed to a channel adapter, which either accepted or rejected it. The log needs enough context to connect that outcome to an incoming support action and enough dimensions to explain resource use.

Start there.

I would start with five groups of fields. Identity includes event_name, delivery_attempt_id, and the notification's internal key. Correlation includes request_id, trace_id, and span_id. Attribution includes workspace_key, actor_key, channel, template_class, and attempt_number. Outcome includes status, duration_ms, and a bounded error_code. Runtime context includes service name, environment, release, and timestamp.

Those IDs do different jobs. A request ID follows one application request and is still useful when tracing is absent. A trace ID joins work across instrumented services and asynchronous boundaries when that context is propagated. A delivery-attempt ID distinguishes retries of the same notification. Combining them into one convenient correlation_id destroys information exactly when a queue retry or fan-out makes the incident interesting.

Keep the vocabulary boring. A log query should not need to remember that one worker calls a workspace tenant, another calls it account, and the API calls it organization. Define the schema once, version it, and reject unknown fields in tests.

The focused Node.js experiment

The example below uses only Node.js built-ins. It deliberately accepts a small input type rather than a request, user, or provider object.

The boundary is the safety feature.

import { createHash, randomUUID } from "node:crypto";

type DeliveryOutcome = "accepted" | "rejected";

type DeliveryAttempt = {
  requestId: string;
  traceId?: string;
  spanId?: string;
  workspaceId: string;
  userId: string;
  notificationId: string;
  attemptNumber: number;
  channel: "email" | "sms" | "push";
  templateClass: "case_update" | "agent_reply" | "sla_warning";
  payloadBytes: number;
  outcome: DeliveryOutcome;
  durationMs: number;
  errorCode?: "timeout" | "rate_limited" | "invalid_recipient" | "provider_rejected";
};

const LOG_HASH_KEY = process.env.LOG_HASH_KEY;
if (!LOG_HASH_KEY) throw new Error("LOG_HASH_KEY is required");

function pseudonymousKey(scope: string, value: string): string {
  return createHash("sha256")
    .update(`${LOG_HASH_KEY}:${scope}:${value}`)
    .digest("hex")
    .slice(0, 24);
}

export function logDeliveryAttempt(input: DeliveryAttempt): void {
  const event = {
    timestamp: new Date().toISOString(),
    severity: input.outcome === "accepted" ? "info" : "warn",
    event_name: "notification.delivery_attempted",
    schema_version: 1,
    service: "notification-worker",
    release: process.env.APP_RELEASE ?? "unknown",
    request_id: input.requestId,
    trace_id: input.traceId,
    span_id: input.spanId,
    delivery_attempt_id: randomUUID(),
    notification_key: pseudonymousKey("notification", input.notificationId),
    workspace_key: pseudonymousKey("workspace", input.workspaceId),
    actor_key: pseudonymousKey("user", input.userId),
    channel: input.channel,
    template_class: input.templateClass,
    attempt_number: input.attemptNumber,
    payload_bytes: input.payloadBytes,
    status: input.outcome,
    duration_ms: input.durationMs,
    error_code: input.errorCode
  };

  process.stdout.write(`${JSON.stringify(event)}\n`);
}
Enter fullscreen mode Exit fullscreen mode

This sample makes one deliberate trade-off: the pseudonymous keys preserve equality for investigation and attribution, but they are still linkable within their scope. Treat the hashing key as a secret, rotate it under a documented policy, and restrict access to the resulting logs. Hashing is not permission to retain arbitrary personal data. If operators do not need cross-event user correlation, use a per-event random token instead.

Also notice what is absent: recipient address, subject, message body, support-case text, raw exception, stack trace, and provider payload. A stack trace can belong in a separate, tightly controlled error event, but copying an unfiltered error object into the main operational stream risks capturing request data. The ship-first choice is an allowlist. Redaction is a second layer for accidental fields, not the primary contract.

Why do request IDs and trace IDs both survive?

An incoming support action may enqueue three notifications and return before any delivery begins. The API can assign a request ID, start or continue a trace, and place both values in the queue message. The worker then records those values without pretending that the queue delivery is the original HTTP request.

W3C Trace Context defines the traceparent format used to propagate trace identity. Preserve valid trace context at service boundaries, but do not manufacture span relationships by copying strings into unrelated jobs. If a queue consumer creates a new processing span, the tracing instrumentation should express the relationship; the application log should report the resulting trace and span identifiers.

There is a practical failure mode here. If request_id exists only in an async-local store inside the web process, it vanishes at the queue boundary. Put the correlation fields in the job envelope explicitly, validate their length and character set, and generate a fresh request ID when an untrusted or missing value arrives. Never let a caller inject line breaks or an unbounded identifier into logs.

No magic here.

Structured logs still need a propagation design, and traces still need application identifiers such as the notification key. One does not replace the other.

Cost attribution without turning logs into a billing ledger

The goal is to explain cost, not calculate an invoice from log lines. For notification delivery, useful cost drivers are channel, attempt count, payload size, template class, outcome, and workspace. They support questions such as: did retries inflate work for SLA warnings, or did one workspace produce a surge of rejected SMS attempts? Consider a notification that times out twice and is accepted on the third try. Three attempt events should share the notification key and attribution dimensions, while each carries its own attempt number, duration, outcome, and delivery-attempt ID. Counting notifications would hide two-thirds of the channel work; counting attempts without the shared key would make the customer-visible action hard to reconstruct. Both views matter, so preserve the raw attempt facts and derive notification-level totals downstream.

Retries are the trap.

Do not put a changing currency price on every event unless the accounting system requires immutable historical rates. Prices and commercial rules change; operational facts such as attempt_number: 3 and payload_bytes: 1840 do not. Join stable usage events to a versioned rate table downstream. That keeps the logging path fast and makes corrections auditable.

The same restraint matters for metrics. Logs can retain a pseudonymous workspace key for a time-bounded investigation, while a metric label carrying every workspace or user would create an unbounded set of time series. Prometheus naming guidance recommends a consistent base unit and explains that labels should represent the same logical quantity across dimensions. For the aggregate counter, use bounded dimensions:

type MetricLabels = {
  channel: "email" | "sms" | "push";
  status: "accepted" | "rejected";
  error_code: "none" | "timeout" | "rate_limited" | "invalid_recipient" | "provider_rejected";
};

function recordDelivery(labels: MetricLabels): void {
  deliveryAttemptsTotal.add(1, labels);
}
Enter fullscreen mode Exit fullscreen mode

delivery_attempts_total is suitable for rate and ratio calculations. workspace_key, actor_key, request_id, and trace_id belong in logs or traces, not metric labels. This split preserves investigation detail without making every customer a permanent metrics dimension.

The controls that make the schema real

A type is helpful, but runtime data ignores TypeScript. Test the serialized output. Feed the logger an email address in every upstream field you control, then assert that the address, authorization tokens, and message text cannot appear. Test that a rejected attempt has a bounded error code, that required correlation keys are present, and that the output parses as exactly one JSON object per line.

Deployment needs two more checks. First, cap field lengths before serialization so a malformed provider message cannot turn one event into megabytes. Second, make stdout backpressure and process termination behavior explicit. Logging should not silently drop the final failure event, but a blocked sink should not freeze notification delivery forever either. The correct buffer and flush policy depends on the loss tolerance of the service; measure it under a slow consumer rather than guessing.

Retention should follow purpose. Delivery investigation, security review, usage aggregation, and billing evidence can require different access and lifetimes. Separate those stores or streams when their controls differ. A single giant index with broad access is operationally convenient until sensitive support context lands in it.

Measure before retaining.

Before copying this design, measure four things in a staging workload: serialized bytes per attempt, events per notification including retries, logging time at the p95 and p99, and the proportion of failed deliveries that can be reconstructed without opening a message body. Then estimate daily volume by channel and retention tier. A schema earns its keep when it answers the failure and attribution questions with fewer sensitive bytes, not when it records the most context.

A small decision rule

Ship the schema when an operator can move from a customer-reported notification to its attempts, see the bounded failure reason, connect the work to the originating request or trace, and attribute usage to a pseudonymous workspace without reading customer content. Delay extra fields until a concrete investigation cannot proceed without them.

That leaves room to evolve. Increment schema_version for a breaking semantic change, run old and new consumers during migration, and keep the event name stable when the business event itself has not changed. The durable asset is the contract.

References

Top comments (0)