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 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