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.
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.
{
"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.
| Timestamp | Stamped by | Which clock | What it is good for |
|---|---|---|---|
| event_time (t_event) | The application or its log library | Wall clock of the producing host | Ordering and display within one host; cross-host only as accurate as that host's clock sync |
| observed_time (collection) | The collector, at admission | Wall clock of a platform-managed collector, with monitored time sync | The 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 CreateTime | Wall clock of a broker, or of the producer | When 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 refresh | Wall clock of an indexer host | When the record became searchable. Search visibility can also wait for an index refresh interval |
| Elapsed time | One process, using a monotonic clock | Monotonic 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.
The Event Lifecycle State Model
Ten named states, each a place a record can stop existing or become stale independently of every other state.
| State | What it actually guarantees | What it does NOT guarantee |
|---|---|---|
| GENERATED | The application produced the record in its own memory | Nothing has left the process yet |
| BUFFERED | In-process buffer holds it | Lost on process crash |
| FLUSHED_TO_AGENT | The record left the app (file, stdout or local socket) and the agent has read it | Not yet written to any disk |
| DISK_SPOOL | Fsynced to local disk | Survives agent crash, not disk loss |
| ACCEPTED_BY_INGEST | Collector validated and forwarded it | Not yet durably replicated anywhere |
| DURABLE_IN_BROKER | Committed: acked by all in-sync replicas, at least min.insync.replicas of them | Not yet read by any indexer |
| READ_BY_INDEXER | An indexer consumed the bus offset | Not yet written to the index |
| INDEXED | A document exists in the store | Not yet guaranteed visible to a live query (refresh/merge lag) |
| QUERYABLE | A search can actually find it now | Not guaranteed to stay retained indefinitely |
| ARCHIVED / DELETED | Moved to cold tier, or removed on schedule | Deletion is final except under an explicit legal hold |
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 pattern | How the bytes reach the agent | Strength | Caveat |
|---|---|---|---|
| stdout/stderr captured by the container runtime | The 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 files | No application change; works for any process | Lines are only as durable as the node's log files, which are rotated and deleted with the pod |
| App-owned log file, tailed | The app appends to a file on a local or shared volume; an agent on the same host, or a sidecar sharing the volume, tails it | Simple and widely supported; the file is a natural buffer | Rotation, 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 on | Structured records, no parsing, lower latency | The 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 collector | A background thread inside the app batches and sends over the network; there is no separate agent | Fewest moving parts | Pending records live only in app memory unless the SDK keeps a local disk queue |
| Shared-memory ring buffer | The app and the agent both map the same region and follow an agreed layout | Very low overhead | Specialized 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.
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, renameThe 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.
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 commitThe 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.
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.
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.
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 off | ISR size | New acks=all writes | Records already acknowledged |
|---|---|---|---|
| Healthy cluster | 3 | Accepted | On three replicas |
| One broker down or lagging out of the ISR | 2 | Accepted | On at least two replicas, so none are lost |
| Two brokers down; the surviving replica was in sync | 1 | Rejected (NotEnoughReplicas): write availability is lost | Still on the survivor, readable once it leads, but only one copy remains |
| Two brokers down; the survivor had fallen behind | 0 in-sync | Partition offline; no write or read | Not 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 |
Index-Write vs. Offset-Commit Ordering
An indexer has exactly two checkpoint orderings available, and each fails differently when the indexer crashes mid-operation.
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 succeedIndex-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).
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.
| Option | Indexes | Strength | Weakness |
|---|---|---|---|
| A — Inverted index (ES/OpenSearch style) | Every token in indexed fields | Fast, flexible free-text search and aggregation | Highest write cost and storage overhead per event |
| B — Label + chunk (Loki style) | Only low-cardinality labels | Cheapest at ingest, scales well for high volume | Free-text search means scanning more chunks; high-cardinality labels catastrophically explode stream count |
| C — Object-store-first + columnar (lake style) | Partition/time metadata only | Cheapest long-term storage, good for batch analytics | Worst 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.
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.
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.
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.
| Layer | Isolation mechanism |
|---|---|
| Collector admission | Tenant attributed from verified connection identity, overwriting any payload-claimed value |
| Bus | Tenant-scoped topic/partition namespace, or a mandatory tenant field enforced at produce time |
| Index | Tenant-scoped index/namespace, or a mandatory, non-bypassable tenant filter injected server-side into every query |
| Query planner | Authorized scope resolved from the credential; a request-supplied tenant_id is rejected if it doesn't match |
| Audit | Every 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.
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.
- 01I1 — Application isolation: no logging backpressure may block or fail the request path
- 02I2 — Unambiguous ACK: every acknowledgment names the exact durability stage it crossed
- 03I3 — Replay correctness: at-least-once delivery is assumed; duplicates are made harmless via idempotent keys, not assumed away
- 04I4 — Tenant isolation: a client-supplied tenant_id is a hint, never an authorization boundary
- 05I5 — Secret minimization: redaction happens before durable persistence, fail-closed on uncertainty
- 06I6 — Bounded resource use: every buffer, spool, and queue has a capacity and an explicit overload policy
- 07I7 — Search honesty: accepted, indexed, and queryable are reported as the distinct states they are
- 08I8 — Retention accountability: deletion follows a documented schedule; legal hold is an explicit, auditable override
- 09I9 — No global ordering: timestamps from hosts with independent clocks can disagree about order, so cross-host order is never claimed as exact
- 10I10 — Incident self-protection: the pipeline's own health is monitored through a path independent of the pipeline itself