pulse-trace-sdk 0.2.4__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,4 @@
1
+ .venv
2
+ .env
3
+ __pycache__/
4
+ *.pyc
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: pulse-trace-sdk
3
+ Version: 0.2.4
4
+ Summary: Pulse Python SDK for tracing LLM providers
5
+ Author: Pulse
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: requests>=2.32.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: black>=26.1.0; extra == 'dev'
11
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Pulse Python SDK
15
+
16
+ Python client helpers for Pulse trace ingestion. Wrap your LLM provider SDK (OpenAI, Anthropic) and Pulse automatically captures trace metadata and ships it to your trace-service instance.
17
+
18
+ ## Installation
19
+
20
+ Install from PyPI:
21
+
22
+ ```bash
23
+ pip install pulse-trace-sdk
24
+ ```
25
+
26
+ For local development:
27
+
28
+ ```bash
29
+ pip install -e .
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```python
35
+ from openai import OpenAI
36
+ from pulse_sdk import init_pulse, observe, Provider
37
+
38
+ init_pulse({
39
+ "api_key": "pulse_sk_...",
40
+ "api_url": "http://localhost:3000",
41
+ })
42
+
43
+ client = OpenAI(api_key="your-openai-key")
44
+ observed = observe(client, Provider.OPENAI)
45
+
46
+ response = observed.chat.completions.create(
47
+ model="gpt-4o-mini",
48
+ messages=[{"role": "user", "content": "Hello Pulse"}],
49
+ pulse_session_id="session-123",
50
+ pulse_metadata={"feature": "chat"},
51
+ )
52
+ ```
53
+
54
+ To instrument Anthropic:
55
+
56
+ ```python
57
+ from anthropic import Anthropic
58
+ from pulse_sdk import observe, Provider
59
+
60
+ anthropic_client = Anthropic(api_key="anthropic-key")
61
+ observe(anthropic_client, Provider.ANTHROPIC)
62
+
63
+ anthropic_client.messages.create(
64
+ model="claude-3-5-haiku-20241022",
65
+ max_tokens=300,
66
+ messages=[{"role": "user", "content": "Summarize"}],
67
+ )
68
+ ```
69
+
70
+ ## API
71
+
72
+ - `init_pulse(config)` – configure API URL, key, batch size, and flush interval. Starts a background worker that periodically flushes traces.
73
+ - `observe(client, provider, options=None)` – wraps the provider SDK and returns the same client instance instrumented with tracing.
74
+ - `flush_buffer()` – (optional) force-send buffered traces, useful before process shutdown.
75
+ - `shutdown()` – stop the background worker and clear buffers.
76
+
77
+ ### Config options
78
+
79
+ ```python
80
+ init_pulse({
81
+ "api_key": "pulse_sk_...", # required
82
+ "api_url": "https://api.example.com", # default http://localhost:3000
83
+ "batch_size": 10, # flush when buffer hits this size
84
+ "flush_interval": 5000, # milliseconds between automatic flushes
85
+ "enabled": True, # set False to disable tracing
86
+ })
87
+ ```
88
+
89
+ ### Pulse specific metadata
90
+
91
+ All `chat.completions.create` / `messages.create` calls support:
92
+
93
+ - `pulse_session_id` – associate traces with a session.
94
+ - `pulse_metadata` – arbitrary dictionary merged into trace metadata.
95
+
96
+ ## Requirements
97
+
98
+ - Python 3.10+
99
+ - `requests`
100
+ - Corresponding provider SDK (`openai`, `anthropic`) for the helpers you use.
101
+
102
+ ## Tests
103
+
104
+ ```bash
105
+ uv run pytest
106
+ ```
107
+
108
+ ### Live integration check
109
+
110
+ To manually exercise the SDK against real providers, run:
111
+
112
+ ```bash
113
+ uv run python tests/test_server.py openai
114
+ uv run python tests/test_server.py anthropic
115
+ ```
116
+
117
+ Set `PULSE_API_KEY` and the relevant provider key (`OPENAI_API_KEY` or `ANTHROPIC_API_KEY`) before running. The script makes a single completion request and relies on `observe()` to push traces to your trace-service instance.
@@ -0,0 +1,104 @@
1
+ # Pulse Python SDK
2
+
3
+ Python client helpers for Pulse trace ingestion. Wrap your LLM provider SDK (OpenAI, Anthropic) and Pulse automatically captures trace metadata and ships it to your trace-service instance.
4
+
5
+ ## Installation
6
+
7
+ Install from PyPI:
8
+
9
+ ```bash
10
+ pip install pulse-trace-sdk
11
+ ```
12
+
13
+ For local development:
14
+
15
+ ```bash
16
+ pip install -e .
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ from openai import OpenAI
23
+ from pulse_sdk import init_pulse, observe, Provider
24
+
25
+ init_pulse({
26
+ "api_key": "pulse_sk_...",
27
+ "api_url": "http://localhost:3000",
28
+ })
29
+
30
+ client = OpenAI(api_key="your-openai-key")
31
+ observed = observe(client, Provider.OPENAI)
32
+
33
+ response = observed.chat.completions.create(
34
+ model="gpt-4o-mini",
35
+ messages=[{"role": "user", "content": "Hello Pulse"}],
36
+ pulse_session_id="session-123",
37
+ pulse_metadata={"feature": "chat"},
38
+ )
39
+ ```
40
+
41
+ To instrument Anthropic:
42
+
43
+ ```python
44
+ from anthropic import Anthropic
45
+ from pulse_sdk import observe, Provider
46
+
47
+ anthropic_client = Anthropic(api_key="anthropic-key")
48
+ observe(anthropic_client, Provider.ANTHROPIC)
49
+
50
+ anthropic_client.messages.create(
51
+ model="claude-3-5-haiku-20241022",
52
+ max_tokens=300,
53
+ messages=[{"role": "user", "content": "Summarize"}],
54
+ )
55
+ ```
56
+
57
+ ## API
58
+
59
+ - `init_pulse(config)` – configure API URL, key, batch size, and flush interval. Starts a background worker that periodically flushes traces.
60
+ - `observe(client, provider, options=None)` – wraps the provider SDK and returns the same client instance instrumented with tracing.
61
+ - `flush_buffer()` – (optional) force-send buffered traces, useful before process shutdown.
62
+ - `shutdown()` – stop the background worker and clear buffers.
63
+
64
+ ### Config options
65
+
66
+ ```python
67
+ init_pulse({
68
+ "api_key": "pulse_sk_...", # required
69
+ "api_url": "https://api.example.com", # default http://localhost:3000
70
+ "batch_size": 10, # flush when buffer hits this size
71
+ "flush_interval": 5000, # milliseconds between automatic flushes
72
+ "enabled": True, # set False to disable tracing
73
+ })
74
+ ```
75
+
76
+ ### Pulse specific metadata
77
+
78
+ All `chat.completions.create` / `messages.create` calls support:
79
+
80
+ - `pulse_session_id` – associate traces with a session.
81
+ - `pulse_metadata` – arbitrary dictionary merged into trace metadata.
82
+
83
+ ## Requirements
84
+
85
+ - Python 3.10+
86
+ - `requests`
87
+ - Corresponding provider SDK (`openai`, `anthropic`) for the helpers you use.
88
+
89
+ ## Tests
90
+
91
+ ```bash
92
+ uv run pytest
93
+ ```
94
+
95
+ ### Live integration check
96
+
97
+ To manually exercise the SDK against real providers, run:
98
+
99
+ ```bash
100
+ uv run python tests/test_server.py openai
101
+ uv run python tests/test_server.py anthropic
102
+ ```
103
+
104
+ Set `PULSE_API_KEY` and the relevant provider key (`OPENAI_API_KEY` or `ANTHROPIC_API_KEY`) before running. The script makes a single completion request and relies on `observe()` to push traces to your trace-service instance.
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from .config import load_config
4
+ from .providers import observe_anthropic, observe_openai
5
+ from .state import (
6
+ flush_buffer,
7
+ reset_state,
8
+ set_config,
9
+ start_flush_worker,
10
+ stop_flush_worker,
11
+ )
12
+ from .types import ObserveOptions, Provider, PulseConfig
13
+
14
+
15
+ def init_pulse(config: PulseConfig) -> None:
16
+ resolved = load_config(config)
17
+ set_config(resolved)
18
+ start_flush_worker()
19
+
20
+
21
+ def shutdown() -> None:
22
+ stop_flush_worker()
23
+ reset_state()
24
+
25
+
26
+ def observe(client, provider: Provider, options: ObserveOptions | None = None):
27
+ if provider in (Provider.OPENAI, Provider.OPENROUTER):
28
+ return observe_openai(client, provider, options)
29
+ if provider == Provider.ANTHROPIC:
30
+ return observe_anthropic(client, options)
31
+ raise ValueError(f"Unsupported provider: {provider}")
32
+
33
+
34
+ __all__ = [
35
+ "init_pulse",
36
+ "shutdown",
37
+ "observe",
38
+ "flush_buffer",
39
+ "Provider",
40
+ "ObserveOptions",
41
+ ]
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+ from .types import PulseConfig
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ResolvedConfig:
11
+ api_key: str
12
+ api_url: str
13
+ batch_size: int
14
+ flush_interval: int
15
+ enabled: bool
16
+
17
+
18
+ DEFAULT_API_URL = "http://localhost:3000"
19
+ DEFAULT_BATCH_SIZE = 10
20
+ DEFAULT_FLUSH_INTERVAL = 5000 # ms
21
+ DEFAULT_ENABLED = True
22
+
23
+
24
+ class ConfigError(ValueError):
25
+ """Raised when the user passes invalid configuration."""
26
+
27
+
28
+ def load_config(config: PulseConfig) -> ResolvedConfig:
29
+ api_key = config.get("api_key")
30
+ if not api_key:
31
+ raise ConfigError("Pulse SDK: api_key is required")
32
+
33
+ if not api_key.startswith("pulse_sk_"):
34
+ raise ConfigError("Pulse SDK: api_key must start with 'pulse_sk_'")
35
+
36
+ batch_size = int(config.get("batch_size", DEFAULT_BATCH_SIZE))
37
+ if batch_size < 1 or batch_size > 100:
38
+ raise ConfigError("Pulse SDK: batch_size must be between 1 and 100")
39
+
40
+ flush_interval = int(config.get("flush_interval", DEFAULT_FLUSH_INTERVAL))
41
+ if flush_interval < 1000:
42
+ raise ConfigError("Pulse SDK: flush_interval must be at least 1000ms")
43
+
44
+ api_url = config.get("api_url", DEFAULT_API_URL)
45
+ enabled = bool(config.get("enabled", DEFAULT_ENABLED))
46
+
47
+ return ResolvedConfig(
48
+ api_key=api_key,
49
+ api_url=api_url,
50
+ batch_size=batch_size,
51
+ flush_interval=flush_interval,
52
+ enabled=enabled,
53
+ )
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from .types import NormalizedResponse
6
+
7
+ try:
8
+ from openai.types.chat import ChatCompletion
9
+ except Exception:
10
+ ChatCompletion = object # type: ignore
11
+
12
+ try:
13
+ from anthropic.types import Message
14
+ except Exception:
15
+ Message = object # type: ignore
16
+
17
+
18
+ ANTHROPIC_STOP_REASON_MAP = {
19
+ "end_turn": "stop",
20
+ "max_tokens": "length",
21
+ "stop_sequence": "stop",
22
+ "tool_use": "tool_calls",
23
+ }
24
+
25
+
26
+ def normalize_openai_response(response: "ChatCompletion") -> NormalizedResponse:
27
+ choice = response.choices[0] if response.choices else None
28
+ content = getattr(choice.message, "content", None) if choice else None
29
+ usage = getattr(response, "usage", None)
30
+ input_tokens = getattr(usage, "prompt_tokens", None)
31
+ output_tokens = getattr(usage, "completion_tokens", None)
32
+ finish_reason = getattr(choice, "finish_reason", None)
33
+ model = getattr(response, "model", "unknown")
34
+ provider_id = getattr(response, "id", None)
35
+
36
+ cost_cents = None
37
+ cost_value = getattr(response, "cost", None)
38
+ if isinstance(cost_value, (int, float)):
39
+ cost_cents = cost_value * 100
40
+
41
+ return NormalizedResponse(
42
+ model=model,
43
+ content=content if isinstance(content, str) else None,
44
+ input_tokens=input_tokens,
45
+ output_tokens=output_tokens,
46
+ finish_reason=finish_reason,
47
+ cost_cents=cost_cents,
48
+ provider_request_id=provider_id,
49
+ )
50
+
51
+
52
+ def normalize_anthropic_response(response: "Message") -> NormalizedResponse:
53
+ content_parts = []
54
+ for block in getattr(response, "content", []) or []:
55
+ if getattr(block, "type", None) == "text":
56
+ text_value = getattr(block, "text", None)
57
+ if text_value:
58
+ content_parts.append(text_value)
59
+ content = "".join(content_parts) if content_parts else None
60
+
61
+ usage = getattr(response, "usage", None)
62
+ input_tokens = getattr(usage, "input_tokens", None)
63
+ output_tokens = getattr(usage, "output_tokens", None)
64
+
65
+ stop_reason = getattr(response, "stop_reason", None)
66
+ finish_reason = ANTHROPIC_STOP_REASON_MAP.get(stop_reason, stop_reason)
67
+ model = getattr(response, "model", "unknown")
68
+
69
+ return NormalizedResponse(
70
+ model=model,
71
+ content=content,
72
+ input_tokens=input_tokens,
73
+ output_tokens=output_tokens,
74
+ finish_reason=finish_reason,
75
+ )
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, Optional
4
+
5
+ ModelPricing = Dict[str, float]
6
+
7
+ MODEL_PRICING: Dict[str, ModelPricing] = {
8
+ "gpt-5.1": {"input": 125, "output": 1000},
9
+ "gpt-5": {"input": 150, "output": 1000},
10
+ "gpt-5-mini": {"input": 90, "output": 400},
11
+ "gpt-5-nano": {"input": 20, "output": 100},
12
+ "gpt-5.1-chat-latest": {"input": 125, "output": 1000},
13
+ "gpt-5-chat-latest": {"input": 150, "output": 1000},
14
+ "gpt-5.1-codex-max": {"input": 250, "output": 1250},
15
+ "gpt-5.1-codex": {"input": 125, "output": 600},
16
+ "gpt-5-codex": {"input": 145, "output": 600},
17
+ "gpt-5.1-codex-mini": {"input": 40, "output": 160},
18
+ "codex-mini-latest": {"input": 40, "output": 160},
19
+ "gpt-5-pro": {"input": 250, "output": 1200},
20
+ "gpt-5-search-api": {"input": 400, "output": 1600},
21
+ "gpt-4.1": {"input": 250, "output": 1000},
22
+ "gpt-4.1-mini": {"input": 100, "output": 400},
23
+ "gpt-4.1-nano": {"input": 15, "output": 60},
24
+ "gpt-4o": {"input": 250, "output": 1000},
25
+ "gpt-4o-2024-05-13": {"input": 250, "output": 1000},
26
+ "gpt-4o-mini": {"input": 15, "output": 60},
27
+ "gpt-4o-mini-search-preview": {"input": 500, "output": 2000},
28
+ "gpt-4o-search-preview": {"input": 1000, "output": 4000},
29
+ "gpt-realtime": {"input": 500, "output": 2000},
30
+ "gpt-realtime-mini": {"input": 250, "output": 1000},
31
+ "gpt-4o-realtime-preview": {"input": 500, "output": 2000},
32
+ "gpt-4o-mini-realtime-preview": {"input": 15, "output": 60},
33
+ "gpt-audio": {"input": 250, "output": 1000},
34
+ "gpt-audio-mini": {"input": 60, "output": 250},
35
+ "gpt-4o-audio-preview": {"input": 250, "output": 1000},
36
+ "gpt-4o-mini-audio-preview": {"input": 15, "output": 60},
37
+ "o1": {"input": 1500, "output": 6000},
38
+ "o1-pro": {"input": 1800, "output": 7200},
39
+ "o1-mini": {"input": 350, "output": 1400},
40
+ "o3-pro": {"input": 1500, "output": 6000},
41
+ "o3": {"input": 600, "output": 2400},
42
+ "o3-mini": {"input": 200, "output": 800},
43
+ "o3-deep-research": {"input": 2500, "output": 10000},
44
+ "o4-mini": {"input": 300, "output": 1200},
45
+ "o4-mini-deep-research": {"input": 500, "output": 2000},
46
+ "computer-use-preview": {"input": 500, "output": 0},
47
+ "gpt-image-1": {"input": 5000, "output": 0},
48
+ "gpt-image-1-mini": {"input": 2000, "output": 0},
49
+ "gpt-4-turbo": {"input": 1000, "output": 3000},
50
+ "gpt-3.5-turbo": {"input": 50, "output": 150},
51
+ "claude-opus-4-5-20251101": {"input": 500, "output": 2500},
52
+ "claude-opus-4-1-20250805": {"input": 1500, "output": 7500},
53
+ "claude-opus-4-20250514": {"input": 1500, "output": 7500},
54
+ "claude-sonnet-4-5-20250929": {"input": 300, "output": 1500},
55
+ "claude-sonnet-4-20250514": {"input": 300, "output": 1500},
56
+ "claude-3-7-sonnet-20250219": {"input": 300, "output": 1500},
57
+ "claude-3-sonnet-20240229": {"input": 300, "output": 1500},
58
+ "claude-3-5-sonnet-20241022": {"input": 300, "output": 1500},
59
+ "claude-haiku-4-5-20251001": {"input": 100, "output": 500},
60
+ "claude-3-5-haiku-20241022": {"input": 80, "output": 400},
61
+ "claude-3-haiku-20240307": {"input": 25, "output": 125},
62
+ "claude-3-opus-20240229": {"input": 1500, "output": 7500},
63
+ }
64
+
65
+ MODEL_ALIASES: Dict[str, str] = {
66
+ "gpt-5.1-latest": "gpt-5.1",
67
+ "gpt-5-latest": "gpt-5",
68
+ "gpt-5-mini-latest": "gpt-5-mini",
69
+ "gpt-5-nano-latest": "gpt-5-nano",
70
+ "gpt-5.1-codex-latest": "gpt-5.1-codex",
71
+ "gpt-5-codex-latest": "gpt-5-codex",
72
+ "gpt-5.1-codex-mini-latest": "gpt-5.1-codex-mini",
73
+ "gpt-4.1-latest": "gpt-4.1",
74
+ "gpt-4.1-mini-latest": "gpt-4.1-mini",
75
+ "gpt-4.1-nano-latest": "gpt-4.1-nano",
76
+ "gpt-4o-2024-11-20": "gpt-4o",
77
+ "gpt-4o-2024-08-06": "gpt-4o",
78
+ "gpt-4o-mini-2024-07-18": "gpt-4o-mini",
79
+ "gpt-4-turbo-2024-04-09": "gpt-4-turbo",
80
+ "gpt-4-turbo-preview": "gpt-4-turbo",
81
+ "gpt-3.5-turbo-0125": "gpt-3.5-turbo",
82
+ "gpt-3.5-turbo-1106": "gpt-3.5-turbo",
83
+ "claude-3-5-sonnet": "claude-3-5-sonnet-20241022",
84
+ "claude-3.5-sonnet": "claude-3-5-sonnet-20241022",
85
+ "claude-3-sonnet": "claude-3-sonnet-20240229",
86
+ "claude-3.0-sonnet": "claude-3-sonnet-20240229",
87
+ "claude-3-5-haiku": "claude-3-5-haiku-20241022",
88
+ "claude-3.5-haiku": "claude-3-5-haiku-20241022",
89
+ "claude-3-opus": "claude-3-opus-20240229",
90
+ "claude-opus-4-5": "claude-opus-4-5-20251101",
91
+ "claude-opus-4.5": "claude-opus-4-5-20251101",
92
+ "claude-opus-4-1": "claude-opus-4-1-20250805",
93
+ "claude-opus-4.1": "claude-opus-4-1-20250805",
94
+ "claude sonnet": "claude-3-sonnet-20240229",
95
+ }
96
+
97
+
98
+ def _resolve_model(model: str) -> Optional[ModelPricing]:
99
+ if model in MODEL_PRICING:
100
+ return MODEL_PRICING[model]
101
+ alias = MODEL_ALIASES.get(model)
102
+ if alias and alias in MODEL_PRICING:
103
+ return MODEL_PRICING[alias]
104
+ return None
105
+
106
+
107
+ def calculate_cost(
108
+ model: str, input_tokens: int, output_tokens: int
109
+ ) -> Optional[float]:
110
+ pricing = _resolve_model(model)
111
+ if not pricing:
112
+ return None
113
+
114
+ cost = 0.0
115
+ cost += (input_tokens * pricing["input"]) / 1_000_000
116
+ cost += (output_tokens * pricing["output"]) / 1_000_000
117
+ return round(cost, 6)
@@ -0,0 +1,4 @@
1
+ from .anthropic import observe_anthropic
2
+ from .openai import observe_openai
3
+
4
+ __all__ = ["observe_openai", "observe_anthropic"]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import time
5
+ from typing import Any, Dict
6
+
7
+ from ..normalize import normalize_anthropic_response
8
+ from ..state import add_to_buffer, is_enabled
9
+ from ..trace import (
10
+ build_error_trace,
11
+ build_trace,
12
+ extract_pulse_params,
13
+ resolve_trace_metadata,
14
+ )
15
+ from ..types import ObserveOptions, Provider
16
+
17
+
18
+ class AnthropicIntegrationError(RuntimeError):
19
+ pass
20
+
21
+
22
+ def observe_anthropic(client: Any, options: ObserveOptions | None = None) -> Any:
23
+ try:
24
+ import anthropic # noqa: F401
25
+ except ImportError as exc:
26
+ raise AnthropicIntegrationError(
27
+ "anthropic package is required to observe Anthropic clients"
28
+ ) from exc
29
+
30
+ messages = getattr(client, "messages", None)
31
+ if messages is None or not hasattr(messages, "create"):
32
+ raise AnthropicIntegrationError("Client is missing messages.create")
33
+
34
+ original_create = messages.create
35
+
36
+ def wrapped_create(*args: Any, **kwargs: Any):
37
+ if not is_enabled():
38
+ return original_create(*args, **kwargs)
39
+
40
+ if args:
41
+ return original_create(*args, **kwargs)
42
+
43
+ clean_payload, pulse_session_id, pulse_metadata = extract_pulse_params(kwargs)
44
+ request_payload: Dict[str, Any] = copy.deepcopy(clean_payload)
45
+
46
+ observe_session = options.session_id if options else None
47
+ observe_metadata = options.metadata if options else None
48
+ session_id, metadata = resolve_trace_metadata(
49
+ observe_session,
50
+ observe_metadata,
51
+ pulse_session_id,
52
+ pulse_metadata,
53
+ )
54
+
55
+ start = time.perf_counter()
56
+ try:
57
+ response = original_create(**clean_payload)
58
+ except Exception as exc:
59
+ latency = (time.perf_counter() - start) * 1000
60
+ trace = build_error_trace(
61
+ request_payload,
62
+ exc,
63
+ Provider.ANTHROPIC,
64
+ latency,
65
+ session_id,
66
+ metadata,
67
+ )
68
+ add_to_buffer(trace)
69
+ raise
70
+
71
+ latency = (time.perf_counter() - start) * 1000
72
+ normalized = normalize_anthropic_response(response)
73
+ trace = build_trace(
74
+ request_payload,
75
+ normalized,
76
+ Provider.ANTHROPIC,
77
+ latency,
78
+ session_id,
79
+ metadata,
80
+ )
81
+ add_to_buffer(trace)
82
+ return response
83
+
84
+ messages.create = wrapped_create # type: ignore[assignment]
85
+ return client
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import time
5
+ from typing import Any, Dict
6
+
7
+ from ..normalize import normalize_openai_response
8
+ from ..state import add_to_buffer, is_enabled
9
+ from ..trace import (
10
+ build_error_trace,
11
+ build_trace,
12
+ extract_pulse_params,
13
+ resolve_trace_metadata,
14
+ )
15
+ from ..types import ObserveOptions, Provider
16
+
17
+
18
+ class OpenAIIntegrationError(RuntimeError):
19
+ pass
20
+
21
+
22
+ def observe_openai(
23
+ client: Any, provider: Provider, options: ObserveOptions | None = None
24
+ ) -> Any:
25
+ if provider not in (Provider.OPENAI, Provider.OPENROUTER):
26
+ raise ValueError("Provider must be openai or openrouter for observe_openai")
27
+
28
+ try:
29
+ import openai # noqa: F401 # ensure dependency is available
30
+ except ImportError as exc:
31
+ raise OpenAIIntegrationError(
32
+ "openai package is required to observe OpenAI clients"
33
+ ) from exc
34
+
35
+ chat = getattr(client, "chat", None)
36
+ completions = getattr(chat, "completions", None)
37
+ if completions is None or not hasattr(completions, "create"):
38
+ raise OpenAIIntegrationError("Client is missing chat.completions.create")
39
+
40
+ original_create = completions.create
41
+
42
+ def wrapped_create(*args: Any, **kwargs: Any):
43
+ if not is_enabled():
44
+ return original_create(*args, **kwargs)
45
+
46
+ if args:
47
+ # openai-python uses keyword-only API. Fall back if user passed args.
48
+ return original_create(*args, **kwargs)
49
+
50
+ clean_payload, pulse_session_id, pulse_metadata = extract_pulse_params(kwargs)
51
+ request_payload: Dict[str, Any] = copy.deepcopy(clean_payload)
52
+
53
+ observe_session = options.session_id if options else None
54
+ observe_metadata = options.metadata if options else None
55
+ session_id, metadata = resolve_trace_metadata(
56
+ observe_session,
57
+ observe_metadata,
58
+ pulse_session_id,
59
+ pulse_metadata,
60
+ )
61
+
62
+ start = time.perf_counter()
63
+ try:
64
+ response = original_create(**clean_payload)
65
+ except Exception as exc:
66
+ latency = (time.perf_counter() - start) * 1000
67
+ trace = build_error_trace(
68
+ request_payload,
69
+ exc,
70
+ provider,
71
+ latency,
72
+ session_id,
73
+ metadata,
74
+ )
75
+ add_to_buffer(trace)
76
+ raise
77
+
78
+ latency = (time.perf_counter() - start) * 1000
79
+ normalized = normalize_openai_response(response)
80
+ trace = build_trace(
81
+ request_payload,
82
+ normalized,
83
+ provider,
84
+ latency,
85
+ session_id,
86
+ metadata,
87
+ )
88
+ add_to_buffer(trace)
89
+ return response
90
+
91
+ completions.create = wrapped_create # type: ignore[assignment]
92
+ return client
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from typing import List
6
+
7
+ from .config import ResolvedConfig
8
+ from .transport import send_traces
9
+ from .types import Trace
10
+
11
+ _config: ResolvedConfig | None = None
12
+ _buffer: List[Trace] = []
13
+ _buffer_lock = threading.Lock()
14
+ _flush_thread: threading.Thread | None = None
15
+ _stop_event: threading.Event | None = None
16
+
17
+
18
+ def set_config(config: ResolvedConfig) -> None:
19
+ global _config
20
+ _config = config
21
+
22
+
23
+ def get_config() -> ResolvedConfig:
24
+ if _config is None:
25
+ raise RuntimeError("Pulse SDK: init_pulse() must be called before use")
26
+ return _config
27
+
28
+
29
+ def is_enabled() -> bool:
30
+ return bool(_config and _config.enabled)
31
+
32
+
33
+ def add_to_buffer(trace: Trace) -> None:
34
+ if not is_enabled():
35
+ return
36
+
37
+ with _buffer_lock:
38
+ _buffer.append(trace)
39
+ threshold = _config.batch_size if _config else 0
40
+ should_flush = len(_buffer) >= threshold > 0
41
+
42
+ if should_flush:
43
+ flush_buffer()
44
+
45
+
46
+ def flush_buffer() -> None:
47
+ if not is_enabled():
48
+ return
49
+
50
+ traces: List[Trace]
51
+ with _buffer_lock:
52
+ if not _buffer:
53
+ return
54
+ traces = list(_buffer)
55
+ _buffer.clear()
56
+
57
+ cfg = get_config()
58
+ try:
59
+ send_traces(cfg.api_url, cfg.api_key, traces)
60
+ except Exception as exc:
61
+ print(f"Pulse SDK: failed to send traces: {exc}")
62
+
63
+
64
+ def _flush_loop(interval_ms: int, stop_event: threading.Event) -> None:
65
+ interval = max(interval_ms / 1000.0, 1.0)
66
+ while not stop_event.wait(interval):
67
+ flush_buffer()
68
+
69
+
70
+ def start_flush_worker() -> None:
71
+ global _flush_thread, _stop_event
72
+
73
+ if not is_enabled():
74
+ return
75
+
76
+ stop_flush_worker()
77
+ cfg = get_config()
78
+ stop_event = threading.Event()
79
+ thread = threading.Thread(
80
+ target=_flush_loop,
81
+ args=(cfg.flush_interval, stop_event),
82
+ name="pulse-sdk-flush",
83
+ daemon=True,
84
+ )
85
+ _stop_event = stop_event
86
+ _flush_thread = thread
87
+ thread.start()
88
+
89
+
90
+ def stop_flush_worker() -> None:
91
+ global _flush_thread, _stop_event
92
+
93
+ if _stop_event:
94
+ _stop_event.set()
95
+ if _flush_thread and _flush_thread.is_alive():
96
+ _flush_thread.join(timeout=1)
97
+ _stop_event = None
98
+ _flush_thread = None
99
+
100
+
101
+ def reset_state() -> None:
102
+ global _buffer
103
+ stop_flush_worker()
104
+ with _buffer_lock:
105
+ _buffer = []
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import datetime
5
+ import time
6
+ import uuid
7
+ from typing import Any, Dict, Optional
8
+
9
+ from .pricing import calculate_cost
10
+ from .types import NormalizedResponse, Provider, Trace, TraceStatus
11
+
12
+
13
+ def generate_trace_id() -> str:
14
+ return str(uuid.uuid4())
15
+
16
+
17
+ def current_timestamp() -> str:
18
+ return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).isoformat()
19
+
20
+
21
+ def extract_pulse_params(
22
+ payload: Dict[str, Any],
23
+ ) -> tuple[Dict[str, Any], Optional[str], Optional[Dict[str, Any]]]:
24
+ clean = copy.deepcopy(payload)
25
+ session = None
26
+ metadata = None
27
+
28
+ for key in ("pulse_session_id", "pulseSessionId"):
29
+ if key in clean:
30
+ session = clean.pop(key)
31
+ break
32
+
33
+ for key in ("pulse_metadata", "pulseMetadata"):
34
+ if key in clean:
35
+ metadata = clean.pop(key)
36
+ break
37
+
38
+ return clean, session, metadata # type: ignore[return-value]
39
+
40
+
41
+ def resolve_trace_metadata(
42
+ observe_session: Optional[str],
43
+ observe_metadata: Optional[Dict[str, Any]],
44
+ pulse_session: Optional[str],
45
+ pulse_metadata: Optional[Dict[str, Any]],
46
+ ) -> tuple[Optional[str], Optional[Dict[str, Any]]]:
47
+ session_id = pulse_session or observe_session
48
+ metadata = observe_metadata.copy() if observe_metadata else None
49
+ if pulse_metadata:
50
+ metadata = {**(metadata or {}), **pulse_metadata}
51
+ return session_id, metadata
52
+
53
+
54
+ def build_trace(
55
+ request: Dict[str, Any],
56
+ response: Optional[NormalizedResponse],
57
+ provider: Provider,
58
+ latency_ms: float,
59
+ session_id: Optional[str] = None,
60
+ metadata: Optional[Dict[str, Any]] = None,
61
+ ) -> Trace:
62
+ trace: Trace = {
63
+ "trace_id": generate_trace_id(),
64
+ "timestamp": current_timestamp(),
65
+ "provider": provider.value,
66
+ "model_requested": str(request.get("model", "unknown")),
67
+ "request_body": request,
68
+ "latency_ms": int(round(latency_ms)),
69
+ "status": TraceStatus.SUCCESS.value if response else TraceStatus.ERROR.value,
70
+ }
71
+
72
+ if response:
73
+ trace["model_used"] = response.model
74
+ trace["response_body"] = {
75
+ "content": response.content,
76
+ "inputTokens": response.input_tokens,
77
+ "outputTokens": response.output_tokens,
78
+ "finishReason": response.finish_reason,
79
+ "model": response.model,
80
+ }
81
+ trace["input_tokens"] = response.input_tokens
82
+ trace["output_tokens"] = response.output_tokens
83
+ trace["output_text"] = response.content
84
+ trace["finish_reason"] = response.finish_reason
85
+ if response.provider_request_id:
86
+ trace["provider_request_id"] = response.provider_request_id
87
+
88
+ if response.cost_cents is not None:
89
+ trace["cost_cents"] = response.cost_cents
90
+ elif response.input_tokens is not None and response.output_tokens is not None:
91
+ calculated = calculate_cost(
92
+ response.model, response.input_tokens, response.output_tokens
93
+ )
94
+ if calculated is not None:
95
+ trace["cost_cents"] = calculated
96
+ else:
97
+ trace["status"] = TraceStatus.ERROR.value
98
+
99
+ if session_id:
100
+ trace["session_id"] = session_id
101
+ if metadata:
102
+ trace["metadata"] = metadata
103
+
104
+ return trace
105
+
106
+
107
+ def build_error_trace(
108
+ request: Dict[str, Any],
109
+ error: Exception,
110
+ provider: Provider,
111
+ latency_ms: float,
112
+ session_id: Optional[str] = None,
113
+ metadata: Optional[Dict[str, Any]] = None,
114
+ ) -> Trace:
115
+ trace = build_trace(request, None, provider, latency_ms, session_id, metadata)
116
+ trace["status"] = TraceStatus.ERROR.value
117
+ trace["error"] = {
118
+ "name": error.__class__.__name__,
119
+ "message": str(error),
120
+ }
121
+ return trace
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import List
5
+
6
+ import requests
7
+
8
+ from .types import Trace
9
+
10
+ DEFAULT_TIMEOUT = 10 # seconds
11
+
12
+
13
+ def send_traces(api_url: str, api_key: str, traces: List[Trace]) -> None:
14
+ if not traces:
15
+ return
16
+
17
+ url = f"{api_url.rstrip('/')}/v1/traces/async"
18
+ headers = {
19
+ "Authorization": f"Bearer {api_key}",
20
+ "Content-Type": "application/json",
21
+ }
22
+ response = requests.post(
23
+ url, headers=headers, data=json.dumps(traces), timeout=DEFAULT_TIMEOUT
24
+ )
25
+ if not response.ok:
26
+ raise RuntimeError(
27
+ f"Pulse SDK: failed to send traces ({response.status_code}): {response.text}"
28
+ )
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import Any, Dict, Optional, TypedDict
6
+
7
+
8
+ class Provider(str, Enum):
9
+ OPENAI = "openai"
10
+ OPENROUTER = "openrouter"
11
+ ANTHROPIC = "anthropic"
12
+
13
+
14
+ class TraceStatus(str, Enum):
15
+ SUCCESS = "success"
16
+ ERROR = "error"
17
+
18
+
19
+ class Trace(TypedDict, total=False):
20
+ trace_id: str
21
+ timestamp: str
22
+ provider: str
23
+ model_requested: str
24
+ model_used: Optional[str]
25
+ provider_request_id: Optional[str]
26
+ request_body: Dict[str, Any]
27
+ response_body: Dict[str, Any]
28
+ input_tokens: Optional[int]
29
+ output_tokens: Optional[int]
30
+ output_text: Optional[str]
31
+ finish_reason: Optional[str]
32
+ status: str
33
+ error: Dict[str, Any]
34
+ cost_cents: Optional[float]
35
+ latency_ms: int
36
+ session_id: Optional[str]
37
+ metadata: Dict[str, Any]
38
+
39
+
40
+ class PulseConfig(TypedDict, total=False):
41
+ api_key: str
42
+ api_url: str
43
+ batch_size: int
44
+ flush_interval: int
45
+ enabled: bool
46
+
47
+
48
+ @dataclass
49
+ class ObserveOptions:
50
+ session_id: Optional[str] = None
51
+ metadata: Optional[Dict[str, Any]] = None
52
+
53
+
54
+ @dataclass
55
+ class NormalizedResponse:
56
+ model: str
57
+ content: Optional[str]
58
+ input_tokens: Optional[int]
59
+ output_tokens: Optional[int]
60
+ finish_reason: Optional[str]
61
+ cost_cents: Optional[float] = None
62
+ provider_request_id: Optional[str] = None
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pulse-trace-sdk"
7
+ version = "0.2.4"
8
+ description = "Pulse Python SDK for tracing LLM providers"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [
12
+ { name = "Pulse" }
13
+ ]
14
+ dependencies = [
15
+ "requests>=2.32.0",
16
+ ]
17
+ license = { text = "MIT" }
18
+
19
+ [tool.hatch.build]
20
+ packages = ["src/pulse_sdk"]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/pulse_sdk"]
24
+ exclude = [
25
+ "src/pulse_sdk/__pycache__",
26
+ "**/*.pyc",
27
+ ]
28
+
29
+ [tool.hatch.build.targets.sdist]
30
+ include = [
31
+ "src/pulse_sdk",
32
+ "README.md",
33
+ "pyproject.toml",
34
+ ]
35
+ exclude = [
36
+ "tests",
37
+ "**/__pycache__",
38
+ "**/*.pyc",
39
+ ]
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "anthropic>=0.79.0",
44
+ "openai>=2.20.0",
45
+ "pytest>=9.0.2",
46
+ "python-dotenv>=1.0.1",
47
+ "fastapi>=0.115.5",
48
+ "uvicorn>=0.32.0",
49
+ ]
50
+
51
+ [project.optional-dependencies]
52
+ dev = [
53
+ "black>=26.1.0",
54
+ "pytest>=8.3.0",
55
+ ]