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
2 changes: 1 addition & 1 deletion docs/instrumentation-genai/util.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ OpenTelemetry Python - GenAI Util
:undoc-members:
:show-inheritance:

.. automodule:: opentelemetry.util.genai.upload_hook
.. automodule:: opentelemetry.util.genai.completion_hook
:members:
:undoc-members:
:show-inheritance:
Expand Down
11 changes: 7 additions & 4 deletions util/opentelemetry-util-genai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- Add upload hook to genai utils to implement semconv v1.37.
- Add completion hook to genai utils to implement semconv v1.37.

The hook uses [`fsspec`](https://filesystem-spec.readthedocs.io/en/latest/) to support
various pluggable backends.
Includes a hook implementation using
[`fsspec`](https://filesystem-spec.readthedocs.io/en/latest/) to support uploading to various
pluggable backends.

([#3780](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3780))
([#3752](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3752))
([#3759](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3752))
([#3759](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3759))
([#3763](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3763))
- Add a utility to parse the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable.
Add `gen_ai_latest_experimental` as a new value to the Sem Conv stability flag ([#3716](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3716)).
4 changes: 2 additions & 2 deletions util/opentelemetry-util-genai/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ dependencies = [
"opentelemetry-api>=1.31.0",
]

[project.entry-points.opentelemetry_genai_upload_hook]
fsspec = "opentelemetry.util.genai._fsspec_upload:fsspec_upload_hook"
[project.entry-points.opentelemetry_genai_completion_hook]
fsspec_upload = "opentelemetry.util.genai._fsspec_upload:fsspec_completion_upload_hook"

[project.optional-dependencies]
test = ["pytest>=7.0.0"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,27 @@

from os import environ

from opentelemetry.util.genai.completion_hook import (
CompletionHook,
_NoOpCompletionHook,
)
from opentelemetry.util.genai.environment_variables import (
OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH,
)
from opentelemetry.util.genai.upload_hook import UploadHook, _NoOpUploadHook


def fsspec_upload_hook() -> UploadHook:
def fsspec_completion_upload_hook() -> CompletionHook:
# If fsspec is not installed the hook will be a no-op.
try:
# pylint: disable=import-outside-toplevel
from opentelemetry.util.genai._fsspec_upload.fsspec_hook import (
FsspecUploadHook,
from opentelemetry.util.genai._fsspec_upload.completion_hook import (
FsspecUploadCompletionHook,
)
except ImportError:
return _NoOpUploadHook()
return _NoOpCompletionHook()

base_path = environ.get(OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH)
if not base_path:
return _NoOpUploadHook()
return _NoOpCompletionHook()

return FsspecUploadHook(base_path=base_path)
return FsspecUploadCompletionHook(base_path=base_path)
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes
from opentelemetry.trace import Span
from opentelemetry.util.genai import types
from opentelemetry.util.genai.upload_hook import UploadHook
from opentelemetry.util.genai.completion_hook import CompletionHook

GEN_AI_INPUT_MESSAGES_REF: Final = (
gen_ai_attributes.GEN_AI_INPUT_MESSAGES + "_ref"
Expand Down Expand Up @@ -75,12 +75,12 @@ def fsspec_open(urlpath: str, mode: Literal["w"]) -> TextIO:
return cast(TextIO, fsspec.open(urlpath, mode)) # pyright: ignore[reportUnknownMemberType]


class FsspecUploadHook(UploadHook):
"""An upload hook using ``fsspec`` to upload to external storage
class FsspecUploadCompletionHook(CompletionHook):
"""An completion hook using ``fsspec`` to upload to external storage

This function can be used as the
:func:`~opentelemetry.util.genai.upload_hook.load_upload_hook` implementation by
setting :envvar:`OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK` to ``fsspec``.
:func:`~opentelemetry.util.genai.completion_hook.load_completion_hook` implementation by
setting :envvar:`OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK` to ``fsspec_upload``.
:envvar:`OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH` must be configured to specify the
base path for uploads.

Expand Down Expand Up @@ -128,7 +128,7 @@ def done(future: Future[None]) -> None:
fut.add_done_callback(done)
except RuntimeError:
_logger.info(
"attempting to upload file after FsspecUploadHook.shutdown() was already called"
"attempting to upload file after FsspecUploadCompletionHook.shutdown() was already called"
)
self._semaphore.release()

Expand Down Expand Up @@ -161,7 +161,7 @@ def _do_upload(
cls=Base64JsonEncoder,
)

def upload(
def on_completion(
self,
*,
inputs: list[types.InputMessage],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""This module defines the generic hooks for GenAI content uploading
"""This module defines the generic hooks for GenAI content completion

The hooks are specified as part of semconv in `Uploading content to external storage
<https://github.com/open-telemetry/semantic-conventions/blob/v1.37.0/docs/gen-ai/gen-ai-spans.md#uploading-content-to-external-storage>`__.

This module defines the `UploadHook` type that custom implementations should implement, and a
`load_upload_hook` function to load it from an entry point.
This module defines the `CompletionHook` type that custom implementations should implement, and a
`load_completion_hook` function to load it from an entry point.
"""

from __future__ import annotations
Expand All @@ -34,18 +34,18 @@
)
from opentelemetry.util.genai import types
from opentelemetry.util.genai.environment_variables import (
OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK,
OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK,
)

_logger = logging.getLogger(__name__)


@runtime_checkable
class UploadHook(Protocol):
"""A hook to upload GenAI content to an external storage.
class CompletionHook(Protocol):
"""A hook to be called on completion of a GenAI operation.

This is the interface for a hook that can be
used to upload GenAI content to an external storage. The hook is a
used to capture GenAI content on completion. The hook is a
callable that takes the inputs, outputs, and system instruction of a
GenAI interaction, as well as the span and log record associated with
it.
Expand All @@ -66,7 +66,7 @@ class UploadHook(Protocol):
interaction.
"""

def upload(
def on_completion(
self,
*,
inputs: list[types.InputMessage],
Expand All @@ -77,43 +77,47 @@ def upload(
) -> None: ...


class _NoOpUploadHook(UploadHook):
def upload(self, **kwargs: Any) -> None:
class _NoOpCompletionHook(CompletionHook):
def on_completion(self, **kwargs: Any) -> None:
return None


def load_upload_hook() -> UploadHook:
"""Load the upload hook from entry point or return a noop implementation
def load_completion_hook() -> CompletionHook:
"""Load the completion hook from entry point or return a noop implementation

This function loads an upload hook from the entry point group
``opentelemetry_genai_upload_hook`` with name coming from
:envvar:`OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK`. If one can't be found, returns a no-op
This function loads an completion hook from the entry point group
``opentelemetry_genai_completion_hook`` with name coming from
:envvar:`OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK`. If one can't be found, returns a no-op
implementation.
"""
hook_name = environ.get(OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK, None)
hook_name = environ.get(OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK, None)
if not hook_name:
return _NoOpUploadHook()
return _NoOpCompletionHook()

for entry_point in entry_points(group="opentelemetry_genai_upload_hook"): # pyright: ignore[reportUnknownVariableType]
for entry_point in entry_points( # pyright: ignore[reportUnknownVariableType]
group="opentelemetry_genai_completion_hook"
):
name = cast(str, entry_point.name) # pyright: ignore[reportUnknownMemberType]
try:
if hook_name != name:
continue

hook = entry_point.load()() # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
if not isinstance(hook, UploadHook):
_logger.debug("%s is not a valid UploadHook. Using noop", name)
if not isinstance(hook, CompletionHook):
_logger.debug(
"%s is not a valid CompletionHook. Using noop", name
)
continue

_logger.debug("Using UploadHook %s", name)
_logger.debug("Using CompletionHook %s", name)
return hook

except Exception: # pylint: disable=broad-except
_logger.exception(
"UploadHook %s configuration failed. Using noop", name
"CompletionHook %s configuration failed. Using noop", name
)

return _NoOpUploadHook()
return _NoOpCompletionHook()


__all__ = ["UploadHook", "load_upload_hook"]
__all__ = ["CompletionHook", "load_completion_hook"]
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
)

OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK = (
"OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK"
OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK = (
"OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK"
)
"""
.. envvar:: OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK
.. envvar:: OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK
"""

OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH = (
Expand Down
101 changes: 101 additions & 0 deletions util/opentelemetry-util-genai/tests/test_completion_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
from dataclasses import dataclass
from typing import Any, Callable
from unittest import TestCase
from unittest.mock import Mock, patch

from opentelemetry.util.genai.completion_hook import (
CompletionHook,
_NoOpCompletionHook,
load_completion_hook,
)
from opentelemetry.util.genai.environment_variables import (
OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK,
)


class FakeCompletionHook(CompletionHook):
def on_completion(self, **kwargs: Any):
pass


class InvalidCompletionHook:
pass


@dataclass
class FakeEntryPoint:
name: str
load: Callable[[], type[CompletionHook]]


class TestCompletionHook(TestCase):
@patch.dict("os.environ", {})
def test_load_completion_hook_noop(self):
self.assertIsInstance(load_completion_hook(), _NoOpCompletionHook)

@patch(
"opentelemetry.util.genai.completion_hook.entry_points",
)
@patch.dict(
"os.environ", {OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK: "my-hook"}
)
def test_load_completion_hook_custom(self, mock_entry_points: Mock):
mock_entry_points.return_value = [
FakeEntryPoint("my-hook", lambda: FakeCompletionHook)
]

self.assertIsInstance(load_completion_hook(), FakeCompletionHook)

@patch("opentelemetry.util.genai.completion_hook.entry_points")
@patch.dict(
"os.environ", {OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK: "my-hook"}
)
def test_load_completion_hook_invalid(self, mock_entry_points: Mock):
mock_entry_points.return_value = [
FakeEntryPoint("my-hook", lambda: InvalidCompletionHook)
]

with self.assertLogs(level=logging.DEBUG) as logs:
self.assertIsInstance(load_completion_hook(), _NoOpCompletionHook)
self.assertEqual(len(logs.output), 1)
self.assertIn(
"is not a valid CompletionHook. Using noop", logs.output[0]
)

@patch("opentelemetry.util.genai.completion_hook.entry_points")
@patch.dict(
"os.environ", {OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK: "my-hook"}
)
def test_load_completion_hook_error(self, mock_entry_points: Mock):
def load():
raise RuntimeError("error")

mock_entry_points.return_value = [FakeEntryPoint("my-hook", load)]

self.assertIsInstance(load_completion_hook(), _NoOpCompletionHook)

@patch("opentelemetry.util.genai.completion_hook.entry_points")
@patch.dict(
"os.environ", {OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK: "my-hook"}
)
def test_load_completion_hook_not_found(self, mock_entry_points: Mock):
mock_entry_points.return_value = [
FakeEntryPoint("other-hook", lambda: FakeCompletionHook)
]

self.assertIsInstance(load_completion_hook(), _NoOpCompletionHook)
Loading