telemetry-dev 0.1.0__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.
- telemetry_dev/__init__.py +55 -0
- telemetry_dev/_client.py +390 -0
- telemetry_dev/_config.py +81 -0
- telemetry_dev/_context.py +101 -0
- telemetry_dev/_logs.py +65 -0
- telemetry_dev/_metrics.py +120 -0
- telemetry_dev/_observe.py +142 -0
- telemetry_dev/_processor.py +77 -0
- telemetry_dev/_semconv.py +115 -0
- telemetry_dev/_serialize.py +103 -0
- telemetry_dev/_spans.py +676 -0
- telemetry_dev/otel.py +186 -0
- telemetry_dev/py.typed +0 -0
- telemetry_dev-0.1.0.dist-info/METADATA +235 -0
- telemetry_dev-0.1.0.dist-info/RECORD +16 -0
- telemetry_dev-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
|
|
7
|
+
from opentelemetry.metrics import Meter
|
|
8
|
+
from opentelemetry.sdk.metrics.export import MetricExportResult, MetricsData
|
|
9
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
10
|
+
|
|
11
|
+
from ._config import report_error
|
|
12
|
+
from ._semconv import (
|
|
13
|
+
ATTR_ERROR_TYPE,
|
|
14
|
+
DURATION_BUCKETS,
|
|
15
|
+
DURATION_METRIC_OPERATIONS,
|
|
16
|
+
METRIC_ATTR_KEYS,
|
|
17
|
+
TOKEN_BUCKETS,
|
|
18
|
+
TOKEN_METRIC_OPERATIONS,
|
|
19
|
+
USAGE_ATTRS,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_INPUT_TOKENS_ATTR = USAGE_ATTRS["input_tokens"]
|
|
23
|
+
_OUTPUT_TOKENS_ATTR = USAGE_ATTRS["output_tokens"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _has_data_points(metrics_data: MetricsData) -> bool:
|
|
27
|
+
for resource_metrics in metrics_data.resource_metrics:
|
|
28
|
+
for scope_metrics in resource_metrics.scope_metrics:
|
|
29
|
+
for metric in scope_metrics.metrics:
|
|
30
|
+
if len(metric.data.data_points) > 0:
|
|
31
|
+
return True
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GuardedOTLPMetricExporter(OTLPMetricExporter):
|
|
36
|
+
"""OTLP metric exporter that skips POSTs for collections with zero data points
|
|
37
|
+
(mirrors the TS emitter's empty-datapoint guard) and surfaces failures to on_error."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*args: Any,
|
|
42
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
43
|
+
**kwargs: Any,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownMemberType]
|
|
46
|
+
self._td_on_error = on_error
|
|
47
|
+
|
|
48
|
+
def export(
|
|
49
|
+
self,
|
|
50
|
+
metrics_data: MetricsData,
|
|
51
|
+
timeout_millis: float | None = 10_000,
|
|
52
|
+
**kwargs: Any,
|
|
53
|
+
) -> MetricExportResult:
|
|
54
|
+
if not _has_data_points(metrics_data):
|
|
55
|
+
return MetricExportResult.SUCCESS
|
|
56
|
+
try:
|
|
57
|
+
result = super().export( # pyright: ignore[reportUnknownMemberType]
|
|
58
|
+
metrics_data, timeout_millis=timeout_millis, **kwargs
|
|
59
|
+
)
|
|
60
|
+
except BaseException as exc:
|
|
61
|
+
report_error(self._td_on_error, "metric export failed", exc)
|
|
62
|
+
return MetricExportResult.FAILURE
|
|
63
|
+
if result is MetricExportResult.FAILURE:
|
|
64
|
+
report_error(
|
|
65
|
+
self._td_on_error,
|
|
66
|
+
"metric export failed",
|
|
67
|
+
RuntimeError("OTLP metric export failed (check the API key and ingest URL)"),
|
|
68
|
+
)
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MetricsRecorder:
|
|
73
|
+
"""Records the auto GenAI histograms from ended spans (called by the span processor)."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
meter: Meter,
|
|
78
|
+
*,
|
|
79
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._on_error = on_error
|
|
82
|
+
self._duration = meter.create_histogram(
|
|
83
|
+
"gen_ai.client.operation.duration",
|
|
84
|
+
unit="s",
|
|
85
|
+
description="Duration of GenAI client operations",
|
|
86
|
+
explicit_bucket_boundaries_advisory=DURATION_BUCKETS,
|
|
87
|
+
)
|
|
88
|
+
self._tokens = meter.create_histogram(
|
|
89
|
+
"gen_ai.client.token.usage",
|
|
90
|
+
unit="{token}",
|
|
91
|
+
description="Number of input and output tokens used by GenAI clients",
|
|
92
|
+
explicit_bucket_boundaries_advisory=TOKEN_BUCKETS,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def record_span(self, span: ReadableSpan) -> None:
|
|
96
|
+
try:
|
|
97
|
+
attributes = span.attributes or {}
|
|
98
|
+
operation = attributes.get("gen_ai.operation.name")
|
|
99
|
+
if not isinstance(operation, str) or operation not in DURATION_METRIC_OPERATIONS:
|
|
100
|
+
return
|
|
101
|
+
metric_attrs = {key: attributes[key] for key in METRIC_ATTR_KEYS if key in attributes}
|
|
102
|
+
if span.end_time is not None and span.start_time is not None:
|
|
103
|
+
duration_s = max(span.end_time - span.start_time, 0) / 1e9
|
|
104
|
+
error_type = attributes.get(ATTR_ERROR_TYPE)
|
|
105
|
+
duration_attrs = (
|
|
106
|
+
{**metric_attrs, ATTR_ERROR_TYPE: error_type}
|
|
107
|
+
if isinstance(error_type, str)
|
|
108
|
+
else metric_attrs
|
|
109
|
+
)
|
|
110
|
+
self._duration.record(duration_s, duration_attrs)
|
|
111
|
+
if operation not in TOKEN_METRIC_OPERATIONS:
|
|
112
|
+
return
|
|
113
|
+
input_tokens = attributes.get(_INPUT_TOKENS_ATTR)
|
|
114
|
+
if isinstance(input_tokens, int):
|
|
115
|
+
self._tokens.record(input_tokens, {**metric_attrs, "gen_ai.token.type": "input"})
|
|
116
|
+
output_tokens = attributes.get(_OUTPUT_TOKENS_ATTR)
|
|
117
|
+
if isinstance(output_tokens, int):
|
|
118
|
+
self._tokens.record(output_tokens, {**metric_attrs, "gen_ai.token.type": "output"})
|
|
119
|
+
except BaseException as exc:
|
|
120
|
+
report_error(self._on_error, "failed to record auto-metrics for span", exc)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from typing import Any, TypeVar, overload
|
|
7
|
+
|
|
8
|
+
from ._client import get_client
|
|
9
|
+
from ._semconv import SpanType
|
|
10
|
+
from ._serialize import AttributeValue
|
|
11
|
+
from ._spans import start_span
|
|
12
|
+
|
|
13
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _bound_input(fn: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
17
|
+
try:
|
|
18
|
+
bound = inspect.signature(fn).bind(*args, **kwargs)
|
|
19
|
+
arguments = dict(bound.arguments)
|
|
20
|
+
arguments.pop("self", None)
|
|
21
|
+
arguments.pop("cls", None)
|
|
22
|
+
return arguments
|
|
23
|
+
except BaseException:
|
|
24
|
+
return {"args": list(args), "kwargs": kwargs}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@overload
|
|
28
|
+
def observe(func: F) -> F: ...
|
|
29
|
+
@overload
|
|
30
|
+
def observe(
|
|
31
|
+
func: None = None,
|
|
32
|
+
*,
|
|
33
|
+
name: str | None = None,
|
|
34
|
+
type: SpanType = "span",
|
|
35
|
+
capture_input: bool | None = None,
|
|
36
|
+
capture_output: bool | None = None,
|
|
37
|
+
attributes: Mapping[str, AttributeValue] | None = None,
|
|
38
|
+
) -> Callable[[F], F]: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def observe(
|
|
42
|
+
func: F | None = None,
|
|
43
|
+
*,
|
|
44
|
+
name: str | None = None,
|
|
45
|
+
type: SpanType = "span",
|
|
46
|
+
capture_input: bool | None = None,
|
|
47
|
+
capture_output: bool | None = None,
|
|
48
|
+
attributes: Mapping[str, AttributeValue] | None = None,
|
|
49
|
+
) -> F | Callable[[F], F]:
|
|
50
|
+
"""Wrap a function in a span: arguments become `input` (param-name dict, self/cls dropped),
|
|
51
|
+
the return value becomes `output`, exceptions are captured and re-raised. Supports sync,
|
|
52
|
+
async, sync-generator, and async-generator functions; activates the span context for
|
|
53
|
+
sync/async functions."""
|
|
54
|
+
|
|
55
|
+
def decorate(fn: F) -> F:
|
|
56
|
+
span_name = name or getattr(fn, "__qualname__", None) or getattr(fn, "__name__", "observe")
|
|
57
|
+
|
|
58
|
+
def open_span(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
59
|
+
return start_span(
|
|
60
|
+
span_name,
|
|
61
|
+
type=type,
|
|
62
|
+
input=_bound_input(fn, args, kwargs),
|
|
63
|
+
attributes=attributes,
|
|
64
|
+
capture_input=capture_input,
|
|
65
|
+
capture_output=capture_output,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if inspect.iscoroutinefunction(fn):
|
|
69
|
+
|
|
70
|
+
@functools.wraps(fn)
|
|
71
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
72
|
+
client = get_client()
|
|
73
|
+
if client is None or not client.enabled:
|
|
74
|
+
return await fn(*args, **kwargs)
|
|
75
|
+
with open_span(args, kwargs) as handle:
|
|
76
|
+
result = await fn(*args, **kwargs)
|
|
77
|
+
handle.update(output=result)
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
return async_wrapper # type: ignore[return-value]
|
|
81
|
+
|
|
82
|
+
if inspect.isasyncgenfunction(fn):
|
|
83
|
+
|
|
84
|
+
@functools.wraps(fn)
|
|
85
|
+
async def async_gen_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
86
|
+
client = get_client()
|
|
87
|
+
if client is None or not client.enabled:
|
|
88
|
+
async for item in fn(*args, **kwargs):
|
|
89
|
+
yield item
|
|
90
|
+
return
|
|
91
|
+
handle = open_span(args, kwargs)
|
|
92
|
+
try:
|
|
93
|
+
async for item in fn(*args, **kwargs):
|
|
94
|
+
yield item
|
|
95
|
+
except GeneratorExit:
|
|
96
|
+
handle.end()
|
|
97
|
+
raise
|
|
98
|
+
except BaseException as exc:
|
|
99
|
+
handle.end(error=exc)
|
|
100
|
+
raise
|
|
101
|
+
else:
|
|
102
|
+
handle.end()
|
|
103
|
+
|
|
104
|
+
return async_gen_wrapper # type: ignore[return-value]
|
|
105
|
+
|
|
106
|
+
if inspect.isgeneratorfunction(fn):
|
|
107
|
+
|
|
108
|
+
@functools.wraps(fn)
|
|
109
|
+
def gen_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
110
|
+
client = get_client()
|
|
111
|
+
if client is None or not client.enabled:
|
|
112
|
+
yield from fn(*args, **kwargs)
|
|
113
|
+
return
|
|
114
|
+
handle = open_span(args, kwargs)
|
|
115
|
+
try:
|
|
116
|
+
yield from fn(*args, **kwargs)
|
|
117
|
+
except GeneratorExit:
|
|
118
|
+
handle.end()
|
|
119
|
+
raise
|
|
120
|
+
except BaseException as exc:
|
|
121
|
+
handle.end(error=exc)
|
|
122
|
+
raise
|
|
123
|
+
else:
|
|
124
|
+
handle.end()
|
|
125
|
+
|
|
126
|
+
return gen_wrapper # type: ignore[return-value]
|
|
127
|
+
|
|
128
|
+
@functools.wraps(fn)
|
|
129
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
130
|
+
client = get_client()
|
|
131
|
+
if client is None or not client.enabled:
|
|
132
|
+
return fn(*args, **kwargs)
|
|
133
|
+
with open_span(args, kwargs) as handle:
|
|
134
|
+
result = fn(*args, **kwargs)
|
|
135
|
+
handle.update(output=result)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
return sync_wrapper # type: ignore[return-value]
|
|
139
|
+
|
|
140
|
+
if func is not None:
|
|
141
|
+
return decorate(func)
|
|
142
|
+
return decorate
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from opentelemetry.context import Context
|
|
7
|
+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
|
|
8
|
+
from opentelemetry.sdk.trace.export import (
|
|
9
|
+
BatchSpanProcessor,
|
|
10
|
+
SimpleSpanProcessor,
|
|
11
|
+
SpanExporter,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from ._config import report_error
|
|
15
|
+
from ._context import propagated_attributes
|
|
16
|
+
from ._metrics import MetricsRecorder
|
|
17
|
+
|
|
18
|
+
ExportMode = Literal["batched", "immediate"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StampingSpanProcessor(SpanProcessor):
|
|
22
|
+
"""Stamps propagated attributes, filters spans, records metrics, then exports."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
span_exporter: SpanExporter,
|
|
27
|
+
*,
|
|
28
|
+
export_mode: ExportMode = "batched",
|
|
29
|
+
max_export_batch_size: int = 64,
|
|
30
|
+
schedule_delay_millis: float = 1000,
|
|
31
|
+
max_queue_size: int = 2048,
|
|
32
|
+
export_timeout_millis: float = 30000,
|
|
33
|
+
span_filter: Callable[[ReadableSpan], bool] | None = None,
|
|
34
|
+
metrics_recorder: MetricsRecorder | None = None,
|
|
35
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._span_filter = span_filter
|
|
38
|
+
self._metrics_recorder = metrics_recorder
|
|
39
|
+
self._on_error = on_error
|
|
40
|
+
if export_mode == "immediate":
|
|
41
|
+
self._inner: SpanProcessor = SimpleSpanProcessor(span_exporter)
|
|
42
|
+
else:
|
|
43
|
+
self._inner = BatchSpanProcessor(
|
|
44
|
+
span_exporter,
|
|
45
|
+
max_queue_size=max_queue_size,
|
|
46
|
+
schedule_delay_millis=schedule_delay_millis,
|
|
47
|
+
max_export_batch_size=max_export_batch_size,
|
|
48
|
+
export_timeout_millis=export_timeout_millis,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
|
|
52
|
+
try:
|
|
53
|
+
attrs = propagated_attributes(parent_context)
|
|
54
|
+
if attrs:
|
|
55
|
+
span.set_attributes(attrs)
|
|
56
|
+
except BaseException as exc:
|
|
57
|
+
report_error(self._on_error, "failed to stamp propagated attributes", exc)
|
|
58
|
+
self._inner.on_start(span, parent_context)
|
|
59
|
+
|
|
60
|
+
def on_end(self, span: ReadableSpan) -> None:
|
|
61
|
+
if self._span_filter is not None:
|
|
62
|
+
try:
|
|
63
|
+
if not self._span_filter(span):
|
|
64
|
+
return
|
|
65
|
+
except BaseException as exc:
|
|
66
|
+
# Fail open: a broken filter must not drop telemetry.
|
|
67
|
+
report_error(self._on_error, "span_filter raised; exporting span anyway", exc)
|
|
68
|
+
# Metrics are recorded only for exported spans (after the filter), matching the TS SDK.
|
|
69
|
+
if self._metrics_recorder is not None:
|
|
70
|
+
self._metrics_recorder.record_span(span)
|
|
71
|
+
self._inner.on_end(span)
|
|
72
|
+
|
|
73
|
+
def shutdown(self) -> None:
|
|
74
|
+
self._inner.shutdown()
|
|
75
|
+
|
|
76
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
77
|
+
return self._inner.force_flush(timeout_millis)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
SCOPE_NAME = "telemetry_dev"
|
|
6
|
+
|
|
7
|
+
SpanType = Literal["span", "generation", "tool", "agent", "embedding"]
|
|
8
|
+
LogLevel = Literal["debug", "info", "warn", "warning", "error"]
|
|
9
|
+
|
|
10
|
+
# Plain spans ALWAYS map to "function" — an operation-less span carrying a propagated
|
|
11
|
+
# gen_ai.conversation.id would otherwise be classified as "llm" by the ingest heuristic.
|
|
12
|
+
SPAN_TYPE_TO_OPERATION: dict[str, str] = {
|
|
13
|
+
"span": "function",
|
|
14
|
+
"generation": "chat",
|
|
15
|
+
"tool": "execute_tool",
|
|
16
|
+
"agent": "invoke_agent",
|
|
17
|
+
"embedding": "embeddings",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
DURATION_METRIC_OPERATIONS = frozenset({"chat", "invoke_agent", "embeddings", "execute_tool"})
|
|
21
|
+
TOKEN_METRIC_OPERATIONS = frozenset({"chat", "invoke_agent", "embeddings"})
|
|
22
|
+
|
|
23
|
+
# Buckets verbatim from packages/ai/src/otel.ts (OTel GenAI semconv recommendations).
|
|
24
|
+
DURATION_BUCKETS = [
|
|
25
|
+
0.01,
|
|
26
|
+
0.02,
|
|
27
|
+
0.04,
|
|
28
|
+
0.08,
|
|
29
|
+
0.16,
|
|
30
|
+
0.32,
|
|
31
|
+
0.64,
|
|
32
|
+
1.28,
|
|
33
|
+
2.56,
|
|
34
|
+
5.12,
|
|
35
|
+
10.24,
|
|
36
|
+
20.48,
|
|
37
|
+
40.96,
|
|
38
|
+
81.92,
|
|
39
|
+
]
|
|
40
|
+
TOKEN_BUCKETS = [
|
|
41
|
+
1,
|
|
42
|
+
4,
|
|
43
|
+
16,
|
|
44
|
+
64,
|
|
45
|
+
256,
|
|
46
|
+
1024,
|
|
47
|
+
4096,
|
|
48
|
+
16384,
|
|
49
|
+
65536,
|
|
50
|
+
262144,
|
|
51
|
+
1048576,
|
|
52
|
+
4194304,
|
|
53
|
+
16777216,
|
|
54
|
+
67108864,
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
SEVERITY: dict[str, int] = {"debug": 5, "info": 9, "warn": 13, "error": 17}
|
|
58
|
+
|
|
59
|
+
RESERVED_METADATA_KEYS = frozenset({"userId", "sessionId", "user_id", "session_id"})
|
|
60
|
+
|
|
61
|
+
ATTR_OPERATION = "gen_ai.operation.name"
|
|
62
|
+
ATTR_PROVIDER = "gen_ai.provider.name"
|
|
63
|
+
ATTR_REQUEST_MODEL = "gen_ai.request.model"
|
|
64
|
+
ATTR_RESPONSE_MODEL = "gen_ai.response.model"
|
|
65
|
+
ATTR_RESPONSE_ID = "gen_ai.response.id"
|
|
66
|
+
ATTR_OUTPUT_TYPE = "gen_ai.output.type"
|
|
67
|
+
ATTR_FINISH_REASONS = "gen_ai.response.finish_reasons"
|
|
68
|
+
ATTR_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
|
|
69
|
+
ATTR_INPUT_MESSAGES = "gen_ai.input.messages"
|
|
70
|
+
ATTR_OUTPUT_MESSAGES = "gen_ai.output.messages"
|
|
71
|
+
ATTR_TOOL_NAME = "gen_ai.tool.name"
|
|
72
|
+
ATTR_TOOL_CALL_ID = "gen_ai.tool.call.id"
|
|
73
|
+
ATTR_TOOL_DESCRIPTION = "gen_ai.tool.description"
|
|
74
|
+
ATTR_TOOL_ARGUMENTS = "gen_ai.tool.call.arguments"
|
|
75
|
+
ATTR_TOOL_RESULT = "gen_ai.tool.call.result"
|
|
76
|
+
ATTR_AGENT_NAME = "gen_ai.agent.name"
|
|
77
|
+
ATTR_AGENT_ID = "gen_ai.agent.id"
|
|
78
|
+
ATTR_COST = "gen_ai.usage.cost"
|
|
79
|
+
ATTR_USER_ID = "user.id"
|
|
80
|
+
ATTR_SESSION_ID = "gen_ai.conversation.id"
|
|
81
|
+
ATTR_ERROR_TYPE = "error.type"
|
|
82
|
+
# Emitted in SECONDS — the ingest multiplies non-"ms" duration keys by 1000.
|
|
83
|
+
ATTR_TIME_TO_FIRST_CHUNK = "gen_ai.response.time_to_first_chunk"
|
|
84
|
+
|
|
85
|
+
METADATA_PREFIX = "td.metadata."
|
|
86
|
+
|
|
87
|
+
USAGE_ATTRS: dict[str, str] = {
|
|
88
|
+
"input_tokens": "gen_ai.usage.input_tokens",
|
|
89
|
+
"output_tokens": "gen_ai.usage.output_tokens",
|
|
90
|
+
"total_tokens": "gen_ai.usage.total_tokens",
|
|
91
|
+
"cache_read_input_tokens": "gen_ai.usage.cache_read.input_tokens",
|
|
92
|
+
"cache_creation_input_tokens": "gen_ai.usage.cache_creation.input_tokens",
|
|
93
|
+
"reasoning_output_tokens": "gen_ai.usage.reasoning.output_tokens",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
SAMPLING_ATTRS: dict[str, str] = {
|
|
97
|
+
"temperature": "gen_ai.request.temperature",
|
|
98
|
+
"top_p": "gen_ai.request.top_p",
|
|
99
|
+
"top_k": "gen_ai.request.top_k",
|
|
100
|
+
"max_tokens": "gen_ai.request.max_tokens",
|
|
101
|
+
"stop_sequences": "gen_ai.request.stop_sequences",
|
|
102
|
+
"seed": "gen_ai.request.seed",
|
|
103
|
+
"frequency_penalty": "gen_ai.request.frequency_penalty",
|
|
104
|
+
"presence_penalty": "gen_ai.request.presence_penalty",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
METRIC_ATTR_KEYS = (ATTR_OPERATION, ATTR_PROVIDER, ATTR_REQUEST_MODEL, ATTR_RESPONSE_MODEL)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def input_key(operation: str) -> str:
|
|
111
|
+
return ATTR_TOOL_ARGUMENTS if operation == "execute_tool" else ATTR_INPUT_MESSAGES
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def output_key(operation: str) -> str:
|
|
115
|
+
return ATTR_TOOL_RESULT if operation == "execute_tool" else ATTR_OUTPUT_MESSAGES
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from ._config import report_error
|
|
9
|
+
|
|
10
|
+
TRUNCATION_MARKER = "...[truncated]"
|
|
11
|
+
DEFAULT_MAX_ATTRIBUTE_LENGTH = 65536
|
|
12
|
+
|
|
13
|
+
AttributeValue = (
|
|
14
|
+
str | bool | int | float | Sequence[str] | Sequence[bool] | Sequence[int] | Sequence[float]
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class MaskContext:
|
|
20
|
+
key: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
Mask = Callable[[Any, MaskContext], Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def truncate(text: str, max_len: int) -> str:
|
|
27
|
+
# The cap counts UTF-16 code units (JavaScript's String.length) so both SDKs truncate
|
|
28
|
+
# identical payloads at the same point; a slice landing mid-surrogate-pair backs off one unit.
|
|
29
|
+
if len(text) * 2 <= max_len:
|
|
30
|
+
return text
|
|
31
|
+
encoded = text.encode("utf-16-le")
|
|
32
|
+
if len(encoded) <= max_len * 2:
|
|
33
|
+
return text
|
|
34
|
+
# Total stays within max_len so the provider's attribute_value_length_limit backstop,
|
|
35
|
+
# set to the same cap, never slices the marker off.
|
|
36
|
+
head = encoded[: max(max_len - len(TRUNCATION_MARKER), 0) * 2]
|
|
37
|
+
try:
|
|
38
|
+
return head.decode("utf-16-le") + TRUNCATION_MARKER
|
|
39
|
+
except UnicodeDecodeError:
|
|
40
|
+
return head[:-2].decode("utf-16-le") + TRUNCATION_MARKER
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def stringify(value: Any) -> str:
|
|
44
|
+
if isinstance(value, str):
|
|
45
|
+
return value
|
|
46
|
+
return json.dumps(value, default=repr, ensure_ascii=False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def serialize_content(
|
|
50
|
+
value: Any,
|
|
51
|
+
*,
|
|
52
|
+
key: str,
|
|
53
|
+
mask: Mask | None,
|
|
54
|
+
max_len: int,
|
|
55
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
56
|
+
) -> str | None:
|
|
57
|
+
"""The single content funnel: mask -> JSON stringify -> truncate.
|
|
58
|
+
|
|
59
|
+
Returns None (content dropped, matching the TypeScript SDK) when the mask hook raises
|
|
60
|
+
or the value cannot be stringified.
|
|
61
|
+
"""
|
|
62
|
+
if mask is not None:
|
|
63
|
+
try:
|
|
64
|
+
value = mask(value, MaskContext(key=key))
|
|
65
|
+
except BaseException as exc:
|
|
66
|
+
report_error(on_error, f"mask hook raised for attribute '{key}'", exc)
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
text = stringify(value)
|
|
70
|
+
except BaseException as exc:
|
|
71
|
+
report_error(on_error, f"failed to serialize attribute '{key}'", exc)
|
|
72
|
+
return None
|
|
73
|
+
return truncate(text, max_len)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def coerce_attr_value(
|
|
77
|
+
value: Any,
|
|
78
|
+
*,
|
|
79
|
+
max_len: int = DEFAULT_MAX_ATTRIBUTE_LENGTH,
|
|
80
|
+
key: str | None = None,
|
|
81
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
82
|
+
) -> AttributeValue | None:
|
|
83
|
+
"""Pass scalars and homogeneous scalar sequences through; JSON-stringify everything else."""
|
|
84
|
+
try:
|
|
85
|
+
if isinstance(value, bool | int | float):
|
|
86
|
+
return value
|
|
87
|
+
if isinstance(value, str):
|
|
88
|
+
return truncate(value, max_len)
|
|
89
|
+
if isinstance(value, Sequence) and not isinstance(value, Mapping):
|
|
90
|
+
items: list[Any] = list(cast("Sequence[Any]", value))
|
|
91
|
+
if items and all(isinstance(item, str) for item in items):
|
|
92
|
+
return [truncate(item, max_len) for item in items]
|
|
93
|
+
if items and all(isinstance(item, bool) for item in items):
|
|
94
|
+
return items
|
|
95
|
+
if items and all(
|
|
96
|
+
isinstance(item, int | float) and not isinstance(item, bool) for item in items
|
|
97
|
+
):
|
|
98
|
+
return items
|
|
99
|
+
return truncate(stringify(value), max_len)
|
|
100
|
+
except BaseException as exc:
|
|
101
|
+
suffix = f" '{key}'" if key is not None else ""
|
|
102
|
+
report_error(on_error, f"failed to serialize attribute{suffix}", exc)
|
|
103
|
+
return None
|