Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions components/PropagationRestrictionsCallout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,11 @@ export function PropagationRestrictionsCallout({
)}
<li>
Call <strong>early in your trace</strong> to ensure all observations
are covered. This way you make sure that all Metrics in Langfuse are
accurate.
are covered. Only the <strong>currently active observation</strong>{" "}
and observations <strong>started while the context is active</strong>{" "}
receive the attributes — observations started earlier are not
retroactively updated. This way you make sure that all Metrics in
Langfuse are accurate.
</li>
<li>Invalid values are dropped with a warning</li>
</ul>
Expand Down
103 changes: 102 additions & 1 deletion content/docs/api-and-data-platform/features/query-via-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

<Callout type="info">

New data is typically available for querying within 15-30 seconds of ingestion, though processing times may vary at times. Please visit [status.langfuse.com](https://status.langfuse.com) if you encounter any issues.
Ingestion is **asynchronous**: SDK `flush()` only guarantees delivery to the API, not read visibility. New data is typically available for querying within 15-30 seconds of ingestion, though processing times may vary — under heavy or parallel load, visibility can lag longer (there is no fixed SLA). Reading a trace immediately after writing it may return a 404. See [Handling ingestion lag](#ingestion-lag) for the recommended retry pattern, and visit [status.langfuse.com](https://status.langfuse.com) if you encounter persistent issues.

</Callout>

Expand Down Expand Up @@ -72,6 +72,40 @@

Use `trace_id` to retrieve the observations that belong to a single trace. Use `parent_observation_id` in the response to reconstruct an observation tree when needed.

### Traces: `list` returns lightweight views [#traces-list-vs-get]

`langfuse.api.trace.list(...)` returns **lightweight trace views**: the `observations` and `scores` fields contain only **IDs (strings)**, not full objects. Trace-level aggregates (`total_cost`, `latency`) are included, but per-observation usage and cost details are not. To get full observation objects, call `langfuse.api.trace.get(trace_id)` per trace (or prefer `observations.get_many(trace_id=...)` above).

List-then-get pagination example for usage/cost aggregation:

Check warning on line 79 in content/docs/api-and-data-platform/features/query-via-sdk.mdx

View check run for this annotation

Claude / Claude Code Review

trace.list lightweight-view docs added only to Python tab, missing from JS/TS tab

The new 'Traces: list returns lightweight views' section (documenting that trace.list's observations/scores fields are IDs-only, plus a list-then-get pagination example) was added only to the Python SDK tab in query-via-sdk.mdx; the JS/TS SDK tab jumps straight from Observations to Metrics with no equivalent guidance, even though this is a property of the shared Public API that applies identically to `langfuse.api.trace.list` in `@langfuse/client`. Consider mirroring the section (with a TS list-
Comment on lines +75 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new 'Traces: list returns lightweight views' section (documenting that trace.list's observations/scores fields are IDs-only, plus a list-then-get pagination example) was added only to the Python SDK tab in query-via-sdk.mdx; the JS/TS SDK tab jumps straight from Observations to Metrics with no equivalent guidance, even though this is a property of the shared Public API that applies identically to langfuse.api.trace.list in @langfuse/client. Consider mirroring the section (with a TS list-then-get example) into the JS/TS tab so those readers get the same warning.

Extended reasoning...

The bug: this PR adds a new ### Traces: list returns lightweight views [#traces-list-vs-get] section to content/docs/api-and-data-platform/features/query-via-sdk.mdx, but it is inserted only inside <Tab title="Python SDK">. The parallel <Tab title="JS/TS SDK"> block goes directly from ### Observations to ### Metrics (then ### Other resources) with no equivalent section and no TS list-then-get example.

Why this matters: the behavior being documented — that trace.list returns lightweight trace views where observations/scores are ID strings rather than full objects, while trace-level aggregates (total_cost, latency) are still included — is a property of the shared Public API. The api namespace on both SDKs is generated from the same OpenAPI spec, so langfuse.api.trace.list(...) in @langfuse/client exhibits the exact same lightweight-view behavior as langfuse.api.trace.list(...) in the Python SDK. There's nothing Python-specific about the gotcha.

Proof of the gap: walk the JS/TS tab as a reader would. Starting at ### Observations (JS/TS), the next heading is ### Metrics, then ### Other resources. A JS/TS developer who wants to aggregate per-observation usage/cost by paginating langfuse.api.trace.list(...) gets no warning that traces.data[i].observations is a list of ID strings, not objects with usageDetails/cost. They would write code assuming full objects, get IDs instead, and hit exactly the confusing failure mode this PR's own description says it set out to close ("agent-behavior testing showed cause real debugging sessions").

Why existing code doesn't prevent it: this is docs-only, so there's no compiler/lint check enforcing tab parity. The PR's own precedent shows parity was achievable and intended elsewhere — the new 'Handling ingestion lag' section further down the same file explicitly provides both a Python and a JS/TS tab (with a TS getTraceWithRetry example paralleling the Python get_trace_with_retry). That makes the omission in the trace.list section read as an oversight rather than deliberate scoping to Python only.

Impact: purely a documentation completeness gap — nothing renders incorrectly, no build/link check fails, and the Python content that does exist is accurate. But it undercuts the stated goal of the PR for exactly the audience (JS/TS SDK users) who would otherwise hit the same footgun.

Suggested fix: add a mirrored ### Traces: list returns lightweight views section to the JS/TS tab, with a langfuse.api.trace.list-based list-then-get TS example analogous to the Python one (fetching traces.data, noting observations/scores are ID arrays, and calling langfuse.api.trace.get(traceId) for full objects), plus the same pointer to observations.getMany/Metrics API v2 for scale.


```python
from collections import defaultdict
from datetime import datetime, timedelta, timezone

usage_by_model = defaultdict(int)
from_timestamp = datetime.now(timezone.utc) - timedelta(days=1)
page = 1

while True:
traces = langfuse.api.trace.list(
from_timestamp=from_timestamp,
page=page,
limit=50,
)
for trace_view in traces.data:
# trace_view.observations is a list of ID strings only
full_trace = langfuse.api.trace.get(trace_view.id)
for obs in full_trace.observations: # full objects incl. usage/cost
if obs.usage_details:
model = obs.model or "unknown"
usage_by_model[model] += obs.usage_details.get("total", 0)
if page >= traces.meta.total_pages:
break
page += 1
```

If you only need trace-level totals, `trace.list` alone is enough (`trace_view.total_cost`, `trace_view.latency`) — no per-trace `get` required. For large-scale aggregation, prefer the [Metrics API v2](/docs/metrics/features/metrics-api#v2) over paginating row-level data.
Comment on lines +75 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Lightweight-view behaviour only documented for Python tab

The new "Traces: list returns lightweight views" section — including the pagination example and the note about total_cost/latency versus per-observation fields — lives entirely inside the Python SDK tab. The JS/TS SDK tab does not mention that trace.list returns ID-only observations/scores fields, even though the same REST endpoint underlies both clients. Users working with @langfuse/client who hit this behaviour have no documentation pointer to guide them.

Prompt To Fix With AI
This is a comment left during a code review.
Path: content/docs/api-and-data-platform/features/query-via-sdk.mdx
Line: 75-107

Comment:
**Lightweight-view behaviour only documented for Python tab**

The new "Traces: `list` returns lightweight views" section — including the pagination example and the note about `total_cost`/`latency` versus per-observation fields — lives entirely inside the Python SDK tab. The JS/TS SDK tab does not mention that `trace.list` returns ID-only `observations`/`scores` fields, even though the same REST endpoint underlies both clients. Users working with `@langfuse/client` who hit this behaviour have no documentation pointer to guide them.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


### Metrics

For the full query schema, supported dimensions, filters, and examples, see the [Metrics API v2 documentation](/docs/metrics/features/metrics-api#v2).
Expand Down Expand Up @@ -217,6 +251,73 @@
</Tab>
</LangTabs>

## Handling ingestion lag (get-after-write) [#ingestion-lag]

Trace ingestion is asynchronous. `flush()` in the SDKs guarantees that data was **delivered** to the Langfuse API, not that it is **readable** yet. Reading right after writing — e.g. `trace.get(trace_id)` after a flush, fetching a score you just created, or fetching dataset run items right after an experiment — may raise a 404 (`langfuse.api.NotFoundError` in Python) or return incomplete data until processing completes. Typical visibility is within 15-30 seconds; under heavy or parallel load it can take longer, and there is no fixed SLA.

Instead of a fixed `time.sleep()`, use bounded retries with backoff:

<LangTabs items={["Python SDK", "JS/TS SDK"]}>
<Tab title="Python SDK">

```python
import time

from langfuse import get_client
from langfuse.api import NotFoundError

langfuse = get_client()


def get_trace_with_retry(trace_id: str, *, timeout: float = 60.0):
"""Fetch a trace, retrying on 404 until ingestion completes."""
deadline = time.monotonic() + timeout
delay = 1.0
while True:
try:
return langfuse.api.trace.get(trace_id)
except NotFoundError:
if time.monotonic() >= deadline:
raise
time.sleep(delay)
delay = min(delay * 2, 10.0) # exponential backoff, capped


langfuse.flush() # ensure delivery before polling
trace = get_trace_with_retry(trace_id, timeout=60)
Comment on lines +286 to +287

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 trace_id undefined in usage example

The concluding call-site snippet uses trace_id as if it were already in scope, but the variable is never assigned anywhere in the snippet. Readers who copy these two lines verbatim will get a NameError at runtime. A short stub assignment or a # replace with your trace ID comment before the call would make the snippet self-contained.

Prompt To Fix With AI
This is a comment left during a code review.
Path: content/docs/api-and-data-platform/features/query-via-sdk.mdx
Line: 286-287

Comment:
**`trace_id` undefined in usage example**

The concluding call-site snippet uses `trace_id` as if it were already in scope, but the variable is never assigned anywhere in the snippet. Readers who copy these two lines verbatim will get a `NameError` at runtime. A short stub assignment or a `# replace with your trace ID` comment before the call would make the snippet self-contained.

How can I resolve this? If you propose a fix, please make it concise.

```

Note: a successful `trace.get` means the trace record exists — observations and scores of that trace may still be arriving. If you depend on completeness (e.g. counting observations or summing costs), additionally check that the expected observations are present before proceeding.

</Tab>
<Tab title="JS/TS SDK">

```ts
import { LangfuseClient } from "@langfuse/client";

const langfuse = new LangfuseClient();

async function getTraceWithRetry(traceId: string, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
let delay = 1_000;
while (true) {
try {
return await langfuse.api.trace.get(traceId);
} catch (err: any) {
const is404 = err?.statusCode === 404;
if (!is404 || Date.now() >= deadline) throw err;
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 2, 10_000); // exponential backoff, capped
}
}
}
```

</Tab>
</LangTabs>

The same pattern applies to any read-after-write on ingested entities: traces, observations, scores, and dataset run items. Entities created via synchronous endpoints (prompts, datasets, dataset items) are readable immediately and do not need retries.

## Related Resources

- To move existing trace or observation reads to v2, see [Observations API v2](/docs/api-and-data-platform/features/observations-api#v2).
Expand Down
6 changes: 6 additions & 0 deletions content/docs/evaluation/evaluation-methods/scores-via-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ You can add scores via the Langfuse SDKs or API. Scores can take one of four dat

If a score is ingested manually using a `trace_id` to link the score to a trace, it is not necessary to wait until the trace has been created. The score will show up in the scores table and will be linked to the trace once the trace with the same `trace_id` is created.

<Callout type="info">

Score ingestion is asynchronous. A score created via SDK is queued, sent on the next flush, and processed server-side — it is typically queryable via the API/UI within 15-30 seconds, potentially longer under load. If you fetch a score right after creating it, retry with bounded backoff. See [Handling ingestion lag](/docs/api-and-data-platform/features/query-via-sdk#ingestion-lag).

</Callout>

Here are examples by `Score` data types.

For trace and observation scores, `trace_id`/`traceId` is required and `observation_id`/`observationId` is optional. If you attach a score to an observation, always provide both the observation ID and the corresponding trace ID.
Expand Down
9 changes: 9 additions & 0 deletions content/docs/observability/features/sessions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@

<PropagationRestrictionsCallout attributes={["sessionId"]} />

## Scope and ordering of `sessionId` propagation [#scope-and-ordering]

Propagation is forward-only and follows the active execution context:

- **Covered**: the observation that is currently active when you enter the propagation context, and every observation started while the context is active (any nesting depth, any type).
- **Not covered**: observations started before entering the context. In the Python SDK, a detached root created with `start_observation()` (which does not become the active span) stays unstamped if you enter `propagate_attributes` afterwards — start it inside the context, or use `start_as_current_observation` for the root and propagate within it.

Where the value lands: each covered observation carries `session.id` in the exported OTel span data, and after ingestion `sessionId` surfaces as a **trace-level field**. When verifying programmatically, read it from the trace (`langfuse.api.trace.get(trace_id).session_id`) — observation objects returned by the public API do not expose a `session_id` field, so asserting on fetched child observations will always fail.

Check failure on line 198 in content/docs/observability/features/sessions.mdx

View check run for this annotation

Claude / Claude Code Review

Claim that observations never expose userId/sessionId contradicts documented v2 Observations API

The new callouts in sessions.mdx, users.mdx, and instrumentation.mdx claim observation objects from "the public API" never expose `sessionId`/`userId`, but this contradicts `observations-api.mdx`: the v2 Observations API's `basic` field group — returned by default and requested by the `observations.get_many(..., fields="core,basic,usage")` call recommended one paragraph above — explicitly returns `userId` and `sessionId` per observation (its sample response shows `"sessionId": "support-chat-sess

Check warning on line 198 in content/docs/observability/features/sessions.mdx

View check run for this annotation

Claude / Claude Code Review

Duplicated 'Scope and ordering' propagation text across 3 pages

The new 'Scope and ordering' sections in sessions.mdx and users.mdx are near-verbatim duplicates (only sessionId/userId swapped), and instrumentation.mdx adds a third paragraph restating the same rules — duplicating the exact pattern that PropagationRestrictionsCallout.tsx was built to avoid across these same three pages. Consider consolidating into that shared component (e.g. via a scope/ordering prop) so future corrections to this behavior only need to be made once.
Comment on lines +194 to +198

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new callouts in sessions.mdx, users.mdx, and instrumentation.mdx claim observation objects from "the public API" never expose sessionId/userId, but this contradicts observations-api.mdx: the v2 Observations API's basic field group — returned by default and requested by the observations.get_many(..., fields="core,basic,usage") call recommended one paragraph above — explicitly returns userId and sessionId per observation (its sample response shows "sessionId": "support-chat-session"). The claim is only true for the legacy trace.get(trace_id).observations nested view and should be scoped accordingly in all three files rather than stated as a blanket property of "the public API."

Extended reasoning...

The bug: All three edited pages add the same unqualified claim — e.g. sessions.mdx: observation objects returned by the public API do not expose a session_id field, so asserting on fetched child observations will always fail. The equivalent sentence appears in users.mdx (user_id) and instrumentation.mdx (these fields = both). As written, this reads as a blanket statement about every public-API path for fetching observations.

Why it's wrong: content/docs/api-and-data-platform/features/observations-api.mdx (unchanged by this PR, so it's the existing source of truth) documents the v2 Observations API's field-group system. Its field-group table lists the basic group as including userId, sessionId per observation, and states that core + basic are returned by default if no fields param is given. The sample JSON response in that same doc shows a populated, per-observation "sessionId": "support-chat-session" (and "userId": ""). So the v2 endpoint plainly does expose these fields on observation objects.

The triggering code path: This isn't a hypothetical edge case — it's the exact path the docs themselves recommend. One paragraph above the disputed callout, query-via-sdk.mdx (edited by this same PR) shows:

observations = langfuse.api.observations.get_many(
    trace_id="abcdef1234",
    type="GENERATION",
    limit=100,
    fields="core,basic,usage"
)

That fields="core,basic,usage" call requests exactly the basic group that carries userId/sessionId. A reader who follows this recommendation, then reads the new callout a page later, is told they "will always fail" to find session_id/user_id on the very objects that just returned it.

Step-by-step proof:

  1. Reader calls langfuse.api.observations.get_many(trace_id=..., fields="core,basic,usage") as instructed in query-via-sdk.mdx.
  2. Per observations-api.mdx's field-group table, the basic group returns userId and sessionId on each observation in the response.
  3. Per observations-api.mdx's sample response, a real observation payload contains "userId": "" and "sessionId": "support-chat-session" — populated values, not nulls/omissions.
  4. Reader then goes to sessions.mdx (or users.mdx / instrumentation.mdx) and reads: "observation objects returned by the public API do not expose a session_id field, so asserting on fetched child observations will always fail."
  5. This directly contradicts what the reader just observed in step 3 — the field is right there, non-empty, on the response object.

Why nothing else catches this: observations-api.mdx wasn't touched by this PR, so there's no cross-reference or lint that would flag the new prose in sessions.mdx/users.mdx/instrumentation.mdx as inconsistent with it. The claim is likely true for the older, distinct trace.get(trace_id).observations nested view (a different, legacy object shape), which is presumably what the PR author had in mind — but the text says "the public API" without that qualification, so it reads as universal.

Impact: Actively wrong debugging guidance. A user trying to verify that sessionId/userId propagation worked, following the docs' own recommended v2 endpoint, would be told by this new text that verification is impossible on observations and to only trust the trace-level field — when in fact the observation-level field is directly readable and would have caught propagation bugs the callout is trying to help with.

Fix: In each of the three locations, scope the claim to the specific object it's actually true for — e.g. "the legacy trace.get(trace_id).observations nested objects do not expose session_id/user_id" — rather than "observation objects returned by the public API." Alternatively, drop the blanket claim and simply recommend verifying via trace.get(trace_id).session_id as the more convenient/canonical check, without asserting the observation-level field categorically doesn't exist.

Addressing the one refutation on file: A refutation for the instrumentation.mdx instance (bug_005) argued it's a duplicate of the sessions.mdx instance (bug_001) and should be dropped as redundant. That refutation is about the bug tracker's dedup mechanics, not about whether the underlying claim is correct — it doesn't dispute the factual error, it just argues one location's report is redundant with another's. Since this synthesized report (merged_bug_001) explicitly covers all three occurrences (sessions.mdx ~line 198, users.mdx ~line 187, instrumentation.mdx ~line 541) as a single finding, that concern is resolved: all three locations are called out here rather than filed as separate near-duplicate reports.

Comment on lines +191 to +198

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new 'Scope and ordering' sections in sessions.mdx and users.mdx are near-verbatim duplicates (only sessionId/userId swapped), and instrumentation.mdx adds a third paragraph restating the same rules — duplicating the exact pattern that PropagationRestrictionsCallout.tsx was built to avoid across these same three pages. Consider consolidating into that shared component (e.g. via a scope/ordering prop) so future corrections to this behavior only need to be made once.

Extended reasoning...

The PR introduces a new "Scope and ordering" section on sessions.mdx (lines 191-198) and an essentially identical section on users.mdx (lines 180-187). Comparing the two: the "Covered"/"Not covered" bullets are 100% identical text, and the "Where the value lands" paragraph differs only by swapping sessionId/userId, session.id/user.id, and session_id/user_id. A third, more condensed paragraph restating the same claims (forward-only propagation, currently-active + started-while-active coverage, detached start_observation() roots staying unstamped, trace-level field, observations not exposing the field) is also added inline in instrumentation.mdx (~line 541).

This is exactly the situation the repo already solved once: components/PropagationRestrictionsCallout.tsx exists specifically to avoid duplicating propagation-restriction prose across these same three files (sessions.mdx, users.mdx, instrumentation.mdx all import and render it today, parameterized only by an attributes array). The new content follows the pre-existing hand-duplication pattern that the callout component was introduced to eliminate, instead of extending that same component (e.g., adding a sessionId/userId-aware scope prop, or a small sibling shared component) to cover the "scope and ordering" rule as well.

CLAUDE.md's review guidelines explicitly call this out: "If blocks of text or code are largely repeated on multiple documentation pages, suggest consolidating them in components-mdx to improve maintainability and consistency." This is a repo-instruction-driven check, not a subjective style opinion, so it is being surfaced even though it doesn't break anything at merge time.

Concrete duplication proof:

  • sessions.mdx: "Covered: the observation that is currently active when you enter the propagation context, and every observation started while the context is active (any nesting depth, any type)." / "Not covered: observations started before entering the context. In the Python SDK, a detached root created with start_observation() (which does not become the active span) stays unstamped if you enter propagate_attributes afterwards — start it inside the context, or use start_as_current_observation for the root and propagate within it."
  • users.mdx: the exact same two sentences, verbatim, character-for-character.
  • Only the header (sessionId vs userId) and the final "Where the value lands" sentence (session.id/session_id vs user.id/user_id) differ between the two pages.
  • instrumentation.mdx restates the same four claims (forward-only, active+later coverage, detached-root exception, trace-level field/API gap) in one merged paragraph.

Impact: nothing breaks today — the pages render correctly and the content is accurate. But because the three copies are hand-maintained prose rather than one parameterized source, any future correction to this behavior (e.g., if SDK semantics change, or to fix the trace/observation field wording) now requires editing three separate places, and the copies can silently drift out of sync — which is a real maintainability cost given how close in wording (but not byte-identical) they already are.

Suggested fix: extend PropagationRestrictionsCallout.tsx (or add a small sibling component in components/ or components-mdx/) to accept the entity name (sessionId/userId) and render the scope/ordering rule once, then reference/import it from all three pages (and reuse the same source for the instrumentation.mdx summary paragraph, or link out to the Sessions/Users sections as the PR already does for further detail).


## Example

Try this feature using the public [example project](/docs/demo).
Expand Down
9 changes: 9 additions & 0 deletions content/docs/observability/features/users.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,15 @@ await startActiveObservation("langchain-call", async () => {

<PropagationRestrictionsCallout attributes={["userId"]} />

## Scope and ordering of `userId` propagation [#scope-and-ordering]

Propagation is forward-only and follows the active execution context:

- **Covered**: the observation that is currently active when you enter the propagation context, and every observation started while the context is active (any nesting depth, any type).
- **Not covered**: observations started before entering the context. In the Python SDK, a detached root created with `start_observation()` (which does not become the active span) stays unstamped if you enter `propagate_attributes` afterwards — start it inside the context, or use `start_as_current_observation` for the root and propagate within it.

Where the value lands: each covered observation carries `user.id` in the exported OTel span data, and after ingestion `userId` surfaces as a **trace-level field**. When verifying programmatically, read it from the trace (`langfuse.api.trace.get(trace_id).user_id`) — observation objects returned by the public API do not expose a `user_id` field, so asserting on fetched child observations will always fail.

## View all users

The user list provides an overview of all users that have been tracked by Langfuse. It makes it simple to segment by overall token usage, number of traces, and user feedback.
Expand Down
2 changes: 2 additions & 0 deletions content/docs/observability/sdk/instrumentation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,8 @@ await startActiveObservation("user-workflow", async () => {
</Tab>
</LangTabs>

**Scope**: propagation is forward-only. The attributes are applied to the observation that is currently active when the context is entered, and to every observation started while the context is active. Observations started earlier are not retroactively updated — in the Python SDK, a detached span from `start_observation()` (never the active span) stays unstamped if you enter `propagate_attributes` after starting it. After ingestion, `userId` and `sessionId` surface as trace-level fields (read them via `api.trace.get(trace_id)`); observation objects returned by the public API do not expose these fields. See [Sessions](/docs/observability/features/sessions#scope-and-ordering) and [Users](/docs/observability/features/users#scope-and-ordering) for details.

<PropagationRestrictionsCallout attributes={[]} />

### Cross-service propagation
Expand Down
Loading