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 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)
@@ -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"
@@ -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,3 @@
1
+ """Framework integrations for eldros-sdk (e.g. the Claude Agent SDK)."""
2
+
3
+ from __future__ import annotations
@@ -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,5 @@
1
+ """Single source of truth for the eldros-sdk package version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__: str = "0.1.0"
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.