Task-aware structured logging for distributed Python services.
Project description
Task Logging
Task-aware structured logging for distributed Python services.
The library plugs into Python's stdlib logging, lets you bind whatever per-request attrs you want (task_id, user_id, trace_id, …) so they propagate automatically through threads and asyncio tasks, and writes JSON to stdout. The container runtime captures stdout, Grafana Alloy scrapes it, ships it to Loki, and you query it through Grafana with LogQL.
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Service A │ │ Service B │ │ Service C │
│ stdlib logging│ │ stdlib logging│ │ stdlib logging│
│ + task_logging│ │ + task_logging│ │ + task_logging│
└───────┬────────┘ └───────┬────────┘ └───────┬────────┘
│ JSON to stdout │ JSON to stdout │ JSON to stdout
▼ ▼ ▼
┌────────────────────────────────────────────────────────────┐
│ Container runtime (Docker / Kubernetes) captures stdout │
└──────────────────────────────┬─────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────┐
│ Grafana Alloy (discovers + scrapes containers) │
└──────────────────────────────┬─────────────────────────────┘
▼
┌──────────────┐
│ Loki │
└──────┬───────┘
▼
┌──────────────┐
│ Grafana │
└──────────────┘
Why this design?
- One central place for logs. All services write JSON to stdout; the container runtime captures it; Alloy ships it to a single Loki, queryable from one Grafana instance.
- Trace a single request across services. Every log line carries whatever attrs you bound —
task_id,service,user_id, anything. Pick any of them in Grafana and follow the request end-to-end. - Third-party logs come along for free. Because the library plugs into the stdlib root logger, anything that uses
logging—requests,urllib3,boto3,sqlalchemy, your own modules — automatically gets the same JSON pipeline and the sametask_idtag. - Loki-friendly schema.
service/env/levelare low-cardinality (good Loki labels).task_idand friends live inside the log line so they don't blow up Loki's stream cardinality. - App stays simple — and 12-factor. No log files, no rotation knobs, no HTTP, no batching, no retries. Just
printto stdout (effectively). The platform handles capture, rotation, and shipping. See 12factor.net/logs.
Installation
pip install task-logging
Or with uv:
uv add task-logging
Requires Python 3.12+ (tested on 3.12 – 3.14). The library has zero runtime dependencies beyond the stdlib.
Quick Start
1. Wire up logging once at startup
The library deliberately does not provide a one-call setup function — the wiring is six lines of stdlib, and hiding it behind a wrapper would force decisions that belong to you (which handler? which stream? which logger? idempotent or not?). Compose the primitives yourself:
import logging
import sys
from task_logging import JsonFormatter, TaskLogFilter
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
handler.addFilter(TaskLogFilter(global_log_attrs={
"service": "OrderService", # used as a Loki label
"env": "prod",
}))
root = logging.getLogger()
root.addHandler(handler)
root.setLevel(logging.INFO)
# Tame noisy third-party libraries — also stdlib:
logging.getLogger("urllib3").setLevel(logging.WARNING)
⚠️ Attach
TaskLogFilterto a HANDLER, not to a logger. Logger-level filters don't see records propagated up from child loggers, sorequests/urllib3/boto3records would slip past unenriched. Handler-level filters see every record that reaches the handler, which is what you want.
After this, every stdlib logger in the process — yours and third-party — writes one line of JSON to stdout, ready for Alloy.
If you need teardown (tests, hot-reloads), it's two lines of stdlib too:
root.removeHandler(handler)
handler.close()
Want human-readable output during local development? Pipe through jq:
python -m myapp | jq
That keeps every structured field (task_id, exc_info.locals_dict, …) visible — a "pretty" formatter would have to drop them.
2. Use stdlib logging the normal way
log = logging.getLogger(__name__)
log.info("service started")
3. Tag work with a task_id
from task_logging import task_log_context
def handle_request(req):
with task_log_context({"task_id": req.id, "user_id": req.user_id}):
log.info("handling request")
do_step_1() # logs from here are tagged too
do_step_2()
# third-party libs you call inside the block are tagged as well:
requests.get("https://api.x.com/v1/foo")
task_log_context() uses Python contextvars, so it works correctly across threads, asyncio tasks, and concurrent.futures executors — each concurrent request gets its own isolated context.
The library doesn't privilege any particular attr name. Pick whatever keys your domain wants — task_id, request_id, trace_id, correlation_id — they all ride through.
If you can't use a with block (e.g. middleware that binds in a before_request hook and unbinds in after_request), the same instance also exposes enter() / exit():
def before_request(req):
req.state.log_ctx = task_log_context({"task_id": req.id})
req.state.log_ctx.enter()
def after_request(req):
req.state.log_ctx.exit()
4. View it in Grafana
Once Alloy → Loki → Grafana is running (see Deployment below), this LogQL query gives you everything for one request, across services:
{env="prod"} | json | task_id="abc-123"
What gets logged
Every record is a single line of JSON with this stable shape. The keys mirror stdlib LogRecord attribute names — anyone who knows stdlib logging already knows the schema:
{
"created": 1717839622.503112,
"asctime": "2024-06-08T14:20:22.503112Z",
"levelname": "INFO",
"name": "billing.settlement",
"message": "charging account",
"process": 4321,
"thread": 140234567890,
"threadName": "MainThread",
"module": "settlement",
"funcName": "charge",
"pathname": "/app/billing/settlement.py",
"lineno": 87,
"exc_info": null,
"service": "Billing",
"env": "prod",
"task_id": "task-42",
"user_id": "u-1"
}
The first block mirrors stdlib LogRecord; the second block is whatever you bound. The library does not auto-detect anything — service, env, task_id, user_id (and hostname, if you want it) are all supplied by you via TaskLogFilter(global_log_attrs=...) and task_log_context({...}).
created and asctime carry the same instant, intentionally. created (Unix float) is what Alloy parses into Loki's timestamp; asctime (ISO-8601 with Z suffix) is what humans read when staring at docker logs ctr without a parser. ~32 extra bytes per record buys "I can tell when this happened" without piping through jq or date -d.
exc_info is null for normal records and an object for exceptions:
"exc_info": {
"name": "ZeroDivisionError",
"details": "division by zero",
"stack_trace": "Traceback (most recent call last):\n File ...",
"locals_dict": {"a": "1", "b": "0"}
}
locals_dict is a repr()-snapshot of the local variables at the deepest stack frame where the exception was raised — invaluable for post-mortem debugging. Disable it with JsonFormatter(capture_locals=False) if you're worried about secrets leaking into logs.
Logging exceptions
Inside an except block, just call log.exception() (or pass exc_info=True to any other method). The formatter populates the exc field automatically:
def divide(a: int, b: int) -> float:
return a / b
try:
divide(1, 0)
except ZeroDivisionError:
log.exception("division failed")
Same goes for raising inside a decorated function — see below.
The @log_func_call decorator
For zero-boilerplate enter / exit / timing logs, wrap any function with log_func_call. It works on plain functions, instance methods, classmethods, staticmethods — anything — and imposes no requirements on the surrounding class.
import logging
from task_logging import log_func_call
log = logging.getLogger(__name__)
@log_func_call(log)
def add(x: int, y: int) -> int:
return x + y
add(2, 3)
# Logs (as JSON):
# ENTER add args=(2, 3) kwargs={}
# EXIT add return=5 cost_ms=0.012
It works on methods the same way — no self._logger attribute, no setup:
class Service:
@log_func_call(log)
def handle(self, payload: dict) -> None:
...
# Logs use the qualified name, so methods are easy to tell apart:
# ENTER Service.handle args=({...},) kwargs={}
Omit the logger to auto-resolve logging.getLogger(func.__module__) — the stdlib "one logger per module" idiom:
@log_func_call() # uses logging.getLogger(func.__module__)
def compute() -> int:
...
Override the level if you want:
@log_func_call(log, level=logging.DEBUG)
def chatty(): ...
If the wrapped function raises, log_func_call emits a RAISE record (with full exception info: stack trace + locals) and re-raises:
RAISE add after 0.142ms (exc=ValueError: nope)
Deployment: Loki + Alloy + Grafana
A docker-compose.yml with Loki, Grafana, Alloy, and your services is enough to start. Alloy uses Docker socket discovery to scrape every container's stdout — no per-service file mounts, no rotation config.
Tag your service containers
Alloy reads container labels to figure out the service / env to attach to logs. Add labels to each app service:
services:
order-service:
image: my-org/order-service:latest
labels:
- "logging=true" # opt this container in
- "logging.service=OrderService"
- "logging.env=prod"
billing:
image: my-org/billing:latest
labels:
- "logging=true"
- "logging.service=Billing"
- "logging.env=prod"
(Pick the label keys you like — Alloy lets you map any label to a Loki label. The keys above match the example Alloy config below.)
docker-compose.yml
services:
loki:
image: grafana/loki:3.2.0
ports: ["3100:3100"]
command: -config.file=/etc/loki/local-config.yaml
volumes:
- loki-data:/loki
alloy:
image: grafana/alloy:latest
command: run --server.http.listen-addr=0.0.0.0:12345 /etc/alloy/config.alloy
volumes:
- ./alloy/config.alloy:/etc/alloy/config.alloy:ro
# Mount the Docker socket so Alloy can discover and scrape sibling
# containers' stdout. Read-only is enough.
- /var/run/docker.sock:/var/run/docker.sock:ro
ports: ["12345:12345"]
depends_on: [loki]
grafana:
image: grafana/grafana:latest
ports: ["3000:3000"]
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
volumes:
- grafana-data:/var/lib/grafana
volumes:
loki-data:
grafana-data:
alloy/config.alloy
// Discover all running containers via the Docker socket.
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
// Keep only containers explicitly opted in via `logging=true`, and promote
// their labels into Prometheus-style targets that loki.source.docker can scrape.
discovery.relabel "containers" {
targets = discovery.docker.containers.targets
// Drop containers that didn't opt in.
rule {
source_labels = ["__meta_docker_container_label_logging"]
regex = "true"
action = "keep"
}
// Map container labels to Loki labels (low-cardinality only).
rule {
source_labels = ["__meta_docker_container_label_logging_service"]
target_label = "service"
}
rule {
source_labels = ["__meta_docker_container_label_logging_env"]
target_label = "env"
}
// The container name is also useful for distinguishing replicas.
rule {
source_labels = ["__meta_docker_container_name"]
target_label = "container"
}
}
// Read each opted-in container's stdout/stderr.
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.relabel.containers.output
forward_to = [loki.process.parse.receiver]
}
// Parse the JSON line. Field names match Python stdlib LogRecord
// attributes (levelname, created, ...). We rename `levelname` to a
// shorter `level` Loki label for query ergonomics — that's a labelling
// decision, not a schema change in the JSON.
loki.process "parse" {
forward_to = [loki.write.default.receiver]
stage.json {
expressions = {
level = "levelname", // pull stdlib's `levelname` out as `level`
created = "created", // Unix float timestamp from stdlib
task_id = "task_id", // our own field
}
}
stage.timestamp {
source = "created"
format = "Unix"
}
stage.labels {
values = { level = "" } // level is a low-cardinality Loki label
}
stage.structured_metadata {
values = { task_id = "" } // task_id is HIGH-cardinality; never make it a label
}
}
loki.write "default" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}
Why
task_idis instructured_metadata, not labels. Loki indexes by label combinations, so a high-cardinality label liketask_idwould create a new stream per request and crash Loki.structured_metadata(Loki ≥ 2.9) gives you fast filtering on high-cardinality fields without that cost.
Bring it up
docker compose up -d
- Loki: http://localhost:3100
- Alloy UI: http://localhost:12345
- Grafana: http://localhost:3000
Add Loki as a Grafana data source (http://loki:3100), then explore:
# all logs from one service
{service="OrderService", env="prod"}
# follow one request across services
{env="prod"} | json | task_id="abc-123"
# only errors, last hour (the Loki label `level` is set by Alloy from the
# JSON `levelname` field — see the Alloy config above)
{env="prod", level=~"ERROR|CRITICAL"}
# filter on a structured field bound via task_log_context({"user_id": ...})
{service="Billing"} | json | user_id="u-42"
# filter on a stdlib LogRecord field after `| json`
{service="Billing"} | json | funcName="charge"
# isolate one container replica
{service="OrderService", container="order-service-2"}
Kubernetes note
In a Kubernetes cluster, replace discovery.docker with discovery.kubernetes and deploy Alloy as a DaemonSet. The kubelet already captures every container's stdout into /var/log/containers/*.log; Alloy tails those files and uses the pod's labels / annotations (instead of Docker labels) to attach service / env. Same loki.process JSON pipeline applies. See the official Alloy install docs for the helm chart.
Public API
Five symbols, no setup wrapper. The library provides only the things you can't get from stdlib alone; the Logger / Handler wiring is yours.
from task_logging import (
TaskLogFilter, # logging.Filter — attach to a HANDLER (not a logger)
JsonFormatter, # logging.Formatter — emits one JSON line per record
task_log_context, # `with task_log_context({...}): ...`,
# or imperative ctx.enter() / ctx.exit()
get_task_log_attrs, # read the currently-active attrs (merged)
log_func_call, # decorator: ENTER / EXIT / RAISE for a function
)
| Symbol | Purpose |
|---|---|
TaskLogFilter(global_log_attrs=None) |
A logging.Filter that copies each record and merges global_log_attrs + the active task_log_context attrs onto it. Attach to a handler, not a logger. |
JsonFormatter(capture_locals=True) |
A logging.Formatter that emits one JSON line per record, with keys mirroring stdlib LogRecord attribute names. capture_locals=False disables the locals snapshot in exception logs. |
task_log_context(attrs) |
Bind a dict of attrs to the current execution context. Use with task_log_context({...}):, or imperative ctx.enter() / ctx.exit() for middleware that splits enter/exit across hooks. |
get_task_log_attrs() |
Return the currently-active merged attrs (empty dict if no context is active). |
log_func_call(logger=None, *, level=logging.INFO) |
Decorator that logs ENTER / EXIT / RAISE for a function. logger=None auto-resolves to logging.getLogger(func.__module__). |
End-to-end example
import logging
import sys
from task_logging import (
JsonFormatter,
TaskLogFilter,
log_func_call,
task_log_context,
)
# Wire up stdlib once at startup.
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
handler.addFilter(TaskLogFilter(global_log_attrs={"service": "Billing", "env": "prod"}))
root = logging.getLogger()
root.addHandler(handler)
root.setLevel(logging.INFO)
# Silence noisy third-party libraries via stdlib:
for chatty in ("urllib3", "botocore"):
logging.getLogger(chatty).setLevel(logging.WARNING)
log = logging.getLogger(__name__)
@log_func_call(log)
def charge(amount: float, currency: str) -> str:
return f"charged {amount} {currency}"
class Settlement:
@log_func_call(log, level=logging.DEBUG)
def settle(self, account: str) -> None:
log.info("settling %s", account)
try:
1 / 0
except ZeroDivisionError:
log.exception("settlement failed")
def handle_request(req):
with task_log_context({"task_id": req.id, "user_id": req.user_id}):
charge(9.99, "USD")
Settlement().settle("acct-1")
When this runs in a container, every line of stdout is JSON tagged with the request's task_id and user_id — including any logs from requests, urllib3, botocore, etc. that fired during the request. Alloy picks the lines up via the Docker socket, attaches the service=Billing / env=prod labels from the container's labels, and ships them to Loki.
Tips & gotchas
-
servicemust be low-cardinality. It becomes a Loki label. Use"OrderService", never"OrderService-pod-abc-7". -
task_idis per-request, never a label. It rides inside the JSON payload. Loki ≥ 2.9 +stage.structured_metadatalets you filter on it efficiently. -
Exception capture only works inside
exceptblocks. The formatter readssys.exc_info(), so calllog.exception(...)while the exception is still being handled. -
Tests / hot-reloads need explicit teardown.
addHandleris additive — running your wiring twice stacks two handlers and logs each line twice. Stash the handler and remove it on teardown:def setup_for_tests(): handler = logging.StreamHandler(sys.stdout) handler.setFormatter(JsonFormatter()) handler.addFilter(TaskLogFilter(...)) logging.getLogger().addHandler(handler) return handler def teardown_for_tests(handler): logging.getLogger().removeHandler(handler) handler.close()
-
Disable locals capture in regulated environments. Pass
capture_locals=FalsetoJsonFormatter()ifrepr()of arbitrary local variables could leak secrets. -
What about loguru? loguru is not based on stdlib
logging, so libraries likerequestsandurllib3won't be captured by it. This package deliberately uses stdlib so third-party logs flow through the same pipeline. If you want loguru in your own code, use loguru'sInterceptHandlerto bridge stdlib → loguru — but this library does not require it.
Design notes
If you want a deeper mental model than this README provides, see docs/:
docs/design/decorators.md— why one@log_func_callinstead ofFunctionLogger+ClassFunctionLogger, and why classes don't need a_loggerattributedocs/design/task-context.md— howtask_log_contextmakes log attrs flow through threads, asyncio tasks, and third-party libraries' logsdocs/design/stdlib-logging-primer.md— bottom-up tour of stdliblogging(LogRecord, the logger tree, handlers, filters, formatters) with the rules that prevent the most common pitfallsdocs/design/why-json-logs.md— Loki accepts arbitrary text; why does this library emit JSON anyway? What do we gain, and what do we trade away?docs/design/json-schema.md— where the JSON keys come from, why we mirror stdlib LogRecord attribute names instead of inventing our own, and the stability promise
Development
git clone https://github.com/im-zhong/task-logging.git
cd task-logging
uv sync
uv run pytest
uv run ruff check .
uv run mypy task_logging
uv run pre-commit run --all-files
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file task_logging-0.1.1.tar.gz.
File metadata
- Download URL: task_logging-0.1.1.tar.gz
- Upload date:
- Size: 106.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5944a8da0cd3fe0a4a56bef622fcddd956ea70031d0bd7090088539a71b87d86
|
|
| MD5 |
e076543077d2a0b65eff540bc6aa6ba9
|
|
| BLAKE2b-256 |
92c45aa802034e09bd42451ac0c6417886dfacc5f0103cebc9eaffd2157f94bf
|
File details
Details for the file task_logging-0.1.1-py3-none-any.whl.
File metadata
- Download URL: task_logging-0.1.1-py3-none-any.whl
- Upload date:
- Size: 26.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9546d3da02cfcc5ae4a51a52f4116e0a949b6097b76d166f9ed5e55a4b85ba69
|
|
| MD5 |
f0ab912449f25326c650eebc027653c3
|
|
| BLAKE2b-256 |
fe093ebabb6a3b2cbd33386b6328fa8783d6a140f54610763f63c702b2a86640
|