At 2 a.m., a customer reports failed checkouts. The dashboard shows increased errors, but the useful detail is buried in inconsistent plaintext: one service writes userId, another writes uid, and the route appears only inside a sentence. Three minutes disappear while someone builds a fragile regular expression.
Structured logs change the next move. A stable event can be filtered by service, environment, route, tenant, severity, status, and correlation identifier in a Live Tail interface, then investigated through an MCP-compatible client using the same account and fields. The practical test is simple: every field must help an engineer isolate an incident, correlate a request, or explain a failure. Anything else creates storage and query overhead without paying rent.
Table of Contents
- Why Structured Logging Matters During an Incident
- A Baseline Event Schema That Scales
- Designing Fields That Stay Searchable
- Context Propagation With Trace and Span IDs
- Errors, Exceptions, and Failure Signals
- Performance, Volume, and Sampling
- Privacy, PII, and Secret Handling
- Ingestion and Routing Patterns
- Correlating Logs With Traces and Metrics
- Anti-Patterns That Quietly Erode Queryability
- Quick Reference and Next Steps
Why Structured Logging Matters During an Incident
Free-text logs force the investigator to interpret language before searching data. A message such as checkout failed for tenant acme on POST /pay may be readable, but it makes route, tenant, and outcome dependent on parsing conventions. A structured record exposes those values as fields, so a human can filter them directly in Live Tail and an MCP-compatible client can query the same dimensions without guessing how the sentence was written.
That matters because log volume is already a cost and usability problem. A major observability survey found that 57% of organizations limit log ingestion or storage because of cost pressure, while the dataset analyzed approximately 2.5 petabytes of logs at an average ingestion rate of 500 gigabytes per day. Those figures are reported in the observability challenge survey, and they explain why filtering, severity control, stable schemas, and high-value context belong in the design rather than being added later.
Incident rule: A log field earns its place when it helps answer “which request, which service, which state, and what failed?” without regex reconstruction.
Correlation identifiers provide the next pivot. Once a matching trace_id or request_id is found, the engineer can move from a filtered log event to the broader request narrative. Without that structure, the team may still find the answer, but the search depends on timing, text conventions, and luck.
A Baseline Event Schema That Scales
A production schema should be small, explicit, and stable. Required fields should identify the event and its origin. Optional fields should add operational context only when the service can populate them accurately.
The baseline below uses snake_case consistently. RFC 3339 timestamps with an explicit timezone keep event ordering understandable, while a documented naming convention prevents one service from emitting requestId and another from emitting request_id. OpenTelemetry defines a broader log model with fields including timestamp, observed timestamp, severity, body, resource, instrumentation scope, attributes, and optional trace context, and it makes clear that structure depends on stable names, types, and semantics, not merely JSON encoding. The OpenTelemetry log data model guidance provides that distinction.
| Field | Type | Required | Used by Live Tail filter | Used by MCP query |
|---|---|---|---|---|
timestamp |
RFC 3339 string | Yes | Time window | “Find events during the incident window” |
severity |
Enum or integer | Yes | Severity filter | “Summarize error and warning events” |
service |
String | Yes | Service filter | “Query checkout service failures” |
environment |
Enum | Yes | Environment filter | “Limit results to production” |
message |
String | Yes | Message search | “Explain matching failure messages” |
correlation_id |
String | Yes | Request or correlation filter | “Follow this request across services” |
trace_id |
String | No | Trace correlation filter | “Find logs for this trace” |
span_id |
String | No | Span correlation filter | “Show events for this operation” |
tenant_id |
String | No | Tenant filter | “Find affected tenants” |
route |
String | No | Route filter | “Compare failures by endpoint” |
status_code |
Integer | No | Status filter | “Group failures by response code” |
duration_ms |
Number | No | Latency filter | “Find slow matching requests” |
error |
Object | No | Exception field filter | “Discover exception types and summaries” |
An illustrative event can look like this:
EXAMPLE 1
{
"timestamp": "2026-09-15T02:14:09.381Z",
"severity": "ERROR",
"service": "checkout",
"environment": "production",
"message": "Payment authorization failed",
"event_name": "payment_authorization_failed",
"correlation_id": "req-7f21",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"tenant_id": "tenant-42",
"route": "POST /checkout",
"status_code": 502,
"duration_ms": 842,
"error": {
"type": "PaymentProviderTimeout",
"message": "Authorization provider timed out",
"is_terminal": false
}
}
Across services, the top-level names should remain unchanged. An inventory service might emit event_name: "inventory_reservation_failed" with the same service, environment, correlation_id, and error shape. Teams can extend the event-specific context, but they shouldn't rename the shared envelope.
A useful companion is the log normalization reference, which helps frame normalization as a queryability concern rather than a formatting exercise.
Designing Fields That Stay Searchable
A field can be structured and still be operationally poor. The main risks are unstable names, unbounded cardinality, and values that change meaning between services.
Consider the difference:
checkout failed for user 9f8... on route /checkout
and:
{
"message": "Checkout failed",
"event_name": "checkout_failed",
"tenant_id": "tenant-42",
"route": "POST /checkout",
"outcome": "failure"
}
The second event separates human-readable narrative from typed data. Live Tail can filter route or tenant_id, while an MCP-compatible client can ask for failure events grouped by event_name or outcome. The message may be edited for clarity without breaking those investigations.
Cardinality requires judgment. A bounded outcome field such as success, failure, or timeout is easier to aggregate than free-form result text. A request identifier is valuable for following one transaction, but it shouldn't become the grouping field for a broad dashboard. Sensitive or extremely variable payloads belong in carefully controlled event context, not in every top-level field.
Naming and evolution rules
Choose one spelling and keep it. If tenant_id replaces customer, retain a schema version or deprecation marker during migration rather than emitting both forever. Promote a field to the top level only when multiple services and investigations need it.
Nested objects make sense for event-specific data, especially errors. They become harmful when every team invents a different nested path for the same concept. Every top-level field is a long-term indexing and governance commitment, so promotion should be deliberate.
Context Propagation With Trace and Span IDs
A correlation ID identifies the request from the application's perspective. trace_id and span_id identify its position in a distributed trace, which gives an investigator a reliable path from one log event to the operation and parent request around it.
At request entry, extract the W3C Trace Context headers, traceparent and tracestate, into the active context. Emit the current trace and span identifiers on each record, including records created by asynchronous work. Frameworks must attach context before handing work to an async boundary and detach it afterward. In Python-style pseudocode:
token = context.attach(request_context)
try:
logger.info("checkout_started", extra={
"trace_id": current_trace_id(),
"span_id": current_span_id()
})
finally:
context.detach(token)
A Go service should pass context.Context through handlers and worker functions, then use a context-aware logger that reads the active span rather than accepting IDs from arbitrary call sites. That prevents one forgotten parameter from producing an orphan event.
{
"timestamp": "2026-09-15T02:14:09.381Z",
"severity": "INFO",
"service": "checkout",
"message": "Checkout started",
"correlation_id": "req-7f21",
"trace_context": {
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7"
}
}
| Field | Source | Purpose |
|---|---|---|
correlation_id |
Request middleware | Joins application events |
trace_id |
Active OpenTelemetry span | Joins the complete distributed trace |
span_id |
Active OpenTelemetry span | Identifies the operation emitting the event |
service |
Resource or service configuration | Identifies the emitting component |
environment |
Deployment metadata | Separates production from other environments |
The Rust tracing integration guide is relevant when a Rust service needs consistent context injection. Missing identifiers leave orphan logs and spans, while an MCP-compatible client can use the shared IDs to assemble a request narrative from the same account instead of relying on manual copy-paste.
Errors, Exceptions, and Failure Signals
An exception should be a structured object, not the result of print(e). A stable error type supports aggregation, while the message explains the current occurrence. The stack trace should remain serialized and searchable, and terminality should distinguish a final failure from a retryable one.
A practical shape is:
{
"error": {
"type": "PaymentProviderTimeout",
"message": "Authorization provider timed out",
"stack_trace": "PaymentProviderTimeout: authorization provider timed out\n at authorize ...",
"is_terminal": false
},
"retry": {
"count": 2,
"next_delay_ms": 500
}
}
The serializer should remove user input from exception messages and preserve a readable fallback while making stack frames available as structured values where the pipeline supports arrays. error.type should remain stable. A raw provider response or request body shouldn't be copied into error.message.
Severity routing can isolate failures for investigation:
routes:
- match:
severity: ERROR
stream: errors-prod
- match:
severity:
not: ERROR
stream: application-prod
The exact routing syntax depends on the collector or backend. The rule itself is portable: route using fields, not text fragments. In Kubernetes pipelines, explicit severity also matters because Google Cloud documents that standard output defaults to INFO, standard error defaults to ERROR, and a structured severity field can define or override severity explicitly in GKE logging guidance.
Performance, Volume, and Sampling
Sampling is a control for volume, not permission to lose operational truth. Ingestion pressure affects storage decisions and the usefulness of real-time investigation, particularly when logs contain high-cardinality values that are expensive to index and difficult to aggregate.
Three approaches solve different problems:
- Head sampling: Decide near request start. This shapes traffic volume early but can't know whether a request will fail later.
- Tail sampling: Decide after the request completes. This retains interesting traces but requires buffering and a later decision.
- Adaptive sampling: Adjust rates by request class, risk, or observed behavior. This preserves more detail where the operational cost of missing an event is higher.
A conservative policy can emit every error at full fidelity, sample successful requests at 1% in low-risk services and 10% on critical paths, and never sample audit or state-changing events. Those rates are policy examples, not universal defaults. Each sampled event should carry decision metadata:
{
"sampling": {
"rate": 0.1,
"decision": "kept",
"reason": "critical_path"
}
}
| Request Class | Sample Rate | Always Retain | Rationale |
|---|---|---|---|
| Error | 100% | Yes | Preserve failure evidence |
| Low-risk success | 1% | No | Control routine volume |
| Critical-path success | 10% | No | Retain more context where impact is higher |
| Audit or state change | 100% | Yes | Preserve business and security evidence |
An MCP-compatible investigation benefits from stable seams such as sampling.reason=error. A client can query retained failures exhaustively while an engineer uses Live Tail to inspect the sampled success context around them.
Privacy, PII, and Secret Handling
Redaction belongs at the emitter whenever possible. The pipeline should then apply a structured allowlist and a final pattern-based sweep before storage, but the last sweep shouldn't be the primary defense against secrets that the application already exposed.
A redacted event can remain useful:
{
"event_name": "user_authenticated",
"redacted": true,
"user_id": "hmac:...",
"headers": {
"authorization": "[REDACTED]"
}
}
Hashing a user identifier with HMAC-SHA256 and a rotating salt can preserve controlled equality checks without storing the original value. The salt rotation policy and access controls must be documented, because pseudonymization isn't the same as removing sensitivity.
| Data Class | Examples | Default Action |
|---|---|---|
| PII | Names, email addresses, phone numbers | Drop or redact at source |
| Regulated data | Health and financial data | Keep out of logs unless explicitly approved |
| Credentials | Tokens, keys, cookies | Strip by field name before write |
| Internal identifiers | Tenant and request identifiers | Retain only when operationally necessary |
Structured namespaces such as headers.authorization, body.password, and cookies.session let processors remove known fields instead of relying on a regex to recognize every representation. Redaction rules should be versioned beside the schema definition, so a later review can reproduce which fields were allowed, removed, or transformed.
Ingestion and Routing Patterns
A log pipeline usually has two paths: shared receivers for application telemetry and dedicated destinations for archival, security, or specialized workloads. Shared HTTP JSON and OTLP receivers can accept output from collectors and processors, while Syslog, GELF, StatsD, Fluent Forward, and Beats destinations serve different source conventions. These paths should remain distinct in documentation and configuration because credentials, parsing, and delivery behavior can differ.
Routing should use structured fields:
[transforms.classify]
type = "remap"
inputs = ["logs"]
source = '''
if .service == "checkout" && .environment == "production" && .severity == "ERROR" {
.destination = "high_priority"
} else {
.destination = "bulk"
}
'''
The snippet is illustrative. A production pipeline should also normalize severity into the chosen numeric and text representation, validate required fields, and define what happens when a field is missing.
Backpressure needs an explicit policy. Buffers should support disk spillover where appropriate, define overflow behavior, and prevent an upstream outage from blocking request handling. A centralized service with named streams can simplify the destination rule to a match on service and environment, while a human operator uses Live Tail to inspect the resulting stream. The log ingestion tools guide covers the broader selection problem without changing the need for schema-based routing.
Correlating Logs With Traces and Metrics
Each telemetry signal should answer a different question:
- Logs: What happened to this request, and what decision or failure did the service record?
- Traces: Where did the request spend time across service boundaries?
- Metrics: How often did the condition occur, and how did it change over time?
Logs should retain the canonical trace_id and span_id, plus event details that aren't useful as span attributes on their own, such as user-facing messages, decision points, and business events. Timing that becomes a distribution belongs on spans or metrics rather than being duplicated across every log line.

Three practical moves keep the signals coherent:
- Derive
http.server.durationfrom the trace span instead of recomputing it in multiple log events. - Record
events.successandevents.failureas metrics with labels derived from the same normalized fields. - Keep logs for events an engineer will read during an incident, rather than copying every metric sample into text.
An MCP-compatible investigation can use a shared trace_id to pivot from an error summary to matching log records and then to the trace view. The identifiers create the join; the signals retain separate jobs.
Anti-Patterns That Quietly Erode Queryability
Most logging failures aren't dramatic. They accumulate as small choices that make the next incident slower.
BAD checkout failed: {"tenant":"tenant-42","status":502}
GOOD {"message":"Checkout failed","tenant_id":"tenant-42","status_code":502}
The bad form hides JSON inside message. The good form makes fields available to a Live Tail filter and to an MCP-backed investigation.
Common silent failures include:
- String concatenation: Replace
userId=... route=...text with typeduser_idandroutefields. - Payload dumping: Log selected attributes and a redaction marker, not complete request or response bodies.
- Production debug noise: Set intentional levels and sample routine success events instead of leaving debug output enabled by default.
- Swallowed exceptions: Emit a stable error object from every failure path, rather than an empty catch block.
- Ambiguous timestamps: Use one canonical event-time field. OpenTelemetry defines
Timestampas the source event time, represented as nanoseconds since the UNIX epoch, as described in the OpenTelemetry timestamp specification. - Inconsistent names: Choose
user_id,trace_id, andstatus_code, then rejectuserId,uid, and competing spellings during validation. - Mixed workloads: Separate application, security, and audit streams when their retention, access, or query patterns differ.
The fix isn't to add more text. It's to make each event predictable enough that both a human filter and a client query can find it without interpretation.
Quick Reference and Next Steps
The baseline schema is useful only when each field has a downstream job. The mapping below keeps implementation decisions tied to incident work rather than to abstract completeness.
| Field | Live Tail Filter Example | MCP Query Example |
|---|---|---|
timestamp |
Restrict the incident time window | “Find events during this time range” |
severity |
Show only errors | “Summarize errors by service” |
service |
Isolate one service | “Query checkout failures” |
environment |
Restrict to production | “Exclude non-production events” |
message |
Search a readable phrase | “Explain matching events” |
event_name |
Filter a known event type | “Find all authorization failures” |
correlation_id |
Follow one request | “Show all events for this request” |
trace_id |
Join logs to a trace | “Find logs for this trace” |
span_id |
Isolate one operation | “Show events from this span” |
tenant_id |
Identify affected tenant activity | “Find failures for this tenant” |
route |
Compare endpoint behavior | “Group failures by route” |
status_code |
Filter response classes | “Summarize responses by status” |
duration_ms |
Find slow requests | “Find slow events above a threshold” |
error.type |
Isolate an exception class | “Discover exception types” |
error.is_terminal |
Separate retryable failures | “Summarize terminal errors” |
sampling.reason |
Show retained failure evidence | “Query all events retained for errors” |
A safe rollout sequence is deliberately narrow:
- Pilot the schema on one service.
- Validate required fields and redaction before ingestion.
- Test Live Tail filters during a known failure scenario.
- Test the equivalent read-only MCP investigations.
- Expand to additional services without renaming shared fields.
- Tune sampling only after error and audit retention is verified.
Fluxtail provides centralized log management with named streams, Live Tail, search and filtering, alerts, built-in AI chat, and a hosted OAuth MCP server for agent-driven read investigations. Supported MCP clients include Codex, Claude Code, Gemini CLI, and VS Code, while mutating actions require a short confirmation token. Teams can apply the baseline schema to a recent incident, search the same account manually or through an MCP-compatible client, and identify which fields still fail to answer the operational question.
Try the baseline schema against a recent production incident in Fluxtail, using Live Tail filters first and an MCP-compatible client for the same read investigation. The public Starter and Pro plans are available through self-service registration, so the next step is to validate whether each field makes the failure easier to isolate.