Skip to content
Open
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
2 changes: 1 addition & 1 deletion instrumentation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
| [opentelemetry-instrumentation-genai-openai](./opentelemetry-instrumentation-genai-openai) | openai >= 1.26.0 | Yes | development
| [opentelemetry-instrumentation-genai-openai-agents](./opentelemetry-instrumentation-genai-openai-agents) | openai-agents >= 0.3.3 | No | development
| [opentelemetry-instrumentation-genai-qwen-agent](./opentelemetry-instrumentation-genai-qwen-agent) | qwen-agent >= 0.0.20 | No | development
| [opentelemetry-instrumentation-genai-smolagents](./opentelemetry-instrumentation-genai-smolagents) | smolagents >= 1.24.0 | No | development
| [opentelemetry-instrumentation-genai-smolagents](./opentelemetry-instrumentation-genai-smolagents) | smolagents >= 1.24.0 | Yes | development
| [opentelemetry-instrumentation-genai-weaviate-client](./opentelemetry-instrumentation-genai-weaviate-client) | weaviate-client >= 3.0.0,<5.0.0 | No | development
| [opentelemetry-instrumentation-google-genai](./opentelemetry-instrumentation-google-genai) | google-genai >= 1.32.0, <3 | No | development
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ``chat`` instrumentation for the smolagents model classes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to instrument chat operation in agent framework.

we do it in langchain for historical reasons and because langchain is so popular and it's still quite controversial. Can we avoid doing it here? Does smolagents rely on other libraries like openai and anthropic to make model calls?

If so users that install both smalagents and openai / anthropic / etc would end up with duplicated spans.
We can spend more effort suppressing them, but I want to check if it's even necessary.
If there is no strong reason to do it, let's just start with more interesting agentic layer

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,31 @@ OpenTelemetry smolagents Instrumentation
:target: https://pypi.org/project/opentelemetry-instrumentation-genai-smolagents/

This library provides OpenTelemetry instrumentation for `smolagents
<https://github.com/huggingface/smolagents>`_.
<https://github.com/huggingface/smolagents>`_. It wraps the smolagents model
classes and emits a GenAI semantic-convention ``chat`` span and the matching
metrics through ``opentelemetry-util-genai``.

Agent runs (``invoke_agent``) and tool calls (``execute_tool``) are not
instrumented yet. A model call made inside an agent run still gets a ``chat``
span, but no agent span sits above it.

A streamed model call, whether it comes from ``stream_outputs=True`` on an agent
or from calling ``Model.generate_stream`` directly, gets a ``chat`` span that
stays open until the caller drains the deltas. The span carries
``gen_ai.request.stream``, and the call also records the
``gen_ai.client.operation.time_to_first_chunk`` and
``gen_ai.client.operation.time_per_output_chunk`` metrics.

Known gaps:

* The instrumentation patches ``generate`` and ``generate_stream`` on the model
classes that smolagents ships. A subclass that inherits either method is
instrumented. A subclass that overrides one is not: the override shadows the
patched method, so the call produces no ``chat`` span.
* A streamed ``chat`` span reports no ``gen_ai.response.id`` and no
``gen_ai.response.model``, because a smolagents stream delta carries neither.
The span reports ``gen_ai.response.finish_reasons`` only when the model
requested tool calls, which is the one stop reason the deltas make visible.

Installation
------------
Expand All @@ -24,10 +48,13 @@ Usage
from opentelemetry.instrumentation.genai.smolagents import (
SmolagentsInstrumentor,
)
from smolagents import InferenceClientModel

# Instrument smolagents
SmolagentsInstrumentor().instrument()

model = InferenceClientModel()
model.generate([{"role": "user", "content": "How many seconds are in a week?"}])

Comment thread
alexander-akhmetov marked this conversation as resolved.
Configuration
-------------

Expand Down Expand Up @@ -71,6 +98,12 @@ environment variable:

SmolagentsInstrumentor().instrument(completion_hook=my_hook)

Conformance
-----------

The scenarios that check this package against the GenAI semantic conventions
live under ``tests/conformance/``.

References
----------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ dependencies = [
"opentelemetry-api ~= 1.43",
"opentelemetry-instrumentation >= 0.64b0, <1",
"opentelemetry-semantic-conventions >= 0.64b0, <1",
"opentelemetry-util-genai >= 1.0b0, <2",
"opentelemetry-util-genai >= 1.1b0.dev, <2",
]

[project.optional-dependencies]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

Instrumentation for `smolagents <https://github.com/huggingface/smolagents>`_.

Model calls are recorded as ``chat`` spans. Agent runs and tool calls are not
instrumented yet.

Usage
-----

Expand All @@ -15,10 +18,13 @@
from opentelemetry.instrumentation.genai.smolagents import (
SmolagentsInstrumentor,
)
from smolagents import InferenceClientModel

# Enable instrumentation
SmolagentsInstrumentor().instrument()

model = InferenceClientModel()
model.generate([{"role": "user", "content": "How many seconds are in a week?"}])
Comment thread
alexander-akhmetov marked this conversation as resolved.

Configuration
-------------

Expand All @@ -38,20 +44,63 @@

from __future__ import annotations

from types import ModuleType
from typing import Any, Collection

from wrapt import wrap_function_wrapper

from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from opentelemetry.instrumentation.utils import unwrap
from opentelemetry.util.genai.completion_hook import load_completion_hook
from opentelemetry.util.genai.handler import TelemetryHandler

from .package import _instruments
from .patch import model_generate, model_generate_stream

__all__ = ["SmolagentsInstrumentor"]


def _model_classes_defining(smolagents: ModuleType, method: str) -> list[type]:
"""The exported model classes whose ``method`` gets wrapped.

Only classes that define ``method`` in their own ``__dict__`` are patched,
so a class that inherits it (``AzureOpenAIModel``, ``LiteLLMRouterModel``)
isn't wrapped a second time and can't produce duplicate ``chat`` spans.
A user-defined subclass that overrides the method shadows the patched base
method and emits no ``chat`` span; that limitation is documented in
``README.rst``.

Deduplicated by class object, because smolagents exports some classes under
two names (``OpenAIServerModel`` is ``OpenAIModel``) and wrapping the same
class twice would double every ``chat`` span.
"""
from smolagents.models import ( # noqa: PLC0415 # pylint: disable=import-outside-toplevel
Model,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same question.. we seem to always call this function so why not put this at the top ?


classes: dict[type, None] = {}
for obj in vars(smolagents).values():
if (
isinstance(obj, type)
and issubclass(obj, Model)
and method in obj.__dict__
):
classes.setdefault(obj, None)
return list(classes)


class SmolagentsInstrumentor(BaseInstrumentor):
"""An instrumentor for smolagents."""

# ``BaseInstrumentor.__new__`` returns a per-class singleton, but Python
# still runs ``__init__`` on every construction. Initializing this state in
# ``__init__`` would let the documented ``SmolagentsInstrumentor()
# .uninstrument()`` form wipe the live instance's bookkeeping and leave
# smolagents permanently patched, so these are class-level defaults that
# only ``_instrument`` / ``_uninstrument`` rebind.
_wrapped_generate_classes: list[type] = []
_wrapped_generate_stream_classes: list[type] = []

def instrumentation_dependencies(self) -> Collection[str]:
return _instruments

Expand All @@ -65,15 +114,51 @@ def _instrument(self, **kwargs: Any) -> None:
- logger_provider: LoggerProvider instance
- completion_hook: CompletionHook instance
"""
TelemetryHandler(
import smolagents # pylint: disable=import-outside-toplevel # noqa: PLC0415

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this outside the toplevel?


handler = TelemetryHandler(
tracer_provider=kwargs.get("tracer_provider"),
meter_provider=kwargs.get("meter_provider"),
logger_provider=kwargs.get("logger_provider"),
completion_hook=kwargs.get("completion_hook")
or load_completion_hook(),
)
# Patching will be added in follow-up PRs.

wrapped_generate_classes: list[type] = []
self._wrapped_generate_classes = wrapped_generate_classes
wrapped_generate_stream_classes: list[type] = []
self._wrapped_generate_stream_classes = wrapped_generate_stream_classes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just do self._wrapped_generate_classes = [] and self._wrapped_generate_classes.append()

try:
for model_cls in _model_classes_defining(smolagents, "generate"):
wrap_function_wrapper(
model_cls,
"generate",
model_generate(handler),
)
wrapped_generate_classes.append(model_cls)

for model_cls in _model_classes_defining(
smolagents, "generate_stream"
):
wrap_function_wrapper(
model_cls,
"generate_stream",
model_generate_stream(handler),
)
wrapped_generate_stream_classes.append(model_cls)
except Exception:
# BaseInstrumentor.instrument() doesn't mark the instrumentor as
# instrumented when _instrument raises, so uninstrument() would
# refuse to run and leave the patches applied with no way to undo.
self._uninstrument()
raise

def _uninstrument(self, **kwargs: Any) -> None:
"""Disable smolagents instrumentation and restore patched originals."""
# Unpatching will be added in follow-up PRs.
for model_cls in self._wrapped_generate_classes:
unwrap(model_cls, "generate")
self._wrapped_generate_classes = []

for model_cls in self._wrapped_generate_stream_classes:
unwrap(model_cls, "generate_stream")
self._wrapped_generate_stream_classes = []
Loading
Loading