Skip to content

Commit 65cff16

Browse files
authored
feat: add iter_observation_pages helper for observations v2 (#2101)
1 parent 946240b commit 65cff16

4 files changed

Lines changed: 430 additions & 26 deletions

File tree

‎langfuse/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- **Prompt management**: `Langfuse.get_prompt`, `Langfuse.create_prompt` with
1616
client-side caching and version/label control.
1717
- **Full REST API**: `Langfuse.api` (sync) / `Langfuse.async_api` (async) clients.
18+
`Langfuse.iter_observation_pages` / `Langfuse.aiter_observation_pages` page through observations.
1819
1920
Quickstart:
2021

‎langfuse/_client/client.py‎

Lines changed: 170 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@
1515
from time import time_ns
1616
from typing import (
1717
Any,
18+
AsyncIterator,
1819
Callable,
1920
Dict,
21+
Iterator,
2022
List,
2123
Literal,
2224
Optional,
25+
Sequence,
2326
Tuple,
2427
Type,
2528
Union,
@@ -40,6 +43,7 @@
4043
)
4144
from packaging.version import Version
4245

46+
from langfuse._client import pagination
4347
from langfuse._client.attributes import (
4448
LangfuseOtelSpanAttributes,
4549
_serialize,
@@ -107,9 +111,12 @@
107111
LangfuseAPI,
108112
MapValue,
109113
NotFoundError,
114+
ObservationLevel,
115+
ObservationsV2Response,
110116
Prompt_Chat,
111117
Prompt_Text,
112118
)
119+
from langfuse.api.core import RequestOptions
113120
from langfuse.api.core.datetime_utils import serialize_datetime
114121
from langfuse.batch_evaluation import (
115122
BatchEvaluationResult,
@@ -444,36 +451,15 @@ def api(self) -> LangfuseAPI:
444451
user, session, tags and input/output are on its root observation. Read
445452
scores with `api.scores.get_many` and experiments with `api.experiments`.
446453
447-
- **Read observation pages until `meta.cursor` is absent.** Pages can be
448-
short or empty before the end. The server scans for up to 45 seconds per
449-
request, so pass `request_options={"timeout_in_seconds": 60}`, and pass
450-
`from_start_time` when you know a lower bound (otherwise the server
451-
scans back to 2020-01-01). A 422 means the request hit a server resource
452-
limit: narrow the time range rather than retrying unchanged. A `startTime`
453-
condition in the JSON `filter` overrides `from_start_time` and
454-
`to_start_time`.
454+
- **Page through observations with `iter_observation_pages`.** Pages can be
455+
short or empty before the end, and a request can take up to 45 seconds.
456+
`iter_observation_pages` / `aiter_observation_pages` follow `meta.cursor`
457+
until it is absent and raise the per-request timeout accordingly.
455458
456459
- For large-scale aggregation (usage/cost by model, user, etc.), prefer the
457460
v2 Metrics API (`api.metrics.metrics(...)`) over paginating row-level data.
458461
459-
Reading all observations of a trace:
460-
```python
461-
observations, cursor = [], None
462-
while True:
463-
page = langfuse.api.observations.get_many(
464-
trace_id=trace_id,
465-
from_start_time=since,
466-
cursor=cursor,
467-
request_options={"timeout_in_seconds": 60},
468-
)
469-
observations.extend(page.data)
470-
cursor = page.meta.cursor
471-
if cursor is None:
472-
break
473-
```
474-
475-
476-
See also: `async_api`,
462+
See also: `async_api`, `iter_observation_pages`,
477463
https://langfuse.com/docs/api-and-data-platform/features/query-via-sdk
478464
(ingestion lag: #ingestion-lag),
479465
https://langfuse.com/docs/api-and-data-platform/features/observations-api,
@@ -505,6 +491,164 @@ def async_api(self, value: AsyncLangfuseAPI) -> None:
505491

506492
self._resources.async_api = value
507493

494+
def iter_observation_pages(
495+
self,
496+
*,
497+
limit: int = 50,
498+
cursor: Optional[str] = None,
499+
max_items: Optional[int] = None,
500+
fields: Optional[str] = None,
501+
expand_metadata: Optional[str] = None,
502+
name: Optional[str] = None,
503+
user_id: Optional[str] = None,
504+
session_id: Optional[str] = None,
505+
type: Optional[str] = None,
506+
trace_id: Optional[str] = None,
507+
level: Optional[ObservationLevel] = None,
508+
parent_observation_id: Optional[str] = None,
509+
is_root_observation: Optional[bool] = None,
510+
environment: Optional[Union[str, Sequence[str]]] = None,
511+
from_start_time: Optional[datetime] = None,
512+
to_start_time: Optional[datetime] = None,
513+
version: Optional[str] = None,
514+
filter: Optional[str] = None,
515+
request_options: Optional[RequestOptions] = None,
516+
) -> Iterator[ObservationsV2Response]:
517+
"""Yield pages of `GET /api/public/v2/observations` until the server returns no cursor.
518+
519+
A page can be short or empty while more data remains, so only a missing
520+
`meta.cursor` ends the iteration. Every page is yielded, including empty
521+
ones. To resume later, save `page.meta.cursor` after processing a page and
522+
pass it back as `cursor` with the same other arguments.
523+
524+
Args:
525+
limit: Rows per request, at most 1000.
526+
cursor: Cursor to resume from.
527+
max_items: Stop after this many rows. The last request asks only for the
528+
remaining rows, so the last page's cursor resumes right after them.
529+
fields: Comma-separated field groups to include, e.g. `"basic,usage,model"`.
530+
Defaults to `core` and `basic` on the server.
531+
expand_metadata: Comma-separated metadata keys to return non-truncated.
532+
name: Filter by observation name.
533+
user_id: Filter by user ID.
534+
session_id: Filter by session ID.
535+
type: Filter by observation type, e.g. `"GENERATION"`.
536+
trace_id: Filter by trace ID.
537+
level: Filter by level, e.g. `"ERROR"`.
538+
parent_observation_id: Filter by the physical parent observation ID.
539+
is_root_observation: Filter by whether an observation is a logical root.
540+
environment: Filter by one or more environments.
541+
from_start_time: Only observations starting at or after this datetime.
542+
to_start_time: Only observations starting before this datetime.
543+
version: Filter by version.
544+
filter: JSON array of filter conditions. Takes precedence over the
545+
individual filter arguments; see `api.observations.get_many`.
546+
request_options: Passed to every request. `timeout_in_seconds` defaults
547+
to 60 because the server may scan for up to 45 s per request.
548+
549+
Errors propagate and end the iteration. A 422 means the server hit a resource
550+
limit, usually its time budget; the same request tends to fail again, so
551+
narrow the time range or add filters instead of retrying it unchanged.
552+
553+
Without a lower time bound the server searches back only to 2020-01-01. A
554+
`startTime` condition in `filter` makes the server ignore `from_start_time`
555+
and `to_start_time`, so put both bounds in `filter`. Rows are not
556+
deduplicated; dedupe by `id` if you need unique observations.
557+
558+
Example:
559+
```python
560+
from datetime import datetime, timedelta, timezone
561+
562+
from langfuse import get_client
563+
564+
langfuse = get_client()
565+
since = datetime.now(timezone.utc) - timedelta(days=7)
566+
567+
for page in langfuse.iter_observation_pages(
568+
trace_id="abc123", from_start_time=since, limit=1000
569+
):
570+
for observation in page.data:
571+
print(observation.id, observation.name)
572+
```
573+
"""
574+
return pagination.iter_observation_pages(
575+
self.api,
576+
limit=limit,
577+
cursor=cursor,
578+
max_items=max_items,
579+
request_options=request_options,
580+
fields=fields,
581+
expand_metadata=expand_metadata,
582+
name=name,
583+
user_id=user_id,
584+
session_id=session_id,
585+
type=type,
586+
trace_id=trace_id,
587+
level=level,
588+
parent_observation_id=parent_observation_id,
589+
is_root_observation=is_root_observation,
590+
environment=environment,
591+
from_start_time=from_start_time,
592+
to_start_time=to_start_time,
593+
version=version,
594+
filter=filter,
595+
)
596+
597+
def aiter_observation_pages(
598+
self,
599+
*,
600+
limit: int = 50,
601+
cursor: Optional[str] = None,
602+
max_items: Optional[int] = None,
603+
fields: Optional[str] = None,
604+
expand_metadata: Optional[str] = None,
605+
name: Optional[str] = None,
606+
user_id: Optional[str] = None,
607+
session_id: Optional[str] = None,
608+
type: Optional[str] = None,
609+
trace_id: Optional[str] = None,
610+
level: Optional[ObservationLevel] = None,
611+
parent_observation_id: Optional[str] = None,
612+
is_root_observation: Optional[bool] = None,
613+
environment: Optional[Union[str, Sequence[str]]] = None,
614+
from_start_time: Optional[datetime] = None,
615+
to_start_time: Optional[datetime] = None,
616+
version: Optional[str] = None,
617+
filter: Optional[str] = None,
618+
request_options: Optional[RequestOptions] = None,
619+
) -> AsyncIterator[ObservationsV2Response]:
620+
"""Async version of `iter_observation_pages`, using `async_api`.
621+
622+
Example:
623+
```python
624+
async for page in langfuse.aiter_observation_pages(trace_id="abc123"):
625+
for observation in page.data:
626+
print(observation.id, observation.name)
627+
```
628+
"""
629+
return pagination.aiter_observation_pages(
630+
self.async_api,
631+
limit=limit,
632+
cursor=cursor,
633+
max_items=max_items,
634+
request_options=request_options,
635+
fields=fields,
636+
expand_metadata=expand_metadata,
637+
name=name,
638+
user_id=user_id,
639+
session_id=session_id,
640+
type=type,
641+
trace_id=trace_id,
642+
level=level,
643+
parent_observation_id=parent_observation_id,
644+
is_root_observation=is_root_observation,
645+
environment=environment,
646+
from_start_time=from_start_time,
647+
to_start_time=to_start_time,
648+
version=version,
649+
filter=filter,
650+
)
651+
508652
@overload
509653
def start_observation(
510654
self,

‎langfuse/_client/pagination.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Page through `GET /api/public/v2/observations`."""
2+
3+
from typing import Any, AsyncIterator, Iterator, Optional
4+
5+
from langfuse.api import AsyncLangfuseAPI, LangfuseAPI, ObservationsV2Response
6+
from langfuse.api.core import RequestOptions
7+
8+
# The server scans for up to 45 s per request; the client-wide default is 5 s.
9+
_FETCH_TIMEOUT_SECONDS = 60
10+
11+
12+
def _with_fetch_timeout(request_options: Optional[RequestOptions]) -> RequestOptions:
13+
return {"timeout_in_seconds": _FETCH_TIMEOUT_SECONDS, **(request_options or {})}
14+
15+
16+
def _next_limit(limit: int, max_items: Optional[int], fetched: int) -> int:
17+
return limit if max_items is None else min(limit, max_items - fetched)
18+
19+
20+
def iter_observation_pages(
21+
api: LangfuseAPI,
22+
*,
23+
limit: int,
24+
cursor: Optional[str],
25+
max_items: Optional[int],
26+
request_options: Optional[RequestOptions],
27+
**params: Any,
28+
) -> Iterator[ObservationsV2Response]:
29+
options = _with_fetch_timeout(request_options)
30+
fetched = 0
31+
while max_items is None or fetched < max_items:
32+
page = api.observations.get_many(
33+
limit=_next_limit(limit, max_items, fetched),
34+
cursor=cursor,
35+
request_options=options,
36+
**params,
37+
)
38+
fetched += len(page.data)
39+
yield page
40+
cursor = page.meta.cursor
41+
if not cursor:
42+
return
43+
44+
45+
async def aiter_observation_pages(
46+
api: AsyncLangfuseAPI,
47+
*,
48+
limit: int,
49+
cursor: Optional[str],
50+
max_items: Optional[int],
51+
request_options: Optional[RequestOptions],
52+
**params: Any,
53+
) -> AsyncIterator[ObservationsV2Response]:
54+
options = _with_fetch_timeout(request_options)
55+
fetched = 0
56+
while max_items is None or fetched < max_items:
57+
page = await api.observations.get_many(
58+
limit=_next_limit(limit, max_items, fetched),
59+
cursor=cursor,
60+
request_options=options,
61+
**params,
62+
)
63+
fetched += len(page.data)
64+
yield page
65+
cursor = page.meta.cursor
66+
if not cursor:
67+
return

0 commit comments

Comments
 (0)