Skip to content

Commit d8691b8

Browse files
jkebingerclaude
andauthored
Add dynamic log level control with LoggerFilter and LoggerProcessor (#15)
* Update protos (and script) * Add dynamic log level control with LoggerFilter and LoggerProcessor Implements configurable log level management through Reforge configuration, allowing real-time control of logging verbosity without application restarts. Key features: - LogLevel enum mapping to Python logging levels - get_log_level() method with context-based evaluation - logger_key option (default: "log-levels.default") - LoggerFilter for standard Python logging integration - LoggerProcessor for structlog integration (optional dependency) - Evaluates LOG_LEVEL_V2 config type with reforge-sdk-logging context - Returns DEBUG as default when config not found - No telemetry/record_log functionality Includes comprehensive tests and examples for both standard logging and structlog integrations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Bump version to 1.1.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix Python 3.9 compatibility: add __future__ annotations import Adds 'from __future__ import annotations' to test_logging.py to enable subscriptable types like logging.StreamHandler[Any] in Python 3.9. Fixes: TypeError: 'type' object is not subscriptable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add structlog to dev dependencies for testing Makes structlog available in the test environment so LoggerProcessor tests can run without being skipped. Structlog remains optional for end users via the extras mechanism. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update field exclusions in context shape test --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0004420 commit d8691b8

19 files changed

+1100
-141
lines changed

compile_protos.sh

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
#!/usr/bin/env bash
22

3+
4+
#!/usr/bin/env bash
5+
6+
set -e
7+
8+
PROTO_ROOT="${PROTO_ROOT:-..}"
9+
10+
311
# https://buf.build/docs/installation
412

5-
cp ../prefab-cloud/prefab.proto .
13+
cp $PROTO_ROOT/prefab-cloud/prefab.proto .
614
buf generate
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
## Standard Logging Example
2+
3+
This example demonstrates how to use Reforge's `LoggerFilter` to dynamically control log levels for standard Python logging.
4+
5+
### Features
6+
7+
- Dynamic log level control through Reforge configuration
8+
- No application restart required to change log levels
9+
- Works with standard Python `logging` module
10+
- Can target specific loggers by name using context-based rules
11+
12+
### Setup
13+
14+
1. Install dependencies:
15+
16+
```bash
17+
poetry install --no-root
18+
```
19+
20+
2. Set your Reforge SDK key (or use local-only mode as in the example):
21+
22+
```bash
23+
export REFORGE_SDK_KEY=your-sdk-key-here
24+
```
25+
26+
3. Create a log level config in your Reforge dashboard:
27+
- Config key: `log-levels.default`
28+
- Type: `LOG_LEVEL_V2`
29+
- Default value: `INFO` (or your preferred level)
30+
31+
### Running the Example
32+
33+
```bash
34+
poetry run python standard_logging_example.py
35+
```
36+
37+
The example will log messages at all levels (DEBUG, INFO, WARNING, ERROR) every second.
38+
39+
### Dynamic Control
40+
41+
While the example is running, you can:
42+
43+
1. Change the log level in your Reforge dashboard
44+
2. See the output change in real-time without restarting the application
45+
3. Use context-based rules to set different levels for different loggers
46+
47+
For example, you could create rules like:
48+
49+
- Set `DEBUG` level for `reforge.python.*` loggers
50+
- Set `ERROR` level for all other loggers
51+
- Set `INFO` level during business hours, `DEBUG` level at night
52+
53+
### How It Works
54+
55+
The `LoggerFilter` integrates with Python's standard logging by:
56+
57+
1. Implementing the `logging.Filter` interface
58+
2. Querying Reforge config for the log level on each log message
59+
3. Returning `True` (allow) or `False` (block) based on the configured level
60+
4. Using a context containing the logger name for targeted rules
61+
62+
### Advanced Usage
63+
64+
You can customize the logger name lookup by subclassing `LoggerFilter`:
65+
66+
```python
67+
class CustomLoggerFilter(LoggerFilter):
68+
def logger_name(self, record: logging.LogRecord) -> str:
69+
# Custom logic to derive logger name
70+
return record.name.replace("myapp", "mycompany")
71+
```
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[tool.poetry]
2+
name = "standard-logging-example"
3+
version = "0.1.0"
4+
description = "Example demonstrating Reforge SDK with standard Python logging"
5+
authors = ["Reforge"]
6+
package-mode = false
7+
8+
[tool.poetry.dependencies]
9+
python = "^3.9"
10+
sdk-reforge = {path = "../../", develop = true}
11+
12+
[build-system]
13+
requires = ["poetry-core"]
14+
build-backend = "poetry.core.masonry.api"
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""
2+
Example demonstrating dynamic log level control with standard Python logging.
3+
4+
This example shows how to use the LoggerFilter to dynamically control log levels
5+
based on Reforge configuration. The log level can be changed in real-time through
6+
the Reforge dashboard without restarting the application.
7+
"""
8+
9+
import logging
10+
import sys
11+
import time
12+
import os
13+
14+
from sdk_reforge import ReforgeSDK, Options, LoggerFilter
15+
16+
17+
def main():
18+
# Set up the root logger with a stdout handler
19+
root_logger = logging.getLogger()
20+
handler = logging.StreamHandler(sys.stdout)
21+
handler.setFormatter(
22+
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
23+
)
24+
root_logger.addHandler(handler)
25+
root_logger.setLevel(logging.DEBUG)
26+
27+
# Configure SDK to run in local-only mode for this example
28+
# In production, you would set your REFORGE_SDK_KEY instead
29+
os.environ["REFORGE_DATASOURCES"] = "LOCAL_ONLY"
30+
31+
def configure_logger():
32+
"""Add the Reforge LoggerFilter after SDK is ready"""
33+
handler.addFilter(LoggerFilter())
34+
print("✓ Logger filter configured - log levels now controlled by Reforge\n")
35+
36+
# Create SDK with on_ready_callback to add the filter when ready
37+
options = Options(
38+
x_datafile="test.datafile.json", # In production, omit this for remote config
39+
on_ready_callback=configure_logger,
40+
logger_key="log-levels.default", # Config key that controls log levels
41+
)
42+
43+
sdk = ReforgeSDK(options)
44+
45+
# Create a test logger
46+
logger = logging.getLogger("reforge.python.example")
47+
48+
print("Logging at all levels every second...")
49+
print("Try changing the 'log-levels.default' config in Reforge dashboard")
50+
print("to see log output change dynamically!\n")
51+
52+
# Log messages at different levels in a loop
53+
try:
54+
for i in range(60): # Run for 60 seconds
55+
logger.debug(f"[{i}] Debug message - only visible when level is DEBUG")
56+
logger.info(f"[{i}] Info message - visible when level is INFO or below")
57+
logger.warning(
58+
f"[{i}] Warning message - visible when level is WARN or below"
59+
)
60+
logger.error(f"[{i}] Error message - visible when level is ERROR or below")
61+
time.sleep(1)
62+
except KeyboardInterrupt:
63+
print("\nShutting down...")
64+
65+
sdk.close()
66+
67+
68+
if __name__ == "__main__":
69+
main()

examples/structlog/README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
## Structlog Example
2+
3+
This example demonstrates how to use Reforge's `LoggerProcessor` to dynamically control log levels for structlog.
4+
5+
### Features
6+
7+
- Dynamic log level control through Reforge configuration
8+
- No application restart required to change log levels
9+
- Works with structlog's processor pipeline
10+
- Can target specific loggers by name using context-based rules
11+
- Integrates seamlessly with structlog's structured logging features
12+
13+
### Setup
14+
15+
1. Install dependencies:
16+
17+
```bash
18+
poetry install --no-root
19+
```
20+
21+
2. Set your Reforge SDK key (or use local-only mode as in the example):
22+
23+
```bash
24+
export REFORGE_SDK_KEY=your-sdk-key-here
25+
```
26+
27+
3. Create a log level config in your Reforge dashboard:
28+
- Config key: `log-levels.default`
29+
- Type: `LOG_LEVEL_V2`
30+
- Default value: `INFO` (or your preferred level)
31+
32+
### Running the Example
33+
34+
```bash
35+
poetry run python structlog_example.py
36+
```
37+
38+
The example will log messages at all levels (DEBUG, INFO, WARNING, ERROR) every second.
39+
40+
### Dynamic Control
41+
42+
While the example is running, you can:
43+
44+
1. Change the log level in your Reforge dashboard
45+
2. See the output change in real-time without restarting the application
46+
3. Use context-based rules to set different levels for different modules
47+
48+
For example, you could create rules like:
49+
50+
- Set `DEBUG` level for `myapp.database` module
51+
- Set `ERROR` level for all other modules
52+
- Set `INFO` level during business hours, `DEBUG` level at night
53+
54+
### How It Works
55+
56+
The `LoggerProcessor` integrates with structlog by:
57+
58+
1. Implementing a processor in the structlog pipeline
59+
2. Querying Reforge config for the log level on each log event
60+
3. Raising `DropEvent` to filter messages below the configured level
61+
4. Using a context containing the logger name for targeted rules
62+
63+
**Important**: The `LoggerProcessor` must come **after** `structlog.stdlib.add_log_level` in the processor pipeline, as it depends on the level information added by that processor.
64+
65+
### Advanced Usage
66+
67+
You can customize the logger name lookup by subclassing `LoggerProcessor`:
68+
69+
```python
70+
class CustomLoggerNameProcessor(LoggerProcessor):
71+
def logger_name(self, logger, event_dict: dict) -> str:
72+
# Use module name as logger name
73+
return event_dict.get("module", "unknown")
74+
```
75+
76+
This allows you to create log level rules based on module names, file names, or any other field in the event dictionary.
77+
78+
### Structlog Installation
79+
80+
Structlog is an optional dependency. To install the SDK with structlog support:
81+
82+
```bash
83+
pip install sdk-reforge[structlog]
84+
```
85+
86+
Or in your `pyproject.toml`:
87+
88+
```toml
89+
[tool.poetry.dependencies]
90+
sdk-reforge = {version = "^1.0", extras = ["structlog"]}
91+
```

examples/structlog/pyproject.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[tool.poetry]
2+
name = "structlog-example"
3+
version = "0.1.0"
4+
description = "Example demonstrating Reforge SDK with structlog"
5+
authors = ["Reforge"]
6+
package-mode = false
7+
8+
[tool.poetry.dependencies]
9+
python = "^3.9"
10+
sdk-reforge = {path = "../../", develop = true, extras = ["structlog"]}
11+
12+
[build-system]
13+
requires = ["poetry-core"]
14+
build-backend = "poetry.core.masonry.api"
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""
2+
Example demonstrating dynamic log level control with structlog.
3+
4+
This example shows how to use the LoggerProcessor to dynamically control log levels
5+
based on Reforge configuration. The log level can be changed in real-time through
6+
the Reforge dashboard without restarting the application.
7+
"""
8+
9+
import time
10+
import os
11+
import structlog
12+
from sdk_reforge import ReforgeSDK, Options, LoggerProcessor
13+
14+
15+
class CustomLoggerNameProcessor(LoggerProcessor):
16+
"""
17+
Custom processor that extracts the logger name from the module field.
18+
19+
This demonstrates how to customize the logger name lookup for more
20+
sophisticated log level rules based on module names.
21+
"""
22+
23+
def logger_name(self, logger, event_dict: dict) -> str:
24+
# Use the module name as the logger name for Reforge evaluation
25+
return event_dict.get("module") or "unknown"
26+
27+
28+
def main():
29+
"""
30+
Configure structlog with Reforge dynamic log level control.
31+
"""
32+
# Configure structlog with our processor
33+
# Note: The LoggerProcessor must come after add_log_level
34+
structlog.configure(
35+
processors=[
36+
structlog.processors.TimeStamper(fmt="iso"),
37+
structlog.stdlib.add_log_level, # Must come before LoggerProcessor
38+
structlog.processors.CallsiteParameterAdder(
39+
{
40+
structlog.processors.CallsiteParameter.THREAD_NAME,
41+
structlog.processors.CallsiteParameter.FILENAME,
42+
structlog.processors.CallsiteParameter.FUNC_NAME,
43+
structlog.processors.CallsiteParameter.LINENO,
44+
structlog.processors.CallsiteParameter.PROCESS,
45+
structlog.processors.CallsiteParameter.MODULE,
46+
}
47+
),
48+
CustomLoggerNameProcessor().processor, # Add Reforge log level control
49+
structlog.dev.ConsoleRenderer(pad_event=25),
50+
]
51+
)
52+
53+
logger = structlog.getLogger()
54+
55+
# Configure SDK to run in local-only mode for this example
56+
# In production, you would set your REFORGE_SDK_KEY instead
57+
os.environ["REFORGE_DATASOURCES"] = "LOCAL_ONLY"
58+
59+
def on_ready():
60+
"""Called when SDK is ready"""
61+
logger.info("✓ SDK ready - log levels now controlled by Reforge")
62+
63+
# Create SDK
64+
options = Options(
65+
x_datafile="test.datafile.json", # In production, omit this for remote config
66+
on_ready_callback=on_ready,
67+
logger_key="log-levels.default", # Config key that controls log levels
68+
)
69+
70+
sdk = ReforgeSDK(options)
71+
72+
logger.info("Starting structlog example")
73+
logger.info(
74+
"Try changing the 'log-levels.default' config in Reforge dashboard "
75+
"to see log output change dynamically!"
76+
)
77+
78+
# Log messages at different levels in a loop
79+
try:
80+
for i in range(60): # Run for 60 seconds
81+
# Get a config value to show SDK is working
82+
config_value = sdk.get("example-config", default="default-value")
83+
84+
logger.debug(
85+
f"[{i}] Debug message",
86+
config_value=config_value,
87+
level_hint="Only visible when level is DEBUG",
88+
)
89+
logger.info(
90+
f"[{i}] Info message",
91+
config_value=config_value,
92+
level_hint="Visible when level is INFO or below",
93+
)
94+
logger.warning(
95+
f"[{i}] Warning message",
96+
config_value=config_value,
97+
level_hint="Visible when level is WARN or below",
98+
)
99+
logger.error(
100+
f"[{i}] Error message",
101+
config_value=config_value,
102+
level_hint="Visible when level is ERROR or below",
103+
)
104+
time.sleep(1)
105+
except KeyboardInterrupt:
106+
logger.info("Shutting down...")
107+
108+
sdk.close()
109+
110+
111+
if __name__ == "__main__":
112+
main()

prefab_pb2.py

Lines changed: 134 additions & 134 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)