A trace is a tree of spans. Understanding what a span carries — and how spans relate to each other — is the foundation for everything else in distributed tracing.

Anatomy of a span

A span represents one unit of work: an HTTP request, a database call, a function invocation. Every span carries:

FieldDescription
NameHuman-readable operation name, e.g. GET /checkout.
Span contextTrace ID, span ID, and trace flags that identify this span within a trace.
Parent span IDThe span that caused this one; empty for the root span.
Start / end timeNanosecond-precision timestamps used to compute duration.
AttributesKey-value pairs describing the operation, e.g. http.route, db.statement.
EventsTimestamped points-in-time within the span, e.g. an exception being thrown.
StatusUnset, Ok, or Error — whether the operation succeeded.
KindServer, Client, Producer, Consumer, or Internal.
LinksReferences to causally-related spans in a different trace (e.g. batch processing).

Span context

Every span has a SpanContext: a 16-byte trace ID shared by every span in the trace, an 8-byte span ID unique to that span, and trace flags (currently just a sampled bit). SpanContext is what makes distributed tracing possible — it's the small, serializable identity that gets propagated across process and network boundaries so a downstream service knows which trace and which parent span it belongs to.

Parent-child relationships

Every span except the first (the root span) has a parent. This parent-child chain is what lets a backend reconstruct the full call tree of a request: a root span for the incoming HTTP request, a child span for a downstream service call, a grandchild span for that service's database query, and so on. The visual result is the familiar "waterfall" or flame graph view in tracing UIs like Jaeger or Grafana Tempo.

Span kind

The SpanKind attribute describes the span's role in a distributed operation and is essential for the tracing backend to correctly stitch spans across process boundaries:

  • Server — handles an incoming synchronous request (e.g. an HTTP handler).
  • Client — makes an outgoing synchronous request (e.g. an HTTP client call).
  • Producer — sends a message to be processed asynchronously (e.g. publishing to a queue).
  • Consumer — processes a message sent by a producer.
  • Internal — default; an operation that doesn't cross a process boundary.

A Client span on one service and a Server span on the next form a matched pair — the tracing backend uses this pairing plus the shared trace/span IDs to draw an accurate service-to-service call graph.

Attributes and events

Attributes are static key-value pairs set once, describing the operation: http.request.method, http.response.status_code, db.system. Events are timestamped things that happened during the span's lifetime — most commonly, exceptions:

span.setAttribute('http.route', '/checkout');
try {
  await chargeCard(order);
} catch (err) {
  span.recordException(err);
  span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
  throw err;
}

Span status

Span status has three possible values: Unset (default — the operation completed without the instrumentation making a judgment), Ok (explicitly successful), and Error (the operation failed). Tracing backends use status to color spans red in the UI and to compute error rates — setting it accurately on failure paths is one of the highest-value things manual instrumentation can do.

⚠️
A 4xx is not always an error

Semantic conventions specify that a 4xx HTTP response on a server span should generally stay Unset — client mistakes aren't server failures. On a client span, however, a 4xx or 5xx should usually be marked Error, since it means the request you made failed.

A full example

const tracer = trace.getTracer('checkout-service');

await tracer.startActiveSpan('process-order', { kind: SpanKind.SERVER }, async (span) => {
  span.setAttribute('order.id', order.id);
  span.setAttribute('order.total', order.total);

  await tracer.startActiveSpan('charge-payment', { kind: SpanKind.CLIENT }, async (child) => {
    child.setAttribute('payment.provider', 'stripe');
    try {
      await stripe.charge(order);
      child.setStatus({ code: SpanStatusCode.OK });
    } catch (err) {
      child.recordException(err);
      child.setStatus({ code: SpanStatusCode.ERROR });
    } finally {
      child.end();
    }
  });

  span.end();
});

The charge-payment span is automatically linked as a child of process-order because it's started while the parent is the "active span" in the current context — no manual ID-passing required within a single process.

Takeaway

A trace is just a tree of spans connected by trace ID and parent span ID. Everything a tracing UI shows you — waterfalls, service maps, error highlighting — is derived from these few fields.