Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-20
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
## Context

`ipsdk.logging` wraps Python's stdlib `logging` module to provide custom levels (TRACE, FATAL, NONE), PII redaction, and a `@trace` decorator. The entire logging hierarchy is pinned to the `ipsdk` root logger; `_get_loggers()` discovers loggers only under the `("ipsdk", "httpx")` prefix tuple and caches the result.

Downstream libraries that embed ipsdk currently have two bad options: (1) bypass `ipsdk.logging` entirely and use stdlib `logging` directly, losing PII filtering and level integration; or (2) manually manipulate stdlib logger internals. Neither is acceptable for a library that advertises itself as production-ready.

The change is entirely within `src/ipsdk/logging.py` and `tests/test_logging.py`. No new runtime dependencies. No public API removals.

## Goals / Non-Goals

**Goals:**
- `get_logger(name)` returns a named child logger under the `ipsdk` namespace, or the root `ipsdk` logger when `name` is `None` (existing behaviour preserved).
- `reset_logger(name)` strips handlers and restores `propagate=True` on any arbitrary stdlib logger by name, enabling callers to silence or re-route third-party noise without touching stdlib internals.
- A module-level `_extra_logger_prefixes: set[str]` registry lets callers register additional prefixes (e.g., `"httpcore"`) so that `_get_loggers()`, `set_level()`, and `initialize()` automatically manage those loggers alongside `ipsdk.*` and `httpx`.
- `set_level()` and `initialize()` accept an optional `loggers: list[str] | None` parameter that temporarily adds prefixes for a single call (without persisting them to the registry).
- 100 % test coverage on all new paths.

**Non-Goals:**
- Structured/JSON log formatting.
- Per-logger PII filtering rules (filtering is global).
- Hierarchical log level inheritance beyond what stdlib already provides.
- Any changes to `heuristics.py`, `connection.py`, or other modules.

## Decisions

### 1. `get_logger(name)` returns a child under `ipsdk.*`

**Decision**: `get_logger("mylib")` returns `logging.getLogger("ipsdk.mylib")`, not `logging.getLogger("mylib")`.

**Why**: Keeps the entire managed hierarchy under one root. A caller can do `ipsdk.logging.set_level(DEBUG)` and have it apply to all child loggers automatically via stdlib propagation, without needing to enumerate them.

**Alternative considered**: Allow arbitrary logger names (callers pass `"mylib.foo"` and get exactly that logger). Rejected because it breaks the single-root invariant and makes `_get_loggers()` non-deterministic.

### 2. `_extra_logger_prefixes` as a module-level `set`

**Decision**: A module-level `_extra_logger_prefixes: set[str]` stores additional prefixes. A public `register_logger_prefix(prefix: str)` function adds to it. `_get_loggers()` merges `{metadata.name, "httpx"}` with this set at call time.

**Why**: Simple, low-overhead, and thread-safe enough for startup-time registration (no hot-path writes). Avoids a full registry class for what is essentially a small config set.

**Alternative considered**: Accept `extra_prefixes` on `set_level()` / `initialize()` only (no persistent registry). Rejected because callers would have to pass the same list on every call.

### 3. `reset_logger(name)` operates on exact logger name, not prefix

**Decision**: `reset_logger("httpcore")` affects only the logger named `"httpcore"`, not `"httpcore.asyncio"` etc.

**Why**: Surgical control is safer. If a caller wants to reset a whole sub-tree, they call `reset_logger` for each. Prefix-matching would silently affect loggers the caller didn't intend.

**Alternative considered**: Accept a prefix and reset all matching loggers. Rejected — too broad, hard to reason about in library code.

### 4. `@cache` on `_get_loggers()` is retained but always cleared before use

**Decision**: Keep `@cache` on `_get_loggers()` for performance; all public functions that depend on it call `_get_loggers.cache_clear()` before calling `_get_loggers()`. This is already the pattern in `set_level()` and `initialize()`.

**Why**: Loggers are created lazily; without clearing the cache, newly registered child loggers would be missed. Clearing before use is cheap (O(1)) and safe.

## Risks / Trade-offs

- **`get_logger("ipsdk")` and `get_logger(None)` return the same object** — callers who pass the literal string `"ipsdk"` get a child named `"ipsdk.ipsdk"`. Document clearly that `name` should not include the `ipsdk.` prefix. This is a footgun but acceptable given the API is additive and opt-in.
→ Mitigation: docstring example + `ValueError` if `name` starts with `metadata.name + "."`.

- **`reset_logger` on an unknown name is a no-op** — stdlib `logging.getLogger("unknown")` creates a placeholder logger. Resetting it is harmless but may confuse callers who misspell a name.
→ Mitigation: function returns `bool` indicating whether the logger had any handlers or non-default state before the reset.

- **`_extra_logger_prefixes` is not thread-safe for concurrent writes** — registration is expected at import/startup time, not during live request handling.
→ Mitigation: document the startup-only contract; add a `threading.Lock` around writes to be safe.

## Migration Plan

All changes are backwards compatible:
1. `get_logger()` with no arguments: unchanged behaviour.
2. `set_level(lvl)` with no `loggers` argument: unchanged behaviour.
3. `initialize()` with no arguments: unchanged behaviour.

No migration steps required. Ship as a minor version bump (0.8.x → 0.9.0 since it adds public API surface).

## Open Questions

- Should `register_logger_prefix` be idempotent (silently ignore duplicates) or raise on duplicate? Lean toward idempotent.
- Should the `loggers` parameter on `set_level()` / `initialize()` persist to `_extra_logger_prefixes`, or be one-shot? Current decision: one-shot (non-persisting). Revisit if callers find it inconvenient.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## Why

The `ipsdk` logging module currently hardcodes its logger hierarchy under the `ipsdk` namespace and `_get_loggers()` only discovers `ipsdk.*` and `httpx` loggers — making it impossible for downstream libraries or application code to create named child loggers, or to suppress/reset noise from arbitrary third-party dependencies (e.g., `httpcore`, `urllib3`, `boto3`) without reaching into stdlib `logging` directly. Libraries that embed ipsdk need a first-class API to integrate their own named loggers into ipsdk's level/filter/handler management.

## What Changes

- Add `get_logger(name: str | None = None)` — when `name` is provided, returns a named child logger under the `ipsdk` namespace (e.g., `ipsdk.mylib`); when `None`, returns the root `ipsdk` logger (existing behaviour).
- Add `reset_logger(name: str)` — removes all handlers, resets level, and sets `propagate=True` on any arbitrary logger by name (e.g., `"httpcore"`, `"boto3"`), letting it fall through to Python's root handler or be silenced.
- Extend `set_level()` / `initialize()` to accept an optional `loggers: list[str] | None` parameter so callers can bring additional named loggers under ipsdk management without touching `propagate` internals directly.
- Update `_get_loggers()` to honour a configurable registry of extra logger names/prefixes, not just the hardcoded `("ipsdk", "httpx")` tuple.

## Capabilities

### New Capabilities

- `named-loggers`: Create and retrieve named child loggers under the `ipsdk` hierarchy via `get_logger(name)`.
- `dependency-logger-reset`: Reset (clear handlers, restore propagation) arbitrary third-party loggers by name via `reset_logger(name)`.
- `managed-logger-registry`: A configurable registry of extra logger prefixes that `_get_loggers()`, `set_level()`, and `initialize()` operate on, replacing the hardcoded `("ipsdk", "httpx")` tuple.

### Modified Capabilities

<!-- No existing spec-level capabilities are changing requirements. -->

## Impact

- `src/ipsdk/logging.py`: All changes are contained here.
- `get_logger()` signature changes (adds optional `name` parameter) — backwards compatible; existing callers with no argument continue to work.
- `set_level()` and `initialize()` gain optional `loggers` parameter — backwards compatible.
- `_get_loggers()` internal logic changes; its `@cache` is still cleared on each `set_level`/`initialize` call.
- Tests: `tests/test_logging.py` will need new coverage for all three new capabilities.
- No new runtime dependencies.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## ADDED Requirements

### Requirement: reset_logger clears handlers and restores propagation on any named logger
`ipsdk.logging.reset_logger(name)` SHALL remove all handlers from the named stdlib logger, reset its level to `NOTSET`, and set `propagate=True`, regardless of whether the logger is in the ipsdk namespace.

#### Scenario: Reset a third-party logger
- **WHEN** `reset_logger("httpcore")` is called
- **THEN** `logging.getLogger("httpcore")` has no handlers
- **AND** its level is `logging.NOTSET`
- **AND** its `propagate` attribute is `True`

#### Scenario: Reset an ipsdk child logger
- **WHEN** a child logger `"ipsdk.mylib"` has been configured with handlers and a level
- **AND** `reset_logger("ipsdk.mylib")` is called
- **THEN** `logging.getLogger("ipsdk.mylib")` has no handlers and level `NOTSET`

### Requirement: reset_logger returns True if the logger had non-default state
`reset_logger` SHALL return `True` if the logger had at least one handler OR a non-NOTSET level before the reset, and `False` otherwise.

#### Scenario: Logger had handlers
- **WHEN** `logging.getLogger("httpcore")` has one or more handlers attached
- **AND** `reset_logger("httpcore")` is called
- **THEN** the return value is `True`

#### Scenario: Logger had no handlers or level
- **WHEN** `logging.getLogger("unknown.lib")` has no handlers and level `NOTSET`
- **AND** `reset_logger("unknown.lib")` is called
- **THEN** the return value is `False`

### Requirement: reset_logger closes handlers before removing them
`reset_logger` SHALL call `handler.close()` on each handler before removing it, consistent with how `initialize()` manages handlers.

#### Scenario: Handler is closed
- **WHEN** a logger has a `StreamHandler` attached
- **AND** `reset_logger` is called
- **THEN** the handler's `close()` method is called exactly once before it is removed
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## ADDED Requirements

### Requirement: register_logger_prefix adds a prefix to the managed logger registry
`ipsdk.logging.register_logger_prefix(prefix)` SHALL add the given string to the module-level `_extra_logger_prefixes` set so that subsequent calls to `_get_loggers()`, `set_level()`, and `initialize()` include loggers whose names start with that prefix.

#### Scenario: Registered prefix is included in logger discovery
- **WHEN** `register_logger_prefix("httpcore")` is called
- **AND** `_get_loggers()` is called (with cache cleared)
- **THEN** any existing logger named `"httpcore"` or `"httpcore.*"` is included in the returned set

#### Scenario: Duplicate registration is idempotent
- **WHEN** `register_logger_prefix("httpcore")` is called twice
- **THEN** `_extra_logger_prefixes` contains `"httpcore"` exactly once

### Requirement: _get_loggers includes extra prefixes from the registry
`_get_loggers()` SHALL discover loggers matching `metadata.name`, `"httpx"`, AND every prefix in `_extra_logger_prefixes`.

#### Scenario: Default discovery without extra prefixes
- **WHEN** `_extra_logger_prefixes` is empty
- **THEN** `_get_loggers()` returns only loggers whose names start with `"ipsdk"` or `"httpx"`

#### Scenario: Discovery with extra prefix registered
- **WHEN** `register_logger_prefix("mylib")` has been called
- **AND** `logging.getLogger("mylib.core")` exists in the logging manager
- **THEN** `_get_loggers()` includes the `"mylib.core"` logger

### Requirement: set_level accepts an optional loggers parameter for one-shot management
`set_level(lvl, *, propagate=False, loggers=None)` SHALL accept an optional `loggers: list[str] | None` parameter. When provided, the named loggers SHALL have their level set to `lvl` in addition to the normal managed set. The extra names SHALL NOT be persisted to `_extra_logger_prefixes`.

#### Scenario: One-shot logger level set
- **WHEN** `set_level(DEBUG, loggers=["boto3"])` is called
- **THEN** `logging.getLogger("boto3").level` is `DEBUG`
- **AND** `"boto3"` is NOT in `_extra_logger_prefixes` after the call

### Requirement: initialize accepts an optional loggers parameter for one-shot initialization
`initialize(loggers=None)` SHALL accept an optional `loggers: list[str] | None` parameter. When provided, the named loggers SHALL be reset (handlers cleared, level set to NONE) in addition to the normally managed set. The extra names SHALL NOT be persisted to `_extra_logger_prefixes`.

#### Scenario: One-shot logger initialization
- **WHEN** `initialize(loggers=["urllib3"])` is called
- **THEN** `logging.getLogger("urllib3")` has a single `StreamHandler` pointing to `stderr`
- **AND** its level is `NONE`
- **AND** `"urllib3"` is NOT in `_extra_logger_prefixes` after the call

### Requirement: registry writes are thread-safe
`register_logger_prefix` SHALL use the existing `_logger_cache_lock` (or an equivalent lock) to protect writes to `_extra_logger_prefixes`.

#### Scenario: Concurrent registration
- **WHEN** multiple threads call `register_logger_prefix` concurrently with different prefixes
- **THEN** all prefixes are present in `_extra_logger_prefixes` after all threads complete
- **AND** no `RuntimeError` or data corruption occurs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
## ADDED Requirements

### Requirement: get_logger returns root logger when name is None
`ipsdk.logging.get_logger()` and `ipsdk.logging.get_logger(None)` SHALL both return the `logging.Logger` instance registered under the `ipsdk` root name, preserving existing behaviour.

#### Scenario: Call with no argument
- **WHEN** `get_logger()` is called with no arguments
- **THEN** the returned logger's name is `"ipsdk"`

#### Scenario: Call with None
- **WHEN** `get_logger(None)` is called
- **THEN** the returned logger's name is `"ipsdk"`

### Requirement: get_logger returns named child logger under ipsdk namespace
When a non-None `name` is passed, `get_logger(name)` SHALL return a `logging.Logger` whose full name is `"ipsdk.<name>"`.

#### Scenario: Named child logger
- **WHEN** `get_logger("mylib")` is called
- **THEN** the returned logger's name is `"ipsdk.mylib"`

#### Scenario: Same instance on repeated calls
- **WHEN** `get_logger("mylib")` is called twice
- **THEN** both calls return the identical `Logger` object (stdlib behaviour via `logging.getLogger`)

### Requirement: get_logger rejects names that already include the ipsdk prefix
If `name` starts with `"ipsdk."`, `get_logger` SHALL raise `ValueError` to prevent double-prefixing (e.g., `"ipsdk.ipsdk.mylib"`).

#### Scenario: Prefix already present
- **WHEN** `get_logger("ipsdk.mylib")` is called
- **THEN** `ValueError` is raised with a message indicating the prefix must not be included

### Requirement: Named child loggers propagate to the ipsdk root by default
Child loggers returned by `get_logger(name)` SHALL have `propagate=True` (stdlib default) so that level changes on the `ipsdk` root logger apply automatically.

#### Scenario: Level inheritance via propagation
- **WHEN** `set_level(DEBUG)` is called on the module
- **AND** `get_logger("mylib")` is called after
- **THEN** the child logger propagates records up to the `ipsdk` root handler
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## 1. Registry Infrastructure

- [x] 1.1 Add `_extra_logger_prefixes: set[str]` module-level set and `_registry_lock = threading.Lock()` to `logging.py`
- [x] 1.2 Implement `register_logger_prefix(prefix: str) -> None` with lock-protected write and idempotent set insertion
- [x] 1.3 Update `_get_loggers()` to merge `{metadata.name, "httpx"}` with `_extra_logger_prefixes` when discovering loggers

## 2. Named Loggers

- [x] 2.1 Update `get_logger(name: str | None = None)` signature and add `ValueError` guard when `name` starts with `metadata.name + "."`
- [x] 2.2 Return `logging.getLogger(f"{metadata.name}.{name}")` for non-None names; preserve existing root-logger path for `None`

## 3. Dependency Logger Reset

- [x] 3.1 Implement `reset_logger(name: str) -> bool` — capture pre-reset state (handlers + level), call `handler.close()` on each, remove handlers, set level to `NOTSET`, set `propagate=True`, return bool
- [x] 3.2 Export `reset_logger` in module public surface (add to module-level docstring listing)

## 4. One-shot loggers Parameter

- [x] 4.1 Add `loggers: list[str] | None = None` keyword-only parameter to `set_level()`; when provided, set level on each named logger in addition to the managed set (do not persist to registry)
- [x] 4.2 Add `loggers: list[str] | None = None` keyword-only parameter to `initialize()`; when provided, apply the same handler-clear + stderr-handler + NONE-level treatment to each named logger (do not persist to registry)

## 5. Tests

- [x] 5.1 Add tests for `get_logger(None)` and `get_logger()` return the `ipsdk` root logger (regression)
- [x] 5.2 Add tests for `get_logger("mylib")` returns logger named `"ipsdk.mylib"` and same instance on second call
- [x] 5.3 Add test for `get_logger("ipsdk.mylib")` raises `ValueError`
- [x] 5.4 Add tests for `reset_logger` on a logger with handlers: handlers removed, closed, level reset, propagate True, returns True
- [x] 5.5 Add test for `reset_logger` on a logger with no handlers and NOTSET level returns False
- [x] 5.6 Add tests for `register_logger_prefix` — idempotent, appears in `_extra_logger_prefixes`, discovered by `_get_loggers()`
- [x] 5.7 Add tests for `set_level(lvl, loggers=["somelib"])` sets level on the named logger without persisting to registry
- [x] 5.8 Add tests for `initialize(loggers=["somelib"])` clears and reinitializes the named logger without persisting to registry
- [x] 5.9 Verify 100% coverage (`make coverage`) passes with no new uncovered lines

## 6. Docs & Cleanup

- [x] 6.1 Update module docstring in `logging.py` to document new public functions (`get_logger` signature, `reset_logger`, `register_logger_prefix`)
- [x] 6.2 Run `make ci` and confirm all checks pass
Loading