spoor-sdk 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .env
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ *.egg-info/
8
+ dist/
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.5
2
+ Name: spoor-sdk
3
+ Version: 0.0.1
4
+ Summary: Send agent and LLM traces to Spoor
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.38
7
+ Requires-Dist: opentelemetry-instrumentation-anthropic>=0.62
8
+ Requires-Dist: opentelemetry-instrumentation-bedrock>=0.62
9
+ Requires-Dist: opentelemetry-instrumentation-cohere>=0.62
10
+ Requires-Dist: opentelemetry-instrumentation-crewai>=0.62
11
+ Requires-Dist: opentelemetry-instrumentation-google-generativeai>=0.62
12
+ Requires-Dist: opentelemetry-instrumentation-groq>=0.62
13
+ Requires-Dist: opentelemetry-instrumentation-langchain>=0.62
14
+ Requires-Dist: opentelemetry-instrumentation-litellm>=0.62
15
+ Requires-Dist: opentelemetry-instrumentation-llamaindex>=0.62
16
+ Requires-Dist: opentelemetry-instrumentation-mistralai>=0.62
17
+ Requires-Dist: opentelemetry-instrumentation-ollama>=0.62
18
+ Requires-Dist: opentelemetry-instrumentation-openai-agents>=0.62
19
+ Requires-Dist: opentelemetry-instrumentation-openai>=0.62
20
+ Requires-Dist: opentelemetry-sdk>=1.38
21
+ Description-Content-Type: text/markdown
22
+
23
+ # spoor-sdk
24
+
25
+ Send agent and LLM traces to Spoor.
26
+
27
+ ```python
28
+ import spoor
29
+
30
+ spoor.init() # reads SPOOR_API_KEY, SPOOR_URL (default http://localhost:8000)
31
+ ```
32
+
33
+ `init()` turns on OpenLLMetry instrumentation for every installed library it supports
34
+ (openai, anthropic, LangChain/LangGraph, litellm, OpenAI Agents SDK, CrewAI, LlamaIndex,
35
+ google-genai, Bedrock, Mistral, Cohere, Groq, Ollama; see `src/spoor/libraries.py`) and exports
36
+ spans over OTLP/HTTP. If the app already has an OTel `TracerProvider`, Spoor's exporter is added to it.
37
+
38
+ Options: `api_key`, `url`, `service_name` (else `OTEL_SERVICE_NAME`, else the script name),
39
+ `environment` (else `SPOOR_ENVIRONMENT`, else `main`).
40
+
41
+ ## Operations
42
+
43
+ Each run belongs to an operation. It's named after the function that made the call, or explicitly:
44
+
45
+ ```python
46
+ @spoor.operation("triage") # or bare @spoor.operation, or `with spoor.operation("triage"):`
47
+ def triage(ticket): ...
48
+ ```
49
+
50
+ ## Test
51
+
52
+ ```bash
53
+ uv run pytest
54
+ ```
@@ -0,0 +1,32 @@
1
+ # spoor-sdk
2
+
3
+ Send agent and LLM traces to Spoor.
4
+
5
+ ```python
6
+ import spoor
7
+
8
+ spoor.init() # reads SPOOR_API_KEY, SPOOR_URL (default http://localhost:8000)
9
+ ```
10
+
11
+ `init()` turns on OpenLLMetry instrumentation for every installed library it supports
12
+ (openai, anthropic, LangChain/LangGraph, litellm, OpenAI Agents SDK, CrewAI, LlamaIndex,
13
+ google-genai, Bedrock, Mistral, Cohere, Groq, Ollama; see `src/spoor/libraries.py`) and exports
14
+ spans over OTLP/HTTP. If the app already has an OTel `TracerProvider`, Spoor's exporter is added to it.
15
+
16
+ Options: `api_key`, `url`, `service_name` (else `OTEL_SERVICE_NAME`, else the script name),
17
+ `environment` (else `SPOOR_ENVIRONMENT`, else `main`).
18
+
19
+ ## Operations
20
+
21
+ Each run belongs to an operation. It's named after the function that made the call, or explicitly:
22
+
23
+ ```python
24
+ @spoor.operation("triage") # or bare @spoor.operation, or `with spoor.operation("triage"):`
25
+ def triage(ticket): ...
26
+ ```
27
+
28
+ ## Test
29
+
30
+ ```bash
31
+ uv run pytest
32
+ ```
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "spoor-sdk"
3
+ version = "0.0.1"
4
+ description = "Send agent and LLM traces to Spoor"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "opentelemetry-sdk>=1.38",
9
+ "opentelemetry-exporter-otlp-proto-http>=1.38",
10
+ "opentelemetry-instrumentation-openai>=0.62",
11
+ "opentelemetry-instrumentation-anthropic>=0.62",
12
+ "opentelemetry-instrumentation-langchain>=0.62",
13
+ "opentelemetry-instrumentation-litellm>=0.62",
14
+ "opentelemetry-instrumentation-openai-agents>=0.62",
15
+ "opentelemetry-instrumentation-crewai>=0.62",
16
+ "opentelemetry-instrumentation-llamaindex>=0.62",
17
+ "opentelemetry-instrumentation-google-generativeai>=0.62",
18
+ "opentelemetry-instrumentation-bedrock>=0.62",
19
+ "opentelemetry-instrumentation-mistralai>=0.62",
20
+ "opentelemetry-instrumentation-cohere>=0.62",
21
+ "opentelemetry-instrumentation-groq>=0.62",
22
+ "opentelemetry-instrumentation-ollama>=0.62",
23
+ ]
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "pytest>=8.3",
28
+ "ruff>=0.7",
29
+ ]
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/spoor"]
37
+
38
+ [tool.ruff]
39
+ line-length = 100
@@ -0,0 +1,5 @@
1
+ from spoor.operations import operation
2
+ from spoor.setup import init
3
+
4
+ __all__ = ["init", "operation"]
5
+ __version__ = "0.1.0"
@@ -0,0 +1,50 @@
1
+ """Name runs after the user code that started them: the first stack frame outside libraries."""
2
+
3
+ import sys
4
+ import sysconfig
5
+ from pathlib import Path
6
+
7
+ from opentelemetry.sdk.trace import SpanProcessor
8
+
9
+ _LIBRARY_PATHS = tuple(
10
+ {sysconfig.get_path(p) for p in ("stdlib", "platstdlib", "purelib", "platlib")}
11
+ | {str(Path(__file__).parent)}
12
+ )
13
+
14
+
15
+ MAX_CALLERS = 5
16
+
17
+
18
+ class CallerProcessor(SpanProcessor):
19
+ """Adds the calling user code to every root span: OTel `code.*` attributes for the innermost
20
+ user frame, plus `spoor.callers`, the user call chain (innermost first). A shared helper like
21
+ `ask()` is the innermost frame of many call types; the chain lets the server name each one."""
22
+
23
+ def on_start(self, span, parent_context=None) -> None:
24
+ if span.parent is not None and not span.parent.is_remote:
25
+ return
26
+ frames = user_frames()
27
+ if not frames:
28
+ return
29
+ frame = frames[0]
30
+ span.set_attribute("code.function.name", function_name(frame))
31
+ span.set_attribute("code.file.path", frame.f_code.co_filename)
32
+ span.set_attribute("code.line.number", frame.f_lineno)
33
+ span.set_attribute("spoor.callers", [function_name(f) for f in frames])
34
+
35
+
36
+ def function_name(frame) -> str:
37
+ code = frame.f_code
38
+ if code.co_name == "<module>": # top-level script code: use the file name
39
+ return Path(code.co_filename).stem
40
+ return getattr(code, "co_qualname", code.co_name)
41
+
42
+
43
+ def user_frames() -> list:
44
+ frames, frame = [], sys._getframe(1)
45
+ while frame is not None and len(frames) < MAX_CALLERS:
46
+ filename = frame.f_code.co_filename
47
+ if not filename.startswith(_LIBRARY_PATHS) and not filename.startswith("<"):
48
+ frames.append(frame)
49
+ frame = frame.f_back
50
+ return frames
@@ -0,0 +1,49 @@
1
+ """Libraries `spoor.init()` instruments when they are installed, via OpenLLMetry instrumentors."""
2
+
3
+ import importlib
4
+ import logging
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ logger = logging.getLogger("spoor")
8
+
9
+ # (distribution the user has installed, instrumentor module, instrumentor class)
10
+ LIBRARIES = [
11
+ ("openai", "opentelemetry.instrumentation.openai", "OpenAIInstrumentor"),
12
+ ("anthropic", "opentelemetry.instrumentation.anthropic", "AnthropicInstrumentor"),
13
+ ("langchain-core", "opentelemetry.instrumentation.langchain", "LangchainInstrumentor"),
14
+ ("litellm", "opentelemetry.instrumentation.litellm", "LiteLLMInstrumentor"),
15
+ ("openai-agents", "opentelemetry.instrumentation.openai_agents", "OpenAIAgentsInstrumentor"),
16
+ ("crewai", "opentelemetry.instrumentation.crewai", "CrewAIInstrumentor"),
17
+ ("llama-index-core", "opentelemetry.instrumentation.llamaindex", "LlamaIndexInstrumentor"),
18
+ ("google-genai", "opentelemetry.instrumentation.google_generativeai", "GoogleGenerativeAiInstrumentor"),
19
+ ("boto3", "opentelemetry.instrumentation.bedrock", "BedrockInstrumentor"),
20
+ ("mistralai", "opentelemetry.instrumentation.mistralai", "MistralAiInstrumentor"),
21
+ ("cohere", "opentelemetry.instrumentation.cohere", "CohereInstrumentor"),
22
+ ("groq", "opentelemetry.instrumentation.groq", "GroqInstrumentor"),
23
+ ("ollama", "opentelemetry.instrumentation.ollama", "OllamaInstrumentor"),
24
+ ]
25
+
26
+
27
+ def installed(distribution: str) -> bool:
28
+ try:
29
+ version(distribution)
30
+ return True
31
+ except PackageNotFoundError:
32
+ return False
33
+
34
+
35
+ def instrument() -> list[str]:
36
+ """Turn on the instrumentor for every installed library; returns the ones enabled."""
37
+ enabled = []
38
+ for library, module, cls in LIBRARIES:
39
+ if not installed(library):
40
+ continue
41
+ try:
42
+ instrumentor = getattr(importlib.import_module(module), cls)()
43
+ if not instrumentor.is_instrumented_by_opentelemetry:
44
+ instrumentor.instrument()
45
+ if instrumentor.is_instrumented_by_opentelemetry:
46
+ enabled.append(library)
47
+ except Exception: # one broken instrumentor must not break the app
48
+ logger.warning("spoor: could not instrument %s", library, exc_info=True)
49
+ return enabled
@@ -0,0 +1,58 @@
1
+ import functools
2
+ import inspect
3
+ from collections.abc import Callable
4
+
5
+ from opentelemetry import trace
6
+
7
+ tracer = trace.get_tracer("spoor")
8
+
9
+
10
+ def operation(name: str | Callable | None = None):
11
+ """Mark an operation: each call is one run in Spoor.
12
+
13
+ @spoor.operation # named after the function
14
+ @spoor.operation("classify") # explicit name
15
+ with spoor.operation("classify"): ...
16
+ """
17
+ if callable(name):
18
+ return _wrap(name, name.__name__)
19
+ return _Operation(name)
20
+
21
+
22
+ class _Operation:
23
+ def __init__(self, name: str | None) -> None:
24
+ self.name = name
25
+
26
+ def __call__(self, fn: Callable) -> Callable:
27
+ return _wrap(fn, self.name or fn.__name__)
28
+
29
+ def __enter__(self):
30
+ self._span = _span(self.name or "operation")
31
+ return self._span.__enter__()
32
+
33
+ def __exit__(self, *exc_info):
34
+ return self._span.__exit__(*exc_info)
35
+
36
+
37
+ def _span(name: str):
38
+ return tracer.start_as_current_span(
39
+ name, attributes={"spoor.operation.name": name, "traceloop.span.kind": "workflow"}
40
+ )
41
+
42
+
43
+ def _wrap(fn: Callable, name: str) -> Callable:
44
+ if inspect.iscoroutinefunction(fn):
45
+
46
+ @functools.wraps(fn)
47
+ async def async_wrapper(*args, **kwargs):
48
+ with _span(name):
49
+ return await fn(*args, **kwargs)
50
+
51
+ return async_wrapper
52
+
53
+ @functools.wraps(fn)
54
+ def wrapper(*args, **kwargs):
55
+ with _span(name):
56
+ return fn(*args, **kwargs)
57
+
58
+ return wrapper
@@ -0,0 +1,65 @@
1
+ import logging
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from opentelemetry import trace
7
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
8
+ from opentelemetry.sdk.resources import Resource
9
+ from opentelemetry.sdk.trace import TracerProvider
10
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
11
+
12
+ from spoor import libraries
13
+ from spoor.caller import CallerProcessor
14
+
15
+ logger = logging.getLogger("spoor")
16
+
17
+ _provider: TracerProvider | None = None
18
+
19
+
20
+ def init(
21
+ api_key: str | None = None,
22
+ *,
23
+ url: str | None = None,
24
+ service_name: str | None = None,
25
+ environment: str | None = None,
26
+ ) -> TracerProvider:
27
+ """Send traces of every installed LLM/agent library to Spoor.
28
+
29
+ Arguments fall back to SPOOR_API_KEY, SPOOR_URL, OTEL_SERVICE_NAME and SPOOR_ENVIRONMENT.
30
+ If the app already has an OTel TracerProvider, Spoor's exporter is added to it.
31
+ """
32
+ global _provider
33
+ if _provider is not None:
34
+ return _provider
35
+
36
+ api_key = api_key or os.environ.get("SPOOR_API_KEY")
37
+ if not api_key:
38
+ raise ValueError("spoor.init(): pass api_key or set SPOOR_API_KEY")
39
+ url = (url or os.environ.get("SPOOR_URL", "http://localhost:8000")).rstrip("/")
40
+ exporter = OTLPSpanExporter(
41
+ endpoint=f"{url}/v1/traces", headers={"Authorization": f"Bearer {api_key}"}
42
+ )
43
+
44
+ provider = trace.get_tracer_provider()
45
+ if not isinstance(provider, TracerProvider):
46
+ provider = TracerProvider(resource=_resource(service_name, environment))
47
+ trace.set_tracer_provider(provider)
48
+ provider.add_span_processor(CallerProcessor())
49
+ provider.add_span_processor(BatchSpanProcessor(exporter))
50
+
51
+ enabled = libraries.instrument()
52
+ logger.info("spoor: sending traces to %s, instrumented: %s", url, ", ".join(enabled) or "none")
53
+ _provider = provider
54
+ return provider
55
+
56
+
57
+ def _resource(service_name: str | None, environment: str | None) -> Resource:
58
+ attributes = {
59
+ "service.name": service_name
60
+ or os.environ.get("OTEL_SERVICE_NAME")
61
+ or Path(sys.argv[0]).stem
62
+ or "unknown_service",
63
+ "deployment.environment.name": environment or os.environ.get("SPOOR_ENVIRONMENT", "main"),
64
+ }
65
+ return Resource.create(attributes)
@@ -0,0 +1,31 @@
1
+ import pytest
2
+ from opentelemetry.sdk.trace import TracerProvider
3
+
4
+ import spoor
5
+ from spoor import libraries, setup
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def reset(monkeypatch):
10
+ monkeypatch.setattr(setup, "_provider", None)
11
+ monkeypatch.setattr(libraries, "instrument", list)
12
+ monkeypatch.delenv("SPOOR_API_KEY", raising=False)
13
+
14
+
15
+ def test_requires_api_key():
16
+ with pytest.raises(ValueError, match="SPOOR_API_KEY"):
17
+ spoor.init()
18
+
19
+
20
+ def test_init_sets_resource_and_is_idempotent(monkeypatch):
21
+ monkeypatch.setenv("SPOOR_API_KEY", "sk-test")
22
+ provider = spoor.init(service_name="billing", environment="staging")
23
+ assert isinstance(provider, TracerProvider)
24
+ assert provider.resource.attributes["service.name"] == "billing"
25
+ assert provider.resource.attributes["deployment.environment.name"] == "staging"
26
+ assert spoor.init() is provider
27
+
28
+
29
+ def test_installed_checks_distributions():
30
+ assert libraries.installed("opentelemetry-sdk")
31
+ assert not libraries.installed("openai-agents")
@@ -0,0 +1,77 @@
1
+ import asyncio
2
+
3
+ import pytest
4
+ from opentelemetry.sdk.trace import TracerProvider
5
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
6
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
7
+ from opentelemetry.trace import StatusCode
8
+
9
+ import spoor
10
+ from spoor import operations
11
+ from spoor.caller import CallerProcessor
12
+
13
+
14
+ @pytest.fixture
15
+ def spans(monkeypatch):
16
+ exporter = InMemorySpanExporter()
17
+ provider = TracerProvider()
18
+ provider.add_span_processor(CallerProcessor())
19
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
20
+ monkeypatch.setattr(operations, "tracer", provider.get_tracer("test"))
21
+ return exporter.get_finished_spans
22
+
23
+
24
+ def test_bare_decorator_uses_function_name(spans):
25
+ @spoor.operation
26
+ def classify_ticket(text):
27
+ return text.upper()
28
+
29
+ assert classify_ticket("hi") == "HI"
30
+ [span] = spans()
31
+ assert span.name == "classify_ticket"
32
+ assert span.attributes["spoor.operation.name"] == "classify_ticket"
33
+
34
+
35
+ def test_named_decorator_on_async_function(spans):
36
+ @spoor.operation("triage")
37
+ async def handle():
38
+ return 1
39
+
40
+ assert asyncio.run(handle()) == 1
41
+ assert spans()[0].attributes["spoor.operation.name"] == "triage"
42
+
43
+
44
+ def test_context_manager_records_errors(spans):
45
+ with pytest.raises(ValueError), spoor.operation("refund"):
46
+ raise ValueError("boom")
47
+ [span] = spans()
48
+ assert span.name == "refund"
49
+ assert span.status.status_code == StatusCode.ERROR
50
+
51
+
52
+ def test_root_span_is_named_after_calling_function(spans):
53
+ def summarize_ticket():
54
+ tracer = operations.tracer
55
+ with tracer.start_as_current_span("openai.chat"), tracer.start_as_current_span("child"):
56
+ pass
57
+
58
+ summarize_ticket()
59
+ child, root = spans()
60
+ assert root.attributes["code.function.name"].endswith("summarize_ticket")
61
+ assert root.attributes["code.file.path"] == __file__
62
+ assert "code.function.name" not in child.attributes
63
+
64
+
65
+ def test_root_span_records_the_user_call_chain(spans):
66
+ def ask():
67
+ with operations.tracer.start_as_current_span("openai.chat"):
68
+ pass
69
+
70
+ def summarize_ticket():
71
+ ask()
72
+
73
+ summarize_ticket()
74
+ callers = spans()[0].attributes["spoor.callers"]
75
+ assert [c.rsplit(".", 1)[-1] for c in callers[:3]] == [
76
+ "ask", "summarize_ticket", "test_root_span_records_the_user_call_chain"
77
+ ]