Skip to content

Commit d8a31c0

Browse files
committed
feat: add debug logging
1 parent 2a43fdc commit d8a31c0

4 files changed

Lines changed: 199 additions & 5 deletions

File tree

docs/source/configuration.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ client = SyncReplaneClient(
2222
initialization_timeout_ms=5000,
2323
retry_delay_ms=200,
2424
inactivity_timeout_ms=30000,
25+
debug=False,
2526
)
2627
```
2728

@@ -166,6 +167,39 @@ client = SyncReplaneClient(
166167
)
167168
```
168169

170+
#### `debug`
171+
- **Type:** `bool`
172+
- **Default:** `False`
173+
174+
Enable debug logging to see detailed information about all client activity. This is useful for troubleshooting connection issues, understanding when configs are loaded, and diagnosing override evaluation.
175+
176+
```python
177+
client = SyncReplaneClient(
178+
...,
179+
debug=True, # Enable debug logging
180+
)
181+
```
182+
183+
When enabled, you'll see logs for:
184+
- Client initialization parameters
185+
- SSE connection attempts and status
186+
- Each config loaded from the server
187+
- Config retrieval with context and evaluated value
188+
- SSE events received
189+
- Reconnection attempts
190+
- Client close operations
191+
192+
Example output:
193+
```
194+
2024-01-15 10:30:00 [DEBUG] replane: Initializing SyncReplaneClient: base_url=https://replane.example.com, ...
195+
2024-01-15 10:30:00 [DEBUG] replane: connect() called, wait=True
196+
2024-01-15 10:30:00 [DEBUG] replane: Connecting to SSE: host=replane.example.com, port=443, https=True
197+
2024-01-15 10:30:00 [DEBUG] replane: Response status: 200 OK
198+
2024-01-15 10:30:00 [DEBUG] replane: SSE event received: type=init
199+
2024-01-15 10:30:00 [DEBUG] replane: Loaded config: rate-limit (value=100, overrides=2)
200+
2024-01-15 10:30:00 [DEBUG] replane: Initialization complete: 5 configs loaded
201+
```
202+
169203
## Manual Lifecycle Management
170204

171205
If you prefer not to use context managers, you can manage the client lifecycle manually:

replane/_async.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@
3434
logger = logging.getLogger("replane")
3535

3636

37+
def _setup_debug_logging() -> None:
38+
"""Configure the replane logger for debug output."""
39+
logger.setLevel(logging.DEBUG)
40+
# Only add handler if none exist to avoid duplicates
41+
if not logger.handlers:
42+
handler = logging.StreamHandler()
43+
handler.setLevel(logging.DEBUG)
44+
formatter = logging.Formatter(
45+
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
46+
datefmt="%Y-%m-%d %H:%M:%S",
47+
)
48+
handler.setFormatter(formatter)
49+
logger.addHandler(handler)
50+
51+
3752
class AsyncReplaneClient:
3853
"""Asynchronous Replane client with background SSE streaming.
3954
@@ -68,6 +83,7 @@ def __init__(
6883
initialization_timeout_ms: int = 5000,
6984
retry_delay_ms: int = 200,
7085
inactivity_timeout_ms: int = 30000,
86+
debug: bool = False,
7187
) -> None:
7288
"""Initialize the async Replane client.
7389
@@ -81,13 +97,35 @@ def __init__(
8197
initialization_timeout_ms: Timeout for initial connection.
8298
retry_delay_ms: Initial delay between retries.
8399
inactivity_timeout_ms: Max time without SSE events before reconnect.
100+
debug: Enable debug logging to see all client activity.
84101
85102
Raises:
86103
MissingDependencyError: If httpx is not installed.
87104
"""
88105
if httpx is None:
89106
raise MissingDependencyError("httpx", "async client")
90107

108+
# Configure debug logging
109+
self._debug = debug
110+
if debug:
111+
_setup_debug_logging()
112+
logger.debug(
113+
"Initializing AsyncReplaneClient: base_url=%s, "
114+
"request_timeout_ms=%d, initialization_timeout_ms=%d, "
115+
"retry_delay_ms=%d, inactivity_timeout_ms=%d",
116+
base_url,
117+
request_timeout_ms,
118+
initialization_timeout_ms,
119+
retry_delay_ms,
120+
inactivity_timeout_ms,
121+
)
122+
if context:
123+
logger.debug("Default context: %s", context)
124+
if fallbacks:
125+
logger.debug("Fallback configs: %s", list(fallbacks.keys()))
126+
if required:
127+
logger.debug("Required configs: %s", required)
128+
91129
self._base_url = base_url.rstrip("/")
92130
self._sdk_key = sdk_key
93131
self._context = context or {}
@@ -131,6 +169,8 @@ async def connect(self, *, wait: bool = True) -> None:
131169
if self._closed:
132170
raise ClientClosedError()
133171

172+
logger.debug("connect() called, wait=%s", wait)
173+
134174
self._http_client = httpx.AsyncClient(
135175
timeout=httpx.Timeout(self._request_timeout, read=None),
136176
)
@@ -139,6 +179,7 @@ async def connect(self, *, wait: bool = True) -> None:
139179
self._run_stream(),
140180
name="replane-sse",
141181
)
182+
logger.debug("SSE background task started")
142183

143184
if wait:
144185
await self.wait_for_init()
@@ -195,14 +236,25 @@ def get(
195236
raise ClientClosedError()
196237

197238
merged_context = {**self._context, **(context or {})}
239+
logger.debug("get(%r) with context: %s", name, merged_context or "(none)")
198240

199241
if name not in self._configs:
200242
if default is not None:
243+
logger.debug("Config %r not found, returning default: %r", name, default)
201244
return default
245+
logger.debug("Config %r not found, no default provided", name)
202246
raise ConfigNotFoundError(name)
203247

204248
config = self._configs[name]
205-
return evaluate_config(config, merged_context)
249+
result = evaluate_config(config, merged_context)
250+
logger.debug(
251+
"Config %r: base_value=%r, overrides=%d, result=%r",
252+
name,
253+
config.value,
254+
len(config.overrides),
255+
result,
256+
)
257+
return result
206258

207259
def subscribe(
208260
self,
@@ -255,17 +307,22 @@ def unsubscribe() -> None:
255307

256308
async def close(self) -> None:
257309
"""Close the client and stop the SSE connection."""
310+
logger.debug("close() called")
258311
self._closed = True
259312

260313
if self._stream_task:
314+
logger.debug("Cancelling SSE task...")
261315
self._stream_task.cancel()
262316
try:
263317
await self._stream_task
264318
except asyncio.CancelledError:
265319
pass
320+
logger.debug("SSE task cancelled")
266321

267322
if self._http_client:
323+
logger.debug("Closing HTTP client...")
268324
await self._http_client.aclose()
325+
logger.debug("HTTP client closed")
269326

270327
async def __aenter__(self) -> AsyncReplaneClient:
271328
await self.connect()
@@ -331,21 +388,27 @@ async def _connect_stream(self) -> None:
331388
"Cache-Control": "no-cache",
332389
}
333390

391+
logger.debug("Connecting to SSE: %s", url)
392+
334393
async with self._http_client.stream(
335394
"POST",
336395
url,
337396
json={},
338397
headers=headers,
339398
) as response:
399+
logger.debug("Response status: %d", response.status_code)
340400
if response.status_code == 401:
401+
logger.debug("Authentication failed (401)")
341402
raise AuthenticationError()
342403
elif response.status_code != 200:
343404
body = await response.aread()
405+
logger.debug("Error response body: %s", body[:500])
344406
raise from_http_status(
345407
response.status_code,
346408
body.decode("utf-8", errors="replace"),
347409
)
348410

411+
logger.debug("SSE connection established, processing stream...")
349412
await self._process_stream(response)
350413

351414
async def _process_stream(self, response: httpx.Response) -> None:
@@ -361,29 +424,44 @@ async def _process_stream(self, response: httpx.Response) -> None:
361424

362425
async def _handle_event(self, event: Any) -> None:
363426
"""Handle a parsed SSE event."""
427+
logger.debug("SSE event received: type=%s", event.event)
364428
if event.event == "init":
365429
await self._handle_init(event.data)
366430
elif event.event == "config_change":
367431
await self._handle_config_change(event.data)
432+
else:
433+
logger.debug("Unknown event type: %s, data=%s", event.event, event.data)
368434

369435
async def _handle_init(self, data: dict[str, Any]) -> None:
370436
"""Handle the init event with all configs."""
371437
configs_data = data.get("configs", [])
438+
logger.debug("Processing init event with %d configs", len(configs_data))
372439

373440
async with self._lock:
374441
for config_data in configs_data:
375442
config = parse_config(config_data)
376443
self._configs[config.name] = config
444+
logger.debug(
445+
"Loaded config: %s (value=%r, overrides=%d)",
446+
config.name,
447+
config.value,
448+
len(config.overrides),
449+
)
377450

378451
# Check required configs
379452
missing = self._required - set(self._configs.keys())
380453
if missing:
454+
logger.debug("Missing required configs: %s", sorted(missing))
381455
self._init_error = ConfigNotFoundError(
382456
f"Missing required configs: {', '.join(sorted(missing))}"
383457
)
384458

385459
self._initialized.set()
386-
logger.debug("Initialized with %d configs", len(configs_data))
460+
logger.debug(
461+
"Initialization complete: %d configs loaded, config names: %s",
462+
len(self._configs),
463+
list(self._configs.keys()),
464+
)
387465

388466
async def _handle_config_change(self, data: dict[str, Any]) -> None:
389467
"""Handle a config change event."""

0 commit comments

Comments
 (0)