04 Tracing 6 chapters

Theme 04 · Debuggability-Focused

Tracing

Explanation

Tracing

Plain Human Explanation

A trace is a breadcrumb trail for one user action. If a request creates a queue job, calls a provider, and writes to the database, the trace lets the team follow that one action across all of those places.

Without tracing, incidents turn into guessing. You may know a job failed, but not which request created it or which provider call belonged to it.

Technical Explanation

Create or accept a trace id at the edge of the system and pass it through the workflow. Logs, queue messages, and outbound calls should include that id in a consistent field.

In TypeScript, a small TraceContext type is usually enough. It should travel with commands and ports instead of being re-created in every helper.

Why It Matters

  • User impact: support can connect a customer report to the exact request and background work behind it.
  • Product behavior: async workflows stay explainable even when work leaves the original HTTP request.
  • Risk: missing trace ids make slow, partial, or duplicate failures hard to reconstruct.
  • Decision point: add trace context when one user action crosses process, queue, service, or provider boundaries.

The Core Move

Carry one request identity through the whole workflow. Do not create a new identity at every step unless you also link it to the parent trace.

Small Example

Tracing: Small Example

Bad TypeScript Example

type Logger = {
  info(event: string, fields: Record<string, unknown>): void;
};

export function logPasswordResetStarted(userId: string, logger: Logger) {
  logger.info("password_reset.started", { userId, requestId: crypto.randomUUID() });
}
type Logger = {
  info(
    event: string,
    fields: Record<string, unknown>,
  ): void;
};

export function logPasswordResetStarted(
  userId: string,
  logger: Logger,
) {
  logger.info("password_reset.started", {
    userId,
    requestId: crypto.randomUUID(),
  });
}

Good TypeScript Example

type TraceContext = {
  traceId: string;
};

type Logger = {
  info(event: string, fields: Record<string, unknown>): void;
};

export function logPasswordResetStarted(
  userId: string,
  trace: TraceContext,
  logger: Logger,
) {
  logger.info("password_reset.started", { userId, traceId: trace.traceId });
}
type TraceContext = {
  traceId: string;
};

type Logger = {
  info(
    event: string,
    fields: Record<string, unknown>,
  ): void;
};

export function logPasswordResetStarted(
  userId: string,
  trace: TraceContext,
  logger: Logger,
) {
  logger.info("password_reset.started", {
    userId,
    traceId: trace.traceId,
  });
}

What Changed

  • The bad version creates a fresh id inside the helper, so it cannot connect to the original request.
  • The good version accepts trace context and logs the same id every downstream step can use.
  • The helper stays small, but it no longer breaks the debugging trail.

Realistic Example

Tracing: Realistic Example

This example starts a report export. The HTTP request returns quickly, and the real work happens later in a queue.

Bad TypeScript Example

export async function requestReportExport(req: any, queue: any, logger: any) {
  logger.info("report_export.requested", { userId: req.user.id });
  await queue.enqueue("report-export", { userId: req.user.id, format: req.body.format });
  return { accepted: true };
}
export async function requestReportExport(
  req: any,
  queue: any,
  logger: any,
) {
  logger.info("report_export.requested", {
    userId: req.user.id,
  });

  await queue.enqueue("report-export", {
    userId: req.user.id,
    format: req.body.format,
  });

  return {
    accepted: true,
  };
}

Good TypeScript Example

type TraceContext = {
  traceId: string;
};

type ReportExportCommand = {
  userId: string;
  format: "csv" | "xlsx";
  trace: TraceContext;
};

type Queue = {
  enqueue(name: "report-export", command: ReportExportCommand): Promise<void>;
};

type Logger = {
  info(event: string, fields: Record<string, unknown>): void;
};

export async function requestReportExport(
  command: Omit<ReportExportCommand, "trace">,
  trace: TraceContext,
  queue: Queue,
  logger: Logger,
) {
  const queuedCommand = { ...command, trace };

  logger.info("report_export.requested", {
    userId: command.userId,
    format: command.format,
    traceId: trace.traceId,
  });
  await queue.enqueue("report-export", queuedCommand);

  return { accepted: true, traceId: trace.traceId };
}
type TraceContext = {
  traceId: string;
};

type ReportExportCommand = {
  userId: string;
  format: "csv" | "xlsx";
  trace: TraceContext;
};

type Queue = {
  enqueue(
    name: "report-export",
    command: ReportExportCommand,
  ): Promise<void>;
};

type Logger = {
  info(
    event: string,
    fields: Record<string, unknown>,
  ): void;
};

export async function requestReportExport(
  command: Omit<
    ReportExportCommand,
    "trace"
  >,
  trace: TraceContext,
  queue: Queue,
  logger: Logger,
) {
  const queuedCommand = {
    ...command,
    trace,
  };

  logger.info("report_export.requested", {
    userId: command.userId,
    format: command.format,
    traceId: trace.traceId,
  });

  await queue.enqueue(
    "report-export",
    queuedCommand,
  );

  return {
    accepted: true,
    traceId: trace.traceId,
  };
}

What Changed

  • The bad version logs the request and queues work with no shared identity.
  • The good version puts the trace id in both the log and the queued command.
  • The response returns the trace id, which gives support a concrete handle for follow-up.

System Example

Tracing: System Example

At system scale, tracing connects the route, queue worker, storage write, and provider call for one workflow. The same trace id should appear everywhere the work appears.

Larger System-Level Bad TypeScript Example

export async function exportWorker(job: any, services: any) {
  services.logger.info("export.started", { jobId: job.id });
  const file = await services.reports.build(job.data.userId);
  await services.storage.save(file);
  await services.email.send(job.data.userId, "Your export is ready");
  services.logger.info("export.finished", { jobId: job.id });
}
export async function exportWorker(
  job: any,
  services: any,
) {
  services.logger.info("export.started", {
    jobId: job.id,
  });

  const file = await services.reports.build(
    job.data.userId,
  );

  await services.storage.save(file);

  await services.email.send(
    job.data.userId,
    "Your export is ready",
  );

  services.logger.info("export.finished", {
    jobId: job.id,
  });
}

Larger System-Level Good TypeScript Example

type TraceContext = {
  traceId: string;
};

type ExportJob = {
  id: string;
  data: {
    userId: string;
    trace: TraceContext;
  };
};

type ReportBuilder = {
  build(
    userId: string,
    trace: TraceContext,
  ): Promise<{
    path: string;
  }>;
};

type Storage = {
  save(
    file: {
      path: string;
    },
    trace: TraceContext,
  ): Promise<void>;
};

type Email = {
  sendExportReady(userId: string, trace: TraceContext): Promise<void>;
};

type Logger = {
  info(event: string, fields: Record<string, unknown>): void;
};

type ExportPorts = {
  reports: ReportBuilder;
  storage: Storage;
  email: Email;
  logger: Logger;
};

export async function exportWorker(job: ExportJob, ports: ExportPorts) {
  const trace = job.data.trace;
  ports.logger.info("export.started", { jobId: job.id, traceId: trace.traceId });

  const file = await ports.reports.build(job.data.userId, trace);
  await ports.storage.save(file, trace);
  await ports.email.sendExportReady(job.data.userId, trace);

  ports.logger.info("export.finished", { jobId: job.id, traceId: trace.traceId });
}
type TraceContext = {
  traceId: string;
};

type ExportJob = {
  id: string;
  data: {
    userId: string;
    trace: TraceContext;
  };
};

type ReportBuilder = {
  build(
    userId: string,
    trace: TraceContext,
  ): Promise<{
    path: string;
  }>;
};

type Storage = {
  save(
    file: {
      path: string;
    },
    trace: TraceContext,
  ): Promise<void>;
};

type Email = {
  sendExportReady(
    userId: string,
    trace: TraceContext,
  ): Promise<void>;
};

type Logger = {
  info(
    event: string,
    fields: Record<string, unknown>,
  ): void;
};

type ExportPorts = {
  reports: ReportBuilder;
  storage: Storage;
  email: Email;
  logger: Logger;
};

export async function exportWorker(
  job: ExportJob,
  ports: ExportPorts,
) {
  const trace = job.data.trace;

  ports.logger.info("export.started", {
    jobId: job.id,
    traceId: trace.traceId,
  });

  const file = await ports.reports.build(
    job.data.userId,
    trace,
  );

  await ports.storage.save(file, trace);

  await ports.email.sendExportReady(
    job.data.userId,
    trace,
  );

  ports.logger.info("export.finished", {
    jobId: job.id,
    traceId: trace.traceId,
  });
}

What Changed

  • The bad worker gives each dependency no way to connect its work to the original request.
  • The good worker passes the same trace context through every boundary.
  • Logs and downstream services can now be searched by one trace id during an incident.

When To Use It

Tracing: When To Use It

Use This When

  • A workflow crosses a queue, worker, webhook, provider, database transaction, or another service.
  • Support needs to connect a customer action to background work.
  • Logs already show partial facts, but nobody can link the facts into one timeline.

Avoid This When

  • The platform already provides a standard trace context and you can reuse it directly.
  • The workflow is a tiny synchronous function with no meaningful debugging boundary.
  • You would put user ids, emails, or secrets into the trace id itself.

Tradeoffs

Tracing adds a value that must be threaded through calls. That can feel repetitive, but the repetition is useful when it marks real boundaries. Keep the context small so it stays easy to pass.

  • Structured errors
  • Typed failure modes
  • Safe telemetry

Practice Prompt

Tracing: Practice Prompt

Beginner Exercise

Find a route that logs an event and enqueues a job. Add a trace id to both the log entry and the queued payload.

Intermediate Exercise

Pick a worker that calls two dependencies. Pass one TraceContext through the worker and into both dependency calls.

Stretch Exercise

Write a test that starts a workflow and asserts every emitted log entry contains the same trace id.

Reflection Question

Where does this workflow leave the original request, and what information would you need to follow it after that point?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.