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.
@@ -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