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.
Files changed (76) hide show
  1. {spanlight-0.2.0 → spanlight-0.2.1}/PKG-INFO +1 -1
  2. {spanlight-0.2.0 → spanlight-0.2.1}/pyproject.toml +1 -1
  3. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_decorator.py +15 -1
  4. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_exporter.py +2 -0
  5. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_exporter_delivery.py +25 -3
  6. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_serialize.py +31 -17
  7. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_span.py +32 -13
  8. spanlight-0.2.1/src/spanlight/_trace_state.py +131 -0
  9. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_usage.py +36 -13
  10. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_version.py +1 -1
  11. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_common.py +26 -14
  12. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/anthropic.py +26 -8
  13. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/openai.py +12 -2
  14. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_anthropic.py +21 -0
  15. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_decorator.py +19 -0
  16. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_exporter.py +13 -0
  17. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_spans_and_context.py +68 -0
  18. {spanlight-0.2.0 → spanlight-0.2.1}/uv.lock +1 -1
  19. spanlight-0.2.0/src/spanlight/_trace_state.py +0 -83
  20. {spanlight-0.2.0 → spanlight-0.2.1}/.gitignore +0 -0
  21. {spanlight-0.2.0 → spanlight-0.2.1}/LICENSE +0 -0
  22. {spanlight-0.2.0 → spanlight-0.2.1}/README.md +0 -0
  23. {spanlight-0.2.0 → spanlight-0.2.1}/examples/support_bot.py +0 -0
  24. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/__init__.py +0 -0
  25. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_client.py +0 -0
  26. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_config.py +0 -0
  27. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_context.py +0 -0
  28. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments.py +0 -0
  29. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments_api.py +0 -0
  30. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments_parse.py +0 -0
  31. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments_report.py +0 -0
  32. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments_runs.py +0 -0
  33. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_experiments_setup.py +0 -0
  34. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_globals.py +0 -0
  35. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_ids.py +0 -0
  36. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_metadata.py +0 -0
  37. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_prompt_client.py +0 -0
  38. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_prompts.py +0 -0
  39. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_ratelimit_headers.py +0 -0
  40. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_request_hash.py +0 -0
  41. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_scores.py +0 -0
  42. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_templates.py +0 -0
  43. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/_tiers.py +0 -0
  44. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/__init__.py +0 -0
  45. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_anthropic_batch.py +0 -0
  46. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_anthropic_message.py +0 -0
  47. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_anthropic_stream.py +0 -0
  48. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_batch.py +0 -0
  49. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_modifiers.py +0 -0
  50. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_openai_batch.py +0 -0
  51. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_openai_response.py +0 -0
  52. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_openai_stream.py +0 -0
  53. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_provider_errors.py +0 -0
  54. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_streams.py +0 -0
  55. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/integrations/_wrappers.py +0 -0
  56. {spanlight-0.2.0 → spanlight-0.2.1}/src/spanlight/py.typed +0 -0
  57. {spanlight-0.2.0 → spanlight-0.2.1}/tests/conftest.py +0 -0
  58. {spanlight-0.2.0 → spanlight-0.2.1}/tests/fixtures/template_cases.json +0 -0
  59. {spanlight-0.2.0 → spanlight-0.2.1}/tests/provider_mocks.py +0 -0
  60. {spanlight-0.2.0 → spanlight-0.2.1}/tests/support.py +0 -0
  61. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_anthropic_batch_results.py +0 -0
  62. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_anthropic_cut_stream.py +0 -0
  63. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_docs_snippets.py +0 -0
  64. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_experiments_report.py +0 -0
  65. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_exporter_counters.py +0 -0
  66. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_exporter_retry_after.py +0 -0
  67. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_gateway_refusal.py +0 -0
  68. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_metadata.py +0 -0
  69. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_openai.py +0 -0
  70. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_openai_batch_output.py +0 -0
  71. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_openai_tier_and_search.py +0 -0
  72. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_package.py +0 -0
  73. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_provider_errors.py +0 -0
  74. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_ratelimit_allowlist.py +0 -0
  75. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_templates_surrogates.py +0 -0
  76. {spanlight-0.2.0 → spanlight-0.2.1}/tests/test_tiers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: spanlight
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Python SDK for Spanlight, open-source observability for LLM applications.
5
5
  Author: Dheeraj
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spanlight"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Python SDK for Spanlight, open-source observability for LLM applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- _finish(span, error=exc)
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
- envelope: dict[str, Any] = {target.body_key: batch}
141
+ fields = [f'"{target.body_key}":[{",".join(encoded)}]']
141
142
  if client is not None and target.body_key == SPANS:
142
- envelope["client"] = dict(client)
143
- body = json.dumps(envelope, separators=(",", ":"), default=str).encode()
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
- MAX_STRING_LENGTH = 32_000
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: list[bool] = []
50
+ cut = _Cut(max_string)
47
51
  try:
48
- return _convert(value, 0, cut), bool(cut)
52
+ return _convert(value, 0, cut), cut.happened
49
53
  except Exception:
50
- return _truncate_string(_safe_repr(value), cut), bool(cut)
54
+ return _truncate_string(_safe_repr(value), cut), cut.happened
51
55
 
52
56
 
53
- def _convert(value: Any, depth: int, cut: list[bool]) -> JsonValue:
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.append(True)
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: list[bool]) -> dict[str, JsonValue]:
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.append(True)
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: list[bool]) -> list[JsonValue]:
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.append(True)
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: list[bool]) -> str:
126
- if len(value) <= MAX_STRING_LENGTH:
139
+ def _truncate_string(value: str, cut: _Cut) -> str:
140
+ if len(value) <= cut.max_string:
127
141
  return value
128
- cut.append(True)
129
- return value[:MAX_STRING_LENGTH] + _TRUNCATION_MARKER
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 JsonValue, to_jsonable, to_jsonable_cut
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 output to fit its bounds; sent as `truncated`.
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
- self.attributes[str(key)] = to_jsonable(value)
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
- for key, value in attributes.items():
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
- if isinstance(error, BaseException):
199
- message = str(error) or type(error).__name__
200
- self.attributes["error.type"] = type(error).__name__
201
- else:
202
- message = error
203
- self.status_message = message[:_MAX_STATUS_MESSAGE]
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
- if input_tokens is not None:
69
- self.input_tokens = int(input_tokens)
70
- if output_tokens is not None:
71
- self.output_tokens = int(output_tokens)
72
- if cached_tokens is not None:
73
- self.cached_tokens = int(cached_tokens)
74
- if cache_write_tokens is not None:
75
- self.cache_write_tokens = int(cache_write_tokens)
76
- if cache_write_1h_tokens is not None:
77
- self.cache_write_1h_tokens = int(cache_write_1h_tokens)
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
- self._set_service_tier(str(service_tier))
80
- if web_search_requests is not None:
81
- self.web_search_requests = int(web_search_requests)
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
@@ -1,3 +1,3 @@
1
1
  """Package version, kept in one place."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
@@ -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, then
97
- converts everything to JSON-compatible values.
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
- cleaned: dict[str, Any] = {}
100
- for key, value in kwargs.items():
101
- if key in _EXCLUDED_REQUEST_KEYS:
102
- continue
103
- if type(value).__name__ in SENTINEL_TYPE_NAMES:
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
- request = sanitize_request(kwargs)
193
- span.set_request_hash(_request_hash_or_none(model, request))
194
- span.set_input(request)
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. Wrapping twice is harmless.
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 = client.messages
79
- is_async = isinstance(messages, AsyncMessages)
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
- patch_method(messages, "create", lambda original: wrap(original, endpoint, get_client))
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 ClientGetter, Endpoint, as_int, client_getter, get_field, patch_method
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. Wrapping the same client twice is harmless.
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
@@ -838,7 +838,7 @@ wheels = [
838
838
 
839
839
  [[package]]
840
840
  name = "spanlight"
841
- version = "0.2.0"
841
+ version = "0.2.1"
842
842
  source = { editable = "." }
843
843
  dependencies = [
844
844
  { name = "httpx" },
@@ -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