spanlight 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ """Fetching prompts from ``GET /v1/prompts/{name}`` with a small revalidating cache.
2
+
3
+ The cache is keyed by ``(name, label, version)``. A fresh entry is served without a request;
4
+ an expired one is revalidated with ``If-None-Match``, so an unchanged prompt costs a ``304``.
5
+ When the server cannot be reached or answers with an error, an expired entry keeps being
6
+ served (a prompt outage must not become an application outage). With nothing cached, the
7
+ caller's ``fallback`` is used; with no fallback either, :class:`PromptUnavailableError` is
8
+ raised.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import threading
15
+ import time
16
+ from collections.abc import Callable, Mapping, Sequence
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+ from urllib.parse import quote
20
+
21
+ import httpx
22
+
23
+ from ._prompts import Prompt, PromptUnavailableError, fallback_prompt, prompt_from_wire
24
+ from ._templates import InvalidTemplateError
25
+ from ._version import __version__
26
+
27
+ logger = logging.getLogger("spanlight")
28
+
29
+ CacheKey = tuple[str, str, int | None]
30
+ Fallback = str | Sequence[Mapping[str, Any]]
31
+
32
+ _WARNING_INTERVAL = 60.0
33
+ # Seconds a failed fetch is remembered, so callers queued behind it do not each time out.
34
+ _FAILURE_MEMO = 5.0
35
+
36
+
37
+ @dataclass
38
+ class _Entry:
39
+ prompt: Prompt
40
+ etag: str | None
41
+ expires_at: float
42
+
43
+
44
+ class _FetchError(Exception):
45
+ """Why one fetch failed; the message never contains the API key or the prompt body."""
46
+
47
+
48
+ class PromptClient:
49
+ """Reads prompts for one Spanlight client. Reach it as ``Spanlight.prompts``.
50
+
51
+ Args:
52
+ base_url: URL of the prompts collection, e.g. ``http://localhost:8000/v1/prompts``.
53
+ api_key: Project API key with the ``prompts:read`` scope, or ``None``.
54
+ timeout: Per-request timeout in seconds.
55
+ cache_ttl: Seconds a fetched prompt is served without asking the server again.
56
+ transport: Optional custom ``httpx`` transport.
57
+ clock: Monotonic clock, replaceable in tests.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ base_url: str,
64
+ api_key: str | None,
65
+ timeout: float,
66
+ cache_ttl: float,
67
+ transport: httpx.BaseTransport | None = None,
68
+ clock: Callable[[], float] = time.monotonic,
69
+ ) -> None:
70
+ self._base_url = base_url.rstrip("/")
71
+ self._api_key = api_key
72
+ self._timeout = timeout
73
+ self._ttl = cache_ttl
74
+ self._transport = transport
75
+ self._clock = clock
76
+ self._cache: dict[CacheKey, _Entry] = {}
77
+ self._locks: dict[CacheKey, threading.Lock] = {}
78
+ self._locks_guard = threading.Lock()
79
+ self._last_warning: dict[CacheKey, float] = {}
80
+ # Per key: until when a failed fetch is not retried, and why it failed.
81
+ self._failures: dict[CacheKey, tuple[float, str]] = {}
82
+ self._client: httpx.Client | None = None
83
+ self._client_guard = threading.Lock()
84
+
85
+ def get(
86
+ self,
87
+ name: str,
88
+ label: str = "production",
89
+ *,
90
+ version: int | None = None,
91
+ fallback: Fallback | None = None,
92
+ ) -> Prompt:
93
+ """Return a prompt version by label (default ``production``) or by number.
94
+
95
+ Args:
96
+ name: The prompt name, e.g. ``"support/answer"``.
97
+ label: The label to resolve; ignored when ``version`` is given.
98
+ version: A specific version number.
99
+ fallback: A text template, or a list of ``{"role", "content"}`` messages, used
100
+ when the prompt cannot be fetched and nothing is cached. The returned
101
+ prompt then has ``version=None``.
102
+
103
+ Returns:
104
+ The prompt. A cached copy is returned while fresh, and also after it expires
105
+ whenever the server cannot be reached.
106
+
107
+ Raises:
108
+ PromptUnavailableError: Nothing is cached, the fetch failed and there is no
109
+ valid ``fallback``; or ``name`` is empty or has an empty, ``.`` or ``..``
110
+ segment (no request is made).
111
+ """
112
+ key: CacheKey = (name, label, version)
113
+ if not _is_valid_name(name):
114
+ raise PromptUnavailableError(f"prompt {name!r} is unavailable: invalid prompt name")
115
+ entry = self._cache.get(key)
116
+ if entry is not None and self._clock() < entry.expires_at:
117
+ return entry.prompt
118
+ lock = self._lock_for(key)
119
+ if not lock.acquire(blocking=False):
120
+ if entry is not None:
121
+ # Another caller is refreshing this prompt; serve the expired copy rather than
122
+ # wait on the server.
123
+ return entry.prompt
124
+ lock.acquire()
125
+ try:
126
+ return self._get_locked(key, fallback)
127
+ finally:
128
+ lock.release()
129
+
130
+ def close(self) -> None:
131
+ """Close the HTTP connection pool. A later ``get`` opens a new one."""
132
+ with self._client_guard:
133
+ client, self._client = self._client, None
134
+ if client is not None:
135
+ client.close()
136
+
137
+ def _get_locked(self, key: CacheKey, fallback: Fallback | None) -> Prompt:
138
+ # Another thread may have refreshed the entry while this one waited.
139
+ entry = self._cache.get(key)
140
+ now = self._clock()
141
+ if entry is not None and now < entry.expires_at:
142
+ return entry.prompt
143
+ failure = self._failures.get(key)
144
+ if entry is None and failure is not None and now < failure[0]:
145
+ # A fetch failed moments ago; do not make this caller wait on the server too.
146
+ return self._without_cache(key, fallback, failure[1])
147
+ try:
148
+ return self._refresh(key, entry)
149
+ except _FetchError as error:
150
+ cause = str(error)
151
+ except Exception as error:
152
+ logger.debug("Unexpected error fetching prompt %r", key[0], exc_info=True)
153
+ cause = type(error).__name__
154
+ return self._recover(key, entry, fallback, cause)
155
+
156
+ def _lock_for(self, key: CacheKey) -> threading.Lock:
157
+ with self._locks_guard:
158
+ return self._locks.setdefault(key, threading.Lock())
159
+
160
+ def _refresh(self, key: CacheKey, entry: _Entry | None) -> Prompt:
161
+ response = self._request(key, entry.etag if entry is not None else None)
162
+ now = self._clock()
163
+ if response.status_code == 304 and entry is not None:
164
+ entry.expires_at = now + self._ttl
165
+ return entry.prompt
166
+ if response.status_code != 200:
167
+ raise _FetchError(f"HTTP {response.status_code}")
168
+ try:
169
+ prompt = prompt_from_wire(response.json())
170
+ except (ValueError, InvalidTemplateError) as error:
171
+ raise _FetchError(f"unreadable response ({error})") from None
172
+ self._cache[key] = _Entry(prompt, response.headers.get("ETag"), now + self._ttl)
173
+ self._failures.pop(key, None)
174
+ return prompt
175
+
176
+ def _request(self, key: CacheKey, etag: str | None) -> httpx.Response:
177
+ if not self._api_key:
178
+ raise _FetchError("no API key configured")
179
+ name, label, version = key
180
+ params = {"version": str(version)} if version is not None else {"label": label}
181
+ headers = {
182
+ "Authorization": f"Bearer {self._api_key}",
183
+ "User-Agent": f"spanlight-python/{__version__}",
184
+ }
185
+ if etag is not None:
186
+ headers["If-None-Match"] = etag
187
+ url = f"{self._base_url}/{quote(name, safe='/')}"
188
+ try:
189
+ return self._http_client().get(url, params=params, headers=headers)
190
+ except httpx.HTTPError as error:
191
+ raise _FetchError(type(error).__name__) from None
192
+
193
+ def _recover(
194
+ self, key: CacheKey, entry: _Entry | None, fallback: Fallback | None, cause: str
195
+ ) -> Prompt:
196
+ name = key[0]
197
+ if entry is not None:
198
+ # Keep serving the stale copy and wait before asking again, so an outage does not
199
+ # put a request (and its timeout) on every call.
200
+ entry.expires_at = self._clock() + max(self._ttl, _FAILURE_MEMO)
201
+ self._warn(key, f"Spanlight: serving a cached copy of prompt {name!r} ({cause})")
202
+ return entry.prompt
203
+ # Remember the failure: briefly when the caller has nothing else (so concurrent callers
204
+ # do not each wait out a timeout), one TTL when a fallback covers the gap.
205
+ window = _FAILURE_MEMO if fallback is None else max(self._ttl, _FAILURE_MEMO)
206
+ self._failures[key] = (self._clock() + window, cause)
207
+ return self._without_cache(key, fallback, cause)
208
+
209
+ def _without_cache(self, key: CacheKey, fallback: Fallback | None, cause: str) -> Prompt:
210
+ """The fallback prompt, or :class:`PromptUnavailableError` when there is none."""
211
+ name = key[0]
212
+ if fallback is None:
213
+ raise PromptUnavailableError(f"prompt {name!r} is unavailable: {cause}")
214
+ try:
215
+ prompt = fallback_prompt(name, fallback)
216
+ except (InvalidTemplateError, TypeError) as error:
217
+ raise PromptUnavailableError(
218
+ f"prompt {name!r} is unavailable ({cause}) and its fallback is invalid: {error}"
219
+ ) from None
220
+ self._warn(key, f"Spanlight: using the fallback for prompt {name!r} ({cause})")
221
+ return prompt
222
+
223
+ def _warn(self, key: CacheKey, message: str) -> None:
224
+ now = self._clock()
225
+ last = self._last_warning.get(key)
226
+ if last is None or now - last >= _WARNING_INTERVAL:
227
+ self._last_warning[key] = now
228
+ logger.warning(message)
229
+
230
+ def _http_client(self) -> httpx.Client:
231
+ with self._client_guard:
232
+ if self._client is None:
233
+ self._client = httpx.Client(transport=self._transport, timeout=self._timeout)
234
+ return self._client
235
+
236
+
237
+ def _is_valid_name(name: str) -> bool:
238
+ """Whether ``name`` can travel in the URL path: no empty, ``.`` or ``..`` segments."""
239
+ if not isinstance(name, str) or not name:
240
+ return False
241
+ return all(segment not in ("", ".", "..") for segment in name.split("/"))
spanlight/_prompts.py ADDED
@@ -0,0 +1,247 @@
1
+ """Prompts fetched from Spanlight, and their compiled form.
2
+
3
+ A :class:`Prompt` is one immutable prompt version (or a local fallback). Compiling it fills in
4
+ the variables and returns a :class:`CompiledPrompt`, which is also a context manager: spans
5
+ started inside ``with compiled:`` are linked to the prompt version they used.
6
+
7
+ The link lives in a :class:`~contextvars.ContextVar`, so it follows contextvars semantics: an
8
+ ``asyncio`` task created inside the block keeps the link for its whole life, and a thread does
9
+ not see it at all. Wrap the creation of long-lived tasks in :func:`no_prompt` to keep their
10
+ spans unlinked.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ from collections.abc import Iterator, Mapping, Sequence
17
+ from contextvars import ContextVar, Token
18
+ from dataclasses import dataclass, field
19
+ from types import TracebackType
20
+ from typing import Any
21
+
22
+ from ._context import (
23
+ get_current_prompt_version_id,
24
+ restore_current_prompt_version_id,
25
+ set_current_prompt_version_id,
26
+ )
27
+ from ._templates import (
28
+ Kind,
29
+ Template,
30
+ compile,
31
+ extract_variables,
32
+ messages_from_wire,
33
+ template_from_wire,
34
+ )
35
+
36
+ # The `with compiled:` blocks open in the current context, innermost last: (prompt, token to
37
+ # undo its link, link before it). Kept per context so threads and tasks entering the same
38
+ # CompiledPrompt never pop each other's entries.
39
+ _Entered = tuple["CompiledPrompt", Token[str | None], str | None]
40
+ _entered: ContextVar[tuple[_Entered, ...]] = ContextVar("spanlight_entered_prompts", default=())
41
+
42
+
43
+ @contextlib.contextmanager
44
+ def no_prompt() -> Iterator[None]:
45
+ """Unlink spans started inside the block from any prompt version.
46
+
47
+ Use it around code that starts long-lived work (``asyncio.create_task``, a background
48
+ worker) from inside ``with compiled:``, which would otherwise inherit the link.
49
+ """
50
+ previous = get_current_prompt_version_id()
51
+ token = set_current_prompt_version_id(None)
52
+ try:
53
+ yield
54
+ finally:
55
+ restore_current_prompt_version_id(token, previous)
56
+
57
+
58
+ class PromptUnavailableError(Exception):
59
+ """A prompt could not be fetched and there was neither a cached copy nor a fallback.
60
+
61
+ This is the one exception the SDK raises on purpose. Pass ``fallback=`` to
62
+ :meth:`PromptClient.get <spanlight.PromptClient.get>` to never see it.
63
+ """
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class CompiledPrompt:
68
+ """A prompt with its variables filled in.
69
+
70
+ Use ``text`` for a text prompt and ``messages`` (plain ``{"role", "content"}`` dicts, ready
71
+ for the OpenAI and Anthropic SDKs) for a chat prompt. Enter it with ``with compiled:`` to link
72
+ every span started inside the block to this prompt version, or pass it to a wrapped client
73
+ call as ``spanlight_prompt=compiled``. A fallback (no version) clears the link instead.
74
+
75
+ Attributes:
76
+ name: The prompt name.
77
+ version: The prompt version, or ``None`` for a local fallback.
78
+ prompt_version_id: The version id spans are linked to, or ``None`` for a fallback.
79
+ kind: ``"text"`` or ``"chat"``.
80
+ text: The compiled text of a text prompt.
81
+ messages: The compiled messages of a chat prompt.
82
+ variables_used: Variables the template uses, in first-appearance order.
83
+ unused_variables: Variables that were passed but not used, sorted.
84
+ """
85
+
86
+ name: str
87
+ version: int | None
88
+ prompt_version_id: str | None
89
+ kind: Kind
90
+ text: str | None
91
+ messages: list[dict[str, str]] | None
92
+ variables_used: tuple[str, ...]
93
+ unused_variables: tuple[str, ...]
94
+
95
+ def __enter__(self) -> CompiledPrompt:
96
+ previous = get_current_prompt_version_id()
97
+ token = set_current_prompt_version_id(self.prompt_version_id)
98
+ _entered.set((*_entered.get(), (self, token, previous)))
99
+ return self
100
+
101
+ def __exit__(
102
+ self,
103
+ exc_type: type[BaseException] | None,
104
+ exc: BaseException | None,
105
+ traceback: TracebackType | None,
106
+ ) -> None:
107
+ stack = _entered.get()
108
+ for index in range(len(stack) - 1, -1, -1):
109
+ prompt, token, previous = stack[index]
110
+ if prompt is self:
111
+ _entered.set(stack[:index] + stack[index + 1 :])
112
+ restore_current_prompt_version_id(token, previous)
113
+ return
114
+
115
+ async def __aenter__(self) -> CompiledPrompt:
116
+ return self.__enter__()
117
+
118
+ async def __aexit__(
119
+ self,
120
+ exc_type: type[BaseException] | None,
121
+ exc: BaseException | None,
122
+ traceback: TracebackType | None,
123
+ ) -> None:
124
+ self.__exit__(exc_type, exc, traceback)
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class Prompt:
129
+ """One prompt version as served by Spanlight, or a local fallback.
130
+
131
+ Attributes:
132
+ name: The prompt name.
133
+ version: The version number, or ``None`` when this is the fallback passed to ``get``.
134
+ version_id: The version's id, or ``None`` for a fallback.
135
+ kind: ``"text"`` or ``"chat"``.
136
+ template: The uncompiled template.
137
+ variables: Variables the template uses, in first-appearance order.
138
+ model_config: Model settings saved with the version (model, temperature, ...).
139
+ labels: Labels that point at this version.
140
+ """
141
+
142
+ name: str
143
+ version: int | None
144
+ version_id: str | None
145
+ kind: Kind
146
+ template: Template
147
+ variables: tuple[str, ...]
148
+ model_config: dict[str, Any] = field(default_factory=dict)
149
+ labels: tuple[str, ...] = ()
150
+
151
+ @property
152
+ def is_fallback(self) -> bool:
153
+ """Whether this prompt is the local fallback rather than a served version."""
154
+ return self.version is None
155
+
156
+ def compile(self, **variables: str | int | float) -> CompiledPrompt:
157
+ """Fill in the template's variables.
158
+
159
+ Values are strings; numbers and booleans are converted with ``str()``. Extra
160
+ variables are reported in ``unused_variables``, never rejected.
161
+
162
+ Raises:
163
+ MissingVariablesError: A variable the template uses was not given.
164
+ InvalidTemplateError: The template is malformed.
165
+ TemplateTooLargeError: The rendered prompt would exceed 256 KB.
166
+ TypeError: A value is not a string, number or boolean (``None`` included).
167
+ """
168
+ rendered = compile(self.template, _as_strings(variables))
169
+ messages = (
170
+ [{"role": message.role, "content": message.content} for message in rendered.messages]
171
+ if rendered.messages is not None
172
+ else None
173
+ )
174
+ return CompiledPrompt(
175
+ name=self.name,
176
+ version=self.version,
177
+ prompt_version_id=self.version_id,
178
+ kind=rendered.kind,
179
+ text=rendered.text,
180
+ messages=messages,
181
+ variables_used=rendered.variables_used,
182
+ unused_variables=rendered.unused_variables,
183
+ )
184
+
185
+
186
+ def _as_strings(variables: Mapping[str, Any]) -> dict[str, str]:
187
+ """Template values as strings: numbers and booleans via ``str()``, anything else refused."""
188
+ converted: dict[str, str] = {}
189
+ for name, value in variables.items():
190
+ if isinstance(value, str):
191
+ converted[name] = value
192
+ elif isinstance(value, int | float):
193
+ converted[name] = str(value)
194
+ else:
195
+ raise TypeError(
196
+ f"variable {name!r} must be a string, number or boolean, not {type(value).__name__}"
197
+ )
198
+ return converted
199
+
200
+
201
+ def prompt_from_wire(body: Any) -> Prompt:
202
+ """Build a :class:`Prompt` from a ``GET /v1/prompts/{name}`` response body.
203
+
204
+ Raises:
205
+ ValueError: The body does not have the documented shape.
206
+ """
207
+ if not isinstance(body, Mapping):
208
+ raise ValueError("prompt response is not a JSON object")
209
+ name, version, version_id = body.get("name"), body.get("version"), body.get("version_id")
210
+ if not isinstance(name, str) or not isinstance(version_id, str):
211
+ raise ValueError("prompt response lacks name or version_id")
212
+ if not isinstance(version, int) or isinstance(version, bool):
213
+ raise ValueError("prompt response lacks a version number")
214
+ template = template_from_wire(body.get("template"))
215
+ model_config = body.get("model_config")
216
+ labels = body.get("labels")
217
+ return Prompt(
218
+ name=name,
219
+ version=version,
220
+ version_id=version_id,
221
+ kind=template.kind,
222
+ template=template,
223
+ variables=extract_variables(template),
224
+ model_config=dict(model_config) if isinstance(model_config, Mapping) else {},
225
+ labels=tuple(str(label) for label in labels) if isinstance(labels, list) else (),
226
+ )
227
+
228
+
229
+ def fallback_prompt(name: str, fallback: str | Sequence[Mapping[str, Any]]) -> Prompt:
230
+ """Build the ``version=None`` prompt used when nothing could be fetched or cached.
231
+
232
+ Raises:
233
+ InvalidTemplateError: ``fallback`` is not a valid text or chat template.
234
+ """
235
+ template = (
236
+ Template("text", text=fallback)
237
+ if isinstance(fallback, str)
238
+ else Template("chat", messages=messages_from_wire(list(fallback)))
239
+ )
240
+ return Prompt(
241
+ name=name,
242
+ version=None,
243
+ version_id=None,
244
+ kind=template.kind,
245
+ template=template,
246
+ variables=extract_variables(template),
247
+ )
@@ -0,0 +1,58 @@
1
+ """Stable hash of a model request, used to group identical calls.
2
+
3
+ The algorithm is shared with the server and must stay byte-for-byte identical
4
+ to it: the first 32 hex characters of the SHA-256 of the canonical JSON
5
+ (sorted keys, ``,`` and ``:`` separators, non-ASCII kept) of
6
+ ``{"model": model, "input": cleaned}``. ``cleaned`` is ``input`` without the
7
+ top-level keys that change per call but not the prompt (streaming flags,
8
+ identifiers, transport options); non-dict inputs are hashed as they are.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ from typing import Any
16
+
17
+ # Top-level request keys that never change what the model is asked.
18
+ _IGNORED_INPUT_KEYS = frozenset(
19
+ {
20
+ "stream",
21
+ "stream_options",
22
+ "user",
23
+ "metadata",
24
+ "idempotency_key",
25
+ "extra_headers",
26
+ "extra_query",
27
+ "extra_body",
28
+ "timeout",
29
+ "api_key",
30
+ "http_client",
31
+ }
32
+ )
33
+
34
+
35
+ def request_hash(model: str | None, value: Any) -> str | None:
36
+ """Return the 32-hex request hash, or ``None`` when ``value`` is ``None``.
37
+
38
+ Args:
39
+ model: The requested model name, or ``None`` when unknown.
40
+ value: The JSON-compatible request input (a dict of call parameters,
41
+ a string or a list).
42
+ """
43
+ if value is None:
44
+ return None
45
+ cleaned = (
46
+ {key: item for key, item in value.items() if key not in _IGNORED_INPUT_KEYS}
47
+ if isinstance(value, dict)
48
+ else value
49
+ )
50
+ canonical = json.dumps(
51
+ {"model": model, "input": cleaned},
52
+ sort_keys=True,
53
+ separators=(",", ":"),
54
+ ensure_ascii=False,
55
+ )
56
+ # ``surrogatepass``, as on the server: a lone surrogate (a valid JSON escape, invalid
57
+ # UTF-8) is hashed instead of raising; every other string encodes exactly as UTF-8.
58
+ return hashlib.sha256(canonical.encode("utf-8", "surrogatepass")).hexdigest()[:32]
spanlight/_scores.py ADDED
@@ -0,0 +1,57 @@
1
+ """Building the ``POST /v1/scores`` payload for one score.
2
+
3
+ Scores are recorded with :func:`spanlight.score` and sent by the background exporter. Each
4
+ score gets a UUIDv7 ``id`` when it is recorded, so a retried request can never store it twice.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import math
11
+
12
+ from ._ids import new_uuid7
13
+ from ._serialize import JsonValue
14
+
15
+ logger = logging.getLogger("spanlight")
16
+
17
+ ScoreValue = float | int | bool | str
18
+
19
+
20
+ def build_score(
21
+ trace_id: str,
22
+ name: str,
23
+ value: ScoreValue,
24
+ *,
25
+ span_id: str | None = None,
26
+ comment: str | None = None,
27
+ ) -> dict[str, JsonValue] | None:
28
+ """Return the wire form of one score, or ``None`` (with a warning) when it cannot be sent.
29
+
30
+ A ``bool`` stays a JSON boolean, which makes the server infer a boolean score stored as
31
+ 1 or 0. A ``str`` becomes ``string_value`` (a categorical score). Numbers must be finite.
32
+ """
33
+ if not isinstance(trace_id, str) or not isinstance(name, str):
34
+ logger.warning("Spanlight: score() needs a string trace_id and name; score dropped")
35
+ return None
36
+ payload: dict[str, JsonValue] = {"id": new_uuid7(), "trace_id": trace_id, "name": name}
37
+ if isinstance(value, bool):
38
+ payload["value"] = value
39
+ elif isinstance(value, int | float):
40
+ if not math.isfinite(value):
41
+ logger.warning("Spanlight: score %r has a non-finite value; score dropped", name)
42
+ return None
43
+ payload["value"] = value
44
+ elif isinstance(value, str):
45
+ payload["string_value"] = value
46
+ else:
47
+ logger.warning(
48
+ "Spanlight: score %r has an unsupported value type %s; score dropped",
49
+ name,
50
+ type(value).__name__,
51
+ )
52
+ return None
53
+ if span_id is not None:
54
+ payload["span_id"] = span_id
55
+ if comment is not None:
56
+ payload["comment"] = comment
57
+ return payload