Skip to main content
RunBook Academy

ObservabilityCVIII · Clock SkewClockSkew

Effect on Logs

Intermediate⏱ ~22 minbashlogcli

What you'll learn

  • Predict the three wrong-time symptoms that clock skew produces in a log pipeline
  • Explain why Loki rejects entries older than the per-tenant rejection window or out of stream order
  • Identify the Loki distributor metric that flags a clock-driven rejection shape
  • Configure the agent to extract a parseable source timestamp and to fail loud on a malformed line
  • Trace a wrong-time entry from the source host through the distributor to the rejected entry in the ingester

Prerequisites

Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13

Not yet marked complete on this device.

The application’s log says the request failed at 14:30:01. The database’s log says the same transaction committed at 14:30:03. The investigator opens Grafana, sets the time range to 14:30:00-14:30:05, and looks for the database-side trace of the request. The query is empty. The application host is one hundred and forty milliseconds behind the database host. The database committed at 14:30:03 wall-clock; the application saw 14:30:02.86 on its own clock. The investigator looked at the wrong time.

Three wrong-time symptoms appear in log pipelines under clock skew: a line that is older than the per-tenant window (the distributor silently rejects it); a line that is newer than the last line in the stream (the ingester rejects it as out of order); and a line that is in the right chunk but at the wrong position in the sorted timeline (the line is stored, the query is wrong). The common shape is the silent rejection at the distributor; the rare shape is the wrong-position entry that corrupts the investigation.

What it is

A log line carries two timestamps from the moment it is written to the moment it appears in Loki:

  • Source timestamp. The wall-clock time the application stamped on the line, or the wall-clock time the kernel recorded when the line was written. For RFC3339 sources, this is the value in the line text. For syslog sources, this is the value the syslog parser extracts.
  • Ingestion timestamp. The wall-clock time the line was accepted by the Loki distributor. This is the time the distributor stamped on the entry when the parser failed.

Loki stores the line against the source timestamp when the parser succeeds; otherwise it stores the line against the ingestion timestamp. The query path reads the stored timestamp. A wrong source timestamp therefore affects every downstream query: the line appears at the wrong time, in the wrong time range, and against the wrong retention boundary.

Loki 3.x accepts timestamps in RFC3339 with up to nanosecond precision. Unix epoch in seconds, milliseconds, or microseconds is also accepted. Local time without an explicit offset is the most common source of a wrong timestamp.

Why a sysadmin cares

A wrong timestamp is a wrong answer to three operational questions:

  1. Was the bug at 14:00 or 13:00? The investigator pivots on the chart at the wrong time. The hypothesis is wrong. The fix is delayed.
  2. Is the entry within retention? A timestamp one hour in the past can fall outside the per-tenant rejection window. The entry is silently dropped.
  3. Did the entries arrive in order? The ingester rejects out-of-order entries. A clock that runs backwards writes a second entry that is older than the first; the second entry is rejected at the ingester.

The alert that fires on the metric is the only signal the operator has. The push is silent. The query is silent. The metric is the signal.

How it works

Loki stamps each entry with the source timestamp extracted from the line. The pipeline is:

   Source line           Agent pipeline          Loki distributor
+-----------------+   +-------------------+   +-------------------+
| 14:00:00 INFO  |   | parse -> map      |   | accept or reject  |
| 14:00:01 ERROR | -> |  -> extract ts    | -> |  -> stamp entry   |
| 14:00:02 INFO  |   |  -> rewrite       |   |  -> store         |
+-----------------+   +-------------------+   +-------------------+

Two boundaries fail at the wrong time:

  • The agent to distributor boundary. The agent parses the source timestamp. If the parser fails, the agent sends the entry with no timestamp. The distributor stamps the entry with the ingestion time.
  • The distributor to ingester boundary. The distributor forwards the entry to the ingester. The ingester accepts entries whose timestamp is newer than the stream’s last timestamp and within the configured rejection_older_than window. Otherwise the entry is rejected with a rejected_older_than or out_of_order reason.

Three wrong-time symptoms produce three operational shapes:

   Host clock 200 ms behind true time
              |
              |  source timestamp is 200 ms in the past
              v
   Agent stamps entry with parsed source timestamp
              |
              |  pushes to distributor
              v
   Distributor accepts the entry
              |
              |  forwards to ingester
              v
   Ingester compares against stream's last timestamp
              |
              |  within rejection_older_than? within order?
              |     no                   no
              v     v                    v
       reason="older_than"     reason="out_of_order"

How to configure it

The collector configuration owns the timestamp. The pipeline stages must extract the source timestamp and either reject lines that fail to parse or forward them with the ingestion time. The minimal River configuration for RFC3339 source logs:

// /etc/alloy/config.alloy
loki.source.file "payments" {
  targets    = local.file_match("/var/log/payments/*.log")
  forward_to = [loki.process.payments.receiver]
  labels     = { job = "payments", instance = sys.env("HOSTNAME") }
}

loki.process "payments" {
  forward_to = [loki.write.default.receiver]

  stage.match {
    selector = "{job=\"payments\"}"
    stage.regex {
      expression = "^(?P<ts>\\S+)\\s+(?P<level>\\S+)\\s+(?P<msg>.*)$"
    }
    stage.timestamp {
      source          = "ts"
      format          = "RFC3339Nano"
      fallback_format = "RFC3339"
      action_on_error = "fwd"
    }
  }
}

loki.write "default" {
  endpoint {
    url = "http://loki-write.monitoring.svc:3100/loki/api/v1/push"
  }
}

The three settings that change the failure mode are stage.timestamp.format (the expected format), fallback_format (an alternative to try when the first fails), and action_on_error (what to do on a parse failure: fwd forwards the line as raw text, drop drops the line, no_match skips stages).

The Loki tenant-side limit controls how far in the past an entry can be accepted:

# /etc/loki/loki.yml
limits_config:
  # Reject samples with timestamps older than 1 hour.
  rejection_older_than: 1h
  # Per-stream rate limit.
  per_stream_rate_limit: 8MB

The retention window is the operational boundary. A source clock that runs behind real time by more than an hour will write entries that fall outside the window.

How to validate it

The validation reads three surfaces: the agent’s parse log, the distributor’s discarded-samples metric, and a representative query.

READ-ONLY: confirm the agent is parsing the timestamp.

sudo journalctl -u alloy -n 50 --no-pager | grep -i 'timestamp'

Expected output:

level=info msg="stage timestamp" path=/var/log/payments/payments.log
level=warn msg="failed to parse timestamp" path=/var/log/payments/payments.log

A failed to parse timestamp line means the agent saw a line whose format was not the expected RFC3339. Count the lines; a small number is normal (an old log file before the format change); a large number is the source of the wrong-time problem.

READ-ONLY: confirm the distributor is rejecting entries for timestamp reasons.

curl -s http://loki-distributor.monitoring.svc:3100/metrics \
  | grep -E 'loki_discarded_samples_total.*(older_than|out_of_order)'

Expected output:

loki_discarded_samples_total{reason="older_than",tenant="1"} 0
loki_discarded_samples_total{reason="out_of_order",tenant="1"} 0

A non-zero value here is the smoking gun. The reason label identifies the failure shape: older_than is a clock behind real time; out_of_order is a clock that ran backwards or a single delayed batch.

READ-ONLY: confirm the stored entries are within the expected time.

logcli -addr http://loki-query.monitoring.svc:3100 query \
  --since=1h '{job="payments"} | json | line_format "{{.ts}} {{.msg}}"'

Expected output:

2026-08-14T03:00:01.412Z payment failed for order 8812
2026-08-14T03:00:02.001Z payment failed for order 8813
2026-08-14T03:00:02.501Z payment failed for order 8814

The timestamps should be within the last hour. A line whose timestamp is more than an hour in the past is the wrong-time symptom; the entry should have been rejected by the agent.

READ-ONLY: confirm the ingester is accepting entries.

curl -s http://loki-ingester.monitoring.svc:3100/metrics \
  | grep -E 'loki_ingester_streams_created_total'

loki_ingester_streams_created_total should be increasing. A flat line plus a non-zero out_of_order counter means the ingester is reading the entries but rejecting them.

How it can fail

Five specific failure shapes appear in production.

  1. The source clock is wrong. The host’s date is behind real time by one hour. The agent stamps the line with the host’s clock. The distributor accepts the entry; the ingester rejects it as older_than. The symptom is a non-zero loki_discarded_samples_total{reason="older_than"}. First hop: timedatectl status on the source host.
  2. The format string is wrong. The line is in RFC3339 but the agent is configured to parse Unix epoch. The agent fails to parse; the agent forwards the line with the ingestion timestamp. The line is stored at the ingestion time, not the source time. First hop: the agent’s loki.process stage configuration.
  3. The ingestion rate is normal, but the entry is rejected as out-of-order. The host’s clock ran backwards (NTP step, VM resume, container clock skew). The agent sends an entry with a timestamp older than the previous entry in the stream. The ingester rejects it as out_of_order. First hop: the source host’s clock; the loki_ingester_received_entries rate.
  4. The timezone is implicit. The line is in local time without an offset. The agent parses the wall-clock value but cannot resolve it to UTC. The line is stored at the local-clock interpretation. A team in Europe/Berlin and a team in America/New_York see the same lines at different UTC offsets. First hop: the source log format; the format on the stage.timestamp block.
  5. The retention window is narrower than the source clock skew. The per-tenant rejection_older_than is one hour; the source clock drifts by 90 seconds; the entry is accepted. The source clock then drifts by 95 minutes after a long outage; the entry is rejected as older_than. First hop: the source host’s clock.

How to troubleshoot it

The diagnose-first order. Each step is read-only.

  1. Confirm the symptom. Reproduce the wrong-time symptom. The metric is the signal. The query is the symptom.
  2. Confirm the source clock. timedatectl status on the source host. The System clock synchronized line should be yes. The NTP service should be active.
  3. Confirm the agent parse log. journalctl -u alloy -n 50 | grep -i timestamp. The failed to parse timestamp line is the smoking gun.
  4. Confirm the distributor discarded samples. loki_discarded_samples_total by reason. The reason label identifies the failure shape.
  5. Confirm the ingester out-of-order counter. loki_ingester_received_entries and the loki_ingester_streams gauge. A stream with a single flat timestamp is the symptom of one rejected entry among many.
  6. Confirm the query returns the wrong-time entries. logcli query with --since=1h. The timestamps should be within the last hour. A line whose timestamp is days in the past is the wrong-time symptom.

The fix is to chase the source clock, the format string, or the time zone. The Loki side is rarely the cause.

Security implications

A wrong timestamp can hide an attack in two ways. The retention window can be set so narrow that the entries from the attack are dropped before they are stored. The clock skew can be so deliberate that the operator cannot correlate the entries with the rest of the trace. Treat a sudden, large increase in loki_discarded_samples_total{reason="older_than"} as a candidate for a security event. The metric is the only signal.

The Loki distributor accepts any push whose timestamp is within the rejection_older_than window. The window is the trust boundary. A narrow window is a small trust boundary; a wide window is a large trust boundary. The choice is operational.

Performance implications

A wrong timestamp adds no cost to the chunk fetch. The wrong timestamp adds cost to the operator’s time. The cost is in the investigation that reaches the wrong conclusion because the chart at the wrong time told the wrong story.

The other performance trap is the rejection_older_than limit. A wide window accepts more entries; the retention window later deletes them. A narrow window rejects entries at the distributor; the metric is non-zero. The choice is operational; the trade-off is between the metric and the storage.

Production guidance

  • Configure the agent to parse the source timestamp. The format string must match the format of the line.
  • Set action_on_error = "fwd" for production to avoid losing lines on a parser error. The cost is a non-zero “failed to parse” log; the benefit is no data loss.
  • Alert on loki_discarded_samples_total growth. The reason label identifies the failure shape.
  • Reject the source clock skew over widening the retention window. The window is the trust boundary; the clock is the agent’s responsibility.
  • Run chronyc tracking on every source host. The drift is the cause; the metric is the symptom.

Verification

You should now be able to answer:

  • What is the difference between a source timestamp and an ingestion timestamp?
  • Which Loki metric is the smoking gun for a timestamp that is older than the per-tenant window?
  • Which Loki metric is the smoking gun for an out-of-order timestamp?
  • What is the role of the agent stage.timestamp block in preventing wrong-time entries from reaching the ingester?
  • Why is a wrong timestamp a worse symptom than a missing log line?

Quiz

Knowledge check · 8 questions

  1. Q1. When a log line carries no parseable timestamp, Loki stamps the entry with:

  2. Q2. A log line whose timestamp is older than the per-tenant rejection_older_than window is:

  3. Q3. Loki 3.x accepts timestamps in RFC3339 with up to nanosecond precision.

  4. Q4. Which of these are the two invariants the ingester validates per stream?

  5. Q5. Name the Loki distributor metric that records the number of samples rejected because their timestamp is out of order.

  6. Q6. A log line is timestamped in local time without an explicit offset. The most likely operational consequence is:

  7. Q7. The right discipline when the rejection_older_than metric is non-zero is to:

  8. Q8. The first hop when the agent logs a continuous stream of failed to parse timestamp warnings is:

Passing score: 75%. Answers are checked in this browser.