The GSC and GA crons failed silently for two weeks. That's a telemetry blindspot, not a cron problem.

The nightly GSC and GA crons for UK Calculators had been failing every night since 20 June.

That surfaced on 4 July during a Saturday morning telemetry review. The crons were scheduled to pull Search Console impressions and Analytics session data nightly, rolling the results into the digest. Fourteen nights. The digests existed. The data did not. The logs showed no failures.

launchd had been marking the jobs complete. The scripts had exited with status 0. The only indication something was wrong was the absence of data, and that reads identically to a genuine quiet period unless you are already watching for a flatline.

What TCC did

The mechanics matched the captainrandom.co.uk refresh cron failure documented previously. UK Calculators scripts live on /Volumes/b/, an external SSD that macOS mounts with the noowners flag. Anything under noowners falls under TCC (Transparency, Consent, and Control). A bash script invoked directly from a launchd plist runs in an unprivileged context. The kernel returns EPERM at the open() call. The script exits before touching the files it was meant to read.

The captainrandom.co.uk cron was fixed via an .app-bundle route: osacompile produced a bundle, PlistBuddy patched a CFBundleIdentifier into Info.plist, and an FDA grant got bound to the bundle identity in System Settings. The UK Calculators crons took the Terminal-spawn route instead.

The Terminal-spawn wrapper works differently. Instead of creating a code-signed bundle that TCC can attach an FDA grant to, the launchd plist invokes osascript to open a Terminal window that runs the target script. Terminal.app already carries Full Disk Access. Child processes spawned from it inherit that context. The script runs with the same effective permissions as a manual terminal invocation.

The plist shape:

<key>ProgramArguments</key>
<array>
  <string>/usr/bin/osascript</string>
  <string>-e</string>
  <string>tell application "Terminal" to do script "/path/to/your/script.sh"</string>
</array>

No new bundle to maintain. No FDA grant to rebind after a codesign reset. The trade-off: a Terminal window opens briefly when the cron fires. Fine for a machine that runs overnight, not suitable for a shared screen or a kiosk.

Verified live on 4 July through the full launchd chain. Forced a kickstart, watched the Terminal window open, script ran, GSC data came in.

Why fourteen days went unnoticed

The cron failing is a solvable problem. The fourteen days going unnoticed is the one worth examining.

The telemetry crons existed to surface data about the site: Search Console impressions, Analytics sessions, the signal used to measure whether the editorial work was landing. When the crons went dark, the measurement surface went dark with them.

There was no alert. The launchd logs showed clean exits. The digest script ran and reported accurately: zero impressions, zero sessions. That reading is correct for a genuine quiet stretch and for a broken data pipeline. From the outside, they are indistinguishable.

The gap surfaced during a routine Saturday morning review. Not from a monitoring alert. Not from a threshold breach. A human looked at a date column and noticed the gap. That is not a detection mechanism.

What shipped as a result

Two things landed in the three weeks following the fix.

A daily ops email. The new ops_report.py (stdlib only, no new dependencies) reads the latest cycle-run JSON and builds a styled daily report covering stage outcomes, wall times, heartbeat state, and awaiting-approval items. If a cron is dark, the report surfaces dark. The distinction between “no new data” and “the cron didn’t run” is explicit in the format, not inferred from a flatline.

User-behaviour events in the nightly digest. The GA4 telemetry skill now queries eventName and eventCount alongside session data. More signal, same delivery channel.

Neither would have caught the original two-week failure. Both shipped after the fix. They are insurance against the next one.

What would have caught it: a heartbeat. An explicit per-run record of “ran at 02:00, returned N rows,” written somewhere independent of the data pipeline it is measuring. The telemetry stack does not have that yet.

The operational shape

A telemetry cron fails differently from a deploy or a content pipeline.

When a deploy fails, the site does not update. The symptom is immediate and observable. When a content pipeline stalls, the queue backs up, visible on the dashboard. When a telemetry cron fails silently, the data goes quiet. Quiet data looks like quiet data.

The failure mode requires something independent to verify. Errors will not do the job, because TCC failures produce none. Output will not either, because a digest reporting zero is technically accurate. What is required is an explicit confirmation, per run, that the machinery ran and returned something real.

The daily ops email moves toward that. It reports on cycle runs and digest outcomes; it does not yet verify that the individual data-pull steps inside each run succeeded. A per-run receipt written to a separate table would close that gap properly. That is the correct long-term shape. The email is the interim one.

Out of scope: rearchitecting the cron to write per-run receipts. That is a later sprint.

What to carry forward

launchd TCC fails silently on noowners volumes. The EPERM does not surface in the launchd error logs. If a cron runs correctly in a terminal but produces nothing via launchd, TCC is the first hypothesis. The .app-bundle fix is deliberate and correct. The Terminal-spawn wrapper is faster to apply across a growing cron estate and does not require maintaining a bundle per job.

Telemetry needs its own observability layer. Measuring what the site does is one problem. Confirming the measurement machinery is operational is a different problem with a different failure mode. Crons are easy to treat as solved infrastructure. They are just scheduled scripts, until one fails silently for a fortnight and nobody notices.

The daily ops email is now load-bearing. Before it existed, verifying cron state required knowing to check the logs. A report that goes out regardless inverts that burden: silence becomes the alert. That inversion is worth more than a wall of monitoring dashboards.

The GSC cron ran last night. The data landed. The ops email confirmed it this morning.

All writing