Skip to main content
RunBook Academy

← All break/fix scenarios in Observability

intermediateloki-ingestion~30 min

Break/Fix: Log Timestamp Problem

Reported symptoms

  • ●A live tail of `{job="payments"}` over the last five minutes returns nothing, at any time of day, and has done for months
  • ●The same selector over the last six hours returns a full, current-looking stream of lines, so nothing appears to be lost
  • ●Reconstructing an incident, the payments error burst sits one hour later in Loki than the Tempo trace timeline and the Prometheus error-rate spike put it; checkout and api-gateway line up with the traces exactly
  • ●The nightly settlement job has been reporting missing logs since the spring. Its lines are not in Loki at all, and the ticket has been filed as a Loki bug twice
  • ●`loki_discarded_samples_total{reason="out_of_order"}` for the payments tenant has been climbing steadily; `reason="older_than"` is flat at zero
  • ●Every payments host reports a synchronised clock: `timedatectl` says yes, and `chronyc tracking` shows a sub-millisecond offset
  • ●The agent logs no timestamp parse failures at all - the counter for the last 200 journal lines is zero
  • ●A colleague who investigated a January incident found the payments timeline lined up with the traces perfectly

Evidence

  • · The raw line on the host reads `2026-08-18 09:14:02.417 INFO settle-worker ...` - a space instead of `T`, and no offset of any kind
  • · `timedatectl status` reports `Time zone: Europe/London (BST, +0100)`, `System clock synchronized: yes`, `NTP service: active`
  • · `chronyc tracking` reports `System time : 0.000000191 seconds fast of NTP time`
  • · The stored timestamp Loki returns for a line is identical to the timestamp written inside that line - they agree exactly, and both are an hour ahead of the trace span for the same `request_id`
  • · The agent `stage.timestamp` block declares `format = "2006-01-02 15:04:05.000"`, a Go layout that names no zone
  • · `sudo journalctl -u alloy --no-pager | grep -ci "failed to parse timestamp"` returns 0
  • · `loki_discarded_samples_total{reason="out_of_order",tenant="payments"}` is in the millions and rising; the same counter for the checkout tenant is zero
  • · The settlement binary is the one component in the payments deployment that still emits RFC 3339 with an explicit `Z`, and it writes into the same log file, under the same labels, as the main service
  • · The logging library change that introduced the current format shipped in February
Diagnosis and resolutionclick to reveal

Root cause

The payments service emits a naked local wall-clock timestamp - no `T` separator, no offset - and the agent pipeline parses it with the Go layout `2006-01-02 15:04:05.000`, which names no zone. A layout with no zone is parsed as UTC, so Loki stores whatever numbers the line contains as though they were already UTC. The hosts are set to `Europe/London`. Through February and March that was harmless, because local time and UTC are the same thing in a British winter, which is why the change passed review, passed its rollout, and looked correct to the colleague who checked a January incident. From the last Sunday in March the offset became one hour, and every payments log line has since been stored exactly one hour in the future. Everything else follows from that single fact. A five-minute tail is empty because the events of the last five minutes are stored an hour ahead of the query window, while a six-hour window is full and looks current because it is returning events that really happened between seven and one hours ago. The timeline disagrees with Tempo and Prometheus by an hour because those two take their time from the same host clocks and are correct. And the settlement job, which is the one component still emitting an explicit `Z` and is therefore the only component telling the truth, writes into a stream whose head the main service has already pushed an hour into the future, so its correctly stamped lines arrive behind the stream head and are rejected as out of order. That is the whole cause: the parse never failed, it succeeded on an ambiguous value and resolved the ambiguity wrongly, which is why no error was raised anywhere.

Remediation

The durable fix is at the source: the application must emit RFC 3339 with an explicit offset, which is one configuration line in every logging library the estate uses, and the pipeline must stop accepting a layout that has no zone. Sequence those two, because doing them in the wrong order costs data. Add `RFC3339Nano` and `RFC3339` to the pipeline's accepted formats first, so both the old and the new line shapes parse while the application rollout is in flight; only once every emitter has shipped do you remove the zoneless layout, and removing it is the point - while it remains, an unoffset line keeps being silently accepted. Then face the cost of correcting the clock. The moment the application starts emitting true UTC, its lines are stamped an hour earlier than the stream head that the previous hour of wrong lines has already established, so they land behind that head and are rejected exactly as the settlement job has been rejected all along - roughly an hour of total log loss for payments, immediately after the fix, caused by the fix. Avoid it by landing the corrected lines in a fresh stream: change one bounded label on the payments emitters as part of the same deployment, so the new label set is a new stream with no future head, and retire the old value once the old stream has aged out. While you are there, give the settlement job its own bounded `job` value; a batch process and a long-running service sharing one stream is what coupled their correctness in the first place. If the deployment cannot be taken now, hold deliberately: say that payments logs remain an hour out and that settlement logs remain absent, name an owner and an end time, and tell anyone reconstructing an incident to subtract an hour rather than letting them find out.

Verification

Verify against something that has an independent clock, because every check that reads only the log line reproduces the original mistake - the stored timestamp and the text inside the line agreed with each other throughout the incident and were both wrong. Take one `request_id`, find its span in Tempo, and require the log line and the span start to agree within milliseconds; do the same against the Prometheus counter increment for the same event. Then confirm the range behaviour has returned: a five-minute tail of the payments selector must return lines, which it has not done since March, and it is the cheapest standing check for a timestamp shift in either direction. Take the rejection counter to zero rather than merely lower - the rate of `loki_discarded_samples_total{reason="out_of_order"}` for the payments tenant must be flat over a full settlement run, and settlement lines must be present and queryable for that run. Read the raw line on disk and require an explicit offset in it. Finally, prove the guard can fail: in staging, emit one line in the old zoneless format and confirm the pipeline now reports a parse failure in the agent log and increments its processing-error metric, instead of accepting it. A pipeline that has only ever accepted lines has not been tested, and this fault survived four months precisely because nothing ever refused anything.

Prevention

Make the timestamp unambiguous at the source and refuse to interpret it anywhere else. RFC 3339 with an explicit offset, generated in the process that observed the event, is the whole of the discipline; a value without an offset is a guess, and the guess is made by a parser that has no idea what zone the host is in. Never write a parser layout that omits the zone, because such a layout is a silent decision to assume UTC and it will be correct in some months and wrong in others. Set servers to UTC so that local time and UTC cannot drift apart, and treat any host that is not on UTC as carrying a scheduled fault twice a year. Add a standing comparison between the newest stored log timestamp per job and wall-clock time: a job whose newest line is persistently in the future, or persistently far in the past, is broken in a way no parse-error metric will ever report, and one query catches both directions. Alert on `loki_discarded_samples_total` broken out by reason and tenant, and treat a non-zero out-of-order rate as a fault rather than as background noise - it was the loudest signal available here and it was on a dashboard nobody read. Keep one emitter per stream, so a batch job cannot inherit a long-running service's timestamp errors. Test the pipeline in CI against a captured line from every emitter that writes into it, including the ones nobody migrated. And schedule a review either side of each daylight saving transition for as long as any host in the estate is not on UTC, because the transition is the moment a latent zone assumption becomes an incident.

Reported symptoms

Three complaints have been open against the observability platform for months, filed by three different teams, and nobody has connected them.

The payments live tail is always empty. A five-minute window on {job="payments"} returns nothing. Not “a few lines”, nothing. It has been that way since roughly the spring, at every hour of the day. Widen the window to six hours and the stream is full, current-looking and busy, which is why the complaint was never escalated: the logs are obviously there, the tail is obviously a UI problem, somebody will look at it eventually.

The settlement job says its logs are missing. The nightly settlement run at 02:00 writes several hundred thousand lines and none of them are in Loki. The ticket has been raised twice and closed twice as “cannot reproduce”, because whoever picked it up ran a six-hour query on the payments selector, saw plenty of lines, and moved on.

The incident timeline does not reconcile. Reconstructing last week’s authorisation failure, the payments log burst sits one hour later than the Tempo trace timeline and one hour later than the Prometheus error-rate spike. Checkout and api-gateway line up with the traces to the millisecond. This is the complaint that finally got someone’s attention, and it arrived with a hypothesis attached: the payments hosts must have a clock problem.

They do not. Every payments host reports a synchronised clock, chronyc shows sub-millisecond offsets, and the agent has logged no parse failures at all.

There is one more fact on the ticket, contributed by a colleague who was asked whether they had ever seen this. They investigated a January incident on the same service and the payments timeline lined up with the traces perfectly.

Evidence provided

Read-only / Safea space instead of T, and no offset anywhere
$ sudo tail -n 1 /var/log/payments/payments.log
2026-08-18 09:14:02.417 INFO  settle-worker request_id=7f4a1c msg=authorisation accepted

Illustrative output

Read-only / Safethe clock is right, and the host is not on UTC
$ timedatectl status
               Local time: Tue 2026-08-18 09:14:03 BST
         Universal time: Tue 2026-08-18 08:14:03 UTC
              Time zone: Europe/London (BST, +0100)
System clock synchronized: yes
            NTP service: active

Illustrative output

Read-only / Safeclock skew is ruled out, and it is worth ruling out
$ chronyc tracking | grep 'System time'
System time     : 0.000000191 seconds fast of NTP time

Illustrative output

Read-only / Safeno parse failures - which is not the same as no problem
$ sudo journalctl -u alloy --no-pager | grep -ci 'failed to parse timestamp'
0

Illustrative output

What Loki returns, with its own stored timestamp on the left and the raw line on the right:

2026-08-18T09:14:02.417Z  2026-08-18 09:14:02.417 INFO  settle-worker request_id=7f4a1c msg=authorisation accepted

The span in Tempo carrying request_id=7f4a1c starts at 08:14:02.402Z.

The distributor’s rejection counters:

# Read-only, against the distributor's own metrics endpoint.
curl -s http://loki-distributor.monitoring.svc:3100/metrics \
  | grep '^loki_discarded_samples_total'
loki_discarded_samples_total{reason="out_of_order",tenant="payments"} 4821013
loki_discarded_samples_total{reason="older_than",tenant="payments"} 0
loki_discarded_samples_total{reason="out_of_order",tenant="checkout"} 0

And the pipeline stage that reads the payments log, unchanged since February:

// /etc/alloy/config.alloy
loki.process "payments" {
  stage.regex {
    expression = "^(?P<ts>\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d+)\\s+(?P<level>\\S+)\\s+(?P<msg>.*)$"
  }
  stage.timestamp {
    source = "ts"
    format = "2006-01-02 15:04:05.000"
  }
  forward_to = [loki.write.default.receiver]
}

The settlement binary, which shares this file and these labels, was never migrated to the new logging library and still writes RFC 3339 with a Z.

Work the evidence before reading on

The clock hypothesis is dead and it deserved to be tested. Start from what survives it.

  1. timedatectl and chronyc both say the clock is correct. What is still capable of putting a log line in the wrong hour when the clock is right?
  2. The stored timestamp and the timestamp inside the line agree exactly. Where can the wrongness have entered, given that those two agree and both disagree with the trace?
  3. The agent reports zero timestamp parse failures. Is that reassuring, or is it the most important line of evidence on the page?
  4. Read the Go layout in stage.timestamp character by character. What does it say about the zone?
  5. A January investigation found the timeline correct. What changes in Europe/London between January and August, and on which date?
  6. Work out the direction of the error before its size. If a line is stored one hour in the future, what does a query for the last five minutes return, and what does a query for the last six hours return? Check that both answers match the reported symptoms.
  7. The settlement job is the only component still emitting an explicit Z, which is to say the only one that is correct. Why would being correct get your lines rejected, and what does the ingester compare a new entry against?

Before continuing: name the single property of the payments log format that produces an empty tail, a healthy six-hour view, a one-hour disagreement with traces, and a rejected batch job, all at once.

Root cause

A layout with no zone is a decision to assume UTC

The line says 2026-08-18 09:14:02.417. It does not say what that means. There is no T, no Z, no numeric offset - nothing that names the zone the numbers were read in.

The pipeline resolves that ambiguity with the Go layout 2006-01-02 15:04:05.000, which also names no zone, and a layout that names no zone is parsed as UTC. So Loki stores the numbers exactly as written, treating 09:14:02.417 as 09:14:02.417Z. The hosts are on Europe/London and the real UTC time of that event was 08:14:02.417Z.

Through February and March this was invisible, and not by luck. In a British winter local time and UTC are the same thing, so the wrong assumption produced the right answer. The change shipped, the rollout looked clean, and the January incident review confirmed the timeline. From the last Sunday in March the offset became one hour, and every payments line since has been stored exactly one hour in the future.

One hour into the future is a range problem, not a loss problem

This is why the two most-reported symptoms look like opposites.

A query is a range over stored timestamps. Events from the last five minutes are stored between fifty-five and sixty minutes ahead of now, which is outside a now-5m to now window, so the tail is empty - and empty in a stable, all-day, entirely reproducible way that reads much more like a broken tail than like a broken clock.

A six-hour window returns everything stored between now-6h and now, which is the events that really happened between seven and one hours ago. There are plenty of them, they are evenly spread, and the newest one displays a timestamp of about now. The view looks perfect. Nothing is lost, nothing is duplicated, and the only thing wrong with it is that every line is filed under the wrong hour - which a reader has no way to see, because the timestamp Loki shows and the timestamp inside the line agree.

That agreement is the trap. Every check that reads only the log line confirms the log line. The error is only visible against a clock that was not involved in producing it, which is why the trace timeline is the piece of evidence that cracked the case.

Being correct in a stream that is wrong gets you rejected

The settlement job writes into the same file, under the same labels, and therefore into the same stream. It is the one component still emitting an explicit Z, so its timestamps are true UTC.

The ingester accepts an entry whose timestamp is newer than the last timestamp it has seen on that stream. By 02:00 the main service has already pushed that stream’s head an hour into the future - to about 03:00Z. Settlement’s honest 02:00:00Z line arrives behind the head and is rejected as out of order, and so is every line after it, for the whole run. loki_discarded_samples_total with reason="out_of_order" has been counting them accurately for months, in the millions, on a dashboard nobody opened.

Two teams have therefore been carrying failures caused by a third team’s timestamp format, with no shared symptom between them, which is the ordinary shape of a shared-stream fault.

Resolution

  1. Tell the three complaining teams that they have one fault, and tell anyone currently reconstructing an incident that payments log timestamps are an hour ahead of reality until further notice. This costs nothing and it is the only part of the fix that is instant.
  2. Widen the pipeline to accept the correct form before anything starts emitting it. Add RFC3339Nano and RFC3339 as accepted formats alongside the existing layout, so old and new line shapes both parse while the application rollout is in flight.
  3. Change the application to emit RFC 3339 with an explicit offset, in UTC. This is one configuration line per logging library and it is the only durable fix; everything done in the pipeline is interpretation of a value that should never have needed interpreting.
  4. Work out the cutover cost before scheduling it. The instant the application starts telling the truth, its lines are stamped an hour behind a stream head that the previous hour of wrong lines has already established, so they are rejected as out of order - about an hour of total log loss for payments, caused by the fix.
  5. Avoid that hour by landing corrected lines in a fresh stream. Change one bounded label on the payments emitters as part of the same deployment; a new label set is a new stream with no future head, so the corrected lines have nothing to collide with. Retire the old label value once the old stream has aged out, so this leaves one extra value behind rather than a growing set.
  6. Give the settlement job its own bounded job value in the same change. A batch process and a long-running service sharing one stream is what coupled their correctness, and separating them means neither can reject the other again.
  7. Remove the zoneless layout from the pipeline once every emitter has shipped the new format. This is the step that turns the fault into a loud failure for the next service that gets it wrong, and it is the step most likely to be forgotten, because by then everything looks fine.
  8. If the deployment cannot be taken now, hold explicitly rather than by default: state that payments logs remain an hour out and settlement logs remain absent, name an owner and an end time, and put the correction factor where an on-call engineer will find it at 03:00.

Verification

  1. A log line and its trace span agree. Take one request_id, find its span in Tempo, and require the stored log timestamp and the span start to match within milliseconds. This is the only check that is independent of the thing that was broken - every check that reads the log line alone agreed with itself throughout the incident.
  2. The same holds against metrics. Compare the log burst with the Prometheus counter increment for the same event and require them to coincide, so the verification does not rest on a single second opinion.
  3. The live tail returns lines. A five-minute window on the payments selector must return recent lines, which it has not done since March. It is the cheapest standing check for a timestamp shift, and it detects a shift in either direction.
  4. Out-of-order rejections go to zero, not merely down. Take the rate of loki_discarded_samples_total{reason="out_of_order"} for the payments tenant across a full settlement run and require it to be flat, with the settlement lines present and queryable for that same run.
  5. The raw line on disk carries an offset. Read it on the host rather than through Loki; the point of the fix is that the source now says what it means.
  6. The guard can fail. In staging, emit one line in the old zoneless format and confirm the pipeline now reports a parse failure in the agent journal and increments its processing-error metric instead of accepting it. A pipeline that has only ever accepted lines has not been tested.
  7. The cutover loss is what you predicted. Compare line counts either side of the deployment against the expected figure; a gap much larger than predicted means the stream rotation did not take effect and corrected lines are being rejected.
  8. The estate is checked, not just the service. Run the newest-stored-timestamp-versus-wall-clock comparison across every job, because the layout that caused this was copied from a template and payments is unlikely to be the only place it landed.

Prevention

  • The source owns the timestamp and it must be unambiguous: RFC 3339 with an explicit offset, in UTC, generated in the process that observed the event. A value without an offset is a guess, and the guess is made by a parser that has no idea what zone the host is in.
  • Never write a parser layout that omits the zone. Such a layout is a silent decision to assume UTC, and it will be right in some months of the year and wrong in others, which is the worst possible distribution of correctness.
  • Put servers on UTC. Where a host must carry a local zone, treat it as a standing fault scheduled twice a year and review it either side of each transition.
  • Add a standing comparison of the newest stored log timestamp per job against wall-clock time. A job whose newest line is persistently in the future, or persistently far in the past, is broken in a way no parse-error metric will ever report, and one query covers both directions across every job at once.
  • Alert on loki_discarded_samples_total broken out by reason and tenant, and treat a non-zero out-of-order rate as a fault rather than as background noise. It was the loudest signal available in this incident and it had been correct and ignored for months.
  • Keep one emitter per stream. A batch job that shares labels with a long-running service inherits that service’s timestamp errors, and neither team can see the coupling from their own side of it.
  • Test the pipeline in CI against a captured line from every emitter that writes into it, including the ones nobody migrated. The settlement binary was invisible to this pipeline’s authors precisely because it had not changed.
  • Treat a logging-format change as a change to the observability contract, not as an application detail. It alters what every downstream query, alert and post-incident timeline means.