spanlight 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {spanlight-0.2.0 → spanlight-0.2.1}/PKG-INFO +1 -1
- {spanlight-0.2.0 → spanlight-0.2.1}/pyproject.toml +1 -1
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_decorator.py +15 -1
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_exporter.py +2 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_exporter_delivery.py +25 -3
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_serialize.py +31 -17
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_span.py +32 -13
- spanlight-0.2.1/src/spanlight/_trace_state.py +131 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_usage.py +36 -13
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_version.py +1 -1
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_common.py +26 -14
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/anthropic.py +26 -8
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/openai.py +12 -2
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_anthropic.py +21 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_decorator.py +19 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_exporter.py +13 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_spans_and_context.py +68 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/uv.lock +1 -1
- spanlight-0.2.0/src/spanlight/_trace_state.py +0 -83
- {spanlight-0.2.0 → spanlight-0.2.1}/.gitignore +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/LICENSE +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/README.md +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/examples/support_bot.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/__init__.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_client.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_config.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_context.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments_api.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments_parse.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments_report.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments_runs.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments_setup.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_globals.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_ids.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_metadata.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_prompt_client.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_prompts.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_ratelimit_headers.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_request_hash.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_scores.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_templates.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_tiers.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/__init__.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_anthropic_batch.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_anthropic_message.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_anthropic_stream.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_batch.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_modifiers.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_openai_batch.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_openai_response.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_openai_stream.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_provider_errors.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_streams.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_wrappers.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/py.typed +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/conftest.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/fixtures/template_cases.json +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/provider_mocks.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/support.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_anthropic_batch_results.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_anthropic_cut_stream.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_docs_snippets.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_experiments_report.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_exporter_counters.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_exporter_retry_after.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_gateway_refusal.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_metadata.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_openai.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_openai_batch_output.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_openai_tier_and_search.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_package.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_provider_errors.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_ratelimit_allowlist.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_templates_surrogates.py +0 -0
- {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_tiers.py +0 -0
|
@@ -16,6 +16,7 @@ SDK itself are swallowed so they cannot break the decorated function.
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import asyncio
|
|
19
20
|
import functools
|
|
20
21
|
import inspect
|
|
21
22
|
import logging
|
|
@@ -252,6 +253,10 @@ async def _traced_async_generator(
|
|
|
252
253
|
"""Async counterpart of :func:`_traced_generator`."""
|
|
253
254
|
collected = _OutputCollector(enabled=options.capture_output)
|
|
254
255
|
step: Callable[[], Any] = functools.partial(generator.asend, None)
|
|
256
|
+
# A cancellation thrown in at a `yield` means the consumer abandoned the generator: asyncio
|
|
257
|
+
# closes one left behind by `async for ... break` that way at loop shutdown. Like a `break`
|
|
258
|
+
# over a sync generator, that ends the span as ok, not failed.
|
|
259
|
+
abandoned: BaseException | None = None
|
|
255
260
|
while True:
|
|
256
261
|
previous = get_current_span()
|
|
257
262
|
token = set_current_span(span)
|
|
@@ -266,7 +271,15 @@ async def _traced_async_generator(
|
|
|
266
271
|
)
|
|
267
272
|
return
|
|
268
273
|
except BaseException as exc:
|
|
269
|
-
|
|
274
|
+
if exc is abandoned:
|
|
275
|
+
_finish(
|
|
276
|
+
span,
|
|
277
|
+
output=collected.result(None),
|
|
278
|
+
capture_output=options.capture_output,
|
|
279
|
+
output_cut=collected.truncated,
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
_finish(span, error=exc)
|
|
270
283
|
raise
|
|
271
284
|
finally:
|
|
272
285
|
restore_current_span(token, previous)
|
|
@@ -286,6 +299,7 @@ async def _traced_async_generator(
|
|
|
286
299
|
)
|
|
287
300
|
raise
|
|
288
301
|
except BaseException as exc:
|
|
302
|
+
abandoned = exc if isinstance(exc, asyncio.CancelledError) else None
|
|
289
303
|
step = functools.partial(generator.athrow, exc)
|
|
290
304
|
else:
|
|
291
305
|
step = functools.partial(generator.asend, sent)
|
|
@@ -189,6 +189,8 @@ class BatchExporter:
|
|
|
189
189
|
``True`` if the queue drained in time, ``False`` otherwise.
|
|
190
190
|
"""
|
|
191
191
|
try:
|
|
192
|
+
# A forked child must not wait on the parent's in-flight batch.
|
|
193
|
+
self._check_fork()
|
|
192
194
|
self._ensure_worker()
|
|
193
195
|
deadline = None if timeout is None else time.monotonic() + timeout
|
|
194
196
|
with self._condition:
|
|
@@ -135,12 +135,13 @@ class Delivery:
|
|
|
135
135
|
def _send_batch(
|
|
136
136
|
self, target: _Target, batch: list[SpanPayload], client: Mapping[str, Any] | None
|
|
137
137
|
) -> None:
|
|
138
|
+
batch, encoded = self._encode_each(target, batch)
|
|
138
139
|
if not batch:
|
|
139
140
|
return
|
|
140
|
-
|
|
141
|
+
fields = [f'"{target.body_key}":[{",".join(encoded)}]']
|
|
141
142
|
if client is not None and target.body_key == SPANS:
|
|
142
|
-
|
|
143
|
-
body =
|
|
143
|
+
fields.append('"client":' + json.dumps(dict(client), separators=(",", ":")))
|
|
144
|
+
body = ("{" + ",".join(fields) + "}").encode()
|
|
144
145
|
if self._gzip:
|
|
145
146
|
body = gzip.compress(body)
|
|
146
147
|
|
|
@@ -179,6 +180,27 @@ class Delivery:
|
|
|
179
180
|
logger.debug("Export failed (%s); retrying in %.2fs", failure, delay)
|
|
180
181
|
self._sleep(delay)
|
|
181
182
|
|
|
183
|
+
def _encode_each(
|
|
184
|
+
self, target: _Target, batch: list[SpanPayload]
|
|
185
|
+
) -> tuple[list[SpanPayload], list[str]]:
|
|
186
|
+
"""JSON-encode each item on its own, so one that cannot be encoded loses only itself."""
|
|
187
|
+
kept: list[SpanPayload] = []
|
|
188
|
+
encoded: list[str] = []
|
|
189
|
+
for item in batch:
|
|
190
|
+
try:
|
|
191
|
+
encoded.append(json.dumps(item, separators=(",", ":"), default=str))
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
self.failed += 1
|
|
194
|
+
logger.warning(
|
|
195
|
+
"Dropping %s %s: it cannot be encoded as JSON (%s)",
|
|
196
|
+
target.body_key[:-1],
|
|
197
|
+
item.get(target.id_field),
|
|
198
|
+
type(exc).__name__,
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
kept.append(item)
|
|
202
|
+
return kept, encoded
|
|
203
|
+
|
|
182
204
|
def _split_and_send(
|
|
183
205
|
self, target: _Target, batch: list[SpanPayload], client: Mapping[str, Any] | None
|
|
184
206
|
) -> None:
|
|
@@ -18,7 +18,10 @@ from collections.abc import Mapping
|
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
20
|
MAX_DEPTH = 12
|
|
21
|
-
|
|
21
|
+
# Input and output strings: long enough that the server's 1 MiB payload cap is the one that
|
|
22
|
+
# applies. Attribute strings stay short: the server refuses a span whose attributes pass 64 KB.
|
|
23
|
+
MAX_STRING_LENGTH = 1_000_000
|
|
24
|
+
MAX_ATTRIBUTE_STRING_LENGTH = 32_000
|
|
22
25
|
MAX_ITEMS = 1_000
|
|
23
26
|
|
|
24
27
|
_TRUNCATION_MARKER = "...[truncated]"
|
|
@@ -27,30 +30,41 @@ JsonValue = Any
|
|
|
27
30
|
"""Alias documenting that a value is JSON-compatible (recursive types are awkward)."""
|
|
28
31
|
|
|
29
32
|
|
|
30
|
-
def to_jsonable(value: Any) -> JsonValue:
|
|
33
|
+
def to_jsonable(value: Any, *, max_string: int = MAX_STRING_LENGTH) -> JsonValue:
|
|
31
34
|
"""Convert ``value`` into a JSON-compatible structure.
|
|
32
35
|
|
|
33
36
|
Args:
|
|
34
37
|
value: Any Python object.
|
|
38
|
+
max_string: The longest string kept whole, in characters.
|
|
35
39
|
|
|
36
40
|
Returns:
|
|
37
41
|
A value composed only of ``dict``, ``list``, ``str``, ``int``,
|
|
38
42
|
``float``, ``bool`` and ``None``. Unknown objects are rendered with
|
|
39
43
|
``repr``. Oversized strings and collections are truncated.
|
|
40
44
|
"""
|
|
41
|
-
return to_jsonable_cut(value)[0]
|
|
45
|
+
return to_jsonable_cut(value, max_string=max_string)[0]
|
|
42
46
|
|
|
43
47
|
|
|
44
|
-
def to_jsonable_cut(value: Any) -> tuple[JsonValue, bool]:
|
|
48
|
+
def to_jsonable_cut(value: Any, *, max_string: int = MAX_STRING_LENGTH) -> tuple[JsonValue, bool]:
|
|
45
49
|
"""Like :func:`to_jsonable`, plus whether anything was cut to fit the bounds."""
|
|
46
|
-
cut
|
|
50
|
+
cut = _Cut(max_string)
|
|
47
51
|
try:
|
|
48
|
-
return _convert(value, 0, cut),
|
|
52
|
+
return _convert(value, 0, cut), cut.happened
|
|
49
53
|
except Exception:
|
|
50
|
-
return _truncate_string(_safe_repr(value), cut),
|
|
54
|
+
return _truncate_string(_safe_repr(value), cut), cut.happened
|
|
51
55
|
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
class _Cut:
|
|
58
|
+
"""The string bound of one conversion, and whether anything was cut to fit the bounds."""
|
|
59
|
+
|
|
60
|
+
__slots__ = ("happened", "max_string")
|
|
61
|
+
|
|
62
|
+
def __init__(self, max_string: int) -> None:
|
|
63
|
+
self.max_string = max_string
|
|
64
|
+
self.happened = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _convert(value: Any, depth: int, cut: _Cut) -> JsonValue:
|
|
54
68
|
if value is None or isinstance(value, bool | int):
|
|
55
69
|
return value
|
|
56
70
|
if isinstance(value, float):
|
|
@@ -58,7 +72,7 @@ def _convert(value: Any, depth: int, cut: list[bool]) -> JsonValue:
|
|
|
58
72
|
if isinstance(value, str):
|
|
59
73
|
return _truncate_string(value, cut)
|
|
60
74
|
if depth >= MAX_DEPTH:
|
|
61
|
-
cut.
|
|
75
|
+
cut.happened = True
|
|
62
76
|
return "[max depth reached]"
|
|
63
77
|
|
|
64
78
|
if isinstance(value, Mapping):
|
|
@@ -86,22 +100,22 @@ def _convert(value: Any, depth: int, cut: list[bool]) -> JsonValue:
|
|
|
86
100
|
return _truncate_string(_safe_repr(value), cut)
|
|
87
101
|
|
|
88
102
|
|
|
89
|
-
def _convert_mapping(value: Mapping[Any, Any], depth: int, cut:
|
|
103
|
+
def _convert_mapping(value: Mapping[Any, Any], depth: int, cut: _Cut) -> dict[str, JsonValue]:
|
|
90
104
|
result: dict[str, JsonValue] = {}
|
|
91
105
|
for index, (key, item) in enumerate(value.items()):
|
|
92
106
|
if index >= MAX_ITEMS:
|
|
93
107
|
result["..."] = f"{len(value) - MAX_ITEMS} more keys truncated"
|
|
94
|
-
cut.
|
|
108
|
+
cut.happened = True
|
|
95
109
|
break
|
|
96
110
|
result[str(key)] = _convert(item, depth + 1, cut)
|
|
97
111
|
return result
|
|
98
112
|
|
|
99
113
|
|
|
100
|
-
def _convert_sequence(items: list[Any], depth: int, cut:
|
|
114
|
+
def _convert_sequence(items: list[Any], depth: int, cut: _Cut) -> list[JsonValue]:
|
|
101
115
|
converted = [_convert(item, depth + 1, cut) for item in items[:MAX_ITEMS]]
|
|
102
116
|
if len(items) > MAX_ITEMS:
|
|
103
117
|
converted.append(f"... {len(items) - MAX_ITEMS} more items truncated")
|
|
104
|
-
cut.
|
|
118
|
+
cut.happened = True
|
|
105
119
|
return converted
|
|
106
120
|
|
|
107
121
|
|
|
@@ -122,11 +136,11 @@ def _dump_model(value: Any) -> Any | None:
|
|
|
122
136
|
return None
|
|
123
137
|
|
|
124
138
|
|
|
125
|
-
def _truncate_string(value: str, cut:
|
|
126
|
-
if len(value) <=
|
|
139
|
+
def _truncate_string(value: str, cut: _Cut) -> str:
|
|
140
|
+
if len(value) <= cut.max_string:
|
|
127
141
|
return value
|
|
128
|
-
cut.
|
|
129
|
-
return value[:
|
|
142
|
+
cut.happened = True
|
|
143
|
+
return value[: cut.max_string] + _TRUNCATION_MARKER
|
|
130
144
|
|
|
131
145
|
|
|
132
146
|
def _safe_repr(value: Any) -> str:
|
|
@@ -18,7 +18,7 @@ from collections.abc import Callable, Mapping
|
|
|
18
18
|
from typing import Any, Literal, get_args
|
|
19
19
|
|
|
20
20
|
from ._ids import new_span_id
|
|
21
|
-
from ._serialize import
|
|
21
|
+
from ._serialize import MAX_ATTRIBUTE_STRING_LENGTH, JsonValue, to_jsonable_cut
|
|
22
22
|
from ._trace_state import TraceState
|
|
23
23
|
from ._usage import SpanUsage
|
|
24
24
|
|
|
@@ -91,9 +91,11 @@ class Span(SpanUsage):
|
|
|
91
91
|
self.prompt_version_id: str | None = None
|
|
92
92
|
self.input: JsonValue = None
|
|
93
93
|
self.output: JsonValue = None
|
|
94
|
-
# Whether the SDK cut the input or
|
|
94
|
+
# Whether the SDK cut the input, output or an attribute to fit its bounds; sent as
|
|
95
|
+
# `truncated`.
|
|
95
96
|
self._input_cut = False
|
|
96
97
|
self._output_cut = False
|
|
98
|
+
self._attributes_cut = False
|
|
97
99
|
self.attributes: dict[str, JsonValue] = {}
|
|
98
100
|
|
|
99
101
|
self._on_end = on_end
|
|
@@ -128,12 +130,24 @@ class Span(SpanUsage):
|
|
|
128
130
|
|
|
129
131
|
def set_attribute(self, key: str, value: Any) -> None:
|
|
130
132
|
"""Attach a single custom attribute."""
|
|
131
|
-
if self._mutable:
|
|
132
|
-
|
|
133
|
+
if not self._mutable:
|
|
134
|
+
return
|
|
135
|
+
try:
|
|
136
|
+
converted, cut = to_jsonable_cut(value, max_string=MAX_ATTRIBUTE_STRING_LENGTH)
|
|
137
|
+
self.attributes[str(key)] = converted
|
|
138
|
+
except Exception:
|
|
139
|
+
logger.debug("Ignoring an attribute whose key cannot be turned into text")
|
|
140
|
+
return
|
|
141
|
+
self._attributes_cut = self._attributes_cut or cut
|
|
133
142
|
|
|
134
143
|
def set_attributes(self, attributes: Mapping[str, Any]) -> None:
|
|
135
144
|
"""Attach several custom attributes at once."""
|
|
136
|
-
|
|
145
|
+
try:
|
|
146
|
+
items = dict(attributes).items()
|
|
147
|
+
except Exception:
|
|
148
|
+
logger.debug("set_attributes() needs a mapping, got %s", type(attributes).__name__)
|
|
149
|
+
return
|
|
150
|
+
for key, value in items:
|
|
137
151
|
self.set_attribute(key, value)
|
|
138
152
|
|
|
139
153
|
def set_model(self, model: str | None, *, provider: str | None = None) -> None:
|
|
@@ -186,7 +200,7 @@ class Span(SpanUsage):
|
|
|
186
200
|
elapsed_ns = time.perf_counter_ns() - self._start_perf_ns
|
|
187
201
|
self.time_to_first_token_ms = elapsed_ns / 1_000_000
|
|
188
202
|
|
|
189
|
-
def record_error(self, error: BaseException | str) -> None:
|
|
203
|
+
def record_error(self, error: BaseException | str | None) -> None:
|
|
190
204
|
"""Mark the span as failed.
|
|
191
205
|
|
|
192
206
|
Args:
|
|
@@ -195,12 +209,17 @@ class Span(SpanUsage):
|
|
|
195
209
|
if not self._mutable:
|
|
196
210
|
return
|
|
197
211
|
self.status = "error"
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
212
|
+
message: str | None = None
|
|
213
|
+
try:
|
|
214
|
+
if isinstance(error, BaseException):
|
|
215
|
+
self.attributes["error.type"] = type(error).__name__
|
|
216
|
+
message = str(error) or type(error).__name__
|
|
217
|
+
elif error is not None:
|
|
218
|
+
message = str(error)
|
|
219
|
+
except Exception:
|
|
220
|
+
# The exception's own `__str__` raised; its type is still a useful message.
|
|
221
|
+
message = type(error).__name__
|
|
222
|
+
self.status_message = message[:_MAX_STATUS_MESSAGE] if message else None
|
|
204
223
|
|
|
205
224
|
def end(self) -> None:
|
|
206
225
|
"""Finish the span and hand it to the exporter. Idempotent."""
|
|
@@ -244,7 +263,7 @@ class Span(SpanUsage):
|
|
|
244
263
|
# Sent only when set, so payloads for servers that predate prompts stay unchanged.
|
|
245
264
|
if self.prompt_version_id is not None:
|
|
246
265
|
payload["prompt_version_id"] = self.prompt_version_id
|
|
247
|
-
if self._input_cut or self._output_cut:
|
|
266
|
+
if self._input_cut or self._output_cut or self._attributes_cut:
|
|
248
267
|
payload["truncated"] = True
|
|
249
268
|
return payload
|
|
250
269
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Trace-level state shared by every span of one trace.
|
|
2
|
+
|
|
3
|
+
The server rejects every span of a trace whose trace-level fields break its limits, so the
|
|
4
|
+
SDK makes them fit instead: ``""`` means unset, a non-string becomes its text, text is cut to
|
|
5
|
+
the limit (name, user and session ids 256 characters, environment 64, release 128) and a trace
|
|
6
|
+
keeps at most 20 tags of up to 64 characters. Each kind of fix is logged once per process.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
|
|
15
|
+
from ._ids import new_trace_id
|
|
16
|
+
from ._metadata import clean_metadata, merge_metadata
|
|
17
|
+
from ._serialize import JsonValue
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("spanlight")
|
|
20
|
+
|
|
21
|
+
MAX_TEXT = 256
|
|
22
|
+
MAX_ENVIRONMENT = 64
|
|
23
|
+
MAX_RELEASE = 128
|
|
24
|
+
MAX_TAGS = 20
|
|
25
|
+
MAX_TAG = 64
|
|
26
|
+
|
|
27
|
+
_logged: set[str] = set()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _log_once(key: str, message: str, *args: object) -> None:
|
|
31
|
+
if key not in _logged:
|
|
32
|
+
_logged.add(key)
|
|
33
|
+
logger.warning("Spanlight: " + message + " (logged once)", *args)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clean_text(field: str, value: object, limit: int) -> str | None:
|
|
37
|
+
"""``value`` as text of 1 to ``limit`` characters, as the server accepts it, or ``None``."""
|
|
38
|
+
if value is None:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
text = value if isinstance(value, str) else str(value)
|
|
42
|
+
except Exception:
|
|
43
|
+
_log_once(field + ".text", "trace %s cannot be turned into text; leaving it unset", field)
|
|
44
|
+
return None
|
|
45
|
+
if not text:
|
|
46
|
+
return None
|
|
47
|
+
if len(text) > limit:
|
|
48
|
+
_log_once(field + ".long", "trace %s over %d characters; cutting it", field, limit)
|
|
49
|
+
return text[:limit]
|
|
50
|
+
return text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TraceState:
|
|
54
|
+
"""Mutable trace-level fields shared by every span of one trace.
|
|
55
|
+
|
|
56
|
+
Instances are shared by reference between parent and child spans
|
|
57
|
+
(including children running in other asyncio tasks), so an
|
|
58
|
+
:func:`~spanlight.update_trace` call anywhere in the trace is
|
|
59
|
+
visible to every span exported afterwards.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
trace_id: str | None = None,
|
|
66
|
+
name: str | None = None,
|
|
67
|
+
environment: str | None = None,
|
|
68
|
+
release: str | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.trace_id = trace_id or new_trace_id()
|
|
71
|
+
self.name = clean_text("name", name, MAX_TEXT)
|
|
72
|
+
self.environment = clean_text("environment", environment, MAX_ENVIRONMENT)
|
|
73
|
+
self.release = clean_text("release", release, MAX_RELEASE)
|
|
74
|
+
self.user_id: str | None = None
|
|
75
|
+
self.session_id: str | None = None
|
|
76
|
+
self.tags: list[str] = []
|
|
77
|
+
self.metadata: dict[str, str] = {}
|
|
78
|
+
self._lock = threading.Lock()
|
|
79
|
+
|
|
80
|
+
def update(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
name: str | None = None,
|
|
84
|
+
user_id: str | None = None,
|
|
85
|
+
session_id: str | None = None,
|
|
86
|
+
tags: Iterable[str] | None = None,
|
|
87
|
+
metadata: Mapping[str, object] | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Set trace-level fields. ``None`` or ``""`` leaves a field unchanged.
|
|
90
|
+
|
|
91
|
+
Tags accumulate; a single string is one tag. Metadata accumulates per key, a later value
|
|
92
|
+
replacing an earlier one; invalid entries are dropped (see :mod:`spanlight._metadata`).
|
|
93
|
+
"""
|
|
94
|
+
cleaned = clean_metadata(metadata)
|
|
95
|
+
name = clean_text("name", name, MAX_TEXT)
|
|
96
|
+
user_id = clean_text("user_id", user_id, MAX_TEXT)
|
|
97
|
+
session_id = clean_text("session_id", session_id, MAX_TEXT)
|
|
98
|
+
single = isinstance(tags, str) or not isinstance(tags, Iterable | None)
|
|
99
|
+
new_tags = [clean_text("tag", tag, MAX_TAG) for tag in ([tags] if single else tags or ())]
|
|
100
|
+
with self._lock:
|
|
101
|
+
if name is not None:
|
|
102
|
+
self.name = name
|
|
103
|
+
if user_id is not None:
|
|
104
|
+
self.user_id = user_id
|
|
105
|
+
if session_id is not None:
|
|
106
|
+
self.session_id = session_id
|
|
107
|
+
for tag in new_tags:
|
|
108
|
+
if tag is None or tag in self.tags:
|
|
109
|
+
continue
|
|
110
|
+
if len(self.tags) >= MAX_TAGS:
|
|
111
|
+
_log_once("tags", "a trace keeps at most %d tags; dropping the rest", MAX_TAGS)
|
|
112
|
+
break
|
|
113
|
+
self.tags.append(tag)
|
|
114
|
+
if cleaned:
|
|
115
|
+
self.metadata = merge_metadata(self.metadata, cleaned)
|
|
116
|
+
|
|
117
|
+
def to_payload(self) -> dict[str, JsonValue]:
|
|
118
|
+
"""Return the ``trace`` object of the ingestion payload."""
|
|
119
|
+
with self._lock:
|
|
120
|
+
payload: dict[str, JsonValue] = {
|
|
121
|
+
"name": self.name,
|
|
122
|
+
"environment": self.environment,
|
|
123
|
+
"release": self.release,
|
|
124
|
+
"user_id": self.user_id,
|
|
125
|
+
"session_id": self.session_id,
|
|
126
|
+
"tags": list(self.tags),
|
|
127
|
+
}
|
|
128
|
+
# Only when set: a server older than trace metadata ignores the field anyway.
|
|
129
|
+
if self.metadata:
|
|
130
|
+
payload["metadata"] = dict(self.metadata)
|
|
131
|
+
return payload
|
|
@@ -13,6 +13,10 @@ from ._tiers import MAX_RAW_TIER_LENGTH, ServiceTier, normalize_tier
|
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger("spanlight")
|
|
15
15
|
|
|
16
|
+
# Outside these bounds the server rejects the whole span (token counts are stored as int4).
|
|
17
|
+
_MAX_TOKENS = 2_000_000_000
|
|
18
|
+
_MAX_WEB_SEARCH_REQUESTS = 100_000
|
|
19
|
+
|
|
16
20
|
|
|
17
21
|
class SpanUsage:
|
|
18
22
|
"""Usage fields and setters of a span; see :meth:`set_usage`."""
|
|
@@ -62,23 +66,33 @@ class SpanUsage:
|
|
|
62
66
|
standard. ``web_search_requests`` is the number of server-side web
|
|
63
67
|
searches the call made; the provider wrappers read it from the
|
|
64
68
|
response.
|
|
69
|
+
|
|
70
|
+
A count that is not a whole number from 0 up to the server's limit
|
|
71
|
+
(2,000,000,000 tokens, 100,000 searches) is ignored with a debug log,
|
|
72
|
+
because the server would reject the whole span.
|
|
65
73
|
"""
|
|
66
74
|
if not self._mutable:
|
|
67
75
|
return
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
counts = {
|
|
77
|
+
"input_tokens": (input_tokens, _MAX_TOKENS),
|
|
78
|
+
"output_tokens": (output_tokens, _MAX_TOKENS),
|
|
79
|
+
"cached_tokens": (cached_tokens, _MAX_TOKENS),
|
|
80
|
+
"cache_write_tokens": (cache_write_tokens, _MAX_TOKENS),
|
|
81
|
+
"cache_write_1h_tokens": (cache_write_1h_tokens, _MAX_TOKENS),
|
|
82
|
+
"web_search_requests": (web_search_requests, _MAX_WEB_SEARCH_REQUESTS),
|
|
83
|
+
}
|
|
84
|
+
for field, (value, limit) in counts.items():
|
|
85
|
+
if value is not None:
|
|
86
|
+
count = _count(value, limit)
|
|
87
|
+
if count is None:
|
|
88
|
+
logger.debug("Ignoring %s: not a count the server accepts", field)
|
|
89
|
+
else:
|
|
90
|
+
setattr(self, field, count)
|
|
78
91
|
if service_tier is not None:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
try:
|
|
93
|
+
self._set_service_tier(str(service_tier))
|
|
94
|
+
except Exception:
|
|
95
|
+
logger.debug("Ignoring a service tier that cannot be turned into text")
|
|
82
96
|
|
|
83
97
|
def _set_service_tier(self, raw: str) -> None:
|
|
84
98
|
if not raw.strip():
|
|
@@ -121,3 +135,12 @@ class SpanUsage:
|
|
|
121
135
|
if self.web_search_requests is not None:
|
|
122
136
|
usage["server_tool_use"] = {"web_search_requests": self.web_search_requests}
|
|
123
137
|
return usage or None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _count(value: object, limit: int) -> int | None:
|
|
141
|
+
"""``value`` as a count from 0 to ``limit``, or ``None`` when it is not one."""
|
|
142
|
+
try:
|
|
143
|
+
count = int(value) # type: ignore[call-overload]
|
|
144
|
+
except Exception:
|
|
145
|
+
return None
|
|
146
|
+
return count if 0 <= count <= limit else None
|
|
@@ -9,13 +9,13 @@ observes streaming responses as the caller consumes them.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import functools
|
|
12
13
|
import logging
|
|
13
14
|
from abc import ABC, abstractmethod
|
|
14
15
|
from collections.abc import Callable, Mapping
|
|
15
16
|
from typing import TYPE_CHECKING, Any
|
|
16
17
|
|
|
17
18
|
from .._request_hash import request_hash
|
|
18
|
-
from .._serialize import to_jsonable
|
|
19
19
|
from .._span import Span
|
|
20
20
|
from ._modifiers import SENTINEL_TYPE_NAMES, pricing_modifier_unknown
|
|
21
21
|
from ._provider_errors import provider_request_id, record_response_details
|
|
@@ -93,17 +93,14 @@ def client_getter(spanlight: Spanlight | None) -> ClientGetter:
|
|
|
93
93
|
def sanitize_request(kwargs: Mapping[str, Any]) -> dict[str, Any]:
|
|
94
94
|
"""Return the request parameters worth recording as span input.
|
|
95
95
|
|
|
96
|
-
Drops transport/auth options and the SDKs' "not given" sentinels
|
|
97
|
-
|
|
96
|
+
Drops transport/auth options and the SDKs' "not given" sentinels. The values are
|
|
97
|
+
converted by ``Span.set_input``, which also records whether anything was cut.
|
|
98
98
|
"""
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
continue
|
|
105
|
-
cleaned[key] = to_jsonable(value)
|
|
106
|
-
return cleaned
|
|
99
|
+
return {
|
|
100
|
+
key: value
|
|
101
|
+
for key, value in kwargs.items()
|
|
102
|
+
if key not in _EXCLUDED_REQUEST_KEYS and type(value).__name__ not in SENTINEL_TYPE_NAMES
|
|
103
|
+
}
|
|
107
104
|
|
|
108
105
|
|
|
109
106
|
def is_raw_response_call(kwargs: Mapping[str, Any]) -> bool:
|
|
@@ -189,9 +186,9 @@ def start_llm_span(
|
|
|
189
186
|
span = client.start_span(endpoint.span_name(kwargs), kind="llm")
|
|
190
187
|
model = kwargs.get("model")
|
|
191
188
|
span.set_model(model if isinstance(model, str) else None, provider=endpoint.provider)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
span.
|
|
189
|
+
span.set_input(sanitize_request(kwargs))
|
|
190
|
+
# From the request as recorded, the same JSON values the server hashes.
|
|
191
|
+
span.set_request_hash(_request_hash_or_none(model, span.input))
|
|
195
192
|
if kwargs.get("stream") is True:
|
|
196
193
|
span.set_attribute("request.stream", True)
|
|
197
194
|
modifier = pricing_modifier_unknown(kwargs)
|
|
@@ -245,3 +242,18 @@ def patch_method(
|
|
|
245
242
|
if original is None or getattr(original, WRAPPED_MARKER, False):
|
|
246
243
|
return
|
|
247
244
|
setattr(resource, attribute, make_wrapper(original))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def patch_copies(client: Any, instrument: Callable[[Any], Any]) -> None:
|
|
248
|
+
"""Instrument the clients that ``client.copy()`` and ``client.with_options()`` return, too."""
|
|
249
|
+
|
|
250
|
+
def make_wrapper(original: Callable[..., Any]) -> Callable[..., Any]:
|
|
251
|
+
@functools.wraps(original)
|
|
252
|
+
def copy(*args: Any, **kwargs: Any) -> Any:
|
|
253
|
+
return instrument(original(*args, **kwargs))
|
|
254
|
+
|
|
255
|
+
setattr(copy, WRAPPED_MARKER, True)
|
|
256
|
+
return copy
|
|
257
|
+
|
|
258
|
+
for attribute in ("copy", "with_options"):
|
|
259
|
+
patch_method(client, attribute, make_wrapper)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Instrumentation for the official ``anthropic`` Python SDK.
|
|
2
2
|
|
|
3
3
|
:func:`wrap_anthropic` patches ``messages.create`` (including
|
|
4
|
-
``stream=True``), the ``messages.stream(...)`` helper and
|
|
5
|
-
``messages.batches.results`` on one client instance, sync or async
|
|
4
|
+
``stream=True``), ``messages.parse``, the ``messages.stream(...)`` helper and
|
|
5
|
+
``messages.batches.results`` on one client instance, sync or async, on its beta
|
|
6
|
+
surface (``client.beta.messages``) and on the clients its ``copy()`` and
|
|
7
|
+
``with_options()`` return.
|
|
6
8
|
|
|
7
9
|
Token accounting: Anthropic reports ``input_tokens`` *excluding* prompt-cache
|
|
8
10
|
reads and writes. To keep one meaning across providers, spans record
|
|
@@ -25,9 +27,11 @@ from ._anthropic_message import PROVIDER, record_message_metadata, record_usage
|
|
|
25
27
|
from ._anthropic_stream import AsyncStreamManager, MessagesStreamAccumulator, SyncStreamManager
|
|
26
28
|
from ._common import (
|
|
27
29
|
WRAPPED_MARKER,
|
|
30
|
+
ClientGetter,
|
|
28
31
|
Endpoint,
|
|
29
32
|
client_getter,
|
|
30
33
|
get_field,
|
|
34
|
+
patch_copies,
|
|
31
35
|
patch_method,
|
|
32
36
|
pop_prompt,
|
|
33
37
|
)
|
|
@@ -51,7 +55,10 @@ def wrap_anthropic(client: ClientT, *, spanlight: Spanlight | None = None) -> Cl
|
|
|
51
55
|
are recorded and re-raised unchanged. Each result read through
|
|
52
56
|
``messages.batches.results`` becomes an ``llm`` span at the batch rate in
|
|
53
57
|
a trace of its own, with ids fixed by the batch, so reading a batch twice
|
|
54
|
-
records it once.
|
|
58
|
+
records it once. ``messages.parse`` and the same methods of
|
|
59
|
+
``client.beta.messages`` are traced too, and so are clients made with
|
|
60
|
+
``client.with_options(...)`` or ``client.copy(...)``. Wrapping twice is
|
|
61
|
+
harmless.
|
|
55
62
|
|
|
56
63
|
Args:
|
|
57
64
|
client: The Anthropic client to instrument.
|
|
@@ -71,15 +78,26 @@ def _instrument(client: Any, spanlight: Spanlight | None) -> None:
|
|
|
71
78
|
from anthropic import AsyncStream, Stream
|
|
72
79
|
from anthropic.resources.messages import AsyncMessages
|
|
73
80
|
|
|
74
|
-
from ._anthropic_batch import patch_batch_results
|
|
75
|
-
|
|
76
81
|
get_client = client_getter(spanlight)
|
|
77
82
|
endpoint = MessagesEndpoint(PROVIDER, (Stream, AsyncStream))
|
|
78
|
-
messages
|
|
79
|
-
|
|
83
|
+
_instrument_messages(client.messages, endpoint, get_client, AsyncMessages)
|
|
84
|
+
patch_copies(client, lambda copy: wrap_anthropic(copy, spanlight=spanlight))
|
|
85
|
+
# Fast mode, compaction and the tool runner exist only on the beta surface.
|
|
86
|
+
from anthropic.resources.beta.messages import AsyncMessages as AsyncBetaMessages
|
|
87
|
+
|
|
88
|
+
_instrument_messages(client.beta.messages, endpoint, get_client, AsyncBetaMessages)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _instrument_messages(
|
|
92
|
+
messages: Any, endpoint: MessagesEndpoint, get_client: ClientGetter, async_cls: type
|
|
93
|
+
) -> None:
|
|
94
|
+
from ._anthropic_batch import patch_batch_results
|
|
80
95
|
|
|
96
|
+
is_async = isinstance(messages, async_cls)
|
|
81
97
|
wrap = wrap_async_method if is_async else wrap_sync_method
|
|
82
|
-
|
|
98
|
+
# `parse` is a separate request, not a call to `create`, so it needs its own span.
|
|
99
|
+
for name in ("create", "parse"):
|
|
100
|
+
patch_method(messages, name, lambda original: wrap(original, endpoint, get_client))
|
|
83
101
|
|
|
84
102
|
manager_cls = AsyncStreamManager if is_async else SyncStreamManager
|
|
85
103
|
|
|
@@ -13,7 +13,15 @@ from collections.abc import Mapping
|
|
|
13
13
|
from typing import TYPE_CHECKING, Any, TypeVar
|
|
14
14
|
|
|
15
15
|
from .._span import Span
|
|
16
|
-
from ._common import
|
|
16
|
+
from ._common import (
|
|
17
|
+
ClientGetter,
|
|
18
|
+
Endpoint,
|
|
19
|
+
as_int,
|
|
20
|
+
client_getter,
|
|
21
|
+
get_field,
|
|
22
|
+
patch_copies,
|
|
23
|
+
patch_method,
|
|
24
|
+
)
|
|
17
25
|
from ._modifiers import has_request_option
|
|
18
26
|
from ._openai_response import (
|
|
19
27
|
chat_output,
|
|
@@ -48,7 +56,8 @@ def wrap_openai(
|
|
|
48
56
|
Every ``chat.completions.create`` and ``responses.create`` call becomes an
|
|
49
57
|
``llm`` span with the model, token usage, time-to-first-token (streams),
|
|
50
58
|
sanitized request parameters and the response. Errors are recorded and
|
|
51
|
-
re-raised unchanged.
|
|
59
|
+
re-raised unchanged. Clients made with ``client.with_options(...)`` or
|
|
60
|
+
``client.copy(...)`` are instrumented too. Wrapping the same client twice is harmless.
|
|
52
61
|
|
|
53
62
|
Args:
|
|
54
63
|
client: The OpenAI client to instrument.
|
|
@@ -90,6 +99,7 @@ def _instrument(client: Any, spanlight: Spanlight | None, provider: str) -> None
|
|
|
90
99
|
_patch_create(
|
|
91
100
|
resource, endpoint, get_client, is_async=isinstance(resource, async_resources)
|
|
92
101
|
)
|
|
102
|
+
patch_copies(client, lambda copy: wrap_openai(copy, spanlight=spanlight, provider=provider))
|
|
93
103
|
|
|
94
104
|
|
|
95
105
|
def _patch_create(
|
|
@@ -251,6 +251,27 @@ async def test_async_messages_create(tracer, ingest):
|
|
|
251
251
|
assert span["usage"]["output_tokens"] == 9
|
|
252
252
|
|
|
253
253
|
|
|
254
|
+
def test_with_options_and_copy_clients_are_traced(tracer, ingest):
|
|
255
|
+
provider = MockProvider(json_response(ANTHROPIC_MESSAGE))
|
|
256
|
+
client = make_client(provider, tracer)
|
|
257
|
+
|
|
258
|
+
client.with_options(timeout=5).messages.create(model=MODEL, max_tokens=8, messages=MESSAGES)
|
|
259
|
+
client.copy().messages.create(model=MODEL, max_tokens=8, messages=MESSAGES)
|
|
260
|
+
|
|
261
|
+
assert len(flushed_spans(tracer, ingest)) == 2
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_beta_messages_are_traced(tracer, ingest):
|
|
265
|
+
client = make_client(MockProvider(json_response(ANTHROPIC_MESSAGE)), tracer)
|
|
266
|
+
client.beta.messages.create(model=MODEL, max_tokens=8, messages=MESSAGES, betas=["x-1"])
|
|
267
|
+
streaming = make_client(MockProvider(sse_response(anthropic_text_stream())), tracer)
|
|
268
|
+
with streaming.beta.messages.stream(model=MODEL, max_tokens=8, messages=MESSAGES) as stream:
|
|
269
|
+
"".join(stream.text_stream)
|
|
270
|
+
|
|
271
|
+
spans = flushed_spans(tracer, ingest)
|
|
272
|
+
assert [span["usage"]["input_tokens"] for span in spans] == [1515, 25]
|
|
273
|
+
|
|
274
|
+
|
|
254
275
|
def test_create_with_stream_true(tracer, ingest):
|
|
255
276
|
provider = MockProvider(sse_response(anthropic_text_stream()))
|
|
256
277
|
client = make_client(provider, tracer)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import re
|
|
6
7
|
|
|
7
8
|
import pytest
|
|
@@ -389,6 +390,24 @@ async def test_async_generator_error_and_early_close(tracer, ingest):
|
|
|
389
390
|
assert ingest.span_named("endless")["output"] == [0, 1]
|
|
390
391
|
|
|
391
392
|
|
|
393
|
+
def test_async_generator_abandoned_by_break_is_not_an_error(tracer, ingest):
|
|
394
|
+
@tracer.observe(name="abandoned")
|
|
395
|
+
async def numbers():
|
|
396
|
+
for index in range(10):
|
|
397
|
+
yield index
|
|
398
|
+
|
|
399
|
+
async def main() -> None:
|
|
400
|
+
async for value in numbers():
|
|
401
|
+
if value == 1:
|
|
402
|
+
break
|
|
403
|
+
|
|
404
|
+
# asyncio closes the abandoned generator by cancelling its `aclose()` at loop shutdown.
|
|
405
|
+
asyncio.run(main())
|
|
406
|
+
|
|
407
|
+
flushed_spans(tracer, ingest)
|
|
408
|
+
assert ingest.span_named("abandoned")["status"] == "ok"
|
|
409
|
+
|
|
410
|
+
|
|
392
411
|
# ---------------------------------------------------------------------- #
|
|
393
412
|
# Disabled mode
|
|
394
413
|
# ---------------------------------------------------------------------- #
|
|
@@ -331,6 +331,19 @@ def test_unexpected_transport_failure_never_escapes(make_exporter, ingest):
|
|
|
331
331
|
assert exporter.sent_count == 1
|
|
332
332
|
|
|
333
333
|
|
|
334
|
+
def test_an_unencodable_span_loses_only_itself(make_exporter, ingest, caplog):
|
|
335
|
+
exporter = make_exporter()
|
|
336
|
+
exporter.export(make_span(1))
|
|
337
|
+
exporter.export({**make_span(2), "attributes": {"huge": 10**5000}})
|
|
338
|
+
exporter.export(make_span(3))
|
|
339
|
+
|
|
340
|
+
with caplog.at_level(logging.WARNING, logger="spanlight"):
|
|
341
|
+
assert exporter.flush(timeout=5)
|
|
342
|
+
assert [span["span_id"] for span in ingest.spans] == [f"{1:016x}", f"{3:016x}"]
|
|
343
|
+
assert exporter.failed_count == 1
|
|
344
|
+
assert f"{2:016x}" in caplog.text
|
|
345
|
+
|
|
346
|
+
|
|
334
347
|
def test_flush_returns_false_when_the_timeout_expires(make_exporter, ingest):
|
|
335
348
|
release = threading.Event()
|
|
336
349
|
|
|
@@ -192,6 +192,38 @@ def test_manual_record_error_with_message(tracer, ingest):
|
|
|
192
192
|
assert exported["status_message"] == "schema mismatch"
|
|
193
193
|
|
|
194
194
|
|
|
195
|
+
def test_exception_whose_str_raises_is_reraised_unchanged(tracer, ingest):
|
|
196
|
+
class Unprintable(Exception):
|
|
197
|
+
def __str__(self) -> str:
|
|
198
|
+
raise RuntimeError("no text")
|
|
199
|
+
|
|
200
|
+
with pytest.raises(Unprintable), tracer.span("fails"):
|
|
201
|
+
raise Unprintable
|
|
202
|
+
|
|
203
|
+
exported = flushed_spans(tracer, ingest)[0]
|
|
204
|
+
assert exported["status"] == "error"
|
|
205
|
+
assert exported["status_message"] == "Unprintable"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_setters_never_raise_on_bad_values(tracer, ingest):
|
|
209
|
+
class NoText:
|
|
210
|
+
def __str__(self) -> str:
|
|
211
|
+
raise RuntimeError("no text")
|
|
212
|
+
|
|
213
|
+
with tracer.span("bad-values", attributes=[("pairs", "work")]) as span: # type: ignore[arg-type]
|
|
214
|
+
span.set_usage(input_tokens="abc", output_tokens=-1, cached_tokens=3) # type: ignore[arg-type]
|
|
215
|
+
span.set_usage(web_search_requests=10**6, service_tier=NoText()) # type: ignore[arg-type]
|
|
216
|
+
span.set_attributes(None) # type: ignore[arg-type]
|
|
217
|
+
span.set_attribute(NoText(), 1) # type: ignore[arg-type]
|
|
218
|
+
span.record_error(None)
|
|
219
|
+
|
|
220
|
+
exported = flushed_spans(tracer, ingest)[0]
|
|
221
|
+
assert exported["usage"] == {"cached_tokens": 3}
|
|
222
|
+
assert exported["attributes"] == {"pairs": "work"}
|
|
223
|
+
assert exported["status"] == "error"
|
|
224
|
+
assert exported["status_message"] is None
|
|
225
|
+
|
|
226
|
+
|
|
195
227
|
def test_setters_after_end_are_ignored(tracer, ingest):
|
|
196
228
|
with tracer.span("done") as span:
|
|
197
229
|
span.set_output("final")
|
|
@@ -266,6 +298,28 @@ def test_update_trace_applies_to_all_spans_of_the_trace(tracer, ingest):
|
|
|
266
298
|
}
|
|
267
299
|
|
|
268
300
|
|
|
301
|
+
def test_trace_fields_are_made_to_fit_the_server_limits(ingest):
|
|
302
|
+
tracer = spanlight.Spanlight(
|
|
303
|
+
api_key="spl_live_testprefix_testsecret",
|
|
304
|
+
host="http://spanlight.test",
|
|
305
|
+
environment="e" * 65,
|
|
306
|
+
release="r" * 129,
|
|
307
|
+
transport=ingest.transport(),
|
|
308
|
+
)
|
|
309
|
+
with tracer.span("root"):
|
|
310
|
+
spanlight.update_trace(name="", user_id="", session_id="s" * 257, tags="beta")
|
|
311
|
+
spanlight.update_trace(user_id=42, tags=[*map(str, range(25)), "t" * 65, ""]) # type: ignore[arg-type]
|
|
312
|
+
|
|
313
|
+
trace = flushed_spans(tracer, ingest)[0]["trace"]
|
|
314
|
+
tracer.shutdown(timeout=2)
|
|
315
|
+
assert trace["name"] == "root"
|
|
316
|
+
assert trace["environment"] == "e" * 64
|
|
317
|
+
assert trace["release"] == "r" * 128
|
|
318
|
+
assert trace["user_id"] == "42"
|
|
319
|
+
assert trace["session_id"] == "s" * 256
|
|
320
|
+
assert trace["tags"] == ["beta", *map(str, range(19))]
|
|
321
|
+
|
|
322
|
+
|
|
269
323
|
def test_update_trace_outside_a_span_is_a_no_op():
|
|
270
324
|
spanlight.update_trace(user_id="nobody") # must not raise
|
|
271
325
|
|
|
@@ -343,3 +397,17 @@ def test_threads_need_an_explicit_context_copy(tracer, ingest):
|
|
|
343
397
|
copied.join()
|
|
344
398
|
|
|
345
399
|
assert seen == {"plain": None, "copied": "main"}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_a_200_kb_input_is_sent_whole_and_a_cut_attribute_flags_the_span(tracer, ingest):
|
|
403
|
+
context = "retrieved passage " * 12_000 # about 216 KB, one string
|
|
404
|
+
with tracer.span("rag", kind="llm") as span:
|
|
405
|
+
span.set_input({"messages": [{"role": "user", "content": context}]})
|
|
406
|
+
with tracer.span("long-attribute") as span:
|
|
407
|
+
span.set_attribute("note", "x" * 40_000)
|
|
408
|
+
|
|
409
|
+
by_name = {item["name"]: item for item in flushed_spans(tracer, ingest)}
|
|
410
|
+
assert by_name["rag"]["input"]["messages"][0]["content"] == context
|
|
411
|
+
assert "truncated" not in by_name["rag"]
|
|
412
|
+
assert len(by_name["long-attribute"]["attributes"]["note"]) < 40_000
|
|
413
|
+
assert by_name["long-attribute"]["truncated"] is True
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
"""Trace-level state shared by every span of one trace."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import threading
|
|
6
|
-
from collections.abc import Iterable, Mapping
|
|
7
|
-
|
|
8
|
-
from ._ids import new_trace_id
|
|
9
|
-
from ._metadata import clean_metadata, merge_metadata
|
|
10
|
-
from ._serialize import JsonValue
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class TraceState:
|
|
14
|
-
"""Mutable trace-level fields shared by every span of one trace.
|
|
15
|
-
|
|
16
|
-
Instances are shared by reference between parent and child spans
|
|
17
|
-
(including children running in other asyncio tasks), so an
|
|
18
|
-
:func:`~spanlight.update_trace` call anywhere in the trace is
|
|
19
|
-
visible to every span exported afterwards.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
def __init__(
|
|
23
|
-
self,
|
|
24
|
-
*,
|
|
25
|
-
trace_id: str | None = None,
|
|
26
|
-
name: str | None = None,
|
|
27
|
-
environment: str | None = None,
|
|
28
|
-
release: str | None = None,
|
|
29
|
-
) -> None:
|
|
30
|
-
self.trace_id = trace_id or new_trace_id()
|
|
31
|
-
self.name = name
|
|
32
|
-
self.environment = environment
|
|
33
|
-
self.release = release
|
|
34
|
-
self.user_id: str | None = None
|
|
35
|
-
self.session_id: str | None = None
|
|
36
|
-
self.tags: list[str] = []
|
|
37
|
-
self.metadata: dict[str, str] = {}
|
|
38
|
-
self._lock = threading.Lock()
|
|
39
|
-
|
|
40
|
-
def update(
|
|
41
|
-
self,
|
|
42
|
-
*,
|
|
43
|
-
name: str | None = None,
|
|
44
|
-
user_id: str | None = None,
|
|
45
|
-
session_id: str | None = None,
|
|
46
|
-
tags: Iterable[str] | None = None,
|
|
47
|
-
metadata: Mapping[str, object] | None = None,
|
|
48
|
-
) -> None:
|
|
49
|
-
"""Set trace-level fields. ``None`` leaves a field unchanged.
|
|
50
|
-
|
|
51
|
-
Tags accumulate. Metadata accumulates per key, a later value replacing an earlier one;
|
|
52
|
-
invalid entries are dropped (see :mod:`spanlight._metadata`).
|
|
53
|
-
"""
|
|
54
|
-
cleaned = clean_metadata(metadata)
|
|
55
|
-
with self._lock:
|
|
56
|
-
if name is not None:
|
|
57
|
-
self.name = name
|
|
58
|
-
if user_id is not None:
|
|
59
|
-
self.user_id = user_id
|
|
60
|
-
if session_id is not None:
|
|
61
|
-
self.session_id = session_id
|
|
62
|
-
if tags is not None:
|
|
63
|
-
for tag in tags:
|
|
64
|
-
if tag not in self.tags:
|
|
65
|
-
self.tags.append(str(tag))
|
|
66
|
-
if cleaned:
|
|
67
|
-
self.metadata = merge_metadata(self.metadata, cleaned)
|
|
68
|
-
|
|
69
|
-
def to_payload(self) -> dict[str, JsonValue]:
|
|
70
|
-
"""Return the ``trace`` object of the ingestion payload."""
|
|
71
|
-
with self._lock:
|
|
72
|
-
payload: dict[str, JsonValue] = {
|
|
73
|
-
"name": self.name,
|
|
74
|
-
"environment": self.environment,
|
|
75
|
-
"release": self.release,
|
|
76
|
-
"user_id": self.user_id,
|
|
77
|
-
"session_id": self.session_id,
|
|
78
|
-
"tags": list(self.tags),
|
|
79
|
-
}
|
|
80
|
-
# Only when set: a server older than trace metadata ignores the field anyway.
|
|
81
|
-
if self.metadata:
|
|
82
|
-
payload["metadata"] = dict(self.metadata)
|
|
83
|
-
return payload
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|