Skip to content

Commit 309d10b

Browse files
Add public force_flush() to OTel and Langfuse observers
Downstream users reach into observer._provider directly to drain the BatchSpanProcessor's outbound buffer in fast-teardown harnesses (serverless functions, CLI one-shots, FastAPI TestClient teardown). Expose force_flush(timeout_ms) as a stable surface instead. OTelObserver.force_flush wraps self._provider.force_flush. Distinct from CompiledGraph.drain, which covers the engine's observer-event queue; force_flush covers the SpanProcessor export buffer. LangfuseObserver.force_flush delegates to LangfuseClient.force_flush, a new Protocol method. InMemoryLangfuseClient implements it as a no-op (no buffer); LangfuseSDKAdapter delegates to the SDK's flush().
1 parent 2b4bc1b commit 309d10b

6 files changed

Lines changed: 107 additions & 2 deletions

File tree

src/openarmature/observability/langfuse/adapter.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,16 @@ def generation(
328328
)
329329
return _SpanHandle(obs)
330330

331+
def force_flush(self, timeout_ms: int = 30_000) -> bool:
332+
# The v4 SDK's flush() returns ``None`` synchronously but
333+
# internally waits for the OTel BatchSpanProcessor to drain.
334+
# No timeout parameter is exposed on the SDK call, so we
335+
# ignore ``timeout_ms`` here and rely on the SDK's own
336+
# default. Returns True if flush() completes without raising.
337+
del timeout_ms
338+
self._client.flush()
339+
return True
340+
331341
def _start_observation(
332342
self,
333343
*,

src/openarmature/observability/langfuse/client.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,19 @@ def generation(
217217
prompt: Any = None,
218218
) -> LangfuseGenerationHandle: ...
219219

220+
def force_flush(self, timeout_ms: int = 30_000) -> bool:
221+
"""Flush any pending outbound buffer in the underlying sink.
222+
223+
Returns ``True`` when the flush completes within the deadline,
224+
``False`` otherwise. The semantics mirror OTel's
225+
``TracerProvider.force_flush``: cover the export-buffer half
226+
of fast-teardown races. The bundled
227+
:class:`InMemoryLangfuseClient` has no buffer and returns
228+
``True`` immediately; SDK adapters delegate to the underlying
229+
client's flush.
230+
"""
231+
...
232+
220233

221234
# Concrete in-memory implementation ---------------------------------------
222235
# Used by tests and the conformance harness. Stores everything the
@@ -407,6 +420,13 @@ def generation(
407420
trace.observations.append(observation)
408421
return _InMemoryGenerationHandle(observation=observation)
409422

423+
def force_flush(self, timeout_ms: int = 30_000) -> bool:
424+
# In-memory recorder has no outbound buffer; every observation
425+
# is captured synchronously on its create call. The ``timeout_ms``
426+
# parameter is accepted for Protocol compatibility but unused.
427+
del timeout_ms
428+
return True
429+
410430
def _get_trace(self, trace_id: str) -> LangfuseTrace:
411431
trace = self.traces.get(trace_id)
412432
if trace is None:

src/openarmature/observability/langfuse/observer.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,24 @@ def close_invocation(self, invocation_id: str) -> None:
741741
):
742742
self._close_subgraph_observation(inv_state, prefix)
743743

744+
def force_flush(self, timeout_ms: int = 30_000) -> bool:
745+
"""Flush pending observations through the underlying client.
746+
747+
Returns ``True`` when the client's flush completes within the
748+
deadline, ``False`` otherwise. Mirrors the OTel observer's
749+
``force_flush`` surface — distinct from
750+
:meth:`~openarmature.graph.compiled.CompiledGraph.drain` (which
751+
covers the engine's observer-event queue): this method covers
752+
the outbound buffer of the Langfuse client (the SDK's OTel
753+
BatchSpanProcessor when wrapped via :class:`LangfuseSDKAdapter`).
754+
755+
Useful in fast-teardown harnesses (CLI one-shots, serverless
756+
functions, ASGI lifespan shutdown) where the SDK's
757+
BatchSpanProcessor export thread would otherwise be cut off
758+
before its buffer drains.
759+
"""
760+
return self.client.force_flush(timeout_ms=timeout_ms)
761+
744762
def shutdown(self) -> None:
745763
"""Drain every in-flight invocation. Use for long-lived
746764
observers shared across requests; CLI / one-shot processes

src/openarmature/observability/otel/observer.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,6 +1445,32 @@ def _close_invocation_span(self, invocation_id: str) -> None:
14451445
self._run_enrichers(open_span.span, None)
14461446
open_span.span.end()
14471447

1448+
def force_flush(self, timeout_ms: int = 30_000) -> bool:
1449+
"""Flush any pending spans through every registered span processor.
1450+
1451+
Returns ``True`` when all processors finish flushing within the
1452+
deadline, ``False`` otherwise. Wraps the underlying OTel
1453+
:class:`TracerProvider`'s ``force_flush`` so callers don't have
1454+
to reach into the private ``_provider`` attribute.
1455+
1456+
**When to call.** Distinct from :meth:`drain` on
1457+
:class:`~openarmature.graph.compiled.CompiledGraph` (which covers
1458+
the engine's observer-event queue): this method covers the
1459+
outbound span-export buffer of each registered
1460+
:class:`SpanProcessor`. Under fast or unusual teardown
1461+
orderings (FastAPI ``TestClient`` teardown, CLI one-shots,
1462+
serverless functions) the :class:`BatchSpanProcessor`'s export
1463+
thread can be cut off before its buffer drains; calling
1464+
``force_flush()`` from a ``finally`` block right before process
1465+
exit is the canonical hardening.
1466+
1467+
The default 30 s ``timeout_ms`` matches the OTel SDK's own
1468+
default. Pass a smaller value when running under a hard
1469+
deadline (a serverless function's max execution time, an
1470+
ASGI lifespan timeout).
1471+
"""
1472+
return self._provider.force_flush(timeout_millis=timeout_ms)
1473+
14481474
def shutdown(self) -> None:
14491475
"""Close any still-open spans across all in-flight invocations
14501476
and shut down the underlying provider. Each per-invocation
@@ -1459,8 +1485,7 @@ def shutdown(self) -> None:
14591485
thread finishes), the flush may not complete in time and spans
14601486
can appear dropped. Workarounds:
14611487
1462-
- Call ``observer._provider.force_flush(timeout_millis=…)``
1463-
explicitly before this method.
1488+
- Call :meth:`force_flush` explicitly before this method.
14641489
- Use :class:`SimpleSpanProcessor` instead of
14651490
:class:`BatchSpanProcessor` in tests; it exports synchronously
14661491
and is unaffected by teardown timing.

tests/unit/test_observability_langfuse.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,25 @@ def test_in_memory_recorder_observation_id_is_unique_per_recorder() -> None:
108108
assert a.id != b.id
109109

110110

111+
def test_observer_force_flush_delegates_to_client() -> None:
112+
# LangfuseObserver.force_flush() calls into the client; the
113+
# InMemoryLangfuseClient's force_flush is a no-op that returns
114+
# True, so this just verifies the delegation wires correctly.
115+
client = InMemoryLangfuseClient()
116+
observer = LangfuseObserver(client=client)
117+
assert observer.force_flush() is True
118+
assert observer.force_flush(timeout_ms=1000) is True
119+
120+
121+
def test_in_memory_recorder_force_flush_is_no_op() -> None:
122+
# In-memory recorder has no outbound buffer; force_flush returns
123+
# True immediately. The timeout_ms parameter is accepted for
124+
# Protocol compatibility but unused.
125+
client = InMemoryLangfuseClient()
126+
assert client.force_flush() is True
127+
assert client.force_flush(timeout_ms=5000) is True
128+
129+
111130
def test_in_memory_recorder_children_of_walks_parent_links() -> None:
112131
client = InMemoryLangfuseClient()
113132
client.trace(id="t1")

tests/unit/test_observability_otel.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,3 +1382,16 @@ async def ask_llm(_s: _S) -> dict[str, str]:
13821382
assert attrs.get("openarmature.prompt.label") == "production"
13831383
assert attrs.get("openarmature.prompt.template_hash") == "sha256:tpl"
13841384
assert attrs.get("openarmature.prompt.rendered_hash") == "sha256:rendered"
1385+
1386+
1387+
def test_force_flush_delegates_to_provider() -> None:
1388+
# Public force_flush wraps TracerProvider.force_flush so downstream
1389+
# users don't reach into observer._provider to drain the
1390+
# BatchSpanProcessor buffer in fast-teardown harnesses.
1391+
exporter = InMemorySpanExporter()
1392+
observer = OTelObserver(span_processor=SimpleSpanProcessor(exporter))
1393+
try:
1394+
assert observer.force_flush() is True
1395+
assert observer.force_flush(timeout_ms=1000) is True
1396+
finally:
1397+
observer.shutdown()

0 commit comments

Comments
 (0)