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 ADDED
@@ -0,0 +1,10 @@
1
+ if (-not (Test-Path "SDK\.gitignore")) {
2
+ @'
3
+ .venv/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ '@ | Set-Content -Encoding utf8 "SDK\.gitignore"
10
+ }
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
+ ]