Logs are data too
Structured logs turn operational facts into queryable records. The shape, volume, retention, and identifiers matter as much as the messages.
Logs usually begin as small notes a program leaves for itself.
One line when a request starts. One line when it fails. A few values dropped into a string because someone, someday, might want to see them.
For a single process on a single host, that is enough. But add more services, more hosts, queues, retries, and deploy versions, and those notes quietly become something larger: data produced by the running system, scattered across every machine that touched the request.
So there is one question worth holding onto:
Could we query this record during an incident without already knowing the answer?
When the answer is yes, the log has earned its place. When the answer is no, it rests in the file as decoration, present but silent in the moment you most need it to speak.
Start with the record
A log line is a record, and the message is just one field within it.
The most useful fields tend to be the most ordinary ones:
| Field | Why it matters |
|---|---|
timestamp | Orders events approximately and supports time windows |
level | Routes each record to the attention it deserves |
event | Names the fact that happened |
service | Identifies the component that emitted the record |
environment | Names the deployment a record came from: production, staging, development |
version | Connects behavior to a deployed build |
correlation_id | Follows one request or workflow across boundaries |
actor_id | Identifies the user, service, or job taking the action |
aggregate_id | Identifies the domain object being changed |
attempt | Shows retries without guessing from repeated messages |
duration_ms | Makes latency queryable |
result | Makes success, failure, timeout, and skipped paths explicit |
error_class | Groups failures by type so they aggregate cleanly |
The fields are the interface. When the only stable part of a log is the sentence, querying it later turns into a kind of string archaeology, slow and uncertain.
Logs are records first
This line is quick to type and reads cleanly on a local terminal:
logger.info(f"User {user_id} created post {post_id} in {duration_ms}ms")
Its values live inside a sentence written for a human eye. But to count post creation latency by service version, a log system has to take that sentence apart again, undoing the work that made it readable.
This shape keeps the same values in named fields:
logger.info(
"post_created",
extra={
"event": "post.created",
"service": "posts-api",
"version": app_version,
"environment": environment,
"correlation_id": request.headers.get("X-Request-ID"),
"user_id": user_id,
"post_id": post_id,
"content_length": len(content),
"duration_ms": duration_ms,
"result": "success",
},
)
The sentence can still exist for the human. The data simply lives, in parallel, in named fields for the machine. Both can be true at once.
Event names
An event name should be stable enough to query for months without changing under you.
Good event names tend to be:
- Specific.
- Lowercase.
- Past tense for facts.
- Consistent across services.
- Not packed with variable data.
For example:
| Queryable event name | Message a human might type |
|---|---|
post.created | created post 928173 |
payment.authorization_failed | payment bad |
job.retry_scheduled | retrying maybe |
cache.stampede_blocked | lock worked |
request.deadline_exceeded | too slow |
The left column names a stable kind of fact and stays the same across millions of events. The right column folds variable data into prose, so each row becomes a fresh string to match against.
A simple division keeps things clear: the event name answers "what kind of thing happened?" and the fields answer "to which object, under which request, with which result?"
Correlation IDs
A correlation ID gives a single logical operation one name to carry as it crosses services, queues, workers, and storage systems. Wherever the work travels, the name travels with it.
At the edge, generate one if the caller did not send one:
def get_correlation_id(request):
incoming = request.headers.get("X-Request-ID")
if incoming:
return incoming[:128]
return uuid.uuid4().hex
Then pass it through every boundary:
def create_post(request):
correlation_id = get_correlation_id(request)
post = db.insert_post(
user_id=request.user.id,
content=request.json["content"],
)
queue.publish(
"post.created",
{
"post_id": post.id,
"user_id": request.user.id,
"correlation_id": correlation_id,
},
)
logger.info(
"post_created",
extra={
"event": "post.created",
"post_id": post.id,
"user_id": request.user.id,
"correlation_id": correlation_id,
},
)
return {"post_id": post.id}
That one ID becomes a shared thread running through the whole workflow, tying together every service it touches.
Logs, metrics, and traces
These three tools overlap, and yet each answers its own kind of question.
| Tool | Shape | Good for |
|---|---|---|
| Log | Event record | Explaining what happened in one case |
| Metric | Numeric time series | Watching rates, latency, saturation, and SLOs |
| Trace | Causal span graph | Following one request through many services |
A single incident often draws on all three in turn:
- Metric shows
payment.authorization_failedincreased. - Trace shows failures cluster behind one dependency call.
- Logs show the dependency returned
429after a rollout changed request volume.
The log gives detail. The metric gives scope. The trace gives shape. Together they let you see the same event from three honest angles.
Levels are an operating contract
Log levels work best when they mean something stable.
| Level | Meaning | Example |
|---|---|---|
ERROR | Something failed and likely needs action or automatic recovery | Payment authorization exhausted retries |
WARN | Something unusual happened, but the system handled it | Cache refresh skipped and stale value served |
INFO | A useful business or lifecycle fact happened | Post created, job completed, deploy started |
DEBUG | Detail useful during development or narrow investigation | Intermediate parser state |
ERROR should mean a real failure, one that warrants action. Reserve it for those, and the alert stream stays meaningful and worth trusting. Log real failures at the right level, and the system keeps its visibility exactly when it needs it most.
Each level carries its own quiet promise. WARN records a handled surprise. INFO records a lifecycle fact. DEBUG records detail for a narrow investigation. When every team keeps those promises, a reader can trust the level before ever reading the message.
Seen this way, levels are simply routing rules for attention.
Aggregation is part of the system
Local logs are temporary by nature. Containers restart, hosts disappear, disks fill. A service that only logs to local files is observable only while that local file remains within reach.
At scale, logs need a pipeline:
application -> stdout/file -> collector -> transport -> index/store -> query/alert
Each step has failure modes:
| Step | Failure to plan for |
|---|---|
| Application | Blocks while trying to write logs |
| Collector | Drops logs during restart or backpressure |
| Transport | Reorders or duplicates records |
| Index | Rejects records with unexpected field types |
| Store | Retention expires before an investigation starts |
| Query | High-cardinality fields make searches slow or expensive |
Logging is a production dependency like any other. So its backpressure, drop behavior, retention, and cost limits all deserve to be understood rather than left to chance.
Sampling
Some events arrive too frequently to keep in full detail.
Page views, health checks, cache hits, and successful background jobs can easily dominate log volume. Keeping every record may end up costing more than the value it returns.
So sampling is best done deliberately:
- Always keep errors.
- Always keep security and audit events.
- Sample high-volume success paths.
- Sample by correlation ID or stable key so whole workflows stay together.
- Record the sample rate on the event.
Stable sampling keeps related logs in each other's company:
import hashlib
def should_sample(key, rate):
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
bucket = int(digest[:8], 16) / 0xFFFFFFFF
return bucket < rate
def log_post_view(post_id, user_id, correlation_id):
if not should_sample(correlation_id, rate=0.01):
return
logger.info(
"post_viewed",
extra={
"event": "post.viewed",
"post_id": post_id,
"user_id": user_id,
"correlation_id": correlation_id,
"sample_rate": 0.01,
},
)
Sampling by workflow keeps the story whole, so that when a record is selected, it is still usable end to end.
Retention
Retention is part of the design, not an afterthought.
Different records call for different lifetimes:
| Record type | Typical retention thought |
|---|---|
| Debug logs | Short, often hours or days |
| Request logs | Long enough for incident review and support |
| Error logs | Long enough to compare releases and recurring failures |
| Security logs | Long enough for investigation and policy |
| Audit events | Long enough for product, legal, or financial requirements |
Hot storage answers queries in milliseconds and costs the most per gigabyte. Cold storage answers in seconds or minutes and costs cents on the dollar. A retention policy that matches the way the data is actually used keeps both the cost and the access in balance.
If support tickets tend to arrive two weeks after the user action, the searchable window has to reach back two weeks to meet them. If debug logs carry large payloads, a short window keeps that volume from quietly accumulating into a year of storage no one will ever query.
Logs as audit trail
Some logs are operational. Others are audit records. The two look alike on the page, but each deserves its own standards, applied with care.
An audit trail usually needs these properties:
- Stable schema.
- Append-only storage.
- Actor and target identifiers.
- Before and after values when appropriate.
- Authorization context.
- Tamper evidence or restricted write access.
- Clear retention.
- PII minimization.
For example:
def change_role(actor_id, user_id, new_role):
old_role = db.get_user_role(user_id)
db.update_user_role(user_id, new_role)
audit_log.write(
{
"event": "user.role_changed",
"actor_id": actor_id,
"user_id": user_id,
"old_role": old_role,
"new_role": new_role,
"result": "success",
}
)
This is a domain record, and it deserves to be treated as one.
Event streams and logs
Logs and event streams are both append-only records. What sets them apart is the contract each one carries.
| Question | Debug log | Event stream |
|---|---|---|
| Is delivery required? | Usually no | Usually yes |
| Can consumers depend on schema? | Often weak | Should be strong |
| Can the record be replayed? | Maybe | Usually yes |
| Is ordering defined? | Often no | Usually per key or partition |
| Is retention a product concern? | Sometimes | Often yes |
The same event shape can serve both observability and downstream work. When another system depends on a fact, give that fact a delivery contract so it arrives, holds its schema, and can be replayed. A debug log, by contrast, makes its best effort and stays purely observational, and that is fine for what it is.
If another system must react to the fact, write an event with a delivery contract:
def publish_post_created(tx, post):
tx.insert_outbox_event(
event_type="post.created",
aggregate_id=post.id,
payload={
"post_id": post.id,
"user_id": post.user_id,
"created_at": post.created_at.isoformat(),
},
)
The logger can note that the event was written, while the outbox holds the delivery boundary itself.
PII and secrets
Logs spread quickly. They move from application memory into collectors, indexes, dashboards, tickets, notebooks, and cold storage. Anything placed into a log is, in practice, likely to be copied many times over.
For that reason, logging benefits from both a list of what to leave out and a list of what to keep.
Do not log:
- Passwords.
- Session tokens.
- API keys.
- Full payment details.
- Raw authorization headers.
- Large request bodies by default.
- Private messages unless the feature explicitly requires secure audit handling.
Do log identifiers that allow lookup in the proper system:
logger.info(
"payment_authorization_failed",
extra={
"event": "payment.authorization_failed",
"payment_id": payment.id,
"order_id": order.id,
"gateway": "stripe",
"gateway_error_code": error.code,
"correlation_id": correlation_id,
},
)
The log points to sensitive data through identifiers, while the values themselves stay safely in the system that owns them.
Schema drift
Structured logs fail quietly when field names begin to drift apart.
One service logs user_id. Another logs uid. A third logs userId. One version writes duration_ms as a number, another as a string. Queries become partial, dashboards lie by omission, and incident review slows down just when speed matters.
A small schema contract steadies this:
def log_event(logger, event, **fields):
required = {
"event": event,
"service": SERVICE_NAME,
"version": VERSION,
"environment": ENVIRONMENT,
}
logger.info(event, extra={**required, **fields})
For larger systems, this grows into a shared logging package or a schema registry. The aim is the same throughout: to make the important fields boring and consistent, so they can be trusted without a second thought.
What I notice
The systems that are easiest to debug tend to log a steady, modest number of facts.
They log the same few facts consistently. They use stable event names. They pass correlation IDs through every path. They mark expected validation at one level and real failure at another. They keep the fields small enough to index. And they know which records are sampled and which are always kept.
A system can also carry many expressive log lines whose values live inside prose. During an incident, someone then sits and reconstructs the record by hand, line by line.
A good log record arrives already structured. It answers the query directly and quietly narrows the work that remains.
Design the log event
For a new user-facing write, it helps to define the log event before the incident rather than during it.
Example for post creation:
| Field | Value |
|---|---|
event | post.created |
level | INFO |
service | posts-api |
correlation_id | Generated at edge, propagated to queue event |
user_id | Author id |
post_id | Created post id |
content_length | Integer, not content body |
duration_ms | API handler duration |
result | success, validation_failed, timeout, unknown |
attempt | Request attempt number if retry-aware |
sample_rate | 1.0 for writes |
Then define the queries that should work:
event:post.created result:success service:posts-api
event:post.created user_id:1234
event:post.created correlation_id:abc123
event:post.created result:timeout version:2026.05.23.1
If those queries cannot be answered, the event is not yet shaped, and that is simply a signal to keep refining it.
Metrics that help
The most useful logging metrics are about the pipeline and the records themselves:
| Metric | Why it matters |
|---|---|
| Log events emitted per service | Shows volume and cost shape |
| Log events dropped by collector | Shows observability loss |
| Error logs by event name | Shows failure category without string parsing |
| Unknown or timeout results | Shows work that needs reconciliation |
| Missing correlation IDs | Shows propagation gaps |
| Index rejection count | Shows schema drift or bad field types |
| Query latency | Shows whether logs are still usable during incidents |
| Storage by event type | Shows which events dominate cost |
| Sampled event rate | Shows whether sampling is behaving as expected |
The logging system itself needs watching too. Without it, the logging can fail at the very moment the application needs it most.
What I think
The practical sequence is steady and unhurried:
- Define common fields.
- Use stable event names.
- Pass a correlation ID through every boundary.
- Put queryable values in named fields.
- Keep secrets and large payloads out by default.
- Treat log levels as an operating contract.
- Aggregate logs outside the host or container.
- Sample high-volume success paths by workflow.
- Never sample records that carry correctness, security, billing, or audit meaning unless another durable source exists.
- Set retention by record type.
- Watch the logging pipeline for drops and schema rejects.
The underengineering move is to log a small set of facts and give each one a stable shape, and then to stop there.
That small set of stable records tends to carry most of the value when an incident arrives.
Tutorial checklist
For any important event, it helps to fill this out:
| Question | Example answer |
|---|---|
| Event name | payment.authorization_failed |
| Level | ERROR if retries exhausted, WARN if handled by retry |
| Service | checkout-api |
| Correlation ID | Required from edge request or generated at ingress |
| Actor | user_id or calling service |
| Aggregate | order_id, payment_id |
| Result values | success, declined, timeout, unknown, retry_scheduled |
| Duration field | duration_ms as a number |
| Error grouping | error_class, gateway_error_code |
| Sampling | Never sampled for payment state changes |
| Retention | Hot 30 days, cold 1 year |
| Sensitive data | No card data, no raw authorization headers |
| Query to support | Find all failed authorizations for an order |
| Alert to support | Error rate by gateway and deploy version |
Then ask:
Can this record be found, grouped, and trusted without parsing a sentence?
If not, the log is not yet structured enough, and that is just the next thing to tend to.
Summary
- Logs are data produced by the running system.
- A useful log line is a record with named, queryable fields.
- Event names should be stable, specific, and queryable.
- Correlation IDs connect local service stories into one workflow story.
- Logs, metrics, and traces answer different questions.
- Log levels route attention and should have consistent meaning.
- Aggregation, indexing, retention, and drops are part of the logging design.
- Sampling should preserve workflows and never hide important correctness records.
- Audit logs need stable schema, actor and target IDs, retention, and controlled writes.
- Logs can feed event thinking; a fact other systems depend on belongs in an event stream with a delivery contract.
- The best logs reduce guessing during incidents.
Pop quiz
Interactive quiz
Logs as operational data
A randomized review of structured logging, correlation IDs, sampling, retention, and audit boundaries.