Structured Logging You Can Actually Search at 2am
Structured Logging You Can Actually Search at 2am

Logs are written when everything is calm and read when nothing is. The gap between those two states is why most logging is not much help: a line that reads perfectly to the person who wrote it tells you nothing when you are looking for the six requests out of four hundred thousand that failed.
This is what to log, in what shape, and the two or three decisions that turn a log file into something you can actually ask questions of.

The problem with a sentence
[2026-09-15 14:03:11] production.ERROR: Failed to generate invoice for org 42
Readable, and effectively unsearchable. To answer “how many organisations hit this today” you are writing a regular expression against prose. To answer “did this happen more for large customers” you cannot, because the size is not in the line. And the moment somebody rewords the message, every query built on it silently stops matching.
{"timestamp":"2026-09-15T14:03:11Z","level":"error","message":"invoice.generation_failed",
"org_id":42,"invoice_period":"2026-09","entry_count":8431,"duration_ms":31204,
"request_id":"01J8F...","exception":"TimeoutException"}
Now every one of those questions is a filter. The message is a stable identifier rather than a sentence, and everything variable is a field.
Three rules that do most of the work
1. The message is an identifier, not prose
Write invoice.generation_failed, not “Failed to generate invoice for org 42”. It stays the same forever, it groups cleanly, and every variable part moves into context where it can be filtered on.
This is the single highest-value change, and it is also the one developers resist most, because the identifier is less pleasant to read. It is not written to be read; it is written to be counted.
2. Context is fields, never interpolation
<?php
// unsearchable
Log::error("User {$user->id} could not access project {$project->id}");
// searchable
Log::error('project.access_denied', [
'user_id' => $user->id,
'project_id' => $project->id,
'reason' => 'not_a_member',
]);
The second version answers “which users”, “which projects” and “which reason” without anybody parsing anything.
3. One request, one id, everywhere
The change that makes incidents tractable. Generate an identifier at the start of every request and attach it to every line, every job it dispatches, and every outbound call.
<?php
// middleware
public function handle($request, Closure $next)
{
$id = $request->header('X-Request-Id') ?: (string) Str::ulid();
Log::withContext([
'request_id' => $id,
'user_id' => optional($request->user())->id,
'org_id' => optional($request->user())->organization_id,
]);
return $next($request)->header('X-Request-Id', $id);
}
Two details make it genuinely useful. Accept an incoming id if there is one, so a trace survives across services. And return it in the response, so a user reporting a problem can quote it and support can find the exact request rather than a time range.
Then carry it into queued jobs, because that is where the trail usually goes cold — a request succeeds, the work it queued fails twenty seconds later, and nothing connects them.

Levels, used consistently
Most teams have five levels and use two. Worth agreeing what each means, because the levels are what decide whether anybody is woken up.
- debug — development only. Off in production, or your storage bill is a logging bill.
- info — something happened that you would want in an audit trail. A user logged in, an invoice was generated, a plan changed. Not “entering function”.
- warning — something unexpected that the system handled. A retry succeeded, a deprecated endpoint was called, a payload was missing an optional field.
- error — a request or a job failed. Somebody did not get what they asked for. This is the level that should be countable and alertable.
- critical — the system cannot do its job. The database is unreachable, the queue is not draining. This one wakes people.
The failure mode to avoid is logging handled situations as errors. An expired token, a validation failure, a 404 — these are the application working correctly. Logging them as errors means the error count is meaningless, and once it is meaningless nobody looks at it.
What never goes in a log
Logs are copied, shipped to third parties, kept for months and read by people who were not thinking about privacy at the time. Treat them as a place data leaks from.
- Passwords and tokens — including in a full request dump, which is where they usually appear.
- Card numbers and bank details.
- Full request or response bodies on anything carrying personal data. Log the shape, not the contents.
- Session identifiers, which are as good as a password while valid.
- Entire user records. An id is enough — the database has the rest.
- Anything that would embarrass you in a screenshot. Logs end up in tickets and chat.
Build a redactor into the logging configuration rather than relying on discipline. A global list of keys that are replaced with a placeholder catches the accidental dump that a code review will not.
<?php
// a processor that runs on every record
$scrub = ['password', 'password_confirmation', 'token', 'secret',
'authorization', 'api_key', 'card_number', 'cvv'];
An easy one to miss: exception messages and stack traces frequently contain the arguments that caused the failure, which can include a token or a password. Redaction has to apply to the exception context too, not only to the fields you passed deliberately.
Log the boring successes too
Logging only failures gives you no denominator. “Fourteen invoice failures” means nothing without knowing whether fourteen thousand succeeded or sixteen did.
For anything that matters, log the completion with its duration and size:
<?php
Log::info('invoice.generated', [
'org_id' => $org->id,
'period' => $period,
'entry_count' => $entries->count(),
'duration_ms' => (int) ((microtime(true) - $start) * 1000),
]);
That one line answers a surprising number of later questions: the failure rate, whether duration correlates with entry count, which customers are largest, and whether a change made things slower. All without any additional instrumentation.

Logging in a queue worker
A long-running worker process is where logging conventions built for web requests quietly stop working, and it is also where the hardest incidents happen.
- Context leaks between jobs. Setting global log context in a web request is safe because the process ends. In a worker it does not, so the organisation id from job 1 is still attached when job 2 logs. Set the context per job and clear it afterwards.
- There is no request id unless you put one there. Pass the originating request id into the job when it is dispatched, and add the job’s own id as well — then a failure in a job twenty seconds later is still connected to the click that caused it.
- Log the attempt number. A line that does not say it is attempt three of five reads like five separate failures.
- Log job start and finish with duration, at info. Queue problems are almost always about how long something took, and without the finish line there is nothing to measure.
What to do when logs are the only evidence
Two habits that decide whether a post-incident investigation is possible at all, and both have to be in place beforehand.
Log the decision, not just the outcome. “Access denied” tells you what happened. “Access denied, reason not_a_member, required role admin, actual role viewer” tells you why, which is the only version that settles an argument with a customer who is certain they had permission.
Log the inputs to anything that computes money or time. When a customer disputes a figure six weeks later, the database holds the result and not the ingredients. A line recording the entry count, the rate applied and the period is what lets you reconstruct it — and it is very cheap compared with the alternative, which is not being able to.
Neither of these is about debugging. They are about being able to answer a question from outside the engineering team, which is a large share of what logs are actually used for.
Where it goes
A file on one server is fine until there are two servers, and then it stops being fine abruptly.
- Write JSON to stdout and let the platform collect it. It is the simplest thing that works everywhere, and it removes log rotation from your problems.
- Ship to something searchable. The specific product matters much less than having one place with all of it.
- Keep errors longer than info. Thirty days of everything and a year of errors is a reasonable shape, and dramatically cheaper than a year of everything.
- Do not let logging block a request. If shipping logs synchronously to a remote service, a slow collector becomes a slow application. Buffer and send asynchronously.
Cost, and the reason people stop logging
The usual arc: a team adopts structured logging, logs generously, gets a bill, and reacts by turning things off — usually the wrong things, because the noisy lines are easy to find and the useful ones are not.
Three adjustments keep the cost sensible without losing the signal.
- Sample the high-volume successes. You do not need every successful request; one in a hundred is enough to see rates and durations. Never sample errors.
- Split retention by level. Debug for a day, info for a month, errors for a year. Most of the volume is at the bottom and most of the value is at the top.
- Find the one line producing most of the volume. There is almost always a single debug statement in a hot loop generating the majority of it, and removing it changes the bill without changing what anybody can answer.
What not to do is reduce fields. The number of lines drives the cost; the number of fields on a line is comparatively free, and the fields are the entire reason the logging is useful.
Errors are not the same as logs
Worth separating, because using one for the other produces two mediocre systems.
A log line is a record that something happened. An error tracker groups identical exceptions, counts occurrences, remembers which are new, and tells you which release introduced one. Trying to get that from log search means writing queries that an error tracker gives you by default.
Run both. Log the event with its context, and let the exception go to the tracker with the request id attached — so the grouped error in one system links to the full trail in the other.
Adopting it in an existing codebase
You are not going to rewrite four thousand log statements, and you do not need to. The order that works:
- Switch the formatter to JSON and add the request id middleware. Every existing line immediately gains the fields that make it findable, without any of them being edited.
- Convert the paths you actually debug — the twenty or thirty lines around payments, jobs and authentication. That is where the value is concentrated.
- Make new code do it properly, and leave the rest alone. The old prose lines are still searchable by text and they are not hurting anything.
The first step alone is an afternoon and delivers most of the benefit, which is why it is worth doing before anybody agrees to the larger project.
Make it useful before you need it
Two exercises, each about an hour, that are the difference between logs that help and logs that do not.
- Pick a real incident from the past and try to answer it from the logs alone. Who was affected, when did it start, what was the trigger, did it stop? The questions you cannot answer name the fields you are not logging.
- Write down the five queries you would run first during an outage — errors by endpoint in the last hour, errors for one organisation, slowest requests, failed jobs by class, everything for one request id. Then check that each one is actually possible. Usually two of them are not.
And a small one that pays for itself the first time it is needed: put the release version or commit on every line. “When did this start?” then has an exact answer rather than a time range, and correlating an error with a deploy stops being guesswork.
None of this is expensive to adopt. What it costs is a convention that everybody follows, which is the part that needs a decision rather than a library.
The short version
- Messages are stable identifiers. Variables go in fields.
- Never interpolate values into the message.
- One request id, on every line, in every job, returned to the caller.
- Five levels with agreed meanings. Handled situations are not errors.
- Redact centrally, including exception context.
- Log successes with duration and size, so failures have a denominator.
- JSON to stdout, shipped somewhere searchable, errors kept longer.
- Use an error tracker as well, linked by request id.
The test for any logging change is simple: could somebody who did not write this code answer a question with it at 2am? If the answer requires knowing which sentence to grep for, it is not logging — it is a diary.

