Skip to content

fix(opal-server): cut per-update and policy-push INFO lines, configure master logging first (0.9.x) - #975

Open
CarlosMion wants to merge 15 commits into
release/0.9.xfrom
carlos/per-16361-opal-server-log-volume-0.9.x
Open

CarlosMion wants to merge 15 commits into
release/0.9.xfrom
carlos/per-16361-opal-server-log-volume-0.9.x

Conversation

@CarlosMion

@CarlosMion CarlosMion commented Oct 22, 2026 •

Copy link
Copy Markdown

Fixes Issue

PER-16361 (opal-server log volume). This ports #973 to release/0.9.x, the line the 0.9.x images are built from. Three changes.

Changes proposed

1. The per-data-update line is DEBUG

opal_server/data/data_update_publisher.py::DataUpdatePublisher.publish_data_updates:

  • "[pid] Publishing data update to topics: …, reason: …, entries: …" is now DEBUG. Its message and kwargs are unchanged.
  • Kept at WARNING: "[pid] No topics were provided for the entry with url: …" in the same function.

2. The gunicorn master configures logging before it logs anything

scripts/gunicorn_conf.py::when_ready now calls configure_logs() first, before it imports opal_server.scopes.task and runs preload_scopes(). preload_scopes' docstring now states that precondition.

On this line nothing configured logging in the master until now. Its output, including the SCOPES preload, went through loguru's default handler: DEBUG level, plaintext, diagnose=True and no credential scrubbing. After this change:

  • The preload's per-scope DEBUG lines no longer appear at the default LOG_LEVEL=INFO. These include "Sync scope:", "Initializing git fetcher", "Repo found at", "Fetching remote", "Fetch completed" and the backoff-skip lines.
  • Master lines use the configured format (JSON with LOG_SERIALIZE=true) and carry their real level.
  • Master lines are now scrubbed by the configured patcher and stream. That includes "Could not clone repo at" (still ERROR, same text) and redis_utils' "Connecting to Redis: {url}" DEBUG line, which on this line passes the URL unredacted. At INFO that line is not emitted at all.
  • The opal-client images use the same gunicorn_conf.py, so their master is configured too before when_ready returns at the ImportError.
  • If you use your own GUNICORN_CONF and call preload_scopes() from it, call configure_logs() first.

Side effects:

  • configure_logs() also takes over stdlib logging in the master (root InterceptHandler at level 0), so third-party records reach the configured sink at their own level, INFO included. Example: datadog.dogstatsd "Error submitting packet" WARNINGs, where no statsd agent is reachable. Workers already do this.
  • With LOG_TO_FILE=true, the master opens its own file sink.
  • A configure_logs() error now fails in the master, so gunicorn exits with code 1. Before, a worker failed to boot and gunicorn exited with code 3.

3. One OPAL INFO line per scopes policy push

Line file::symbol Level
"Triggering policy update for scope …" opal_server/scopes/service.py::NewCommitsCallbacks.trigger_notification INFO, kept: one per publish per pod leader
"scope '…': Found new commits …" opal_server/scopes/service.py::NewCommitsCallbacks.on_update INFO → DEBUG
"Publishing to topics: …" opal_common/topics/publisher.py::ScopedServerSideTopicPublisher.publish INFO → DEBUG
"Refresh scope: …" opal_server/scopes/api.py::init_scope_router.refresh_scope INFO → DEBUG
"Webhook listener triggered (N)" opal_server/policy/watcher/task.py::BasePolicyWatcherTask._on_webhook INFO → DEBUG
"Repo was fetched after refresh request, …" opal_server/git_fetcher.py::GitPolicyFetcher._should_fetch INFO → DEBUG
  • Lines that keep their level:
    • "Webhook trigger task failed" (ERROR), opal_server/policy/watcher/task.py::BasePolicyWatcherTask._on_webhook
    • "repo path is not found" and "Got exception for repo" (ERROR), opal_server/scopes/service.py::NewCommitsCallbacks.on_update
    • "no tracked files were affected" (WARN), opal_server/policy/watcher/callbacks.py::create_policy_update
    • "Could not clone repo at" (ERROR), opal_server/git_fetcher.py::GitPolicyFetcher._clone
    • "Target branch was not found" and "Hinted commit hash was not found" (INFO), opal_server/git_fetcher.py::GitPolicyFetcher._should_fetch
  • Other INFO lines that stay by design:
    • per pass: "Periodic sync" and "OPAL Scopes: syncing N scopes"
    • a scope's first sync: the clone lines and "Publishing policy update, directories"
    • a hinted refresh: "Hinted commit hash was not found", which normally fires because the hinted commit isn't in the clone yet
    • fetch anomalies, and recovery or deletion notices
    • third-party lines: the pubsub broadcaster and uvicorn's access log
  • Data updates: the scoped publisher also carries POST /scopes/{id}/data/update, so together with change 1, data updates have no OPAL INFO line at the default level. Use OPAL_LOG_LEVEL=DEBUG to see them.
  • Non-scopes mode: "Webhook listener triggered (N)" sits in a shared base class, so non-scopes mode loses it too. That mode still logs its own INFO per check from GitPolicySource.
  • The N count: N, the length of the webhook task list, is now visible only at DEBUG.

Docs

The two tutorials quoted the data-update line as default-level output. They now show the remaining lines and mention OPAL_LOG_LEVEL=DEBUG. The compose example's comments drop the line. The healthcheck sample also drops two "calling subscription callbacks" lines, which fastapi_websocket_pubsub logs at DEBUG.

Also

  • scopes/task.py::_periodic_polling drops an unused except … as e and a placeholder-less f-string (flake8 F841/F541). The message is unchanged.
  • The preload comment cites scripts/gunicorn_conf.py::when_ready.

Not addressed (flagged)

  • "Skipping sync for {url}: a previous git operation is still running after its timeout." (git_fetcher.py::GitPolicyFetcher.fetch_and_notify_on_changes) causes most per-restart WARNING lines on a large fleet, and the workers log it too. Its text is unchanged here.
  • scopes/task.py::trigger's WARN uses %s, so the data is never interpolated.
  • refresh_scope's ERROR says "Unauthorized to delete scope".

Differences from #973

Tests

  • opal_common/tests/log_capture.py: records at DEBUG and through a real INFO sink.
  • opal_server/tests/gunicorn_master_logging_test.py:
    • runs the real configure_logs() against a fresh loguru state whose only sink stands in for the default handler;
    • asserts nothing reaches that stand-in, with SCOPES on (INFO and DEBUG) and off, at import time, and in the client path;
    • asserts credentials are masked: git remote, plus a credentialed REDIS_URL logged as a kwarg the way RedisDB does;
    • asserts stdlib logging is routed into the configured sink;
    • asserts preload_scopes doesn't reconfigure logging;
    • each check has a control arm; most of them skip configure_logs.
  • opal_server/tests/scopes_push_log_levels_test.py:
    • exactly one OPAL INFO line per push, via on_update and end to end via ScopesService.sync_scope against a local clone;
    • the levels of the WARN/ERROR siblings, refresh_scope, _should_fetch's three lines and "Could not clone repo at".
  • Level tests in test_data_update_publisher.py (which also checks redaction of the DEBUG entries and the WARN URL), webhook_task_cleanup_test.py and opal_common/tests/scoped_publisher_log_level_test.py.
  • Verified on Python 3.9.6 with pydantic 1.10.26:
    • pytest packages/opal-server packages/opal-common: 568 passed;
    • 100% diff coverage;
    • every demotion and kept sibling is mutation-checked: reverting any of them fails a test.

Check List (Check all the applicable boxes)

  • I sign off on contributing this submission to open-source
  • My code follows the code style of this project.
  • My change requires changes to the documentation.
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • This PR does not contain plagiarized content.
  • The title of my pull request is a short description of the requested changes.

Note to reviewers

opal_client/tests/data_updater_test.py::test_data_updater_with_report_callback fails intermittently on macOS. It fails the same way on an untouched release/0.9.x, and this PR doesn't touch the client.

🤖 Generated with Claude Code

CarlosMion and others added 10 commits October 21, 2026 15:22
capture_records() collects every record at DEBUG and, through a real
INFO sink, what a sink at OPAL's default level receives, with the
global credential patcher disabled. Tests that pin a log line's level
use it to assert both the emitted level and that the line is absent
at the deployed level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
when_ready now calls configure_logs() before importing the scopes task
and before preload_scopes. Until now nothing configured logging in the
gunicorn master, so its logs - the preload's per-scope git lines,
"Finished pre loading scopes..." and anything logged on import - went
through loguru's default handler: DEBUG level, plaintext, no credential
scrubbing. The client images share this file, so their master is
configured too before when_ready returns. preload_scopes' docstring now
states that it expects logging to be configured.

The tests run the real configure_logs() against a loguru whose only
sink stands in for that default handler, with SCOPES on and off, and
each has a positive control where configure_logs is skipped.

Port of the master change in #973 to release/0.9.x.
Part of PER-16361.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"[pid] Publishing data update to topics: ..." was logged at INFO for
every data update, with its entries as a structured kwarg. It is now
DEBUG; its text and kwargs are unchanged. The "No topics were provided"
warning in the same function stays at WARNING.

The tutorials and the single-topic compose example quoted the line as
default-level output; they now show the remaining lines and point to
OPAL_LOG_LEVEL=DEBUG for the per-update line.

Port of the master change in #973 to release/0.9.x.
Part of PER-16361.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scopes policy push logged four to five INFO lines. Keep
NewCommitsCallbacks.trigger_notification's "Triggering policy update
for scope" at INFO and move the rest of the chain to DEBUG:
- "scope '...': Found new commits" (NewCommitsCallbacks.on_update)
- "Publishing to topics" (ScopedServerSideTopicPublisher.publish)
- "Refresh scope" (POST /scopes/{id}/refresh)
- "Webhook listener triggered (N)" (BasePolicyWatcherTask._on_webhook)
- "Repo was fetched after refresh request"
  (GitPolicyFetcher._should_fetch)

Message texts are unchanged, as are their WARN/ERROR siblings and
"Target branch was not found" (INFO). The tests pin each level, and
exactly one OPAL INFO line per incremental push both from on_update and
end to end from ScopesService.sync_scope against a local clone.

Port of the master change in #973 to release/0.9.x.
Part of PER-16361.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove the unused `as e` binding and the placeholder-less f-string
(flake8 F841, F541). The logged message is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Worker threads that earlier tests leave behind can log at any moment -
here a late concurrent.futures callback error reached the default
handler stand-in through a root InterceptHandler and failed
test_when_ready_scopes_on_no_record_reaches_default_handler in a full
run. when_ready and the stubbed preload run on the test's thread, and
a real gunicorn master is a fresh process, so both the stand-in and the
configured output now keep only that thread's records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The per-update line is now DEBUG and the docs point readers there,
  so assert its logged entries carry the redacted URL; removing the
  call-site redact_url used to pass every test.
- The master preload test's fake RedisDB now logs a credentialed
  REDIS_URL as a kwarg, as RedisDB does, so the masking of the master's
  own log setup is pinned for that line too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mment

Matches the docstring above it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
configure_logs() in the master also takes over stdlib logging (a root
InterceptHandler), so third-party records now reach the configured sink
at their level; say so in when_ready's comment and pin it, with a
control where configure_logs is skipped. The master-logging fixture now
starts from a fresh process's root logger (no leftover handlers, level
WARNING), so an InterceptHandler an earlier test left cannot route
stdlib on its own. Also give the Redis line its own unmasked control
arm and word the fake RedisDB comment so it holds where RedisDB already
redacts at the call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The control arm asserted the configured stream stayed empty, which holds
whenever configure_logs is skipped. It now asserts no stdlib record
reaches loguru at all, which fails if the fixture's fresh root logger
keeps a handler an earlier test left. The test also logs an INFO record,
the level whose routing the master gains. Smaller cleanups: name the
saved root logger state, drop a redundant addHandler guard, give the git
line its own unmasked control, keep the thread-filter notes on what they
filter, and tighten wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 22, 2026 14:20
@linear-code

linear-code Bot commented Oct 22, 2026

Copy link
Copy Markdown

PER-16361

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

CarlosMion and others added 5 commits October 22, 2026 12:16
master_logs replaced loguru's core and stripped the root handlers before
its try block, so a failing monkeypatch after the swap skipped the
teardown and left the rest of the session logging to a dead stand-in.
The config patches now run first and the swap sits inside the try. The
docstring also says which pytest handlers stay detached afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Hinted commit hash was not found" stays INFO by decision, and the PR
lists it as kept, but no test caught a demotion. The new test reaches it
through a real clone. The on_update parametrization gets plain ids in
place of the error-message node ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fastapi_websocket_pubsub logs "calling subscription callbacks" at DEBUG
across the >=1.0.0,<2 range release/0.9.x pins, so a server at the
default level never prints those two sample lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The root-logger teardown keeps pytest's handlers and puts the saved ones
back, mirroring the setup's split. A redundant hasattr assert goes: the
next line reads both attributes. The hinted-hash test takes the clone
from the fetcher instead of rebuilding its path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 22, 2026 17:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

This branch has not been deployed

No deployments
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.

2 participants