Skip to content

Commit 7eb9203

Browse files
authored
Define genai content uploader hook and entry point (#3752)
* Get genai utils sphinx docs building * Add genai types and utils for loading an uploader hook
1 parent 843f345 commit 7eb9203

File tree

5 files changed

+259
-0
lines changed

5 files changed

+259
-0
lines changed

docs/conf.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,20 @@
6767
for f in listdir(resource)
6868
if isdir(join(resource, f))
6969
]
70+
util = "../util"
71+
util_dirs = [
72+
os.path.abspath("/".join([util, f, "src"]))
73+
for f in listdir(util)
74+
if isdir(join(util, f))
75+
]
7076
sys.path[:0] = (
7177
exp_dirs
7278
+ instr_dirs
7379
+ instr_genai_dirs
7480
+ sdk_ext_dirs
7581
+ prop_dirs
7682
+ resource_dirs
83+
+ util_dirs
7784
)
7885

7986
# -- Project information -----------------------------------------------------
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
OpenTelemetry Python - GenAI Util
2+
=================================
3+
4+
.. automodule:: opentelemetry.util.genai
5+
:members:
6+
:undoc-members:
7+
:show-inheritance:
8+
9+
.. automodule:: opentelemetry.util.genai.utils
10+
:members:
11+
:undoc-members:
12+
:show-inheritance:
13+
14+
.. automodule:: opentelemetry.util.genai.types
15+
:members:
16+
:undoc-members:
17+
: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: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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 upload(
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+
) -> None: ...
78+
79+
80+
class _NoOpUploadHook(UploadHook):
81+
def upload(self, **kwargs: Any) -> None:
82+
return None
83+
84+
85+
def load_upload_hook() -> UploadHook:
86+
"""Load the upload hook from entry point or return a noop implementation
87+
88+
This function loads an upload hook from the entry point group
89+
``opentelemetry_genai_upload_hook`` with name coming from
90+
:envvar:`OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK`. If one can't be found, returns a no-op
91+
implementation.
92+
"""
93+
hook_name = environ.get(OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK, None)
94+
if not hook_name:
95+
return _NoOpUploadHook()
96+
97+
for entry_point in entry_points(group="opentelemetry_genai_upload_hook"): # pyright: ignore[reportUnknownVariableType]
98+
name = cast(str, entry_point.name) # pyright: ignore[reportUnknownMemberType]
99+
try:
100+
if hook_name != name:
101+
continue
102+
103+
hook = entry_point.load()() # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
104+
if not isinstance(hook, UploadHook):
105+
_logger.debug("%s is not a valid UploadHook. Using noop", name)
106+
continue
107+
108+
_logger.debug("Using UploadHook %s", name)
109+
return hook
110+
111+
except Exception: # pylint: disable=broad-except
112+
_logger.exception(
113+
"UploadHook %s configuration failed. Using noop", name
114+
)
115+
116+
return _NoOpUploadHook()
117+
118+
119+
__all__ = ["UploadHook", "load_upload_hook"]
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
import logging
16+
from dataclasses import dataclass
17+
from typing import Any, Callable
18+
from unittest import TestCase
19+
from unittest.mock import Mock, patch
20+
21+
from opentelemetry.util.genai.environment_variables import (
22+
OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK,
23+
)
24+
from opentelemetry.util.genai.upload_hook import (
25+
UploadHook,
26+
_NoOpUploadHook,
27+
load_upload_hook,
28+
)
29+
30+
31+
class FakeUploadHook(UploadHook):
32+
def upload(self, **kwargs: Any):
33+
pass
34+
35+
36+
class InvalidUploadHook:
37+
pass
38+
39+
40+
@dataclass
41+
class FakeEntryPoint:
42+
name: str
43+
load: Callable[[], type[UploadHook]]
44+
45+
46+
class TestUploadHook(TestCase):
47+
@patch.dict("os.environ", {})
48+
def test_load_upload_hook_noop(self):
49+
self.assertIsInstance(load_upload_hook(), _NoOpUploadHook)
50+
51+
@patch(
52+
"opentelemetry.util.genai.upload_hook.entry_points",
53+
)
54+
@patch.dict(
55+
"os.environ", {OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK: "my-hook"}
56+
)
57+
def test_load_upload_hook_custom(self, mock_entry_points: Mock):
58+
mock_entry_points.return_value = [
59+
FakeEntryPoint("my-hook", lambda: FakeUploadHook)
60+
]
61+
62+
self.assertIsInstance(load_upload_hook(), FakeUploadHook)
63+
64+
@patch("opentelemetry.util.genai.upload_hook.entry_points")
65+
@patch.dict(
66+
"os.environ", {OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK: "my-hook"}
67+
)
68+
def test_load_upload_hook_invalid(self, mock_entry_points: Mock):
69+
mock_entry_points.return_value = [
70+
FakeEntryPoint("my-hook", lambda: InvalidUploadHook)
71+
]
72+
73+
with self.assertLogs(level=logging.DEBUG) as logs:
74+
self.assertIsInstance(load_upload_hook(), _NoOpUploadHook)
75+
self.assertEqual(len(logs.output), 1)
76+
self.assertIn("is not a valid UploadHook. Using noop", logs.output[0])
77+
78+
@patch("opentelemetry.util.genai.upload_hook.entry_points")
79+
@patch.dict(
80+
"os.environ", {OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK: "my-hook"}
81+
)
82+
def test_load_upload_hook_error(self, mock_entry_points: Mock):
83+
def load():
84+
raise RuntimeError("error")
85+
86+
mock_entry_points.return_value = [FakeEntryPoint("my-hook", load)]
87+
88+
self.assertIsInstance(load_upload_hook(), _NoOpUploadHook)
89+
90+
@patch("opentelemetry.util.genai.upload_hook.entry_points")
91+
@patch.dict(
92+
"os.environ", {OTEL_INSTRUMENTATION_GENAI_UPLOAD_HOOK: "my-hook"}
93+
)
94+
def test_load_upload_hook_not_found(self, mock_entry_points: Mock):
95+
mock_entry_points.return_value = [
96+
FakeEntryPoint("other-hook", lambda: FakeUploadHook)
97+
]
98+
99+
self.assertIsInstance(load_upload_hook(), _NoOpUploadHook)

0 commit comments

Comments
 (0)