-
Notifications
You must be signed in to change notification settings - Fork 41
[opentelemetry-instrumentation-genai-smolagents] Add chat instrumentation #352
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add ``chat`` instrumentation for the smolagents model classes. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| ----- | ||
|
|
||
|
|
@@ -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?"}]) | ||
|
alexander-akhmetov marked this conversation as resolved.
|
||
|
|
||
| Configuration | ||
| ------------- | ||
|
|
||
|
|
@@ -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, | ||
| ) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not just do |
||
| 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 = [] | ||
There was a problem hiding this comment.
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