Skip to content
SeeItFlow
System Design · 9 chapters · ~32 min read

Distributed Logging Architecture: Schema, Agents, Kafka Internals & Query Planning

How each major component in the distributed logging design actually works under the hood — the log record schema, agent and collector mechanics, the durable bus's partition and acknowledgment internals, the three storage options compared honestly, the query planner's pseudocode, and the ten correctness invariants that tie the whole pipeline together.

Mangesh MandlikLast updated: How lessons are made
Chapter 01

The Log Record Schema

Every field in a log record exists to answer a specific operational question later — this schema is the contract every producer, every transport stage, and every consumer agrees to.

Swipe to pan
Anatomy of One Log RecordEvery field here exists to answer a specific operational question latertimestamp / event_timewhen the app believed this happenedobserved_timewhen the pipeline actually received ittrace_id / span_idcorrelates across services for one requesttenant_id (authenticated)isolation boundary — never client-claimedservice, instance_id, regionwhere it came fromlevel, message, structured fieldswhat happenedschema_versionlets the pipeline evolve safely
Seven field groups, each answering a different diagnostic question
jsonIllustrative log record shape — not a real SeeItFlow or vendor wire format
{
  "schema_version": 3,
  "event_time": "2025-03-14T18:02:11.482Z",
  "observed_time": "2025-03-14T18:02:11.519Z",
  "trace_id": "7f2a9c1e4b3d4f0a",
  "span_id": "a1b2c3d4",
  "tenant_id": "tenant-acme",
  "service": "payment-service",
  "instance_id": "i-0a1b2c3d",
  "region": "us-east-1",
  "level": "ERROR",
  "message": "payment provider timed out",
  "fields": { "order_id": "CO-9931", "provider": "stripe-sandbox", "timeout_ms": 3000 }
}

event_time vs. observed_time: keep both

event_time is when the application believed the event happened, read from the wall clock of the host that produced it. observed_time is stamped by the collector when it admits the batch. Keeping both, rather than collapsing to one, is what lets you tell a late record from a record that carries a wrong timestamp. The next two blocks spell out what each timestamp can and cannot tell you.

TimestampStamped byWhich clockWhat it is good for
event_time (t_event)The application or its log libraryWall clock of the producing hostOrdering and display within one host; cross-host only as accurate as that host's clock sync
observed_time (collection)The collector, at admissionWall clock of a platform-managed collector, with monitored time syncThe platform's own record of when it first saw the batch. It includes any delay inside the agent or spool
t_durable (append)The broker, if configured to stamp append time (Kafka's LogAppendTime), otherwise the producer's CreateTimeWall clock of a broker, or of the producerWhen the record became part of the durable log. Compare with other server-side timestamps, not with event_time
t_indexed (processing)The indexer, at write or at refreshWall clock of an indexer hostWhen the record became searchable. Search visibility can also wait for an index refresh interval
Elapsed timeOne process, using a monotonic clockMonotonic clock (for example CLOCK_MONOTONIC)Durations inside one process, such as how long a batch sat in the spool. It never jumps when NTP steps the wall clock, but it is meaningless across hosts or across a reboot

When is subtracting two timestamps meaningful?

Subtracting is only exact when both timestamps come from the same clock. Across two hosts, the difference is the true delay plus the difference between the two clocks' errors: (t_b − t_a) = true delay + (offset_b − offset_a). If the producer's clock is 4 seconds fast, t_event is 4 seconds too large, so t_durable − t_event comes out 4 seconds too small, and the record looks 4 seconds fresher than it is, possibly even negative. If it is 4 seconds slow, the record looks 4 seconds more delayed. The subtraction is only trustworthy when you can bound that offset, for example when time sync is monitored and its error is a few milliseconds, and the delay you care about is much larger. Three rules follow. Measure server-side stages (collector, broker, indexer) with server-side timestamps, because the platform controls and monitors those clocks. Measure waiting inside one process with a monotonic clock, for example an agent attaching how long each batch sat in its spool. Treat event_time to observed_time as a sanity check, not a latency measurement, because it includes the producer's clock error.

tenant_id is attributed, never trusted as written

The tenant_id field is populated from the authenticated connection identity at the collector, not copied verbatim from whatever the producer's payload claims. A payload-supplied tenant_id is, at most, a hint for client-side debugging — never an authorization input. Chapter 06 covers the admission check that enforces this.

schema_version enables safe evolution

A consumer encountering a newer schema_version than it understands degrades gracefully — reading the fields it recognizes and ignoring the rest — rather than failing closed on every record from an upgraded producer.

Chapter 02

The Event Lifecycle State Model

Ten named states, each a place a record can stop existing or become stale independently of every other state.

Swipe to pan
The Full Event LifecycleTen named states — each a place the record can stop existing or become staleGENERATEDBUFFEREDFLUSHEDTO AGENTDISKSPOOLACCEPTED(ingest)DURABLE(broker)READ BYINDEXERINDEXEDQUERYABLEARCHIVEDDELETED
GENERATED → BUFFERED → FLUSHED_TO_AGENT → (optional DISK_SPOOL) → ACCEPTED_BY_INGEST → DURABLE_IN_BROKER → READ_BY_INDEXER → INDEXED → QUERYABLE → ARCHIVED → DELETED
StateWhat it actually guaranteesWhat it does NOT guarantee
GENERATEDThe application produced the record in its own memoryNothing has left the process yet
BUFFEREDIn-process buffer holds itLost on process crash
FLUSHED_TO_AGENTThe record left the app (file, stdout or local socket) and the agent has read itNot yet written to any disk
DISK_SPOOLFsynced to local diskSurvives agent crash, not disk loss
ACCEPTED_BY_INGESTCollector validated and forwarded itNot yet durably replicated anywhere
DURABLE_IN_BROKERCommitted: acked by all in-sync replicas, at least min.insync.replicas of themNot yet read by any indexer
READ_BY_INDEXERAn indexer consumed the bus offsetNot yet written to the index
INDEXEDA document exists in the storeNot yet guaranteed visible to a live query (refresh/merge lag)
QUERYABLEA search can actually find it nowNot guaranteed to stay retained indefinitely
ARCHIVED / DELETEDMoved to cold tier, or removed on scheduleDeletion is final except under an explicit legal hold
Chapter 03

Agent and Collector Mechanics

The agent's job is narrow and specific: get bytes off this host reliably. The collector's job is to turn an authenticated, validated batch into a bus-ready publish.

First, how do log lines cross the process boundary at all? A separate agent process cannot read an application's heap: operating systems isolate process memory, and the agent only ever sees bytes that the application, or the runtime hosting it, deliberately puts somewhere the agent can reach. There are a handful of supported ways to do that, and the choice decides what the agent can promise.

Integration patternHow the bytes reach the agentStrengthCaveat
stdout/stderr captured by the container runtimeThe app prints lines; the runtime (for example the kubelet with containerd) writes them to files on the node, and a node-level agent tails those filesNo application change; works for any processLines are only as durable as the node's log files, which are rotated and deleted with the pod
App-owned log file, tailedThe app appends to a file on a local or shared volume; an agent on the same host, or a sidecar sharing the volume, tails itSimple and widely supported; the file is a natural bufferRotation, partial lines and checkpointing become the agent's job
Local socket or pipe (Unix domain socket, localhost TCP/UDP, journald)The app's logging thread writes to an endpoint the agent listens onStructured records, no parsing, lower latencyThe agent being down or slow means the app must drop or spool, and data in the socket buffer is lost if the agent dies
In-process exporter (SDK) sending to a collectorA background thread inside the app batches and sends over the network; there is no separate agentFewest moving partsPending records live only in app memory unless the SDK keeps a local disk queue
Shared-memory ring bufferThe app and the agent both map the same region and follow an agreed layoutVery low overheadSpecialized and rare: it works only because both sides cooperate, not because the agent can inspect the app

Sidecar and node agent are deployment topologies, not transports

Whether the agent runs as a sidecar in each pod or as one DaemonSet agent per node is a separate choice from how the bytes reach it. A sidecar shares a volume or the pod's network namespace with the app, which isolates one noisy workload from another but costs memory and CPU per pod. A node agent tails the runtime's log files for every pod on the node, which is cheaper and outlives any single pod, but it shares fate across all the workloads on that node. Either way, the agent receives data through one of the patterns above; it does not reach into the application's memory. Lines still sitting in the application's own in-process queue are lost if the app crashes, which is the data-loss window the next sections keep honest.

textIllustrative agent tail loop: the checkpoint moves only after a batch is acknowledged by the next durable stage — not a real SeeItFlow or vendor implementation
function tailLoop(path, checkpointStore, outbox):
  cp = checkpointStore.load(path)            # {fileId, committedOffset}; only ever points at handed-off data
  file = openByFileId(cp.fileId) or openPath(path)
  if file.id != cp.fileId:                   # rotated or replaced while the agent was down
    drainPredecessor(cp)                     # finish the old file from committedOffset first, if it still exists
    cp = {fileId: file.id, committedOffset: 0}
  if file.size < cp.committedOffset:         # copytruncate: same file, content restarted
    cp.committedOffset = 0

  readOffset = cp.committedOffset            # re-read everything not yet acknowledged
  carry = ""                                 # trailing text with no newline yet, kept in memory only
  loop:
    chunk = file.readFrom(readOffset)
    if chunk is empty:
      sleep(pollInterval)
      continue
    text = carry + chunk
    lines, carry = splitCompleteLines(text)  # an unterminated last line waits for its newline
    readOffset += len(chunk)
    if lines:
      endOffset = readOffset - len(carry)    # just past the last complete line
      outbox.enqueue(Batch(file.id, endOffset, [parse(l) for l in lines]))

# outbox.enqueue returns once the batch is in the fsynced disk spool, or the collector has
# acknowledged a durable publish when no spool is configured. Only then is it acknowledged.
function onBatchAcknowledged(batch):         # called in batch order
  cp.committedOffset = batch.endOffset
  checkpointStore.saveDurable(cp)            # write temp file, fsync, rename

The checkpoint follows the acknowledgment, never the read

The checkpoint means 'I will never need to re-read bytes before this offset.' That is only true once the next stage has durably taken those bytes, so the checkpoint advances when a batch is acknowledged, not when it has been read or appended to an in-memory buffer. If the agent crashes after reading but before the acknowledgment, the checkpoint is still behind and the lines are read and sent again, so delivery is at-least-once and duplicates are expected. A duplicate is handled downstream by an idempotent key such as a hash of source, file identity and start offset, which is identical on every replay. Checkpointing is also batched: one durable checkpoint write per acknowledged batch, not per line, so the cost of the fsync is amortised. Two separate durability questions are in play: the checkpoint file's own durability (temp file, fsync, atomic rename, so a crash never leaves a half-written checkpoint) and the durability of the data the checkpoint vouches for (the spool or the broker). Getting the first right does not help if the second was not true when the checkpoint moved.

Rotation, truncation and partial lines

Identify a file by device and inode plus a fingerprint of its first bytes, because filesystems can reuse an inode number soon after a file is deleted. After a rename-based rotation, keep reading the old file descriptor to its end before switching to the new file, and if the agent was down during rotation, finish the old file by its new name where it still exists; if it has already been deleted, record an explicit gap metric instead of pretending nothing was lost. After copytruncate the file keeps its identity but its size drops below the checkpoint, which is the signal to restart at zero, accepting that any lines written between the agent's last read and the truncation are gone. A line without a trailing newline may be half written, so it stays in memory and the checkpoint never points into the middle of a line. After a restart the agent re-reads from the last complete line boundary, and the partial line is simply read again once its newline arrives.

textIllustrative collector admission path — not a real SeeItFlow or vendor implementation
function handleBatch(connection, rawBatch):
  tenant = connection.verifiedTenantIdentity()      # from mTLS cert, never payload
  if not withinRateLimit(tenant):
    return reject(429)

  records = []
  for raw in rawBatch:
    record = parseAndValidateSchema(raw)
    if record is None:
      deadLetter(raw, reason="SCHEMA_INVALID")
      continue
    record.tenant_id = tenant                        # overwrite, don't trust payload
    record = applyRedactionRules(record)
    if record is None:                                # fail-closed redaction
      deadLetter(raw, reason="REDACTION_FAIL_CLOSED")
      continue
    records.append(record)

  batch = compress(records)
  key = partitionKey(tenant, records)                 # composite, see Chapter 04
  return publishDurable(bus, key, batch)              # ack only after broker commit

The collector tier is stateless and horizontally scaled

No agent talks to one fixed collector instance — the collector tier runs as a fleet of identical, stateless replicas behind a standard load balancer, the same way any stateless HTTP service scales. Statelessness here specifically means no collector instance holds state an agent's retry depends on — a retried batch landing on a different replica than the original attempt must succeed identically, which is what makes this tier trivially horizontally scalable under the 12× burst scenario in Failure & Scale Scenarios.

Chapter 04

Durable Bus Internals: Partitions, Acks, and the Durability Boundary

Three decisions on the bus determine the pipeline's actual durability and throughput ceiling: the partition key, the acknowledgment level, and the replication factor.

Swipe to pan
Full Ingest Write Pathapp → agent → spool → collector → broker — each hop a distinct stageAppAgentSpoolCollectorBroker
app → agent → spool → collector → broker — each hop a distinct, independently-failing stage

Partition key choice

A composite key of service plus instance ID or service plus a hashed shard spreads one high-volume service's traffic across many partitions, instead of the naive service-name-only key that funnels an entire service onto one partition. A consumer group's parallelism for a single partition is capped at one consumer reading it at a time, so the hot partition — not the broker count — sets the real ceiling.

textIllustrative composite partition key function — not a real SeeItFlow or vendor implementation
function partitionKey(tenant, records):
  dominantService = mostCommonService(records)
  if tenant.isHighVolumeTenant():
    shard = hash(records[0].instance_id) % tenant.dedicatedShardCount
    return f"{tenant.id}:{dominantService}:{shard}"
  shard = hash(dominantService, records[0].instance_id) % defaultShardCount
  return f"{dominantService}:{shard}"

The ack boundary: option A vs. option B

Option A acks a publish once it is queued in the collector's local memory: low latency, weak durability, because a collector crash loses it. Option B acks only after the broker commit: the partition leader has the record and every replica currently in the in-sync set has it too, and that set holds at least min.insync.replicas members. This design's reference choice is option B. The acknowledgment a producer receives must mean 'a committed broker write that survives the loss of any single broker,' not merely 'a process accepted it.' The next block shows exactly which settings make that sentence true.

Replication factor, acks, min.insync.replicas, and what they really guarantee

Kafka's documented guarantee is that a committed record is not lost as long as at least one in-sync replica (ISR member) that has it remains alive. Four settings decide how far that goes. The replication factor (RF) sets how many copies exist. With acks=all, the leader acknowledges only after every replica currently in the ISR has the record. min.insync.replicas sets the smallest ISR size at which acks=all writes are still accepted; below it, producers get a NotEnoughReplicas error instead of an ack. unclean.leader.election.enable decides whether a replica outside the ISR may become leader when no in-sync replica is left; it defaults to false, which protects acknowledged records by keeping the partition offline instead. A realistic logging setup is RF=3, acks=all, min.insync.replicas=2, unclean election disabled, with replicas spread across racks or zones. That survives any single broker failure with no data loss and no interruption to writes. It does not mean 'RF=3 tolerates losing two brokers': two failures leave one in-sync replica, below the minimum of two, so new writes are rejected. Whether previously acknowledged records survive depends on whether that last replica held them, which min.insync.replicas=2 does not guarantee for a failure of exactly the two brokers that held the up-to-date copies. Setting min.insync.replicas=3 would guarantee it but stop writes after a single failure. Also remember that an ack means the record is replicated in broker memory and the page cache, not that it has been fsynced to disk everywhere, so a simultaneous power loss across replicas is a risk that rack or zone spread reduces but does not remove.

RF=3, acks=all, min.insync.replicas=2, unclean election offISR sizeNew acks=all writesRecords already acknowledged
Healthy cluster3AcceptedOn three replicas
One broker down or lagging out of the ISR2AcceptedOn at least two replicas, so none are lost
Two brokers down; the surviving replica was in sync1Rejected (NotEnoughReplicas): write availability is lostStill on the survivor, readable once it leads, but only one copy remains
Two brokers down; the survivor had fallen behind0 in-syncPartition offline; no write or readNot lost while the failed brokers' disks are intact, but unavailable until one returns. Enabling unclean election restores service by promoting the stale replica and permanently discards records it never received
Chapter 05

Index-Write vs. Offset-Commit Ordering

An indexer has exactly two checkpoint orderings available, and each fails differently when the indexer crashes mid-operation.

Swipe to pan
Two Checkpoint Orderings, Two Failure ModesNeither is 'correct' — each trades duplication against silent lossIndex THEN commit offsetCommit offset THEN indexCrash after index, before commit→ replay re-indexes same record→ DUPLICATE (fixable: upsert by ID)Crash after commit, before index→ resumes past this offset→ SILENT LOSS (not recoverable)
Index-then-commit risks duplicates; commit-then-index risks silent, unrecoverable loss
textIllustrative indexer consume loop — not a real SeeItFlow or vendor implementation
function consumeAndIndex(consumer, indexStore):
  loop:
    batch = consumer.poll()
    for record in batch:
      try:
        indexStore.upsert(record.id, record)   # idempotent by record.id
      except MalformedRecordError:
        deadLetterQueue.send(record)
        continue
    consumer.commitOffset(batch.lastOffset)     # AFTER the index writes succeed

Index-then-commit: if the indexer crashes after the index write but before the offset commit, replay re-indexes the same record on restart. Because the index write is an upsert keyed by a stable record ID, the replay simply overwrites the first write with identical content — a harmless duplicate, not a visible one, as long as the ID is genuinely stable across retries.

Commit-then-index (the reverse ordering) is strictly worse for this use case: a crash after the commit but before the index write means that record is never indexed, and the indexer resumes from an offset already past it — a silent, unrecoverable skip with no automatic replay path. This module's reference design always indexes before committing, specifically to make the recoverable failure (duplicate) the default outcome instead of the unrecoverable one (loss).

Chapter 06

Three Storage Options, Compared Honestly

Option A (inverted full-text index), option B (label + compressed chunk), and option C (object-store-first with a columnar layer) solve the same problem with different cost curves.

OptionIndexesStrengthWeakness
A — Inverted index (ES/OpenSearch style)Every token in indexed fieldsFast, flexible free-text search and aggregationHighest write cost and storage overhead per event
B — Label + chunk (Loki style)Only low-cardinality labelsCheapest at ingest, scales well for high volumeFree-text search means scanning more chunks; high-cardinality labels catastrophically explode stream count
C — Object-store-first + columnar (lake style)Partition/time metadata onlyCheapest long-term storage, good for batch analyticsWorst interactive query latency; not suited to live incident response

This design's reference choice

A hot tier using option A for the 0–7 day window, where interactive, flexible incident search matters most and volume is smallest, paired with option B's cheaper label-chunk model (or an equivalent columnar approach) for the warm/cold tiers, where volume is largest and most queries are already scoped by known labels and a time range. This is a genuine trade-off, not a universal best practice — a team that never does free-text search could reasonably run option B everywhere, and a team obsessed only with interactive search and unconcerned with cost could run option A everywhere.

Swipe to pan
request_id as a Label Explodes the Stream CountHigh-cardinality values belong in structured fields, never in labelsBefore~40 streams (service × region × level)Aftermillions of 1-event streams+ request_idas a label
request_id as a label: ~40 streams becomes millions of near-single-event streams
Chapter 07

Query Architecture: Planning, Fan-Out, Merge

A query request passes through five ordered stages before any storage shard is touched, and a sixth before the caller sees a result.

textIllustrative query planner — not a real SeeItFlow or vendor implementation
function planAndExecute(request, credential):
  tenantScope = resolveScopeFromCredential(credential)   # never from request.tenant_id
  if not tenantScope.permits(request.requestedScope):
    return reject(403)

  window = clampTimeRange(request.timeRange, maxWindow=tenantScope.policy.maxWindow)
  shards = planShards(tenantScope, window, request.filters)   # time-sliced + label-pruned
  budget = ScanBudget(maxBytes=tenantScope.policy.scanBudget, maxConcurrency=8)

  futures = []
  for shard in shards[:budget.maxConcurrency]:
    futures.append(asyncQueryShard(shard, request.filters, budget))
  partials = awaitAll(futures, timeout=tenantScope.policy.queryTimeout)

  merged = mergeSortedByKey(partials, key=(event_time, record_id))
  return paginate(merged, request.cursor)

Deterministic tie-break is not optional

Sorting purely by event_time breaks pagination the instant two records share a timestamp across shards — a stable secondary key (record_id, or a monotonic per-shard sequence) is required for the merge to produce the same order on every page request.

Budget enforcement happens at plan time

The scan budget and concurrency cap are applied before any shard query is dispatched, not as a timeout after the fact — this is what stops a single unbounded query from starving every other concurrent search, and is the direct fix for the query-storm failure scenario.

Chapter 08

Multi-Tenancy and Security Mechanics

Tenant isolation is enforced at every layer a record or a query passes through — never only at the first authentication check.

LayerIsolation mechanism
Collector admissionTenant attributed from verified connection identity, overwriting any payload-claimed value
BusTenant-scoped topic/partition namespace, or a mandatory tenant field enforced at produce time
IndexTenant-scoped index/namespace, or a mandatory, non-bypassable tenant filter injected server-side into every query
Query plannerAuthorized scope resolved from the credential; a request-supplied tenant_id is rejected if it doesn't match
AuditEvery query logged with the resolved (not claimed) tenant scope, for after-the-fact review

Redaction as a minimization control, not a cleanup step

Secret-shaped values are stripped before a record becomes durable anywhere, not after. A redaction rule that cannot confirm a field is safe fails closed — holding back the field, or the whole record, rather than guessing it's fine.

Chapter 09

The Ten Correctness Invariants

Every mechanism in this design exists to uphold one of these ten invariants. If a proposed addition doesn't map to one of them, it's probably solving a different system's problem.

Key takeaways
  1. 01I1 — Application isolation: no logging backpressure may block or fail the request path
  2. 02I2 — Unambiguous ACK: every acknowledgment names the exact durability stage it crossed
  3. 03I3 — Replay correctness: at-least-once delivery is assumed; duplicates are made harmless via idempotent keys, not assumed away
  4. 04I4 — Tenant isolation: a client-supplied tenant_id is a hint, never an authorization boundary
  5. 05I5 — Secret minimization: redaction happens before durable persistence, fail-closed on uncertainty
  6. 06I6 — Bounded resource use: every buffer, spool, and queue has a capacity and an explicit overload policy
  7. 07I7 — Search honesty: accepted, indexed, and queryable are reported as the distinct states they are
  8. 08I8 — Retention accountability: deletion follows a documented schedule; legal hold is an explicit, auditable override
  9. 09I9 — No global ordering: timestamps from hosts with independent clocks can disagree about order, so cross-host order is never claimed as exact
  10. 10I10 — Incident self-protection: the pipeline's own health is monitored through a path independent of the pipeline itself