fix(observability): don't spawn a root trace per cascade embedding
The embedding-observation change wrapped every _embed_chunk call in a span. Cascade-time indexing embeds run outside any request trace, so each chunk started its OWN root trace — a per-chunk trace explosion (13 orphan everos.embedding traces per add/flush), detached from session/user and contrary to the "cascade is not instrumented" decision. memory_span gains nested_only: open a span only when one is already active. Embedding uses it, so search/flush embeds still nest under their recall/extract span, while cascade embeds no-op (no trace) — restoring the cascade-untraced boundary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f20a123805
commit
25964ccbb3
|
|
@ -90,8 +90,11 @@ class OpenAIEmbeddingProvider:
|
|||
"""One ``/embeddings`` call, semaphore-guarded."""
|
||||
# Wrap in an EMBEDDING-typed span so token usage lands on an embedding
|
||||
# observation (which Langfuse can price) rather than the enclosing
|
||||
# retriever span. No-op when tracing is off.
|
||||
with memory_span("everos.embedding", observation_type="embedding"):
|
||||
# retriever span. nested_only: skip when there is no active trace (e.g.
|
||||
# cascade-time indexing) so we don't spawn one root trace per chunk.
|
||||
with memory_span(
|
||||
"everos.embedding", observation_type="embedding", nested_only=True
|
||||
):
|
||||
async with self._semaphore:
|
||||
try:
|
||||
response = await self._client.embeddings.create(
|
||||
|
|
|
|||
|
|
@ -85,6 +85,13 @@ except ImportError: # pragma: no cover - only without the [otel] extra
|
|||
_OTEL_AVAILABLE = False
|
||||
|
||||
|
||||
def _has_active_span() -> bool:
|
||||
"""True when a valid span context is currently active (a parent exists)."""
|
||||
if not _OTEL_AVAILABLE:
|
||||
return False
|
||||
return _otel_trace.get_current_span().get_span_context().is_valid
|
||||
|
||||
|
||||
@contextmanager
|
||||
def memory_span(
|
||||
name: str,
|
||||
|
|
@ -94,6 +101,7 @@ def memory_span(
|
|||
user_id: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
tags: Sequence[str] = DEFAULT_TAGS,
|
||||
nested_only: bool = False,
|
||||
) -> Iterator[Any]:
|
||||
"""Open a span named ``name`` and stamp the langfuse.* attributes.
|
||||
|
||||
|
|
@ -105,7 +113,14 @@ def memory_span(
|
|||
metadata: Flat mapping → ``langfuse.trace.metadata.<key>``; None
|
||||
values are dropped rather than emitted as the string "None".
|
||||
tags: ``langfuse.trace.tags`` list.
|
||||
nested_only: When True, only open a span if one is already active.
|
||||
Calls that run outside any request trace (e.g. cascade-time
|
||||
embedding during indexing) would otherwise each start a NEW root
|
||||
trace — a per-chunk trace explosion — so they no-op instead.
|
||||
"""
|
||||
if nested_only and not _has_active_span():
|
||||
yield _otel_trace.get_current_span() if _OTEL_AVAILABLE else None
|
||||
return
|
||||
tracer = get_tracer("everos")
|
||||
with tracer.start_as_current_span(name) as span:
|
||||
span.set_attribute(LF_OBSERVATION_TYPE, observation_type)
|
||||
|
|
|
|||
|
|
@ -97,6 +97,34 @@ def test_set_generation_usage_accumulates_across_calls(
|
|||
assert attrs["gen_ai.usage.output_tokens"] == 12
|
||||
|
||||
|
||||
def test_nested_only_span_skips_when_no_active_parent(
|
||||
captured: InMemorySpanExporter,
|
||||
) -> None:
|
||||
# A nested_only span with no active parent must NOT start a new root trace
|
||||
# (this is what prevented cascade-time embeddings from exploding into one
|
||||
# orphan trace per chunk).
|
||||
with memory_span(
|
||||
"everos.embedding", observation_type="embedding", nested_only=True
|
||||
):
|
||||
pass
|
||||
force_flush()
|
||||
assert captured.get_finished_spans() == ()
|
||||
|
||||
|
||||
def test_nested_only_span_opens_under_active_parent(
|
||||
captured: InMemorySpanExporter,
|
||||
) -> None:
|
||||
with (
|
||||
memory_span("everos.memory.search", observation_type="retriever"),
|
||||
memory_span("everos.embedding", observation_type="embedding", nested_only=True),
|
||||
):
|
||||
pass
|
||||
force_flush()
|
||||
names = {s.name for s in captured.get_finished_spans()}
|
||||
assert "everos.embedding" in names
|
||||
assert "everos.memory.search" in names
|
||||
|
||||
|
||||
def test_set_generation_usage_outside_span_is_noop() -> None:
|
||||
# No active span → must not raise (and nothing to record).
|
||||
set_generation_usage(model="x", input_tokens=1, output_tokens=2)
|
||||
|
|
|
|||
Loading…
Reference in New Issue