Skip to content

Log profiles, destination registry, and queued Cloud Logging transport - #25

Merged
anth-volk merged 9 commits into
mainfrom
feat/log-profiles-queued-transport
Jul 9, 2026
Merged

Log profiles, destination registry, and queued Cloud Logging transport#25
anth-volk merged 9 commits into
mainfrom
feat/log-profiles-queued-transport

Conversation

@anth-volk

@anth-volk anth-volk commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #22

What this adds

The restart of the bounded/async log emission work after PR #23 was abandoned (see its closing comment). The design is deliberately smaller and strategy-shaped, in six commits:

  1. Bounded Google writes — the gapic write method (the single choke point under log_struct) is rebound on the destination-owned client with a retry whose transient-error budget and per-call timeout are both capped by OBSERVABILITY_GOOGLE_WRITE_TIMEOUT_SECONDS (default 10s, clamped — 0/inf/nan cannot disable or unbound it), replacing the 60s transport defaults. The write path stays log_struct, so field mapping is unchanged.
  2. Stdout formatter registry — formatters register by name; the core knows only plain. The google formatter (agent-native severity/trace/spanId/labels special keys, no time key since emission is synchronous) lives in the google module. A broken formatter degrades to the unformatted line.
  3. Destination registry + generic queued transport — destinations register as inline (stdout) or remote; the manager builds by lookup (no destination names in the manager) and wraps every remote strategy in QueuedLogDestination, built on stdlib queue.Queue + logging.handlers.QueueListener so CPython owns the thread/shutdown machinery. Mutable state is exactly four pieces (queue, listener, drop counter, closed flag — documented in the module docstring with the accepted races). emit() snapshots + stamps enqueue time and never blocks or raises; a full queue drops-and-counts with throttled reports; close() is idempotent and deadline-bounded (one monotonic deadline covers the sentinel put and the join; an undrained listener is abandoned with a report). No breaker, no retry queue, no fork hooks, no generations — delivery is best-effort by contract because stdout is always the durable sibling record.
  4. Bounded shutdown + restart_observability() — shutdown closes log destinations first, inline, with a deadline derived from the shutdown budget (half when OTel providers need flushing, all of it otherwise); the provider flush thread only exists when providers do and is joined with the remaining budget. Restart closes and rebuilds destinations from config, documented for single-threaded lifecycle moments only (post-snapshot, post-fork, before traffic).
  5. Log profiles — consumers set one env var: OBSERVABILITY_LOG_PROFILE = gcp-agent (google-format stdout only; Cloud Run), gcp-direct (stdout + queued Cloud Logging; Modal), plain-sync (plain stdout, guaranteed zero threads — the kill switch), auto (default: detects via OBSERVABILITY_PLATFORM > K_SERVICE > Modal markers, preserving caller defaults when nothing matches). Explicit OBSERVABILITY_LOG_DESTINATIONS/OBSERVABILITY_STDOUT_FORMAT override the expansion; gcp-direct without a resolvable project downgrades to plain-sync with a warning.
  6. Docs — README profiles/semantics/lifecycle sections; runbook switched to profile-based config with the plain-sync kill switch.

Semantics changes to be aware of

  • The google destination is always queued (maintainer-confirmed): no remote write ever runs on a request thread, making the 60–180s request stalls from the sync log_struct path structurally impossible. Delivery becomes explicitly best-effort; a hard crash loses what was still queued. No production consumer uses sync google writes today (household pins stdout).
  • auto detection partially activates on deployed platforms: verified against household main's actual deploy config — on a lock refresh before the adoption PR, Cloud Run (which sets OBSERVABILITY_PLATFORM=google_cloud_run) starts emitting google-format stdout (additive keys the agent promotes; payload fields unchanged), while Modal does not activate: its secret sync on main carries no OBSERVABILITY_GOOGLE_CLOUD_PROJECT, so gcp-direct downgrades to plain-sync with a one-time warning report. Queued direct writes from Modal begin only when the adoption PR adds the project/WIF vars. If even the Cloud Run format change is unwanted early, deploys can pin OBSERVABILITY_LOG_PROFILE=plain-sync until adoption.

Validation

188 tests (was 126 on main), branch coverage 91% (gate ≥90), ruff clean. The queued transport is tested against fake inner destinations only (drain determinism via sentinel ordering, snapshot-at-enqueue, overflow + throttling, bounded close with stuck writes and jammed queues, listener survival across write failures, emit-never-raises matrix, atexit lifecycle, breaker non-interaction, reconfigure-closes-previous). Sequencing uses threading.Event only — no unconditional sleeps.

Merging auto-releases as 1.4.0 (.added fragments → minor).

Review fixes (ee055cf)

A five-angle deep review (bugs, plan conformance, test coverage, DRY, strategy purity) ran against this branch; every finding is fixed in the follow-up commit:

  • Lifecycle bugs: restart_observability() now honors OBSERVABILITY_ENABLED; reconfigure installs new destinations before closing the old ones so close-phase reports keep a sink; the Google library's one-time instrumentation diagnostic entry is suppressed (the README had claimed it already was); the google destination closes its client after a drained close; manager close shares one deadline across destinations.
  • Strategy purity: strategy requirements are declarative (register_destination(required_config=...), checked generically during profile expansion — no google conditional left in core config); backend knobs are strategy-owned (OBSERVABILITY_GOOGLE_WRITE_TIMEOUT_SECONDS is parsed by the google factory, the per-backend config field is gone); register_destination/register_stdout_formatter are exported top-level; google_credentials moved under destinations/; the old module path is removed (top-level re-exports unchanged; verified no consumer imports the deep path).
  • DRY: one shared guarded-report helper + throttled counter, one duck-typed close contract, one name normalizer (destinations, formatters, and profiles now all forgive hyphen/underscore/case variance; unknown stdout formats are reported instead of silently degrading), queue defaults hoisted to config, stdout construction unified.
  • Tests: shared fakes + conftest; 216 tests (was 188), 92% branch coverage; new coverage for close-failure containment, the configure-crash stdout fallback, gcp-agent end-to-end formatting through the manager, a broken reporting channel at construction, timed-out closes leaving the inner destination alone, clamp boundary pins, and restart delivery resumption.

Remaining before merge: a live smoke against real Cloud Logging (gcp-direct with local ADC) — not yet run.

🤖 Generated with Claude Code

anth-volk and others added 9 commits July 8, 2026 22:12
log_struct exposes no call options, so writes inherited the transport
defaults (60s retry deadline) and a degraded Logging API could hold one
write for a minute. The gapic write method is the single choke point
underneath log_struct, so the destination rebinds it on its own client
with a retry whose transient-error budget and per-call timeout are both
capped by OBSERVABILITY_GOOGLE_WRITE_TIMEOUT_SECONDS (default 10s).
The knob is clamped through a new shared clamped() helper that rejects
zero, negative, and non-finite values, so a stray env value can never
disable or unbound the write. The emit path also accepts an optional
timestamp so queued transports can preserve event time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stdout lines gain a named-formatter hook: formatters register by name
and shape the normalized payload into the JSON line a platform's log
agent expects. The core knows only the plain default; the google
formatter (severity plus the logging.googleapis.com trace/spanId/labels
special keys the Cloud Run and GKE agents promote to LogEntry fields)
lives in the google module and registers itself. No time key is set:
emission is synchronous, so the agent's receive time is the event time.
A broken formatter degrades to the unformatted line — stdout is the
fallback sink and must keep delivering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Destinations now register as named strategies: inline strategies
(stdout) write synchronously on the caller's thread, and every remote
strategy is wrapped in QueuedLogDestination, so no remote write can
ever run on a request thread. The manager builds by registry lookup and
carries no destination names; the google strategy registers itself and
its aliases from its own module.

QueuedLogDestination is built on stdlib queue.Queue plus
logging.handlers.QueueListener so CPython owns the thread and shutdown
machinery. Its mutable state is exactly four pieces (queue, listener,
drop counter, closed flag). emit() snapshots the payload, stamps the
enqueue time, and never blocks or raises: a full queue drops the newest
record with counted, throttled reports. close() is idempotent and
deadline-bounded — one monotonic deadline covers the sentinel put and
the thread join, and an undrained listener is abandoned as a daemon
with a report rather than waited on. Reconfiguring the manager closes
replaced destinations and clears the id()-keyed failure ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
shutdown() now closes log destinations first, inline (destination close
is inherently deadline-bounded), with a deadline derived from the
shutdown budget: half when OTel providers still need flushing, the full
budget otherwise. The provider flush thread is only spawned when
providers exist and is joined with the remaining budget, so a slow log
sink can never starve the OTel flush and a disabled runtime spawns no
thread. The budget itself is clamped against pathological env values.

restart_observability() closes and rebuilds log destinations from
configuration for forked or snapshot-restored processes, documented for
single-threaded lifecycle moments only — no locking machinery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One env var now expands to a full routing configuration:
OBSERVABILITY_LOG_PROFILE=gcp-agent (agent-native google-format stdout
only), gcp-direct (plain stdout plus queued direct Cloud Logging
writes), plain-sync (plain stdout, guaranteed zero threads — the kill
switch), or auto (default). Auto detects the platform in precedence
order OBSERVABILITY_PLATFORM, K_SERVICE, Modal markers, and preserves
caller-supplied defaults when nothing matches, so consumers stop
carrying platform routing logic. Profiles are presets expanding to
generic primitives (destination names and a formatter name); the
mechanism knows no backend. Explicit OBSERVABILITY_LOG_DESTINATIONS and
OBSERVABILITY_STDOUT_FORMAT still override the expansion, gcp-direct
downgrades to plain-sync with a warning when no Google Cloud project is
resolvable, and unknown profiles warn and fall back to plain-sync; the
manager reports these warnings through the internal-error channel at
configure time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bugs:
- restart_observability() now honors the OBSERVABILITY_ENABLED kill
  switch (mirrors configure()), so a disabled deployment's post-fork
  hook cannot build clients or start listener threads.
- configure() installs the new destinations before closing the replaced
  ones, so close-phase failure reports (which route through emit) have
  a sink instead of vanishing into an empty destination list.
- The Google client library's one-time instrumentation diagnostic entry
  is suppressed at construction, matching the documented behavior.
- GoogleCloudLoggingDestination gains close(), so a drained queue close
  releases the owned client instead of leaking it on restart.
- Manager close shares one monotonic deadline across all destinations,
  so N stuck closes cannot take N times the shutdown budget.

Strategy purity:
- Strategy requirements are declarative: register_destination() takes
  required_config, and profile expansion checks it generically — the
  google-conditional in core config is gone and the downgrade warning
  names the actual profile and missing fields.
- Backend knobs are strategy-owned: the google factory parses
  OBSERVABILITY_GOOGLE_WRITE_TIMEOUT_SECONDS itself; the per-backend
  config field is removed.
- register_destination/register_stdout_formatter exported at top level;
  google_credentials moved under destinations/ (shim kept).

DRY:
- Shared safe_report/_ThrottledCounter replace four hand-rolled guarded
  report sites; one close_destination() contract (deadline passed only
  when accepted) replaces two divergent duck-typed close conventions.
- One normalize_name() for destination, formatter, and profile lookups;
  unknown stdout formats are reported instead of silently degrading.
- Queue knob defaults hoisted to config; stdout construction unified in
  build_stdout_destination(); trace resource name built in one helper.

Tests: shared fakes/conftest; new coverage for close-failure
containment, configure-crash fallback, gcp-agent end-to-end formatting,
constructor-level broken reporting channel, timed-out close leaving the
inner destination alone, clamp boundary pins, knob wiring, restart
delivery resumption, Modal auto-detection edge cases, and the
restart/shutdown lifecycle guards. 216 tests, 92% branch coverage.

Fixes #22

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolve_stdout_formatter() called the registered factory unguarded, so
a third-party formatter factory that raises (registered through the
newly public register_stdout_formatter) escaped every path that
resolves it: manager.configure()'s empty-destinations fallback and the
_ensure_destinations except-branch re-raised through the same broken
factory, propagating into runtime.configure() at host startup and
leaving the lazy path rebuilding (and raising) on every emit.

The factory call now degrades to the built-in plain formatter — not
the registry's "plain" entry, which could itself be the overridden
broken one — and reports through the internal-error channel when a
reporting channel exists. Tests pin the resolver-level degrade+report
and the previously-crashing configure fallback path end to end.

Fixes #22

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified no consumer imports the old module path: the household API's
only google_credentials references are its own unrelated
policyengine_household_modal module, and org-wide code search finds no
external importers — everything goes through the top-level re-exports,
which are unchanged. The path removal gets its own .removed changelog
fragment (still a minor bump).

Fixes #22

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@anth-volk
anth-volk marked this pull request as ready for review July 9, 2026 14:17
@anth-volk
anth-volk merged commit b418965 into main Jul 9, 2026
3 checks passed
@anth-volk
anth-volk deleted the feat/log-profiles-queued-transport branch July 9, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bounded and asynchronous log emission: cap Google write latency, add a background emitter, and agent-native stdout format

1 participant