# Cron Job Logging: What Each Run Should Record

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/scheduled-job-logging

> Where cron job output goes by default, how to send it to a file or journald, what every run should log, and how to rotate job logs and keep secrets out.

**Cron job logging means sending each run's output somewhere you control, such as a file, the systemd journal, or a container's stdout, and having every run write a start line and an end line that share a run ID and record what it processed, what it counted, how long it took, and how it exited.** Out of the box, cron mails a job's output or throws it away, and cron's own log line proves only that a command started. Behavior below comes from Debian cron 3.0pl1-197 (Debian 13), cronie 1.7.2, BusyBox 1.37.0, util-linux 2.41.5, systemd 257 and 262, logrotate 3.22.0, and jq 1.8, checked October 25, 2026.

## Where Cron Job Output Goes by Default

Cron collects whatever a job writes to stdout and stderr and mails it: to the address in `MAILTO` if the crontab sets one, otherwise to the crontab's owner ([cronie crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html), [Debian cron(8)](https://manpages.debian.org/trixie/cron/cron.8.en.html)). With no mailer installed, Debian's cron discards the output, cronie writes it to syslog, and BusyBox crond, the cron in Alpine, captures output only for entries under a `MAILTO=` line ([cron "No MTA installed"](https://cronradar.com/blog/cron-no-mta-installed) traces each case). Either way it lands in a mailbox or nowhere, not in a log anyone searches.

Cron's own log doesn't fill the gap. Debian's cron logs a `CMD` line when a job starts and, by default, nothing when it ends; its `-L` option can add end and failure lines ([cron(8)](https://manpages.debian.org/trixie/cron/cron.8.en.html)). cronie adds a `CMDEND` line without the exit status ([do_command.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/do_command.c#L662-L667)), and BusyBox crond logs only the start ([crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L959-L960)). Those lines prove the schedule fired ([how to know if a cron job ran](https://cronradar.com/blog/how-to-know-if-cron-job-ran)); recording what the job did is up to the job.

## Send Output to a File, the Journal, or stdout

Pick one destination per job and send both streams to it:

| Destination                   | How                                                                        | Exit status cron sees                     | Read it with                     | Retention                          |
| ----------------------------- | -------------------------------------------------------------------------- | ----------------------------------------- | -------------------------------- | ---------------------------------- |
| File                          | `>>/var/log/cron-jobs/report.log 2>&1` after the command                   | The job's                                 | `tail`, `grep`, `jq`             | logrotate                          |
| Journal, via systemd-cat      | `systemd-cat -t report` before the command                                 | The job's                                 | `journalctl -t report`           | journald's size limits             |
| Journal or syslog, via logger | `2>&1 \| logger -t report` after the command                               | logger's, unless the shell has `pipefail` | `journalctl -t report` or syslog | journald, or rsyslog and logrotate |
| Container stdout              | supercronic, BusyBox `crond -f`, or Debian cron with `>>/proc/1/fd/1 2>&1` | The job's                                 | `docker logs`, `kubectl logs`    | The container runtime              |

systemd-cat runs the command with its stdout and stderr connected to the journal, replacing itself with the command ([systemd-cat(1)](https://www.freedesktop.org/software/systemd/man/latest/systemd-cat.html), [cat.c](https://github.com/systemd/systemd/blob/v257/src/journal/cat.c#L177)), so cron still gets the job's exit status. Lines are logged at priority `info` by default; `--stderr-priority=warning` raises stderr lines, and with `--level-prefix`, on by default, a line beginning `<3>` is logged at priority 3. Priorities are syslog's severities, 0 (`emerg`) to 7 (`debug`) ([RFC 5424](https://www.rfc-editor.org/rfc/rfc5424#section-6.2.1)); `journalctl -p warning` shows warnings and anything more severe ([journalctl(1)](https://www.freedesktop.org/software/systemd/man/latest/journalctl.html)).

`logger` suits single lines, such as `logger -t report -p user.err "run failed"`. It logs at `user.notice` by default and limits a message to 1 KiB ([logger(1)](https://manpages.debian.org/trixie/bsdutils/logger.1.en.html)): util-linux 2.41.5 truncates a longer argument and splits a longer stdin line into several messages ([logger.c](https://github.com/util-linux/util-linux/blob/v2.41.5/misc-utils/logger.c#L978-L1060)), cutting a long JSON line in two. systemd-cat lines can reach journald's `LineMax`, 48K by default ([journald.conf(5)](https://www.freedesktop.org/software/systemd/man/latest/journald.conf.html)). With output in a log, cron has nothing left to mail, which removes a second, unrouted alert path ([cron job alerting](https://cronradar.com/blog/cron-job-alerting)).

## What Each Run Should Record

| Field          | Example                                     | Why                                                         |
| -------------- | ------------------------------------------- | ----------------------------------------------------------- |
| `ts`           | `2026-10-24T03:15:02Z`                      | UTC with the offset written                                 |
| `run_id`       | `nightly-report-20261024T031502Z-48213`     | Ties a run's lines together, and to its check-ins           |
| `job`          | `nightly-report`                            | One name for the log tag, file, and monitor key             |
| `event`        | `start`, `params`, `summary`, `end`         | A `start` with no `end` is a run that died                  |
| `version`      | `4f2c9e1`                                   | Which code ran                                              |
| `host`         | `app-02`                                    | Which machine, if the job can run on several                |
| Parameters     | `"period":"2026-10-23"`                     | What the run covered, so you know what to rerun or backfill |
| Counts         | `rows_read`, `rows_written`, `rows_skipped` | A run that exits 0 after writing nothing looks fine         |
| External calls | `billing_api_calls`, `billing_api_retries`  | Which dependency was slow or flaky                          |
| `duration_s`   | `312`                                       | Shows a job creeping toward its timeout                     |
| `exit_code`    | `0`                                         | The result cron and your monitor see                        |

A wrapper can record the start, end, host, duration, and exit status. Only the job knows its version, what it processed, and what it counted, so it writes those lines itself, with the run ID the wrapper hands it. Log counts once per run in a summary line, not a line per row: per-row logging buries the run, and journald drops a service's messages past its rate limit, 10,000 messages per 30 seconds by default ([journald.conf(5)](https://www.freedesktop.org/software/systemd/man/latest/journald.conf.html)).

## Write One JSON Object per Line

One JSON object per line, with the same keys in every job, lets `jq` or a log system filter on fields such as `event`, `exit_code`, and `run_id`. Keep key names and types stable: a field that's a number in one run and a string in the next breaks comparisons.

- **UTC timestamps with the offset.** `2026-10-24T03:15:02Z` is an RFC 3339 date-time ([§5.6](https://www.rfc-editor.org/rfc/rfc3339#section-5.6)); timestamps in one offset form and precision sort correctly as plain strings ([§5.1](https://www.rfc-editor.org/rfc/rfc3339#section-5.1)), and UTC never repeats an hour at a daylight saving change. If a log must show local time, write the offset: `2026-10-24T05:15:02+02:00`.
- **Few levels, used consistently.** `info` for start, parameters, summary, and end; `warning` for something the run survived, such as a retried call; `error` for the reason a run failed. Keep `debug` off for scheduled runs.
- **An encoder, not string concatenation.** A JSON library escapes quotes and newlines, so an error message can't split a record or forge a second line, the log injection the [OWASP Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) warns about.

In Python, a helper reads the run ID from the environment and prints one line per call:

```python
import json
import os
from datetime import datetime, timedelta, timezone

RUN_ID = os.environ.get("RUN_ID") or f"nightly-report-manual-{os.getpid()}"


def log(event, level="info", **fields):
    record = {
        "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "level": level,
        "job": "nightly-report",
        "run_id": RUN_ID,
        "event": event,
        **fields,
    }
    print(json.dumps(record, separators=(",", ":")), flush=True)


period = (datetime.now(timezone.utc) - timedelta(days=1)).date().isoformat()
log("params", version=os.environ.get("APP_REVISION", "unknown"), period=period)
```

`APP_REVISION` is whatever your deploy sets, such as the Git commit. `flush=True` matters because Python block-buffers stdout when it isn't a terminal ([sys.stdout](https://docs.python.org/3/library/sys.html#sys.stdout)), so a crash would otherwise lose the last lines. The job ends with one `log("summary", ...)` call carrying its counts. With the wrapper below, a run reads:

```text
{"ts":"2026-10-24T03:15:02Z","level":"info","job":"nightly-report","run_id":"nightly-report-20261024T031502Z-48213","event":"start","host":"app-02"}
{"ts":"2026-10-24T03:15:03Z","level":"info","job":"nightly-report","run_id":"nightly-report-20261024T031502Z-48213","event":"params","version":"4f2c9e1","period":"2026-10-23"}
{"ts":"2026-10-24T03:19:40Z","level":"warning","job":"nightly-report","run_id":"nightly-report-20261024T031502Z-48213","event":"retry","dependency":"billing-api","status":503,"retry":1}
{"ts":"2026-10-24T03:20:13Z","level":"info","job":"nightly-report","run_id":"nightly-report-20261024T031502Z-48213","event":"summary","rows_read":48210,"rows_written":48190,"rows_skipped":20,"billing_api_calls":12,"billing_api_retries":1}
{"ts":"2026-10-24T03:20:14Z","level":"info","job":"nightly-report","run_id":"nightly-report-20261024T031502Z-48213","event":"end","exit_code":0,"duration_s":312}
```

## A Wrapper That Logs and Reports Every Run

This POSIX `sh` wrapper writes the start and end lines, passes the run ID to the job as `RUN_ID`, and sends the same ID on its CronRadar check-ins. There is no CronRadar SDK for shell scripts or crontab and no auto-discovery, so each job reports with its own HTTP check-in. It writes to its own stdout and stderr, so the crontab entry picks the destination:

```bash
#!/bin/sh
# /usr/local/bin/cron-run JOB 'SCHEDULE' COMMAND [ARG...]
# Writes JSON start and end lines for one run and reports it to CronRadar.
if [ "$#" -lt 3 ]; then
  echo "usage: cron-run JOB 'SCHEDULE' COMMAND [ARG...]" >&2
  exit 64
fi
job=$1
schedule=$2
shift 2
case $job in
  *[!a-z0-9_-]*)
    echo "cron-run: use only a-z, 0-9, - and _ in the job name" >&2
    exit 64
    ;;
esac
if [ -z "$job" ] || [ "${#job}" -gt 64 ]; then
  echo "cron-run: the job name must be 1 to 64 characters" >&2
  exit 64
fi

run_id="$job-$(date -u +%Y%m%dT%H%M%SZ)-$$"
url="https://cron.life/api/v1/ping/$job"
# A shell variable, not exported: the job's process never sees the key.
api_key=$(cat "${CRONRADAR_API_KEY_FILE:-/etc/cronradar/api-key}" 2>/dev/null)
now() { date -u +%Y-%m-%dT%H:%M:%SZ; }

report() {
  event=$1
  shift
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --get --user "$api_key:" \
    --data-urlencode "schedule=$schedule" \
    --data-urlencode "run_id=$run_id" \
    "$@" "$url/$event" || true
}

printf '{"ts":"%s","level":"info","job":"%s","run_id":"%s","event":"start","host":"%s"}\n' \
  "$(now)" "$job" "$run_id" "$(uname -n)"
report start

started=$(date +%s)
RUN_ID=$run_id "$@"
rc=$?
duration=$(($(date +%s) - started))

if [ "$rc" -eq 0 ]; then
  printf '{"ts":"%s","level":"info","job":"%s","run_id":"%s","event":"end","exit_code":0,"duration_s":%d}\n' \
    "$(now)" "$job" "$run_id" "$duration"
  report complete
else
  printf '{"ts":"%s","level":"error","job":"%s","run_id":"%s","event":"end","exit_code":%d,"duration_s":%d}\n' \
    "$(now)" "$job" "$run_id" "$rc" "$duration" >&2
  report fail --data-urlencode "message=$job exited with code $rc after ${duration}s, run $run_id"
fi
exit "$rc"
```

Two ways to call it, one per destination:

```text
# Journal: stdout at info, stderr (including the failure line) at warning, one tag
15 3 * * * systemd-cat -t nightly-report --stderr-priority=warning /usr/local/bin/cron-run nightly-report '15 3 * * *' /srv/app/bin/nightly-report

# File: for hosts without journald, rotated by the logrotate rule below
15 3 * * * /usr/local/bin/cron-run nightly-report '15 3 * * *' /srv/app/bin/nightly-report >>/var/log/cron-jobs/nightly-report.log 2>&1
```

What it guarantees, and what it can't:

- **Its JSON needs no encoder** because it writes only values it controls: a validated job name, numbers, timestamps, and the host name. The same check makes the job name safe as the monitor key in the URL and as the journal tag.
- **The run ID is unique per run on one host**: start time plus the shell's PID. If the job can start on two machines in the same second, add the host name. A 64-character job name keeps the ID under CronRadar's 128-character limit; retries inside one run should keep it and send `attempt`.
- **Monitoring can't fail the job.** Each check-in gives up after five seconds, `|| true` ignores its result, curl's errors go to the log, and the wrapper exits with the job's status.
- **The schedule creates the monitor.** Sent on every check-in, it lets the first run create the monitor and the next run recreate a deleted one. It's applied only on creation and read in UTC, so pass the UTC equivalent if the server isn't on UTC. Without `gracePeriod`, a new monitor gets the 90-second default; change either in the monitor's settings.
- **A killed run leaves a start line with no end line**: nothing runs after SIGKILL, and without a trap, nothing after TERM. [Shell script best practices](https://cronradar.com/blog/cron-shell-script-best-practices) shows traps that report INT and TERM.

## Find Failed Runs With journalctl and jq

`journalctl -o cat` prints only each entry's message ([journalctl(1)](https://www.freedesktop.org/software/systemd/man/latest/journalctl.html)). jq's `-R` reads each line as a string, and `fromjson?` parses it, skipping lines that aren't JSON ([jq 1.8 manual](https://jqlang.org/manual/v1.8/)):

```bash
# Failed runs since October 1, from the journal
journalctl -t nightly-report --since "2026-10-01" -o cat \
  | jq -cR 'fromjson? | select(.event == "end" and .exit_code != 0)'

# The same, from a log file
jq -cR 'fromjson? | select(.event == "end" and .exit_code != 0)' \
  /var/log/cron-jobs/nightly-report.log

# Every structured line of one run; --since reads local time unless told UTC
journalctl -t nightly-report --since "2026-10-24 03:00 UTC" -o cat \
  | jq -cR --arg id nightly-report-20261024T031502Z-48213 'fromjson? | select(.run_id == $id)'

# Warning or worse since yesterday: all of stderr, given --stderr-priority=warning
journalctl -t nightly-report -p warning --since yesterday
```

The run-ID query skips unstructured output such as a stack trace; drop `-o cat` and `jq` to see every line in order.

## Keep Secrets and Personal Data Out of Logs

Logs get copied, shipped, and kept for months, readable by more people than the job's credentials are.

- **Nothing secret in the crontab command.** cronie, Debian's cron, and BusyBox crond log the full command of every run by default ([cronie](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/do_command.c#L217-L224), [Debian](https://sources.debian.org/src/cron/3.0pl1-197/do_command.c/#L252-L256), [BusyBox](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L959-L960)), so a token typed into an entry lands in the system log every time it runs. Read secrets from a file or secret store, as the wrapper does with the API key.
- **No shell traces in scheduled runs.** `set -x` prints each command after expansion ([bash manual](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html)), so `curl --user "$api_key:"` appears with the key in it.
- **No environment dumps or full records.** A debug `env` line or a crash handler that prints the environment logs every secret in it, which is why the wrapper doesn't export the key. Log record IDs and counts, not rows, request bodies, or personal data; [OWASP's list](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) of data to exclude includes access tokens, passwords, connection strings, and encryption keys.
- **Files only the job's account can read.** Create logs `0640` in a directory that isn't world-readable, as the rule below does.

## Rotate and Retain Job Logs

### Log Files: logrotate

```text
/var/log/cron-jobs/*.log {
    daily
    rotate 30
    dateext
    missingok
    notifempty
    compress
    delaycompress
    create 0640 deploy deploy
    su deploy deploy
}
```

Save it as `/etc/logrotate.d/cron-jobs` and check it with `logrotate -d /etc/logrotate.d/cron-jobs`, which prints what it would do without changing anything ([logrotate(8)](https://manpages.debian.org/trixie/logrotate/logrotate.8.en.html)). It keeps 30 dated files per job and rotates as `deploy`, the account that owns the directory.

`create` renames the log and creates a new one. Each run opens its file with `>>` when cron starts it, so a run in progress keeps appending to the renamed file and the next run opens the new one; `delaycompress` keeps that renamed file uncompressed until the following rotation, so the run's last lines aren't written to a file already compressed and deleted. `copytruncate` is for long-running programs that can't reopen their log, and the manual warns it can lose lines written between its copy and truncate. [Shell script best practices](https://cronradar.com/blog/cron-shell-script-best-practices) covers how `>>` and `>` writers behave if you use it.

### The Journal: Size Limits Come First

journald deletes the oldest entries when the journal reaches `SystemMaxUse=` (by default 10% of the filesystem, capped at 4G) or free space falls below `SystemKeepFree=` (15%), and has no time limit, since `MaxRetentionSec=` defaults to 0 ([journald.conf(5)](https://www.freedesktop.org/software/systemd/man/latest/journald.conf.html)). On a busy host that can be days of history; `journalctl --disk-usage` shows the current size. A drop-in sets both limits:

```ini
# /etc/systemd/journald.conf.d/retention.conf
[Journal]
SystemMaxUse=4G
MaxRetentionSec=3month
```

Apply it with `sudo systemctl restart systemd-journald`. `MaxRetentionSec=` only deletes older entries; it can't keep anything past the size limits. Whether the journal survives a reboot depends on `Storage=`, covered in [how to know if a cron job ran](https://cronradar.com/blog/how-to-know-if-cron-job-ran).

## Logs From Cron Jobs in Containers

In a container, write to stdout and stderr and let the runtime collect them; there's rarely a syslog daemon or journald inside. [Cron in Docker](https://cronradar.com/blog/cron-in-docker) shows how each cron gets job output into `docker logs`. Two things differ from a host:

- **supercronic wraps job output.** By default it logs each line a job prints as the message of its own entry ([cron.go](https://github.com/aptible/supercronic/blob/v0.2.51/cron/cron.go#L85-L113)), so a JSON line arrives inside another record. In supercronic 0.2.51, `-passthrough-logs` sends job output straight to supercronic's stdout and stderr, and `-json` makes supercronic's own lines JSON ([main.go](https://github.com/aptible/supercronic/blob/v0.2.51/main.go#L34-L50)):

```dockerfile
CMD ["supercronic", "-passthrough-logs", "-json", "/etc/supercronic/crontab"]
```

- **Retention belongs to the runtime.** Docker's default `json-file` driver doesn't rotate; set `max-size` and `max-file`, or use the `local` driver, which rotates by default ([Docker logging](https://docs.docker.com/engine/logging/configure/)). On Kubernetes, the kubelet rotates container logs at 10Mi, keeping 5 files, and `kubectl logs` reads only the latest file ([logging architecture](https://kubernetes.io/docs/concepts/cluster-administration/logging/)). A CronJob keeps 3 successful Jobs and 1 failed Job by default ([CronJob](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#jobs-history-limits)), and deleting a Job cleans up its Pods ([Job](https://kubernetes.io/docs/concepts/workloads/controllers/job/)), so `kubectl logs` can't reach older runs; ship logs to a collector for more (defaults checked October 25, 2026).

## Connect a CronRadar Run to Its Logs

CronRadar doesn't collect logs. A check-in carries the monitor key, the `run_id`, an optional `attempt`, and on `fail` an optional message; the log lines stay in your log system. Three things connect the two:

- **The same `run_id` in both.** CronRadar pairs a run's `start` with its `complete` or `fail` by `run_id`, and the run's details in the monitor's history show it as the Execution ID, ready to paste into a log search. It can be at most 128 characters ([API reference](https://docs.cronradar.com/api-reference)); reuse it across retry attempts of one run, and give overlapping runs different IDs. Without it, CronRadar keeps the events but labels any pairing as legacy or incomplete evidence.
- **A Logs URL on the monitor.** In the monitor's settings, set **Logs URL** to a saved search for the job, filtered by its journal tag, file, or label, over a relative time range such as the last seven days; Grafana's Explore, for one, keeps the query in its URL so you can bookmark it ([Grafana](https://grafana.com/docs/grafana/latest/explore/get-started-with-explore/)). The monitor's page then shows an **Open logs** button, and webhook payloads and PagerDuty custom details include the link. The link opens the job's logs; the run ID narrows them to one run. CronRadar stores the link and never fetches what's behind it. Put the same search in the job's [runbook](https://cronradar.com/blog/scheduled-job-runbooks).
- **A short, safe failure message.** The `message` on a `fail` check-in appears in the run's history and as the reason in the failure alert on every channel, so write it for whoever reads the alert: what failed, the exit code, and the run ID, as the wrapper does. It's limited to 1,000 characters; a longer one gets the check-in rejected with HTTP 400, and the failure isn't recorded. Keep log tails, secrets, records, and personal data out of it.

## Frequently Asked Questions

### Where does cron job output go by default?

Cron mails it to the address in `MAILTO`, or to the crontab's owner when `MAILTO` isn't set. Without a mailer, Debian's cron discards it, cronie writes it to syslog, and BusyBox crond leaves the output of entries without `MAILTO` on its own stdout, which is `/dev/null` when it runs as a daemon. Cron's own log records that the command started, not what it printed or how it exited.

### How do I log cron job output to a file or journald?

For a file, append `>>/var/log/cron-jobs/nightly-report.log 2>&1` to the crontab entry; `2>&1` keeps error messages in the same file. For the journal, start the entry with `systemd-cat -t nightly-report`, which connects both streams to journald and keeps the job's exit status, and read it with `journalctl -t nightly-report`. Piping to `logger -t nightly-report` works too, but needs `pipefail` to keep the job's status and splits lines over 1 KiB.

### How do I rotate cron job logs?

Add a logrotate rule for the log directory with `create`, `compress`, `delaycompress`, and `su` set to the directory's owner. Because each run opens its log with `>>`, rename-and-create rotation is safe and `copytruncate` isn't needed. For the journal, set `SystemMaxUse=` and `MaxRetentionSec=` in a journald.conf drop-in.

### What should a cron job log on every run?

A start and an end line sharing one run ID, plus the code version, the parameters such as the date range processed, counts of what was read, written, and skipped, external calls and retries, the duration, and the exit status. Write one JSON object per line with UTC timestamps, and keep secrets and personal data out.

### How do I match a CronRadar run to its log lines?

Send the same `run_id` on the run's `start` and `complete` or `fail` check-ins that the job writes into its log lines. CronRadar shows it as the run's Execution ID, and the monitor's Logs URL opens your log search for the job, where you search for that ID. CronRadar stores the link, not the logs.
