Skip to content

feat(llm-obs): add datasets records-all to page past the preview cap - #678

Merged
platinummonkey merged 1 commit into
DataDog:mainfrom
tillwf:till.wohlfarth/llm-obs-datasets-records-all
Jul 27, 2026
Merged

feat(llm-obs): add datasets records-all to page past the preview cap#678
platinummonkey merged 1 commit into
DataDog:mainfrom
tillwf:till.wohlfarth/llm-obs-datasets-records-all

Conversation

@tillwf

@tillwf tillwf commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Problem

pup llm-obs datasets records posts to /api/unstable/llm-obs-mcp/v1/dataset/records, which trims its response to a size budget and returns no cursor. On a dataset whose records carry sizeable inputs that lands at 19 records with truncated: true — and since the endpoint emits no cursor, there's no way to reach the rest. --cursor exists as a flag but nothing ever produces a value for it.

Confirmed against the API directly, not inferred from the CLI:

POST /api/unstable/llm-obs-mcp/v1/dataset/records   limit=50
  -> returned: 19, truncated: true, next_cursor: None

--limit 25 and --limit 50 both return 19; no cursor key appears in the response at any limit. records-full isn't a workaround either — it caps at 3 ids per call (record_ids has N entries; cap is 3), and you'd need the full id list to use it, which is exactly what you can't get.

So a dataset larger than ~19 records currently cannot be enumerated through pup.

Fix

The plain REST route has no such cap and pages properly:

GET /api/unstable/llm-obs/v1/datasets/{id}/records?page[limit]=N&page[cursor]=<meta.after>

Adds pup llm-obs datasets records-all --dataset-id D [--limit N], which pages that route via meta.after and emits the aggregate under the same records/returned keys, plus pages_fetched.

Verified against a real 50-record dataset:

command result
datasets records --limit 50 (existing) 19 records, truncated: true
datasets records-all --limit 7 (new) 50 records, 8 pages, 50 unique ids, no placeholders
datasets records-all (default limit 100) 50 records, 1 page

Why a new subcommand rather than fixing records

The two routes return different shapes. records gives size-trimmed previews with __nested_object__ / __nested_array__ placeholders; the REST route gives full records. Switching routes under the existing flag would silently change its output shape and break anything parsing it. records stays the cheap browse path; this is the explicit "give me everything" path.

It takes only --dataset-id — the REST route doesn't require a project id.

Loop safety

Terminates on an empty cursor or a cursor identical to the previous one (a server returning a fixed cursor would otherwise spin forever), with a 200-page hard stop that warns before returning what it has.

Checks

  • 3 tests: happy path (asserts both pages were requested, proving the cursor is followed), repeated-cursor guard, 500 response — all pass
  • cargo fmt --check clean, cargo clippy --bin pup -- -D warnings clean
  • No Cargo.toml change, so the dependency set is untouched and no new advisories are possible
  • docs/COMMANDS.md command list updated

+174/−0 in src/, 2 lines in docs. Opened from a fork — I don't have write access to DataDog/pup.

🤖 Generated with Claude Code

`pup llm-obs datasets records` posts to /api/unstable/llm-obs-mcp/v1/dataset/records, which trims
its response to a size budget and returns no cursor. On a dataset whose records carry sizeable
inputs that lands at 19 records with `truncated: true`, and because the endpoint emits no cursor
there is no way to reach the remainder — `--cursor` exists as a flag but nothing ever produces a
value for it. Confirmed against the API directly: --limit 25 and --limit 50 both return 19, and no
cursor key appears in the response at any limit. `records-full` is not a workaround either; it
caps at 3 ids per call ("record_ids has N entries; cap is 3") and you would still need the full id
list to use it.

The plain REST route has no such cap and pages properly:

    GET /api/unstable/llm-obs/v1/datasets/{id}/records?page[limit]=N&page[cursor]=<meta.after>

This adds `datasets records-all`, which pages that route via meta.after and emits the aggregate
under the same records/returned keys plus pages_fetched. Verified against a 50-record dataset:
--limit 7 returns all 50 across 8 pages with 50 unique ids and no __nested_object__ placeholders,
where the existing command returns 19.

Added as a separate subcommand rather than changing `records` in place, because the two routes
return different shapes — `records` gives size-trimmed previews with __nested_object__ /
__nested_array__ placeholders, the REST route gives full records. Switching routes under the
existing flag would silently change its output shape and break callers that parse it. `records`
stays the cheap browse path; this is the explicit "give me everything" path. It takes only
--dataset-id, since the REST route does not require a project id.

Loop safety: terminates on an empty cursor or a cursor identical to the previous one (a server
returning a fixed cursor would otherwise spin), with a 200-page hard stop that warns on stdout
before returning what it has.

Tests cover the happy path (asserts BOTH pages were requested, proving the cursor is followed),
the repeated-cursor guard, and a 500 response.
@tillwf
tillwf requested a review from a team as a code owner July 27, 2026 15:45
@platinummonkey
platinummonkey merged commit d433f5b into DataDog:main Jul 27, 2026
6 checks passed
tillwf added a commit to tillwf/agent-skills that referenced this pull request Jul 27, 2026
The pup backend was reading the eval corpus with `datasets records`, which posts to an
MCP-token-budget endpoint that trims to a response-size budget (~19 records on a dataset with
sizeable inputs), reports truncated: true, and returns no cursor. A run built that way silently
measures a different corpus than an mcp run of the same dataset_id — different split, different
class balance, nothing comparable. That is exactly what happened on a 50-record dataset: the pup
run scored 19 records while two earlier mcp runs scored 50.

DataDog/pup#678 (merged) adds `datasets records-all`, which pages the REST route internally and
returns every record in one call. The table now makes that the corpus path, demotes `records` to
browsing with its cap marked, and Step 1's dataset_id branch names the per-backend command
explicitly: page next_cursor on mcp, records-all on pup.

Two guards, both learned the hard way:

  * Absence of records-all is a STOP under datadog_backend: pup, like a missing binary —
    continuing on the capped path yields a corpus that is a truncation artifact. Detect it by
    running the subcommand and checking the exit code, NOT by --help, which exits 0 for unknown
    subcommands on some builds and reports a feature present when it is absent.
  * After loading, assert the materialized count equals the dataset's true size before splitting.
    That is the cheap check that would have caught the 19-of-50 truncation immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants