Python SDK

Install, initialize, and instrument Python LLM and agent apps with Neatlogs.

The Python SDK installs from PyPI as neatlogs. Wrap the AI clients you already use, add spans to your own functions, and track prompt templates — every call is captured as a trace in your dashboard. This page covers everything from install to the full init() reference; use the contents on the right to jump around.

New to tracing? A trace is the record of one run of your app; a span is one step inside it (an LLM call, a tool call). You instrument once, then read traces in the dashboard. See Introduction.

Install

pip install -U neatlogs

To auto-instrument a specific library, install its extra (same -U rule applies):

pip install -U "neatlogs[openai]"      # OpenAI
pip install -U "neatlogs[anthropic]"   # Anthropic
pip install -U "neatlogs[langchain]"   # LangChain / LangGraph
pip install -U "neatlogs[crewai]"      # CrewAI

Requires Python ≥ 3.10, < 3.14. See Supported Libraries for every key.

Your first trace

Call neatlogs.init() once, then wrap your AI client with neatlogs.wrap(...). From then on every call that client makes is recorded — you use the client exactly as before.

import os
import neatlogs
from openai import OpenAI

neatlogs.init(api_key=os.environ["NEATLOGS_API_KEY"], workflow_name="my-first-app")

client = neatlogs.wrap(OpenAI())

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "What is the capital of France?"}],
)
print(response.choices[0].message.content)

neatlogs.flush()
neatlogs.shutdown()

Run the script — a trace appears in your dashboard under my-first-app within a few seconds. wrap() opens a WORKFLOW root automatically (named after your workflow_name) with the LLM span nested inside it, showing the prompt, response, token counts, latency, and model name. No extra wrapper needed.

neatlogs.wrap() auto-detects the client type and works for OpenAI, Anthropic, Google GenAI, Azure OpenAI, Bedrock, Vertex AI, OpenRouter, and agent frameworks like CrewAI, Pydantic AI, DSPy, Agno, Google ADK. You can wrap the client wherever you build it — order doesn't matter. See Integrations for every supported client, with runnable examples.

flush() and shutdown()

The SDK batches spans and exports them in the background every few seconds. A short-lived script can exit before that fires, losing spans — so neatlogs.flush() forces an immediate export and neatlogs.shutdown() stops the background thread cleanly.

neatlogs.flush()
neatlogs.shutdown()

Always call both at the end of scripts and CLI tools. In long-running servers (FastAPI, Flask, …) the background thread exports continuously, so don't flush per request — call flush() + shutdown() once at server shutdown (a FastAPI lifespan handler or an atexit hook).

Auto-instrumentation

Instead of wrap(), you can patch a library by name with instrumentations=[...] in init(). This is the right choice for frameworks like LangChain or CrewAI that create LLM clients internally — you never touch the client to wrap it.

neatlogs.init(
    api_key=os.environ["NEATLOGS_API_KEY"],
    workflow_name="my-app",
    instrumentations=["langchain"],
)

# Import the library AFTER init() — see the rule below.
from langchain_openai import ChatOpenAI

The one rule for instrumentations=[...]: call neatlogs.init() before you import that library — auto-instrumentation patches it at init time, so a library imported first is missed. (neatlogs.wrap() has no such rule; it patches the specific client object you hand it.) If you use load_dotenv(), call it before init(). Google GenAI is stricter still: construct genai.Client() after init().

init() registers a global tracer, so you call it once per process; any module imported afterward is covered.

Instrument your own code with @span

wrap() and auto-instrumentation only see library calls. To make your own functions (custom agents, pipelines, tools) appear as steps, put @neatlogs.span(...) on the line directly above the function.

import neatlogs

@neatlogs.span(kind="WORKFLOW")
def handle_request(user_input: str) -> str:
    return support_agent(user_input)

@neatlogs.span(kind="AGENT", name="support_agent")
def support_agent(message: str) -> str:
    ...

@neatlogs.span(kind="TOOL", tool_name="get_order_status")
def get_order_status(order_id: str) -> dict:
    return orders_db.get(order_id)

Works on sync and async functions. The decorator captures the arguments as input.value and the return value as output.value.

Grouping calls into one trace. A single wrap()-ed call renders on its own — wrap() opens a WORKFLOW root automatically. But each call with no surrounding context becomes its own trace, so when a run makes several calls (or mixes provider calls with your own functions), decorate the entry point with @neatlogs.span(kind="WORKFLOW") (or AGENT/CHAIN). That function becomes the single root and everything nests under it; the automatic root steps aside.

The WORKFLOW root

A WORKFLOW span is the entry point of a trace — the outermost function you call to process one request or task. Decorating it makes that function the trace's single, meaningfully-named root, and everything it calls (your own @span functions, wrapped provider calls, auto-instrumented libraries) nests underneath in the order it ran:

@neatlogs.span(kind="WORKFLOW")
def handle_customer_request(message: str) -> str:
    intent = classify_intent(message)
    if intent == "order_status":
        return check_order_agent(message)
    return general_support_agent(message)

If you don't add one, Neatlogs opens a WORKFLOW root automatically so a lone instrumented call still renders cleanly. Decorate your own entry point when you want the root to carry a specific name and group several steps under it.

A complete multi-span example

import os
import json
import neatlogs
from openai import OpenAI

neatlogs.init(
    api_key=os.environ["NEATLOGS_API_KEY"],
    workflow_name="support-bot",
)

client = neatlogs.wrap(OpenAI())

@neatlogs.span(kind="TOOL", tool_name="get_order_status")
def get_order_status(order_id: str) -> dict:
    return {"order_id": order_id, "status": "shipped", "eta": "2025-01-20"}

@neatlogs.span(kind="AGENT", name="support_agent")
def support_agent(message: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "You are a support agent."},
            {"role": "user", "content": message},
        ],
        tools=[{
            "type": "function",
            "function": {
                "name": "get_order_status",
                "description": "Get the status of an order",
                "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}}},
            }
        }],
    )
    msg = response.choices[0].message
    if msg.tool_calls:
        args = json.loads(msg.tool_calls[0].function.arguments)
        return str(get_order_status(**args))
    return msg.content

@neatlogs.span(kind="WORKFLOW")
def handle_request(user_input: str) -> str:
    return support_agent(user_input)

handle_request("Where is my order #12345?")

neatlogs.flush()
neatlogs.shutdown()

The nesting reflects the actual call hierarchy at runtime — each decorated function appears exactly where it ran, with its inputs, outputs, and timing:

WORKFLOW  handle_request           0.8s
  AGENT   support_agent            0.8s
    LLM   gpt-4o                   0.6s
    TOOL  get_order_status         0.0s

Decorator ordering

When you stack @neatlogs.span with a framework decorator that transforms the function (@function_tool, @tool, @task), put @neatlogs.span below it — closest to def — so it wraps the real function:

# CORRECT — @function_tool wraps the span-decorated function
@function_tool
@neatlogs.span(kind="TOOL", tool_name="search")
def search(query: str) -> str:
    ...

Decorators that preserve the callable (@app.get, @app.post) can sit either side.

@span parameters

ParameterKindDescription
kindAllRequired. The span kind (see below).
nameAllThe span's label in the tree. Defaults to the function name.
roleAGENTThe agent's role (also sets agent.name).
goalAGENTThe agent's objective.
tool_nameTOOL, MCP_TOOLThe tool identifier (sets tool.name). Distinct from name: name is the label, tool_name is what the dashboard groups/reports on.
parametersTOOLTool parameter schema (dict).
descriptionTOOL, MCP_TOOLHuman-readable tool description.
capture_inputAllRecord the arguments (default True).
capture_outputAllRecord the return value (default True).
maskAll(span_dict) -> span_dict, applied before export for this span.

To disable input/output capture for one span, pass capture_input=False / capture_output=False; to disable it everywhere, set export NEATLOGS_TRACE_CONTENT=false.

Span kinds

@neatlogs.span() accepts these eight kinds:

KindUse for
WORKFLOWTop-level entry point — one per trace root
AGENTA reasoning step that calls an LLM and decides what to do next
CHAINA fixed sequence of steps with no branching LLM decisions
TOOLA function the agent calls to interact with the world
RETRIEVERA vector search or document lookup (custom retrievers)
EMBEDDINGA function that produces embeddings
GUARDRAILA safety or validation check
MCP_TOOLA tool exposed over the Model Context Protocol

Passing any other kind raises ValueError. The kinds RERANKER and VECTOR_STORE are created with trace() (below); LLM comes from wrap() / auto-instrumentation. For the complete catalogue of every kind, see Span Kinds.

Inline spans with trace()

with neatlogs.trace(...) opens a span for a block inside a function — use it for prompt-template tracking (below), for custom span kinds (RERANKER, VECTOR_STORE), or to set attributes yourself on custom retrieval/guardrail logic that Neatlogs doesn't already recognize.

with neatlogs.trace(
    name,
    kind=None,
    system_prompt_template=None,
    user_prompt_template=None,
    version=None,
    mask=None,
) as span:
    ...

Supported libraries (vector DBs, framework retrievers) are captured automatically — you only set attributes for custom implementations:

import json
import neatlogs

@neatlogs.span(kind="CHAIN")
def rag_pipeline(query: str) -> str:
    with neatlogs.trace("retrieve", kind="RETRIEVER") as span:
        span.set_attribute("neatlogs.retrieval.query", query)
        docs = my_custom_search(query, k=5)   # not a supported library
        span.set_attribute("neatlogs.retrieval.documents", json.dumps(docs))
    return generate(query, docs)

The neatlogs.* attribute names each custom kind expects (RETRIEVER, RERANKER, VECTOR_STORE, GUARDRAIL, EMBEDDING, TOOL) are listed in Custom Attributes.

Prompt templates

Capture both the template structure and the runtime variable values for an LLM call, linked to its span. SystemPromptTemplate is the system/instruction prompt; UserPromptTemplate is the user turn. Pass them to trace(kind="LLM", ...) around the call:

import neatlogs
from neatlogs import SystemPromptTemplate, UserPromptTemplate

system_template = SystemPromptTemplate([
    {"role": "system", "content": "You are a {{role}} assistant."},
])
user_template = UserPromptTemplate([
    {"role": "user", "content": "{{question}}"},
])

@neatlogs.span(kind="AGENT")
def answer_agent(question: str) -> str:
    with neatlogs.trace("answer", kind="LLM",
                        system_prompt_template=system_template,
                        user_prompt_template=user_template):
        msgs = system_template.compile(role="support") + user_template.compile(question=question)
        return client.chat.completions.create(model="gpt-4o", messages=msgs).choices[0].message.content

For prompts managed centrally in the dashboard, the module-level functions (neatlogs.get_prompt, create_prompt, update_prompt, …) are available after init(). See Prompt Templates for managed prompts and the full API.

PromptTemplate and the prompt_template= keyword are backward-compatible aliases for SystemPromptTemplate / system_prompt_template=. New code should use the canonical names.

Log capture

Log capture is opt-in — pass capture_logs=True to init(). Neatlogs then records logs as LOG spans (children of the active span) so they appear inline in the trace timeline. Without it, none of the three mechanisms below capture anything.

neatlogs.init(api_key="...", capture_logs=True)

@neatlogs.span(kind="CHAIN")
def rag_pipeline(query: str) -> str:
    docs = retrieve(query)
    neatlogs.log("retrieved {count} docs", count=len(docs))
    return generate(query, docs)

1. neatlogs.log() — structured template messages

The primary way to emit a named, structured step. The message template becomes the span name (low-cardinality, searchable) and each keyword argument is stored as a log.{key} attribute.

neatlogs.log(msg_template, /, level="info", **data)
ParameterDescription
msg_templateMessage template with {key} placeholders. Stored as the span name.
levelLog level: "info", "debug", "warning", "error". Default: "info".
**dataKey-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}}. neatlogs.log() must be called inside an active span (@neatlogs.span or with neatlogs.trace()); calls outside a traced block are dropped.

neatlogs.log("retrieved {count} docs, top score {score:.2f}",
             count=len(docs), score=docs[0]["score"])

When init(debug=True), every neatlogs.log() call is also echoed to stderr immediately, so you can watch steps in the terminal without opening the dashboard:

[neatlogs] 12:34:56  LOG  retrieved 42 docs, top score 0.94  count=42  score=0.94

2. stdlib logging.*() — auto-captured

Any logging.debug() / info() / warning() / error() call inside a traced block is captured automatically — no code changes. All levels are captured by default; raise the floor with log_level="WARNING" in init() to capture only WARNING and above.

3. capture_stdout=True — print capture

Pass capture_stdout=True to @neatlogs.span or with neatlogs.trace() to capture every print() line inside that block as a LOG span. Output is still mirrored to the real stdout — capture does not suppress terminal output.

@neatlogs.span(kind="AGENT", capture_stdout=True)
def run_agent(query: str) -> str:
    print(f"Processing query: {query}")   # captured as a LOG span
    return agent.invoke(query)

What you'll see in the dashboard

Each log call produces a LOG span as a child of the active span, with these fields:

FieldSource
Span nameneatlogs.log(): the message template. stdlib/print: the rendered message.
input.valueThe fully rendered log message.
log.levelinfo, warning, error, etc.
log.{key}Each kwarg from neatlogs.log(), e.g. log.count, log.score.

Framework & provider helpers

Beyond instrumentations=[...], a few helpers attach tracing to a specific object:

  • neatlogs.wrap(client) — the main path; wraps an LLM client or agent (OpenAI, Anthropic, Bedrock, Vertex, OpenRouter, CrewAI, Pydantic AI, DSPy, Agno, Google ADK, Hermes).
  • neatlogs.langchain_handler() — a LangChain callback handler to pass in config={"callbacks": [...]}.
  • neatlogs.openai_agents_processor() — a trace processor for the OpenAI Agents SDK.
  • neatlogs.strands_hooks(agent) — registers hooks on a Strands agent.
  • neatlogs.bind_templates(...) / register_crewai_task(...) — attach prompt templates to CrewAI work.

Each provider and framework has a runnable example in Integrations.

Supported libraries

Pass these to instrumentations=[] (install the matching extra first).

LLM providers: openai, anthropic, google_genai, azure_ai_inference, bedrock, litellm, cohere, groq, mistralai, together, ollama, replicate, openrouter

Agent frameworks: langchain (covers LangGraph), langgraph, crewai, openai_agents, pydantic_ai, dspy, agno, google_adk, strands, autogen, haystack, smolagents, hermes

Vector databases: chromadb, pinecone, qdrant, weaviate, milvus, elasticsearch

Other: mcp, instructor, guardrails

For most LangGraph apps, "langchain" is all you need — it captures calls through langchain_openai, langchain_anthropic, etc. For CrewAI, also add the provider key matching crewai.LLM(model=...): openai / azure_ai_inference / google_genai / anthropic.

init() reference

ParameterTypeDefaultDescription
api_keystrNEATLOGS_API_KEY envProject API key. If unset, spans are created but not exported.
workflow_namestrscript filenameLabel all traces from this process appear under.
instrumentationslist[str]Libraries to auto-instrument.
tagslist[str]Tags attached to every trace.
session_idstrGroup multi-turn traces under one session.
auto_sessionboolFalseAuto-generate a session ID for the process.
end_user_idstrYour app's end-user on every trace (process-global default; set per-request via trace() on a server).
end_user_metadatadictArbitrary end-user fields stored as JSON (e.g. {"plan": "pro"}).
capture_logsboolFalseCapture neatlogs.log(), stdlib logging, and print() as LOG spans.
log_levelstr"INFO"Minimum stdlib log level to capture.
sample_ratefloat1.0Fraction of traces to export.
flush_intervalfloat5.0Seconds between background batch flushes. Lower it for short-lived workers that can't wait; raise it to batch more aggressively.
batch_sizeint100Max spans per export request.
maskcallableClient-side redaction (span_dict) -> span_dict.
pii_enabledboolteam settingOverride server-side PII redaction.
pii_span_typeslist[str]team settingLimit redaction to specific span kinds.
debugboolFalseVerbose logging to stderr.

For PII redaction details see PII Redaction; for sessions, tags, and workflow names see Reference; to attach your app's users to traces see End-User Identity.

Building in Node.js? See the TypeScript SDK.

On this page