Skip to content

Commit 01cf2d9

Browse files
committed
Add genai types and utils for loading an uploader hook
1 parent 954b79d commit 01cf2d9

File tree

4 files changed

+217
-0
lines changed

4 files changed

+217
-0
lines changed

docs/instrumentation-genai/util.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,13 @@ OpenTelemetry Python - GenAI Util
1515
:members:
1616
:undoc-members:
1717
:show-inheritance:
18+
19+
.. automodule:: opentelemetry.util.genai.environment_variables
20+
:members:
21+
:undoc-members:
22+
:show-inheritance:
23+
24+
.. automodule:: opentelemetry.util.genai.upload_hook
25+
:members:
26+
:undoc-members:
27+
:show-inheritance:

util/opentelemetry-util-genai/src/opentelemetry/util/genai/environment_variables.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,10 @@
1515
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = (
1616
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
1717
)
18+
19+
OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK = (
20+
"OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK"
21+
)
22+
"""
23+
.. envvar:: OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK
24+
"""
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""This module defines the generic hooks for GenAI content uploading
16+
17+
The hooks are specified as part of semconv in `Uploading content to external storage
18+
<https://github.com/open-telemetry/semantic-conventions/blob/v1.37.0/docs/gen-ai/gen-ai-spans.md#uploading-content-to-external-storage>`__.
19+
20+
This module defines the `UploadHook` type that custom implementations should implement, and a
21+
`load_upload_hook` function to load it from an entry point.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import logging
27+
from os import environ
28+
from typing import Any, Protocol, cast, runtime_checkable
29+
30+
from opentelemetry._logs import LogRecord
31+
from opentelemetry.trace import Span
32+
from opentelemetry.util._importlib_metadata import (
33+
entry_points, # pyright: ignore[reportUnknownVariableType]
34+
)
35+
from opentelemetry.util.genai import types
36+
from opentelemetry.util.genai.environment_variables import (
37+
OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK,
38+
)
39+
40+
_logger = logging.getLogger(__name__)
41+
42+
43+
@runtime_checkable
44+
class UploadHook(Protocol):
45+
"""A hook to upload GenAI content to an external storage.
46+
47+
This is the interface for a hook that can be
48+
used to upload GenAI content to an external storage. The hook is a
49+
callable that takes the inputs, outputs, and system instruction of a
50+
GenAI interaction, as well as the span and log record associated with
51+
it.
52+
53+
The hook can be used to upload the content to any external storage,
54+
such as a database, a file system, or a cloud storage service.
55+
56+
The span and log_record arguments should be provided based on the content capturing mode
57+
:func:`~opentelemetry.util.genai.utils.get_content_capturing_mode`.
58+
59+
Args:
60+
inputs: The inputs of the GenAI interaction.
61+
outputs: The outputs of the GenAI interaction.
62+
system_instruction: The system instruction of the GenAI
63+
interaction.
64+
span: The span associated with the GenAI interaction.
65+
log_record: The event log associated with the GenAI
66+
interaction.
67+
"""
68+
69+
def __call__(
70+
self,
71+
*,
72+
inputs: list[types.InputMessage],
73+
outputs: list[types.OutputMessage],
74+
system_instruction: list[types.MessagePart],
75+
span: Span | None = None,
76+
log_record: LogRecord | None = None,
77+
**kwargs: Any,
78+
) -> None: ...
79+
80+
81+
def _no_op_upload_hook(**kwargs: Any) -> None:
82+
return None
83+
84+
85+
_: UploadHook = _no_op_upload_hook
86+
87+
88+
def load_upload_hook() -> UploadHook:
89+
"""Load the upload hook from entry point or return a noop implementation
90+
91+
This function loads an upload hook from the entry point group
92+
``opentelemetry_genai_upload_hook`` with name coming from
93+
:envvar:`OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK`. If one can't be found, returns a no-op
94+
implementation.
95+
"""
96+
hook_name = environ.get(OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK, None)
97+
if not hook_name:
98+
return _no_op_upload_hook
99+
100+
for entry_point in entry_points(group="opentelemetry_genai_upload_hook"): # pyright: ignore[reportUnknownVariableType]
101+
name = cast(str, entry_point.name) # pyright: ignore[reportUnknownMemberType]
102+
try:
103+
if hook_name != name:
104+
continue
105+
106+
hook = entry_point.load() # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
107+
if not isinstance(hook, UploadHook):
108+
_logger.debug("%s is not a valid UploadHook. Using noop", name)
109+
continue
110+
111+
_logger.debug("Using UploadHook %s", name)
112+
return hook
113+
114+
except Exception: # pylint: disable=broad-except
115+
_logger.exception(
116+
"UploadHook %s configuration failed. Using noop", name
117+
)
118+
119+
return _no_op_upload_hook
120+
121+
122+
__all__ = ["UploadHook", "load_upload_hook"]
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from unittest import TestCase
16+
from unittest.mock import MagicMock, Mock, patch
17+
18+
from opentelemetry.util.genai._upload_hook import (
19+
_no_op_upload_hook,
20+
load_upload_hook,
21+
)
22+
from opentelemetry.util.genai.environment_variables import (
23+
OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK,
24+
)
25+
26+
27+
class TestUploadHook(TestCase):
28+
@patch.dict("os.environ", {})
29+
def test_load_upload_hook_noop(self):
30+
self.assertIs(load_upload_hook(), _no_op_upload_hook)
31+
32+
@patch("opentelemetry.util.genai._upload_hook.entry_points")
33+
@patch.dict(
34+
"os.environ", {OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK: "my-hook"}
35+
)
36+
def test_load_upload_hook_custom(self, mock_entry_points: Mock):
37+
mock_hook = MagicMock()
38+
mock_entry_point = MagicMock()
39+
mock_entry_point.name = "my-hook"
40+
mock_entry_point.load.return_value = mock_hook
41+
mock_entry_points.return_value = [mock_entry_point]
42+
43+
self.assertIs(load_upload_hook(), mock_hook)
44+
45+
@patch("opentelemetry.util.genai._upload_hook.entry_points")
46+
@patch.dict(
47+
"os.environ", {OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK: "my-hook"}
48+
)
49+
def test_load_upload_hook_invalid(self, mock_entry_points: Mock):
50+
mock_entry_point = MagicMock()
51+
mock_entry_point.name = "my-hook"
52+
mock_entry_point.load.return_value = object()
53+
mock_entry_points.return_value = [mock_entry_point]
54+
55+
self.assertIs(load_upload_hook(), _no_op_upload_hook)
56+
57+
@patch("opentelemetry.util.genai._upload_hook.entry_points")
58+
@patch.dict(
59+
"os.environ", {OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK: "my-hook"}
60+
)
61+
def test_load_upload_hook_error(self, mock_entry_points: Mock):
62+
mock_entry_point = MagicMock()
63+
mock_entry_point.name = "my-hook"
64+
mock_entry_point.load.side_effect = Exception("error")
65+
mock_entry_points.return_value = [mock_entry_point]
66+
67+
self.assertIs(load_upload_hook(), _no_op_upload_hook)
68+
69+
@patch("opentelemetry.util.genai._upload_hook.entry_points")
70+
@patch.dict(
71+
"os.environ", {OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK: "my-hook"}
72+
)
73+
def test_load_upload_hook_not_found(self, mock_entry_points: Mock):
74+
mock_entry_point = MagicMock()
75+
mock_entry_point.name = "other-hook"
76+
mock_entry_points.return_value = [mock_entry_point]
77+
78+
self.assertIs(load_upload_hook(), _no_op_upload_hook)

0 commit comments

Comments
 (0)