When the picture itself lies — clock skew, gaps and the uninstrumented hop

Every span's times come from the clock of the machine that recorded it and those clocks disagree, so viewers quietly shift timestamps to make a child fit inside its parent — and an empty gap has at least five possible causes that nothing in the data distinguishes.

Previously

Self time and the critical path are read off timestamps, and off the spans that happen to be there. Both of those assumptions are worth checking.

Scene 14a

When the picture itself lies

  1. Watch
  2. Try it
  3. Predict
  4. Capture
clock skew: what this picture cannot meanTHE TRACE AS THE CLOCKS REPORTED IT-20ms50ms120ms190ms260mszoomed: one call (example)POST /pay (client)200msPOST /pay (server)30msTEMPTINGthe network between checkout and payments was slowTHE PICTUREraw timestamps, as reportedpayments clock runs behind-50msIN THE GAPqueue waitreal elapsed time that nobody opened aspan for✕ POST /pay (server) starts 25ms beforeits parentRaw, as the two machines reported it: payments' span starts before the span that called it.
What to watch for

Can you trust the shape of a trace? Every span's start and end are stamped by the clock of the machine that recorded it, and machines in a fleet do not agree about what time it is — plain time synchronisation leaves tens of milliseconds of disagreement, which is wider than plenty of spans you care about. Watch the same two spans of one call drawn twice: once with the numbers the two machines actually reported, then once after the viewer quietly repairs them.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

Tracer.finish_span
each span is stamped by the clock of the host that ran it
def finish_span(span):
span.start_time = local_clock.now() - span.elapsed
span.end_time = local_clock.now()
span.process.tags['ip'] = host_ip
exporter.export(span)
Adjuster.adjust_trace
cross-host children only; every shift lands in the warnings
def adjust_trace(trace):
for child in trace.spans:
parent = trace.by_id[child.parent_id]
if host_of(child) == host_of(parent):
continue
delta = calculate_delta(child, parent)
if delta == 0:
continue
child.start += delta
child.warnings.append(
f'shifted {delta}ms for clock skew'
)
Adjuster.calculate_delta
assumes the child nests, and the round trip splits evenly
def calculate_delta(child, parent):
if child.duration > parent.duration:
return parent.start - child.start
if (child.start >= parent.start
and child.end <= parent.end):
return 0
latency = (parent.duration - child.duration) / 2
return parent.start + latency - child.start
Viewer.explain_gap
what the viewer can say about time no span covers
def explain_gap(prev, next):
gap_ms = next.start - prev.end
candidates = [
'the two spans are genuinely adjacent',
'queue or scheduler wait',
'a hop nobody instrumented',
'a span dropped or not yet arrived',
'network, TLS, serialization',
'a stop-the-world pause',
]
return Gap(gap_ms, cause=UNKNOWN,
candidates=candidates)

Where this sits in Build a distributed tracing system (Jaeger / Zipkin style)

Scene 14a of 17, in the Store & read act — Key by trace id, find it, read it, distrust it.. Every span's times come from the clock of the machine that recorded it, and those clocks disagree. Viewers quietly shift timestamps to make a child fit its parent, folding real network and queue wait into the parent.

Up next. One trace can mislead you in ways you cannot detect from inside it. A million traces average that out — so what can you build from all of them at once?

All 17 scenes in Build a distributed tracing system (Jaeger / Zipkin style) · Every curriculum

Built with Arqly
Every scene in Build a distributed tracing system (Jaeger / Zipkin style) builds on the one before it.All 17 Build a distributed tracing system (Jaeger / Zipkin style) scenes