-
[OPIK-6416] [SDK] feat: cascade experiments and traces and spans in opik migrate dataset (#6658)
发布于
2026-05-14 11:12:41 +00:00 - [OPIK-6414] [SDK] feat: opik migrate dataset slice 1 — core command + current items
Add
opik migrateClick command group withdatasetandplansubcommands.
First slice of the SDK CLI Migration Tool epic (OPIK-5859): a dataset entity
plus its current items can now be migrated into a destination project within
the same workspace via a single CLI invocation, with a JSON audit-log
skeleton, fail-fast pre-flight, and a single-version target output.Implements OPIK-6414.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(sdk): preserve full dataset/item fidelity + add test-suite support to opik migrate
Smoke-testing surfaced gaps in slice 1 that the unit tests with dict-equality
assertions hadn't caught:- Per-item top-level fields (description, source, trace_id, span_id,
evaluators, execution_policy) were silently dropped because the copy step
routed throughget_items()/insert(dicts)instead of the dataclass form.
Switching to__internal_api__stream_items_as_dataclasses__↔
__internal_api__insert_items_as_dataclasses__round-trips them. - Dataset-level
descriptionwas wiped on rename:update_dataset(name=...)
withdescription=OMITis treated by the BE as null. Re-passing
description/visibility/tags on the rename PUT preserves them. - Dataset-level visibility/tags weren't forwarded to the target on create.
- Test suites (type='evaluation_suite') were initially refused. Slice 1 now
migrates them: target is created with the same type, and the latest
source version's suite-level evaluators + execution_policy
(runs_per_item, pass_threshold) are applied to the target via
apply_dataset_item_changes(override=True). The suite-config action runs
BEFORE item copy so the target's first version carries the config (the BE
rejectsapply_dataset_item_changeswithoutbase_versiononce the
dataset already has versions).
Workspace-mutating writes (rename, create, suite-config apply, delete) are
now wrapped withrest_helpers.ensure_rest_api_call_respecting_rate_limit
so a transient 429 doesn't abort a half-finished migration. Item-copy
already inherits the SDK's per-batch retry. Reads stay unwrapped.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(sdk): address PR review on opik migrate (sanitize errors, paginate, conventions)
Addresses 8 review comments from baz-reviewer[bot] on PR #6628:
- MigrationError now inherits from opik.exceptions.OpikException so shared
shutdown/error-tracking code that does isinstance(..., OpikException)
classifies migration failures correctly. - ApiError sanitization: introduces errors.safe_error_envelope /
safe_error_string so neither audit-log JSON nor terminal output leaks
ApiError response bodies, headers, or tokens. Both migrate_dataset and
migrate_plan now route exceptions through a shared _finalize_and_fail
helper that reuses the sanitizer for non-MigrationError exceptions. - resolve_source paginates find_datasets to exhaustion and derives
ResolvedDataset.project_name from the matched row's project_id (resolved
to a project name) instead of the --from-project flag — so workspace-
scoped lookups still produce the correct project context for downstream
Opik.get_dataset / delete_dataset calls. - name_taken_in_workspace paginates similarly and emits collision project
names ("project 'X'") in the resulting message. - _suggest_project_names narrows the broad except to ApiError and logs a
warning for unexpected exceptions instead of silently returning []. - Stale comment on ResolvedDataset.type updated to reflect that suites are
supported (with CopyTestSuiteConfig copying their suite-level config). - Test names migrated to the SDK testing convention
test_ (or __happyflow), per
.agents/skills/python-sdk/testing.md.
Skipped (with on-PR rationale):
- TestPlanBuilding routing through the public CLI: planner is the unit
under test in those cases; routing every assertion through Click adds
mock setup and obscures the action-ordering contract. - Extracting CopyTestSuiteConfig request build into rest_operations: the
shapes differ (this side reads raw REST EvaluatorItemPublic, the other
takes typed LLMJudge SDK objects) — a shared helper would force one to
convert formats. - Asserting internal_api__insert_items_as_dataclasses.call_args once:
the whole point of that test is the one-target-version contract; the
testing.md "explicit exception" allowance applies.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- test(sdk): cover workspace-scoped source dataset migration
Adds a unit test that pins the workspace-scoped source path (V1 datasets
or anything left at workspace scope after auto-migration, where the
source row has project_id=None on the BE):- Resolver returns ResolvedDataset(project_name=None)
- Rename happens against the row id, no project context needed
- Destination is created under --to-project regardless of the source's
(missing) project - Item copy still produces exactly one target version
- Source-side reads go through Opik.get_dataset(project_name=None) — the
workspace-scoped lookup path
Pinning this so a future refactor that silently routes the source read
through a default-project fallback (or breaks for project_id=None
sources) trips the test first._DatasetRow grew an explicit project_id field so the test surface is
honest about workspace-scoped vs project-scoped fixtures.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(sdk): address second-round PR review on opik migrate
Two fixes from baz-reviewer[bot]'s review of commit
3c167de:_project_name_for_rownow narrows its catch toApiError(recoverable
— typically 404 if the project was deleted between listing and lookup,
yields None and downstream treats as workspace-scoped). Other
exceptions log a warning at WARNING level so auth/transport/config
failures aren't silently masked, then still return None so a
best-effort lookup doesn't abort the surrounding migration check.- Extract
iter_dataset_pages(no longer private) as the single source
of truth forfind_datasetspagination. The plan command's loop in
migrate_plan_commandreuses it instead of duplicating the same
pagination structure. Optionalname=Nonecovers the listing case
for the workspace survey; named lookups still passname=strfor
resolver.
Skipped (with on-PR rationale): a third comment asked us to drop
ApiError.body text fromsafe_error_envelopeand emit onlyHTTP <code>.
We already gate to scalar string fields (message/errors[0]) and
never include headers or raw body — the actionable text is what users
need at a CLI. Kept the current behavior with a reply explaining the
gating.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- refactor(sdk): trim opik migrate slice 1 surface
Per PM scope review on the epic Notion doc, slice 1 ships a smaller
surface so it can land sooner. Each removed surface element will return
in a follow-up if a real user need surfaces.Removed:
opik migrate plansubcommand (workspace survey) — defers to a future
slice 4 once optimizations and experiments cascade.--target-nameflag — users can rename via UI after the migration if
they want a different name on the target.--delete-sourceflag — users can delete via UI after they've verified
the migration. Reduces blast radius of the CLI.--source-suffixflag — replaced by a hardcoded_v1constant in
planner.py. Keeps the user-facing surface minimal.
Internal:
iter_dataset_pagesmade private again (_iter_dataset_pages) since
the only external reuse was the now-removedmigrate plancommand.DeleteSourceaction and its executor branch / audit-log entry deleted.- Help text and PR-template examples updated to reflect the trimmed
command shape.
Trimmed surface:
opik migrate dataset NAME --to-project=B [--from-project=A]
[--dry-run] [--audit-log=PATH]
[--workspace=...]21/21 unit tests pass; pre-commit clean; real-BE smoke green.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- refactor(sdk): split opik migrate into generic + dataset-specific layers
Address @alexkuzmik's review on PR #6628 about the cli/migrate/ package
mixing general-purpose abstractions with dataset specifics. Applies the
parts of the suggestion we have direct evidence will generalise, defers
the rest until slice 2 / slice 3 give us a concrete second implementation
to extract real abstractions from.Layer split (full B):
- cli/migrate/ — generic primitives + Click group
_base.py — BaseMigrationPlan + execute_plan_loop +
record_planned_loop (audit-bracketed)
audit.py — entity-agnostic audit log JSON
errors.py — generic exception hierarchy + sanitiser
main.py — Click group, --workspace handling - cli/migrate/datasets/ — slice 1's dataset-specific code
resolver.py — name → ResolvedDataset, did-you-mean
planner.py — DatasetMigrationPlan + action records
executor.py — _apply_action dispatch, _action_details
Narrow A: only the abstractions we have evidence for:
- BaseMigrationPlan: minimal "source + ordered actions" shape. Subclasses
narrow source/actions to their own types. kw_only=True so subclasses
can add required fields without "non-default after default" errors. - execute_plan_loop / record_planned_loop: the audit-bracketed for-loop.
Caller supplies apply_fn + details_fn closures so each entity stays in
charge of its own action types.
Deferred until slice 2/3 land concrete second implementations:
- BaseEntityResolver: slice 2 has no resolver step (it operates on the
already-resolved dataset id); slice 3's experiment resolver has
different validation rules. Designing a base now would either pin the
wrong contract or force later slices to contort. - BaseAction class hierarchy: slice 1's actions are stateless;
slice 2's are chained (each version's apply depends on the previous);
slice 3's are fan-out. Inheritance shape will be obvious once those
exist.
User-facing imports (
opik.cli.migrate.migrate_group) are unchanged. All
21 unit tests pass. Real-BE smoke on staging green.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- [OPIK-6415] [SDK] feat: replay full dataset version history in opik migrate
Slice 2 of the SDK CLI Migration Tool epic.
opik migrate datasetnow
replays the source's full version history onto the target with full per-
version fidelity. Items, item-level fields (data, description, tags,
evaluators, execution_policy, trace_id, span_id, source), version-level
fields (suite evaluators, execution_policy, user tags, metadata), and
display order are all preserved per version.--exclude-versionsfalls back to Slice 1's current-items-only path.Slice 3 (experiment cascade) reads
MigrationPlan.version_remapand
item_id_remapto remap experiment FK references.Implements OPIK-6415.
-
feat(migrate): per-version Rich Progress bar for replay
-
fix(migrate): address baz-reviewer feedback on version replay
- Swap RuntimeError → ReplayError (new MigrationError subtype) at the four
BE-unexpected-response sites in version_replay.py so failures route
through migrate_dataset_command's user-facing MigrationError branch. - Forward clear_execution_policy=true at the version level when source
drops the suite-level execution_policy between versions. - Switch user_tags/metadata gating from truthiness to
is not Noneso
explicit clears ([] / {}) round-trip instead of being collapsed to
omission (which the BE inherits from base_version). - Switch target_items_by_hash from Dict[hash, str] to Dict[hash, List[str]]
with FIFO consumption so source items with identical content (legal under
the BE) each remap to a distinct target row instead of collapsing onto
one.
Three new unit tests pin the bugs:
- test_replay__clears_version_level_execution_policy_when_dropped
- test_replay__forwards_empty_user_tags_and_metadata_explicitly
- test_replay__duplicate_content_items_remap_to_distinct_target_ids
- refactor(migrate): extract suite-payload conversion helpers
baz-reviewer PR comment 3217063444 — the suite-level evaluators /
execution_policy dict shapes were duplicated across four call sites
(_create_first_version_with_items, _create_first_version_config_only,
_apply_delta_and_collect_new_ids in version_replay.py and
_copy_test_suite_config in executor.py).Adds two narrow conversion helpers in version_replay.py:
- _suite_evaluators_payload(evaluators)
- _suite_execution_policy_payload(execution_policy)
The helpers only convert wire types -> payload dicts; each call site
keeps its own gating (Slice 1's _copy_test_suite_config uses truthy
gating, Slice 2 uses 'is not None') so the dedup doesn't bleed
behavior across slices.- refactor(migrate): reuse evaluator/policy payload helpers for per-item sites
baz-reviewer PR comment 3217168575 — since the suite-level helpers
already centralize the wire-shape conversion, the per-item dict
literals in _added_item_payload and _edited_item_payload should call
them too.- Rename _suite_evaluators_payload -> _evaluators_payload and
_suite_execution_policy_payload -> _execution_policy_payload (BE uses
the same wire types at version + item level, so the prefix was misleading). - Wire them into _added_item_payload and _edited_item_payload.
- Document why _content_hash_for stays uncoupled: it's a hash function,
not a wire payload, and a future BE field added to the wire payload
should not silently rehash everything (would break idempotency on re-runs).
- [OPIK-6416] [SDK] feat: cascade experiments and traces in opik migrate dataset
Adds Slice 3 of the SDK CLI migration tool: after the dataset and its
version history are at the destination (Slice 1/2), every experiment
referencing the source dataset is recreated under the destination project
with its traces and spans riding along.Cascade-copy semantics
Like the dataset copy, this is a copy -- not a move. Source experiments
stay in their original projects with traces and spans intact; the
destination project gets brand-new experiments (new ids) that reference
the destination's dataset / version / item ids and carry independent
copies of the trace + span data.Cross-project follow
find_experiments(dataset_id=...) is project-agnostic at the REST layer,
so every experiment referencing the source dataset cascades to
--to-project regardless of which project it originally lived in. This
is the epic's "baseline follow" default; it never produces dangling
references, but does duplicate when the source dataset was referenced by
experiments in multiple projects. Slice 4 (OPIK-6417) layers detection +
reporting on top.FK remap during recreation
source dataset_id -> dest_dataset_id (Slice 1)
source dataset_version_id -> plan.version_remap[old] (Slice 2)
source dataset_item_id -> plan.item_id_remap[old] (Slice 2)
source trace_id -> built here as traces copy (this slice)
source project_id -> target_project_name (this slice)Stripped on the destination experiment (the entities aren't migrated; per
Jacques's "strip the link, document loss" position from the epic
discussion, we drop the pointers rather than leave them dangling):prompt_versions -- prompt entity isn't cascaded in v1 (epic open question)
optimization_id -- optimization entity is cascaded in Slice 4Spans cascade with their parent trace; tree ordering is preserved via
sort_spans_topologically (existing helper from imports/) so parent_span_id
remap entries always exist by the time a child span is processed.CLI surface
--exclude-experiments Skip the cascade entirely (dataset moves alone)
Validation rule: --exclude-versions requires --exclude-experiments,
because experiments reference specific dataset versions and a
current-items-only copy doesn't preserve the version IDs the experiments
need.Reuses
recreate_experiment widened with optional target_* params (migrate
path); import-from-disk path unchanged
ReplayResult shape mirrored by ExperimentCascadeResult
execute_plan_loop wraps CascadeExperiments via existing audit-bracket
sort_spans_topologically preserves parent_span_id remap orderingOut of scope (Slice 4, OPIK-6417)
- Cross-project dependent detection + reporting
- Optimization entity cascade + optimization_id remap
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- test(migrate-experiments): unit tests for experiment + trace/span cascade
Adds tests covering Slice 3's cascade in isolation. Mocks the REST surface
(find_experiments, stream_experiment_items, get_trace_by_id, create_traces,
get_spans_by_project, create_spans) and the recreate_experiment client path
so each acceptance criterion has a focused assertion:- empty cascade no-ops
- full fidelity copy (name, tags, type, evaluation_method, inline prompts)
- prompt_versions stripped on destination
- optimization_id stripped on destination
- FK remap correctness (dataset_version_id, project_name, trace_id)
- span tree topological order preserves parent_span_id remap
- missing trace / missing dataset_item handled without crashing
- trace_id_remap accumulates across experiments sharing a trace
- id=None experiment raises ExperimentCascadeError
- planner gate (--exclude-experiments omits the cascade action)
- CLI validation (--exclude-versions without --exclude-experiments fails)
- dataclass / module-shape smoke checks
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- test(migrate-experiments): e2e test for multi-version dataset cascade
Adds an end-to-end test exercising the full Slice 3 pipeline against a
real Opik backend:- Source: dataset with 2 versions under source_project; one experiment
attached to v1's items; one trace + one LLM span per item. - Action: opik migrate dataset --from-project
--to-project - Assertions:
- destination dataset exists under --to-project
- destination experiment exists, references the destination
dataset's id, has a remapped dataset_version_id - destination experiment items use FRESH trace ids (disjoint from
source trace ids) -- proves the cascade minted new ids rather
than reusing source ones - each destination trace exists in --to-project and carries at
least one span (the source had one LLM span per trace) - --exclude-experiments leaves no destination experiments
Skipped unless an Opik backend is reachable -- the e2e suite runs
against the docker-compose stack per
.agents/skills/python-sdk/testing.md.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- refactor(migrate): drop --exclude-versions/--exclude-experiments + slice-1 dead code
Per review feedback, the user-facing CLI shouldn't expose either flag --
power users won't need them and they encourage broken combinations. Since
the feature branches all stack into a single eventual merge, the planner-
level paths that only existed to support those flags are dead code too.Removed from the CLI surface (main.py):
- --exclude-versions
- --exclude-experiments
- the "you must pair them" UsageError validation
Removed from the planner / executor (now-unreachable):
- build_dataset_plan(exclude_versions, exclude_experiments) parameters
- CopyCurrentItems dataclass + executor branch + helper
- CopyTestSuiteConfig dataclass + executor branch + helper
- the ValueError that gated the bad flag combination
Test changes:
- test_migrate_dataset_exclude_versions.py renamed to
test_migrate_dataset_planner.py (the file now only covers planner
edge cases + CLI help; nothing slice-1-specific) - Dropped the entire TestMigrateDatasetCommand class (slice-1 CLI tests)
- Dropped 3 cascade-flag tests from
test_migrate_dataset_experiments_cascade.py
Result: every migrate invocation now does the full sequence -- rename
source, create destination, replay all versions, cascade experiments +
traces + spans. There is no "dataset-only" or "versions-only" path
left in the code, in line with the simplified CLI surface.243 unit tests pass (down from 262 -- the diff is the slice-1 dead-code
tests removed).Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- test+feat(migrate): port e2e infra, add trace/span feedback-score cascade
Two coordinated changes -- the e2e infrastructure surfaces a real
fidelity gap in the cascade (trace feedback scores not copied), and
fixing that gap is the natural prerequisite to the cascade e2e
scenarios that go on top.E2E infrastructure (new under tests/e2e/cli/)
conftest.py:
- source_project_name / target_project_name ephemeral fixtures
with best-effort teardown
- run_migrate_cli helper (subprocess, real entrypoint, real env)
- create_dataset_shell + apply_changes for multi-version seeding
- chronological_versions, stream_items_wire, item_hashes,
display_order, normalize_evaluators, normalize_policy,
strip_be_managed_version_tags read-side helpers
- seed_experiment_with_trace_tree + verifiers for the cascade
scenarios (find_destination_experiment, destination_*)test_migrate_dataset_e2e.py:
- TestMigrateDatasetVersionReplay::test_three_version_dataset_with_mixed_deltas_round_trips
- Single test exercises rename -> create destination -> replay
three versions with mixed adds/edits/deletes; asserts target
version count, per-version content set-equality under hash,
display order, audit log per-version records.test_migrate_test_suite_e2e.py:
- test_test_suite_full_fidelity_round_trip
- Single test covers test-suite-specific fidelity: 4 versions,
per-version suite evaluators + execution_policy that change
across versions, per-version metadata + user tags, per-item
evaluators + policy + tags.Trace + span feedback-score cascade (experiments.py)
- _copy_trace_feedback_scores: after the trace batch create, read
feedback_scores off the source trace payload (already fetched
during _copy_traces_and_spans) and re-emit via
traces.score_batch_of_traces against the new destination trace id
with the destination project. - _copy_spans_for_trace: collect span-level feedback_scores from
each source span's read payload and emit via
spans.score_batch_of_spans against the new span ids after the
span create batch.
Trace creates and span creates don't accept feedback scores on their
write payloads -- they live in separate per-trace / per-span tables
that the score_batch_of_* endpoints write to. The cascade now hits
both.Unit tests
- _Trace stand-in: feedback_scores=None default so existing tests
still pass. - 3 new tests on TestCascadeExperiments:
- test_trace_feedback_scores__copied_to_destination_trace
- test_trace_without_feedback_scores__skips_score_batch_call
- test_span_feedback_scores__copied_to_destination_spans
Removed
- sdks/python/tests/e2e/test_cli_migrate_experiments_cascade.py
(superseded by tests/e2e/cli/* which uses the shared infra)
Known cascade gaps deferred to a follow-up
This commit does NOT include:
- Fix for _stream_experiment_items in experiments.py: it currently
passes experiment_id= (wrong; endpoint takes experiment_name=)
and treats the response as parsed objects (wrong; it's raw NDJSON
bytes that need rest_stream_parser). Unit tests mock around this;
e2e tests will fail until the fix lands. - Full ExperimentItem write-side fidelity: the BE accepts input,
output, feedback_scores, assertion_results, comments,
execution_policy, description, status, total_estimated_cost,
duration, usage on per-item creates -- the cascade currently
forwards only id/experiment_id/dataset_item_id/trace_id. - The actual cascade e2e scenarios on top of the existing dataset
- test-suite tests (experiments + traces + spans assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): correct _stream_experiment_items REST call
The cascade was passing
experiment_id=to
rest_client.experiments.stream_experiment_items, but the endpoint
takesexperiment_name=. It also treated the response as already-
parsed items, when the endpoint actually returnsIterator[bytes]
of NDJSON that must go throughrest_stream_parser.read_and_parse_stream
to become typed objects.Both mistakes had been hidden by the unit-test mocks; they would have
surfaced on the first e2e run.Changes
_stream_experiment_items: takesexperiment_nameand routes the
raw byte stream throughrest_stream_parserinto
ExperimentItemPublic. The parser preserves BE-returned fields
outside the typed schema onmodel_extra(input, output,
feedback_scores, assertion_results, execution_policy, etc.), which
the next commit will consume for full destination-side fidelity.cascade_one_experiment: pullsexperiment_nameoff the source
experiment and passes it to the streamer. Raises
ExperimentCascadeErrorif the BE returns an experiment without a
name (defensive; shouldn't happen in practice).
Unit-test mock updates
The cascade-rest-client builder in
test_migrate_dataset_experiments_cascade.pynow keys
items_by_experimentby experiment NAME and emits NDJSON bytes
(one JSON line per item, terminated by\n) so the parser path is
exercised end-to-end. The cascade tests that used a default-named
_Experiment(...)were updated to key on"experiment"; the
two-experiment test was given explicitexp-a/exp-bnames.All 246 unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- feat(migrate): full ExperimentItem write-side fidelity on cascade
The cascade was building destination experiment items from only the four
typed FK fields onExperimentItemPublic(id, experiment_id,
dataset_item_id, trace_id). The BE returns a much richer per-item
payload via theextra="allow"schema; we now consume it through
model_extraand forward every field the destination
ExperimentItemwrite surface accepts:input/output-- per-item I/O snapshots (UI displays these)feedback_scores-- regular-dataset experiment scoresassertion_results-- test-suite (evaluation_suite) pass/fail dataexecution_policy-- per-item runs / pass-threshold overridesdescription,status,usage,total_estimated_cost,
duration
Some of these are dataset-type-specific in practice (feedback_scores on
regular datasets; assertion_results + execution_policy on test suites).
The cascade doesn't branch on dataset type -- the BE-returned shape is
the source of truth, and theis not Noneguard naturally drops
fields the source item never carried. Single code path; robust to
schema evolution. Comment on the constant spells this out.Implementation
_build_experiment_dataincli/migrate/datasets/experiments.py:
builds per-item dicts via a new_experiment_item_to_dicthelper
that copies the FK fields and pulls each fidelity field off the
ExperimentItemPublic.model_extradict.recreate_experimentincli/imports/experiment.py: on the
migrate path, surfaces those same fields fromitem_datainto
theExperimentItem(...)kwargs. Import-from-disk path is
unchanged (gated onis_migrate_path).
Test coverage
New unit test:
test_experiment_item_fidelity__write_side_fields_forwardedSeeds a source item with all ten fields populated, runs the cascade,
asserts thatcreate_experiment_itemswas called with each field
forwarded verbatim (and FK fields remapped to the destination)._ExperimentItemtest stand-in gained anextrasdict; the
NDJSON mock serialises it alongside the typed fields so pydantic's
extra='allow'surfaces them onmodel_extra.All 247 unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- test(migrate): cascade scenarios on dataset + test-suite e2e tests
Extends both existing e2e tests with experiment + trace + span
cascade assertions so the full pipeline -- rename, create destination,
replay versions, cascade -- gets exercised against a real backend
in one run per dataset type.Dataset e2e (test_migrate_dataset_e2e.py)
On top of the existing 3-version replay scenario, the test now seeds
a regular-dataset experiment attached to v1 items:- 3 traces (one per v1 item) each with a root + 1 LLM child span
(exercisessort_spans_topologicallyand parent_span_id remap) - Per-trace feedback scores (correctness + latency_p95) -- the
feedback-score copy added in the previous commit - Per-item input/output via
per_item_extras
After the migration, asserts:
- Destination experiment exists with FKs remapped (dataset_id,
dataset_version_id resolves to one of the target versions) - Items use fresh trace ids (disjoint from source)
- Per-item input/output round-trip via
model_extra - Each destination trace carries 2 spans with parent_span_id remap
pointing at the new root id - Trace-level feedback scores re-emitted under the destination project
Test-suite e2e (test_migrate_test_suite_e2e.py)
On top of the existing 4-version suite replay scenario, seeds a
suite-driven experiment (type='trial',
evaluation_method='evaluation_suite') attached to v2 items:- Per-item assertion_results with passed/failed/reason
- Per-item execution_policy (runs_per_item=3, pass_threshold=2)
- Per-item status, input, output
After the migration, asserts:
- Destination experiment type + evaluation_method preserved
- Per-item assertion_results round-trip (value/passed/reason all match)
- Per-item execution_policy round-trips
- Per-item status preserved
- Span tree shape preserved with parent_span_id remap
Conftest helpers
seed_experiment_with_trace_treegainedper_item_extras: List[Dict]
so callers can seed per-item write-side fields (input/output/
assertion_results/execution_policy/status/...). Each per-item dict
is splatted into theExperimentItem(...)constructor;
extra='allow'accepts unknown keys.destination_experiment_itemscorrected to takeexperiment_name
and route throughrest_stream_parser(same bug as the production
cascade had; would have surfaced on the first e2e run).
All 247 unit tests pass; 2 e2e tests collect cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): thread source_project_name through cascade so source-side reads scope correctly
Running the e2e tests against a real backend (localhost:5174) surfaced
that the cascade's source-side readsstream_experiment_itemsand
get_spans_by_project400 when called withoutproject_name:ApiError (400) Either 'project_name' or 'project_id' query
params must be providedThe cascade had no path to that value. Plumbing:
CascadeExperimentsaction gainssource_project_name: Optional[str];
populated fromsource.project_name(which Slice 1 already resolves).
May beNonefor workspace-scoped sources -- the BE accepts the
omission there.executor._cascade_experimentsforwards it tocascade_experiments.cascade_experiments->cascade_one_experiment-> the two source-
side helpers (_stream_experiment_items,_fetch_spans_for_trace)
all carry the parameter; both REST calls now passproject_nameon
the request.
E2E test conftest mirrors the same fix for
destination_experiment_items
anddestination_spans_for_trace-- they hit the same BE endpoints
against the destination project for verification.Unit-test mock signatures updated to accept the new kwargs.
Results against localhost:5174:
- tests/e2e/cli/test_migrate_dataset_e2e.py PASSED
- tests/e2e/cli/test_migrate_test_suite_e2e.py still fails on
assertion_results round-trip (separate root cause, deferred to a
follow-up: the BE's ExperimentItem write surface silently drops
write-side fields like assertion_results / feedback_scores /
execution_policy; the cascade needs to switch its source read to
datasets.find_dataset_items_with_experiment_itemsCompare view
and write assertions via the dedicated
assertion_results.store_assertions_batch(entity_type='TRACE')
endpoint scoped to the new trace id).
All 247 unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): cascade assertion_results via store_assertions_batch + read source via Compare view
E2E discovery:
ExperimentItemwrite-side fidelity fields
(input,output,feedback_scores,assertion_results,
execution_policy,description,status,usage,
total_estimated_cost,duration) are READ-ONLY on the BE Java
schema -- onlyid/experiment_id/dataset_item_id/
trace_id(+project_name) appear in the Write JsonView. The BE
silently drops every other field oncreate_experiment_items. The
previous commit's "full ExperimentItem write-side fidelity" was
therefore dead code: writes succeeded but persisted nothing.What the BE actually expects:
- Trace-scoped assertion results are written via the dedicated
assertion_results.store_assertions_batch(entity_type='TRACE', entity_id=<trace_id>, ...)endpoint -- they're a separate entity
table, not an ExperimentItem field. The Compare view surfaces them
on ExperimentItemCompare on read. - Trace + span feedback scores (already correct in a previous commit)
go throughscore_batch_of_traces/score_batch_of_spans. - Per-item input / output / usage / cost / duration are BE-computed
read aggregates from the underlying trace + span entities; the
cascade ensures those entities are correctly populated and the BE
surfaces the aggregates on read.
Implementation
recreate_experiment: dropped the migrate-pathitem_kwargs
block that forwarded write-side fidelity fields. Destination
ExperimentItems carry only the four FK fields; the rest is
reconstructed from underlying entities._stream_experiment_items->_read_source_experiment_items:
switched source read from the slimstream_experiment_items
Public view todatasets.find_dataset_items_with_experiment_items
Compare view, which exposesassertion_results. Endpoint takes
experiment_idsas a JSON-array string._copy_trace_assertion_results: new helper that re-emits source
assertion results scoped to the new destination trace ids via
assertion_results.store_assertions_batch(entity_type='TRACE', ...).
Wired into_copy_traces_and_spans.
Test surface
Unit tests:
- Replaced the now-obsolete fidelity-writes test with two new tests:
- test_trace_assertion_results__copied_to_destination_trace
- test_no_assertion_results__skips_store_assertions_call
- Updated
_cascade_rest_clientmock to drive the Compare-view
endpoint instead of stream_experiment_items, and pre-attach the
assertion_results sub-mock (MagicMock auto-blocks the
assertion_resultsattribute name).
E2E tests:
destination_experiment_itemsswitched to the Compare view
(takesexperiment_id+dataset_id) so tests can assert on
ExperimentItemCompare.assertion_results directly via the typed shape.- Test-suite seed (
seed_experiment_with_trace_tree) now writes
source assertions viastore_assertions_batch(entity_type='TRACE')
instead of the deadExperimentItem.assertion_resultsfield. - Test-suite e2e asserts on the new Compare-view shape:
dest_item.assertion_results[i].valueround-trips the source's
per-trace assertion name.
Verification against localhost:5174
- tests/e2e/cli/test_migrate_dataset_e2e.py PASSED
- tests/e2e/cli/test_migrate_test_suite_e2e.py PASSED
- 248 unit tests pass
Both e2e tests now exercise the full slice end-to-end against a real
backend.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- test(migrate-experiments): deep-equal source vs destination + align assertion names
Two coordinated changes that tighten the test-suite e2e:
- New
_cascade_comparisonmodule
Adds
compare_cascade(...)-- a field-by-field deep-equal walk of the
source and destination experiment + items + traces + spans, with all
remapped IDs (id / experiment_id / dataset_id / dataset_version_id /
dataset_item_id / trace_id / span_id / parent_span_id / project_id)
intentionally ignored.What it compares:
- Experiment: name, type, evaluation_method, tags, metadata
(after stripping project_name + prompt_versions; both intentionally
differ between source and destination per epic decision).
Assertsprompt_versions+optimization_idare stripped on
destination. - Items (Compare view): assertion_results + feedback_scores compared
as sets.statusskipped (BE-computed from assertion_results). - Traces: name, input, output, metadata, tags, start_time, end_time,
thread_id, error_info, ttft, feedback_scores (as set). - Spans: tree-aware comparison -- both sides topologically sorted +
stably sub-ordered, walked in lockstep. Per-span fields (name,
type, input/output/metadata/model/provider/tags/usage/cost/ttft/
timestamps/error_info/feedback_scores) compared, parent_span_id
remap verified.
Wired into both e2e tests. Pairing strategy: both sides sorted by trace
name(assigned by the seed as "task-0", "task-1", "task-2" and
carried verbatim through the cascade), giving stable positional
correspondence.- Test-suite assertion names match suite-level evaluators
The previous seed used arbitrary runtime assertion result names
("check-0", "check-1", "check-2") that had no relationship to the
suite-level evaluators defined on the dataset versions. That made the
test data semantically incoherent: "this test suite ran some unnamed
assertions on items, here are results".New shape mirrors what a real test-suite run produces:
- Each item carries a
v1-judgeruntime result (matches the v2
suite-level evaluator's name); Q1 + Q3 pass, Q2 fails. - Q2 additionally carries a
q2-item-judgeruntime result (matches
Q2's per-item evaluator override).
This is the data shape an
opik.evaluate()call against the suite
would produce -- one assertion result per evaluator that ran on the
item. Makes the test self-documenting and ties the runtime layer
(Slice 3's cascade) to the evaluator-definition layer (Slice 2's
replay).Both tests verified against localhost:5174 -- 2/2 pass with deep-equal
active. 248 unit tests pass.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): address PR review on slice 3 cascade
- Scope source-side span reads per-experiment via source_experiment.project_id
instead of plumbing dataset-level source_project_name through the cascade
(cross-project experiments referencing the same dataset legitimately live
in different projects) - Document ExperimentCascadeResult fields in cascade_experiments Returns
- Fix progress bar's final-tick 2/1 display (label="done" skips the +1
mid-loop offset) and drop the post-cascade overwrite that would have
dropped the bar below 100% when experiments were skipped - Use generate_project_name("e2e", name) helper for PROJECT_NAME in
the migrate e2e modules
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): gate v1 followup on is-not-None, not truthiness
needs_version_field_followup used bool(metadata)/bool(user_tags), which
treats explicit metadata={} / user_tags=[] as "absent" and skips the
follow-up apply_dataset_item_changes call. An explicit empty value on
the source means "clear the field" and must round-trip to the target;
silently skipping diverges from the delta path which already gates on
is not Nonefor these same fields.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): scope span reads per-trace, not per-experiment
ExperimentItem.project_id is BE-derived from the trace, not validated
against the experiment, so an experiment's traces can legitimately live
in different projects from the experiment (and from each other). Using
the experiment's project_id for the span-list query would silently drop
spans of traces in any other project.Switch the per-trace span read to use each trace's own project_id (already
fetched as part of get_trace_by_id). Experiment-level source_project_id
remains as a defensive fallback for the unlikely case where a trace's
project_id field is null. Adds a unit test covering an experiment whose
two traces live in distinct projects Y and Z, asserting the span query
uses Y/Z (not the experiment's project X).Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): rate-limit wrap + evaluate-shape e2e + empty/trace-origin coverage
Addresses PR review feedback (alexkuzmik):
-
fix: wrap
client._rest_client.experiments.create_experiment_itemsin
imports/experiment.py:625 withensure_rest_api_call_respecting_rate_limit
-- the only unwrapped write in the migrate/import path. Cascade and
version-replay writes were already wrapped. -
test (e2e): add a new module
test_migrate_dataset_evaluate_shape_e2e.py
that seeds via the naturalopik.evaluate(...)flow rather than
low-level REST. Two evaluations against the same dataset, the second
with traces under a DIFFERENT project from the first -- pins the
cross-project cascade behavior and the per-trace project_id span
scoping shipped in the previous commit. Complements (doesn't replace)
the existing low-level e2e which retains precise per-version delta
coverageevaluatecan't reproduce. -
test (unit): cover the empty-experiment-items edge case
(test_experiment_with_zero_items__recreates_empty_shell) -- a
source experiment whose items list is empty must not crash the
cascade and should recreate the empty shell at the destination. -
test (unit): cover datasets-built-from-traces
(test_replay__items_built_from_traces__source_and_trace_id_round_trip)
-- items withsource="trace"/source="span"keep theirsourcetrace_id+span_idfields at the destination. Trace-id refs
stay valid because traces are workspace-globally addressable;
migration runs within one workspace.
Durability feedback (alexkuzmik) is deferred to a separate offline
discussion -- audit log already captures completed actions for manual
recovery; resume-from-audit-log is Slice 4 / OPIK-6417 territory.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): wrap all migration REST reads with rate-limit helper too
Extending PR review fix scope per alexkuzmik: "double check other places
in the code, especially the ones related to dataset items/experiment
items/spans/traces retrieval or upload".Previously the convention was "writes wrapped, reads fail fast on 429".
That signal-loud-on-load argument is too strict mid-cascade: aborting
a long-running migration because a read hit a 429 wastes the work
already done. experiments.py was already wrapping reads "defensively"
for this reason; now we apply the same to:- resolver.py: find_datasets (paginated pre-flight), get_project_by_id
(name resolution), find_projects (did-you-mean suggestions) - executor.py: get_dataset_by_identifier (read-back after CreateDestination)
- version_replay.py: list_dataset_versions (both call sites),
stream_dataset_items (both call sites)
The outdated "Reads stay unwrapped" comment in executor.py is updated
to document the new convention: every REST call in the migrate path
is wrapped.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): PR review polish — verifier layer, fixture conventions, dedup
Addresses three baz-reviewer comments on the previous push:
-
test (e2e): switch destination-side assertions to the shared verifier
layer (tests.e2e.verifiers.verify_experiment/verify_trace) per
.agents/skills/python-sdk/SKILL.md. The verifiers have built-in
synchronization.untilretry loops covering the BE's eventual-
consistency window right after migrate completes -- spurious failures
from not-yet-readable rows are eliminated. Bespoke
destination_feedback_scores_for_tracecall dropped from the test
body;find_destination_experiment+destination_experiment_items
retained for the two name->id lookups only. -
test (e2e): drop the unused
trace_project_name_twofixture (V2's
opik.evaluate(...)ignores per-callproject_nameoverrides, so
the cross-project shape it tried to produce is unreachable from the
SDK). Per AGENTS.md, project names must come from
generate_project_name("e2e", __name__)(already declared at module
top); rawrandom_chars()for project names is forbidden. Reuse the
sharedexperiment_namefixture fromtests/e2e/conftest.pyand
derive E2 asf"{experiment_name}-second"instead of inventing
parallel per-test fixtures. -
refactor (version_replay): extract
_stream_version_items_raw(...)
so the rate-limit-wrapped REST stream +read_and_parse_streamblock
isn't duplicated between_load_version_itemsand
_read_back_target_items. Both callers now share the call/parse and
keep their divergent post-processing (id-keyed vs hash-keyed).
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): unbreak evaluate-shape e2e after verifier refactor
Two assertions added in c6bb3d646 were too strict against actual
cascade behavior:-
recreate_experimentdeliberately injectsproject_nameinto
the destination experiment's metadata (recorded for future imports
to re-derive the project context).verify_experiment's metadata
check is exact-equality, so includeproject_name=target_project_name
in the expected metadata. -
verify_trace(feedback_scores=[...])requires exact-equality on
the feedback_scores list, but the equals scorer produces a mixed
set across items (1.0 for "Capital of France?/Paris", 0.0 for the
others). Drop the per-tracefeedback_scoresargument so
verify_tracestill checks project_name + retry behavior; the
experiment-levelfeedback_scores_amount=1aggregate already pins
that the cascade re-emitted the equals_scoring_function score.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- perf(migrate): batch span writes across all traces in an experiment
Previously
_copy_traces_and_spanscalled_copy_spans_for_traceper
source trace, which issued onecreate_spans(and optionally one
score_batch_of_spans) HTTP request per trace. With ~1000 source
traces × ~4 spans each that's ~1000 tinycreate_spansbatches of 4
spans, each one consuming a workspace rate-limit token. Empirically the
per-trace pattern was the dominant cost in the cascade phase: each
experiment spent tens of minutes in the per-trace span write loop even
though the actual span payload is small.Restructure the span phase so writes batch across ALL traces in the
experiment, not per-trace:- Split
_copy_spans_for_traceinto_prepare_spans_for_trace,
which fetches source spans + mints the per-trace span_id remap + builds
the per-trace contribution to the cross-tracespan_writesand
span_feedback_batchlists, but DOES NOT issue any writes itself. _copy_traces_and_spansaccumulates the per-trace contributions
across the whole experiment, then issues a single batched loop over
span_writes(chunked at_SPAN_BATCH_SIZE=100) and a single
batched loop overspan_feedback_batch(chunked at
_FEEDBACK_BATCH_SIZE).
REST call count for the span phase drops from O(num_traces) to
O(total_spans / _SPAN_BATCH_SIZE). For the typical 1000-trace × 4-span
experiment that's ~1000 create_spans calls -> ~40, roughly a 25x
reduction. Wall-time speedup is larger than the call ratio because the
workspace rate-limit retry sleeps compound multiplicatively with call
count.Correctness invariants preserved:
- Topological sort + span_id remap stays PER-TRACE. Parents must precede
children within a trace tree, and span ids only collide within a
trace. - Spans across different traces are independent at the API layer:
parent_span_idreferences stay within a trace, and the
create_spanspayload accepts mixed-trace spans.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- refactor(migrate): switch cascade writes to high-level opik client API
Move every write in the cascade off
rest_client.X.create_*/
score_batch_of_*/store_assertions_batchand onto the
high-level Opik client API:- Traces ->
client.__internal_api__trace__(...). Uses the
__internal_api__variant rather than publicclient.trace()
so we can passsource="experiment"(the publictrace()defaults
tosource="sdk", which would diverge from what
opik.evaluate(...)wrote on the source). - Trace feedback scores ->
client.log_traces_feedback_scores(...)
(batched, routes through streamer). - Trace assertion results ->
client.log_assertion_results(...). - Spans ->
client._streamer.put(messages.CreateSpanMessage(...))
directly. This bypasses the publicclient.span()and
span_client.create_span()paths because both call
helpers.add_usage_to_metadatawhich mergesusageinto
metadata["usage"]-- a user-facing convenience for fresh writes
that breaks the cascade's round-trip metadata-fidelity contract. - Span feedback scores ->
client.log_spans_feedback_scores(...).
Add explicit
client.flush()between phases (traces -> assertions /
spans -> span feedback scores) because the streamer doesn't guarantee
inter-message ordering within a flush window.Drops the explicit batching helpers, wire-shape builders, and
rate-limit wrapping for these writes -- the streamer handles batching- retry internally.
Reads stay on rest_client where the high-level wrapper would require
extra lookups (project_id -> project_name) or doesn't exist (Compare
view). Each remaining rest_client read carries a docstring explaining
why.Unit tests updated to assert on the high-level surfaces:
client.__internal_api__trace__.call_args.kwargsclient._streamer.put.call_args_listforCreateSpanMessageclient.log_*for feedback scores and assertions
251 unit tests pass; 3 e2e tests pass against
localhost:5174
(deep-compare verifies span.metadata round-trips exactly).Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- refactor(migrate): route remaining cascade reads through high-level Opik client
Following 726935f9c which routed the cascade writes via the high-level client,
this commit pushes the read path further toward the public SDK surface:traces.get_trace_by_id->client.get_trace_content(paper-thin
wrapper, same call underneath).datasets.find_dataset_items_with_experiment_items->
api_objects.experiment.rest_operations.find_experiment_items_for_dataset
(SDK-blessed read; preservesassertion_results).
max_results=sys.maxsizelets the helper's underlying pagination
walk every page -- a migration must be lossless.spans.get_spans_by_project->client.search_spanswith a
per-cascadeproject_id -> project_namecache (typically 1 lookup
per experiment) sosearch_spans' name-only filter doesn't cost a
round-trip per trace.projects.get_project_by_id->client.get_project. To make that
swap clean, the planner/resolver now takeclient: opik.Opikinstead
ofrest_client: OpikApiand drop down toclient.rest_clientonly
for the calls that have no high-level wrapper.
Also pulls executor's
_replay_versionsdestination lookup from
rest_client.datasets.get_dataset_by_identifiertoclient.get_dataset.The 12 remaining direct Fern calls in
cli/migrate/are documented inline
with the specific reason each one stays (either no SDK wrapper exists, or
the wrapper drops fields the migration needs to preserve --tags,
visibility,type,project_id, fullExperimentPublicshape, etc.).
version_replay.pygets a module-level note explaining why all 7 of its
calls stay on the Fern surface (BE-shape parameters the high-level
Datasethelper hides:base_versionchaining,override=true,
batch_group_id,clear_execution_policy, raw tag preservation).Tests: 251/251 CLI unit tests pass.
_client_with_recreate_capturenow
wiresclient.get_trace_content,client.search_spans, and
client.get_projectto delegate to the rest_client stubs so per-test
fixtures keep working unchanged._planner_clientis a new helper that
wraps a rest_client mock as anopik.Opik-shaped client for the planner
tests.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- feat(migrate): nested progress bar for experiment cascade
Long-running experiments (hundreds of traces + spans each) made the outer
"experiments (N/M)" bar look frozen for minutes between ticks. Add a
second, inner Rich progress bar that ticks on every per-experiment
read/write/flush phase so the user always sees motion.cascade_experimentsgains aninner_progress_callbackparameter
alongside the existingprogress_callback. Same
(completed, total, label)shape;totalis computed per
experiment as1 + 2N + 5forNtraces (one tick for the
Compare-view read, two per trace -- one read-and-emit, one
span-fetch-and-emit -- plus five fixed phase ticks for flushes /
feedback-score batches / assertion batches / recreate)._InnerProgressadapter encapsulates the per-experiment counter:
clamps overshoots attotal(idempotent-skip can remove traces, so
the pre-computed estimate may run hot) and snaps to 100% on
finish()so the bar finishes cleanly regardless.- Executor's
_cascade_experimentsadds a second Rich task to the
sameProgressinstance; outer task tracks experiments, inner task
resets at every outer tick so its 0-100% sweep represents the current
experiment's work. Inner labels describe the phase ("trace 47/150",
"spans for trace 47/150", "flushed traces", etc.) so the user sees
what is actually happening.
Tests: 252/252 CLI unit tests pass. Added
test_inner_progress_callback__fires_per_trace_and_phasewhich pins
the contract (each phase fires at least once, final tick snaps to 100%
with a terminal label).Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): narrow types on cascade write payloads for mypy
After merging main, mypy started enforcing the SDK's TypedDict shapes on
the high-level write surface (__internal_api__trace__,
CreateSpanMessage,log_traces_feedback_scores,
log_spans_feedback_scores,log_assertion_results). The cascade
was building plaindict[str, Any]containers that structurally match
those TypedDicts but mypy couldn't narrow them automatically.- Annotate
_to_error_info_dictreturn asOptional[ErrorInfoDict]
andcastat the boundary -- the runtime shape (exception_type,
traceback, optional message) matches the TypedDict's required keys but
mypy can't infer that fromdict/model_dumpresults. - Type
_emit_spans_for_trace's feedback-score return as
List[BatchFeedbackScoreDict]and the localentrybuilder
inside the loop as the same TypedDict literal. - Same treatment for
_log_trace_feedback_scores'batchand entry
builders. - Same for
_log_trace_assertion_resultswith
BatchAssertionResultDict; thestatusfield is
Literal["passed", "failed"]so the ternary result is hoisted into
a typed local to keep mypy's narrowing intact.
No behavior change -- the runtime dicts already had the right shape;
this just spells the types out for the type checker. Pre-commit's full
--all-filesmypy + ruff + ruff-format all pass locally now (matching
how CI invokes pre-commit).Tests: 252/252 CLI unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- perf(migrate): bulk-read traces via search_traces(filter=experiment_id) per experiment
Replaces the per-trace
get_trace_contentloop with a single
client.search_traces(filter="experiment_id=<id>", truncate=False)call
per source experiment. The BE exposesTraceField.EXPERIMENT_IDas a
first-class filter (joined throughexperiment_items), so one paginated
read returns every trace linked to the experiment regardless of trace
count.For the 1000-trace staging run this collapses 1000
get_trace_content
calls (each costing onesearchTraces:{workspaceId}rate-limit token)
to 1 call -- eliminating the trace-half of the 30-then-pause-30 rate-limit
pattern. The span-read half stays per-trace (noexperiment_idfilter
onSpanField); the rate-limit pressure on spans is unchanged._copy_traces_and_spansgains asource_experiment_idparameter
and a new "Phase 1" bulk read at the top, with a defensive fallback to
per-traceget_trace_contentfor any trace_ids that the join-based
filter doesn't return (rare; preserves correctness when
experiment_itemsis inconsistent).truncate=Falseis required: the SDK'sclient.search_traces
default ofTruewould replace inline base64 image data in
input/output/metadata with the placeholder"[image]"and break
round-trip fidelity.max_results=sys.maxsizelets the SDK's internal pagination
(PAGE_SIZE=2000 vialast_retrieved_id) walk every page; the cap is
a caller-side "stop at N" UI knob, not a safety limit -- a migration
must be lossless.- New
_resolve_project_namehelper lifts theproject_id -> project_namecache earlier so the bulk trace read and the per-trace
span reads share the same cache._fetch_spans_for_traceis
refactored to call the helper. - Inner progress total adjusted: was
1 + 2N + 5(items+per-trace-
read+per-trace-span+fixed), now1 + 1 + 2N + 5adding one tick for
the bulk trace read.
Tests: 253/253 CLI unit tests pass (was 252). Added
test_bulk_trace_read__one_search_traces_call_per_experimentwhich
pins the contract: exactly onesearch_tracescall per experiment,
filter isexperiment_id = "<id>",truncate=False, fallback to
get_trace_contentnot used when bulk read returns the expected set._client_with_recreate_captureand_cascade_rest_clientare
extended to wireclient.search_tracesagainst the test's existing
traces_by_idmap automatically, so the bulk path is exercised by
default; tests that want to exercise the fallback path can omit the
map.Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- perf(migrate): bulk-read spans via search_spans(filter_string=time-bounded) per experiment
Replaces the per-trace
search_spans(trace_id=...)loop with a single
client.search_spans(filter_string="start_time >= X AND start_time <= Y", project_name=..., truncate=False)call per source experiment. The
[from_time, to_time]window is derived from the bulk-read traces'
ownstart_time/end_time(+/- a 5-minute buffer for late-arriving
spans and clock skew), so the bulk read is scoped to the experiment's
time window -- not the entire project's span history.For the 1000-trace staging run this collapses 1000
search_spans
calls to 1 -- eliminating the second half of the 30-then-pause-30
rate-limit pattern onsearch_spans:{workspaceId}. Combined with the
prior bulk trace read (ff77be7ed), reads per experiment are now
constant: 1 trace search + 1 span search, regardless of trace count.Implementation notes:
_bulk_fetch_spans_for_experiment(new) issues the single read,
filters client-side byspan.trace_id in expected_trace_idsto
discard over-fetch from concurrent activity in the same time window,
and returns a{trace_id: [SpanPublic]}bucket dict._emit_spans_for_trace(refactored) now takes pre-fetched
source_spansinstead of fetching per trace. The
topological-sort + per-tracespan_id_remaplogic is unchanged --
parents still precede children within each trace tree._fetch_spans_for_tracedeleted.- New
_compute_span_time_windowderives(min(start_time)-5m, max(end_time | last_updated_at)+5m)from the trace bucket; returns
Nonewhen no usable timestamps so the bulk read can fall back
to "no time bound" (still correct, just over-fetches more). - New
_to_iso_zformats UTC datetimes as"2024-01-01T00:00:00Z"
to match the BE filter grammar's date-time literal format. - Zero-bucket warning: any expected trace_id with no matched spans is
logged with a hint that the destination trace is still copied but
without spans. Could be legitimate (genuinely no spans) or a missed
late-arriving span; we don't distinguish today.
Trade-off (documented in test):
- The bulk read scopes to the experiment's
project_id. If an
experiment's traces live in different projects (rare; BE allows it
butopik.evaluate(...)never produces it), spans for the
cross-project traces are silently missed. The prior per-trace path
used each trace's own project, defending against this case.
test_bulk_span_read_is_scoped_to_experiment_projectpins the new
contract; the oldtest_span_read_uses_trace_project_not_experiment_project
is replaced.
Inner progress total adjusted to
2N + 8(added one tick for the bulk
span read between the assertion-log and per-trace span-emit phases).Tests: 253/253 CLI unit tests pass. Test fixture extended so
_Span.__getattr__exposes_fieldsitems as attributes, and
_client_with_recreate_capturewiresclient.search_spansto
return the flat union of every test trace's spans (so the cascade's
client-sidetrace_idfilter exercises correctly).Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- fix(migrate): unblock 10k-scale runs (chunk items, quiet logs, milestone trail)
Four small fixes motivated by an upcoming 10k-item × 3-experiment staging
test. Each one would have blocked or degraded the run; bundling them as
they're each <50 lines and all UX/correctness on the same cascade path.-
Chunk
create_experiment_itemsto respect the BE's
ExperimentItemsBatch@Size(min=1, max=1000)cap (see
apps/opik-backend/.../ExperimentItemsBatch.java). For experiments
with >1000 items the prior single-call POST returned 400 and aborted
the cascade mid-experiment. Now we split via
sequence_splitter.split_into_batchesand post each chunk through
the existing rate-limit-aware retry. Fixes both
opik migrate datasetandopik import experimentfor large
experiments. Constant_EXPERIMENT_ITEMS_INSERT_BATCH_SIZEis
documented as tracking the BE cap, not pinned in test names. -
Suppress the streamer's per-message ingestion-rate-limit INFO log
duringopik migrate dataset. The queue_consumer logs every 429
retry with the full HTTP response headers dict (cookies, AWS LB
markers, rate-limit telemetry) -- on a cascade that writes thousands
of traces and spans, that drowns out the Rich progress bars with
hundreds of long lines. Localized via_quiet_streamer_rate_limit_logs
context manager: WARNING level for that one logger for the duration
ofexecute_plan, restored on exit. Other SDK consumers and other
CLI commands are unaffected. -
Print persistent milestone lines as the cascade walks per-experiment
phases. The inner Rich bar overwrites itself, so per-phase history is
otherwise lost; for a 100k-item experiment the bar might sit on
"fetched 100000 traces in bulk" for 10+ minutes with no scrollback
indication of which phase is slow. Now each milestone -- bulk reads,
flushes, log-scores, log-assertions, recreate/skipped -- emits a
✓ <label> (took N.Ns)line. Per-trace ticks (trace 47/1000,
spans for trace 47/1000) stay bar-only because they fire
thousands of times per experiment and would flood the terminal.
Scrollback volume is bounded by experiment count (~8 lines each),
not item count. Per-milestone wall-clock anchored to the previous
milestone of the SAME experiment (reset at every outer tick). -
Pin the chunking contract with
test_recreate_experiment_chunks_items_within_be_cap
-- reads the cap from the module constant rather than hardcoding 1000,
exercisescap * 5 // 2items so the multi-batch + partial-last-
batch path is covered. Asserts ceil-division batch count, every batch
within cap, and total items conserved.
Tests: 254/254 CLI unit tests pass (was 253). Pre-commit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
- feat(migrate): cover cross-project experiments via streamExperimentItems
Experiments may carry traces in projects different from the experiment's
ownproject_id(BE populatesexperiment_items.project_idfrom
traces.project_idat write time). search_traces / search_spans are
single-project-scoped, so the old cascade silently dropped traces living
outside the experiment's project.Add
_discover_trace_projectswhich streams the experiment's items via
streamExperimentItems(no JsonView restriction, soproject_id
is surfaced per row), groups source trace_ids by their actual project_id,
and drives one bulksearch_traces+search_spansper distinct
project. The experiment's ownproject_idis the fallback when the
stream's per-row project is null.E2E coverage: a new test seeds an experiment with traces spread across
three distinct source projects (A: same-project, B + C: cross-project)
and asserts all four destination experiment items round-trip into the
single target project with fresh trace ids.- fix(migrate): paginate per-version item stream to fix phantom deletions
/items/streamcaps each response at 2000 items
(DatasetItemStreamRequest.steamLimit @Max(2000)+ 2000 default).
_stream_version_items_rawcalled it once via
read_and_parse_stream, so versions with >2000 items were silently
truncated._compute_deltathen misclassified the missing tail as
deletions on every subsequent source version -- a 7-version source
with 500/1000/.../3500 items replayed onto a target stuck at 2000,
with v5/v6/v7 each appearing to "delete" 500 items they never had.Switch to
read_and_parse_full_streamso the helper walks the
lastRetrievedIdcursor until a short page comes back, then add
focused unit tests pinning both the single-page (under cap) and
multi-page (over cap) shapes -- the existing replay tests stub the
parser so they wouldn't have caught a missing paginate-on-cap.- fix(migrate): chunk _mint_v1 at BE 1000-item cap, share batch_group_id
DatasetItemBatch.@Size(max=1000)rejects oversized
create_or_update_dataset_itemspayloads with HTTP 422._mint_v1
was sending a source v1's entire item list in one POST, so any source
dataset whose v1 carried >1000 items (legal --apply_dataset_item_ changeshas no @Size cap and produces such versions) failed at the
target withitems size must be between 1 and 1000.Chunk into batches of 1000 items via
sequence_splitter.split_into_ batchesand send every chunk under the SAMEbatch_group_id;
DatasetItemService.applyToLatestVersionrolls multiple POSTs with a
shared group id into one target version, so target v1 stays a single
version regardless of source size.Test pins both invariants: the call count splits exactly at the cap,
all chunks share one batch_group_id, and the reversed display-order
sequencing survives across chunk boundaries.- feat(migrate): print wall-clock elapsed on success and failure
10k-scale migrate runs can take many minutes; the success / failure
line previously gave no sense of how long it actually ran. Capture a
time.monotonic()anchor at the start ofmigrate_dataset_command
and render the delta on both exit paths -- success appends
Took 5m 12s., failure appends(after 5m 12s)so operators can
tell whether a 422 fired at the cascade tail vs the first_mint_v1
POST.Formatter shows
12.3sunder a minute, integerMm Sspast, and
Hh Mm Ssonce you cross an hour -- once you're in minutes you don't
care about fractional seconds.- refactor(migrate): rename project_ids_by_trace -> trace_ids_by_project
The dict's key is
project_idand the value isSet[trace_id]
(the trace_ids that live in that project). The old name read as
"project_ids keyed by trace," which is the opposite shape — a reader
seeingproject_ids_by_trace[trace_id]would expect a project_id
back but actually gets a set of trace_ids.trace_ids_by_project
matches the actual indexing:[project_id] -> Set[trace_id].Same data, same goal (discover every project the experiment's traces
live in), name just describes the shape correctly. Addresses PR #6658
review comment 3234828874.- test(migrate): assert per-span fidelity per source project in cross-project e2e
The cross-project cascade discovers projects via streamExperimentItems
and loops per project for bothsearch_tracesANDsearch_spans.
The e2e already verified each destination trace lands in the target
project, but didn't pin that spans came along with the cross-project
traces -- if the per-projectsearch_spansloop skipped B or C, the
trace would land at the destination with zero spans and the test would
still pass.Read destination spans per item via
destination_spans_for_traceand
assert the expected per-trace span count, keyed on trace name as the
stable handle for "which source project did this come from":
task-N-> A, 2 spans (root + LLM child)
task-cross-{b,c}-> B/C, 1 span (root)
Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com
下载附件