feat(publisher): degrade PubSubPublisher to noop when GCP credentials are absent - #388
feat(publisher): degrade PubSubPublisher to noop when GCP credentials are absent#388julietshen wants to merge 18 commits into
Conversation
… are absent First PR for #343 — makes Osprey runnable without GCP credentials. PubSubPublisher now auto-detects whether google.auth.default() resolves at construction time. If it doesn't, the publisher sets _enabled=False, skips building the underlying client, logs a one-time warning, and short-circuits publish() and stop(). A PubSubPublisher.publisher.noop metric is emitted per attempt so the inert state is visible in dashboards. Detection is cached at module scope. No call-site changes — the three PubSubPublisher(...) sites in cli/sinks.py and the singleton in ui_api/osprey/singletons.py continue to work unmodified. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthrough
ChangesPubSubPublisher noop mode
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related issues
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
osprey_worker/src/osprey/worker/lib/tests/test_publisher.py (1)
59-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert noop metric tags too.
The implementation sends publisher tags with the noop metric; checking only the name leaves the dashboard-slicing contract unprotected.
Proposed test tightening
- metric_names = [call.args[0] for call in metrics_mock.increment.call_args_list] - assert metric_names == ['PubSubPublisher.publisher.noop'] + metrics_mock.increment.assert_called_once_with( + 'PubSubPublisher.publisher.noop', + tags=['project:proj', 'topic:projects/proj/topics/topic'], + )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@osprey_worker/src/osprey/worker/lib/tests/test_publisher.py` around lines 59 - 67, The noop metric test only verifies the metric name in test_publish_short_circuits_and_emits_noop_metric_when_disabled, but not the publisher tags that the implementation emits. Update this test to also assert the tags passed to metrics.increment when PubSubPublisher.publish short-circuits, using the existing publisher.metrics mock and the PubSubPublisher publisher path so the dashboard-slicing contract stays covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@osprey_worker/src/osprey/worker/lib/publisher.py`:
- Around line 135-146: The lazy GCP credential cache in _check_gcp_credentials
can still race when multiple PubSubPublisher instances are created concurrently.
Add a small module-level lock around the first google.auth.default() lookup so
only one thread can populate _gcp_credentials_available, and keep the cached
return path unchanged. Use the existing _gcp_credentials_available global and
_check_gcp_credentials function to locate the fix.
---
Nitpick comments:
In `@osprey_worker/src/osprey/worker/lib/tests/test_publisher.py`:
- Around line 59-67: The noop metric test only verifies the metric name in
test_publish_short_circuits_and_emits_noop_metric_when_disabled, but not the
publisher tags that the implementation emits. Update this test to also assert
the tags passed to metrics.increment when PubSubPublisher.publish
short-circuits, using the existing publisher.metrics mock and the
PubSubPublisher publisher path so the dashboard-slicing contract stays covered.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 82dfa609-db16-4dd3-b7f3-3dce5ac305d7
📒 Files selected for processing (2)
osprey_worker/src/osprey/worker/lib/publisher.pyosprey_worker/src/osprey/worker/lib/tests/test_publisher.py
| google.auth.default() | ||
| _gcp_credentials_available = True |
There was a problem hiding this comment.
I'm wondering if a better way to do this would be to check for "Google" in /sys/class/dmi/id/product_name, so we can avoid the cost of this network call.
Seeing as we actually aren't using the credentials, only checking for them.
There was a problem hiding this comment.
oooh good catch. you're right that we only need to check whether creds exist here, not actually use them. I looked into what that network call really costs:
google.auth.default() looks for credentials in three places, in order: an environment variable pointing at a key file, then a local gcloud login file, and it only calls to the network if both of those come up empty. So the network call only happens when there are no creds anywhere else, which is the exact case we're trying to catch.
it shouldn't slows down a normal startup that has creds, and I only run the check once per process and remember the answer, so it doesn't happen on every publish.
The reason I'd hold off on reading /sys/class/dmi/id/product_name: that tells you whether the machine is a Google Cloud box, which isn't quite the same as "do we have google credentials." Those two usually match, but not always: for example, someone running Osprey outside Google Cloud (say on AWS or their own servers) but still sending to Pub/Sub with a key file has credentials, even though the machine isn't a Google box.
In that case, google.auth.default() still finds the key and publishing works. But if we switched to reading the machine type, it would see "not a Google box," assume there are no credentials, and shut publishing off without any error. Since the whole point of #343 is to stop GCP from failing quietly, I'd rather not add another way for that to happen.
If that startup check does turn out to slow down docker compose up, I think the better fix is to just give it a short time limit so it can't get stuck, vs switch to the hardware check and lose the ability to find key-file creds. Happy to do that if you think it's worth it! What do you think?
There was a problem hiding this comment.
The reason I'd hold off on reading /sys/class/dmi/id/product_name: that tells you whether the machine is a Google Cloud box, which isn't quite the same as "do we have google credentials." Those two usually match, but not always: for example, someone running Osprey outside Google Cloud (say on AWS or their own servers) but still sending to Pub/Sub with a key file has credentials, even though the machine isn't a Google box.
Yeah, this makes sense.
In that case, google.auth.default() still finds the key and publishing works. But if we switched to reading the machine type, it would see "not a Google box," assume there are no credentials, and shut publishing off without any error. Since the whole point of #343 is to stop GCP from failing quietly, I'd rather not add another way for that to happen.
If that startup check does turn out to slow down docker compose up, I think the better fix is to just give it a short time limit so it can't get stuck, vs switch to the hardware check and lose the ability to find key-file creds. Happy to do that if you think it's worth it! What do you think?
The network cost might be something we need not worry about tbh, putting a limit sounds like a good idea,
Resolve conflict in publisher.py: keep the noop-mode degradation on top of main's modernized type hints (dict[str, str] | None) and the moved _raise_on_error/_tags assignments.
- Guard the cred-detection cache with a module-level lock and double-checked read so concurrent PubSubPublisher constructors probe google.auth.default() exactly once (CodeRabbit inline). - Tighten the noop-metric test to assert the publisher tags, not just the metric name, so the dashboard-slicing contract stays covered (CodeRabbit nitpick). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
The exporter tests build a real PubSubPublisher, which now degrades to noop when GCP credentials are absent (as in CI). Force the credential check so the mocked PublisherClient is exercised and the publish assertions hold. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
The previous fix set _check_gcp_credentials via the session-scoped monkeypatch, which never reverts, so the forced-True check leaked into later modules. In test_publisher.py the disabled-publisher tests then built a real batch client and hung on stop(), timing out the integration job. Use the function-scoped monkeypatch so the patch reverts after each exporter test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
ThisIsMissEm
left a comment
There was a problem hiding this comment.
The only thing I'm not sure on is the usage of a metric for this state. I think you'd probably want just a config flag fed back into the system. So like allow setup of the publisher to fail, and report it back as a configuration error rather than a metric (one parsable log line vs metric per send), since this is only changing at startup.
It'd also be an idea to add a DISABLE_GCP_PUBSUB boolean environment variable that completely opts out of this codepath.
|
|
||
| def publish(self, data: _PydanticModelT, attributes: dict[str, str] | None = None) -> None: | ||
| if not self._enabled: | ||
| metrics.increment(f'{self.__class__.__name__}.publisher.noop', tags=self._tags) |
There was a problem hiding this comment.
Not sure on the usage of another metric here, and I think the dashboard could probably just surface this as a warning banner or something "Publishing to GCP PubSub is currently disabled due to invalid credentials"
There was a problem hiding this comment.
ooooh good idea! I'll work that in
There was a problem hiding this comment.
i'm actually going to add that separately, since it's a UI change and i want to keep this and #411 focused on the publisher changes
…etric Addresses review feedback: the disabled state only changes at startup, so a metric emitted per publish is noise. Rely on the one-time startup warning log instead of the PubSubPublisher.publisher.noop metric. Also add a DISABLE_GCP_PUBSUB env var that opts out of the GCP Pub/Sub codepath entirely, short-circuiting before the credential probe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
…CP creds Extract the cached, lock-guarded GCP credential check out of the sync PubSubPublisher into a shared osprey.worker.lib.gcp_credentials helper, and apply the same noop-when-credentials-absent behavior to the async worker's AsyncPubSubPublisher: skip building the client, log a one-time warning, and short-circuit the publish and stop paths with an async_pubsub_publisher.publish.noop metric for dashboard visibility. Both publishers now share one credential-detection implementation, so a future change (e.g. bounding the metadata probe with a timeout) lands in one place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
The shared credential check moved to gcp_credentials_available; update the validation-exporter conftest patch to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
Mirror the sync-publisher review changes on AsyncPubSubPublisher: drop the per-send async_pubsub_publisher.publish.noop metric in favor of the one-time startup warning, and honor the shared DISABLE_GCP_PUBSUB env opt-out that short-circuits before the credential probe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
…creds Per review: surface missing GCP credentials as a startup configuration error rather than a per-send metric. Both publishers emit configuration.errors once at construction when credentials can't be resolved, tagged with project, topic, and reason. The deliberate DISABLE_GCP_PUBSUB opt-out stays silent since it is not a misconfiguration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
|
@julietshen this gets close to what I was suggesting; here's something you could feed back into claude to adjust the approach, which will lift it up one layer: 1. Reuse the existing noop publisher instead of building disable logic into PubSubPublisherThere's already a Right now the PR teaches The cleaner shape is to make that decision at the point where the publisher is created, Trade-off worth naming: the current self-disabling approach automatically covers any The credential probe could actually throw and be caught higher up and then the publisher class returned is actually NullPublisher because gcp isn't configured correctly. We're essentially lifting the logic a layer up the stack, rather than focusing on the PubSubPublisher itself. 2. Read the flag through the existing config system, not os.environ directlyThe codebase has a config helper for reading settings like this: You could potentially even derive this flag from 3. Minor: flag naming
Also worth keeping in mind: the broader goal (#343) is running Osprey without any GCP, |
|
oh, also, there's only ever GCP PUBSUB, so you could drop the |
There was a problem hiding this comment.
This should be in osprey_worker/src/ospery/worker/lib/utils.
There was a problem hiding this comment.
during the refactor based on feedback from the working group, this is resolved! the shared credential-probe module is gone entirely now. Instead of probing up front, make_publisher just constructs the PubSubPublisher and catches the DefaultCredentialsError, so there's no gcp_credentials module left to relocate.
|
Thank you @chimosky and @ThisIsMissEm for the reviews and suggestions! I'm addressing them in my next commit. FYI, I'm going to focus on sync since |
…er factory
Address review: rather than PubSubPublisher disabling itself, build publishers
through a make_publisher() factory that returns the existing NullPublisher when
publishing is off (PUBSUB_ENABLED false, or GCP credentials absent). Keeps the
transport class a plain sender and reuses the noop publisher we already have.
- New make_publisher() factory; the ~5 construction sites (cli/sinks.py,
validation_result_exporter.py, ui_api/singletons.py) route through it.
- Missing credentials emit a one-time configuration.errors metric from the
factory; the deliberate PUBSUB_ENABLED opt-out stays silent.
- Read the flag via config.get_bool('PUBSUB_ENABLED', True) (positive form) instead
of a raw os.environ DISABLE_GCP_PUBSUB read.
- Move the shared credential check to lib/utils/gcp_credentials.py.
- Revert AsyncPubSubPublisher to a plain transport; it has no callers yet, so a
factory/noop there would be dead code until it is wired into a sink.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
bd0feb3 to
6b84b57
Compare
PubSubPublisher no longer self-disables (the decision moved to make_publisher), so forcing gcp_credentials_available in the exporter conftest is dead. The fixture builds PubSubPublisher directly, which now always uses the mocked PublisherClient. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
…in factory Per review: publishing is opt-in via OSPREY_PUBSUB_ENABLED (default off), so the default experience needs no GCP. The factory no longer probes credentials up front; PubSubPublisher construction resolves auth once and raises DefaultCredentialsError when creds are absent, which make_publisher catches and swaps for a NullPublisher (logging + a configuration.errors metric). The flag is an explicit boolean rather than inferred from a GCP project id, since adopters split work across several project ids. Drops the now-unneeded gcp_credentials probe module and its test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
| if not CONFIG.instance().get_bool('OSPREY_PUBSUB_ENABLED', False): | ||
| return NullPublisher() | ||
| try: | ||
| return PubSubPublisher(project_id, topic_id) |
There was a problem hiding this comment.
To retain what was previously here:
| return PubSubPublisher(project_id, topic_id) | |
| return PubSubPublisher( | |
| project_id, | |
| topic_id, | |
| raise_on_error=raise_on_error, | |
| max_bytes=max_bytes, | |
| max_messages=max_messages, | |
| max_latency=max_latency, | |
| ) |
Though these perhaps have default values?
There was a problem hiding this comment.
ty! They do have defaults, but no reason to drop the passthrough. Done in f0b8f6e
| try: | ||
| return PubSubPublisher(project_id, topic_id) | ||
| except DefaultCredentialsError: | ||
| logger.warning('GCP credentials not detected, publishing disabled (project=%s, topic=%s)', project_id, topic_id) |
There was a problem hiding this comment.
| logger.warning('GCP credentials not detected, publishing disabled (project=%s, topic=%s)', project_id, topic_id) | |
| logger.warning('GCP credentials errored, publishing disabled (project=%s, topic=%s)', project_id, topic_id) |
| # Imported here to avoid pulling the config/singletons stack into this low-level module. | ||
| from osprey.worker.lib.singletons import CONFIG | ||
|
|
||
| if not CONFIG.instance().get_bool('OSPREY_PUBSUB_ENABLED', False): |
There was a problem hiding this comment.
Current behavior would be defaulting to True, I think, if I understand/remember this method correctly:
| if not CONFIG.instance().get_bool('OSPREY_PUBSUB_ENABLED', False): | |
| if not CONFIG.instance().get_bool('OSPREY_PUBSUB_ENABLED', True): |
There was a problem hiding this comment.
agreed: since the construction-catch already returns a NullPublisher when credentials can't be resolved, a no-GCP setup still runs either way. defaulting to True avoids silently disabling publishing for deployments that already have creds. Done in f0b8f6e.
…arams Address review suggestions on make_publisher: - Default OSPREY_PUBSUB_ENABLED to true (opt-out). The catch-at-construction path already degrades to NullPublisher when credentials are missing, so a no-GCP setup still runs; defaulting on avoids silently disabling publishing for existing credentialed deployments. - Forward raise_on_error/max_bytes/max_messages/max_latency through to PubSubPublisher again. - Reword the fallback warning to "GCP credentials errored". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
…free The change makes Pub/Sub publishing degrade to a noop instead of crashing; it does not make the GCP-backed features (analytics, webhooks, rules-visualizer metadata) work without GCP. Reword to reflect that. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X
First PR for #343 — making Osprey runnable without GCP credentials. Tracked by #410, and now incorporates the async-worker changes originally split into #411.
What this changes
Both Pub/Sub publishers now degrade to a noop instead of failing when GCP credentials can't be resolved (the default
docker compose upexperience, or any adopter env without GCP), or when theDISABLE_GCP_PUBSUBenv var is set. In noop mode no underlying client is built andpublish()/stop()return immediately.osprey/worker/lib/gcp_credentials.pyexposesgcp_credentials_available()(cached + lock-guarded, sogoogle.auth.default()is probed at most once per process) andgcp_pubsub_disabled()(theDISABLE_GCP_PUBSUBenv opt-out). Both publishers share this one implementation.PubSubPublisher(osprey_worker/.../lib/publisher.py) — auto-detects credentials at construction, honors the env opt-out (short-circuiting before the credential probe), logs a one-time warning, and short-circuitspublish()/stop().AsyncPubSubPublisher(osprey_async_worker/.../lib/publisher.py) — the same behavior for the experimental asyncio worker.Observability
configuration.errorsmetric — emitted once at startup when credentials are missing (tagged with project, topic, andreason:gcp_credentials_missing). The deliberateDISABLE_GCP_PUBSUBopt-out stays silent, since it isn't a misconfiguration. There is no per-send metric: the state only changes at startup, and credentials revoked mid-run surface aspublish.failurespikes rather than a disabled state.Not in this PR
Broader GCP-eager modules (
secret_manager.py,bigquery.py,access_audit_log.py) remain tracked under #343.🤖 Generated with Claude Code
https://claude.ai/code/session_01TEcfpqhza3dPepZUVWw36X