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,25 @@
|
|
|
1
|
+
"""Emitter package consolidating all telemetry signal emitters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pkgutil import extend_path
|
|
6
|
+
|
|
7
|
+
__path__ = extend_path(__path__, __name__)
|
|
8
|
+
|
|
9
|
+
from .composite import CompositeEmitter # noqa: F401
|
|
10
|
+
from .content_events import ContentEventsEmitter # noqa: F401
|
|
11
|
+
from .evaluation import ( # noqa: F401
|
|
12
|
+
EvaluationEventsEmitter,
|
|
13
|
+
EvaluationMetricsEmitter,
|
|
14
|
+
)
|
|
15
|
+
from .metrics import MetricsEmitter # noqa: F401
|
|
16
|
+
from .span import SpanEmitter # noqa: F401
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"SpanEmitter",
|
|
20
|
+
"MetricsEmitter",
|
|
21
|
+
"ContentEventsEmitter",
|
|
22
|
+
"CompositeEmitter",
|
|
23
|
+
"EvaluationMetricsEmitter",
|
|
24
|
+
"EvaluationEventsEmitter",
|
|
25
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Iterable, Iterator, Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
from ..debug import genai_debug_log
|
|
7
|
+
from ..interfaces import EmitterMeta, EmitterProtocol
|
|
8
|
+
from ..types import Error, EvaluationResult, GenAI
|
|
9
|
+
|
|
10
|
+
_LOGGER = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_CATEGORY_START_ORDER: Sequence[str] = ("span", "metrics", "content_events")
|
|
13
|
+
_CATEGORY_END_ORDER: Sequence[str] = (
|
|
14
|
+
"evaluation",
|
|
15
|
+
"metrics",
|
|
16
|
+
"content_events",
|
|
17
|
+
"span",
|
|
18
|
+
)
|
|
19
|
+
_EVALUATION_CATEGORY = "evaluation"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CompositeEmitter(EmitterMeta):
|
|
23
|
+
"""Category-aware orchestrator for GenAI emitters.
|
|
24
|
+
|
|
25
|
+
Emitters are grouped by category to allow targeted replacement/augmentation while
|
|
26
|
+
preserving ordering guarantees:
|
|
27
|
+
|
|
28
|
+
* ``span`` emitters run first on ``on_start`` and last on ``on_end``/``on_error``
|
|
29
|
+
* ``metrics`` emitters run before content emitters at the end of an invocation
|
|
30
|
+
* ``content_events`` emitters observe invocations after metrics but before the
|
|
31
|
+
final span closure
|
|
32
|
+
* ``evaluation`` emitters observe ``on_evaluation_results`` and receive ``on_end``/``on_error`` for flush-style behaviour
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
role = "composite"
|
|
36
|
+
name = "composite"
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
span_emitters: Iterable[EmitterProtocol] | None = None,
|
|
42
|
+
metrics_emitters: Iterable[EmitterProtocol] | None = None,
|
|
43
|
+
content_event_emitters: Iterable[EmitterProtocol] | None = None,
|
|
44
|
+
evaluation_emitters: Iterable[EmitterProtocol] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._categories: dict[str, list[EmitterProtocol]] = {
|
|
47
|
+
"span": list(span_emitters or []),
|
|
48
|
+
"metrics": list(metrics_emitters or []),
|
|
49
|
+
"content_events": list(content_event_emitters or []),
|
|
50
|
+
_EVALUATION_CATEGORY: list(evaluation_emitters or []),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Public API used by the handler lifecycle
|
|
55
|
+
|
|
56
|
+
def on_start(self, obj: Any) -> None: # type: ignore[override]
|
|
57
|
+
self._dispatch(_CATEGORY_START_ORDER, "on_start", obj=obj)
|
|
58
|
+
|
|
59
|
+
def on_end(self, obj: Any) -> None: # type: ignore[override]
|
|
60
|
+
self._dispatch(_CATEGORY_END_ORDER, "on_end", obj=obj)
|
|
61
|
+
|
|
62
|
+
def on_error(self, error: Error, obj: Any) -> None: # type: ignore[override]
|
|
63
|
+
self._dispatch(_CATEGORY_END_ORDER, "on_error", obj=obj, error=error)
|
|
64
|
+
|
|
65
|
+
def on_evaluation_results(
|
|
66
|
+
self,
|
|
67
|
+
results: Sequence[EvaluationResult],
|
|
68
|
+
obj: Any | None = None,
|
|
69
|
+
) -> None: # type: ignore[override]
|
|
70
|
+
if not results:
|
|
71
|
+
genai_debug_log("emitter.on_evaluation_results.empty", obj)
|
|
72
|
+
return
|
|
73
|
+
self._dispatch(
|
|
74
|
+
(_EVALUATION_CATEGORY,),
|
|
75
|
+
"on_evaluation_results",
|
|
76
|
+
obj=obj,
|
|
77
|
+
results=results,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Introspection helpers used during configuration refresh
|
|
82
|
+
|
|
83
|
+
def iter_emitters(
|
|
84
|
+
self, categories: Sequence[str] | None = None
|
|
85
|
+
) -> Iterator[EmitterProtocol]:
|
|
86
|
+
names = categories or (
|
|
87
|
+
"span",
|
|
88
|
+
"metrics",
|
|
89
|
+
"content_events",
|
|
90
|
+
_EVALUATION_CATEGORY,
|
|
91
|
+
)
|
|
92
|
+
for name in names:
|
|
93
|
+
for emitter in self._categories.get(name, []):
|
|
94
|
+
yield emitter
|
|
95
|
+
|
|
96
|
+
def emitters_for(self, category: str) -> Sequence[EmitterProtocol]:
|
|
97
|
+
return self._categories.get(category, [])
|
|
98
|
+
|
|
99
|
+
def categories(self) -> Mapping[str, Sequence[EmitterProtocol]]:
|
|
100
|
+
return self._categories
|
|
101
|
+
|
|
102
|
+
def add_emitter(self, category: str, emitter: EmitterProtocol) -> None:
|
|
103
|
+
self._categories.setdefault(category, []).append(emitter)
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Internal helpers
|
|
107
|
+
|
|
108
|
+
def _dispatch(
|
|
109
|
+
self,
|
|
110
|
+
categories: Sequence[str],
|
|
111
|
+
method_name: str,
|
|
112
|
+
*,
|
|
113
|
+
obj: Any | None = None,
|
|
114
|
+
error: Error | None = None,
|
|
115
|
+
results: Sequence[EvaluationResult] | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
try:
|
|
118
|
+
genai_debug_log(
|
|
119
|
+
"composite.dispatch.begin",
|
|
120
|
+
obj if isinstance(obj, GenAI) else None,
|
|
121
|
+
method=method_name,
|
|
122
|
+
categories=list(categories),
|
|
123
|
+
result_count=len(results or ()),
|
|
124
|
+
)
|
|
125
|
+
except Exception: # pragma: no cover - defensive
|
|
126
|
+
pass
|
|
127
|
+
for category in categories:
|
|
128
|
+
emitters = self._categories.get(category)
|
|
129
|
+
if not emitters:
|
|
130
|
+
continue
|
|
131
|
+
for emitter in list(emitters):
|
|
132
|
+
handler = getattr(emitter, method_name, None)
|
|
133
|
+
if handler is None:
|
|
134
|
+
continue
|
|
135
|
+
if method_name == "on_evaluation_results":
|
|
136
|
+
args = (results or (), obj)
|
|
137
|
+
target = obj
|
|
138
|
+
elif method_name == "on_error":
|
|
139
|
+
args = (error, obj)
|
|
140
|
+
target = obj
|
|
141
|
+
else:
|
|
142
|
+
args = (obj,)
|
|
143
|
+
target = obj
|
|
144
|
+
try:
|
|
145
|
+
handles = getattr(emitter, "handles", None)
|
|
146
|
+
if handles is not None and target is not None:
|
|
147
|
+
if not handles(target):
|
|
148
|
+
try:
|
|
149
|
+
genai_debug_log(
|
|
150
|
+
"composite.dispatch.skip",
|
|
151
|
+
target
|
|
152
|
+
if isinstance(target, GenAI)
|
|
153
|
+
else None,
|
|
154
|
+
method=method_name,
|
|
155
|
+
category=category,
|
|
156
|
+
emitter=getattr(
|
|
157
|
+
emitter, "name", repr(emitter)
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
except Exception: # pragma: no cover
|
|
161
|
+
pass
|
|
162
|
+
continue
|
|
163
|
+
genai_debug_log(
|
|
164
|
+
"composite.dispatch.emit",
|
|
165
|
+
target if isinstance(target, GenAI) else None,
|
|
166
|
+
method=method_name,
|
|
167
|
+
category=category,
|
|
168
|
+
emitter=getattr(emitter, "name", repr(emitter)),
|
|
169
|
+
)
|
|
170
|
+
handler(*args)
|
|
171
|
+
except Exception: # pragma: no cover - defensive
|
|
172
|
+
_LOGGER.debug(
|
|
173
|
+
"Emitter %s failed during %s for category %s",
|
|
174
|
+
getattr(emitter, "name", repr(emitter)),
|
|
175
|
+
method_name,
|
|
176
|
+
category,
|
|
177
|
+
exc_info=True,
|
|
178
|
+
)
|
|
179
|
+
try:
|
|
180
|
+
genai_debug_log(
|
|
181
|
+
"composite.dispatch.end",
|
|
182
|
+
obj if isinstance(obj, GenAI) else None,
|
|
183
|
+
method=method_name,
|
|
184
|
+
)
|
|
185
|
+
except Exception: # pragma: no cover - defensive
|
|
186
|
+
pass
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from types import MethodType
|
|
6
|
+
from typing import Any, Dict, Iterable, List, Sequence
|
|
7
|
+
|
|
8
|
+
from ..config import Settings
|
|
9
|
+
from ..interfaces import EmitterProtocol
|
|
10
|
+
from ..plugins import load_emitter_specs
|
|
11
|
+
from ..types import ContentCapturingMode
|
|
12
|
+
from .composite import CompositeEmitter
|
|
13
|
+
from .content_events import ContentEventsEmitter
|
|
14
|
+
from .evaluation import EvaluationEventsEmitter, EvaluationMetricsEmitter
|
|
15
|
+
from .metrics import MetricsEmitter
|
|
16
|
+
from .span import SpanEmitter
|
|
17
|
+
from .spec import CategoryOverride, EmitterFactoryContext, EmitterSpec
|
|
18
|
+
|
|
19
|
+
_logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_CATEGORY_SPAN = "span"
|
|
22
|
+
_CATEGORY_METRICS = "metrics"
|
|
23
|
+
_CATEGORY_CONTENT = "content_events"
|
|
24
|
+
_CATEGORY_EVALUATION = "evaluation"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CaptureControl:
|
|
29
|
+
span_allowed: bool
|
|
30
|
+
span_initial: bool
|
|
31
|
+
events_initial: bool
|
|
32
|
+
mode: ContentCapturingMode
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_emitter_pipeline(
|
|
36
|
+
*,
|
|
37
|
+
tracer: Any,
|
|
38
|
+
meter: Any,
|
|
39
|
+
event_logger: Any,
|
|
40
|
+
content_logger: Any,
|
|
41
|
+
evaluation_histogram: Any,
|
|
42
|
+
settings: Settings,
|
|
43
|
+
) -> tuple[CompositeEmitter, CaptureControl]:
|
|
44
|
+
"""Construct the CompositeEmitter and capture control metadata."""
|
|
45
|
+
|
|
46
|
+
span_allowed = (
|
|
47
|
+
settings.capture_messages_override
|
|
48
|
+
or settings.legacy_capture_request
|
|
49
|
+
or not settings.enable_content_events
|
|
50
|
+
)
|
|
51
|
+
span_initial = span_allowed and settings.capture_messages_mode in (
|
|
52
|
+
ContentCapturingMode.SPAN_ONLY,
|
|
53
|
+
ContentCapturingMode.SPAN_AND_EVENT,
|
|
54
|
+
)
|
|
55
|
+
events_initial = settings.enable_content_events and (
|
|
56
|
+
settings.capture_messages_mode
|
|
57
|
+
in (
|
|
58
|
+
ContentCapturingMode.EVENT_ONLY,
|
|
59
|
+
ContentCapturingMode.SPAN_AND_EVENT,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
context = EmitterFactoryContext(
|
|
64
|
+
tracer=tracer,
|
|
65
|
+
meter=meter,
|
|
66
|
+
event_logger=event_logger,
|
|
67
|
+
content_logger=content_logger,
|
|
68
|
+
evaluation_histogram=evaluation_histogram,
|
|
69
|
+
capture_span_content=span_initial,
|
|
70
|
+
capture_event_content=events_initial,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
category_specs: Dict[str, List[EmitterSpec]] = {
|
|
74
|
+
_CATEGORY_SPAN: [],
|
|
75
|
+
_CATEGORY_METRICS: [],
|
|
76
|
+
_CATEGORY_CONTENT: [],
|
|
77
|
+
_CATEGORY_EVALUATION: [],
|
|
78
|
+
}
|
|
79
|
+
spec_registry: Dict[str, EmitterSpec] = {}
|
|
80
|
+
|
|
81
|
+
def _register(spec: EmitterSpec) -> None:
|
|
82
|
+
target = category_specs.setdefault(spec.category, [])
|
|
83
|
+
mode = getattr(spec, "mode", "append")
|
|
84
|
+
if mode == "replace-category":
|
|
85
|
+
target.clear()
|
|
86
|
+
target.append(spec)
|
|
87
|
+
elif mode == "prepend":
|
|
88
|
+
target.insert(0, spec)
|
|
89
|
+
elif mode == "replace-same-name":
|
|
90
|
+
replaced = False
|
|
91
|
+
for idx, existing in enumerate(target):
|
|
92
|
+
if existing.name == spec.name:
|
|
93
|
+
target[idx] = spec
|
|
94
|
+
replaced = True
|
|
95
|
+
break
|
|
96
|
+
if not replaced:
|
|
97
|
+
target.append(spec)
|
|
98
|
+
else:
|
|
99
|
+
target.append(spec)
|
|
100
|
+
spec_registry[spec.name] = spec
|
|
101
|
+
|
|
102
|
+
if settings.enable_span and not settings.only_traceloop_compat:
|
|
103
|
+
_register(
|
|
104
|
+
EmitterSpec(
|
|
105
|
+
name="SemanticConvSpan",
|
|
106
|
+
category=_CATEGORY_SPAN,
|
|
107
|
+
factory=lambda ctx: SpanEmitter(
|
|
108
|
+
tracer=ctx.tracer,
|
|
109
|
+
capture_content=ctx.capture_span_content,
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
if settings.enable_metrics:
|
|
114
|
+
_register(
|
|
115
|
+
EmitterSpec(
|
|
116
|
+
name="SemanticConvMetrics",
|
|
117
|
+
category=_CATEGORY_METRICS,
|
|
118
|
+
factory=lambda ctx: MetricsEmitter(meter=ctx.meter),
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
if settings.enable_content_events:
|
|
122
|
+
_register(
|
|
123
|
+
EmitterSpec(
|
|
124
|
+
name="ContentEvents",
|
|
125
|
+
category=_CATEGORY_CONTENT,
|
|
126
|
+
factory=lambda ctx: ContentEventsEmitter(
|
|
127
|
+
logger=ctx.content_logger,
|
|
128
|
+
capture_content=ctx.capture_event_content,
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Evaluation emitters are always present
|
|
134
|
+
_register(
|
|
135
|
+
EmitterSpec(
|
|
136
|
+
name="EvaluationMetrics",
|
|
137
|
+
category=_CATEGORY_EVALUATION,
|
|
138
|
+
factory=lambda ctx: EvaluationMetricsEmitter(
|
|
139
|
+
ctx.evaluation_histogram # now a callable returning histogram per metric
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
_register(
|
|
144
|
+
EmitterSpec(
|
|
145
|
+
name="EvaluationEvents",
|
|
146
|
+
category=_CATEGORY_EVALUATION,
|
|
147
|
+
factory=lambda ctx: EvaluationEventsEmitter(
|
|
148
|
+
ctx.content_logger,
|
|
149
|
+
emit_legacy_event=settings.emit_legacy_evaluation_event,
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
for spec in load_emitter_specs(settings.extra_emitters):
|
|
155
|
+
if spec.category not in {
|
|
156
|
+
_CATEGORY_SPAN,
|
|
157
|
+
_CATEGORY_METRICS,
|
|
158
|
+
_CATEGORY_CONTENT,
|
|
159
|
+
_CATEGORY_EVALUATION,
|
|
160
|
+
}:
|
|
161
|
+
_logger.warning(
|
|
162
|
+
"Emitter spec %s targets unknown category '%s'",
|
|
163
|
+
spec.name,
|
|
164
|
+
spec.category,
|
|
165
|
+
)
|
|
166
|
+
continue
|
|
167
|
+
_register(spec)
|
|
168
|
+
|
|
169
|
+
_apply_category_overrides(
|
|
170
|
+
category_specs, spec_registry, settings.category_overrides
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
span_emitters = _instantiate_category(
|
|
174
|
+
category_specs.get(_CATEGORY_SPAN, ()), context
|
|
175
|
+
)
|
|
176
|
+
metrics_emitters = _instantiate_category(
|
|
177
|
+
category_specs.get(_CATEGORY_METRICS, ()), context
|
|
178
|
+
)
|
|
179
|
+
content_emitters = _instantiate_category(
|
|
180
|
+
category_specs.get(_CATEGORY_CONTENT, ()), context
|
|
181
|
+
)
|
|
182
|
+
evaluation_emitters = _instantiate_category(
|
|
183
|
+
category_specs.get(_CATEGORY_EVALUATION, ()), context
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
composite = CompositeEmitter(
|
|
187
|
+
span_emitters=span_emitters,
|
|
188
|
+
metrics_emitters=metrics_emitters,
|
|
189
|
+
content_event_emitters=content_emitters,
|
|
190
|
+
evaluation_emitters=evaluation_emitters,
|
|
191
|
+
)
|
|
192
|
+
control = CaptureControl(
|
|
193
|
+
span_allowed=span_allowed,
|
|
194
|
+
span_initial=span_initial,
|
|
195
|
+
events_initial=events_initial,
|
|
196
|
+
mode=settings.capture_messages_mode,
|
|
197
|
+
)
|
|
198
|
+
return composite, control
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _instantiate_category(
|
|
202
|
+
specs: Iterable[EmitterSpec], context: EmitterFactoryContext
|
|
203
|
+
) -> List[EmitterProtocol]:
|
|
204
|
+
instances: List[EmitterProtocol] = []
|
|
205
|
+
for spec in specs:
|
|
206
|
+
try:
|
|
207
|
+
emitter = spec.factory(context)
|
|
208
|
+
if spec.invocation_types:
|
|
209
|
+
allowed = {name for name in spec.invocation_types}
|
|
210
|
+
original = getattr(emitter, "handles", None)
|
|
211
|
+
orig_func = getattr(original, "__func__", None)
|
|
212
|
+
|
|
213
|
+
def _filtered_handles(
|
|
214
|
+
self, obj, _allowed=allowed, _orig=orig_func
|
|
215
|
+
):
|
|
216
|
+
if obj is None:
|
|
217
|
+
if _orig is not None:
|
|
218
|
+
return _orig(self, obj)
|
|
219
|
+
return True
|
|
220
|
+
if type(obj).__name__ not in _allowed:
|
|
221
|
+
return False
|
|
222
|
+
if _orig is not None:
|
|
223
|
+
return _orig(self, obj)
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
setattr(
|
|
227
|
+
emitter,
|
|
228
|
+
"handles",
|
|
229
|
+
MethodType(_filtered_handles, emitter),
|
|
230
|
+
)
|
|
231
|
+
instances.append(emitter)
|
|
232
|
+
except Exception: # pragma: no cover - defensive
|
|
233
|
+
_logger.exception("Failed to instantiate emitter %s", spec.name)
|
|
234
|
+
return instances
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _apply_category_overrides(
|
|
238
|
+
category_specs: Dict[str, List[EmitterSpec]],
|
|
239
|
+
spec_registry: Dict[str, EmitterSpec],
|
|
240
|
+
overrides: Dict[str, CategoryOverride],
|
|
241
|
+
) -> None:
|
|
242
|
+
for category, override in overrides.items():
|
|
243
|
+
current = category_specs.setdefault(category, [])
|
|
244
|
+
if override.mode == "replace-category":
|
|
245
|
+
replacement: List[EmitterSpec] = []
|
|
246
|
+
for name in override.emitter_names:
|
|
247
|
+
spec = spec_registry.get(name)
|
|
248
|
+
if spec is None:
|
|
249
|
+
_logger.warning(
|
|
250
|
+
"Emitter '%s' referenced in %s override is not registered",
|
|
251
|
+
name,
|
|
252
|
+
category,
|
|
253
|
+
)
|
|
254
|
+
continue
|
|
255
|
+
replacement.append(spec)
|
|
256
|
+
if not replacement:
|
|
257
|
+
_logger.warning(
|
|
258
|
+
"replace-category override for '%s' resolved to empty set; retaining existing emitters (fallback)",
|
|
259
|
+
category,
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
# Auto-augment evaluation if user attempted to replace with only SplunkEvaluationResults
|
|
263
|
+
if (
|
|
264
|
+
category == _CATEGORY_EVALUATION
|
|
265
|
+
and len(replacement) == 1
|
|
266
|
+
and replacement[0].name == "SplunkEvaluationResults"
|
|
267
|
+
):
|
|
268
|
+
builtin_metrics = spec_registry.get("EvaluationMetrics")
|
|
269
|
+
if builtin_metrics and builtin_metrics not in replacement:
|
|
270
|
+
replacement.insert(0, builtin_metrics)
|
|
271
|
+
category_specs[category] = replacement
|
|
272
|
+
continue
|
|
273
|
+
if override.mode == "prepend":
|
|
274
|
+
additions = _resolve_specs(
|
|
275
|
+
override.emitter_names, spec_registry, category
|
|
276
|
+
)
|
|
277
|
+
category_specs[category] = additions + current
|
|
278
|
+
continue
|
|
279
|
+
if override.mode == "replace-same-name":
|
|
280
|
+
for name in override.emitter_names:
|
|
281
|
+
spec = spec_registry.get(name)
|
|
282
|
+
if spec is None:
|
|
283
|
+
_logger.warning(
|
|
284
|
+
"Emitter '%s' referenced in %s override is not registered",
|
|
285
|
+
name,
|
|
286
|
+
category,
|
|
287
|
+
)
|
|
288
|
+
continue
|
|
289
|
+
replaced = False
|
|
290
|
+
for idx, existing in enumerate(current):
|
|
291
|
+
if existing.name == name:
|
|
292
|
+
current[idx] = spec
|
|
293
|
+
replaced = True
|
|
294
|
+
break
|
|
295
|
+
if not replaced:
|
|
296
|
+
current.append(spec)
|
|
297
|
+
continue
|
|
298
|
+
# append (default)
|
|
299
|
+
additions = _resolve_specs(
|
|
300
|
+
override.emitter_names, spec_registry, category
|
|
301
|
+
)
|
|
302
|
+
current.extend(additions)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _resolve_specs(
|
|
306
|
+
names: Sequence[str],
|
|
307
|
+
spec_registry: Dict[str, EmitterSpec],
|
|
308
|
+
category: str,
|
|
309
|
+
) -> List[EmitterSpec]:
|
|
310
|
+
resolved: List[EmitterSpec] = []
|
|
311
|
+
for name in names:
|
|
312
|
+
spec = spec_registry.get(name)
|
|
313
|
+
if spec is None:
|
|
314
|
+
_logger.warning(
|
|
315
|
+
"Emitter '%s' referenced in %s override is not registered",
|
|
316
|
+
name,
|
|
317
|
+
category,
|
|
318
|
+
)
|
|
319
|
+
continue
|
|
320
|
+
resolved.append(spec)
|
|
321
|
+
return resolved
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
__all__ = ["CaptureControl", "build_emitter_pipeline"]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from opentelemetry._logs import Logger, get_logger
|
|
7
|
+
|
|
8
|
+
from ..interfaces import EmitterMeta
|
|
9
|
+
from ..types import (
|
|
10
|
+
AgentCreation,
|
|
11
|
+
AgentInvocation,
|
|
12
|
+
EmbeddingInvocation,
|
|
13
|
+
Error,
|
|
14
|
+
LLMInvocation,
|
|
15
|
+
Step,
|
|
16
|
+
Workflow,
|
|
17
|
+
)
|
|
18
|
+
from .utils import (
|
|
19
|
+
_agent_to_log_record,
|
|
20
|
+
_embedding_to_log_record,
|
|
21
|
+
_llm_invocation_to_log_record,
|
|
22
|
+
_step_to_log_record,
|
|
23
|
+
_workflow_to_log_record,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ContentEventsEmitter(EmitterMeta):
|
|
28
|
+
"""Emits input/output content as events (log records) instead of span attributes.
|
|
29
|
+
|
|
30
|
+
Supported: LLMInvocation only.
|
|
31
|
+
|
|
32
|
+
Exclusions:
|
|
33
|
+
* EmbeddingInvocation – embeddings are vector lookups; content events intentionally omitted to reduce noise & cost.
|
|
34
|
+
* ToolCall – tool calls typically reference external functions/APIs; their arguments are already span attributes and
|
|
35
|
+
are not duplicated as content events (future structured tool audit events may be added separately).
|
|
36
|
+
|
|
37
|
+
This explicit exclusion avoids surprising cardinality growth and keeps event volume proportional to user/chat messages.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
role = "content_event"
|
|
41
|
+
name = "semconv_content_events"
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, logger: Optional[Logger] = None, capture_content: bool = False
|
|
45
|
+
):
|
|
46
|
+
self._logger: Logger = logger or get_logger(__name__)
|
|
47
|
+
self._capture_content = capture_content
|
|
48
|
+
self._py_logger = logging.getLogger(f"{__name__}.ContentEventsEmitter")
|
|
49
|
+
if self._py_logger.isEnabledFor(logging.DEBUG):
|
|
50
|
+
self._py_logger.debug(
|
|
51
|
+
"Initialized ContentEventsEmitter capture_content=%s logger=%s",
|
|
52
|
+
capture_content,
|
|
53
|
+
type(self._logger).__name__,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def on_start(self, obj: Any) -> None:
|
|
57
|
+
# LLM events are emitted in finish() when we have both input and output
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def on_end(self, obj: Any) -> None:
|
|
61
|
+
if not self._capture_content:
|
|
62
|
+
if self._py_logger.isEnabledFor(logging.DEBUG):
|
|
63
|
+
self._py_logger.debug(
|
|
64
|
+
"Skipping content emission (capture_content disabled) obj_type=%s",
|
|
65
|
+
type(obj).__name__,
|
|
66
|
+
)
|
|
67
|
+
return
|
|
68
|
+
# Emit workflow event (includes initial input + final output messages)
|
|
69
|
+
if isinstance(obj, Workflow):
|
|
70
|
+
self._emit_workflow_event(obj)
|
|
71
|
+
return
|
|
72
|
+
# Emit agent creation/invocation event (input/output messages where available)
|
|
73
|
+
if isinstance(obj, (AgentCreation, AgentInvocation)):
|
|
74
|
+
self._emit_agent_event(obj)
|
|
75
|
+
return
|
|
76
|
+
# Optional: step and embedding events (currently excluded from request scope)
|
|
77
|
+
# Uncomment if needed later:
|
|
78
|
+
# if isinstance(obj, Step):
|
|
79
|
+
# self._emit_step_event(obj); return
|
|
80
|
+
# if isinstance(obj, EmbeddingInvocation):
|
|
81
|
+
# self._emit_embedding_event(obj); return
|
|
82
|
+
if isinstance(obj, LLMInvocation):
|
|
83
|
+
# Emit a single event for the entire LLM invocation
|
|
84
|
+
try:
|
|
85
|
+
record = _llm_invocation_to_log_record(
|
|
86
|
+
obj,
|
|
87
|
+
self._capture_content,
|
|
88
|
+
)
|
|
89
|
+
if record and self._logger:
|
|
90
|
+
if self._py_logger.isEnabledFor(logging.DEBUG):
|
|
91
|
+
self._py_logger.debug(
|
|
92
|
+
"Emitting LLM content event trace_id=%s span_id=%s",
|
|
93
|
+
getattr(obj, "trace_id", None),
|
|
94
|
+
getattr(obj, "span_id", None),
|
|
95
|
+
)
|
|
96
|
+
self._logger.emit(record)
|
|
97
|
+
elif self._py_logger.isEnabledFor(logging.DEBUG):
|
|
98
|
+
self._py_logger.debug(
|
|
99
|
+
"No log record generated for LLM invocation (capture_content=%s)",
|
|
100
|
+
self._capture_content,
|
|
101
|
+
)
|
|
102
|
+
except (TypeError, ValueError, AttributeError) as e:
|
|
103
|
+
logging.getLogger(__name__).warning(
|
|
104
|
+
"Failed to emit LLM invocation event: %s", e, exc_info=True
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def on_error(self, error: Error, obj: Any) -> None:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def handles(self, obj: Any) -> bool:
|
|
111
|
+
return isinstance(
|
|
112
|
+
obj,
|
|
113
|
+
(LLMInvocation, Workflow, AgentCreation, AgentInvocation, Step),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Helper methods for new agentic types
|
|
117
|
+
def _emit_workflow_event(self, workflow: Workflow) -> None:
|
|
118
|
+
"""Emit an event for a workflow."""
|
|
119
|
+
try:
|
|
120
|
+
record = _workflow_to_log_record(workflow, self._capture_content)
|
|
121
|
+
if record and self._logger:
|
|
122
|
+
self._logger.emit(record)
|
|
123
|
+
except (TypeError, ValueError, AttributeError):
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def _emit_agent_event(
|
|
127
|
+
self, agent: AgentCreation | AgentInvocation
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Emit an event for an agent operation."""
|
|
130
|
+
try:
|
|
131
|
+
record = _agent_to_log_record(agent, self._capture_content)
|
|
132
|
+
if record and self._logger:
|
|
133
|
+
self._logger.emit(record)
|
|
134
|
+
except (TypeError, ValueError, AttributeError):
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def _emit_step_event(self, step: Step) -> None:
|
|
138
|
+
"""Emit an event for a step."""
|
|
139
|
+
try:
|
|
140
|
+
record = _step_to_log_record(step, self._capture_content)
|
|
141
|
+
if record and self._logger:
|
|
142
|
+
self._logger.emit(record)
|
|
143
|
+
except (TypeError, ValueError, AttributeError):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def _emit_embedding_event(self, embedding: EmbeddingInvocation) -> None:
|
|
147
|
+
"""Emit an event for an embedding operation."""
|
|
148
|
+
try:
|
|
149
|
+
record = _embedding_to_log_record(embedding, self._capture_content)
|
|
150
|
+
if record and self._logger:
|
|
151
|
+
self._logger.emit(record)
|
|
152
|
+
except (TypeError, ValueError, AttributeError):
|
|
153
|
+
return None
|