eden-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.
- eden/__init__.py +77 -0
- eden/py.typed +0 -0
- eden_sdk/__init__.py +144 -0
- eden_sdk/client.py +478 -0
- eden_sdk/config.py +316 -0
- eden_sdk/filters.py +130 -0
- eden_sdk/gateway_session.py +128 -0
- eden_sdk/instrumentations/__init__.py +54 -0
- eden_sdk/instrumentations/anthropic.py +436 -0
- eden_sdk/instrumentations/autogen.py +95 -0
- eden_sdk/instrumentations/crewai.py +159 -0
- eden_sdk/instrumentations/langchain.py +360 -0
- eden_sdk/instrumentations/llama_index.py +147 -0
- eden_sdk/instrumentations/mastra.py +36 -0
- eden_sdk/instrumentations/openai.py +484 -0
- eden_sdk/model_advisor.py +455 -0
- eden_sdk/py.typed +0 -0
- eden_sdk/trace.py +254 -0
- eden_sdk-0.1.0.dist-info/METADATA +394 -0
- eden_sdk-0.1.0.dist-info/RECORD +23 -0
- eden_sdk-0.1.0.dist-info/WHEEL +5 -0
- eden_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- eden_sdk-0.1.0.dist-info/top_level.txt +2 -0
eden/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Eden — top-level import.
|
|
2
|
+
|
|
3
|
+
This module is the canonical user-facing entry point. It re-exports
|
|
4
|
+
the public API from :mod:`eden_sdk` so that ``import eden`` works
|
|
5
|
+
the way the README and examples show.
|
|
6
|
+
|
|
7
|
+
We keep the implementation in ``eden_sdk`` so the PyPI distribution
|
|
8
|
+
name stays ``eden-sdk`` (which is unique) while the import path is
|
|
9
|
+
the short, memorable ``eden`` (which is what users actually type).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from eden_sdk import ( # noqa: F401,F403
|
|
15
|
+
EdenClient,
|
|
16
|
+
EdenConfig,
|
|
17
|
+
Span,
|
|
18
|
+
TraceContext,
|
|
19
|
+
__version__,
|
|
20
|
+
configure,
|
|
21
|
+
current_span,
|
|
22
|
+
current_trace,
|
|
23
|
+
get_config,
|
|
24
|
+
get_default_client,
|
|
25
|
+
instrument,
|
|
26
|
+
reset_config,
|
|
27
|
+
trace,
|
|
28
|
+
trace_agent,
|
|
29
|
+
uninstrument,
|
|
30
|
+
)
|
|
31
|
+
from eden_sdk.filters import FilterConfig, PIIConfig # noqa: F401
|
|
32
|
+
from eden_sdk.gateway_session import chat_completion, openai_client # noqa: F401
|
|
33
|
+
from eden_sdk.model_advisor import ( # noqa: F401
|
|
34
|
+
ModelAdvisor,
|
|
35
|
+
ModelAdvisorError,
|
|
36
|
+
ModelComparison,
|
|
37
|
+
ModelSpec,
|
|
38
|
+
Recommendation,
|
|
39
|
+
RunnerUp,
|
|
40
|
+
SwitchProjection,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
# Configuration
|
|
45
|
+
"EdenConfig",
|
|
46
|
+
"configure",
|
|
47
|
+
"get_config",
|
|
48
|
+
"reset_config",
|
|
49
|
+
# Public client
|
|
50
|
+
"EdenClient",
|
|
51
|
+
"get_default_client",
|
|
52
|
+
# Tracing API
|
|
53
|
+
"trace",
|
|
54
|
+
"trace_agent",
|
|
55
|
+
"Span",
|
|
56
|
+
"TraceContext",
|
|
57
|
+
"current_span",
|
|
58
|
+
"current_trace",
|
|
59
|
+
# Auto-instrumentation
|
|
60
|
+
"instrument",
|
|
61
|
+
"uninstrument",
|
|
62
|
+
# Per-filter configuration for the gateway
|
|
63
|
+
"FilterConfig",
|
|
64
|
+
"PIIConfig",
|
|
65
|
+
# High-level gateway helpers (mirrors TypeScript SDK)
|
|
66
|
+
"openai_client",
|
|
67
|
+
"chat_completion",
|
|
68
|
+
# Model comparison + recommendation (mirrors TypeScript SDK)
|
|
69
|
+
"ModelAdvisor",
|
|
70
|
+
"ModelAdvisorError",
|
|
71
|
+
"ModelComparison",
|
|
72
|
+
"ModelSpec",
|
|
73
|
+
"Recommendation",
|
|
74
|
+
"RunnerUp",
|
|
75
|
+
"SwitchProjection",
|
|
76
|
+
"__version__",
|
|
77
|
+
]
|
eden/py.typed
ADDED
|
File without changes
|
eden_sdk/__init__.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Eden Python Runtime SDK.
|
|
2
|
+
|
|
3
|
+
A lightweight, fire-and-forget observability SDK for LLM applications.
|
|
4
|
+
|
|
5
|
+
Highlights
|
|
6
|
+
----------
|
|
7
|
+
- ``@trace_agent`` decorator — trace any Python function (sync or async).
|
|
8
|
+
- ``with eden.trace(...)`` — manual span context manager.
|
|
9
|
+
- One-line auto-instrumentation for OpenAI, Anthropic, LangChain, LlamaIndex,
|
|
10
|
+
CrewAI, AutoGen, Mastra.
|
|
11
|
+
- Async, batched, non-blocking HTTP sender (target p99 overhead ≤ 5 ms).
|
|
12
|
+
- Zero required dependencies beyond ``httpx`` (the only required runtime dep).
|
|
13
|
+
|
|
14
|
+
Example
|
|
15
|
+
-------
|
|
16
|
+
>>> import eden
|
|
17
|
+
>>> eden.configure(org_id="org_123", api_key="sk-...")
|
|
18
|
+
>>> eden.instrument() # patches OpenAI, Anthropic, LangChain, etc.
|
|
19
|
+
>>>
|
|
20
|
+
>>> @eden.trace_agent(name="my-agent")
|
|
21
|
+
... async def run(q: str) -> str:
|
|
22
|
+
... from openai import AsyncOpenAI
|
|
23
|
+
... client = AsyncOpenAI()
|
|
24
|
+
... resp = await client.chat.completions.create(
|
|
25
|
+
... model="gpt-4o-mini", messages=[{"role": "user", "content": q}]
|
|
26
|
+
... )
|
|
27
|
+
... return resp.choices[0].message.content
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from eden_sdk.client import EdenClient, get_default_client
|
|
33
|
+
from eden_sdk.config import EdenConfig, configure, get_config, reset_config
|
|
34
|
+
from eden_sdk.filters import FilterConfig, PIIConfig
|
|
35
|
+
from eden_sdk.gateway_session import chat_completion, openai_client
|
|
36
|
+
from eden_sdk.model_advisor import (
|
|
37
|
+
ModelAdvisor,
|
|
38
|
+
ModelAdvisorError,
|
|
39
|
+
ModelComparison,
|
|
40
|
+
ModelSpec,
|
|
41
|
+
Recommendation,
|
|
42
|
+
RunnerUp,
|
|
43
|
+
SwitchProjection,
|
|
44
|
+
)
|
|
45
|
+
from eden_sdk.trace import (
|
|
46
|
+
Span,
|
|
47
|
+
TraceContext,
|
|
48
|
+
current_span,
|
|
49
|
+
current_trace,
|
|
50
|
+
trace,
|
|
51
|
+
trace_agent,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
# Configuration
|
|
56
|
+
"EdenConfig",
|
|
57
|
+
"configure",
|
|
58
|
+
"get_config",
|
|
59
|
+
"reset_config",
|
|
60
|
+
# Public client
|
|
61
|
+
"EdenClient",
|
|
62
|
+
"get_default_client",
|
|
63
|
+
# Tracing API
|
|
64
|
+
"trace",
|
|
65
|
+
"trace_agent",
|
|
66
|
+
"Span",
|
|
67
|
+
"TraceContext",
|
|
68
|
+
"current_span",
|
|
69
|
+
"current_trace",
|
|
70
|
+
# Auto-instrumentation
|
|
71
|
+
"instrument",
|
|
72
|
+
"uninstrument",
|
|
73
|
+
# Per-filter configuration for the gateway
|
|
74
|
+
"FilterConfig",
|
|
75
|
+
"PIIConfig",
|
|
76
|
+
# High-level gateway helpers
|
|
77
|
+
"openai_client",
|
|
78
|
+
"chat_completion",
|
|
79
|
+
# Model comparison + recommendation
|
|
80
|
+
"ModelAdvisor",
|
|
81
|
+
"ModelAdvisorError",
|
|
82
|
+
"ModelComparison",
|
|
83
|
+
"ModelSpec",
|
|
84
|
+
"Recommendation",
|
|
85
|
+
"RunnerUp",
|
|
86
|
+
"SwitchProjection",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
__version__ = "0.1.0"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Lazy instrumentation registry — the per-framework modules are
|
|
93
|
+
# imported on demand so the SDK works for users that only need a subset.
|
|
94
|
+
INSTRUMENTED: set[str] = set()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def instrument(*frameworks: str) -> None:
|
|
98
|
+
"""Auto-instrument one or more LLM framework SDKs in place.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
*frameworks
|
|
103
|
+
Names of frameworks to instrument. Allowed values:
|
|
104
|
+
``"openai"``, ``"anthropic"``, ``"langchain"``, ``"llama_index"``,
|
|
105
|
+
``"crewai"``, ``"autogen"``, ``"mastra"``. If no name is given,
|
|
106
|
+
all currently-importable frameworks are instrumented.
|
|
107
|
+
"""
|
|
108
|
+
from eden_sdk import instrumentations # local import to avoid cycles
|
|
109
|
+
|
|
110
|
+
targets = frameworks or tuple(instrumentations.__all__)
|
|
111
|
+
for name in targets:
|
|
112
|
+
if name in INSTRUMENTED:
|
|
113
|
+
continue
|
|
114
|
+
module = getattr(instrumentations, name, None)
|
|
115
|
+
if module is None:
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
module.install()
|
|
119
|
+
except Exception as exc: # noqa: BLE001
|
|
120
|
+
# Don't let a missing optional dep break the whole SDK.
|
|
121
|
+
import logging
|
|
122
|
+
|
|
123
|
+
logging.getLogger("eden_sdk").debug(
|
|
124
|
+
"instrumentation %s skipped: %s", name, exc
|
|
125
|
+
)
|
|
126
|
+
continue
|
|
127
|
+
INSTRUMENTED.add(name)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def uninstrument(*frameworks: str) -> None:
|
|
131
|
+
"""Reverse the patches applied by :func:`instrument`."""
|
|
132
|
+
from eden_sdk import instrumentations
|
|
133
|
+
|
|
134
|
+
targets = frameworks or tuple(INSTRUMENTED)
|
|
135
|
+
for name in targets:
|
|
136
|
+
if name not in INSTRUMENTED:
|
|
137
|
+
continue
|
|
138
|
+
module = getattr(instrumentations, name, None)
|
|
139
|
+
if module is not None:
|
|
140
|
+
try:
|
|
141
|
+
module.uninstall()
|
|
142
|
+
except Exception: # noqa: BLE001
|
|
143
|
+
pass
|
|
144
|
+
INSTRUMENTED.discard(name)
|
eden_sdk/client.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""Batched, async, fire-and-forget HTTP sender.
|
|
2
|
+
|
|
3
|
+
The :class:`EdenClient` is the SDK's only network surface. Events from
|
|
4
|
+
auto-instrumentation or the :func:`eden.trace` context manager are
|
|
5
|
+
enqueued, then flushed either when the batch fills or when a timer
|
|
6
|
+
fires. The hot path is *non-blocking*: callers never wait on the
|
|
7
|
+
network — they just append to an in-memory queue and return.
|
|
8
|
+
|
|
9
|
+
Threading
|
|
10
|
+
---------
|
|
11
|
+
- A single background thread runs the async event loop with an
|
|
12
|
+
``httpx.AsyncClient`` connection pool.
|
|
13
|
+
- Producer threads (the user's main code, callback handlers, etc.)
|
|
14
|
+
call :meth:`enqueue` which is just a ``queue.put_nowait`` plus
|
|
15
|
+
a ``threading.Event.set`` to wake the loop.
|
|
16
|
+
- The loop pops events, builds a batch, and ``await``s the HTTP POST.
|
|
17
|
+
- On a full queue (back-pressure) the oldest event is dropped with
|
|
18
|
+
a warning — the SDK must never block user code.
|
|
19
|
+
|
|
20
|
+
Shutdown
|
|
21
|
+
--------
|
|
22
|
+
Call :func:`shutdown` (or use the SDK as a context manager via
|
|
23
|
+
:func:`get_default_client` returning a singleton) to flush and stop
|
|
24
|
+
the worker. ``atexit`` registers a best-effort flush.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import atexit
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import queue as _queue
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from collections import deque
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
import httpx
|
|
39
|
+
|
|
40
|
+
from eden_sdk.config import EdenConfig, get_config
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger("eden_sdk.client")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── Public types ──────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TraceEvent:
|
|
49
|
+
"""A single telemetry event ready for the wire.
|
|
50
|
+
|
|
51
|
+
The SDK only ships JSON-safe dicts — this is a thin convenience
|
|
52
|
+
wrapper that records the source format and the dict payload so
|
|
53
|
+
the sender can route to the right ingestion endpoint.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
__slots__ = ("source_format", "payload")
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
payload: dict[str, Any],
|
|
61
|
+
source_format: str = "openai",
|
|
62
|
+
) -> None:
|
|
63
|
+
self.source_format = source_format
|
|
64
|
+
self.payload = payload
|
|
65
|
+
|
|
66
|
+
def to_json_bytes(self) -> bytes:
|
|
67
|
+
return json.dumps(self.payload, default=str).encode("utf-8")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Sender ────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class EdenClient:
|
|
74
|
+
"""Batched, async HTTP sender.
|
|
75
|
+
|
|
76
|
+
Most users never instantiate this directly — :func:`get_default_client`
|
|
77
|
+
returns a process-wide singleton. Tests can build their own
|
|
78
|
+
:class:`EdenClient` with a custom config.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
# Per-batch endpoint format: we POST one event at a time, but the
|
|
82
|
+
# ingestion route also accepts a JSON array. The default decoder
|
|
83
|
+
# accepts both shapes. We use array form to halve the number of
|
|
84
|
+
# round-trips for bursts of activity.
|
|
85
|
+
_BATCH_AS_ARRAY = True
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
config: EdenConfig | None = None,
|
|
90
|
+
*,
|
|
91
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Build a sender.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
config
|
|
98
|
+
Runtime configuration. Defaults to the process-wide
|
|
99
|
+
config singleton.
|
|
100
|
+
transport
|
|
101
|
+
Optional httpx async transport to install on the
|
|
102
|
+
internal AsyncClient. Use this to inject a
|
|
103
|
+
MockTransport in tests or a custom transport in
|
|
104
|
+
production (e.g. for custom TLS / metrics).
|
|
105
|
+
"""
|
|
106
|
+
self.config = config or get_config()
|
|
107
|
+
self._queue: _queue.Queue[TraceEvent] = _queue.Queue(
|
|
108
|
+
maxsize=self.config.max_queue
|
|
109
|
+
)
|
|
110
|
+
# Streaming chunks accumulate here and are flushed on the
|
|
111
|
+
# same worker tick (or via :meth:`flush_chunks`). Distinct
|
|
112
|
+
# from the regular queue because they go to ``/ingest/chunks``
|
|
113
|
+
# with a smaller payload shape.
|
|
114
|
+
self._chunk_buffer: list[dict[str, Any]] = []
|
|
115
|
+
self._chunk_wake = threading.Event()
|
|
116
|
+
self._chunk_generation = 0
|
|
117
|
+
self._chunk_processed_generation = 0
|
|
118
|
+
self._chunk_idle = threading.Event()
|
|
119
|
+
self._chunk_idle.set()
|
|
120
|
+
self._wake = threading.Event()
|
|
121
|
+
self._stop = threading.Event()
|
|
122
|
+
self._thread: threading.Thread | None = None
|
|
123
|
+
self._http: httpx.AsyncClient | None = None
|
|
124
|
+
# Transport precedence: explicit kwarg > config.transport > None.
|
|
125
|
+
# The config field is what :func:`configure` threads through
|
|
126
|
+
# the singleton, so users can set ``configure(transport=...)``
|
|
127
|
+
# to mock the gateway in tests.
|
|
128
|
+
cfg_transport = getattr(self.config, "transport", None)
|
|
129
|
+
self._transport: httpx.AsyncBaseTransport | None = (
|
|
130
|
+
transport if transport is not None else cfg_transport
|
|
131
|
+
)
|
|
132
|
+
self._loop_started = threading.Event()
|
|
133
|
+
self._dropped_total = 0
|
|
134
|
+
self._sent_total = 0
|
|
135
|
+
self._failed_total = 0
|
|
136
|
+
self._latency_window: deque[float] = deque(maxlen=1024)
|
|
137
|
+
# Generation counter: incremented at the end of each
|
|
138
|
+
# send_batch. ``flush()`` waits until the queue is empty
|
|
139
|
+
# AND the generation is stable for one tick — that way
|
|
140
|
+
# we know the in-flight HTTP has finished.
|
|
141
|
+
self._generation = 0
|
|
142
|
+
self._processed_generation = 0
|
|
143
|
+
self._generation_idle = threading.Event()
|
|
144
|
+
self._generation_idle.set()
|
|
145
|
+
|
|
146
|
+
# ── Lifecycle ────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def start(self) -> None:
|
|
149
|
+
"""Start the background sender thread (idempotent)."""
|
|
150
|
+
if self._thread is not None and self._thread.is_alive():
|
|
151
|
+
return
|
|
152
|
+
if not self.config.enabled:
|
|
153
|
+
logger.debug("eden client disabled; not starting sender")
|
|
154
|
+
return
|
|
155
|
+
self._stop.clear()
|
|
156
|
+
self._thread = threading.Thread(
|
|
157
|
+
target=self._run_loop, name="eden-sdk-sender", daemon=True
|
|
158
|
+
)
|
|
159
|
+
self._thread.start()
|
|
160
|
+
# Wait until the loop has its asyncio loop ready so that
|
|
161
|
+
# shutdown() can join immediately.
|
|
162
|
+
self._loop_started.wait(timeout=1.0)
|
|
163
|
+
|
|
164
|
+
def shutdown(self, timeout: float = 5.0) -> None:
|
|
165
|
+
"""Stop the sender, draining the queue first."""
|
|
166
|
+
if self._thread is None:
|
|
167
|
+
return
|
|
168
|
+
self._stop.set()
|
|
169
|
+
self._wake.set()
|
|
170
|
+
self._thread.join(timeout=timeout)
|
|
171
|
+
self._thread = None
|
|
172
|
+
|
|
173
|
+
# ── Producer API ─────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
def enqueue(self, event: TraceEvent) -> None:
|
|
176
|
+
"""Non-blocking enqueue. Drops oldest on overflow.
|
|
177
|
+
|
|
178
|
+
Designed to be called from any thread — including the
|
|
179
|
+
framework callbacks running on the user's event loop.
|
|
180
|
+
"""
|
|
181
|
+
if not self.config.enabled:
|
|
182
|
+
return
|
|
183
|
+
if self.config.dry_run:
|
|
184
|
+
self._sent_total += 1
|
|
185
|
+
logger.debug(
|
|
186
|
+
"eden[dry-run] fmt=%s keys=%s",
|
|
187
|
+
event.source_format,
|
|
188
|
+
sorted(event.payload.keys()),
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
self._queue.put_nowait(event)
|
|
193
|
+
except _queue.Full:
|
|
194
|
+
# Drop the oldest to make room.
|
|
195
|
+
try:
|
|
196
|
+
self._queue.get_nowait()
|
|
197
|
+
except _queue.Empty:
|
|
198
|
+
pass
|
|
199
|
+
try:
|
|
200
|
+
self._queue.put_nowait(event)
|
|
201
|
+
except _queue.Full:
|
|
202
|
+
self._dropped_total += 1
|
|
203
|
+
logger.warning("eden sdk: queue full, dropped event")
|
|
204
|
+
self._wake.set()
|
|
205
|
+
# Wake the worker when a streaming chunk lands so it flushes
|
|
206
|
+
# without waiting for the full batch window. The producer
|
|
207
|
+
# pays only the ``Event.set`` cost.
|
|
208
|
+
if self._chunk_buffer:
|
|
209
|
+
self._chunk_wake.set()
|
|
210
|
+
|
|
211
|
+
def flush(self, timeout: float = 2.0) -> None:
|
|
212
|
+
"""Block until the current queue + in-flight HTTP is drained.
|
|
213
|
+
|
|
214
|
+
Waits up to ``timeout`` seconds. Returns early as soon as
|
|
215
|
+
the queue is empty AND the in-flight batch completes. Used
|
|
216
|
+
by the test-suite and by the ``atexit`` hook for a clean
|
|
217
|
+
shutdown.
|
|
218
|
+
"""
|
|
219
|
+
if not self.config.enabled:
|
|
220
|
+
return
|
|
221
|
+
# Force the worker to drain on the next loop tick.
|
|
222
|
+
self._wake.set()
|
|
223
|
+
deadline = time.monotonic() + timeout
|
|
224
|
+
while time.monotonic() < deadline:
|
|
225
|
+
if self._queue.empty() and self._processed_generation >= self._generation:
|
|
226
|
+
return
|
|
227
|
+
# Block on the idle event with a small timeout so we
|
|
228
|
+
# can re-check the queue state.
|
|
229
|
+
self._generation_idle.wait(timeout=0.01)
|
|
230
|
+
self._generation_idle.clear()
|
|
231
|
+
|
|
232
|
+
# ── Internal loop ────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
def _run_loop(self) -> None:
|
|
235
|
+
import asyncio
|
|
236
|
+
|
|
237
|
+
asyncio.run(self._async_loop())
|
|
238
|
+
|
|
239
|
+
async def _async_loop(self) -> None:
|
|
240
|
+
|
|
241
|
+
self._http = httpx.AsyncClient(
|
|
242
|
+
timeout=httpx.Timeout(self.config.timeout_s),
|
|
243
|
+
limits=httpx.Limits(
|
|
244
|
+
max_connections=16, max_keepalive_connections=8
|
|
245
|
+
),
|
|
246
|
+
transport=self._transport,
|
|
247
|
+
)
|
|
248
|
+
self._loop_started.set()
|
|
249
|
+
flush_interval = self.config.flush_interval_ms / 1000.0
|
|
250
|
+
try:
|
|
251
|
+
while not self._stop.is_set():
|
|
252
|
+
# Wait for the next event or the flush interval.
|
|
253
|
+
self._wake.wait(timeout=flush_interval)
|
|
254
|
+
self._wake.clear()
|
|
255
|
+
if self._stop.is_set() and self._queue.empty():
|
|
256
|
+
break
|
|
257
|
+
batch = self._drain(self.config.batch_size)
|
|
258
|
+
if batch:
|
|
259
|
+
self._generation += 1
|
|
260
|
+
try:
|
|
261
|
+
await self._send_batch(batch)
|
|
262
|
+
finally:
|
|
263
|
+
self._processed_generation = self._generation
|
|
264
|
+
self._generation_idle.set()
|
|
265
|
+
else:
|
|
266
|
+
# Queue is empty: tell flush() it can return.
|
|
267
|
+
self._generation_idle.set()
|
|
268
|
+
# Drain streaming chunks on the same loop tick.
|
|
269
|
+
if self._chunk_buffer:
|
|
270
|
+
self._chunk_generation += 1
|
|
271
|
+
try:
|
|
272
|
+
await self._send_chunks()
|
|
273
|
+
finally:
|
|
274
|
+
self._chunk_processed_generation = self._chunk_generation
|
|
275
|
+
self._chunk_idle.set()
|
|
276
|
+
else:
|
|
277
|
+
self._chunk_idle.set()
|
|
278
|
+
finally:
|
|
279
|
+
# Final drain on shutdown.
|
|
280
|
+
tail = self._drain(self.config.batch_size)
|
|
281
|
+
if tail:
|
|
282
|
+
self._generation += 1
|
|
283
|
+
await self._send_batch(tail)
|
|
284
|
+
self._processed_generation = self._generation
|
|
285
|
+
if self._chunk_buffer:
|
|
286
|
+
self._chunk_generation += 1
|
|
287
|
+
await self._send_chunks()
|
|
288
|
+
self._chunk_processed_generation = self._chunk_generation
|
|
289
|
+
self._generation_idle.set()
|
|
290
|
+
self._chunk_idle.set()
|
|
291
|
+
if self._http is not None:
|
|
292
|
+
await self._http.aclose()
|
|
293
|
+
self._http = None
|
|
294
|
+
|
|
295
|
+
def _drain(self, max_items: int) -> list[TraceEvent]:
|
|
296
|
+
items: list[TraceEvent] = []
|
|
297
|
+
for _ in range(max_items):
|
|
298
|
+
try:
|
|
299
|
+
items.append(self._queue.get_nowait())
|
|
300
|
+
except _queue.Empty:
|
|
301
|
+
break
|
|
302
|
+
return items
|
|
303
|
+
|
|
304
|
+
def flush_chunks(self, timeout: float = 2.0) -> None:
|
|
305
|
+
"""Block until buffered streaming chunks are flushed.
|
|
306
|
+
|
|
307
|
+
Mirrors :meth:`flush` but for the streaming chunks buffer.
|
|
308
|
+
No-op when there are no buffered chunks.
|
|
309
|
+
"""
|
|
310
|
+
if not self.config.enabled:
|
|
311
|
+
return
|
|
312
|
+
self._chunk_wake.set()
|
|
313
|
+
deadline = time.monotonic() + timeout
|
|
314
|
+
while time.monotonic() < deadline:
|
|
315
|
+
if (
|
|
316
|
+
not self._chunk_buffer
|
|
317
|
+
and self._chunk_processed_generation >= self._chunk_generation
|
|
318
|
+
):
|
|
319
|
+
return
|
|
320
|
+
self._chunk_idle.wait(timeout=0.01)
|
|
321
|
+
self._chunk_idle.clear()
|
|
322
|
+
|
|
323
|
+
def ingest_chunks_url(self) -> str | None:
|
|
324
|
+
"""Return the canonical streaming chunks URL, or ``None`` when
|
|
325
|
+
the org id is not configured."""
|
|
326
|
+
if not self.config.org_id:
|
|
327
|
+
return None
|
|
328
|
+
base = self.config.base_url.rstrip("/")
|
|
329
|
+
return f"{base}/orgs/{self.config.org_id}/ingest/chunks"
|
|
330
|
+
|
|
331
|
+
async def _send_chunks(self) -> None:
|
|
332
|
+
"""POST buffered streaming chunks to ``/ingest/chunks``.
|
|
333
|
+
|
|
334
|
+
Best-effort: a transport failure logs a warning and counts
|
|
335
|
+
the chunks as failed, but does not raise (the user's stream
|
|
336
|
+
already returned to them).
|
|
337
|
+
"""
|
|
338
|
+
assert self._http is not None
|
|
339
|
+
if not self._chunk_buffer:
|
|
340
|
+
return
|
|
341
|
+
url = self.ingest_chunks_url()
|
|
342
|
+
if url is None:
|
|
343
|
+
# No org id — drop silently.
|
|
344
|
+
self._chunk_buffer.clear()
|
|
345
|
+
return
|
|
346
|
+
chunks = self._chunk_buffer
|
|
347
|
+
self._chunk_buffer = []
|
|
348
|
+
try:
|
|
349
|
+
body = json.dumps({"chunks": chunks}, default=str).encode("utf-8")
|
|
350
|
+
headers = self.config.auth_headers()
|
|
351
|
+
headers.setdefault("Content-Type", "application/json")
|
|
352
|
+
t0 = time.perf_counter()
|
|
353
|
+
resp = await self._http.post(url, content=body, headers=headers)
|
|
354
|
+
dt = (time.perf_counter() - t0) * 1000.0
|
|
355
|
+
self._latency_window.append(dt)
|
|
356
|
+
if 200 <= resp.status_code < 300:
|
|
357
|
+
self._sent_total += len(chunks)
|
|
358
|
+
else:
|
|
359
|
+
self._failed_total += len(chunks)
|
|
360
|
+
logger.warning(
|
|
361
|
+
"eden ingest chunks returned %s: %s",
|
|
362
|
+
resp.status_code,
|
|
363
|
+
resp.text[:200],
|
|
364
|
+
)
|
|
365
|
+
except Exception as exc: # noqa: BLE001
|
|
366
|
+
self._failed_total += len(chunks)
|
|
367
|
+
logger.warning("eden ingest chunks failed: %s", exc)
|
|
368
|
+
|
|
369
|
+
async def _send_batch(self, batch: list[TraceEvent]) -> None:
|
|
370
|
+
assert self._http is not None
|
|
371
|
+
# Group by source_format so each ingestion route gets its
|
|
372
|
+
# own request. Routes are 1:1 with decoder names.
|
|
373
|
+
groups: dict[str, list[dict[str, Any]]] = {}
|
|
374
|
+
for ev in batch:
|
|
375
|
+
groups.setdefault(ev.source_format, []).append(ev.payload)
|
|
376
|
+
for source_format, payloads in groups.items():
|
|
377
|
+
try:
|
|
378
|
+
url = self.config.ingest_url(source_format)
|
|
379
|
+
headers = self.config.auth_headers()
|
|
380
|
+
headers.setdefault("Content-Type", "application/json")
|
|
381
|
+
if self._BATCH_AS_ARRAY:
|
|
382
|
+
body = json.dumps(payloads, default=str).encode("utf-8")
|
|
383
|
+
count = len(payloads)
|
|
384
|
+
else:
|
|
385
|
+
body = json.dumps(payloads[0], default=str).encode("utf-8")
|
|
386
|
+
# Single-event mode: we send one event per request,
|
|
387
|
+
# so each request only counts one event regardless of
|
|
388
|
+
# the size of the drained batch.
|
|
389
|
+
count = 1
|
|
390
|
+
t0 = time.perf_counter()
|
|
391
|
+
resp = await self._http.post(
|
|
392
|
+
url, content=body, headers=headers
|
|
393
|
+
)
|
|
394
|
+
dt = (time.perf_counter() - t0) * 1000.0
|
|
395
|
+
self._latency_window.append(dt)
|
|
396
|
+
# 2xx = accepted; 4xx = rejected by the server (auth,
|
|
397
|
+
# validation) and should be counted as failed so operators
|
|
398
|
+
# notice a misconfigured org_id / api_key. 5xx is a
|
|
399
|
+
# server error; either way the event did not land.
|
|
400
|
+
if 200 <= resp.status_code < 300:
|
|
401
|
+
self._sent_total += count
|
|
402
|
+
else:
|
|
403
|
+
self._failed_total += count
|
|
404
|
+
logger.warning(
|
|
405
|
+
"eden ingest %s returned %s: %s",
|
|
406
|
+
source_format,
|
|
407
|
+
resp.status_code,
|
|
408
|
+
resp.text[:200],
|
|
409
|
+
)
|
|
410
|
+
except Exception as exc: # noqa: BLE001
|
|
411
|
+
self._failed_total += count
|
|
412
|
+
logger.warning("eden ingest %s failed: %s", source_format, exc)
|
|
413
|
+
|
|
414
|
+
# ── Stats / introspection ─────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
@property
|
|
417
|
+
def sent(self) -> int:
|
|
418
|
+
return self._sent_total
|
|
419
|
+
|
|
420
|
+
@property
|
|
421
|
+
def dropped(self) -> int:
|
|
422
|
+
return self._dropped_total
|
|
423
|
+
|
|
424
|
+
@property
|
|
425
|
+
def failed(self) -> int:
|
|
426
|
+
return self._failed_total
|
|
427
|
+
|
|
428
|
+
def p99_latency_ms(self) -> float:
|
|
429
|
+
"""99th percentile of recent send latencies in milliseconds."""
|
|
430
|
+
if not self._latency_window:
|
|
431
|
+
return 0.0
|
|
432
|
+
ordered = sorted(self._latency_window)
|
|
433
|
+
idx = max(0, int(round(0.99 * (len(ordered) - 1))))
|
|
434
|
+
return ordered[idx]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ── Singleton ────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
_lock = threading.Lock()
|
|
440
|
+
_default: EdenClient | None = None
|
|
441
|
+
_atexit_registered = False
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def get_default_client() -> EdenClient:
|
|
445
|
+
"""Return the process-wide :class:`EdenClient` singleton.
|
|
446
|
+
|
|
447
|
+
The first call also starts the sender thread and registers an
|
|
448
|
+
:mod:`atexit` hook for a best-effort final flush.
|
|
449
|
+
"""
|
|
450
|
+
global _default, _atexit_registered
|
|
451
|
+
with _lock:
|
|
452
|
+
if _default is None:
|
|
453
|
+
_default = EdenClient()
|
|
454
|
+
_default.start()
|
|
455
|
+
if not _atexit_registered:
|
|
456
|
+
atexit.register(_atexit_shutdown)
|
|
457
|
+
_atexit_registered = True
|
|
458
|
+
return _default
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _atexit_shutdown() -> None:
|
|
462
|
+
global _default
|
|
463
|
+
if _default is None:
|
|
464
|
+
return
|
|
465
|
+
try:
|
|
466
|
+
_default.flush(timeout=2.0)
|
|
467
|
+
finally:
|
|
468
|
+
_default.shutdown(timeout=2.0)
|
|
469
|
+
_default = None
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def reset_default_client() -> None:
|
|
473
|
+
"""Test helper: drop the singleton (synchronously)."""
|
|
474
|
+
global _default
|
|
475
|
+
with _lock:
|
|
476
|
+
if _default is not None:
|
|
477
|
+
_default.shutdown(timeout=2.0)
|
|
478
|
+
_default = None
|