A native structured logging library with a loguru-style API.
Combines a loguru-style API — brace formatting, bind, contextualize, opt, sink management — with a native Rust renderer for maximum performance. Since v1.0, the Rust extension is the default (and only) rendering path; structlog and orjson are no longer dependencies.
- Loguru-style API —
logger.info("User {id} logged in", id=123) - Structured JSON output in production (rendered natively in Rust for speed)
- Pretty colored console output in development
- Context management —
bind()for persistent context,contextualize()for request-scoped context - Sentry integration — redacted breadcrumbs/events with raw exceptions preserved for capture
- stdlib interop —
logger.add()sinks can also receive third-partyloggingrecords - RFC 5424 severity codes included in every log record
- Native Rust runtime — rendering and output run through the bundled abi3 extension
- Fully typed — PEP 561 compliant with strict mypy
Native processing:
- Redaction — mask sensitive fields (passwords, tokens) by key name or regex
- Sampling — probabilistic and rate-limited log suppression
- Metrics — extract counters/histograms from log events via callbacks
- Exception formatting — render
exc_infoas text or a structured frame dictionary - Off-thread logging — native Rust writer with a bounded queue and backpressure
- OpenTelemetry — automatic
trace_id/span_idinjection from current span
Framework integrations (optional dependencies):
- ASGI (FastAPI, Starlette) — request ID, timing, context binding middleware
- Celery — task context binding and cross-worker context propagation via headers
- Flask — before/after request hooks with request ID tracking
- Django — logging dict config builder and request middleware
- SQLAlchemy — slow query detection and logging
- gRPC — server interceptor with per-RPC context binding
- Sentry — forward log events as breadcrumbs/events with configurable severity
pip install structguruWith optional integrations:
pip install structguru[celery,flask,sentry] # pick what you need
pip install structguru[all] # everythingAvailable extras: otel, celery, flask, django, sqlalchemy, grpc,
sentry, httpx, requests, all.
from structguru import Logger, configure, logger
# Configure once at startup
configure(service="myapp", level="DEBUG", format="json")
# Use anywhere
logger.info("Hello {name}", name="world")
# → {"logger":"...","level":"INFO","severity":6,"timestamp":"...","service":"myapp","message":"Hello world"}
# Or choose an explicit module name
log = Logger(name=__name__)Call configure() once at startup. Each call replaces the previous configuration:
explicit keywords override environment values, which override built-in defaults.
Use update() to retain existing options while changing selected ones:
from structguru import Settings, configure, get_config, update
configure(service="checkout", sensitive_patterns=[r"token=\w+"])
update(otel=True, structured_exceptions=True) # retains service and redaction
current = get_config() # Settings, or None after shutdown()
# Applications can load a mapping themselves; no files are read automatically.
settings = Settings.from_mapping({"service": "checkout", "level": "DEBUG"})
configure(settings) # uses this object instead of environment valuesSettings.from_env() resolves environment values without applying them; pass a mapping
instead of using the process environment when testing. Settings validates Python
values and freezes collections. Native regex compilation and file access are checked
when configuring; failure leaves the previous runtime active. Explicit keywords,
including None and built-in default values, win over the selected base.
update() never rereads the environment and requires an active runtime. An empty update
does nothing. Level-only updates and set_level() preserve queues and rate-limit state;
other updates rebuild writers and reset filter state. Snapshots describe configured
options, not buffered records, counters, stdlib bridge state, or logger.add() sinks.
Those registered sinks survive reconfiguration. Streams and callbacks retain their identity.
Levels accept case-insensitive names (including NOTSET) or non-negative integer
thresholds. Unknown names, booleans and negative integers raise ValueError.
| Variable | Default | Compatibility fallback |
|---|---|---|
STRUCTGURU_SERVICE |
app |
— |
STRUCTGURU_LEVEL |
INFO |
LOG_LEVEL |
STRUCTGURU_TARGET |
stdout |
STRUCTGURU_NATIVE_TARGET |
STRUCTGURU_FORMAT |
json |
— |
STRUCTGURU_SAMPLE_RATE |
1.0 |
STRUCTGURU_NATIVE_SAMPLE_RATE |
STRUCTGURU_RATE_LIMIT |
disabled; period defaults to 60 seconds | STRUCTGURU_NATIVE_RATE_LIMIT |
STRUCTGURU_AUTOCONFIGURE |
enabled | inverse of STRUCTGURU_LEGACY |
New names win over their fallbacks; old names remain supported without warnings.
Rate limits use MAX or MAX/PERIOD, with integer counts and seconds for the period.
Autoconfiguration accepts 1/0, true/false, yes/no, or on/off and controls import
only. Set it to 0 before import to configure explicitly later. Invalid selected values
fail validation; an invalid import-time value must be corrected or autoconfiguration
disabled before the application can call configure().
File output, redaction, exceptions and other settings currently use Python configuration.
The stdlib bridge retains its separate STRUCTGURU_STDLIB_* options and explicit installer.
logger.debug("Debug message")
logger.info("Info message")
logger.warning("Warning message")
logger.error("Error message")
logger.critical("Critical message")
# Aliases
logger.trace("Maps to DEBUG")
logger.success("Maps to INFO")
logger.warn("Alias for warning")
logger.fatal("Alias for critical")Arguments used in str.format placeholders are consumed by formatting (matching loguru behaviour). Extra kwargs that are not in any placeholder are forwarded as structured fields:
logger.info("User {user_id} logged in", user_id=42, ip="10.0.0.1")
# message: "User 42 logged in"
# ip: "10.0.0.1" (extra kwarg kept as structured field)
# user_id is consumed by formatting and not duplicatedlog = logger.bind(request_id="abc-123", user="alice")
log.info("Processing request") # includes request_id and user
log.info("Request complete") # same context carried throughwith logger.contextualize(request_id="abc-123"):
logger.info("Handling request") # includes request_id
do_work() # any logging inside also gets request_id
# request_id removed automaticallytry:
risky_operation()
except Exception:
logger.exception("Operation failed") # logs with exc_info at ERROR level
# Or with opt():
logger.opt(exception=True).error("Something went wrong")# Add a file sink
handler_id = logger.add("/var/log/app.log", level="ERROR")
# Add a callable sink
logger.add(lambda msg: send_to_monitoring(msg), level="CRITICAL")
# Remove a specific sink
logger.remove(handler_id)
# Remove all added sinks
logger.remove()On Unix, new files created by either logger.add(path) or the native rotating
file sink are owner-only (0600). Existing files retain their permissions.
All sink forms receive structguru records. They are also registered with the stdlib root logger for third-party records, which arrive raw (unrendered) on that path. Install the stdlib bridge to receive them rendered and redacted instead — while it is installed the raw delivery is suspended, so a sink never sees the same record twice.
Logs emitted inside a sink callback, whether it received a native record or a
raw stdlib one, reach the native writer but skip callable and logger.add()
sinks, preventing recursive delivery and worker deadlocks. Outside callbacks,
logger.remove() waits for producers that already selected the removed sink
and for raw stdlib deliveries in progress. Lifecycle calls inside a callback,
including a stdlib handler's emit() on the raw path, cannot wait for the
worker; previously selected deliveries finish as that worker drains.
For native file/stdout mirroring, writer_metrics()["sink_errors"] includes
failed destinations even when another destination successfully writes the record.
Native delivery uses the bounded callable queue and is drained on
reconfiguration, shutdown(), fork, and interpreter exit. Call
structguru.flush() when you need to block until buffered records have actually
been written:
import structguru
logger.info("checkpoint")
structguru.flush() # returns once the line has reached its sinkformat= selects the renderer: "json" (default, production) or "console"
(colored, human-readable development output).
# JSON (production)
update(service="myapp", format="json")
# → {"logger":"...","level":"INFO","severity":6,"timestamp":"...","service":"myapp","message":"..."}
# Console (development) — colored, human-readable
update(service="myapp", format="console")
# → 2026-01-15T12:00:00.123456Z [INFO ] Hello worldMask sensitive fields automatically:
from structguru import update
update(
sensitive_keys=["password", "token", "ssn"],
sensitive_patterns=[r"\b\d{3}-\d{2}-\d{4}\b"],
pattern_replacement="***",
)Patterns run on Rust's linear-time regex engine (no ReDoS), which rejects
look-around and backreferences at update() time. Most look-behinds rewrite
as capture groups — (?<=password=)\S+ becomes (password=)\S+ with
pattern_replacement="$1[REDACTED]", so the prefix is re-emitted and the
secret is replaced (password=hunter2 → password=[REDACTED]). Put the
capture group around the part you want to keep, never around the secret.
For patterns that can't be rewritten,
allow_backtracking_patterns=True opts them into a bounded backtracking
engine: look-around and backreferences then work as written, at the cost of
the linear-time guarantee for those patterns. If a value ever exceeds the
backtrack limit, it is redacted entirely (fail-closed) rather than emitted
unchecked.
update(
sensitive_patterns=[r"(?<=password=)\S+"],
allow_backtracking_patterns=True,
)Suppress noisy logs:
from structguru import update
update(sample_rate=0.1, rate_limit_max=5, rate_limit_period=60)Derive metrics from log events:
from structguru import MetricProcessor, update
metrics = MetricProcessor()
metrics.counter("user.login", lambda ed: login_counter.inc())
metrics.histogram("db.query", "duration_ms", lambda v, ed: query_hist.observe(v))
update(metric_processor=metrics)Render exceptions as JSON-serializable dictionaries:
from structguru import update
update(structured_exceptions=True, exception_max_frames=20)exception_max_frames=0 omits traceback frames entirely. Negative frame and
local-representation limits are rejected during configuration.
Formatted tracebacks (the default) carry CPython's per-frame position markers,
the ~~~^^^ lines, and on Python 3.11+ computing them is most of the cost of
logger.exception(). exception_carets=False omits them, which formats a
traceback about five times faster and matches what CPython prints under
PYTHONNODEBUGRANGES=1:
update(exception_carets=False)Inject trace context into every log event:
from structguru import update
update(otel=True) # no-op injection when opentelemetry-api is absentSince v1.0, log I/O is offloaded to a background thread by default. The native
Rust writer uses a bounded 8192-record queue and waits for space while it is open. Set
overflow="drop" to favor caller latency, or explicitly pass maxsize=0 only
when an unbounded queue is acceptable.
structguru ships a required Rust extension that renders and enqueues logging
natively, off-thread. It is auto-enabled at import time. The runtime does not depend on orjson;
exotic values (datetime, UUID, Enum, dataclasses) are converted natively
in Rust.
A field value the renderer cannot represent never fails the record. It is replaced by a marker so the message, level, remaining fields, and exception traceback still ship:
<unsupported: WSGIRequest>for any other object (Decimal,bytes,set,Path, request objects, ...). The marker names the type only: structguru never callsstr()/repr()on an arbitrary object or reads its attributes, so nothing it holds can leak into a log line.<cycle: dict>for a container that refers back to itself.<max depth exceeded>beyond 64 levels of nesting.
Integers outside the 64-bit range are written as JSON numbers, non-string
mapping keys are rendered as strings ({200: 3} becomes {"200": 3}), and
unpaired surrogates in text become U+FFFD. Markers are redacted like any other
string. The policy applies to native logger fields and to extra= fields
bridged from the standard library alike.
import structguru
# Native mode is already on. Logger calls route through the Rust renderer.
structguru.logger.info("order {id} accepted", id=987)
# → JSON line written to stdout by a background writer threadNo configuration is required for the default JSON-to-stdout behavior. Call
configure(...) to customize the renderer, filtering, or sinks.
import structguru
structguru.configure(service="myapp", level="INFO", file_path="/var/log/app.log")
structguru.logger.bind(request_id="abc").info("order {id} accepted", id=987)
# → JSON line written to /var/log/app.log by a background writer threadImport-time configuration and configure() without a Settings object honor environment variables:
STRUCTGURU_LEVEL=INFO STRUCTGURU_SERVICE=myapp python -m myappInvalid native environment values fail import with an actionable exception. This prevents a deployment from starting while the native-only logging path is disabled.
Public API:
| Symbol | Purpose |
|---|---|
configure(...) |
Replace rendering, filtering, redaction, and output settings. |
Settings |
Validate reusable options; construct from Python values, a mapping, or the environment. |
get_config() |
Return configured options, or None when shut down. |
update(...) |
Change selected active options without rereading the environment. |
shutdown() |
Stop the writer; logging is disabled until configure() is called. |
set_level(level) |
Adjust the level threshold at runtime. |
writer_metrics() |
Current writer counters (enqueued/written/dropped/depth/...) plus filter counters when active; None after shutdown. |
lifecycle_metrics() |
Cumulative native deliveries rejected by closed writers; available after shutdown and across reconfiguration. |
is_available() |
Whether the compiled extension is importable. |
Behavior notes:
- Overflow: the default
maxsize=8192usesoverflow="block"to wait for queue space. Useoverflow="drop"for drop-newest behavior with metrics and rate-limited warnings.maxsize=0explicitly opts into an unbounded queue. - Redaction, level filtering, exceptions, and OpenTelemetry injection are supported natively; redaction covers the message and all structured string values before rendering or Sentry export.
sensitive_keysoverrides the default redaction keys. Rust's linear-time regex engine rejects backreferences and look-around withValueErrorat configuration time. - Sampling & rate limiting (
sample_rate,rate_limit_max,rate_limit_period) are applied as native pre-render filters — dropped records cost zero rendering.sampledandrate_limitedcounters are distinct from the transportdroppedcounter.sample_max_levelrestricts sampling to records at or below that level; more severe records always pass. - Metric hooks (
metric_processor=...) invoke a structlog-style processor (e.g.MetricProcessor) for every kept record on the caller's thread, with(None, method, {"event": message, **fields}). Dropped records (level/sampling/rate-limit) never reach it; hook errors are swallowed. - Fork/shutdown safe — the writer is flushed on exit and respawned in forked children (gunicorn/celery prefork). Rotating-file writers sharing a path coordinate through an owner-only
.locksidecar; distributed hosts should still prefer stdout and an external collector. - Structured exceptions (
structured_exceptions=True) rendertype,message,module, and frames as a dictionary, with optional redacted/truncated locals controlled by theexception_*options. Exception groups include nested members underexceptions, with the same frame limits and redaction. Traversal stops at ten nesting levels or 100 exception nodes;exceptions_truncatedcounts omitted direct children. Failed message conversions produce a marker instead of interrupting logging. stack_infois supported natively: the stack is captured in Python and rendered in the same position asStackInfoRenderer(stackbetweenserviceandmessage). Unlike the standard path, the stack ends at the user's calling frame (structguru-internal frames are skipped, the way structlog skips its own).- Console mode (
format="console"): renders colored, human-readable lines instead of JSON — structguru's own stable dev format (<timestamp> [<LEVEL>] <message> k=v), with ANSI colors by default on a TTY. Override withcolors=True/False. - File sinks (
file_path=...): write to a rotating file natively. Defaults mirrorRotatingFileHandler(50 MB, 5 backups); configure viafile_max_bytes/file_backup_count. Setalso_stdout=Trueto mirror output to both file and stdout (e.g. container + persistent log). - Callable sinks (
callable_sinks=[fn, ...]): use a bounded queue (callable_queue_maxsize=1024).overflow="block"provides lossless backpressure;overflow="drop"reportscallable_droppedmetrics. Flush and lifecycle operations drain queued calls. - Sentry integration (
sentry_processor=SentryProcessor(...)): receives the already-redacted event and rawexc_infoonly for exception capture. - Scope: the native renderer covers JSON and console rendering, file/stdout/callable sinks, redaction, sampling/rate limiting, metrics, exceptions, and stack information.
logger.add()sinks receive native and stdlib records.
Shutdown and reconfiguration drain records already accepted by the native queue.
Calls still formatting or waiting for space may be rejected when their writer closes,
even in block mode. lifecycle_metrics()["rejected"] counts these native deliveries
without queue-full warnings and survives shutdown and configuration changes; forked
children start at zero. Another destination, such as a synchronous stream, may already
have received the event. Calls begun while logging is disabled are no-ops and are not
counted. See the lifecycle contract for details.
from structguru.integrations.asgi import StructguruMiddleware
app = FastAPI()
app.add_middleware(StructguruMiddleware, request_id_header="X-Request-ID")Route Granian's server logs through StructguruHandler using --log-config,
and use StructguruMiddleware for ASGI request IDs and structured summaries.
The Granian integration guide includes a complete
logging dictionary, application and launcher examples, and access-log options.
from structguru.integrations.celery import setup_celery_logging
setup_celery_logging(propagate_context=True, context_keys=["request_id"])
# Binds task_id/task_name to context, propagates selected keys via headersfrom structguru.integrations.flask import setup_flask_logging
app = Flask(__name__)
setup_flask_logging(app, request_id_header="X-Request-ID")# settings.py
from structguru.integrations.django import build_logging_config, StructguruMiddleware
LOGGING = build_logging_config(service="myapp", level="INFO", json_logs=True)
MIDDLEWARE = ["structguru.integrations.django.StructguruMiddleware", ...]from structguru.integrations.sqlalchemy import setup_query_logging
setup_query_logging(engine, slow_threshold_ms=100, log_all=False)from structguru.integrations.grpc import StructguruInterceptor
server = grpc.server(
futures.ThreadPoolExecutor(),
interceptors=[StructguruInterceptor()],
)import logging
from structguru import update
from structguru.integrations.sentry import SentryProcessor
sentry = SentryProcessor(event_level=logging.ERROR, tag_keys=frozenset({"service"}))
update(sentry_processor=sentry)Third-party libraries log through the standard logging module. Installing the
bridge re-emits those records through structguru, so they share the same JSON /
console formatting, redaction, and output stream as your own logs:
from structguru.integrations.stdlib import install_stdlib_bridge
bridge = install_stdlib_bridge(
level="INFO",
suppress_loggers=("urllib3", "botocore"),
disable_existing_loggers=False,
)
import logging
logging.getLogger("sqlalchemy.engine").info("SELECT 1")
# → {"logger":"sqlalchemy.engine","level":"INFO",...,"message":"SELECT 1"}While the bridge is installed, logger.add() sinks receive third-party records
only through it — rendered once, never also raw. Pass the returned handler to
uninstall_stdlib_bridge() to restore the previous behavior.
Installing a second bridge while one is active raises RuntimeError. When
logging setup legitimately runs more than once per process (a Django
manage.py that imports a Celery app module, repeated setup in test suites),
pass replace=True to release the previous bridge first — last call wins:
bridge = install_stdlib_bridge(level="INFO", replace=True)The swap is atomic for callers: a record logged by another thread during it is
delivered at most once (rendered, raw, or dropped — never twice). Suppression
levels applied by the earlier install are not reverted, and calling
uninstall_stdlib_bridge() on the replaced handler is a no-op.
disable_existing_loggers=True disables named stdlib loggers that already
exist at installation time; False re-enables them, following dictConfig
semantics. When the option is omitted, install_stdlib_bridge() reads
STRUCTGURU_STDLIB_DISABLE_EXISTING_LOGGERS; if the variable is also unset,
existing states are preserved. Explicit Python values override the environment.
An empty environment value is treated as unset.
STRUCTGURU_STDLIB_DISABLE_EXISTING_LOGGERS=false python -m myappTo configure all bridge options from environment variables at a controlled point in application startup:
STRUCTGURU_STDLIB_LEVEL=INFO \
STRUCTGURU_STDLIB_DISABLE_EXISTING_LOGGERS=false \
python -m myappfrom structguru.integrations.stdlib import install_stdlib_bridge_from_env
bridge = install_stdlib_bridge_from_env()- Python 3.11+
- The compiled Rust extension (shipped as abi3 wheels for Linux/macOS/Windows)
- Integrations Guide — Detailed instructions for setting up frameworks.
- Full-stack Example — FastAPI + Celery + SQLAlchemy in action.
- Existing-loggers Example — Configure the stdlib policy and bridge replacement from code or environment.
uv sync --all-extras
uv run pytest
make bench
uv run ruff check .
uv run mypy src/MIT — Copyright (c) 2025 Aleksandr Pavlov