DEV Community

JethroRhodes8268
JethroRhodes8268

Posted on

Node.js Error Tracking: Custom Metadata Payloads with Rollback-Safe Logs

Short answer: treat an error event as a versioned log record, not a string with a stack trace attached. For a media team's nightly data pipeline, send a small Node.js payload with a stable event name, bounded metadata, and the pipeline revision that produced it. Keep the original event immutable, validate it before transport, and make consumers tolerate fields they do not know yet. That turns a rollback into a data decision instead of a schema emergency.

The boundary is intentionally boring. Application code creates an event. A transport handles delivery. Storage indexes fields for search. Every extra configuration knob becomes another explanation for the developer making the first call.

That is the whole point.

Why does rollback safety matter more than a richer error payload?

Imagine a nightly job that normalizes video metadata, writes captions, and publishes search documents. One bad asset should be searchable by asset ID, job run, stage, and release revision. It should not force the logging system to understand every field in the media catalog.

The tempting design is to serialize the whole failed input. It feels useful until a caption blob, signed URL, or customer-provided label lands in the index. Large, unstable payloads make retention, access control, and reprocessing harder. More fields do not help if the important ones cannot be filtered consistently.

I use a fixed spine with an extensible metadata map. The spine carries event, occurredAt, service, pipelineRunId, assetId, stage, revision, and error. Metadata carries bounded, non-secret context such as sourceFormat or attempt. The revision is the important field: after a deployment is rolled back, it separates errors emitted by the reverted build from errors that survived it.

OpenTelemetry's logs model separates the log body, attributes, and resource information. That is a useful constraint even when the receiving API is proprietary or self-hosted.

How should Node.js tracking handle errors with custom metadata in an API?

This example uses one public function and one generic HTTP boundary. The endpoint is a placeholder for a collector or internal service. It validates metadata size and does not mutate the caller's error object.

type ErrorMetadata = Record<string, string | number | boolean | null>

type ErrorEvent = {
  event: 'pipeline.error'
  occurredAt: string
  service: string
  pipelineRunId: string
  assetId: string
  stage: string
  revision: string
  error: { name: string; message: string; stack?: string }
  metadata: ErrorMetadata
}

const MAX_METADATA_BYTES = 4096

function makeErrorEvent(input: {
  error: unknown
  pipelineRunId: string
  assetId: string
  stage: string
  revision: string
  metadata?: ErrorMetadata
}): ErrorEvent {
  const error = input.error instanceof Error
    ? input.error
    : new Error(String(input.error))
  const metadata = { ...(input.metadata ?? {}) }
  const bytes = new TextEncoder().encode(JSON.stringify(metadata)).byteLength

  if (bytes > MAX_METADATA_BYTES) {
    throw new RangeError('error metadata exceeds 4096 bytes')
  }

  return {
    event: 'pipeline.error',
    occurredAt: new Date().toISOString(),
    service: 'nightly-media-pipeline',
    pipelineRunId: input.pipelineRunId,
    assetId: input.assetId,
    stage: input.stage,
    revision: input.revision,
    error: { name: error.name, message: error.message, stack: error.stack },
    metadata,
  }
}

export async function trackPipelineError(
  endpoint: string,
  input: Parameters<typeof makeErrorEvent>[0],
): Promise<void> {
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(makeErrorEvent(input)),
  })

  if (!response.ok) {
    throw new Error(`error collector returned HTTP ${response.status}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

There is no hidden retry loop. No automatic traversal turns the entire job context into metadata. I initially thought a generic context object would make debugging faster; the first useful review question was instead, “Can we remove this field without changing a query?” That is the right pressure test for an event contract.

The caller can decide whether lost telemetry should fail the pipeline, enter a local outbox, or use a separate alert path. That decision belongs to the job's reliability policy.

A production transport should add a timeout and bounded retry budget. It should attach an idempotency key derived from pipelineRunId, assetId, stage, and revision when the receiver supports deduplication. Those choices affect delivery semantics, so burying them in a helper makes rollback behavior harder to inspect.

What should the payload contain, and what should it refuse?

A searchable error record needs dimensions that remain stable across releases. In this scenario, assetId answers which item failed, stage answers where, pipelineRunId answers which execution, and revision answers which code produced the record. The message and stack are evidence, not dimensions. Indexing arbitrary message text as a field is a quick route to noisy queries.

Metadata should pass through an allowlist or small schema at the boundary. sourceFormat, captionLanguage, and attempt are reasonable examples. Raw request headers, access tokens, full media manifests, and user-entered text need different treatment. Refusing unnecessary data is better than hoping redaction catches it later.

Add a schema version even if the first version has only a few fields. A consumer can accept version 1 and version 2 during a deployment window. Adding a nullable field is usually easy; changing assetId from a string to an object is a migration. Make that distinction explicit in review.

Test fixtures should cover a missing source file, malformed caption, downstream timeout, and serialization failure. Assert field types, metadata size, absence of secrets, and preservation of the original error name. A snapshot of one happy-path JSON object is not enough.

How do you search the records without coupling the pipeline to storage?

The producer should know the event contract, not the storage query language. A collector can map the envelope into an OpenTelemetry log record or another structured format, attach resource attributes such as service identity, and route it to the chosen backend. This keeps the nightly job stable when indexing changes.

Three common tools show why this boundary matters. Sentry is centered on captured application events and issue grouping. Datadog combines logs with a broader hosted monitoring platform. Grafana Loki uses labels and log streams, with a different indexing trade-off from systems that index many log fields. These are different models, not interchangeable endpoint names. A neutral envelope lets a team evaluate retention, query shape, residency, and rollback requirements without rewriting the producer.

Approach Ingestion boundary Good fit Main limitation
Application SDK Library call inside the job Fast capture of runtime errors Couples producer code to a backend's event model
Generic REST collector JSON payload over HTTP A stable contract across storage trials Delivery, timeout, and retry policy remain your responsibility
Self-hosted log pipeline Structured event to a collector Teams that need control over routing and residency More components to operate and test

The table is a decision aid, not a product ranking.

I would benchmark the actual queries before choosing storage: lookup by assetId, filter by revision, group by stage, and open one full stack trace. Record ingestion delay, query latency, dropped events, and indexed metadata volume. The result depends on cardinality and retention, so a fixture is more honest than a generic feature table.

Keep high-cardinality values out of stream labels when the backend treats labels as an index boundary. An asset ID may be essential to the event but expensive as a label. Store it as a searchable field unless the backend's documented query model says otherwise.

What I would change at scale

At higher volume, separate the synchronous failure path from telemetry delivery. The job writes a compact outbox record with the same idempotency key, and a worker delivers it with bounded retries. The outbox needs retention and a dead-letter path. Otherwise, a collector outage can turn into disk pressure during the run operators need to inspect.

I would sample repeated failures only after preserving one complete record per pipeline run and revision. Sampling by message alone can erase the difference between two assets that fail for different reasons. Make the sampling rule visible in the event or transport metrics, so a search result is not mistaken for a complete count.

The trade-off is operational weight. An outbox adds a component, a cleanup job, and another dashboard. For a small nightly pipeline, direct delivery with a strict timeout may be better. Choose the smallest delivery design that can explain a rollback and prove what was dropped.

Sources

References:

Top comments (0)