Saturday, 4 July. Morning telemetry review. First finding was benign: no 07:00 cycle-run from the editorial cron. The editorial cron is weekday-gated by design; Saturday is a scheduled quiet day. Expected.
Second finding was not. GA event counts sat at 0; GSC impressions and clicks the same. This wasn’t a quiet traffic day. The data simply wasn’t in the digest. The nightly pulls hadn’t delivered anything.
The GSC and GA crons had been dead since 20 June.
What was missing and why
The gsc-telemetry and ga-telemetry skills run nightly as launchd agents. Each opens its API credentials, calls the read-only endpoint, writes numbers to the digest file. That file feeds the editorial review: impressions, clicks, top queries, session data, event counts. The digest had been producing output every morning. The output had contained zeros for two weeks.
launchd was spawning the jobs on schedule. TCC was killing them. The scripts were trying to open credentials files outside the home directory; TCC returned EPERM; the processes exited non-zero; launchd logged a completed run. No stderr noise in the outputs the plists declared. Nothing I was watching treated a non-zero subprocess exit from those jobs as a failure worth surfacing.
That’s TCC’s default failure mode: silence. The EPERM doesn’t propagate into the launchd job summary. The job runs. The subprocess fails. The job shows as complete. The only record is in the err log for that specific plist label, written at 03:00, which nobody reads at 03:00.
Something cleared the grant between the last clean run and 20 June. The crons kept running nightly on schedule. The data stopped arriving. Fourteen consecutive nights of silent failure, logged to err files I wasn’t watching at the right time.
What caught it
The ga-telemetry and gsc-telemetry skills write their output to a nightly digest. That digest is the downstream: the thing that receives data from the upstream crons, formats it, and puts numbers somewhere I read them regularly. Both skills run server-side only. No site changes, no new authorisation; the existing read-only tokens cover the event reports.
The Saturday review reads that digest. The zeros were visible because the digest existed and I was reading it. No automated alert fired. No threshold check triggered. The digest showed 0 where numbers above 0 should have been. That was the signal.
This is the shape of passive telemetry: it doesn’t alert on failure, it shows absence. The absence is a signal only if a human reads the output on a cadence short enough to matter. A daily review would have caught this on 21 June. A weekly review would have caught it by 28 June. The review cadence was weekly. The gap was fourteen nights.
The fix
The documented path through launchd TCC for this type of failure is the .app bundle approach: osacompile to produce an executable bundle, PlistBuddy to inject a CFBundleIdentifier, ad-hoc codesign, then drag the bundle into System Settings → Full Disk Access. TCC attaches the grant to the bundle’s stable identity. Six steps, one manual UI interaction, survives TCC resets.
The Terminal-spawn wrapper is cheaper for this specific case. Terminal.app already has Full Disk Access on this machine, granted during an earlier TCC encounter and not revoked since. Routing the cron through osascript’s do shell script inside a Terminal invocation means the child process inherits that FDA grant. The child gets the access the direct launchd-spawned process didn’t.
The fix landed as a wrapper routing the nightly GSC and GA crons through the Terminal-spawn pattern instead of direct subprocess invocation. Verified live through the full launchd chain. The err log that had been accumulating EPERM failures at 03:00 every night showed clean output. Data arrived in the digest the next morning.
Out of scope: the underlying TCC database entry. The wrapper sidesteps it rather than repairing it. If Terminal.app loses FDA (a reinstall, a macOS major upgrade), the crons break again. The .app bundle approach is the durable fix. The wrapper unblocks the data pipeline immediately; the durable path is the queued follow-up.
What came next
Three weeks after the 4 July catch, the daily ops email went into service. A styled summary arriving each morning: cycle-run outcomes with stage dots showing which pipeline stages completed, wall times, a heartbeat line, and telemetry performance numbers in one glance. The same nightly data the GSC and GA crons produce, surfaced automatically without having to open the raw digest file.
On 25 July, user-behaviour events went live in the digest. GA4 eventName and eventCount queries surfaced action-level data from the calculators. Server-side only, zero site changes, zero new authorisation. Part 1 of the behaviour-telemetry plan.
Both depend on the GSC and GA crons delivering data nightly. Without the 4 July catch, the behaviour-events rollout would have landed on infrastructure that had been broken for two weeks. The ops email would have been reading from dead crons from day one. The milestone would have shipped. I wouldn’t have known the pipeline feeding it was empty.
The asymmetry
A healthy cron and a dead cron are identical from the outside. Both run on schedule. Both exit. Neither announces a failure without explicit failure instrumentation. The only observable difference is whether data arrives downstream.
Server-side telemetry creates that downstream. Something receives the data, formats it, and puts numbers in front of a human on a regular cadence. When the upstream cron dies, the downstream shows absence. The absence is the failure signal, provided the downstream exists and someone is reading it on a cadence that matters.
The ga-telemetry and gsc-telemetry skills exist to surface numbers for editorial review. That’s the primary job. The secondary job, the one they earned their keep on during this incident, is proving each time the digest is read that the upstream crons are still alive. The proof is a non-zero number. The absence is the alert.
The Saturday cadence existed before this incident. The skills existed before this incident. What the incident established is that together they form a monitoring layer for the underlying cron estate. I hadn’t designed it that way explicitly. The Saturday that surfaced the failure is when it became clear that was what it was doing.
Fourteen nights. One Saturday morning read. The zeros were in the digest every day. The cadence was the thing that made them matter.



