|
14 | 14 |
|
15 | 15 | import pytest |
16 | 16 | import structlog |
| 17 | +import structlog._frames |
| 18 | +import structlog.stdlib |
17 | 19 | from _pytest.capture import CaptureFixture |
18 | 20 | from _pytest.logging import LogCaptureFixture |
19 | 21 | from _pytest.monkeypatch import MonkeyPatch |
@@ -63,6 +65,23 @@ def _snapshot(name: str) -> logging.Logger: |
63 | 65 | logger.setLevel(level) |
64 | 66 |
|
65 | 67 |
|
| 68 | +@pytest.fixture() |
| 69 | +def restore_structlog_config() -> Generator[None, None, None]: |
| 70 | + """Snapshot the global structlog configuration and restore it after the test.""" |
| 71 | + was_configured = structlog.is_configured() |
| 72 | + config = structlog.get_config() |
| 73 | + yield |
| 74 | + if was_configured: |
| 75 | + structlog.configure(**config) |
| 76 | + else: |
| 77 | + structlog.reset_defaults() |
| 78 | + |
| 79 | + |
| 80 | +def _sentinel_processor(logger: object, method_name: str, event_dict: dict) -> dict: |
| 81 | + """A no-op processor used to detect whether an existing structlog config was left untouched.""" |
| 82 | + return event_dict |
| 83 | + |
| 84 | + |
66 | 85 | @pytest.fixture() |
67 | 86 | def set_context_var_key() -> Generator[str, None, None]: |
68 | 87 | structlog.contextvars.bind_contextvars(context_var="value") |
@@ -753,3 +772,112 @@ def test_structlog_native_logger_still_filters_below_level(self, capfd: CaptureF |
753 | 772 | structlog.get_logger("haystack.native_filtered_level").debug("debug below the configured level") |
754 | 773 |
|
755 | 774 | assert "debug below the configured level" not in capfd.readouterr().err |
| 775 | + |
| 776 | + |
| 777 | +class TestStructlogConfigIsPreserved: |
| 778 | + """ |
| 779 | + `structlog.configure` writes to a single process-global configuration. These tests pin down that merely importing |
| 780 | + Haystack (which calls `configure_logging(force=False)`) does not overwrite a structlog configuration that the host |
| 781 | + application already set up, while an explicit call still takes over. |
| 782 | + """ |
| 783 | + |
| 784 | + def test_not_forced_skips_when_structlog_already_configured(self, restore_structlog_config: None) -> None: |
| 785 | + # Stand-in for the host application configuring structlog before Haystack is imported/configured. |
| 786 | + structlog.reset_defaults() |
| 787 | + structlog.configure(processors=[_sentinel_processor]) |
| 788 | + haystack_logger = logging.getLogger("haystack") |
| 789 | + haystack_logger.handlers = [] |
| 790 | + |
| 791 | + haystack_logging.configure_logging(force=False) |
| 792 | + |
| 793 | + # The application's structlog configuration is left untouched ... |
| 794 | + assert structlog.get_config()["processors"] == [_sentinel_processor] |
| 795 | + # ... and we did not attach our handler on top of their setup. |
| 796 | + assert not any(getattr(h, "name", None) == "HaystackLoggingHandler" for h in haystack_logger.handlers) |
| 797 | + |
| 798 | + def test_forced_takes_over_existing_structlog_config(self, restore_structlog_config: None) -> None: |
| 799 | + structlog.reset_defaults() |
| 800 | + structlog.configure(processors=[_sentinel_processor]) |
| 801 | + haystack_logger = logging.getLogger("haystack") |
| 802 | + haystack_logger.handlers = [] |
| 803 | + |
| 804 | + haystack_logging.configure_logging(use_json=True, force=True) |
| 805 | + |
| 806 | + assert structlog.get_config()["processors"] != [_sentinel_processor] |
| 807 | + assert any(getattr(h, "name", None) == "HaystackLoggingHandler" for h in haystack_logger.handlers) |
| 808 | + |
| 809 | + def test_not_forced_still_configures_when_structlog_is_unconfigured(self, restore_structlog_config: None) -> None: |
| 810 | + # This is the real import-time situation: nobody configured structlog yet, so we set up our nice defaults. |
| 811 | + structlog.reset_defaults() |
| 812 | + haystack_logger = logging.getLogger("haystack") |
| 813 | + haystack_logger.handlers = [] |
| 814 | + assert not structlog.is_configured() |
| 815 | + |
| 816 | + haystack_logging.configure_logging(force=False) |
| 817 | + |
| 818 | + assert structlog.is_configured() |
| 819 | + assert any(getattr(h, "name", None) == "HaystackLoggingHandler" for h in haystack_logger.handlers) |
| 820 | + |
| 821 | + |
| 822 | +class TestGetLoggerIsIdempotent: |
| 823 | + """ |
| 824 | + `logging.getLogger(name)` returns a process-wide singleton. `haystack.logging.getLogger` patches that shared |
| 825 | + object in place, so calling it more than once for the same name (different modules, re-imports, ...) must not wrap |
| 826 | + the already-wrapped methods again. The user-visible symptom of re-wrapping is that the message is run through |
| 827 | + `str.format` once per wrap, so a field value that itself contains `{...}` gets re-interpolated. |
| 828 | + """ |
| 829 | + |
| 830 | + def test_repeated_get_logger_interpolates_the_message_exactly_once(self, capfd: CaptureFixture) -> None: |
| 831 | + haystack_logging.configure_logging(use_json=True) |
| 832 | + |
| 833 | + # Two modules grabbing the same logger name is the realistic trigger for re-wrapping. |
| 834 | + haystack_logging.getLogger("haystack.idempotency_test") |
| 835 | + logger = haystack_logging.getLogger("haystack.idempotency_test") |
| 836 | + logger.setLevel(logging.INFO) |
| 837 | + |
| 838 | + # `a`'s value contains a `{b}` placeholder. With a single interpolation it must be left as-is; a second |
| 839 | + # interpolation would expand it using `b` and leak "SECRET" into the message. |
| 840 | + logger.info("Hello {a}", a="{b}", b="SECRET") |
| 841 | + |
| 842 | + parsed_output = json.loads(capfd.readouterr().err) |
| 843 | + assert parsed_output["event"] == "Hello {b}" |
| 844 | + assert "SECRET" not in parsed_output["event"] |
| 845 | + |
| 846 | + def test_repeated_get_logger_does_not_rewrap_methods(self) -> None: |
| 847 | + haystack_logging.getLogger("haystack.idempotency_identity_test") |
| 848 | + # Capture the patched methods after the first call, before the second one runs. |
| 849 | + patched = logging.getLogger("haystack.idempotency_identity_test") |
| 850 | + debug_after_first = patched.debug |
| 851 | + make_record_after_first = patched.makeRecord |
| 852 | + |
| 853 | + haystack_logging.getLogger("haystack.idempotency_identity_test") |
| 854 | + |
| 855 | + # The second call must leave the already-patched methods in place, not wrap a fresh layer on top. |
| 856 | + assert patched.debug is debug_after_first |
| 857 | + assert patched.makeRecord is make_record_after_first |
| 858 | + |
| 859 | + |
| 860 | +class TestFindCallerMatchesStructlog: |
| 861 | + """ |
| 862 | + `_patch_structlog_call_information` mirrors structlog's `_FixedFindCallerLogger.findCaller`, only adding |
| 863 | + `haystack.logging` to the ignored frames. structlog itself does not guard the frame lookup, so neither do we: any |
| 864 | + error must propagate as-is instead of being swallowed and printed to stdout. |
| 865 | + """ |
| 866 | + |
| 867 | + def test_find_caller_does_not_print_or_mask_errors(self, capsys: CaptureFixture, monkeypatch: MonkeyPatch) -> None: |
| 868 | + # Force the frame lookup to fail. It is imported inside `_patch_structlog_call_information`, so we patch the |
| 869 | + # module attribute before patching the logger. |
| 870 | + def boom(*args: object, **kwargs: object) -> tuple: |
| 871 | + raise RuntimeError("frame lookup failed") |
| 872 | + |
| 873 | + monkeypatch.setattr(structlog._frames, "_find_first_app_frame_and_name", boom) |
| 874 | + |
| 875 | + logger = structlog.stdlib._FixedFindCallerLogger("haystack.find_caller_test") |
| 876 | + haystack_logging._patch_structlog_call_information(logger) |
| 877 | + |
| 878 | + # The original error must propagate (not be masked by a NameError on an unbound `f`) ... |
| 879 | + with pytest.raises(RuntimeError, match="frame lookup failed"): |
| 880 | + logger.findCaller() |
| 881 | + |
| 882 | + # ... and nothing must be written to stdout. |
| 883 | + assert capsys.readouterr().out == "" |
0 commit comments