Not an explainer — you already have that. This is a design session: you build the system piece by piece, commit to a decision before every reveal, and compare against a reference design afterward. The reveal is a design-review comment, not an answer key. Do the "build it yourself first" box before you touch the reveal — that's the entire point.
On paper: write your own 5–7 FR bullets, then NFR targets with real numbers — throughput (spans/sec), added latency to the caller, query latency, retention window, and your consistency/availability trade-off — for a tracing system sitting in front of MF order flow, IPO allotment, NSDL demat, MOFSL, PolicyBazaar, and LAMF. Then write one line on what you're deliberately scoping out, and why.
Commit: head sampling or tail sampling. Write 2–3 sentences you'd defend in a design review, justified specifically against your FR6 (error/slow traces must survive) from segment 1 — not against sampling in the abstract.
Draw your own 5–7 box diagram, arrows and all. Before you reveal: write one sentence on why the hot-path decision logic (the sampler) can't live in the same component as the durable-write path (storage).
Instrumented Service (SDK) | spans, async, non-blocking v Local Agent / Sidecar -- batches, local retry, backpressure | v Ingestion Gateway -- auth, validation, rate-limit (stateless) | v Trace Assembler / Sampler -- buffers per trace_id, tail decision (stateful) | \ v v Hot Storage Cold / Archive Storage <- async tiering (segment 7) | v Query Service / API | v Dashboards / Alerting / on-call toolingWhy this exact shape: every box exists because of a decision made earlier — the assembler exists because of segment 2's tail-sampling choice; the agent exists because of the <5ms caller-latency NFR; the split between hot and cold storage exists because of segment 1's retention numbers. A component diagram that isn't traceable back to a requirement is usually just habit.
Design your own 5–6 endpoints as a table: method, path, purpose, caller (be specific — a service? an operator? an autoscaler?), payload, idempotency behavior. Then decide: should the ingest endpoint and the policy-update endpoint share the same auth scope?
Accept a batch of spans from an agent.
Fetch the full assembled span tree for a trace.
Update sampling rules (per-service rates, guaranteed-keep rules for errors/slow traces).
Agent reports its buffer depth, drop count, last-flush time.
Set hot/cold retention windows, with per-flow overrides for regulated flows.
Manual purge of a specific trace — e.g. a compliance/PII scrub request.
Sketch your own span schema. Then estimate: at 80+ microservices, roughly how many spans/sec pre-sampling, how many persisted post-sampling, and how much hot storage per day? Do the arithmetic before revealing — don't guess a round number.
| Field class | Examples | Durability need | Consistency need |
|---|---|---|---|
| Durable config | sampling policy, retention policy, service registry | must survive crash | strongly consistent across instances |
| Ephemeral derived state | in-flight trace buffers in the assembler | fine to lose on crash | local to one instance only |
| Persisted span data | hot-storage span rows, cold-archive trace blobs | durable once written | eventual — spans can assemble out of order |
Write actual pseudocode — not prose — for: (1) the buffer data structure holding in-flight trace spans, (2) the per-span-arrival logic, (3) the decision-trigger logic when a trace is judged complete. Then answer on paper: what happens when the buffer is full and a span for a brand-new trace arrives?
struct TraceBuffer:
trace_id, spans: list<Span>, first_seen: ts, root_seen: bool, decided: bool
buffer_map: ConcurrentMap<trace_id, TraceBuffer> # sharded by hash(trace_id)
on_span_arrival(span):
buf = buffer_map.get_or_create(span.trace_id) # atomic
lock(buf):
buf.spans.append(span)
if span.parent_span_id is null: buf.root_seen = true
if buf.root_seen and span.parent_span_id is null and span.end_ts is set:
trigger_decision(buf)
elif now() - buf.first_seen > MAX_TRACE_WINDOW:
trigger_decision(buf) # forced, incomplete trace
trigger_decision(buf):
lock(buf):
if buf.decided: return # idempotent guard
buf.decided = true
keep = evaluate_policy(buf.spans) # error? p95+? priority? random%?
if keep: persist_async(buf.spans)
buffer_map.remove(buf.trace_id) # free memory either way
on_buffer_full(new_trace_id):
evict_oldest_incomplete_trace() # forces an early decision — chosen default
# explicit alternative: reject_new_span(), incrementing a dropped_backpressure counter
# never: drop silently with no counter at all
Why the guard and the memory-free step are both non-negotiable: without buf.decided, the root-arrival path and the timeout-sweep path can race on the same trace and double-persist or double-apply policy — corrupting the "one coherent decision per trace" guarantee tail sampling exists to provide. And freeing the buffer immediately at decision time, regardless of keep/drop, is what keeps the assembler's memory bound only to undecided traces — anything else is waste sitting in the exact resource this whole design is trying to protect.
Design the tiering loop precisely: what triggers a trace moving from hot to cold storage, and what triggers a permanent purge? Then decide: one central coordinator running this for the whole platform, or every storage node running its own local loop?
every TIERING_INTERVAL (e.g. 15 min), per storage shard, independently:
for trace in shard.traces_older_than(HOT_WINDOW):
cold_days = retention_policy[trace.flow_tag].cold_days # e.g. LAMF=90d, default=7d
move_to_cold(trace)
mark_purge_after(trace, cold_days)
for trace in shard.traces_past_purge_date():
hard_delete(trace)
emit_audit_log(trace_id, reason="retention_expired")
Why per-flow, not global: retention is a compliance decision, not an infrastructure default — LAMF disbursal and IPO allotment carry real regulatory retention exposure that a blanket "delete after 7 days" would silently violate. A flow-tag lookup means the loop enforces what compliance actually decided, not what the loop's original author guessed was reasonable.
Draw the state machine covering every state mentioned in segments 1–7. Mark which state is most at risk of "stuck forever" — and what specifically guarantees it always exits.
RECEIVED_AT_AGENT -> BATCHED -> INGESTED_AT_GATEWAY -> BUFFERED -> DECIDED(keep) -> PERSISTED(hot) -> TIERED(cold) -> PURGED -> DECIDED(drop) -> [discarded, buffer freed] Every state has a bounded max-time-in-state. BUFFERED specifically: forced exit via MAX_TRACE_WINDOW (segment 6), even if the root span never closes.Why every state needs this, not just BUFFERED: it's the obvious one to protect because it's stateful and memory-bound, but INGESTED_AT_GATEWAY and TIERED can stall too (a downstream queue backing up, a stuck tiering job) — a state machine is only as safe as its least-protected state, so the discipline has to be applied uniformly, not just where the failure mode is most visible.
buf.decided guard), and the hot-to-cold tier move (a retried partial move must not double-write or leave a trace in two places).
Why "everywhere" is too vague to be useful: naming the specific transitions forces you to check each one has an actual guard, rather than asserting idempotency as a general property of the system. A transition without a concrete dedup key or guard is a transition that will corrupt state under retry — in a distributed pipeline that's the normal operating condition, not a rare edge case.
How do multiple instances of your own assembler/sampler stay in sync without a central coordination bottleneck? Write one concrete metric — tied to a segment-1 NFR — that tells you it's time to scale out.
hash(trace_id) mod N at the ingestion gateway guarantees this with zero coordination overhead per request. The real cost only shows up when N changes — a naive rehash on scale-out would instantly split in-flight traces across old and new instance assignments, so scale-out needs a controlled rebalance, not an instant reshuffle.
on_buffer_full path) going above zero and staying there.
Why this maps to the <5ms NFR specifically: once buffer pressure forces the assembler to do extra work per span — forced evictions, early decisions — that latency budget is the first thing to slip, well before the more obvious CPU or raw throughput metrics move. Scaling on CPU alone would catch the problem too late for a system whose actual constraint is memory-bound state, not compute.
Draw the whole thing from memory — both the hot/data path (span → … → query) and the control/config path (policy push → … → assembler) in one diagram. Don't look back at earlier segments while you do it.
HOT / DATA PATH
SDK -> Agent -> Ingestion Gateway -> Assembler(hash(trace_id) routed)
-> [DECIDED keep] -> Hot Storage -> (tiering loop) -> Cold Storage -> Purge
-> [DECIDED drop] -> discarded
Hot Storage -> Query Service -> Dashboards / on-call tooling
CONTROL / CONFIG PATH
Operator -> POST /sampling-policy \
Operator -> POST /retention-policy >-- Policy Store (replicated, strongly consistent)
/
Policy Store -> pushed/pulled by -> every Assembler instance
Agent -> POST /agent/heartbeat -> Platform Monitoring -> scale-out signal (segment 9)
Why these two paths are drawn separately but must both appear: the hot path is judged by the <5ms and throughput NFRs; the control path is judged by consistency and blast-radius (segment 4/11's authorization reasoning). A design that only ever draws the hot path is a design that's never actually reasoned about how a bad policy push gets safely rolled out — which is usually where the real incidents happen.