Log Capture
Capture structured log steps, stdlib logging calls, and print() output as LOG spans inside traces.
Neatlogs captures logs as LOG spans — children of the active span — so they appear inline in the trace timeline in the dashboard. There are three mechanisms, and you can use any combination.
Log capture is opt-in. Enable it by passing capture_logs=True to neatlogs.init():
neatlogs.init(
api_key="...",
capture_logs=True, # required to enable all log capture
)Without capture_logs=True, none of the three mechanisms below will capture LOG spans.
1. neatlogs.log() — Structured Template Messages
neatlogs.log() is the primary way to emit a named, structured step inside a span. It stores the message template as the span name (low-cardinality, searchable) and each keyword argument as a log.{key} attribute.
import neatlogs
@neatlogs.span(kind="CHAIN")
def rag_pipeline(query: str) -> str:
docs = retrieve(query)
neatlogs.log("retrieved {count} docs, top score {score:.2f}",
count=len(docs), score=docs[0]["score"])
reranked = rerank(query, docs)
neatlogs.log("reranked to {count} docs", count=len(reranked))
return generate(query, reranked)Signature
neatlogs.log(msg_template, /, level="info", **data)| Parameter | Description |
|---|---|
msg_template | Message template with {key} placeholders. Stored as the span name. |
level | Log level: "info", "debug", "warning", "error". Default: "info". |
**data | Key-value pairs rendered into the template and stored as log.{key} attributes. |
Templates use Python's str.format_map() syntax — single braces: {count}, not {{count}}.
Debug echo
When neatlogs.init(debug=True), every neatlogs.log() call is echoed to stderr immediately so you can see steps in the terminal without opening the dashboard:
[neatlogs] 12:34:56 LOG retrieved 42 docs, top score 0.94 count=42 score=0.94Important
neatlogs.log() must be called inside an active span (@neatlogs.span or with neatlogs.trace()). Calls outside a traced block are dropped — there is no active trace to attach them to.
2. stdlib logging.*() — Auto-Captured
When capture_logs=True, any logging.debug(), logging.info(), logging.warning(), logging.error() call inside a @span or with neatlogs.trace(): block is captured automatically — no code changes needed. All levels are captured by default.
import logging
import neatlogs
logger = logging.getLogger(__name__)
@neatlogs.span(kind="AGENT")
def classify_intent(message: str) -> str:
intent = run_classifier(message)
logger.debug("Classifier input length: %d", len(message)) # captured
logger.info("Intent classified: %s", intent) # captured
if intent == "inappropriate":
logger.warning("Inappropriate content — routing to rejection") # captured
return intentTo restrict capture to higher-severity levels only, raise the threshold at init time:
neatlogs.init(
...,
log_level="WARNING", # only WARNING, ERROR, CRITICAL
)3. capture_stdout=True — Print Capture
With capture_logs=True enabled, pass capture_stdout=True to @neatlogs.span or with neatlogs.trace() to capture every print() line inside that block as a LOG span. Existing code doesn't need to change.
@neatlogs.span(kind="AGENT", capture_stdout=True)
def run_agent(query: str) -> str:
print(f"Processing query: {query}") # captured as LOG span
result = agent.invoke(query)
print(f"Agent result: {result[:80]}") # captured as LOG span
return resultOr with the context manager:
with neatlogs.trace("my_step", capture_stdout=True):
print("step started")
do_work()
print("step done")Output is still mirrored to the real stdout — capture_stdout=True does not suppress terminal output.
What You'll See in the Dashboard
Each log call produces a LOG span as a child of the active span. The span includes:
| Field | Source |
|---|---|
| Span name | neatlogs.log(): the message template. stdlib/print: the rendered message. |
input.value | The fully rendered log message |
log.level | info, warning, error, etc. |
log.{key} | Each kwarg from neatlogs.log(), e.g. log.count, log.score |