spano 0.1.0__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.
- spano-0.1.0/.gitignore +10 -0
- spano-0.1.0/LICENSE +17 -0
- spano-0.1.0/PKG-INFO +72 -0
- spano-0.1.0/README.md +55 -0
- spano-0.1.0/pyproject.toml +31 -0
- spano-0.1.0/src/spano/__init__.py +19 -0
- spano-0.1.0/src/spano/client.py +127 -0
- spano-0.1.0/src/spano/config.py +39 -0
- spano-0.1.0/src/spano/events.py +145 -0
- spano-0.1.0/src/spano/exceptions.py +13 -0
- spano-0.1.0/src/spano/tracer.py +111 -0
- spano-0.1.0/src/spano/transport.py +54 -0
- spano-0.1.0/test/test_client.py +131 -0
- spano-0.1.0/test/test_events.py +53 -0
- spano-0.1.0/uv.lock +106 -0
spano-0.1.0/.gitignore
ADDED
spano-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Spano contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
spano-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: spano
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for tracing and exporting AI-agent execution telemetry
|
|
5
|
+
Author: Spano contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: ai-agents,llm,observability,telemetry,tracing
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Spano Python SDK
|
|
19
|
+
|
|
20
|
+
Spano is a lightweight Python SDK for recording AI-agent traces and exporting spans to an HTTP ingestion endpoint. This early release provides manual tracing; automatic LangChain/LangGraph instrumentation is not included yet.
|
|
21
|
+
|
|
22
|
+
## Install for local development
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv sync
|
|
26
|
+
uv run pytest
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from spano import Spano, SpanKind
|
|
33
|
+
|
|
34
|
+
client = Spano(
|
|
35
|
+
api_key="your-api-key",
|
|
36
|
+
endpoint="http://localhost:8000",
|
|
37
|
+
# The server must implement POST /api/v1/traces.
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
with client.start_trace("research-agent", attributes={"environment": "dev"}) as trace:
|
|
42
|
+
with trace.start_span("search-web", kind=SpanKind.TOOL, attributes={"tool": "web-search"}):
|
|
43
|
+
# Run your tool here.
|
|
44
|
+
pass
|
|
45
|
+
finally:
|
|
46
|
+
client.close()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Ingestion contract
|
|
50
|
+
|
|
51
|
+
Spano sends `POST {endpoint}/api/v1/traces` with `Authorization: Bearer <api-key>` and a JSON body shaped like:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{"spans": [{"trace_id": "...", "span_id": "...", "parent_span_id": null, "name": "research-agent", "kind": "agent", "start_time": "...", "end_time": "...", "duration_ms": 1.2, "status": "ok", "attributes": {}, "error": null}]}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The endpoint should return a 2xx response. If the response body is non-empty, it must be a JSON object; an optional integer `accepted` field should equal the number of spans in the request. This SDK does not include the backend or persist traces itself.
|
|
58
|
+
|
|
59
|
+
## Buffering and failures
|
|
60
|
+
|
|
61
|
+
- `flush()` exports buffered spans and returns `True` on success, `False` when best-effort delivery fails.
|
|
62
|
+
- `close()` attempts to flush and is safe to call more than once.
|
|
63
|
+
- Buffer capacity is bounded. If it fills, the oldest span is dropped and `client.dropped_spans` increments.
|
|
64
|
+
- Transport errors are logged and passed to `on_error` if supplied. Set `strict_transport=True` to raise transport errors from `flush()`/`close()`.
|
|
65
|
+
- Application exceptions are recorded in spans and re-raised; Spano does not swallow them.
|
|
66
|
+
- Input/output prompts and responses are not captured automatically. Only metadata explicitly provided by the developer is recorded. Do not add credentials or sensitive values to attributes.
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
`Spano(api_key, endpoint="http://localhost:8000", timeout=5.0, ingestion_path="/api/v1/traces", max_buffer_size=1000, batch_size=100, strict_transport=False, logger=None, on_error=None)`
|
|
71
|
+
|
|
72
|
+
This package is an early alpha. Review behavior and endpoint compatibility before production use.
|
spano-0.1.0/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Spano Python SDK
|
|
2
|
+
|
|
3
|
+
Spano is a lightweight Python SDK for recording AI-agent traces and exporting spans to an HTTP ingestion endpoint. This early release provides manual tracing; automatic LangChain/LangGraph instrumentation is not included yet.
|
|
4
|
+
|
|
5
|
+
## Install for local development
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv sync
|
|
9
|
+
uv run pytest
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from spano import Spano, SpanKind
|
|
16
|
+
|
|
17
|
+
client = Spano(
|
|
18
|
+
api_key="your-api-key",
|
|
19
|
+
endpoint="http://localhost:8000",
|
|
20
|
+
# The server must implement POST /api/v1/traces.
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
with client.start_trace("research-agent", attributes={"environment": "dev"}) as trace:
|
|
25
|
+
with trace.start_span("search-web", kind=SpanKind.TOOL, attributes={"tool": "web-search"}):
|
|
26
|
+
# Run your tool here.
|
|
27
|
+
pass
|
|
28
|
+
finally:
|
|
29
|
+
client.close()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Ingestion contract
|
|
33
|
+
|
|
34
|
+
Spano sends `POST {endpoint}/api/v1/traces` with `Authorization: Bearer <api-key>` and a JSON body shaped like:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{"spans": [{"trace_id": "...", "span_id": "...", "parent_span_id": null, "name": "research-agent", "kind": "agent", "start_time": "...", "end_time": "...", "duration_ms": 1.2, "status": "ok", "attributes": {}, "error": null}]}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The endpoint should return a 2xx response. If the response body is non-empty, it must be a JSON object; an optional integer `accepted` field should equal the number of spans in the request. This SDK does not include the backend or persist traces itself.
|
|
41
|
+
|
|
42
|
+
## Buffering and failures
|
|
43
|
+
|
|
44
|
+
- `flush()` exports buffered spans and returns `True` on success, `False` when best-effort delivery fails.
|
|
45
|
+
- `close()` attempts to flush and is safe to call more than once.
|
|
46
|
+
- Buffer capacity is bounded. If it fills, the oldest span is dropped and `client.dropped_spans` increments.
|
|
47
|
+
- Transport errors are logged and passed to `on_error` if supplied. Set `strict_transport=True` to raise transport errors from `flush()`/`close()`.
|
|
48
|
+
- Application exceptions are recorded in spans and re-raised; Spano does not swallow them.
|
|
49
|
+
- Input/output prompts and responses are not captured automatically. Only metadata explicitly provided by the developer is recorded. Do not add credentials or sensitive values to attributes.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
`Spano(api_key, endpoint="http://localhost:8000", timeout=5.0, ingestion_path="/api/v1/traces", max_buffer_size=1000, batch_size=100, strict_transport=False, logger=None, on_error=None)`
|
|
54
|
+
|
|
55
|
+
This package is an early alpha. Review behavior and endpoint compatibility before production use.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "spano"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for tracing and exporting AI-agent execution telemetry"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Spano contributors" }]
|
|
9
|
+
keywords = ["ai-agents", "observability", "tracing", "llm", "telemetry"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
dependencies = []
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/spano"]
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest>=8.0", "build>=1.0"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["test"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Public API for the Spano SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import Spano
|
|
4
|
+
from .config import SpanoConfig
|
|
5
|
+
from .events import Span, SpanKind, SpanStatus
|
|
6
|
+
from .exceptions import SpanoConfigurationError, SpanoError, SpanoTransportError
|
|
7
|
+
from .tracer import Trace
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Spano",
|
|
11
|
+
"SpanoConfig",
|
|
12
|
+
"Trace",
|
|
13
|
+
"Span",
|
|
14
|
+
"SpanKind",
|
|
15
|
+
"SpanStatus",
|
|
16
|
+
"SpanoError",
|
|
17
|
+
"SpanoConfigurationError",
|
|
18
|
+
"SpanoTransportError",
|
|
19
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Public Spano client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from threading import RLock
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from .config import SpanoConfig
|
|
10
|
+
from .events import Span
|
|
11
|
+
from .exceptions import SpanoTransportError
|
|
12
|
+
from .tracer import Trace
|
|
13
|
+
from .transport import HTTPExporter
|
|
14
|
+
|
|
15
|
+
TelemetryErrorCallback = Callable[[Exception], None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Spano:
|
|
19
|
+
"""Client entry point for Spano tracing and telemetry export.
|
|
20
|
+
|
|
21
|
+
Telemetry delivery is best-effort by default. Set ``strict_transport=True``
|
|
22
|
+
to re-raise transport errors from ``flush()`` and ``close()``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
api_key: str,
|
|
28
|
+
endpoint: str = "http://localhost:8000",
|
|
29
|
+
timeout: float = 5.0,
|
|
30
|
+
*,
|
|
31
|
+
ingestion_path: str = "/api/v1/traces",
|
|
32
|
+
max_buffer_size: int = 1000,
|
|
33
|
+
batch_size: int = 100,
|
|
34
|
+
strict_transport: bool = False,
|
|
35
|
+
logger: logging.Logger | None = None,
|
|
36
|
+
on_error: TelemetryErrorCallback | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.config = SpanoConfig(
|
|
39
|
+
api_key=api_key,
|
|
40
|
+
endpoint=endpoint,
|
|
41
|
+
timeout=timeout,
|
|
42
|
+
ingestion_path=ingestion_path,
|
|
43
|
+
max_buffer_size=max_buffer_size,
|
|
44
|
+
batch_size=batch_size,
|
|
45
|
+
strict_transport=strict_transport,
|
|
46
|
+
)
|
|
47
|
+
self._exporter = HTTPExporter(
|
|
48
|
+
endpoint=self.config.endpoint,
|
|
49
|
+
api_key=self.config.api_key,
|
|
50
|
+
timeout=self.config.timeout,
|
|
51
|
+
ingestion_path=self.config.ingestion_path,
|
|
52
|
+
)
|
|
53
|
+
self._logger = logger or logging.getLogger("spano")
|
|
54
|
+
self._on_error = on_error
|
|
55
|
+
self._buffer: list[dict[str, Any]] = []
|
|
56
|
+
self._lock = RLock()
|
|
57
|
+
self._closed = False
|
|
58
|
+
self.dropped_spans = 0
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def closed(self) -> bool:
|
|
62
|
+
return self._closed
|
|
63
|
+
|
|
64
|
+
def start_trace(self, name: str, *, attributes: dict[str, Any] | None = None) -> Trace:
|
|
65
|
+
if self._closed:
|
|
66
|
+
raise RuntimeError("Spano client is closed")
|
|
67
|
+
return Trace(self, name, attributes)
|
|
68
|
+
|
|
69
|
+
def _enqueue(self, spans: list[Span]) -> None:
|
|
70
|
+
with self._lock:
|
|
71
|
+
if self._closed:
|
|
72
|
+
self._report_error(RuntimeError("Cannot enqueue telemetry after client is closed"))
|
|
73
|
+
return
|
|
74
|
+
for span in spans:
|
|
75
|
+
if len(self._buffer) >= self.config.max_buffer_size:
|
|
76
|
+
self._buffer.pop(0)
|
|
77
|
+
self.dropped_spans += 1
|
|
78
|
+
self._buffer.append(span.to_dict())
|
|
79
|
+
should_flush = len(self._buffer) >= self.config.batch_size
|
|
80
|
+
if should_flush:
|
|
81
|
+
self.flush()
|
|
82
|
+
|
|
83
|
+
def flush(self) -> bool:
|
|
84
|
+
"""Send buffered spans. Returns True when the buffer is empty after success."""
|
|
85
|
+
if self._closed and not self._buffer:
|
|
86
|
+
return True
|
|
87
|
+
while True:
|
|
88
|
+
with self._lock:
|
|
89
|
+
if not self._buffer:
|
|
90
|
+
return True
|
|
91
|
+
batch = self._buffer[: self.config.batch_size]
|
|
92
|
+
try:
|
|
93
|
+
self._exporter.export(batch)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
self._report_error(exc)
|
|
96
|
+
if self.config.strict_transport:
|
|
97
|
+
if isinstance(exc, SpanoTransportError):
|
|
98
|
+
raise
|
|
99
|
+
raise SpanoTransportError(str(exc)) from exc
|
|
100
|
+
return False
|
|
101
|
+
with self._lock:
|
|
102
|
+
del self._buffer[: len(batch)]
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
"""Flush buffered telemetry and close the client; safe to call repeatedly."""
|
|
106
|
+
if self._closed:
|
|
107
|
+
return
|
|
108
|
+
try:
|
|
109
|
+
self.flush()
|
|
110
|
+
finally:
|
|
111
|
+
self._closed = True
|
|
112
|
+
|
|
113
|
+
def _report_error(self, error: Exception) -> None:
|
|
114
|
+
self._logger.warning("Spano telemetry error: %s", error)
|
|
115
|
+
if self._on_error is not None:
|
|
116
|
+
try:
|
|
117
|
+
self._on_error(error)
|
|
118
|
+
except Exception:
|
|
119
|
+
self._logger.exception("Spano error callback failed")
|
|
120
|
+
|
|
121
|
+
def __enter__(self) -> "Spano":
|
|
122
|
+
if self._closed:
|
|
123
|
+
raise RuntimeError("Spano client is already closed")
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def __exit__(self, exc_type: Any, exc: BaseException | None, tb: Any) -> None:
|
|
127
|
+
self.close()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Configuration for the Spano SDK."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import math
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class SpanoConfig:
|
|
10
|
+
api_key: str
|
|
11
|
+
endpoint: str = "http://localhost:8000"
|
|
12
|
+
timeout: float = 5.0
|
|
13
|
+
ingestion_path: str = "/api/v1/traces"
|
|
14
|
+
max_buffer_size: int = 1000
|
|
15
|
+
batch_size: int = 100
|
|
16
|
+
strict_transport: bool = False
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
if not isinstance(self.api_key, str) or not self.api_key.strip():
|
|
20
|
+
raise ValueError("api_key cannot be empty")
|
|
21
|
+
if not isinstance(self.endpoint, str):
|
|
22
|
+
raise ValueError("endpoint must be a string")
|
|
23
|
+
parsed = urlsplit(self.endpoint)
|
|
24
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
25
|
+
raise ValueError("endpoint must be an absolute HTTP or HTTPS URL")
|
|
26
|
+
if parsed.username or parsed.password:
|
|
27
|
+
raise ValueError("endpoint must not contain embedded credentials")
|
|
28
|
+
if parsed.query or parsed.fragment:
|
|
29
|
+
raise ValueError("endpoint must not contain a query or fragment")
|
|
30
|
+
if not math.isfinite(self.timeout) or self.timeout <= 0:
|
|
31
|
+
raise ValueError("timeout must be a finite number greater than zero")
|
|
32
|
+
if not isinstance(self.ingestion_path, str) or not self.ingestion_path.startswith("/"):
|
|
33
|
+
raise ValueError("ingestion_path must start with '/'")
|
|
34
|
+
if self.max_buffer_size < 1:
|
|
35
|
+
raise ValueError("max_buffer_size must be at least 1")
|
|
36
|
+
if self.batch_size < 1:
|
|
37
|
+
raise ValueError("batch_size must be at least 1")
|
|
38
|
+
object.__setattr__(self, "endpoint", self.endpoint.rstrip("/"))
|
|
39
|
+
object.__setattr__(self, "ingestion_path", "/" + self.ingestion_path.strip("/"))
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Core tracing events and span models for the Spano SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import secrets
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def utc_now() -> datetime:
|
|
15
|
+
return datetime.now(timezone.utc)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def new_trace_id() -> str:
|
|
19
|
+
"""Generate a 32-character hexadecimal trace ID."""
|
|
20
|
+
return secrets.token_hex(16)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def new_span_id() -> str:
|
|
24
|
+
"""Generate a 16-character hexadecimal span ID."""
|
|
25
|
+
return secrets.token_hex(8)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SpanKind(StrEnum):
|
|
29
|
+
AGENT = "agent"
|
|
30
|
+
LLM = "llm"
|
|
31
|
+
TOOL = "tool"
|
|
32
|
+
CHAIN = "chain"
|
|
33
|
+
INTERNAL = "internal"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SpanStatus(StrEnum):
|
|
37
|
+
UNSET = "unset"
|
|
38
|
+
OK = "ok"
|
|
39
|
+
ERROR = "error"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_attributes(attributes: dict[str, Any]) -> None:
|
|
43
|
+
if not isinstance(attributes, dict):
|
|
44
|
+
raise ValueError("attributes must be a dictionary")
|
|
45
|
+
if any(not isinstance(key, str) for key in attributes):
|
|
46
|
+
raise ValueError("attribute keys must be strings")
|
|
47
|
+
try:
|
|
48
|
+
json.dumps(attributes, allow_nan=False)
|
|
49
|
+
except (TypeError, ValueError) as exc:
|
|
50
|
+
raise ValueError("attributes must contain JSON-serializable values") from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class Span:
|
|
55
|
+
"""Represents one unit of work within an execution trace."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
kind: SpanKind = SpanKind.INTERNAL
|
|
59
|
+
trace_id: str = field(default_factory=new_trace_id)
|
|
60
|
+
span_id: str = field(default_factory=new_span_id)
|
|
61
|
+
parent_span_id: str | None = None
|
|
62
|
+
start_time: datetime = field(default_factory=utc_now)
|
|
63
|
+
end_time: datetime | None = None
|
|
64
|
+
status: SpanStatus = SpanStatus.UNSET
|
|
65
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
error: dict[str, str] | None = None
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
if not isinstance(self.name, str) or not self.name.strip():
|
|
70
|
+
raise ValueError("Span name cannot be empty")
|
|
71
|
+
if not isinstance(self.kind, SpanKind):
|
|
72
|
+
raise ValueError("kind must be a SpanKind")
|
|
73
|
+
self._validate_id(self.trace_id, 32, "trace_id")
|
|
74
|
+
self._validate_id(self.span_id, 16, "span_id")
|
|
75
|
+
if self.parent_span_id is not None:
|
|
76
|
+
self._validate_id(self.parent_span_id, 16, "parent_span_id")
|
|
77
|
+
if self.parent_span_id == self.span_id:
|
|
78
|
+
raise ValueError("A span cannot be its own parent")
|
|
79
|
+
self._validate_timestamp(self.start_time, "start_time")
|
|
80
|
+
if self.end_time is not None:
|
|
81
|
+
self._validate_timestamp(self.end_time, "end_time")
|
|
82
|
+
if self.end_time < self.start_time:
|
|
83
|
+
raise ValueError("end_time cannot precede start_time")
|
|
84
|
+
validate_attributes(self.attributes)
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _validate_id(value: str, length: int, name: str) -> None:
|
|
88
|
+
if (
|
|
89
|
+
not isinstance(value, str)
|
|
90
|
+
or len(value) != length
|
|
91
|
+
or any(char not in "0123456789abcdef" for char in value)
|
|
92
|
+
or set(value) == {"0"}
|
|
93
|
+
):
|
|
94
|
+
raise ValueError(f"{name} must be {length} non-zero hexadecimal characters")
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _validate_timestamp(value: datetime, name: str) -> None:
|
|
98
|
+
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
|
|
99
|
+
raise ValueError(f"{name} must be timezone-aware")
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def duration_ms(self) -> float | None:
|
|
103
|
+
if self.end_time is None:
|
|
104
|
+
return None
|
|
105
|
+
return (self.end_time - self.start_time).total_seconds() * 1000
|
|
106
|
+
|
|
107
|
+
def set_attributes(self, attributes: dict[str, Any] | None = None, **kwargs: Any) -> None:
|
|
108
|
+
"""Add JSON-serializable metadata to this span."""
|
|
109
|
+
updates = dict(attributes or {})
|
|
110
|
+
updates.update(kwargs)
|
|
111
|
+
validate_attributes(updates)
|
|
112
|
+
self.attributes.update(updates)
|
|
113
|
+
validate_attributes(self.attributes)
|
|
114
|
+
|
|
115
|
+
def finish(
|
|
116
|
+
self,
|
|
117
|
+
status: SpanStatus = SpanStatus.OK,
|
|
118
|
+
error: dict[str, str] | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
if self.end_time is not None:
|
|
121
|
+
raise RuntimeError("Span has already finished")
|
|
122
|
+
if not isinstance(status, SpanStatus):
|
|
123
|
+
raise ValueError("status must be a SpanStatus")
|
|
124
|
+
if status == SpanStatus.ERROR and error is None:
|
|
125
|
+
raise ValueError("An error span must include error details")
|
|
126
|
+
if status != SpanStatus.ERROR and error is not None:
|
|
127
|
+
raise ValueError("Error details require ERROR status")
|
|
128
|
+
self.end_time = utc_now()
|
|
129
|
+
self.status = status
|
|
130
|
+
self.error = error
|
|
131
|
+
|
|
132
|
+
def to_dict(self) -> dict[str, Any]:
|
|
133
|
+
return {
|
|
134
|
+
"trace_id": self.trace_id,
|
|
135
|
+
"span_id": self.span_id,
|
|
136
|
+
"parent_span_id": self.parent_span_id,
|
|
137
|
+
"name": self.name,
|
|
138
|
+
"kind": self.kind.value,
|
|
139
|
+
"start_time": self.start_time.isoformat(),
|
|
140
|
+
"end_time": self.end_time.isoformat() if self.end_time is not None else None,
|
|
141
|
+
"duration_ms": self.duration_ms,
|
|
142
|
+
"status": self.status.value,
|
|
143
|
+
"attributes": self.attributes,
|
|
144
|
+
"error": self.error,
|
|
145
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Custom exceptions for the Spano SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SpanoError(Exception):
|
|
5
|
+
"""Base exception for Spano SDK errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SpanoConfigurationError(SpanoError):
|
|
9
|
+
"""Invalid SDK configuration."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SpanoTransportError(SpanoError):
|
|
13
|
+
"""Telemetry transport failed."""
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Trace and span context managers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar, Token
|
|
6
|
+
from typing import Any, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .events import Span, SpanKind, SpanStatus
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .client import Spano
|
|
12
|
+
|
|
13
|
+
_active_trace: ContextVar[Trace | None] = ContextVar("spano_active_trace", default=None)
|
|
14
|
+
_active_span: ContextVar[Span | None] = ContextVar("spano_active_span", default=None)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SpanScope:
|
|
18
|
+
"""Context manager that automatically finishes a span and captures exceptions."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, trace: "Trace", span: Span) -> None:
|
|
21
|
+
self.trace = trace
|
|
22
|
+
self.span = span
|
|
23
|
+
self._token: Token[Span | None] | None = None
|
|
24
|
+
|
|
25
|
+
def __enter__(self) -> Span:
|
|
26
|
+
if self.trace.finished:
|
|
27
|
+
raise RuntimeError("Cannot create a span in a finished trace")
|
|
28
|
+
self._token = _active_span.set(self.span)
|
|
29
|
+
return self.span
|
|
30
|
+
|
|
31
|
+
def __exit__(self, exc_type: Any, exc: BaseException | None, tb: Any) -> bool:
|
|
32
|
+
try:
|
|
33
|
+
if self.span.end_time is None:
|
|
34
|
+
if exc is not None:
|
|
35
|
+
self.span.finish(
|
|
36
|
+
status=SpanStatus.ERROR,
|
|
37
|
+
error={"type": exc_type.__name__ if exc_type else type(exc).__name__, "message": str(exc)[:1000]},
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
self.span.finish()
|
|
41
|
+
finally:
|
|
42
|
+
if self._token is not None:
|
|
43
|
+
_active_span.reset(self._token)
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Trace:
|
|
48
|
+
"""A trace owns a root span and all child spans created inside it."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, client: "Spano", name: str, attributes: dict[str, Any] | None = None) -> None:
|
|
51
|
+
self.client = client
|
|
52
|
+
self.root_span = Span(name=name, kind=SpanKind.AGENT, attributes=dict(attributes or {}))
|
|
53
|
+
self.spans: list[Span] = [self.root_span]
|
|
54
|
+
self.finished = False
|
|
55
|
+
self._trace_token: Token[Trace | None] | None = None
|
|
56
|
+
self._span_token: Token[Span | None] | None = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def trace_id(self) -> str:
|
|
60
|
+
return self.root_span.trace_id
|
|
61
|
+
|
|
62
|
+
def __enter__(self) -> "Trace":
|
|
63
|
+
if self.finished:
|
|
64
|
+
raise RuntimeError("Trace has already finished")
|
|
65
|
+
self._trace_token = _active_trace.set(self)
|
|
66
|
+
self._span_token = _active_span.set(self.root_span)
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def start_span(
|
|
70
|
+
self,
|
|
71
|
+
name: str,
|
|
72
|
+
*,
|
|
73
|
+
kind: SpanKind = SpanKind.INTERNAL,
|
|
74
|
+
attributes: dict[str, Any] | None = None,
|
|
75
|
+
) -> SpanScope:
|
|
76
|
+
if self.finished:
|
|
77
|
+
raise RuntimeError("Cannot create a span in a finished trace")
|
|
78
|
+
active_trace = _active_trace.get()
|
|
79
|
+
active_span = _active_span.get()
|
|
80
|
+
parent = active_span if active_trace is self and active_span is not None else self.root_span
|
|
81
|
+
span = Span(
|
|
82
|
+
name=name,
|
|
83
|
+
kind=kind,
|
|
84
|
+
trace_id=self.trace_id,
|
|
85
|
+
parent_span_id=parent.span_id,
|
|
86
|
+
attributes=dict(attributes or {}),
|
|
87
|
+
)
|
|
88
|
+
self.spans.append(span)
|
|
89
|
+
return SpanScope(self, span)
|
|
90
|
+
|
|
91
|
+
def set_attributes(self, attributes: dict[str, Any] | None = None, **kwargs: Any) -> None:
|
|
92
|
+
self.root_span.set_attributes(attributes, **kwargs)
|
|
93
|
+
|
|
94
|
+
def __exit__(self, exc_type: Any, exc: BaseException | None, tb: Any) -> bool:
|
|
95
|
+
try:
|
|
96
|
+
if not self.finished:
|
|
97
|
+
if exc is not None:
|
|
98
|
+
self.root_span.finish(
|
|
99
|
+
status=SpanStatus.ERROR,
|
|
100
|
+
error={"type": exc_type.__name__ if exc_type else type(exc).__name__, "message": str(exc)[:1000]},
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
self.root_span.finish()
|
|
104
|
+
self.finished = True
|
|
105
|
+
self.client._enqueue(self.spans)
|
|
106
|
+
finally:
|
|
107
|
+
if self._span_token is not None:
|
|
108
|
+
_active_span.reset(self._span_token)
|
|
109
|
+
if self._trace_token is not None:
|
|
110
|
+
_active_trace.reset(self._trace_token)
|
|
111
|
+
return False
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""HTTP transport for Spano telemetry, implemented with the standard library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from urllib.error import HTTPError, URLError
|
|
7
|
+
from urllib.request import Request, urlopen
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .exceptions import SpanoTransportError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HTTPExporter:
|
|
14
|
+
def __init__(self, endpoint: str, api_key: str, timeout: float, ingestion_path: str) -> None:
|
|
15
|
+
self.url = endpoint + ingestion_path
|
|
16
|
+
self.api_key = api_key
|
|
17
|
+
self.timeout = timeout
|
|
18
|
+
|
|
19
|
+
def export(self, spans: list[dict[str, Any]]) -> None:
|
|
20
|
+
if not spans:
|
|
21
|
+
return
|
|
22
|
+
body = json.dumps({"spans": spans}, allow_nan=False).encode("utf-8")
|
|
23
|
+
request = Request(
|
|
24
|
+
self.url,
|
|
25
|
+
data=body,
|
|
26
|
+
method="POST",
|
|
27
|
+
headers={
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
"Accept": "application/json",
|
|
30
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
31
|
+
"User-Agent": "spano-python-sdk/0.1.0",
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
try:
|
|
35
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
36
|
+
status = response.status
|
|
37
|
+
response_body = response.read()
|
|
38
|
+
except HTTPError as exc:
|
|
39
|
+
raise SpanoTransportError(f"Telemetry endpoint returned HTTP {exc.code}") from exc
|
|
40
|
+
except (URLError, TimeoutError, OSError) as exc:
|
|
41
|
+
raise SpanoTransportError(f"Unable to send telemetry: {exc}") from exc
|
|
42
|
+
|
|
43
|
+
if not 200 <= status < 300:
|
|
44
|
+
raise SpanoTransportError(f"Telemetry endpoint returned HTTP {status}")
|
|
45
|
+
if response_body:
|
|
46
|
+
try:
|
|
47
|
+
decoded = json.loads(response_body.decode("utf-8"))
|
|
48
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
49
|
+
raise SpanoTransportError("Telemetry endpoint returned invalid JSON") from exc
|
|
50
|
+
if not isinstance(decoded, dict):
|
|
51
|
+
raise SpanoTransportError("Telemetry endpoint response must be a JSON object")
|
|
52
|
+
accepted = decoded.get("accepted")
|
|
53
|
+
if accepted is not None and (not isinstance(accepted, int) or accepted != len(spans)):
|
|
54
|
+
raise SpanoTransportError("Telemetry endpoint accepted count did not match batch size")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from spano import Spano, SpanoConfig, SpanKind, SpanStatus, SpanoTransportError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeResponse:
|
|
10
|
+
status = 200
|
|
11
|
+
|
|
12
|
+
def __init__(self, body: bytes = b'{"accepted": 2}') -> None:
|
|
13
|
+
self.body = body
|
|
14
|
+
|
|
15
|
+
def __enter__(self):
|
|
16
|
+
return self
|
|
17
|
+
|
|
18
|
+
def __exit__(self, *args):
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
def read(self) -> bytes:
|
|
22
|
+
return self.body
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_client_initializes() -> None:
|
|
26
|
+
client = Spano(api_key="test-key", endpoint="http://localhost:8000/")
|
|
27
|
+
assert client.config.endpoint == "http://localhost:8000"
|
|
28
|
+
assert not client.closed
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_empty_api_key_is_rejected() -> None:
|
|
32
|
+
with pytest.raises(ValueError, match="api_key"):
|
|
33
|
+
Spano(api_key=" ")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_invalid_endpoint_is_rejected() -> None:
|
|
37
|
+
with pytest.raises(ValueError, match="endpoint"):
|
|
38
|
+
Spano(api_key="test-key", endpoint="localhost:8000")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_config_is_immutable() -> None:
|
|
42
|
+
config = SpanoConfig(api_key="test-key")
|
|
43
|
+
with pytest.raises(AttributeError):
|
|
44
|
+
config.timeout = 10.0 # type: ignore[misc]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_nested_spans_share_trace_and_parent_ids() -> None:
|
|
48
|
+
client = Spano(api_key="test-key")
|
|
49
|
+
with patch.object(client._exporter, "export") as export:
|
|
50
|
+
with client.start_trace("agent.run") as trace:
|
|
51
|
+
with trace.start_span("tool.search", kind=SpanKind.TOOL):
|
|
52
|
+
with trace.start_span("http.request") as child:
|
|
53
|
+
child.set_attributes(status_code=200)
|
|
54
|
+
assert client.flush() is True
|
|
55
|
+
assert export.call_count == 1
|
|
56
|
+
spans = export.call_args.args[0]
|
|
57
|
+
assert len(spans) == 3
|
|
58
|
+
assert len({span["trace_id"] for span in spans}) == 1
|
|
59
|
+
assert spans[1]["parent_span_id"] == spans[0]["span_id"]
|
|
60
|
+
assert spans[2]["parent_span_id"] == spans[1]["span_id"]
|
|
61
|
+
assert spans[0]["status"] == "ok"
|
|
62
|
+
assert spans[2]["attributes"]["status_code"] == 200
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_exceptions_are_recorded_and_propagated() -> None:
|
|
66
|
+
client = Spano(api_key="test-key")
|
|
67
|
+
with patch.object(client._exporter, "export") as export:
|
|
68
|
+
with pytest.raises(ValueError, match="application failure"):
|
|
69
|
+
with client.start_trace("agent.run") as trace:
|
|
70
|
+
with trace.start_span("tool.fail"):
|
|
71
|
+
raise ValueError("application failure")
|
|
72
|
+
assert client.flush() is True
|
|
73
|
+
spans = export.call_args.args[0]
|
|
74
|
+
assert spans[1]["status"] == "error"
|
|
75
|
+
assert spans[1]["error"]["type"] == "ValueError"
|
|
76
|
+
assert spans[0]["status"] == "error"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_flush_sends_bearer_token_and_json_payload() -> None:
|
|
80
|
+
client = Spano(api_key="secret", batch_size=1)
|
|
81
|
+
with patch("spano.transport.urlopen", return_value=FakeResponse(b'{"accepted": 2}')) as mocked:
|
|
82
|
+
with client.start_trace("agent"):
|
|
83
|
+
pass
|
|
84
|
+
request = mocked.call_args.args[0]
|
|
85
|
+
assert request.get_header("Authorization") == "Bearer secret"
|
|
86
|
+
payload = json.loads(request.data.decode())
|
|
87
|
+
assert len(payload["spans"]) == 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_transport_failure_is_nonfatal_by_default_and_reported() -> None:
|
|
91
|
+
errors: list[Exception] = []
|
|
92
|
+
client = Spano(api_key="test-key", on_error=errors.append)
|
|
93
|
+
with patch.object(client._exporter, "export", side_effect=SpanoTransportError("offline")):
|
|
94
|
+
with client.start_trace("agent"):
|
|
95
|
+
pass
|
|
96
|
+
assert client.flush() is False
|
|
97
|
+
assert len(errors) == 1
|
|
98
|
+
assert client._buffer
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_strict_transport_raises() -> None:
|
|
102
|
+
client = Spano(api_key="test-key", strict_transport=True)
|
|
103
|
+
with patch.object(client._exporter, "export", side_effect=SpanoTransportError("offline")):
|
|
104
|
+
with client.start_trace("agent"):
|
|
105
|
+
pass
|
|
106
|
+
with pytest.raises(SpanoTransportError):
|
|
107
|
+
client.flush()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_close_flushes_and_is_idempotent() -> None:
|
|
111
|
+
client = Spano(api_key="test-key")
|
|
112
|
+
with patch.object(client._exporter, "export") as export:
|
|
113
|
+
with client.start_trace("agent"):
|
|
114
|
+
pass
|
|
115
|
+
client.close()
|
|
116
|
+
client.close()
|
|
117
|
+
assert client.closed
|
|
118
|
+
assert export.call_count == 1
|
|
119
|
+
with pytest.raises(RuntimeError, match="closed"):
|
|
120
|
+
client.start_trace("too-late")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_buffer_is_bounded() -> None:
|
|
124
|
+
client = Spano(api_key="test-key", max_buffer_size=1, batch_size=100)
|
|
125
|
+
with patch.object(client._exporter, "export"):
|
|
126
|
+
with client.start_trace("first"):
|
|
127
|
+
pass
|
|
128
|
+
with client.start_trace("second"):
|
|
129
|
+
pass
|
|
130
|
+
assert len(client._buffer) == 1
|
|
131
|
+
assert client.dropped_spans == 1
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from spano import Span, SpanKind, SpanStatus
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_span_generates_valid_ids() -> None:
|
|
7
|
+
span = Span(name="agent.run")
|
|
8
|
+
assert len(span.trace_id) == 32
|
|
9
|
+
assert len(span.span_id) == 16
|
|
10
|
+
assert span.trace_id != "0" * 32
|
|
11
|
+
assert span.span_id != "0" * 16
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_span_starts_unfinished() -> None:
|
|
15
|
+
span = Span(name="llm.call", kind=SpanKind.LLM)
|
|
16
|
+
assert span.end_time is None
|
|
17
|
+
assert span.duration_ms is None
|
|
18
|
+
assert span.status == SpanStatus.UNSET
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_span_finishes_successfully() -> None:
|
|
22
|
+
span = Span(name="tool.search")
|
|
23
|
+
span.finish()
|
|
24
|
+
assert span.end_time is not None
|
|
25
|
+
assert span.duration_ms is not None
|
|
26
|
+
assert span.duration_ms >= 0
|
|
27
|
+
assert span.status == SpanStatus.OK
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_error_span_requires_details() -> None:
|
|
31
|
+
span = Span(name="llm.call")
|
|
32
|
+
with pytest.raises(ValueError, match="error details"):
|
|
33
|
+
span.finish(status=SpanStatus.ERROR)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_span_serializes_to_dict() -> None:
|
|
37
|
+
span = Span(name="agent.run", kind=SpanKind.AGENT)
|
|
38
|
+
data = span.to_dict()
|
|
39
|
+
assert data["name"] == "agent.run"
|
|
40
|
+
assert data["kind"] == "agent"
|
|
41
|
+
assert data["trace_id"] == span.trace_id
|
|
42
|
+
assert data["duration_ms"] is None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_attributes_must_be_json_serializable() -> None:
|
|
46
|
+
with pytest.raises(ValueError, match="JSON-serializable"):
|
|
47
|
+
Span(name="bad", attributes={"object": object()})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_set_attributes() -> None:
|
|
51
|
+
span = Span(name="agent")
|
|
52
|
+
span.set_attributes(model="gpt-test", token_count=12)
|
|
53
|
+
assert span.attributes == {"model": "gpt-test", "token_count": 12}
|
spano-0.1.0/uv.lock
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.11"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "build"
|
|
7
|
+
version = "1.6.1"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
dependencies = [
|
|
10
|
+
{ name = "colorama", marker = "os_name == 'nt'" },
|
|
11
|
+
{ name = "packaging" },
|
|
12
|
+
{ name = "pyproject-hooks" },
|
|
13
|
+
]
|
|
14
|
+
sdist = { url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz", hash = "sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53", size = 113427, upload-time = "2026-09-10T07:56:00.417Z" }
|
|
15
|
+
wheels = [
|
|
16
|
+
{ url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl", hash = "sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7", size = 31179, upload-time = "2026-09-10T07:55:59.148Z" },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[[package]]
|
|
20
|
+
name = "colorama"
|
|
21
|
+
version = "0.4.6"
|
|
22
|
+
source = { registry = "https://pypi.org/simple" }
|
|
23
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
24
|
+
wheels = [
|
|
25
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[[package]]
|
|
29
|
+
name = "iniconfig"
|
|
30
|
+
version = "2.3.1"
|
|
31
|
+
source = { registry = "https://pypi.org/simple" }
|
|
32
|
+
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
|
33
|
+
wheels = [
|
|
34
|
+
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[[package]]
|
|
38
|
+
name = "packaging"
|
|
39
|
+
version = "26.3"
|
|
40
|
+
source = { registry = "https://pypi.org/simple" }
|
|
41
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
|
42
|
+
wheels = [
|
|
43
|
+
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[[package]]
|
|
47
|
+
name = "pluggy"
|
|
48
|
+
version = "1.7.0"
|
|
49
|
+
source = { registry = "https://pypi.org/simple" }
|
|
50
|
+
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
|
|
51
|
+
wheels = [
|
|
52
|
+
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[[package]]
|
|
56
|
+
name = "pygments"
|
|
57
|
+
version = "2.21.0"
|
|
58
|
+
source = { registry = "https://pypi.org/simple" }
|
|
59
|
+
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
|
60
|
+
wheels = [
|
|
61
|
+
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[[package]]
|
|
65
|
+
name = "pyproject-hooks"
|
|
66
|
+
version = "1.3.3"
|
|
67
|
+
source = { registry = "https://pypi.org/simple" }
|
|
68
|
+
sdist = { url = "https://files.pythonhosted.org/packages/6d/5d/f2ddeef4a855a102aaae5e97826a0260007522ab504421b75addfdb1517c/pyproject_hooks-1.3.3.tar.gz", hash = "sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43", size = 21013, upload-time = "2026-09-16T08:58:03.999Z" }
|
|
69
|
+
wheels = [
|
|
70
|
+
{ url = "https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl", hash = "sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b", size = 10724, upload-time = "2026-09-16T08:58:02.96Z" },
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[[package]]
|
|
74
|
+
name = "pytest"
|
|
75
|
+
version = "9.1.1"
|
|
76
|
+
source = { registry = "https://pypi.org/simple" }
|
|
77
|
+
dependencies = [
|
|
78
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
79
|
+
{ name = "iniconfig" },
|
|
80
|
+
{ name = "packaging" },
|
|
81
|
+
{ name = "pluggy" },
|
|
82
|
+
{ name = "pygments" },
|
|
83
|
+
]
|
|
84
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
|
85
|
+
wheels = [
|
|
86
|
+
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[[package]]
|
|
90
|
+
name = "spano"
|
|
91
|
+
version = "0.1.0"
|
|
92
|
+
source = { editable = "." }
|
|
93
|
+
|
|
94
|
+
[package.dev-dependencies]
|
|
95
|
+
dev = [
|
|
96
|
+
{ name = "build" },
|
|
97
|
+
{ name = "pytest" },
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
[package.metadata]
|
|
101
|
+
|
|
102
|
+
[package.metadata.requires-dev]
|
|
103
|
+
dev = [
|
|
104
|
+
{ name = "build", specifier = ">=1.0" },
|
|
105
|
+
{ name = "pytest", specifier = ">=8.0" },
|
|
106
|
+
]
|