Engineering Practice
Stable Log Messages and Structured Context Scale Better
Machine-readable logs with fixed message text and structured fields outperform string interpolation for search, alerting, redaction, and cardinality control.
- Application Logging
- Structured Data
- Observability
- System Design
- Search and Alerting
Machine-readable logs work better when the message is stable and variable data lives in structured fields rather than string interpolation. The difference becomes obvious when you query millions of events or try to alert on patterns you didn’t anticipate at log-write time.
String interpolation feels natural at first. You write logger.info(f"User {user_id} logged in from {ip_address}"), and the log line is human-readable. But the moment you need to search for all login events from a specific subnet, or count unique users, or redact IP addresses before exporting logs, you’ve built yourself a parser problem. The parser has to extract structure from sentences—a task that breaks when the sentence changes.
Structured logging inverts this. The message stays fixed and semantic: logger.info("user.login", user_id=123, ip_address="198.51.100.42"). The log system sees a stable event type and a set of named fields. Your search engine doesn’t parse; it indexes. Your alerting engine doesn’t regex; it filters. Your redaction policy doesn’t guess; it knows which fields need masking. That’s the boundary between a log that scales and one that becomes a liability.
A stable event vocabulary is your contract
The message text itself must be stable. Not the data inside it—the sentence.
Don’t do this:
logger.info(f"Database query took {duration_ms}ms")
logger.info(f"Database operation took {duration_ms}ms") # oops, changed
Your log aggregator now has two event types where you thought you had one. Your alert on “query took > 5000ms” misses half the slow queries. Your cardinality explodes because every permutation of wording becomes a new series.
Do this:
logger.info(
"database.query.complete",
duration_ms=2150,
operation="SELECT"
)
The event name is an invariant. It doesn’t change because someone rewrote the message for clarity or added a unit suffix. It becomes a contract. When you’re designing for a system that will emit millions of events, the contract is the asset. Every consumer of that log—the search engine, the alert rule, the billing system, the compliance audit—depends on that vocabulary staying consistent.
Choose names that are namespaced, specific, and unlikely to collide. database.query.complete is better than query_done because it distinguishes the event from cache.query.complete or index.query.complete. It’s testable: you can grep your codebase to see exactly where the event fires and verify the fields always appear.
Structured fields are where the variation lives
Variable data goes into fields, not into the message sentence. This is how you achieve both readability and queryability.
Every field should answer one of these questions:
- What happened? (operation type, event name—often in the message)
- Who or what was affected? (user ID, resource ID, tenant ID)
- What were the conditions? (duration, status code, error reason)
- Where did it happen? (service name, host, region)
- When? (timestamp, usually automatic)
Fields should be named consistently across all events. If you log a user ID in one event as user_id and in another as uid, your search queries become compound statements or miss half the data. If you log duration in one event as ms and another as seconds, comparison queries break.
Set field names as schema. If you’re using JSON, define them once and validate on write. If you’re using a logging library with structured context, document the field contract. Make it part of code review: new events must follow the naming and type conventions of existing ones.
Fields should also be granular. Don’t log "user_action": "login_from_subnet_10.0.0.0/24_at_3pm"; log user_id, action, subnet, hour_of_day separately. The cost of separate fields is lower than the cost of parsing compound values later.
Search and alert reliability depend on cardinality discipline
When you query logs by event_name = "user.login", your aggregator scans one partition, not ten thousand. When you alert on database.query.duration_ms > 5000, the alert engine checks a bounded set of time series, not an explosion of variants.
Cardinality is how many unique values a field can hold. A field with low cardinality—like event_name, status, operation—indexes cheaply and queries fast. A field with unbounded cardinality—like ip_address, user_id, or free-text error messages—becomes expensive because the system must track and store every permutation.
High-cardinality fields are not forbidden. User IDs and IP addresses are essential. But they should be fields in a structured context, not part of the message string. Your log system can be configured to index them differently: store the value, allow exact match queries, but don’t build full-text search indices over them. You save storage and query latency.
If you have a field that could have millions of unique values, ask whether it needs to be indexed at all. Can you log it as context for debugging but not make it queryable? Can you hash it or bucket it? Can you log a category instead of the raw value? These are design decisions that pay off when your log volume grows.
Redaction and compliance become testable
When PII, secrets, or sensitive data lives inside a string message, redaction is a scanning problem. You write regex to find and mask patterns, and you live in fear of the pattern you missed.
When sensitive data is in a known field, redaction is a schema problem. You mark the field as sensitive. Your logging library or aggregator omits it before export, or masks it with a hash, or redacts it according to policy. The rule is declarative and testable. You can prove that password fields never leave the trusted boundary.
This also simplifies compliance audits. When someone asks “what data do you log,” you point to the schema. When they ask “which events contain IP addresses,” you query the schema, not grep output. When they ask “prove that PII is redacted before export,” you show the redaction policy tied to the field definition.
Structured fields also make it easier to implement least-privilege log access. You might allow oncall to query logs for a specific service but not see IP addresses or user IDs. You can enforce that in the aggregator: certain roles see certain fields, redacted by default. That’s not possible with interpolated strings.
Logging design review checklist
Before you ship a new logging point, run through these questions:
- Event name: Is it stable, namespaced, and unlikely to collide? Can you grep the codebase to find all occurrences?
- Required fields: Which fields must always be present? (Usually: timestamp, event name, service/host, trace ID if applicable.)
- Context fields: What information would someone need to understand this event without reading the code? Are those fields always populated?
- Cardinality: Are any fields unbounded? If so, are they really needed as queryable dimensions, or can they be context-only?
- Sensitivity: Does any field contain PII, secrets, or compliance-sensitive data? Is it marked for redaction?
- Consistency: Do field names and types match other events in the same domain?
- Testable: Can you unit test that the event fires with the right field values?
This checklist isn’t about perfection. It’s about catching the most common mistakes: fields that should be stable but aren’t, cardinality that will explode, sensitive data that should have been redacted.
The payoff lives in operations, not in the log line
Structured logging is boring. There’s no elegance to it, no moment where you think, “look at this clever message format.” It feels like overhead compared to a printf statement.
The payoff arrives at 2 a.m. when you’re investigating an outage. You search for event_name = "api.request.timeout" and duration_ms > 30000 across the last hour and find fourteen events. You click one and see exactly which endpoint, which client, which trace ID. You don’t parse. You don’t guess.
Or it arrives when you’re scaling: your log volume tripled, but your query latency stayed flat because you indexed by stable event names, not by string content. Your alert that fires on pattern changes actually works because the events it watches have consistent structure.
Or it arrives when you’re building compliance evidence: you export logs to the audit system, and sensitive fields are already redacted because the schema told the system which fields to mask.
Structured logging scales because it trades human convenience at write time for machine reliability at query time. At the scale where logs matter, that’s the right trade.
/contact