If a trace is the story of a request, spans are the sentences. Each one records a single thing the request did and how long it took, and the timestamps and parent pointers are what let a tool reassemble them into the waterfall you actually read. Most of the time you never create a span by hand — the framework instrumentation does it for the operations that usually matter. The reason to understand them is for the times it doesn't.
Anatomy of a span
A single DB query span
name: db.query
trace_id: 0af7651916cd43dd8448eb211c80319c
span_id: b7ad6b7169203331
parent_id: 0f9e8d7c6b5a4392
start_time: 2026-04-13T14:23:05.240Z
end_time: 2026-04-13T14:23:05.260Z
duration_ms: 20
attributes:
db.system: postgresql
db.statement: SELECT * FROM orders WHERE user_id = ? LIMIT 10
db.rows: 10
laravel.connection: pgsql
status:
code: OKThe span tells you everything you need to reconstruct that one operation's timing, inputs, and outcome — but nothing about the surrounding request. That's what the parent span is for.
Parent-child relationships
Spans form a tree via parent_id. The root span (the HTTP request) has no parent. Every other span points to its parent. The tracing backend reconstructs the tree from these pointers to render the waterfall.
Attributes
Spans carry structured key-value metadata — attributes. OpenTelemetry defines semantic conventions so every vendor uses the same attribute names for the same concepts:
http.method,http.status_code,http.route— HTTP spansdb.system,db.statement,db.operation— DB spansmessaging.system,messaging.destination— queue spansexception.type,exception.message,exception.stacktrace— error spans
Creating custom spans in Laravel
For an expensive internal operation that isn't captured by auto-instrumentation:
use OpenTelemetry\API\Globals;
$tracer = Globals::tracerProvider()->getTracer('app');
$span = $tracer->spanBuilder('image.resize')
->setAttribute('image.width', $width)
->setAttribute('image.height', $height)
->startSpan();
$scope = $span->activate();
try {
$resized = $this->resize($image, $width, $height);
$span->setStatus(StatusCode::STATUS_OK);
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus(StatusCode::STATUS_ERROR);
throw $e;
} finally {
$scope->detach();
$span->end();
}Laravel-idiomatic alternative: wrap the operation in a macro or service method and have NightOwl's Nightwatch integration surface it via its own span abstraction.
Span events vs child spans
A span event is a timestamped annotation within a span — not a child span, just a marker. Use events for point-in-time signals (cache.hit, retry.attempted). Use child spans when the thing has its own duration worth tracking. Events are cheaper but less queryable.
Spans aren't free
Once you can make spans, it's tempting to make a lot of them. Resist it. Every span carries its attributes into storage, and at production traffic a span-per-loop-iteration becomes millions of near-identical records that cost money to keep and bury the spans that matter. The heuristic that holds up: a span should correspond to a decision you might make. If seeing it wouldn't change what you do next, it's an attribute on the parent span, not a span of its own.