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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ``chat`` instrumentation for the in-process smolagents model classes (``TransformersModel``, ``VLLMModel``, ``MLXModel``).
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,56 @@ 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 model classes that
run inference in your own process and emits a GenAI semantic-convention ``chat``
span and the matching metrics through ``opentelemetry-util-genai``:

* ``TransformersModel``
* ``VLLMModel``
* ``MLXModel``

The API-backed model classes are not instrumented here. Each one calls a client
library that carries its own instrumentation. Emitting a span at the smolagents
layer as well would produce two ``chat`` spans for one model call, and would
count the token-usage and duration metrics twice. Install the instrumentation
for the client library instead:

.. list-table::
:header-rows: 1

* - smolagents model class
- Instrument this instead
* - ``OpenAIModel``, ``AzureOpenAIModel``
- `opentelemetry-instrumentation-genai-openai
<https://pypi.org/project/opentelemetry-instrumentation-genai-openai/>`_
* - ``AmazonBedrockModel``
- `opentelemetry-instrumentation-botocore
<https://pypi.org/project/opentelemetry-instrumentation-botocore/>`_
* - ``InferenceClientModel``, ``LiteLLMModel``, ``LiteLLMRouterModel``
- the instrumentation or built-in telemetry of the client library the model
calls (``huggingface_hub``, ``litellm``)

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.

``TransformersModel`` is the only instrumented class with a ``generate_stream``.
A streamed call gets a ``chat`` span that stays open until the caller drains the
deltas. This covers both ``stream_outputs=True`` on an agent and a direct
``generate_stream`` call. 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:

* A subclass that inherits ``generate`` or ``generate_stream`` from one of the
three classes above is instrumented. A subclass that overrides one is not: the
override shadows the patched method, so the call produces no ``chat`` span.
* A ``chat`` span reports no ``gen_ai.response.id``, no
``gen_ai.response.model``, no ``gen_ai.response.finish_reasons`` and no
``server.address``. A runtime in this process returns the generated text and
the token counts, nothing more. It also listens on no socket.

Installation
------------
Expand All @@ -24,10 +73,22 @@ Usage
from opentelemetry.instrumentation.genai.smolagents import (
SmolagentsInstrumentor,
)
from smolagents import TransformersModel

# Instrument smolagents
SmolagentsInstrumentor().instrument()

model = TransformersModel(model_id="HuggingFaceTB/SmolLM2-135M-Instruct")
model.generate(
[
{
"role": "user",
"content": [
{"type": "text", "text": "How many seconds are in a week?"}
],
}
]
)

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

Expand Down Expand Up @@ -71,6 +132,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,13 @@

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

Calls to the in-process model classes (``TransformersModel``, ``VLLMModel`` and
``MLXModel``) are recorded as ``chat`` spans. The API-backed model classes are
not instrumented here: each one calls a client library that carries its own
instrumentation, and emitting a span at this layer as well would duplicate the
span and count the token-usage and duration metrics twice. Agent runs and tool
calls are not instrumented yet.

Usage
-----

Expand All @@ -15,10 +22,22 @@
from opentelemetry.instrumentation.genai.smolagents import (
SmolagentsInstrumentor,
)
from smolagents import TransformersModel

# Enable instrumentation
SmolagentsInstrumentor().instrument()

model = TransformersModel(model_id="HuggingFaceTB/SmolLM2-135M-Instruct")
model.generate(
[
{
"role": "user",
"content": [
{"type": "text", "text": "How many seconds are in a week?"}
],
}
]
)

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

Expand All @@ -41,18 +60,67 @@
from collections.abc import Collection
from typing import Any

from smolagents import models
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"]


# The model classes that run inference in the current process. They call no
# client library, so this instrumentation is the only place their model calls
# can be observed.
#
# The API-backed classes are left out on purpose. Each one calls a client
# library whose own instrumentation emits the ``chat`` span, so wrapping them
# here as well would produce two spans for one model call and count the
# token-usage and duration metrics twice. ``README.rst`` lists which
# instrumentation covers which class.
_IN_PROCESS_MODEL_CLASSES = ("MLXModel", "TransformersModel", "VLLMModel")


def _model_classes_defining(method: str) -> list[type]:
"""The in-process model classes whose ``method`` gets wrapped.

Only classes that define ``method`` in their own ``__dict__`` are patched.
``MLXModel`` and ``VLLMModel`` have no ``generate_stream``, and neither does
the base class, so wrapping it on them raises ``AttributeError``. The same
check keeps a method defined on a shared base from being wrapped once per
subclass.

A user-defined subclass that overrides the method shadows the patched one and
emits no ``chat`` span. ``README.rst`` documents that limitation.

A class is looked up by name so that a smolagents version without one of them
is skipped rather than raising.
"""
classes: list[type] = []
for name in _IN_PROCESS_MODEL_CLASSES:
model_cls = getattr(models, name, None)
if isinstance(model_cls, type) and method in model_cls.__dict__:
classes.append(model_cls)
return 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 @@ -66,15 +134,45 @@ def _instrument(self, **kwargs: Any) -> None:
- logger_provider: LoggerProvider instance
- completion_hook: CompletionHook instance
"""
TelemetryHandler(
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.

self._wrapped_generate_classes = []
self._wrapped_generate_stream_classes = []
try:
for model_cls in _model_classes_defining("generate"):
wrap_function_wrapper(
model_cls,
"generate",
model_generate(handler),
)
self._wrapped_generate_classes.append(model_cls)

for model_cls in _model_classes_defining("generate_stream"):
wrap_function_wrapper(
model_cls,
"generate_stream",
model_generate_stream(handler),
)
self._wrapped_generate_stream_classes.append(model_cls)
except BaseException:
# 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