Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/examples/telemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ This directory contains examples demonstrating OpenTelemetry tracing and metrics

- **`telemetry_example.py`** - Demonstrates distributed tracing (application and backend traces)
- **`metrics_example.py`** - Demonstrates token usage metrics collection
- **`otel_genai_semconv_example.py`** - Verifies OTel GenAI semantic convention attributes
emitted on backend spans (`gen_ai.provider.name`, `gen_ai.conversation.id`, `error.type`).
Designed for human verification against [otelite](https://github.com/planetf1/otelite).

## Quick Start

Expand Down
89 changes: 89 additions & 0 deletions docs/examples/telemetry/otel_genai_semconv_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# pytest: ollama, e2e, qualitative

"""Mellea backend spans carrying OTel GenAI semantic convention attributes.

Each backend generation call emits a ``chat`` span with the following attributes
drawn from the OTel GenAI semconv (https://opentelemetry.io/docs/specs/semconv/gen-ai/):

gen_ai.provider.name — provider identity (current semconv)
gen_ai.system — same value, retained for back-compat with existing dashboards
gen_ai.conversation.id — correlated to the active session via ``with_context``
error.type — set on the error path alongside ERROR span status

Run against otelite for human verification:

# Terminal 1 — start otelite (OTLP gRPC :4317, UI :8080)
docker run --rm -p 4317:4317 -p 8080:8080 ghcr.io/planetf1/otelite:latest

# Terminal 2
export MELLEA_TRACE_BACKEND=1
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_SERVICE_NAME=mellea-semconv-demo
python otel_genai_semconv_example.py

Then open http://localhost:8080 and select the mellea-semconv-demo service.

Expected span attributes
------------------------
Span "chat" (normal path)
gen_ai.system = "ollama"
gen_ai.provider.name = "ollama"
gen_ai.conversation.id = "demo-session-1"
mellea.session_id = "demo-session-1"

Span "chat" (error path)
error.type = <exception class name>
status = ERROR
"""

from mellea import start_session
from mellea.telemetry import is_backend_tracing_enabled, with_context


def _section(title: str) -> None:
print(f"\n{'=' * 60}\n {title}\n{'=' * 60}")


def main() -> None:
_section("Mellea OTel GenAI Semantic Convention Demo")
print(f"Backend tracing: {is_backend_tracing_enabled()}")
if not is_backend_tracing_enabled():
print("Set MELLEA_TRACE_BACKEND=1 to enable backend spans.")

# -----------------------------------------------------------------------
# Normal path: provider name + conversation id
# -----------------------------------------------------------------------
_section("Normal path — provider name and conversation id")
print("Expected span attrs:")
print(" gen_ai.system = 'ollama'")
print(" gen_ai.provider.name = 'ollama'")
print(" gen_ai.conversation.id = 'demo-session-1'")

with with_context(session_id="demo-session-1"):
with start_session() as m:
result = m.instruct("Summarise quantum tunnelling in one sentence.")
print(f"\nOutput: {str(result)[:120]}")

# -----------------------------------------------------------------------
# Error path: error.type + ERROR status
# -----------------------------------------------------------------------
_section("Error path — error.type on span")
print("Expected span attrs:")
print(" status = ERROR")
print(" error.type = <exception class name>")

try:
with start_session(base_url="http://localhost:19999") as m2:
m2.instruct("Hello")
except Exception as exc:
print(f"\nGot expected error: {exc.__class__.__name__}")
else:
print("\n(No error — nothing is listening on port 19999)")

_section("Done")
print("If OTEL_EXPORTER_OTLP_ENDPOINT is set, check your trace backend.")
print("If MELLEA_TRACE_CONSOLE=1, spans were printed to stdout above.")


if __name__ == "__main__":
main()
1 change: 0 additions & 1 deletion mellea/backends/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
from ..stdlib.components import Message
from ..stdlib.requirements import ALoraRequirement
from ..telemetry.backend_instrumentation import (
instrument_generate_from_context,
instrument_generate_from_raw,
start_generate_span,
)
Expand Down
1 change: 0 additions & 1 deletion mellea/backends/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@
from ..stdlib.components import Intrinsic, Message
from ..stdlib.requirements import LLMaJRequirement
from ..telemetry.backend_instrumentation import (
instrument_generate_from_context,
instrument_generate_from_raw,
start_generate_span,
)
Expand Down
5 changes: 2 additions & 3 deletions mellea/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,10 +523,9 @@ async def astream(self) -> str:
# but we must not leak the span.
span = self._meta.get("_telemetry_span")
if span is not None:
from ..telemetry import end_backend_span, set_span_error
from ..telemetry.backend_instrumentation import finalize_backend_span

set_span_error(span, chunks[-1])
end_backend_span(span)
finalize_backend_span(span, error=chunks[-1])
del self._meta["_telemetry_span"]

# Fire generation_error hook (FIRE_AND_FORGET — does not block the raise)
Expand Down
4 changes: 4 additions & 0 deletions mellea/telemetry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@ def my_function():
)
from .pricing import is_pricing_enabled
from .tracing import (
add_span_event,
end_backend_span,
is_application_tracing_enabled,
is_backend_tracing_enabled,
is_content_tracing_enabled,
set_span_attribute,
set_span_error,
start_backend_span,
Expand All @@ -104,6 +106,7 @@ def my_function():

__all__ = [
"MelleaContextFilter",
"add_span_event",
"async_with_context",
"create_counter",
"create_histogram",
Expand All @@ -118,6 +121,7 @@ def my_function():
"get_session_id",
"is_application_tracing_enabled",
"is_backend_tracing_enabled",
"is_content_tracing_enabled",
"is_metrics_enabled",
"is_pricing_enabled",
"record_cost",
Expand Down
118 changes: 77 additions & 41 deletions mellea/telemetry/backend_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import Any

from ..backends.utils import get_value
from .tracing import set_span_attribute, trace_backend
from .tracing import end_backend_span, set_span_attribute, set_span_error, trace_backend


def get_model_id_str(backend: Any) -> str:
Expand All @@ -30,6 +30,9 @@ def get_model_id_str(backend: Any) -> str:
def get_system_name(backend: Any) -> str:
"""Get the Gen-AI system name from backend.

Kept for back-compatibility with existing dashboards keyed on ``gen_ai.system``.
New code should prefer ``get_provider_name()``.

Args:
backend: Backend instance

Expand All @@ -51,6 +54,21 @@ def get_system_name(backend: Any) -> str:
return backend.__class__.__name__


def get_provider_name(backend: Any) -> str:
"""Get the Gen-AI provider name from backend.

Returns the value for ``gen_ai.provider.name`` (semconv v1.37.0+), which
supersedes the deprecated ``gen_ai.system`` attribute.

Args:
backend: Backend instance

Returns:
Provider name (e.g., 'openai', 'ollama', 'huggingface')
"""
return get_system_name(backend)


def get_context_size(ctx: Any) -> int:
"""Get the size of a context.

Expand All @@ -70,44 +88,6 @@ def get_context_size(ctx: Any) -> int:
return 0


def instrument_generate_from_context(
backend: Any, action: Any, ctx: Any, format: Any = None, tool_calls: bool = False
):
"""Create a backend trace span for generate_from_context.

Follows Gen-AI semantic conventions for chat operations.

Args:
backend: Backend instance
action: Action component
ctx: Context
format: Response format (BaseModel subclass or None)
tool_calls: Whether tool calling is enabled

Returns:
Context manager for the trace span
"""
model_id = get_model_id_str(backend)
system_name = get_system_name(backend)

return trace_backend(
"chat", # Gen-AI convention: use 'chat' for chat completions
**{
# Gen-AI semantic convention attributes
"gen_ai.system": system_name,
"gen_ai.request.model": model_id,
"gen_ai.operation.name": "chat",
# Mellea-specific attributes
"mellea.backend": backend.__class__.__name__,
"mellea.action_type": action.__class__.__name__,
"mellea.context_size": get_context_size(ctx),
"mellea.has_format": format is not None,
"mellea.format_type": format.__name__ if format else None,
"mellea.tool_calls_enabled": tool_calls,
},
)


def start_generate_span(
backend: Any, action: Any, ctx: Any, format: Any = None, tool_calls: bool = False
):
Expand All @@ -130,13 +110,15 @@ def start_generate_span(

model_id = get_model_id_str(backend)
system_name = get_system_name(backend)
provider_name = get_provider_name(backend)

from .context import get_current_context

telemetry_ctx = get_current_context()
span_attrs: dict = {
span_attrs: dict[str, Any] = {
# Gen-AI semantic convention attributes
"gen_ai.system": system_name,
"gen_ai.provider.name": provider_name,
"gen_ai.request.model": model_id,
"gen_ai.operation.name": "chat",
# Mellea-specific attributes
Expand All @@ -147,10 +129,16 @@ def start_generate_span(
"mellea.format_type": format.__name__ if format else None,
"mellea.tool_calls_enabled": tool_calls,
}

# Propagate telemetry context to span
for key, value in telemetry_ctx.items():
span_attrs[f"mellea.{key}"] = value

# gen_ai.conversation.id maps from the existing session_id ContextVar
session_id = telemetry_ctx.get("session_id")
if session_id is not None:
span_attrs["gen_ai.conversation.id"] = session_id

return start_backend_span("chat", **span_attrs)


Expand All @@ -172,12 +160,14 @@ def instrument_generate_from_raw(
"""
model_id = get_model_id_str(backend)
system_name = get_system_name(backend)
provider_name = get_provider_name(backend)

return trace_backend(
"text_completion", # Gen-AI convention: use 'text_completion' for completions
**{
# Gen-AI semantic convention attributes
"gen_ai.system": system_name,
"gen_ai.provider.name": provider_name,
"gen_ai.request.model": model_id,
"gen_ai.operation.name": "text_completion",
# Mellea-specific attributes
Expand Down Expand Up @@ -214,6 +204,22 @@ def record_token_usage(span: Any, usage: Any) -> None:
total_tokens = get_value(usage, "total_tokens")
if total_tokens is not None:
set_span_attribute(span, "gen_ai.usage.total_tokens", total_tokens)

cache_read = get_value(usage, "cache_read_input_tokens")
if cache_read is not None:
set_span_attribute(span, "gen_ai.usage.cache_read.input_tokens", cache_read)

cache_creation = get_value(usage, "cache_creation_input_tokens")
if cache_creation is not None:
set_span_attribute(
span, "gen_ai.usage.cache_creation.input_tokens", cache_creation
)

reasoning_tokens = get_value(usage, "reasoning_tokens")
if reasoning_tokens is not None:
set_span_attribute(
span, "gen_ai.usage.reasoning.output_tokens", reasoning_tokens
)
Comment thread
planetf1 marked this conversation as resolved.
except Exception:
# Don't fail if we can't extract token usage
pass
Expand Down Expand Up @@ -260,12 +266,42 @@ def record_response_metadata(
pass


def finalize_backend_span(span: Any, *, error: Exception | None = None) -> None:
"""Close a backend span on the error path, setting error.type and ERROR status.

Used by the streaming error path in ``ModelOutputThunk.__aiter__`` where a
span may be left open after an exception. Backends close spans on the
success path themselves via ``record_token_usage`` + ``record_response_metadata``
+ ``end_backend_span``.

Args:
span: The span to finalise (no-op when ``None``).
error: Exception to record; sets ERROR status and ``error.type``.
"""
if span is None:
return

try:
if error is not None:
set_span_error(span, error)
# error.type is a Stable OTel cross-signal attribute
set_span_attribute(span, "error.type", type(error).__name__)
except Exception:
pass
try:
end_backend_span(span)
except Exception:
pass


__all__ = [
"finalize_backend_span",
"get_context_size",
"get_model_id_str",
"get_provider_name",
"get_system_name",
"instrument_generate_from_context",
"instrument_generate_from_raw",
"record_response_metadata",
"record_token_usage",
"start_generate_span",
]
Loading
Loading