Fourteen nights of missing telemetry: the four-part skeleton every new skill now inherits

Saturday morning. GSC and GA nightly digests: both absent. Not erroring. Absent. The crons had been running on schedule, logging clean exits, and producing nothing. Two weeks of silence. Last data in either file dated 20 June.

Cause: launchd TCC. The macOS Transparency, Consent, and Control layer that guards access to external volumes. The shell scripts had Full Disk Access toggled on in System Settings, but the launchd-spawned processes didn’t inherit the grant. Fix: Terminal-spawn wrapper, re-grant, chain verified live on 4 July. Fourteen nights of missing data, fixed in an afternoon.

The TCC diagnosis was familiar. The silence wasn’t.

Three telemetry reads running on crons, two of them down for a fortnight, nothing surfacing it. No alert. No visible diff in any view. No indication until I looked.

That’s the cost of telemetry output with nowhere reliable to land.

The skeleton

Every telemetry skill in the estate follows the same four-part shape. It wasn’t designed upfront; it converged from three independent builds across GSC, GA4, and AdSense.

Read-only OAuth scope. Each skill requests only what it needs to query. GSC covers Search Console read access. GA4 covers the Data API read scope for traffic and conversions, extended on 25 July to include eventName and eventCount. AdSense (B-078) covers the AdSense Management API v2 read scope: approval status, earnings, payments. No write grants on any of them.

The minimum-viable-authorisation principle is deliberate. Narrow scopes limit the blast radius if a token leaks and make the authorisation surface auditable at a glance.

Refresh token in macOS Keychain. Never in .env or any config file that reaches git. The script fetches from Keychain at runtime. On a fresh machine, the first auth is manual; every subsequent run is silent and automated.

Stdlib-only digest. No third-party HTTP client. No data-processing dependency. The digest is a structured block parseable by the ops report without pulling in anything beyond the standard library. Stdlib-only keeps the cron environment stable and removes a category of silent-failure risk: a missing package breaks loudly on install, not silently at runtime.

Appended to one email, not a new surface. The skill writes its digest to a known path. The ops report reads it. The integration point is a file, not an endpoint. No new view is required to consume a new skill.

One email

The ops report (.editorial/scripts/ops_report.py, stdlib only) landed on 22 July. It reads the latest cycle-run JSON (stage dots, wall-clock times, outcome, heartbeat, awaiting-approval status) and both telemetry digests, and emits a single styled email.

The case for one email over a dashboard: a dashboard requires you to go and look. An email arrives. If a cron ran and data arrived, it’s in the email. If either is absent, the absence is visible in the same place the presence would be. No second monitor. No separate health-check loop.

That’s the structural answer to the 4 July failure. Not an alert system. An email that surfaces a blank row as clearly as a populated one. A two-week silence becomes a one-night absence.

Adding a new skill doesn’t require building a new view. Extend the email template by one block, point it at the new digest path. Coverage grows without a new surface to maintain.

The first-run fragility

Every skill has one weak point: the initial OAuth authentication is manual and it’s fragile.

The AdSense live read on 2 July is the example. First CLI auth attempt failed silently: blank or mistyped paste at the client-id prompt. Second attempt clean after fetching the correct credentials from Keychain directly. Not a code bug. A first-run friction point that occurs once per machine, per skill.

Once the token is in Keychain, the fragility disappears. The AdSense read confirmed: account READY, site GETTING_READY (review in progress), no policy strikes, Auto Ads enabled. One run, all the data relevant to the “are we ready for AdSense” question that triggered the build.

The only mitigation for the first-run problem is documentation: what to paste, where to get it, what a silent failure looks like. Worth documenting once. Not worth engineering around.

Zero new authorisation

The 25 July behaviour-events entry operated under a specific constraint: no new authorisation surface.

GA4 event reports (eventName and eventCount) landed in the nightly digest via the existing read-only token. No new OAuth flow, no new Keychain entry, no changes to the site. The GA4 Data API covers event reporting inside the same read scope already authorised for traffic and conversion data. The ga-telemetry digest was extended to query the event fields; nothing else changed.

That’s what the devlog calls “freeze-safe”: server-side only, zero new permissions, appended to the existing email.

The constraint changes the design question. Instead of “what scope do I need?” the question becomes “what does the existing scope already cover?” In this case it covered more than the first reading of the API docs suggested. The extension cost nothing beyond the query.

Out of scope: write operations, user-identifying event data, cross-property joins. The skeleton is read-and-surface, full stop.

What the pattern gives you

Three services. Different auth endpoints, different response shapes, different API versions. The skeleton is identical.

That uniformity pays off on the next build. The OAuth pattern, Keychain integration, and digest format are already documented and copy-pasteable. The ops email extension is a one-block change. The TCC fix (Terminal-spawn wrapper, re-grant procedure) is in the runbook from 4 July.

The first telemetry skill took a day. The behaviour-events extension took a morning. That compression is what a consistent skeleton does to the second and third build.

The 4 July failure ran for two weeks because the pattern wasn’t complete. No ops email, no consistent digest output, no single surface for anything to land. The skeleton closed that. The email is the receipt. The two-week silence is what it prevents.

All writing →