Log profiles, destination registry, and queued Cloud Logging transport - #25
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
log_struct) is rebound on the destination-owned client with a retry whose transient-error budget and per-call timeout are both capped byOBSERVABILITY_GOOGLE_WRITE_TIMEOUT_SECONDS(default 10s, clamped —0/inf/nancannot disable or unbound it), replacing the 60s transport defaults. The write path stayslog_struct, so field mapping is unchanged.plain. Thegoogleformatter (agent-native severity/trace/spanId/labels special keys, notimekey since emission is synchronous) lives in the google module. A broken formatter degrades to the unformatted line.inline(stdout) orremote; the manager builds by lookup (no destination names in the manager) and wraps every remote strategy inQueuedLogDestination, built on stdlibqueue.Queue+logging.handlers.QueueListenerso 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.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).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 viaOBSERVABILITY_PLATFORM>K_SERVICE> Modal markers, preserving caller defaults when nothing matches). ExplicitOBSERVABILITY_LOG_DESTINATIONS/OBSERVABILITY_STDOUT_FORMAToverride the expansion;gcp-directwithout a resolvable project downgrades toplain-syncwith a warning.plain-synckill switch.Semantics changes to be aware of
log_structpath 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).autodetection partially activates on deployed platforms: verified against householdmain's actual deploy config — on a lock refresh before the adoption PR, Cloud Run (which setsOBSERVABILITY_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 noOBSERVABILITY_GOOGLE_CLOUD_PROJECT, sogcp-directdowngrades toplain-syncwith 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 pinOBSERVABILITY_LOG_PROFILE=plain-syncuntil 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.Eventonly — no unconditional sleeps.Merging auto-releases as 1.4.0 (
.addedfragments → 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:
restart_observability()now honorsOBSERVABILITY_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.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_SECONDSis parsed by the google factory, the per-backend config field is gone);register_destination/register_stdout_formatterare exported top-level;google_credentialsmoved underdestinations/; the old module path is removed (top-level re-exports unchanged; verified no consumer imports the deep path).Remaining before merge: a live smoke against real Cloud Logging (
gcp-directwith local ADC) — not yet run.🤖 Generated with Claude Code