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 |