OpenTelemetry Tracing & Tool Callbacks¶
litestar-mcp provides two complementary observability mechanisms:
- OpenTelemetry Tracing (
TelemetryConfig&SpanManager): Emits vendor-neutral OpenTelemetry spans with standard GenAI semantic conventions (gen_ai.operation.name,gen_ai.agent.name,gen_ai.tool.name, token usage, and turn metadata) acrossAgentRuntimeturns, tool executions, and custom instrumentation blocks. - Tool-Call Lifecycle Hooks (
MCPConfig.before_tool_call&MCPConfig.after_tool_call): Runs synchronous or asynchronous callbacks around every MCPtools/calldispatch for structured audit logging, latency histograms, and request-state tagging.
1. Installing OpenTelemetry Support¶
Install the optional opentelemetry extra (opentelemetry-api>=1.20.0) using your package manager:
Zero-Overhead No-Op Fallback
SpanManager resolves opentelemetry.trace lazily only when TelemetryConfig(enable_spans=True) is set. When enable_spans=False (the default) or opentelemetry-api is not installed in the environment, all SpanManager methods degrade gracefully to zero-allocation no-ops without raising ImportError.
2. Configuring TelemetryConfig and SpanManager¶
TelemetryConfig (litestar_mcp.core.observability.TelemetryConfig) controls span emission, custom TracerProvider resolution, resource attributes, and the instrumentation scope name:
TelemetryConfig Field |
Type | Default | Description |
|---|---|---|---|
enable_spans |
bool |
False |
Enables OpenTelemetry span creation for agent turns, tool calls, and SpanManager.span(...) blocks. |
provider_factory |
Callable[[], Any] \| None |
None |
Optional factory returning a custom OpenTelemetry TracerProvider. When None, SpanManager uses the global opentelemetry.trace.get_tracer(tracer_name). |
resource_attributes |
dict[str, Any] |
{} |
Key-value attributes merged onto every span started by SpanManager (for example, {"service.name": "notes-mcp", "deployment.environment": "production"}). |
tracer_name |
str |
"litestar_mcp" |
Instrumentation scope name passed to get_tracer(tracer_name). |
Pass a configured SpanManager(telemetry) to AgentRuntime(..., span_manager=...) and use it inside MCPConfig tool callbacks or route dependencies:
"""Snippet: OpenTelemetry TelemetryConfig, SpanManager, and MCP tool lifecycle hooks."""
# start-example
from typing import Any
from litestar import Litestar, Request, get
from litestar_mcp import LitestarMCP, MCPConfig
from litestar_mcp.agent import Agent, AgentRuntime
from litestar_mcp.core.observability import SpanManager, TelemetryConfig
telemetry = TelemetryConfig(
enable_spans=True,
tracer_name="my_app.mcp",
resource_attributes={
"service.name": "notes-mcp",
"deployment.environment": "production",
},
)
span_manager = SpanManager(telemetry)
async def before_tool_call(
tool_name: str,
arguments: dict[str, Any],
request: Request[Any, Any, Any],
/,
) -> None:
"""Record tool invocation start on the request state."""
request.state["active_mcp_tool"] = tool_name
request.state["active_mcp_arg_keys"] = sorted(arguments.keys())
async def after_tool_call(
tool_name: str,
arguments: dict[str, Any],
request: Request[Any, Any, Any],
/,
*,
result: Any,
exception: Exception | None,
duration: float,
) -> None:
"""Emit structured audit log entries and span telemetry after each tool call."""
_ = arguments, result
with span_manager.span(
f"mcp.tool.{tool_name}",
attributes={
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": tool_name,
"mcp.tool.duration_s": round(duration, 6),
"mcp.tool.failed": exception is not None,
},
):
request.app.logger.info(
"mcp_tool=%s duration_s=%0.4f failed=%s",
tool_name,
duration,
exception is not None,
)
@get("/notes/summary", mcp_tool="summarize_notes")
async def summarize_notes() -> dict[str, int]:
"""Return summary counts for stored notes."""
return {"total_notes": 12, "archived_notes": 3}
notes_agent = Agent(
name="notes_assistant",
model="gemini-3.8-flash",
instructions="Summarize and audit notes for the current workspace.",
)
agent_runtime = AgentRuntime(notes_agent, span_manager=span_manager)
mcp_config = MCPConfig(
before_tool_call=before_tool_call,
after_tool_call=after_tool_call,
)
app = Litestar(
route_handlers=[summarize_notes],
plugins=[LitestarMCP(mcp_config), agent_runtime],
)
# end-example
def build() -> Litestar:
"""Return the configured Litestar application for snippet smoke testing."""
return app
3. Emitted Span Hierarchy & GenAI Semantic Conventions¶
SpanManager (litestar_mcp.core.observability.SpanManager) and litestar_mcp.core.observability.semantics follow the OpenTelemetry Semantic Conventions for Generative AI systems (SpanKind.INTERNAL):
Span Names¶
| Operation | SpanManager Method |
Span Name Format | gen_ai.operation.name |
|---|---|---|---|
| Agent Turn Execution | start_agent_span(agent_name, action="invoke_agent", ...) |
invoke_agent <agent_name> |
"invoke_agent" |
| Tool Execution | start_tool_span(tool_name, call_id=..., ...) |
execute_tool <tool_name> |
"execute_tool" |
| Custom Instrumentation Block | span(name, attributes=...) / start_span(name, attributes=...) |
<name> |
(caller-supplied) |
Span Attributes Reference (litestar_mcp.core.observability.semantics)¶
| Constant | Attribute Key | Emitted On | Value / Description |
|---|---|---|---|
GEN_AI_OPERATION_NAME |
"gen_ai.operation.name" |
Agent & Tool spans | "invoke_agent" for agent turns; "execute_tool" for tool invocations. |
GEN_AI_AGENT_NAME |
"gen_ai.agent.name" |
Agent spans | Name of the active Agent (agent.name). |
GEN_AI_CONVERSATION_ID |
"gen_ai.conversation.id" |
Agent spans | Conversation session_id when present on the TurnRequest. |
GEN_AI_REQUEST_MODEL |
"gen_ai.request.model" |
Agent spans | Requested model identifier (for example, "gemini-3.8-flash"). |
GEN_AI_PROVIDER_NAME |
"gen_ai.provider.name" |
Agent spans | Model provider identifier (for example, "google_genai"). |
GEN_AI_USAGE_INPUT_TOKENS |
"gen_ai.usage.input_tokens" |
Agent spans (set_usage) |
Total prompt/input tokens consumed during the turn. |
GEN_AI_USAGE_OUTPUT_TOKENS |
"gen_ai.usage.output_tokens" |
Agent spans (set_usage) |
Total completion/output tokens produced during the turn. |
GEN_AI_TOOL_NAME |
"gen_ai.tool.name" |
Tool spans | Registered name of the executed tool. |
GEN_AI_TOOL_CALL_ID |
"gen_ai.tool.call.id" |
Tool spans | Model-assigned call_id correlating the tool invocation to the assistant turn. |
LITESTAR_MCP_AGENT_GROUP |
"litestar_mcp.agent.group" |
Agent spans | Name of the owning AgentGroup when running a coordinator/specialist tree. |
LITESTAR_MCP_TURN_ID |
"litestar_mcp.turn.id" |
Agent spans | Unique turn_id for the current turn execution. |
ERROR_TYPE |
"error.type" |
Any failed span (end_span) |
Exception class name (type(error).__name__); end_span also calls span.record_exception(error) and sets Status(StatusCode.ERROR, str(error)). |
4. MCP Tool-Call Lifecycle Hooks (before_tool_call & after_tool_call)¶
For MCP server observability independent of AgentRuntime, MCPConfig exposes before_tool_call (BeforeToolCallHook) and after_tool_call (AfterToolCallHook):
"""Snippet: MCP tool-call callback configuration."""
from typing import Any
from litestar import Litestar, Request
from litestar_mcp import LitestarMCP, MCPConfig
# start-example
async def after_tool_call(
tool_name: "str",
arguments: "dict[str, Any]",
request: "Request[Any, Any, Any]",
*,
result: "Any",
exception: "Exception | None",
duration: "float",
) -> "None":
request.app.logger.info(
"mcp tool=%s duration=%0.4f failed=%s",
tool_name,
duration,
exception is not None,
)
config = MCPConfig(after_tool_call=after_tool_call)
# end-example
def build() -> "Litestar":
app = Litestar(route_handlers=[], plugins=[LitestarMCP(config)])
return app
Callback Signatures & Execution Order¶
before_tool_call(tool_name: str, arguments: dict[str, Any], request: Request[Any, Any, Any], /) -> Awaitable[None] | None:- Runs once before every
tools/callexecution, immediately after the synthesized internal HTTPRequestis created and before route guards or dependencies run. - Receives a shallow copy of
argumentsso mutations inside the hook do not alter the arguments passed to the handler. - Useful for binding correlation IDs or audit metadata onto
request.state. after_tool_call(tool_name: str, arguments: dict[str, Any], request: Request[Any, Any, Any], /, *, result: Any, exception: Exception | None, duration: float) -> Awaitable[None] | None:- Runs inside a
finallyblock after everytools/callattempt—whether the tool succeeded (exception is None), failed argument validation, was rejected by aGuard(PermissionDeniedException/NotAuthorizedException), or raised an unhandled exception. durationis the monotonic elapsed wall-clock time in seconds (float) coveringbefore_tool_call, guard evaluation, dependency resolution, and handler execution.- Fault Isolation:
- Both hooks accept either synchronous functions or
async defcoroutines. - Any exception raised inside
before_tool_callorafter_tool_callis caught and logged bylitestar-mcpso a failing metrics sink or audit logger never aborts a valid tool execution.