Skip to content

Commit 7d10bd4

Browse files
committed
fix: Additional logging adjustments
1 parent 3dcffd6 commit 7d10bd4

9 files changed

Lines changed: 160 additions & 26 deletions

File tree

README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,17 +163,21 @@ import logging
163163
logging.getLogger("configdirector").setLevel(logging.DEBUG)
164164
```
165165

166-
With no logging configured at all, Python still surfaces warnings and errors on `stderr`, so an
167-
invalid SDK key never passes silently.
166+
The SDK sets no level of its own, so this logger follows the usual `logging` rules. With nothing
167+
configured at all, Python still surfaces warnings and errors on `stderr`, so an invalid SDK key
168+
never passes silently.
168169

169-
Pass any object implementing `ConfigDirectorLogger` to override it — including a different
170-
stdlib logger, or the SDK's own console logger if you would rather not configure `logging`:
170+
If you would rather not configure `logging`, `log_level` sets a level on that logger for you:
171171

172172
```python
173-
from configdirector import create_console_logger
173+
client = ConfigDirectorClient("YOUR-SERVER-SDK-KEY", log_level="DEBUG")
174+
```
175+
176+
Pass any object implementing `ConfigDirectorLogger` to override the logger entirely — a
177+
different stdlib logger, or your own adapter:
174178

179+
```python
175180
client = ConfigDirectorClient("YOUR-SERVER-SDK-KEY", logger=logging.getLogger("my_app.flags"))
176-
client = ConfigDirectorClient("YOUR-SERVER-SDK-KEY", logger=create_console_logger("debug"))
177181
```
178182

179183
### Shutdown

samples/flask/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,11 @@ client = ConfigDirectorClient(..., logger=sdk_logger)
9696
flask_sample.configdirector DEBUG No config state found for 'integer-config', returning default value 10
9797
```
9898

99-
Any object with `debug`/`info`/`warning`/`error` methods works a stdlib `Logger` satisfies
100-
that, and so does `create_console_logger("debug")` if you would rather not configure the
101-
`logging` module at all. Omit `logger=` entirely and the SDK falls back to the standard library
102-
logger named `configdirector`. Set `CONFIGDIRECTOR_LOG_LEVEL=DEBUG` to watch every evaluation as
103-
it happens.
99+
Any object with `debug`/`info`/`warning`/`error` methods works, and a stdlib `Logger` satisfies
100+
that. Omit `logger=` entirely and the SDK falls back to the standard library logger named
101+
`configdirector`, leaving the level to your application — or pass `log_level=` if you would
102+
rather not configure the `logging` module at all. Set `CONFIGDIRECTOR_LOG_LEVEL=DEBUG` to watch
103+
every evaluation as it happens.
104104

105105
**Shutdown is clean.** `atexit` closes the client, dropping connections and flushing pending
106106
telemetry. A production deployment would also hook its server's worker-exit signal.

samples/flask/configdirector_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@
5050
# named "configdirector"; passing one in puts its output under this application's own logging
5151
# namespace instead, where existing handlers, filters, and level config already apply.
5252
#
53-
# Any object with debug/info/warning/error methods works a stdlib Logger satisfies that, and
54-
# so does `configdirector.create_console_logger("debug")` if you would rather not configure the
55-
# logging module at all.
53+
# Any object with debug/info/warning/error methods works, and a stdlib Logger satisfies that.
54+
# Passing `log_level=` instead of `logger=` is the shortcut when you would rather not configure
55+
# the logging module at all.
5656
sdk_logger = logging.getLogger("flask_sample.configdirector")
5757
sdk_logger.setLevel(os.environ.get("CONFIGDIRECTOR_LOG_LEVEL", "INFO"))
5858

src/configdirector/_eventsource/client.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,17 @@ def _run(self) -> None:
160160
self._set_state(ReadyState.CONNECTING)
161161
if self._stop.wait(self._reconnect_delay(state)):
162162
return
163-
except BaseException as error: # the worker must not die without saying why
164-
self._logger.error("[EventSource] The connection loop stopped unexpectedly: %r", error)
163+
except BaseException as error:
164+
# The loop is over either way, and a state left at OPEN would have callers believing
165+
# there is still a reader on the stream.
165166
self._ready_state = ReadyState.CLOSED
167+
# SystemExit and KeyboardInterrupt ask to unwind; they are not stream failures.
168+
# Logging one as "the connection stopped" would describe a problem the caller never
169+
# had, so they are left to travel as themselves.
170+
if not isinstance(error, Exception):
171+
raise
172+
# Anything else: the worker must not die without saying why.
173+
self._logger.error("[EventSource] The connection loop stopped unexpectedly: %r", error)
166174

167175
def _connect_once(self) -> _Failure | None:
168176
try:

src/configdirector/_logger.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import logging
22

3+
__all__ = ["LOGGER_NAME", "get_default_logger"]
34

4-
def get_default_logger(log_level: int | str | None = None) -> logging.Logger:
5-
"""Return the standard library logger the SDK uses when none is supplied.
5+
LOGGER_NAME = __package__ or "configdirector"
66

7-
Configure it like any other logger::
87

9-
logging.getLogger("configdirector").setLevel(logging.DEBUG)
10-
"""
11-
logger = logging.getLogger(__name__)
12-
logger.setLevel(logging.WARNING if log_level is None else log_level)
8+
def get_default_logger(log_level: int | str | None = None) -> logging.Logger:
9+
logger = logging.getLogger(LOGGER_NAME)
10+
if log_level is not None:
11+
logger.setLevel(log_level)
1312
return logger

src/configdirector/client.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,13 @@ class ConfigDirectorClient:
7878
recommended so that they can be referenced from targeting rules.
7979
connection: Connection options such as mode, timeout, and polling interval.
8080
logger: Any object implementing :class:`~configdirector.types.ConfigDirectorLogger`,
81-
including a standard library :class:`logging.Logger`. Defaults to the logger named
82-
``"configdirector"``.
81+
including a standard library :class:`logging.Logger`. Defaults to the standard
82+
library logger named ``"configdirector"``, which your application configures like
83+
any other logger.
84+
log_level: A level to set on that default logger, as either a :mod:`logging` constant
85+
or its name. A convenience for applications that do not otherwise configure
86+
logging; when omitted, the SDK sets no level and the usual ``logging`` rules apply.
87+
Ignored when ``logger`` is supplied.
8388
telemetry: Telemetry queue and flush tuning.
8489
hooks: Event handlers to attach before the client can emit any event.
8590
@@ -633,6 +638,7 @@ def create_client(
633638
metadata: Metadata | None = None,
634639
connection: ConnectionOptions | None = None,
635640
logger: ConfigDirectorLogger | None = None,
641+
log_level: int | str | None = None,
636642
telemetry: TelemetryOptions | None = None,
637643
hooks: ClientHooks | None = None,
638644
) -> ConfigDirectorClient:
@@ -646,6 +652,7 @@ def create_client(
646652
metadata=metadata,
647653
connection=connection,
648654
logger=logger,
655+
log_level=log_level,
649656
telemetry=telemetry,
650657
hooks=hooks,
651658
)

tests/eventsource/test_client.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
)
1818

1919
from .helpers import FailingTransport, FakeResponse, FakeTransport, sse, wait_for
20-
from helpers import create_stubbed_logger
20+
from helpers import RecordingLogger, create_stubbed_logger
2121

2222
URL = "http://localhost/sse"
2323
_R = TypeVar("_R")
@@ -382,6 +382,40 @@ def explode(message: EventSourceMessage) -> None:
382382
client.connect()
383383
assert wait_for(lambda: received == ["one", "two"])
384384

385+
# The escaping SystemExit is the point of the test: it reaches threading.excepthook as
386+
# itself instead of being logged as a connection error, and pytest reports that as a warning.
387+
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
388+
def test_system_exit_from_a_handler_is_not_reported_as_a_loop_failure(
389+
self, clients: list[EventSourceClient]
390+
) -> None:
391+
# SystemExit is a deliberate request to unwind, not a stream error. Logging it as one
392+
# would leave a caller that raised it looking at an unexplained connection failure.
393+
recording = RecordingLogger()
394+
delivered = threading.Event()
395+
396+
def exit_now(message: EventSourceMessage) -> None:
397+
delivered.set()
398+
raise SystemExit(1)
399+
400+
client = build(
401+
clients,
402+
logger=recording,
403+
transport=FakeTransport(FakeResponse(chunks=sse("data: one\n\n"))),
404+
on_message=exit_now,
405+
should_reconnect=lambda state: False,
406+
)
407+
408+
client.connect()
409+
assert delivered.wait(timeout=5)
410+
worker = client._thread
411+
assert worker is not None
412+
worker.join(timeout=5)
413+
assert worker.is_alive() is False
414+
415+
assert recording.messages("error") == []
416+
# The loop still leaves the state honest on its way out.
417+
assert client.ready_state is ReadyState.CLOSED
418+
385419
def test_a_raising_should_reconnect_falls_back_to_reconnecting(
386420
self, clients: list[EventSourceClient]
387421
) -> None:

tests/test_construction.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import inspect
4+
35
import pytest
46

57
from configdirector import (
@@ -32,6 +34,14 @@ def test_create_client_is_equivalent_to_the_constructor() -> None:
3234
assert client.is_ready is False
3335

3436

37+
def test_create_client_takes_every_constructor_option() -> None:
38+
# create_client documents itself as equivalent to the constructor, so an option added to one
39+
# and not the other is a defect rather than a design choice.
40+
assert list(inspect.signature(create_client).parameters) == list(
41+
inspect.signature(ConfigDirectorClient).parameters
42+
)
43+
44+
3545
def test_accepts_every_option() -> None:
3646
client = ConfigDirectorClient(
3747
SDK_KEY,

tests/test_logger.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from collections.abc import Iterator
5+
6+
import pytest
7+
8+
from configdirector import ConfigDirectorClient, create_client
9+
from configdirector._logger import get_default_logger
10+
11+
SDK_KEY = "test-server-sdk-key"
12+
13+
# Spelled out rather than imported, because the name being exactly this is what is under test.
14+
LOGGER_NAME = "configdirector"
15+
16+
17+
@pytest.fixture(autouse=True)
18+
def restore_the_package_logger() -> Iterator[None]:
19+
"""Loggers are process-wide, so a test that changes one has to hand it back."""
20+
logger = logging.getLogger(LOGGER_NAME)
21+
level = logger.level
22+
yield
23+
logger.setLevel(level)
24+
25+
26+
def test_the_default_logger_is_named_for_the_package() -> None:
27+
assert get_default_logger().name == "configdirector"
28+
29+
30+
def test_no_level_is_imposed_when_none_is_given() -> None:
31+
assert get_default_logger().level == logging.NOTSET
32+
33+
34+
def test_the_application_keeps_control_of_the_level() -> None:
35+
# The configuration the README and the client docstring both tell users to write.
36+
logging.getLogger("configdirector").setLevel(logging.DEBUG)
37+
38+
assert get_default_logger().isEnabledFor(logging.DEBUG) is True
39+
40+
41+
def test_warnings_reach_an_application_that_configured_nothing() -> None:
42+
# An unconfigured application inherits the root logger's WARNING, which logging.lastResort
43+
# then carries to stderr. That is what keeps an invalid SDK key from failing silently.
44+
assert get_default_logger().isEnabledFor(logging.WARNING) is True
45+
46+
47+
@pytest.mark.parametrize(
48+
("log_level", "expected"),
49+
[(logging.DEBUG, logging.DEBUG), ("INFO", logging.INFO)],
50+
)
51+
def test_applies_an_explicit_log_level(log_level: int | str, expected: int) -> None:
52+
assert get_default_logger(log_level).level == expected
53+
54+
55+
def test_the_client_logs_through_the_package_logger() -> None:
56+
client = ConfigDirectorClient(SDK_KEY)
57+
58+
assert client._logger is logging.getLogger("configdirector")
59+
60+
61+
def test_the_client_applies_an_explicit_log_level() -> None:
62+
client = ConfigDirectorClient(SDK_KEY, log_level="DEBUG")
63+
64+
assert isinstance(client._logger, logging.Logger)
65+
assert client._logger.isEnabledFor(logging.DEBUG) is True
66+
67+
68+
def test_create_client_applies_an_explicit_log_level() -> None:
69+
client = create_client(SDK_KEY, log_level="DEBUG")
70+
71+
assert isinstance(client._logger, logging.Logger)
72+
assert client._logger.isEnabledFor(logging.DEBUG) is True

0 commit comments

Comments
 (0)