Skip to content

feat: forward Pact logs through the logging module - #1730

Draft
JP-Ellis wants to merge 10 commits into
mainfrom
feat/logging-forwarding
Draft

JP-Ellis wants to merge 10 commits into
mainfrom
feat/logging-forwarding

Conversation

@JP-Ellis

@JP-Ellis JP-Ellis commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

✈️ Pre-flight checklist

  • I have read the Contributing Guidelines on pull requests.
  • If this is a code change: I have written unit tests and/or added dogfooding pages to fully verify the new behavior.
  • If this is a new API or substantial change: the PR has an accompanying issue (closes #0000) and the maintainers have approved on my working plan.

📝 Summary

Pact Python now logs like a native Python library. Events from the Pact FFI and from plugins are re-emitted through the standard logging module, so pact_ffi.log_to_stderr is no longer needed.

  • Library events go to pact.ffi.<module>. The Rust :: separators become ., so pact_matching::matchers logs on pact.ffi.pact_matching.matchers. Each record carries log_id and test_run_id.
  • Plugin events go to pact.plugins, with plugin_instance_id, test_run_id and plugin_target.
  • Logging is initialised on the first Pact or Verifier. The forwarding level is the most verbose level set on pact.ffi or pact.plugins, or INFO when neither is set.
  • log_id is the mock server ID while the server handles a request, or verify:<provider> during verification.

This is the first of three stacked PRs. #1731 adds a pytest plugin and a unittest mixin which set the test run ID per test. #1732 exposes the plugin log entries buffered by the FFI.

🚨 Breaking Changes

  • pact_ffi.log_to_stderr, log_to_stdout, log_to_file, init and init_with_log_level now emit a UserWarning and do nothing once pact is imported. Remove those calls and configure logging instead, as described in docs/logging.md.
  • With no logging configured, library warnings and errors now reach stderr through Python's last-resort handler.

🔥 Motivation

The FFI logged to its own sinks, outside the Python interpreter. Its output bypassed pytest's log capture and handlers configured in user code. Correlating it with a test meant reading a separate stream.

Pact FFI 0.5.9 added a log callback, and 0.5.10 added log_id for verification. Forwarding through the callback lets every standard logging tool work on Pact output.

pact-python-ffi is pinned to 0.5.12, which waits for an in-flight callback before deregistration returns. Without that, deregistering at interpreter exit could free a callback while a mock server thread was still running it. This PR stays in draft until 0.5.12 is released.

🔨 Test Plan

  • Unit tests for the level mapping, the callbacks, initialisation and the log_to_* stand-ins.
  • End-to-end tests for a mock server, a provider verification (log_id == "verify:<provider>"), and a fake plugin under PACT_PLUGIN_DIR. The plugin test is skipped on Windows, as the fake plugin is a shell script.

🔗 Related issues/PRs

Add pact.logging, which initialises the Pact FFI once with a silent sink
and registers the library and plugin log callbacks, re-emitting every
event on the pact.ffi.<target> and pact.plugins loggers with the test run
ID and mock server or plugin instance ID as record attributes. The
forwarding level follows any explicit level on those loggers, and
otherwise defaults to DEBUG.

Initialisation is implicit because the FFI logger can be applied once
per process and users should not have to know that. Pact and Verifier
will call ensure_initialised() on construction, so the first use of the
library wires everything up. A logger applied earlier through one of
the pact_ffi.log_to_* functions is kept, with a warning on the pact
logger, and the callbacks attach to it instead.

ensure_initialised() also registers an atexit hook which sets the
callback level to OFF and deregisters both callbacks. A callback fired
from a library thread during interpreter finalisation cannot acquire
the GIL and aborts the process, so the hook cuts the callbacks off
before finalisation starts.

The test suite no longer initialises FFI logging to stderr itself, as
initialisation is now the library's responsibility.

tests/interaction/test_http_interaction.py::test_pact_server_verbose
counted every record caplog captured, expecting only the `pact.pact`
mismatch record. The mock server's own events now reach caplog's root
handler through the `pact.ffi.*` loggers, so the assertion filters to
records from `pact.pact`.

Signed-off-by: JP-Ellis <josh@jpellis.me>
Assisted-by: Claude Code:claude-opus-5-5
Pact and Verifier call pact.logging.ensure_initialised on construction,
so a test suite gets library and plugin logs through logging with no
set-up of its own.

ensure_initialised is a no-op past the first call in the process, while
the FFI callbacks are process-wide. A test elsewhere in the suite that
registers and deregisters its own callback leaves pact.logging's
callbacks unregistered. TestCallbacks re-registers them after
ensure_initialised so it does not depend on suite order.

Signed-off-by: JP-Ellis <josh@jpellis.me>
Assisted-by: Claude Code:claude-opus-5-5
Rewrite the logging page around the automatic forwarding: the loggers
and record attributes, controlling verbosity through logger levels,
correlating records with a test run ID, and configuring the FFI's own
sinks as the escape hatch.

The examples no longer initialise FFI logging to stderr, as the library
now does its own initialisation.

Fix cross-reference syntax in src/pact/logging.py: `mkdocs build
--strict` reported them unresolved. mkdocstrings' scoped_crossrefs
resolves an unqualified reference relative to the referencing module,
but a `pact.`-prefixed target from a module below the package root gets
that prefix prepended again (e.g. `pact.Verifier` from `pact.logging`
resolves to `pact.pact.Verifier`), so cross-module references need the
path relative to the `pact` package instead of the full `pact.`-prefixed
path. Within `pact/logging.py` itself, the module's own `import logging`
shadows a relative `logging.x` self-reference, so those use the bare
member name instead.

Signed-off-by: JP-Ellis <josh@jpellis.me>
Assisted-by: Claude Code:claude-opus-5-5
Observed DEBUG output from the consumer suite (tests/test_pact.py),
provider verification (tests/test_verifier.py) and an HTTP example
(examples/http/requests_and_fastapi) with --log-cli-level=DEBUG. In
all three, DEBUG volume was dominated by "FFI function invoked"
tracing on every call and per-request matching dumps (headers, body,
matching rules) that add nothing once a test passes; running the fake
plugin driver additionally showed a burst of hyper/rustls/reqwest
transport chatter from the plugin driver's metrics beacon. INFO keeps
the messages that matter (catalogue updates, plugin lifecycle,
warnings and errors) without that noise, so DEFAULT_LEVEL is now
logging.INFO.

Signed-off-by: JP-Ellis <josh@jpellis.me>
Assisted-by: Claude Code:claude-opus-5
The unit tests in tests/test_logging.py drive the CFFI callback objects
directly, so none of them proves that the Pact library reaches the
Python callback from its own threads. Add a consumer test which starts
a mock server, makes one request through it and asserts that records
arrived on a pact.ffi.* logger with a mock server ID as log_id.

The assertions stop at "some records, at least one with a log_id"; the
count and wording of the library's events vary between Pact FFI
releases and would make the test brittle.

Signed-off-by: JP-Ellis <josh@jpellis.me>
Assisted-by: Claude Code:claude-opus-5
pact.logging needs `register_log_callback` and `set_log_callback_level`,
added in Pact FFI 0.5.9. Under the previous `~=0.5.0` specifier, an
older pact-python-ffi raised AttributeError on the first `Pact` or
`Verifier`.

0.5.12 makes deregistering a log callback safe while another thread is
still running it. pact.logging deregisters both callbacks at interpreter
exit, when a mock server or plugin thread may still be emitting.

Signed-off-by: JP-Ellis <josh@jpellis.me>
Assisted-by: Claude Code:claude-opus-5-5
Test suites used to call `pact_ffi.log_to_stderr`, usually from a
session-scoped conftest fixture, to see the Pact library's output. Pact
Python now applies the library's logger on the first `Pact` or
`Verifier`, and that logger can be applied once per process. An existing
call made after that point failed with "Logger already initialized".

Importing `pact` now replaces `init`, `init_with_log_level`,
`log_to_stderr`, `log_to_stdout` and `log_to_file` on `pact_ffi` with
functions which emit a `UserWarning` and return without applying
anything. A suite which still calls one of them keeps running, and the
warning asks for the call to be removed in favour of `logging`
configuration. A call made before `pact` is imported still applies the
library's own sink, which Pact Python keeps and warns about on the
`pact` logger, as before.

`log_to_buffer` is left in place, since pact.logging applies its silent
sink through it.

The consumer and provider guides no longer recommend `log_to_stderr`,
and the logging page explains the change and how to get the library's
output on stderr through `logging`.

Signed-off-by: JP-Ellis <josh@jpellis.me>
Assisted-by: Claude Code:claude-opus-5-5
Pact FFI reports `verify:<provider name>` as the log ID of events
emitted while verifying a provider, alongside the mock server ID while a
mock server handles a request. The verifier also generates its own test
run ID for the verification, which most of its records carry in place
of the ID set with `set_test_run_id`.

Describe both on the logging page, in the pact.logging module docstring
and in `set_test_run_id`. Records from the library's own threads are
grouped by `log_id`, not `test_run_id`.

Cover the verification path end to end, asserting that a record arrives
with `log_id == "verify:provider"`.

Signed-off-by: JP-Ellis <josh@jpellis.me>
Assisted-by: Claude Code:claude-opus-5-5
Events from the Pact library were emitted on `pact.ffi.<target>` with
the Rust module path as given, such as
`pact.ffi.pact_matching::matchers`. The `logging` hierarchy only splits
names on `.`, so that logger's parent was `pact.ffi` and a level set on
`pact.ffi.pact_matching` did not reach it.

Replace `::` with `.` in the target, so records from
`pact_matching::matchers` go to `pact.ffi.pact_matching.matchers`, and
one crate or module can be filtered on its own.

Signed-off-by: JP-Ellis <josh@jpellis.me>
Assisted-by: Claude Code:claude-opus-5-5
A fake plugin under PACT_PLUGIN_DIR writes a line to stderr and exits
without completing the handshake. The line arrives on `pact.plugins`
with the plugin instance ID, and the driver's own records about loading
the plugin carry the test run ID set on the calling thread.

The plugin's stderr line has no test run ID: it is not tied to any
request. The test run ID reaches a plugin only as part of a request, so
only the log entries a plugin sends back for that request carry it.
The logging page and `set_test_run_id` claimed every plugin record
carried the ID; they now say which ones do.

Signed-off-by: JP-Ellis <josh@jpellis.me>
Assisted-by: Claude Code:claude-opus-5-5
@JP-Ellis
JP-Ellis added this pull request to stack #1733 October 16, 2026 00:37
@JP-Ellis JP-Ellis changed the title feat/logging forwarding feat: forward Pact logs through the logging module Oct 16, 2026

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.

1 participant