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.
- spoor_sdk-0.0.1/.gitignore +8 -0
- spoor_sdk-0.0.1/.python-version +1 -0
- spoor_sdk-0.0.1/PKG-INFO +54 -0
- spoor_sdk-0.0.1/README.md +32 -0
- spoor_sdk-0.0.1/pyproject.toml +39 -0
- spoor_sdk-0.0.1/src/spoor/__init__.py +5 -0
- spoor_sdk-0.0.1/src/spoor/caller.py +50 -0
- spoor_sdk-0.0.1/src/spoor/libraries.py +49 -0
- spoor_sdk-0.0.1/src/spoor/operations.py +58 -0
- spoor_sdk-0.0.1/src/spoor/setup.py +65 -0
- spoor_sdk-0.0.1/tests/test_init.py +31 -0
- spoor_sdk-0.0.1/tests/test_operations.py +77 -0
- spoor_sdk-0.0.1/uv.lock +862 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
spoor_sdk-0.0.1/PKG-INFO
ADDED
|
@@ -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,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
|
+
]
|