A @trace decorator for every tool

You could write with tracer.start_as_current_span at the top of every tool, but you will forget one, and the code gets noisy. A decorator centralises the pattern. One @trace, every tool is instrumented the same way, every new tool is traced on day one.

observability.py
python
import functools, time, json
from opentelemetry import trace

tracer = trace.get_tracer("agent")

def trace(name=None, span_type="CHAIN", attributes=None, model=None):
    static_attrs = attributes or {}

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            span_name = name or func.__name__
            start = time.perf_counter()
            with tracer.start_as_current_span(span_name) as span:
                span.set_attribute("openinference.span.kind", span_type)
                span.set_attribute("func.name", func.__name__)
                for key, value in static_attrs.items():
                    span.set_attribute(key, value)
                if model:
                    span.set_attribute("llm.model_name", model)
                try:
                    result = func(*args, **kwargs)
                    elapsed = round((time.perf_counter() - start) * 1000, 2)
                    span.set_attribute("duration_ms", elapsed)
                    return result
                except Exception as e:
                    span.set_attribute("error", True)
                    span.set_attribute("error.message", str(e))
                    raise
        return wrapper
    return decorator

A compact version of the real decorator. It sets the span kind attribute Phoenix uses, records duration, and captures errors.

agent/router.py
python
from observability import trace

@trace(span_type="PARSER", model="gpt-4o-mini")
def route_question(user_message: str, conversation_history: list[dict]) -> dict:
    # ... existing router code
    return {"route": route, "reason": parsed.get("reason", "")}

One line above the function and the router is instrumented. The decorator also works on synthesis_node, sql_node, and every other step.

tools/sql_tool.py
python
from observability import trace
from config import DB_PATH

@trace(span_type="TOOL", model="gpt-4o-mini", attributes={
    "db.type": "sqlite",
    "db.path": DB_PATH,
})
def run_sql_tool(user_question: str, conversation_history: list[dict]) -> dict:
    # ... generate and execute SQL
    return {"sql": sql, "rows": rows, "table_md": table_md, "error": error}

Static attributes like db.type and db.path describe the tool once. The decorator adds duration on every call.

Fill in the blanks: Complete the RAG tool decoration

Loading practice…