eldros-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.
- eldros_sdk/__init__.py +38 -0
- eldros_sdk/_instrument.py +72 -0
- eldros_sdk/attributes.py +37 -0
- eldros_sdk/client.py +311 -0
- eldros_sdk/config.py +98 -0
- eldros_sdk/decorator.py +168 -0
- eldros_sdk/integrations/__init__.py +3 -0
- eldros_sdk/integrations/claude_agent_sdk.py +64 -0
- eldros_sdk/version.py +5 -0
- eldros_sdk-0.1.0.dist-info/METADATA +189 -0
- eldros_sdk-0.1.0.dist-info/RECORD +13 -0
- eldros_sdk-0.1.0.dist-info/WHEEL +4 -0
- eldros_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
eldros_sdk/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Eldros tracing SDK — OpenTelemetry-native instrumentation for AI agents.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from eldros_sdk import init, trace
|
|
6
|
+
|
|
7
|
+
init(agent_id="my-agent") # ELDROS_API_KEY and ELDROS_API_BASE_URL from env
|
|
8
|
+
|
|
9
|
+
@trace
|
|
10
|
+
async def handle_conversation(user_message: str) -> str:
|
|
11
|
+
# OpenAI / Anthropic calls inside here are auto-instrumented.
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
One ``init()`` call resolves Langfuse credentials from the Eldros platform, wires
|
|
15
|
+
up OpenTelemetry, and auto-instruments installed LLM providers. ``@trace`` opens a
|
|
16
|
+
root span per call.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from . import attributes
|
|
22
|
+
from .client import flush, get_config, init, is_enabled, shutdown
|
|
23
|
+
from .config import TraceConfig
|
|
24
|
+
from .decorator import get_tracer, trace
|
|
25
|
+
from .version import __version__
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"__version__",
|
|
29
|
+
"init",
|
|
30
|
+
"trace",
|
|
31
|
+
"flush",
|
|
32
|
+
"shutdown",
|
|
33
|
+
"get_config",
|
|
34
|
+
"get_tracer",
|
|
35
|
+
"is_enabled",
|
|
36
|
+
"TraceConfig",
|
|
37
|
+
"attributes",
|
|
38
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Optional auto-instrumentation of LLM provider SDKs.
|
|
2
|
+
|
|
3
|
+
We rely on OpenLLMetry (traceloop) instrumentors, which emit OpenTelemetry
|
|
4
|
+
GenAI-semconv spans that Langfuse understands natively. Each provider is an
|
|
5
|
+
optional extra; if the instrumentor is not installed we log and move on so the
|
|
6
|
+
core SDK never hard-depends on any provider library.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
16
|
+
|
|
17
|
+
from .config import TraceConfig
|
|
18
|
+
|
|
19
|
+
logger: logging.Logger = logging.getLogger("eldros_sdk")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def instrument_llm_providers(config: TraceConfig, provider: TracerProvider) -> None:
|
|
23
|
+
"""Auto-instrument enabled LLM providers against the given tracer provider."""
|
|
24
|
+
# OpenLLMetry honors this env var to toggle prompt/response content capture.
|
|
25
|
+
os.environ.setdefault(
|
|
26
|
+
"TRACELOOP_TRACE_CONTENT", "true" if config.capture_content else "false"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if config.instrument_openai:
|
|
30
|
+
_safe_instrument(
|
|
31
|
+
label="openai",
|
|
32
|
+
module_path="opentelemetry.instrumentation.openai",
|
|
33
|
+
class_name="OpenAIInstrumentor",
|
|
34
|
+
provider=provider,
|
|
35
|
+
)
|
|
36
|
+
if config.instrument_anthropic:
|
|
37
|
+
_safe_instrument(
|
|
38
|
+
label="anthropic",
|
|
39
|
+
module_path="opentelemetry.instrumentation.anthropic",
|
|
40
|
+
class_name="AnthropicInstrumentor",
|
|
41
|
+
provider=provider,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _safe_instrument(
|
|
46
|
+
label: str, module_path: str, class_name: str, provider: TracerProvider
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Import and apply one instrumentor, swallowing all failures with a log line."""
|
|
49
|
+
try:
|
|
50
|
+
module: Any = __import__(module_path, fromlist=[class_name])
|
|
51
|
+
instrumentor_cls: Any = getattr(module, class_name)
|
|
52
|
+
except ImportError:
|
|
53
|
+
logger.info(
|
|
54
|
+
"eldros_sdk: %s auto-instrumentation not installed "
|
|
55
|
+
"(pip install eldros-sdk[%s])",
|
|
56
|
+
label,
|
|
57
|
+
label,
|
|
58
|
+
)
|
|
59
|
+
return
|
|
60
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
61
|
+
logger.warning("eldros_sdk: could not import %s instrumentor: %s", label, exc)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
instrumentor: Any = instrumentor_cls()
|
|
66
|
+
if getattr(instrumentor, "is_instrumented_by_opentelemetry", False):
|
|
67
|
+
logger.debug("eldros_sdk: %s already instrumented; skipping", label)
|
|
68
|
+
return
|
|
69
|
+
instrumentor.instrument(tracer_provider=provider)
|
|
70
|
+
logger.info("eldros_sdk: %s auto-instrumented", label)
|
|
71
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
72
|
+
logger.warning("eldros_sdk: failed to instrument %s: %s", label, exc)
|
eldros_sdk/attributes.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Canonical span/resource attribute keys for the Eldros tracing SDK.
|
|
2
|
+
|
|
3
|
+
These are the first-class attributes our backend understands. Most voice keys
|
|
4
|
+
are not emitted by the Week 1-2 core (text agents only) but are defined here so
|
|
5
|
+
the schema lives in one place and ``@trace(attributes=...)`` usage is typed and
|
|
6
|
+
discoverable. See ``sdk_for_client_traces.md`` section 5 for the full schema.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# --- identity / routing (set on every span via resource + root span) ---------
|
|
12
|
+
AGENT_ID: str = "agent.id"
|
|
13
|
+
TRAFFIC_TYPE: str = "traffic_type" # "prod" | "eval"
|
|
14
|
+
SESSION_ID: str = "session.id"
|
|
15
|
+
|
|
16
|
+
# --- generic IO capture for user-decorated functions -------------------------
|
|
17
|
+
# Langfuse-native keys: these map directly to an observation's Input/Output in
|
|
18
|
+
# the Langfuse UI, and the root observation's I/O becomes the trace's I/O.
|
|
19
|
+
INPUT: str = "langfuse.observation.input"
|
|
20
|
+
OUTPUT: str = "langfuse.observation.output"
|
|
21
|
+
|
|
22
|
+
# --- voice: speech-to-text ----------------------------------------------------
|
|
23
|
+
VOICE_TURN_INDEX: str = "voice.turn_index"
|
|
24
|
+
STT_PROVIDER: str = "stt.provider"
|
|
25
|
+
STT_TRANSCRIPT: str = "stt.transcript"
|
|
26
|
+
STT_LATENCY_MS: str = "stt.latency_ms"
|
|
27
|
+
STT_AUDIO_DURATION_MS: str = "stt.audio_duration_ms"
|
|
28
|
+
|
|
29
|
+
# --- voice: text-to-speech ----------------------------------------------------
|
|
30
|
+
TTS_PROVIDER: str = "tts.provider"
|
|
31
|
+
TTS_TTFB_MS: str = "tts.ttfb_ms"
|
|
32
|
+
TTS_CHARACTERS: str = "tts.characters"
|
|
33
|
+
VOICE_INTERRUPTED: str = "voice.interrupted"
|
|
34
|
+
|
|
35
|
+
# --- tools --------------------------------------------------------------------
|
|
36
|
+
TOOL_CALL_NAME: str = "tool_call.name"
|
|
37
|
+
TOOL_CALL_MOCKED: str = "tool_call.mocked"
|
eldros_sdk/client.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""SDK lifecycle: ``init()`` configures OpenTelemetry to export to Langfuse.
|
|
2
|
+
|
|
3
|
+
A single global state object holds the resolved config and tracer provider.
|
|
4
|
+
``init()`` is idempotent. When tracing is disabled or credentials are missing,
|
|
5
|
+
span creation still works but spans are simply never exported — client code
|
|
6
|
+
never has to null-check (the OTel API returns non-recording spans by default).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import logging
|
|
13
|
+
import ssl
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from opentelemetry import trace as otel_trace
|
|
18
|
+
from opentelemetry.sdk.resources import Resource
|
|
19
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
20
|
+
from opentelemetry.sdk.trace.export import (
|
|
21
|
+
BatchSpanProcessor,
|
|
22
|
+
ConsoleSpanExporter,
|
|
23
|
+
SpanExporter,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from . import attributes as attr
|
|
27
|
+
from ._instrument import instrument_llm_providers
|
|
28
|
+
from .config import TraceConfig
|
|
29
|
+
from .version import __version__
|
|
30
|
+
|
|
31
|
+
logger: logging.Logger = logging.getLogger("eldros_sdk")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _State:
|
|
35
|
+
"""Process-global SDK state, populated once by ``init()``."""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self.config: Optional[TraceConfig] = None
|
|
39
|
+
self.provider: Optional[TracerProvider] = None
|
|
40
|
+
self.initialized: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_state: _State = _State()
|
|
44
|
+
_lock: threading.Lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def init(
|
|
48
|
+
*,
|
|
49
|
+
api_key: Optional[str] = None,
|
|
50
|
+
api_base_url: Optional[str] = None,
|
|
51
|
+
agent_id: Optional[str] = None,
|
|
52
|
+
traffic_type: Optional[str] = None,
|
|
53
|
+
env: Optional[str] = None,
|
|
54
|
+
service_name: Optional[str] = None,
|
|
55
|
+
instrument_openai: Optional[bool] = None,
|
|
56
|
+
instrument_anthropic: Optional[bool] = None,
|
|
57
|
+
instrument_claude_agent_sdk: Optional[bool] = None,
|
|
58
|
+
capture_content: Optional[bool] = None,
|
|
59
|
+
debug: Optional[bool] = None,
|
|
60
|
+
enabled: Optional[bool] = None,
|
|
61
|
+
) -> TraceConfig:
|
|
62
|
+
"""Initialize tracing for this process. Safe to leave in production.
|
|
63
|
+
|
|
64
|
+
The SDK resolves Langfuse credentials from the Eldros platform using the
|
|
65
|
+
API key — clients never configure Langfuse directly.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
api_key: Eldros platform key (``agt_...``). Falls back to ELDROS_API_KEY env var.
|
|
69
|
+
api_base_url: Eldros platform base URL. Falls back to ELDROS_API_BASE_URL env var.
|
|
70
|
+
agent_id: Stamped as ``agent.id`` on every span. Falls back to ELDROS_AGENT_ID.
|
|
71
|
+
traffic_type/env: ``"prod"`` or ``"eval"`` (``env`` is an alias).
|
|
72
|
+
service_name: OTel ``service.name``.
|
|
73
|
+
instrument_openai: Auto-instrument openai if installed. Default False — opt in explicitly.
|
|
74
|
+
instrument_anthropic: Auto-instrument anthropic if installed. Default False — opt in explicitly.
|
|
75
|
+
instrument_claude_agent_sdk: Auto-instrument claude_agent_sdk via LangSmith if installed. Default False — opt in explicitly.
|
|
76
|
+
capture_content: Whether LLM spans record prompt/response text.
|
|
77
|
+
debug: Also print spans to the console.
|
|
78
|
+
enabled: Master off-switch; ``False`` makes the SDK a no-op.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The resolved :class:`TraceConfig`.
|
|
82
|
+
"""
|
|
83
|
+
with _lock:
|
|
84
|
+
if _state.initialized:
|
|
85
|
+
logger.warning("eldros_sdk.init() already called; ignoring repeat call")
|
|
86
|
+
return _state.config # type: ignore[return-value]
|
|
87
|
+
|
|
88
|
+
config: TraceConfig = TraceConfig.resolve(
|
|
89
|
+
api_key=api_key,
|
|
90
|
+
api_base_url=api_base_url,
|
|
91
|
+
agent_id=agent_id,
|
|
92
|
+
traffic_type=traffic_type or env,
|
|
93
|
+
service_name=service_name,
|
|
94
|
+
instrument_openai=instrument_openai,
|
|
95
|
+
instrument_anthropic=instrument_anthropic,
|
|
96
|
+
instrument_claude_agent_sdk=instrument_claude_agent_sdk,
|
|
97
|
+
capture_content=capture_content,
|
|
98
|
+
debug=debug,
|
|
99
|
+
enabled=enabled,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if not config.enabled:
|
|
103
|
+
logger.info("eldros_sdk disabled (enabled=False); all tracing is a no-op")
|
|
104
|
+
_state.config = config
|
|
105
|
+
_state.initialized = True
|
|
106
|
+
return config
|
|
107
|
+
|
|
108
|
+
# Resolve a platform API key to Langfuse credentials (unless creds were supplied).
|
|
109
|
+
if config.api_key and not config.has_credentials:
|
|
110
|
+
_resolve_credentials_from_api_key(config)
|
|
111
|
+
|
|
112
|
+
# Additive: if the client already has a real TracerProvider (their own OTel setup,
|
|
113
|
+
# LangSmith OTEL mode, Datadog, etc.) attach our exporter to it instead of
|
|
114
|
+
# replacing it — spans flow to both their backend and our Langfuse.
|
|
115
|
+
existing: otel_trace.TracerProvider = otel_trace.get_tracer_provider()
|
|
116
|
+
if isinstance(existing, TracerProvider):
|
|
117
|
+
provider = existing
|
|
118
|
+
logger.info("eldros_sdk: attaching to existing TracerProvider (additive mode)")
|
|
119
|
+
else:
|
|
120
|
+
provider = TracerProvider(resource=_build_resource(config))
|
|
121
|
+
otel_trace.set_tracer_provider(provider)
|
|
122
|
+
logger.info("eldros_sdk: created new TracerProvider")
|
|
123
|
+
|
|
124
|
+
if config.has_credentials:
|
|
125
|
+
provider.add_span_processor(BatchSpanProcessor(_build_otlp_exporter(config)))
|
|
126
|
+
logger.info("eldros_sdk exporting spans to %s", config.otlp_traces_endpoint)
|
|
127
|
+
else:
|
|
128
|
+
logger.warning(
|
|
129
|
+
"eldros_sdk: no Langfuse credentials resolved — set ELDROS_API_KEY "
|
|
130
|
+
"and ELDROS_API_BASE_URL. Spans will be created but not exported."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if config.debug:
|
|
134
|
+
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
|
|
135
|
+
|
|
136
|
+
instrument_llm_providers(config, provider)
|
|
137
|
+
|
|
138
|
+
if config.instrument_claude_agent_sdk:
|
|
139
|
+
_try_instrument_claude(config)
|
|
140
|
+
|
|
141
|
+
_state.config = config
|
|
142
|
+
_state.provider = provider
|
|
143
|
+
_state.initialized = True
|
|
144
|
+
logger.info(
|
|
145
|
+
"eldros_sdk initialized (agent_id=%s, traffic_type=%s, service=%s)",
|
|
146
|
+
config.agent_id,
|
|
147
|
+
config.traffic_type,
|
|
148
|
+
config.service_name,
|
|
149
|
+
)
|
|
150
|
+
return config
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _build_resource(config: TraceConfig) -> Resource:
|
|
154
|
+
"""OTel resource attributes stamped onto every span emitted by this process."""
|
|
155
|
+
resource_attrs: dict[str, str] = {
|
|
156
|
+
"service.name": config.service_name,
|
|
157
|
+
"eldros_sdk.version": __version__,
|
|
158
|
+
attr.TRAFFIC_TYPE: config.traffic_type,
|
|
159
|
+
}
|
|
160
|
+
if config.agent_id:
|
|
161
|
+
resource_attrs[attr.AGENT_ID] = config.agent_id
|
|
162
|
+
return Resource.create(resource_attrs)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _build_otlp_exporter(config: TraceConfig) -> SpanExporter:
|
|
166
|
+
"""Build the Langfuse OTLP/HTTP exporter with basic-auth headers.
|
|
167
|
+
|
|
168
|
+
Imported lazily so the core package imports without the OTLP HTTP exporter
|
|
169
|
+
extra installed (only needed when credentials are present).
|
|
170
|
+
"""
|
|
171
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
172
|
+
|
|
173
|
+
token: str = base64.b64encode(
|
|
174
|
+
f"{config._public_key}:{config._secret_key}".encode("utf-8")
|
|
175
|
+
).decode("ascii")
|
|
176
|
+
return OTLPSpanExporter(
|
|
177
|
+
endpoint=config.otlp_traces_endpoint,
|
|
178
|
+
headers={"Authorization": f"Basic {token}"},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _resolve_credentials_from_api_key(config: TraceConfig) -> None:
|
|
183
|
+
"""Resolve the Eldros platform API key to Langfuse credentials at startup.
|
|
184
|
+
|
|
185
|
+
Calls ``GET {api_base_url}/api/v1/langfuse/resolve`` with the
|
|
186
|
+
``X-Client-API-Key`` header and fills public_key/secret_key/host on ``config``.
|
|
187
|
+
Tolerant: on any failure it logs a warning and leaves credentials unset (spans are
|
|
188
|
+
created but not exported) rather than crashing the client app.
|
|
189
|
+
|
|
190
|
+
Must not be called from a running async event loop — use ``asyncio.run()`` or call
|
|
191
|
+
``init()`` before starting the event loop (e.g. at module import time).
|
|
192
|
+
"""
|
|
193
|
+
import asyncio
|
|
194
|
+
import json
|
|
195
|
+
import time
|
|
196
|
+
import urllib.error
|
|
197
|
+
import urllib.request
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
asyncio.get_running_loop()
|
|
201
|
+
logger.warning(
|
|
202
|
+
"eldros_sdk: init() was called from a running event loop — "
|
|
203
|
+
"credential resolve skipped to avoid blocking. Call init() before "
|
|
204
|
+
"starting the event loop, or use ainit() in async contexts."
|
|
205
|
+
)
|
|
206
|
+
return
|
|
207
|
+
except RuntimeError:
|
|
208
|
+
pass # no running loop — safe to block
|
|
209
|
+
|
|
210
|
+
if not config.api_base_url:
|
|
211
|
+
logger.warning(
|
|
212
|
+
"eldros_sdk: api_key set but ELDROS_API_BASE_URL is missing; cannot resolve."
|
|
213
|
+
)
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
base_url: str = config.api_base_url.rstrip("/")
|
|
217
|
+
# Reject non-HTTPS in production to prevent secret_key travelling in cleartext.
|
|
218
|
+
if not base_url.startswith("https://") and not base_url.startswith("http://localhost") and not base_url.startswith("http://127.0.0.1"):
|
|
219
|
+
logger.warning(
|
|
220
|
+
"eldros_sdk: ELDROS_API_BASE_URL uses non-HTTPS scheme (%s); "
|
|
221
|
+
"resolve skipped to protect credentials.",
|
|
222
|
+
base_url,
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
url: str = f"{base_url}/api/v1/langfuse/resolve"
|
|
227
|
+
req: urllib.request.Request = urllib.request.Request(
|
|
228
|
+
url, headers={"X-Client-API-Key": config.api_key or ""}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
ssl_ctx: ssl.SSLContext | None = None
|
|
232
|
+
if not config.ssl_verify:
|
|
233
|
+
ssl_ctx = ssl.create_default_context()
|
|
234
|
+
ssl_ctx.check_hostname = False
|
|
235
|
+
ssl_ctx.verify_mode = ssl.CERT_NONE
|
|
236
|
+
logger.warning("eldros_sdk: SSL certificate verification disabled (ssl_verify=False)")
|
|
237
|
+
|
|
238
|
+
# Retry with exponential backoff (3 attempts: 1s, 2s, 4s).
|
|
239
|
+
_MAX_ATTEMPTS: int = 3
|
|
240
|
+
for attempt in range(1, _MAX_ATTEMPTS + 1):
|
|
241
|
+
try:
|
|
242
|
+
with urllib.request.urlopen(req, timeout=10, context=ssl_ctx) as response: # noqa: S310
|
|
243
|
+
creds: dict[str, str] = json.loads(response.read().decode("utf-8"))
|
|
244
|
+
config._public_key = creds["public_key"]
|
|
245
|
+
config._secret_key = creds["secret_key"]
|
|
246
|
+
config._host = creds.get("host") or config._host
|
|
247
|
+
logger.info("eldros_sdk resolved Langfuse credentials via API key (%s)", url)
|
|
248
|
+
return
|
|
249
|
+
except (urllib.error.URLError, KeyError, ValueError, TimeoutError) as exc:
|
|
250
|
+
if attempt < _MAX_ATTEMPTS:
|
|
251
|
+
wait: float = 2 ** (attempt - 1)
|
|
252
|
+
logger.warning(
|
|
253
|
+
"eldros_sdk: resolve attempt %d/%d failed (%s); retrying in %.0fs",
|
|
254
|
+
attempt, _MAX_ATTEMPTS, exc, wait,
|
|
255
|
+
)
|
|
256
|
+
time.sleep(wait)
|
|
257
|
+
else:
|
|
258
|
+
logger.warning(
|
|
259
|
+
"eldros_sdk: could not resolve credentials after %d attempts: %s",
|
|
260
|
+
_MAX_ATTEMPTS, exc,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _try_instrument_claude(config: TraceConfig) -> None:
|
|
265
|
+
"""Auto-instrument claude_agent_sdk if the [claude] extra is installed."""
|
|
266
|
+
try:
|
|
267
|
+
import claude_agent_sdk # noqa: F401
|
|
268
|
+
except ImportError:
|
|
269
|
+
return # not installed — skip silently
|
|
270
|
+
|
|
271
|
+
if not config.has_credentials:
|
|
272
|
+
logger.warning(
|
|
273
|
+
"eldros_sdk: claude_agent_sdk found but credentials not resolved — "
|
|
274
|
+
"instrument_claude_agent_sdk() skipped."
|
|
275
|
+
)
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
from .integrations.claude_agent_sdk import _instrument_langsmith
|
|
280
|
+
_instrument_langsmith()
|
|
281
|
+
except ImportError:
|
|
282
|
+
logger.info(
|
|
283
|
+
"eldros_sdk: claude_agent_sdk found but langsmith[claude-agent-sdk] not installed; "
|
|
284
|
+
"run: pip install 'eldros-sdk[claude]'"
|
|
285
|
+
)
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
logger.warning("eldros_sdk: claude_agent_sdk auto-instrumentation failed: %s", exc)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def get_config() -> Optional[TraceConfig]:
|
|
291
|
+
"""Return the resolved config, or ``None`` if ``init()`` has not run."""
|
|
292
|
+
return _state.config
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def is_enabled() -> bool:
|
|
296
|
+
"""True when the SDK is initialized and not disabled."""
|
|
297
|
+
return bool(_state.config and _state.config.enabled)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def flush() -> None:
|
|
301
|
+
"""Force-export any buffered spans. Useful before a short-lived process exits."""
|
|
302
|
+
if _state.provider is not None:
|
|
303
|
+
_state.provider.force_flush()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def shutdown() -> None:
|
|
307
|
+
"""Flush and tear down the tracer provider."""
|
|
308
|
+
if _state.provider is not None:
|
|
309
|
+
_state.provider.shutdown()
|
|
310
|
+
_state.provider = None
|
|
311
|
+
_state.initialized = False
|
eldros_sdk/config.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Configuration model for the Eldros tracing SDK.
|
|
2
|
+
|
|
3
|
+
Values can come from explicit ``init(...)`` keyword arguments or environment
|
|
4
|
+
variables. Explicit kwargs always win over env vars.
|
|
5
|
+
|
|
6
|
+
Credentials are resolved from the Eldros platform via ``ELDROS_API_KEY`` —
|
|
7
|
+
clients never configure Langfuse directly. ``LANGFUSE_*`` env vars are NOT read.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
|
16
|
+
|
|
17
|
+
# Langfuse Cloud (EU). US region: https://us.cloud.langfuse.com
|
|
18
|
+
DEFAULT_LANGFUSE_HOST: str = "https://cloud.langfuse.com"
|
|
19
|
+
|
|
20
|
+
# No default — clients must set ELDROS_API_BASE_URL. Updated to the real domain when available.
|
|
21
|
+
DEFAULT_ELDROS_API_BASE_URL: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _as_bool(value: Optional[str]) -> Optional[bool]:
|
|
25
|
+
"""Parse a loose boolean env var. Returns ``None`` when unset."""
|
|
26
|
+
if value is None:
|
|
27
|
+
return None
|
|
28
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TraceConfig(BaseModel):
|
|
32
|
+
"""Resolved configuration for a single SDK process.
|
|
33
|
+
|
|
34
|
+
Client-facing fields are set via ``init()`` kwargs or env vars.
|
|
35
|
+
Langfuse credentials (_public_key, _secret_key, _host) are private —
|
|
36
|
+
resolved internally from the Eldros platform and never exposed to clients.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(repr=False)
|
|
40
|
+
|
|
41
|
+
# Private — resolved from Eldros platform, never set by clients.
|
|
42
|
+
_public_key: str = PrivateAttr(default="")
|
|
43
|
+
_secret_key: str = PrivateAttr(default="")
|
|
44
|
+
_host: str = PrivateAttr(default=DEFAULT_LANGFUSE_HOST)
|
|
45
|
+
|
|
46
|
+
# Client-facing — set via init() kwargs or env vars.
|
|
47
|
+
api_key: Optional[str] = None
|
|
48
|
+
api_base_url: Optional[str] = None
|
|
49
|
+
ssl_verify: bool = True
|
|
50
|
+
agent_id: Optional[str] = None
|
|
51
|
+
traffic_type: str = "prod"
|
|
52
|
+
service_name: str = "eldros-agent"
|
|
53
|
+
instrument_openai: bool = False
|
|
54
|
+
instrument_anthropic: bool = False
|
|
55
|
+
instrument_claude_agent_sdk: bool = False
|
|
56
|
+
capture_content: bool = True
|
|
57
|
+
debug: bool = False
|
|
58
|
+
enabled: bool = True
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
return (
|
|
62
|
+
f"TraceConfig(agent_id={self.agent_id!r}, host={self._host!r}, "
|
|
63
|
+
f"traffic_type={self.traffic_type!r}, enabled={self.enabled})"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def __str__(self) -> str:
|
|
67
|
+
return self.__repr__()
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def resolve(cls, **overrides: object) -> "TraceConfig":
|
|
71
|
+
"""Build a config from environment variables overlaid with explicit kwargs.
|
|
72
|
+
|
|
73
|
+
``None`` overrides are dropped so they fall back to env, then to defaults.
|
|
74
|
+
"""
|
|
75
|
+
from_env: dict[str, object] = {
|
|
76
|
+
"api_key": os.getenv("ELDROS_API_KEY"),
|
|
77
|
+
"api_base_url": os.getenv("ELDROS_API_BASE_URL") or DEFAULT_ELDROS_API_BASE_URL,
|
|
78
|
+
"ssl_verify": _as_bool(os.getenv("ELDROS_API_SSL_VERIFY")),
|
|
79
|
+
"agent_id": os.getenv("ELDROS_AGENT_ID"),
|
|
80
|
+
"traffic_type": os.getenv("ELDROS_TRAFFIC_TYPE"),
|
|
81
|
+
"service_name": os.getenv("ELDROS_SERVICE_NAME"),
|
|
82
|
+
"instrument_claude_agent_sdk": _as_bool(os.getenv("ELDROS_INSTRUMENT_CLAUDE_AGENT_SDK")),
|
|
83
|
+
"capture_content": _as_bool(os.getenv("ELDROS_CAPTURE_CONTENT")),
|
|
84
|
+
"debug": _as_bool(os.getenv("ELDROS_TRACE_DEBUG")),
|
|
85
|
+
"enabled": _as_bool(os.getenv("ELDROS_TRACE_ENABLED")),
|
|
86
|
+
}
|
|
87
|
+
merged: dict[str, object] = {k: v for k, v in from_env.items() if v is not None}
|
|
88
|
+
merged.update({k: v for k, v in overrides.items() if v is not None})
|
|
89
|
+
return cls(**merged)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def has_credentials(self) -> bool:
|
|
93
|
+
return bool(self._public_key and self._secret_key)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def otlp_traces_endpoint(self) -> str:
|
|
97
|
+
"""Full OTLP/HTTP traces endpoint for the configured Langfuse host."""
|
|
98
|
+
return f"{self._host.rstrip('/')}/api/public/otel/v1/traces"
|
eldros_sdk/decorator.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""The ``@trace`` decorator — wraps a function in a root span.
|
|
2
|
+
|
|
3
|
+
Handles plain sync functions, coroutines, sync generators and async generators
|
|
4
|
+
so streaming agent loops keep a single span open across the whole generation.
|
|
5
|
+
Identity attributes (``agent.id``, ``traffic_type``) from :func:`init` config are
|
|
6
|
+
stamped onto the span automatically. If ``init()`` was never called the span is
|
|
7
|
+
a no-op (the OTel API returns a non-recording span), so the decorator is always
|
|
8
|
+
safe to apply.
|
|
9
|
+
|
|
10
|
+
All four wrapper variants share span setup/teardown via :func:`_span_scope`, so
|
|
11
|
+
identity stamping, input capture and error recording live in exactly one place.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import functools
|
|
18
|
+
import inspect
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Callable, Iterator, Mapping, Optional
|
|
21
|
+
|
|
22
|
+
from opentelemetry import trace as otel_trace
|
|
23
|
+
from opentelemetry.trace import Span, SpanKind, Status, StatusCode
|
|
24
|
+
|
|
25
|
+
from . import attributes as attr
|
|
26
|
+
from .client import get_config
|
|
27
|
+
from .config import TraceConfig
|
|
28
|
+
from .version import __version__
|
|
29
|
+
|
|
30
|
+
logger: logging.Logger = logging.getLogger("eldros_sdk")
|
|
31
|
+
|
|
32
|
+
_MAX_IO_CHARS: int = 2000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_tracer() -> otel_trace.Tracer:
|
|
36
|
+
"""Return the SDK tracer. Non-recording until ``init()`` sets a provider."""
|
|
37
|
+
return otel_trace.get_tracer("eldros_sdk", __version__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _truncate(text: str) -> str:
|
|
41
|
+
return text if len(text) <= _MAX_IO_CHARS else text[:_MAX_IO_CHARS] + "...[truncated]"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _io_repr(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
|
45
|
+
return f"args={args!r} kwargs={kwargs!r}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _apply_attributes(span: Span, extra: Optional[Mapping[str, Any]]) -> None:
|
|
49
|
+
"""Stamp identity attributes from config plus any caller-supplied extras."""
|
|
50
|
+
config: Optional[TraceConfig] = get_config()
|
|
51
|
+
if config is not None:
|
|
52
|
+
if config.agent_id:
|
|
53
|
+
span.set_attribute(attr.AGENT_ID, config.agent_id)
|
|
54
|
+
span.set_attribute(attr.TRAFFIC_TYPE, config.traffic_type)
|
|
55
|
+
if extra:
|
|
56
|
+
for key, value in extra.items():
|
|
57
|
+
span.set_attribute(key, value)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _record_error(span: Span, exc: BaseException, record_exception: bool) -> None:
|
|
61
|
+
logger.debug("eldros_sdk: recording error on span: %s", exc)
|
|
62
|
+
if record_exception:
|
|
63
|
+
span.record_exception(exc)
|
|
64
|
+
span.set_status(Status(StatusCode.ERROR, str(exc)))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextlib.contextmanager
|
|
68
|
+
def _span_scope(
|
|
69
|
+
span_name: str,
|
|
70
|
+
kind: SpanKind,
|
|
71
|
+
attributes: Optional[Mapping[str, Any]],
|
|
72
|
+
capture_io: bool,
|
|
73
|
+
args: tuple[Any, ...],
|
|
74
|
+
kwargs: dict[str, Any],
|
|
75
|
+
record_exception: bool,
|
|
76
|
+
) -> Iterator[Span]:
|
|
77
|
+
"""Open a span, stamp attributes/input, and record any error from the body."""
|
|
78
|
+
tracer: otel_trace.Tracer = get_tracer()
|
|
79
|
+
with tracer.start_as_current_span(span_name, kind=kind) as span:
|
|
80
|
+
_apply_attributes(span, attributes)
|
|
81
|
+
logger.debug("eldros_sdk: span %r started", span_name)
|
|
82
|
+
if capture_io:
|
|
83
|
+
span.set_attribute(attr.INPUT, _truncate(_io_repr(args, kwargs)))
|
|
84
|
+
try:
|
|
85
|
+
yield span
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
_record_error(span, exc, record_exception)
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def trace(
|
|
92
|
+
func: Optional[Callable[..., Any]] = None,
|
|
93
|
+
*,
|
|
94
|
+
name: Optional[str] = None,
|
|
95
|
+
kind: SpanKind = SpanKind.INTERNAL,
|
|
96
|
+
attributes: Optional[Mapping[str, Any]] = None,
|
|
97
|
+
capture_io: bool = False,
|
|
98
|
+
record_exception: bool = True,
|
|
99
|
+
) -> Any:
|
|
100
|
+
"""Wrap a function so each call is recorded as a span.
|
|
101
|
+
|
|
102
|
+
Usable bare (``@trace``) or parameterized (``@trace(name=..., capture_io=True)``).
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
name: Span name. Defaults to the function's qualified name.
|
|
106
|
+
kind: OTel :class:`SpanKind`.
|
|
107
|
+
attributes: Static attributes to set on every span this function emits.
|
|
108
|
+
capture_io: Record (truncated) call args and return value on the span.
|
|
109
|
+
Off by default to avoid capturing large/sensitive payloads.
|
|
110
|
+
record_exception: Attach exception details to the span before re-raising.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
114
|
+
span_name: str = name or fn.__qualname__
|
|
115
|
+
|
|
116
|
+
if inspect.isasyncgenfunction(fn):
|
|
117
|
+
|
|
118
|
+
@functools.wraps(fn)
|
|
119
|
+
async def async_gen_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
120
|
+
with _span_scope(
|
|
121
|
+
span_name, kind, attributes, capture_io, args, kwargs, record_exception
|
|
122
|
+
):
|
|
123
|
+
async for item in fn(*args, **kwargs):
|
|
124
|
+
yield item
|
|
125
|
+
|
|
126
|
+
return async_gen_wrapper
|
|
127
|
+
|
|
128
|
+
if inspect.iscoroutinefunction(fn):
|
|
129
|
+
|
|
130
|
+
@functools.wraps(fn)
|
|
131
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
132
|
+
with _span_scope(
|
|
133
|
+
span_name, kind, attributes, capture_io, args, kwargs, record_exception
|
|
134
|
+
) as span:
|
|
135
|
+
result: Any = await fn(*args, **kwargs)
|
|
136
|
+
if capture_io:
|
|
137
|
+
span.set_attribute(attr.OUTPUT, _truncate(repr(result)))
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
return async_wrapper
|
|
141
|
+
|
|
142
|
+
if inspect.isgeneratorfunction(fn):
|
|
143
|
+
|
|
144
|
+
@functools.wraps(fn)
|
|
145
|
+
def gen_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
146
|
+
with _span_scope(
|
|
147
|
+
span_name, kind, attributes, capture_io, args, kwargs, record_exception
|
|
148
|
+
):
|
|
149
|
+
yield from fn(*args, **kwargs)
|
|
150
|
+
|
|
151
|
+
return gen_wrapper
|
|
152
|
+
|
|
153
|
+
@functools.wraps(fn)
|
|
154
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
155
|
+
with _span_scope(
|
|
156
|
+
span_name, kind, attributes, capture_io, args, kwargs, record_exception
|
|
157
|
+
) as span:
|
|
158
|
+
result: Any = fn(*args, **kwargs)
|
|
159
|
+
if capture_io:
|
|
160
|
+
span.set_attribute(attr.OUTPUT, _truncate(repr(result)))
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
return sync_wrapper
|
|
164
|
+
|
|
165
|
+
# Bare @trace vs @trace(...) usage.
|
|
166
|
+
if func is not None and callable(func):
|
|
167
|
+
return decorator(func)
|
|
168
|
+
return decorator
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Instrument the Claude Agent SDK and route its traces to Langfuse via LangSmith.
|
|
2
|
+
|
|
3
|
+
LangSmith patches ClaudeSDKClient and converts RunTree data to OTel spans via its
|
|
4
|
+
background thread when OTEL_ENABLED=true. It detects the global TracerProvider set
|
|
5
|
+
by eldros_sdk.init() and uses it directly — no separate OTLP endpoint config needed.
|
|
6
|
+
Spans flow through our BatchSpanProcessor to Langfuse alongside @trace and LLM spans.
|
|
7
|
+
|
|
8
|
+
If the client also has LANGSMITH_API_KEY set, the RunTree path simultaneously posts
|
|
9
|
+
to their LangSmith account (dual destination).
|
|
10
|
+
|
|
11
|
+
Clients who add their own SpanProcessor to the global TracerProvider will also receive
|
|
12
|
+
Claude Agent SDK spans — they can route to their own Langfuse or any OTel backend.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("eldros_sdk")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _instrument_langsmith() -> None:
|
|
24
|
+
try:
|
|
25
|
+
from langsmith.integrations.claude_agent_sdk import configure_claude_agent_sdk
|
|
26
|
+
except ImportError as exc:
|
|
27
|
+
raise ImportError(
|
|
28
|
+
"instrument_claude_agent_sdk() needs the langsmith[claude-agent-sdk] extra: "
|
|
29
|
+
"pip install 'eldros-sdk[claude-agent-sdk]'"
|
|
30
|
+
) from exc
|
|
31
|
+
|
|
32
|
+
# Always enable OTel so LangSmith emits spans via our global TracerProvider → Langfuse.
|
|
33
|
+
os.environ["LANGSMITH_OTEL_ENABLED"] = "true"
|
|
34
|
+
|
|
35
|
+
has_real_ls_key: bool = bool(os.environ.get("LANGSMITH_API_KEY"))
|
|
36
|
+
if has_real_ls_key:
|
|
37
|
+
# Hybrid: OTEL_ONLY not set → hybrid_otel_and_langsmith=True automatically.
|
|
38
|
+
# RunTree posts to client's LangSmith AND OTel → our Langfuse.
|
|
39
|
+
logger.info("eldros_sdk: LANGSMITH_API_KEY detected — traces → LangSmith + Langfuse")
|
|
40
|
+
else:
|
|
41
|
+
# OTel-only: explicitly disable LangSmith API posting — no 401, no noise.
|
|
42
|
+
os.environ["LANGSMITH_OTEL_ONLY"] = "true"
|
|
43
|
+
logger.info("eldros_sdk: no LANGSMITH_API_KEY — OTel-only mode → Langfuse only")
|
|
44
|
+
|
|
45
|
+
os.environ.setdefault("LANGSMITH_TRACING", "true")
|
|
46
|
+
|
|
47
|
+
# get_env_var is lru_cache'd — clear it so the values we just set are picked up.
|
|
48
|
+
try:
|
|
49
|
+
import langsmith.utils as _ls_utils
|
|
50
|
+
_ls_utils.get_env_var.cache_clear()
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
# Rebuild the LangSmith client so it picks up OTEL_ENABLED and attaches to
|
|
55
|
+
# our global TracerProvider.
|
|
56
|
+
try:
|
|
57
|
+
import langsmith.run_trees as _rt
|
|
58
|
+
_rt._CLIENT = None
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
configure_claude_agent_sdk()
|
|
63
|
+
|
|
64
|
+
logger.info("eldros_sdk: claude_agent_sdk instrumented via LangSmith (OTel → global TracerProvider)")
|
eldros_sdk/version.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eldros-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenTelemetry-native tracing SDK for Eldros AI agents
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: opentelemetry,tracing,llm,ai-agents,observability
|
|
8
|
+
Author: Eldros
|
|
9
|
+
Author-email: shyam@sentient.xyz
|
|
10
|
+
Requires-Python: >=3.10,<4.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Provides-Extra: all
|
|
23
|
+
Provides-Extra: anthropic
|
|
24
|
+
Provides-Extra: claude-agent-sdk
|
|
25
|
+
Provides-Extra: openai
|
|
26
|
+
Requires-Dist: claude-agent-sdk (>=0.2.0) ; extra == "claude-agent-sdk" or extra == "all"
|
|
27
|
+
Requires-Dist: langsmith[claude-agent-sdk] (>=0.8.17) ; extra == "claude-agent-sdk" or extra == "all"
|
|
28
|
+
Requires-Dist: opentelemetry-api (>=1.25.0)
|
|
29
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http (>=1.25.0)
|
|
30
|
+
Requires-Dist: opentelemetry-instrumentation-anthropic (>=0.25.0) ; extra == "anthropic" or extra == "all"
|
|
31
|
+
Requires-Dist: opentelemetry-instrumentation-openai (>=0.25.0) ; extra == "openai" or extra == "all"
|
|
32
|
+
Requires-Dist: opentelemetry-sdk (>=1.25.0)
|
|
33
|
+
Requires-Dist: pydantic (>=2.0)
|
|
34
|
+
Project-URL: Homepage, https://github.com/GShyam001/Construct
|
|
35
|
+
Project-URL: Repository, https://github.com/GShyam001/Construct
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# eldros-sdk
|
|
39
|
+
|
|
40
|
+
OpenTelemetry-native tracing SDK for Eldros AI agents. One `init()` call
|
|
41
|
+
resolves your Langfuse credentials from the Eldros platform, wires up
|
|
42
|
+
OpenTelemetry, and auto-instruments your LLM provider clients. Drop a `@trace`
|
|
43
|
+
decorator on your handler and you get a root span per call.
|
|
44
|
+
|
|
45
|
+
**You need exactly one thing: an Eldros API key** (`agt_...`). The SDK
|
|
46
|
+
resolves Langfuse credentials from the Eldros platform at startup — you never
|
|
47
|
+
configure Langfuse directly.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install eldros-sdk # core
|
|
53
|
+
pip install "eldros-sdk[claude-agent-sdk]" # + Claude Agent SDK instrumentation via LangSmith
|
|
54
|
+
pip install "eldros-sdk[openai]" # + OpenAI auto-instrumentation
|
|
55
|
+
pip install "eldros-sdk[anthropic]" # + Anthropic auto-instrumentation
|
|
56
|
+
pip install "eldros-sdk[all]" # everything
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
export ELDROS_API_KEY=agt_...
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from eldros_sdk import init, trace
|
|
67
|
+
|
|
68
|
+
init(agent_id="support-bot") # api_key is read from ELDROS_API_KEY
|
|
69
|
+
|
|
70
|
+
@trace
|
|
71
|
+
async def handle_conversation(user_message: str) -> str:
|
|
72
|
+
# OpenAI / Anthropic calls in here are auto-instrumented as child spans.
|
|
73
|
+
...
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`init()` blocks briefly at startup to call the Eldros platform and fetch your
|
|
77
|
+
Langfuse credentials — call it before starting your event loop (e.g. at module
|
|
78
|
+
import time or in your `if __name__ == "__main__"` block).
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
Every argument can come from an environment variable. Explicit `init(...)` kwargs
|
|
83
|
+
win over env vars.
|
|
84
|
+
|
|
85
|
+
| `init()` arg | Env var | Default |
|
|
86
|
+
| ------------------------ | ------------------------ | ---------------------------------------------- |
|
|
87
|
+
| `api_key` | `ELDROS_API_KEY` | — *(required)* |
|
|
88
|
+
| `api_base_url` | `ELDROS_API_BASE_URL` | — *(required until official domain is live)* |
|
|
89
|
+
| `agent_id` | `ELDROS_AGENT_ID` | — |
|
|
90
|
+
| `traffic_type` / `env` | `ELDROS_TRAFFIC_TYPE` | `prod` |
|
|
91
|
+
| `service_name` | `ELDROS_SERVICE_NAME` | `eldros-agent` |
|
|
92
|
+
| `instrument_claude_agent_sdk` | `ELDROS_INSTRUMENT_CLAUDE_AGENT_SDK` | `false` *(opt in explicitly)* |
|
|
93
|
+
| `instrument_openai` | — | `false` *(opt in explicitly)* |
|
|
94
|
+
| `instrument_anthropic` | — | `false` *(opt in explicitly)* |
|
|
95
|
+
| `capture_content` | `ELDROS_CAPTURE_CONTENT` | `true` |
|
|
96
|
+
| `debug` | `ELDROS_TRACE_DEBUG` | `false` |
|
|
97
|
+
| `enabled` | `ELDROS_TRACE_ENABLED` | `true` |
|
|
98
|
+
|
|
99
|
+
- **`enabled=False`** makes the whole SDK a no-op — safe to leave in any environment.
|
|
100
|
+
- **`debug=True`** (or `ELDROS_TRACE_DEBUG=true`) also prints spans to the console.
|
|
101
|
+
- **`capture_content=False`** strips prompt/response text from LLM spans.
|
|
102
|
+
|
|
103
|
+
## Decorator options
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
@trace(name="checkout", capture_io=True, attributes={"tier": "pro"})
|
|
107
|
+
def run(payload: dict) -> dict:
|
|
108
|
+
...
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`@trace` supports sync functions, coroutines, sync generators, and async
|
|
112
|
+
generators (the span stays open across the whole generation).
|
|
113
|
+
|
|
114
|
+
## Claude Agent SDK instrumentation
|
|
115
|
+
|
|
116
|
+
Install the `[claude-agent-sdk]` extra and pass the flag to `init()` — no separate call:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install "eldros-sdk[claude-agent-sdk]"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import eldros_sdk
|
|
124
|
+
|
|
125
|
+
eldros_sdk.init(agent_id="my-agent", instrument_claude_agent_sdk=True)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`init()` is the single entry point. LangSmith patches `ClaudeSDKClient` and routes
|
|
129
|
+
spans through the global `TracerProvider` — alongside `@trace` and LLM spans.
|
|
130
|
+
Requires `ClaudeSDKClient` (not the module-level `query()`).
|
|
131
|
+
|
|
132
|
+
If `LANGSMITH_API_KEY` is also set, traces go to both your LangSmith account and
|
|
133
|
+
Eldros's Langfuse simultaneously (hybrid mode).
|
|
134
|
+
|
|
135
|
+
## Lifecycle
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from eldros_sdk import flush, shutdown
|
|
139
|
+
|
|
140
|
+
flush() # force-export buffered spans (e.g. end of a serverless invocation)
|
|
141
|
+
shutdown() # flush + tear down the provider
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Testing the SDK
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
# Verify credential resolution and SDK init
|
|
148
|
+
ELDROS_API_KEY=agt_... ELDROS_API_BASE_URL=https://... python3 -c "
|
|
149
|
+
import eldros_sdk
|
|
150
|
+
cfg = eldros_sdk.init(agent_id='test')
|
|
151
|
+
print('has_credentials:', cfg.has_credentials)
|
|
152
|
+
"
|
|
153
|
+
|
|
154
|
+
# Verify a span actually reaches Langfuse
|
|
155
|
+
ELDROS_API_KEY=agt_... ELDROS_TRACE_DEBUG=true python3 -c "
|
|
156
|
+
import eldros_sdk
|
|
157
|
+
|
|
158
|
+
eldros_sdk.init(agent_id='test', debug=True)
|
|
159
|
+
|
|
160
|
+
@eldros_sdk.trace
|
|
161
|
+
def hello():
|
|
162
|
+
return 'world'
|
|
163
|
+
|
|
164
|
+
hello()
|
|
165
|
+
eldros_sdk.flush()
|
|
166
|
+
print('done — check Langfuse')
|
|
167
|
+
"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Design notes
|
|
171
|
+
|
|
172
|
+
- **Credential resolution.** On `init()`, the SDK calls
|
|
173
|
+
`GET {api_base_url}/api/v1/langfuse/resolve` with your `ELDROS_API_KEY` in the
|
|
174
|
+
`X-Client-API-Key` header and receives `{public_key, secret_key, host}` for your
|
|
175
|
+
org's Langfuse project. The Langfuse secret key is never stored on the client.
|
|
176
|
+
- **Langfuse via OTLP/HTTP.** No Langfuse SDK dependency — spans are exported as
|
|
177
|
+
OTel protobuf over HTTP with basic auth.
|
|
178
|
+
- **Additive OTel.** If a `TracerProvider` already exists (Datadog, etc.) the SDK
|
|
179
|
+
attaches its exporter to it instead of replacing it — spans flow to both destinations.
|
|
180
|
+
**Call `init()` before any other tracing setup** so the SDK's provider is detected first.
|
|
181
|
+
- **Claude Agent SDK spans use the global provider.** LangSmith detects the global
|
|
182
|
+
`TracerProvider` set by `init()` and routes all Claude Agent SDK OTel spans through it.
|
|
183
|
+
This means any `SpanProcessor` you add to the global provider also receives those spans —
|
|
184
|
+
you can route to your own Langfuse or any OTel backend alongside ours.
|
|
185
|
+
- **Auto-instrumentation is optional.** Provider instrumentors are extras; if a
|
|
186
|
+
provider library isn't installed the SDK logs and continues.
|
|
187
|
+
- **Safe by default.** Spans are non-recording until `init()` runs, so `@trace`
|
|
188
|
+
never breaks code even if `init()` is missing.
|
|
189
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
eldros_sdk/__init__.py,sha256=jQ6Y_rAGx_NOL0k5hPla3rvrxqgqiMyvtV0ukEgh7sI,974
|
|
2
|
+
eldros_sdk/_instrument.py,sha256=oQTdfVGZFNTmz20eZaQJY1Sk5B6lTHYlz-w1HKRhlY0,2632
|
|
3
|
+
eldros_sdk/attributes.py,sha256=WvyqA0ZpPmwPEdMRvrsonViR_WjFeNQqoZdhAc6UDlg,1630
|
|
4
|
+
eldros_sdk/client.py,sha256=Syp82E7yhbXLcHZvSXLKh746chJZGCosQmhVksnQA98,12061
|
|
5
|
+
eldros_sdk/config.py,sha256=Wvd944dOwIJhHiilOqO8kcWD0dyyj3lfcuTSPw3ZplE,3827
|
|
6
|
+
eldros_sdk/decorator.py,sha256=snCYvE9hxOlk4zehC-zmpPyApmDCJVTVUp4NHpxQNmg,6024
|
|
7
|
+
eldros_sdk/integrations/__init__.py,sha256=ZdO7tbTp_orp0vZdZwg1Yi3AbgyfFZNP-UI5ynsS8iM,109
|
|
8
|
+
eldros_sdk/integrations/claude_agent_sdk.py,sha256=KgqoGvfk9T3IlnSCtJihxnwtYVOFW4ZOnRvAFOAD_Ec,2574
|
|
9
|
+
eldros_sdk/version.py,sha256=mSjIBsld1fVnbLPzLrU3TdY5rADDr3qIx6GGfr1Rqxc,129
|
|
10
|
+
eldros_sdk-0.1.0.dist-info/METADATA,sha256=S1k2cmjfhmhvPNdYh4MmJ0DqrnrF2u3_5kYMnG6u3HI,7782
|
|
11
|
+
eldros_sdk-0.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
12
|
+
eldros_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=T-phNFfIt9tzz23JHErwAvuy8KVP5rjvcix7sh29ovI,1063
|
|
13
|
+
eldros_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eldros
|
|
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. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|