blitz-sdk 0.1.0__py3-none-any.whl

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.
blitz/__init__.py ADDED
@@ -0,0 +1,121 @@
1
+ """blitz — drop-in distributed tracing for LLM calls.
2
+
3
+ Usage:
4
+
5
+ import blitz
6
+
7
+ blitz.init(
8
+ project_id="proj_abc",
9
+ api_key="sk_...",
10
+ endpoint="https://api.sparepartslabs.com",
11
+ sample_rate=0.1,
12
+ )
13
+
14
+ After init(), any OpenAI / Anthropic / Gemini call made in this process is traced
15
+ and shipped to the blitz backend. Nothing else in your code changes.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import atexit
21
+ import logging
22
+ import os
23
+ from typing import Callable, Optional
24
+
25
+ from opentelemetry import trace
26
+ from opentelemetry.sdk.resources import Resource
27
+ from opentelemetry.sdk.trace import TracerProvider
28
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
29
+ from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
30
+
31
+ from ._exporter import BlitzSpanExporter
32
+ from ._instrument import instrument_providers
33
+
34
+ __all__ = ["init"]
35
+
36
+ logger = logging.getLogger("blitz")
37
+
38
+ _initialized = False
39
+
40
+
41
+ def init(
42
+ *,
43
+ project_id: str,
44
+ api_key: str,
45
+ endpoint: str,
46
+ sample_rate: float = 1.0,
47
+ capture_content: bool = True,
48
+ redact: Optional[Callable[[str], str]] = None,
49
+ max_content_chars: int = 24_000,
50
+ service_name: str = "llm-app",
51
+ ) -> list[str]:
52
+ """Initialize blitz tracing.
53
+
54
+ Args:
55
+ project_id: Your blitz project id (multitenancy key).
56
+ api_key: Project API key, sent as the ``x-api-key`` header.
57
+ endpoint: Base URL of the blitz backend, e.g.
58
+ ``https://api.sparepartslabs.com``. The SDK posts to
59
+ ``{endpoint}/blitz/v1/traces``.
60
+ sample_rate: Head sampling ratio in [0.0, 1.0]. 0.1 = trace 10% of
61
+ requests. Sampling is parent-based so a sampled trace keeps all its
62
+ child spans.
63
+ capture_content: When False, prompts/completions are stripped before
64
+ export — only metadata (model, tokens, latency, cost) is sent.
65
+ redact: Optional callable applied to every prompt/completion string
66
+ before export (PII scrubbing).
67
+ max_content_chars: Hard cap per content field; longer values are
68
+ truncated with a ``…[truncated]`` marker.
69
+ service_name: Logical service name attached to every span.
70
+
71
+ Returns:
72
+ The list of providers that were successfully instrumented
73
+ (e.g. ``["openai", "anthropic"]``).
74
+ """
75
+ global _initialized
76
+ if _initialized:
77
+ logger.warning("blitz.init() called more than once; ignoring")
78
+ return []
79
+
80
+ if not 0.0 <= sample_rate <= 1.0:
81
+ raise ValueError("sample_rate must be between 0.0 and 1.0")
82
+
83
+ # Tell the underlying instrumentors not to capture prompt content at the
84
+ # source when the caller opted out — cheaper and avoids the content ever
85
+ # entering a span. The exporter enforces this again as a backstop.
86
+ if not capture_content:
87
+ os.environ.setdefault("TRACELOOP_TRACE_CONTENT", "false")
88
+
89
+ resource = Resource.create(
90
+ {
91
+ "service.name": service_name,
92
+ "blitz.project_id": project_id,
93
+ }
94
+ )
95
+ provider = TracerProvider(
96
+ resource=resource,
97
+ sampler=ParentBased(TraceIdRatioBased(sample_rate)),
98
+ )
99
+
100
+ exporter = BlitzSpanExporter(
101
+ endpoint=endpoint,
102
+ api_key=api_key,
103
+ project_id=project_id,
104
+ capture_content=capture_content,
105
+ redact=redact,
106
+ max_content_chars=max_content_chars,
107
+ )
108
+ provider.add_span_processor(BatchSpanProcessor(exporter))
109
+ trace.set_tracer_provider(provider)
110
+
111
+ instrumented = instrument_providers(provider)
112
+ atexit.register(provider.shutdown)
113
+
114
+ _initialized = True
115
+ logger.info(
116
+ "blitz initialized — project=%s providers=[%s] sample_rate=%s",
117
+ project_id,
118
+ ", ".join(instrumented) or "none",
119
+ sample_rate,
120
+ )
121
+ return instrumented
blitz/_exporter.py ADDED
@@ -0,0 +1,182 @@
1
+ """Span exporter that converts OTel GenAI spans into blitz's wire format and
2
+ POSTs them to the blitz backend.
3
+
4
+ We use our own JSON shape (not raw OTLP) because we own both ends — it keeps the
5
+ FastAPI ingest endpoint trivial and lets redaction happen cleanly during the
6
+ conversion step rather than mutating immutable OTel spans. The instrumentors
7
+ still emit standard OTel spans, so a customer can additionally attach a vanilla
8
+ OTLP exporter to fan telemetry out to Datadog/Phoenix/etc.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import urllib.request
16
+ from typing import Callable, Optional, Sequence
17
+
18
+ from opentelemetry.sdk.trace import ReadableSpan
19
+ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
20
+ from opentelemetry.trace import StatusCode
21
+
22
+ logger = logging.getLogger("blitz")
23
+
24
+ _PROMPT_PREFIX = "gen_ai.prompt."
25
+ _COMPLETION_PREFIX = "gen_ai.completion."
26
+
27
+
28
+ class BlitzSpanExporter(SpanExporter):
29
+ def __init__(
30
+ self,
31
+ *,
32
+ endpoint: str,
33
+ api_key: str,
34
+ project_id: str,
35
+ capture_content: bool = True,
36
+ redact: Optional[Callable[[str], str]] = None,
37
+ max_content_chars: int = 24_000,
38
+ timeout: float = 10.0,
39
+ ) -> None:
40
+ self._url = endpoint.rstrip("/") + "/blitz/v1/traces"
41
+ self._headers = {"content-type": "application/json", "x-api-key": api_key}
42
+ self._project_id = project_id
43
+ self._capture_content = capture_content
44
+ self._redact = redact
45
+ self._max = max_content_chars
46
+ self._timeout = timeout
47
+
48
+ # -- SpanExporter interface ---------------------------------------------
49
+
50
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
51
+ try:
52
+ payload = {
53
+ "project_id": self._project_id,
54
+ "spans": [self._convert(s) for s in spans],
55
+ }
56
+ data = json.dumps(payload).encode("utf-8")
57
+ req = urllib.request.Request(
58
+ self._url, data=data, headers=self._headers, method="POST"
59
+ )
60
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
61
+ if resp.status >= 300:
62
+ logger.warning("blitz export got HTTP %s", resp.status)
63
+ return SpanExportResult.FAILURE
64
+ return SpanExportResult.SUCCESS
65
+ except Exception: # noqa: BLE001 - exporting must never raise into the app
66
+ logger.warning("blitz export failed", exc_info=True)
67
+ return SpanExportResult.FAILURE
68
+
69
+ def shutdown(self) -> None: # pragma: no cover - nothing to clean up
70
+ pass
71
+
72
+ # -- conversion ----------------------------------------------------------
73
+
74
+ def _convert(self, span: ReadableSpan) -> dict:
75
+ attrs = dict(span.attributes or {})
76
+ prompt, completion, other = self._split_content(attrs)
77
+
78
+ content = None
79
+ if self._capture_content:
80
+ content = self._redact_content(
81
+ {"prompt": prompt, "completion": completion}
82
+ )
83
+
84
+ ctx = span.get_span_context()
85
+ status = (
86
+ "error"
87
+ if span.status is not None and span.status.status_code == StatusCode.ERROR
88
+ else "ok"
89
+ )
90
+
91
+ return {
92
+ "trace_id": format(ctx.trace_id, "032x"),
93
+ "span_id": format(ctx.span_id, "016x"),
94
+ "parent_span_id": (
95
+ format(span.parent.span_id, "016x") if span.parent else None
96
+ ),
97
+ "name": span.name,
98
+ "provider": attrs.get("gen_ai.system"),
99
+ "model": attrs.get("gen_ai.response.model")
100
+ or attrs.get("gen_ai.request.model"),
101
+ "input_tokens": _first_int(
102
+ attrs,
103
+ "gen_ai.usage.input_tokens",
104
+ "gen_ai.usage.prompt_tokens",
105
+ "llm.usage.prompt_tokens",
106
+ ),
107
+ "output_tokens": _first_int(
108
+ attrs,
109
+ "gen_ai.usage.output_tokens",
110
+ "gen_ai.usage.completion_tokens",
111
+ "llm.usage.completion_tokens",
112
+ ),
113
+ "start_unix_ns": span.start_time,
114
+ "end_unix_ns": span.end_time,
115
+ "status": status,
116
+ "attributes": _jsonable(other),
117
+ "content": content,
118
+ }
119
+
120
+ def _split_content(self, attrs: dict):
121
+ """Pull the indexed prompt/completion attributes
122
+ (gen_ai.prompt.0.role, gen_ai.prompt.0.content, ...) into ordered lists,
123
+ leaving everything else in `other`."""
124
+ prompts: dict[str, dict] = {}
125
+ completions: dict[str, dict] = {}
126
+ other: dict = {}
127
+
128
+ for key, value in attrs.items():
129
+ if key.startswith(_PROMPT_PREFIX):
130
+ idx, _, field = key[len(_PROMPT_PREFIX) :].partition(".")
131
+ prompts.setdefault(idx, {})[field or "value"] = value
132
+ elif key.startswith(_COMPLETION_PREFIX):
133
+ idx, _, field = key[len(_COMPLETION_PREFIX) :].partition(".")
134
+ completions.setdefault(idx, {})[field or "value"] = value
135
+ else:
136
+ other[key] = value
137
+
138
+ return _ordered(prompts), _ordered(completions), other
139
+
140
+ def _redact_content(self, content: dict) -> dict:
141
+ for bucket in ("prompt", "completion"):
142
+ for msg in content.get(bucket, []):
143
+ if "content" in msg and isinstance(msg["content"], str):
144
+ msg["content"] = self._scrub(msg["content"])
145
+ return content
146
+
147
+ def _scrub(self, text: str) -> str:
148
+ if self._redact:
149
+ try:
150
+ text = self._redact(text)
151
+ except Exception: # noqa: BLE001
152
+ logger.warning("blitz redact callable raised", exc_info=True)
153
+ if self._max and len(text) > self._max:
154
+ text = text[: self._max] + "…[truncated]"
155
+ return text
156
+
157
+
158
+ def _ordered(indexed: dict[str, dict]) -> list[dict]:
159
+ return [
160
+ indexed[i]
161
+ for i in sorted(indexed, key=lambda x: int(x) if x.isdigit() else 0)
162
+ ]
163
+
164
+
165
+ def _first_int(attrs: dict, *keys: str):
166
+ for key in keys:
167
+ if key in attrs and attrs[key] is not None:
168
+ try:
169
+ return int(attrs[key])
170
+ except (TypeError, ValueError):
171
+ continue
172
+ return None
173
+
174
+
175
+ def _jsonable(obj):
176
+ """OTel attribute values are already JSON-safe scalars/sequences, but coerce
177
+ tuples to lists so json.dumps is happy."""
178
+ if isinstance(obj, dict):
179
+ return {k: _jsonable(v) for k, v in obj.items()}
180
+ if isinstance(obj, (list, tuple)):
181
+ return [_jsonable(v) for v in obj]
182
+ return obj
blitz/_instrument.py ADDED
@@ -0,0 +1,52 @@
1
+ """Wire up the provider instrumentors.
2
+
3
+ We wrap the maintained openllmetry (Traceloop) instrumentors. Each is optional:
4
+ if the provider SDK (and its instrumentor extra) isn't installed, we skip it
5
+ silently so a customer who only uses Anthropic doesn't need OpenAI installed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from opentelemetry.sdk.trace import TracerProvider
13
+
14
+ logger = logging.getLogger("blitz")
15
+
16
+
17
+ def instrument_providers(tracer_provider: TracerProvider) -> list[str]:
18
+ """Instrument every supported provider that is importable. Returns the
19
+ list of provider names that were successfully instrumented."""
20
+ instrumented: list[str] = []
21
+
22
+ def _try(name: str, importer) -> None:
23
+ try:
24
+ instrumentor = importer()
25
+ instrumentor.instrument(tracer_provider=tracer_provider)
26
+ instrumented.append(name)
27
+ except ImportError as exc:
28
+ logger.debug("blitz: %s instrumentation unavailable (%s)", name, exc)
29
+ except Exception: # noqa: BLE001 - never let instrumentation crash the app
30
+ logger.warning("blitz: failed to instrument %s", name, exc_info=True)
31
+
32
+ def _openai():
33
+ from opentelemetry.instrumentation.openai import OpenAIInstrumentor
34
+
35
+ return OpenAIInstrumentor()
36
+
37
+ def _anthropic():
38
+ from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor
39
+
40
+ return AnthropicInstrumentor()
41
+
42
+ def _gemini():
43
+ from opentelemetry.instrumentation.google_generativeai import (
44
+ GoogleGenerativeAiInstrumentor,
45
+ )
46
+
47
+ return GoogleGenerativeAiInstrumentor()
48
+
49
+ _try("openai", _openai)
50
+ _try("anthropic", _anthropic)
51
+ _try("gemini", _gemini)
52
+ return instrumented
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: blitz-sdk
3
+ Version: 0.1.0
4
+ Summary: Drop-in OpenTelemetry tracing for OpenAI, Anthropic, and Gemini LLM calls. Ships full I/O, token usage, model, latency, and cost to your blitz backend.
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: opentelemetry-api>=1.27.0
7
+ Requires-Dist: opentelemetry-sdk>=1.27.0
8
+ Provides-Extra: all
9
+ Requires-Dist: opentelemetry-instrumentation-anthropic>=0.33.0; extra == 'all'
10
+ Requires-Dist: opentelemetry-instrumentation-google-generativeai>=0.33.0; extra == 'all'
11
+ Requires-Dist: opentelemetry-instrumentation-openai>=0.33.0; extra == 'all'
12
+ Provides-Extra: anthropic
13
+ Requires-Dist: opentelemetry-instrumentation-anthropic>=0.33.0; extra == 'anthropic'
14
+ Provides-Extra: gemini
15
+ Requires-Dist: opentelemetry-instrumentation-google-generativeai>=0.33.0; extra == 'gemini'
16
+ Provides-Extra: openai
17
+ Requires-Dist: opentelemetry-instrumentation-openai>=0.33.0; extra == 'openai'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # blitz
21
+
22
+ Documentation coming.
@@ -0,0 +1,6 @@
1
+ blitz/__init__.py,sha256=OtxasaffxbQhULDAeTLdJ0XArVT5ySaW5wiqpBqx2mo,3832
2
+ blitz/_exporter.py,sha256=7VrcrvwxJ2kjD2Jj60V0bAWos8KEepbAys39kMeb_J4,6663
3
+ blitz/_instrument.py,sha256=RW_li9hr2SzpXAMNjgFa1NNbxz6zuygDs1C0Xc11Zl4,1742
4
+ blitz_sdk-0.1.0.dist-info/METADATA,sha256=Xng1VHipsdBX4yT0MwgkIAtyN1Rs9yG-qCVeXE4Qa-E,983
5
+ blitz_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ blitz_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any