splunk-otel-util-genai 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opentelemetry/util/genai/__init__.py +17 -0
- opentelemetry/util/genai/_fsspec_upload/__init__.py +39 -0
- opentelemetry/util/genai/_fsspec_upload/fsspec_hook.py +184 -0
- opentelemetry/util/genai/attributes.py +60 -0
- opentelemetry/util/genai/callbacks.py +24 -0
- opentelemetry/util/genai/config.py +184 -0
- opentelemetry/util/genai/debug.py +183 -0
- opentelemetry/util/genai/emitters/__init__.py +25 -0
- opentelemetry/util/genai/emitters/composite.py +186 -0
- opentelemetry/util/genai/emitters/configuration.py +324 -0
- opentelemetry/util/genai/emitters/content_events.py +153 -0
- opentelemetry/util/genai/emitters/evaluation.py +519 -0
- opentelemetry/util/genai/emitters/metrics.py +308 -0
- opentelemetry/util/genai/emitters/span.py +774 -0
- opentelemetry/util/genai/emitters/spec.py +48 -0
- opentelemetry/util/genai/emitters/utils.py +961 -0
- opentelemetry/util/genai/environment_variables.py +200 -0
- opentelemetry/util/genai/handler.py +1002 -0
- opentelemetry/util/genai/instruments.py +44 -0
- opentelemetry/util/genai/interfaces.py +58 -0
- opentelemetry/util/genai/plugins.py +114 -0
- opentelemetry/util/genai/span_context.py +80 -0
- opentelemetry/util/genai/types.py +440 -0
- opentelemetry/util/genai/upload_hook.py +119 -0
- opentelemetry/util/genai/utils.py +182 -0
- opentelemetry/util/genai/version.py +15 -0
- splunk_otel_util_genai-0.1.3.dist-info/METADATA +70 -0
- splunk_otel_util_genai-0.1.3.dist-info/RECORD +31 -0
- splunk_otel_util_genai-0.1.3.dist-info/WHEEL +4 -0
- splunk_otel_util_genai-0.1.3.dist-info/entry_points.txt +5 -0
- splunk_otel_util_genai-0.1.3.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,44 @@
|
|
|
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 opentelemetry.metrics import Histogram, Meter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Instruments:
|
|
19
|
+
"""
|
|
20
|
+
Manages OpenTelemetry metrics instruments for GenAI telemetry.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, meter: Meter):
|
|
24
|
+
self.operation_duration_histogram: Histogram = meter.create_histogram(
|
|
25
|
+
name="gen_ai.client.operation.duration",
|
|
26
|
+
unit="s",
|
|
27
|
+
description="Duration of GenAI client operations",
|
|
28
|
+
)
|
|
29
|
+
self.token_usage_histogram: Histogram = meter.create_histogram(
|
|
30
|
+
name="gen_ai.client.token.usage",
|
|
31
|
+
unit="{token}",
|
|
32
|
+
description="Number of input and output tokens used",
|
|
33
|
+
)
|
|
34
|
+
# Agentic AI metrics
|
|
35
|
+
self.workflow_duration_histogram: Histogram = meter.create_histogram(
|
|
36
|
+
name="gen_ai.workflow.duration",
|
|
37
|
+
unit="s",
|
|
38
|
+
description="Duration of GenAI workflows",
|
|
39
|
+
)
|
|
40
|
+
self.agent_duration_histogram: Histogram = meter.create_histogram(
|
|
41
|
+
name="gen_ai.agent.duration",
|
|
42
|
+
unit="s",
|
|
43
|
+
description="Duration of agent operations",
|
|
44
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Phase 1 refactor: introduce lightweight protocol-style interfaces so future
|
|
2
|
+
# composite generator + plugin system can rely on a stable narrow contract.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, Sequence, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from .types import Error, EvaluationResult, LLMInvocation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class EmitterProtocol(Protocol):
|
|
12
|
+
"""Protocol implemented by all telemetry emitters.
|
|
13
|
+
|
|
14
|
+
Accepts any GenAI domain object (LLMInvocation, EmbeddingInvocation, etc.).
|
|
15
|
+
Implementations MAY ignore objects of unsupported types.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def on_start(self, obj: Any) -> None: # pragma: no cover - structural
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def on_end(self, obj: Any) -> None: # pragma: no cover - structural
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def on_error(
|
|
25
|
+
self, error: Error, obj: Any
|
|
26
|
+
) -> None: # pragma: no cover - structural
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def on_evaluation_results(
|
|
30
|
+
self, results: Sequence[EvaluationResult], obj: Any | None = None
|
|
31
|
+
) -> None: # pragma: no cover - structural
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class EvaluatorProtocol(Protocol):
|
|
37
|
+
"""Protocol for evaluator objects (future phases may broaden)."""
|
|
38
|
+
|
|
39
|
+
def evaluate(
|
|
40
|
+
self, invocation: LLMInvocation
|
|
41
|
+
) -> Any: # pragma: no cover - structural
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EmitterMeta:
|
|
46
|
+
"""Simple metadata mixin for emitters (role/name used by future plugin system)."""
|
|
47
|
+
|
|
48
|
+
role: str = "span" # default / legacy generators are span focused
|
|
49
|
+
name: str = "legacy"
|
|
50
|
+
override: bool = False
|
|
51
|
+
|
|
52
|
+
def handles(self, obj: Any) -> bool: # pragma: no cover (trivial)
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
def on_evaluation_results(
|
|
56
|
+
self, results: Sequence[EvaluationResult], obj: Any | None = None
|
|
57
|
+
) -> None: # pragma: no cover - default no-op
|
|
58
|
+
return None
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Iterable, Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
from opentelemetry.util._importlib_metadata import (
|
|
7
|
+
entry_points, # pyright: ignore[reportUnknownVariableType]
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from .emitters.spec import EmitterSpec
|
|
11
|
+
|
|
12
|
+
_logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_emitter_specs(
|
|
16
|
+
names: Sequence[str] | None = None,
|
|
17
|
+
) -> list[EmitterSpec]:
|
|
18
|
+
"""Load emitter specs declared under the ``opentelemetry_util_genai_emitters`` entry point.
|
|
19
|
+
|
|
20
|
+
Entry points should return an iterable of :class:`EmitterSpec` instances or dictionaries
|
|
21
|
+
matching the ``EmitterSpec`` constructor signature. When ``names`` is provided, only
|
|
22
|
+
entry points whose name matches (case-insensitive) the selection are loaded.
|
|
23
|
+
Legacy group support has been removed; vendor packages must migrate to the new group.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
selected = {name.lower() for name in names} if names else None
|
|
27
|
+
loaded_specs: list[EmitterSpec] = []
|
|
28
|
+
seen: set[str] = set()
|
|
29
|
+
# Primary (new) group
|
|
30
|
+
for ep in entry_points(group="opentelemetry_util_genai_emitters"):
|
|
31
|
+
ep_name = getattr(ep, "name", "")
|
|
32
|
+
seen.add(ep_name.lower())
|
|
33
|
+
if selected and ep_name.lower() not in selected:
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
provider = ep.load()
|
|
37
|
+
except Exception: # pragma: no cover - defensive
|
|
38
|
+
_logger.exception("Emitter entry point %s failed to load", ep_name)
|
|
39
|
+
continue
|
|
40
|
+
try:
|
|
41
|
+
loaded_specs.extend(_coerce_to_specs(provider, ep_name))
|
|
42
|
+
except Exception: # pragma: no cover - defensive
|
|
43
|
+
_logger.exception(
|
|
44
|
+
"Emitter entry point %s returned an unsupported value", ep_name
|
|
45
|
+
)
|
|
46
|
+
# Silent legacy fallback (temporary for transition/tests). Only consult if specific names requested
|
|
47
|
+
# or if no specs loaded yet and legacy group is present.
|
|
48
|
+
if (selected and loaded_specs) or (not selected and loaded_specs):
|
|
49
|
+
pass # already satisfied
|
|
50
|
+
else:
|
|
51
|
+
try:
|
|
52
|
+
for ep in entry_points(group="opentelemetry_genai_emitters"):
|
|
53
|
+
ep_name = getattr(ep, "name", "")
|
|
54
|
+
if ep_name.lower() in seen:
|
|
55
|
+
continue
|
|
56
|
+
if selected and ep_name.lower() not in selected:
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
provider = ep.load()
|
|
60
|
+
except Exception: # pragma: no cover - defensive
|
|
61
|
+
_logger.exception(
|
|
62
|
+
"(legacy group) Emitter entry point %s failed to load",
|
|
63
|
+
ep_name,
|
|
64
|
+
)
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
loaded_specs.extend(_coerce_to_specs(provider, ep_name))
|
|
68
|
+
except Exception: # pragma: no cover - defensive
|
|
69
|
+
_logger.exception(
|
|
70
|
+
"(legacy group) Emitter entry point %s returned an unsupported value",
|
|
71
|
+
ep_name,
|
|
72
|
+
)
|
|
73
|
+
except Exception: # pragma: no cover - defensive
|
|
74
|
+
_logger.debug("Legacy emitter entry point group not available")
|
|
75
|
+
if selected:
|
|
76
|
+
missing = selected - seen
|
|
77
|
+
for name in missing:
|
|
78
|
+
_logger.debug("Emitter entry point '%s' was not found", name)
|
|
79
|
+
return loaded_specs
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _coerce_to_specs(provider: object, source: str) -> list[EmitterSpec]:
|
|
83
|
+
if provider is None:
|
|
84
|
+
return []
|
|
85
|
+
if callable(provider):
|
|
86
|
+
return _coerce_to_specs(provider(), source)
|
|
87
|
+
if isinstance(provider, EmitterSpec):
|
|
88
|
+
return [provider]
|
|
89
|
+
if isinstance(provider, Mapping):
|
|
90
|
+
return [_mapping_to_spec(provider, source)]
|
|
91
|
+
if isinstance(provider, Iterable):
|
|
92
|
+
specs: list[EmitterSpec] = []
|
|
93
|
+
for item in provider:
|
|
94
|
+
if isinstance(item, EmitterSpec):
|
|
95
|
+
specs.append(item)
|
|
96
|
+
elif isinstance(item, Mapping):
|
|
97
|
+
specs.append(_mapping_to_spec(item, source))
|
|
98
|
+
else:
|
|
99
|
+
raise TypeError(
|
|
100
|
+
f"Unsupported emitter spec element {item!r} from {source}"
|
|
101
|
+
)
|
|
102
|
+
return specs
|
|
103
|
+
raise TypeError(
|
|
104
|
+
f"Unsupported emitter spec provider {provider!r} from {source}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _mapping_to_spec(data: Mapping[str, object], source: str) -> EmitterSpec:
|
|
109
|
+
if "factory" not in data:
|
|
110
|
+
raise ValueError(f"Emitter spec from {source} must define a factory")
|
|
111
|
+
return EmitterSpec(**data) # type: ignore[arg-type]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = ["load_emitter_specs"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Tuple
|
|
4
|
+
|
|
5
|
+
from opentelemetry import trace
|
|
6
|
+
from opentelemetry.trace import NonRecordingSpan, Span, SpanContext
|
|
7
|
+
|
|
8
|
+
from .types import GenAI
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def extract_span_context(span: Span | Any | None) -> SpanContext | None:
|
|
12
|
+
"""Return a SpanContext from the given span-like object if available."""
|
|
13
|
+
|
|
14
|
+
if span is None:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
context = getattr(span, "context", None)
|
|
18
|
+
if context is not None and getattr(context, "trace_id", 0):
|
|
19
|
+
return context
|
|
20
|
+
|
|
21
|
+
get_ctx = getattr(span, "get_span_context", None)
|
|
22
|
+
if callable(get_ctx):
|
|
23
|
+
try:
|
|
24
|
+
context = get_ctx()
|
|
25
|
+
except Exception: # pragma: no cover - defensive
|
|
26
|
+
context = None
|
|
27
|
+
if context is not None and getattr(context, "trace_id", 0):
|
|
28
|
+
return context
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def store_span_context(target: GenAI, context: SpanContext | None) -> None:
|
|
33
|
+
"""Persist span context metadata on the GenAI invocation."""
|
|
34
|
+
|
|
35
|
+
if context is None:
|
|
36
|
+
return
|
|
37
|
+
if not getattr(context, "trace_id", 0):
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
target.span_context = context
|
|
41
|
+
target.trace_id = getattr(context, "trace_id", None)
|
|
42
|
+
target.span_id = getattr(context, "span_id", None)
|
|
43
|
+
trace_flags = getattr(context, "trace_flags", None)
|
|
44
|
+
if trace_flags is not None:
|
|
45
|
+
try:
|
|
46
|
+
target.trace_flags = int(trace_flags)
|
|
47
|
+
except Exception: # pragma: no cover - defensive
|
|
48
|
+
target.trace_flags = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def span_context_hex_ids(
|
|
52
|
+
context: SpanContext | None,
|
|
53
|
+
) -> Tuple[str | None, str | None]:
|
|
54
|
+
"""Return hexadecimal trace/span identifiers."""
|
|
55
|
+
|
|
56
|
+
if context is None or not getattr(context, "trace_id", 0):
|
|
57
|
+
return None, None
|
|
58
|
+
trace_id = f"{context.trace_id:032x}"
|
|
59
|
+
span_id = f"{context.span_id:016x}"
|
|
60
|
+
return trace_id, span_id
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_otel_context(
|
|
64
|
+
span: Span | None,
|
|
65
|
+
context: SpanContext | None,
|
|
66
|
+
) -> trace.Context | None:
|
|
67
|
+
"""Return an OpenTelemetry Context carrying a span or span_context."""
|
|
68
|
+
|
|
69
|
+
if span is not None:
|
|
70
|
+
try:
|
|
71
|
+
return trace.set_span_in_context(span)
|
|
72
|
+
except Exception: # pragma: no cover - defensive
|
|
73
|
+
pass
|
|
74
|
+
if context is not None:
|
|
75
|
+
try:
|
|
76
|
+
non_recording = NonRecordingSpan(context)
|
|
77
|
+
return trace.set_span_in_context(non_recording)
|
|
78
|
+
except Exception: # pragma: no cover - defensive
|
|
79
|
+
return None
|
|
80
|
+
return None
|