Skip to content

fix(tracing): keep metadata within the span attribute limit - #1957

Merged
hassiebp merged 8 commits into
prepare-v5-releasefrom
fix/metadata-attribute-limit
Oct 9, 2026
Merged

hassiebp merged 8 commits into
prepare-v5-releasefrom
fix/metadata-attribute-limit

Conversation

@niklassemmler

@niklassemmler niklassemmler commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Observation metadata is written as one OTel attribute per key (langfuse.observation.metadata.<key>). The OTel SDK caps attributes per span (SpanLimits.max_span_attributes, default 128, configurable via OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT), and BoundedAttributes evicts the oldest attribute on overflow. Langfuse core attributes are written first, so large metadata evicts them.

Repro on main: a generation started with input, model and 130 metadata keys ended with 128 attributes and dropped_attributes=9. input, model, version and similar attributes were gone; only langfuse.observation.type and output survived. Propagated trace attributes (langfuse.trace.metadata.*, user id, session id, tags) use up the same budget.

Behavior changes

What users will notice compared to main:

  • Earlier attributes are no longer evicted. When metadata fills the span, input, model, version and other earlier attributes used to be evicted silently. Now the excess metadata keys are dropped instead, and later writes (output, usage, cost) still land.
  • A full span drops the newest attribute instead of evicting the oldest, for every Langfuse write (observation updates, propagated attributes, experiment attributes). This matches OTel JS and langfuse-js.
  • New warning log. Each write that drops keys logs one warning naming the observation, the limit and up to 5 dropped keys, in the same format as langfuse-js.
  • Observations with very large metadata keep a few fewer metadata keys. Metadata leaves room for attributes that later updates may still write: up to 7 slots for spans and other non-generation types, 14 for generations and embeddings, as in langfuse-js.
  • Experiments:
    • When item metadata hits the limit, user metadata keys are dropped before the experiment run keys (experiment_name, experiment_run_name, dataset_id, dataset_item_id). Run keys still win over user keys with the same name.
    • Wire format change: experiment metadata and experiment item metadata are sent as one JSON attribute each (langfuse.experiment.metadata, langfuse.experiment.item.metadata) instead of one attribute per flattened key, as in langfuse-js. The server stores the same paths. Custom span processors, exporters or masking functions that read the per-key attributes will see the new format.
    • The experiment task span gets its observation metadata after the experiment attributes, so large item metadata trims the duplicate observation metadata instead of dropping the task output.
    • Experiment attributes set by the SDK skip the 200-character limit for propagated values, so long run names now propagate, as in langfuse-js.
  • Unchanged: observation metadata's per-key wire format, copied trace metadata (never trimmed), and spans under the limit.

Fix

_drop_attributes_over_span_limit in langfuse/_client/span.py (via _set_span_attributes_within_limit) runs before every Langfuse attribute write on a span: the observation constructor (span start, which covers start_observation, start_as_current_observation, @observe, LangChain and OpenAI), update() (which also covers update_current_span / update_current_generation), _set_processed_span_attributes, set_trace_as_public, the experiment item attribute writes and parent backfill in run_experiment, as_root marking, propagated attributes (span processor on_start and propagate_attributes on the current span), and the app-root marker.

It counts the span's existing attribute keys (ReadableSpan.attributes) plus the new non-None keys that aren't already on the span. If that exceeds span._limits.max_span_attributes, it works in two steps, both keeping insertion order:

  1. Metadata budget: drops the excess new metadata keys from the tail (with reserved slots, see below).
  2. Hard guard: if the remaining new keys still exceed the physical capacity (limit minus attributes already on the span), drops the excess new keys from the tail.

This matches OTel JS, which drops the newest attribute once a span is full, whereas Python's BoundedAttributes evicts the oldest. Langfuse writes no longer evict an existing attribute. Overwriting a key that's already on the span costs nothing and is always kept. Spans without SDK limits (non-recording or non-SDK spans) are left alone. It keeps no side state: the OTel span already holds the key set.

Experiment run keys

run_experiment used to put experiment_name, experiment_run_name, dataset_id and dataset_item_id after item and experiment metadata in the task span metadata, so they were the first keys to be dropped. They now go first in insertion order (so they survive truncation) and again last (so they still win over user keys of the same name), like langfuse-js.

Experiment metadata wire format

Experiment metadata and experiment item metadata used to be propagated as one attribute per flattened key (langfuse.experiment.item.metadata.<path>), so an item with many metadata keys filled the task span and every child span. They are now sent as one serialized JSON attribute each (langfuse.experiment.metadata, langfuse.experiment.item.metadata), like langfuse-js (serializeValue(...)). The server (extractMetadataFromPrefix) parses both the blob at the bare prefix and per-key attributes and flattens them to the same stored paths. The blob is also the format the server parsed first: experiment ingestion (langfuse/langfuse#10004, Nov 2025) only did JSON.parse on the bare attribute, and per-key parsing was added later (langfuse/langfuse#13368, Apr 2026). So the blob works on older self-hosted servers too. Like JS, these SDK-set experiment attributes skip the 200-character validation for user-propagated values. _flatten_and_serialize_metadata_values is now unused and removed.

Experiment task span write order

The task span used to get its observation metadata at start, before the experiment attributes were copied onto it, so the copied attributes could take the slot reserved for the output. It now starts without metadata and gets update(metadata=...) inside the propagation block, before the task runs. The metadata cap then counts the copied attributes and trims the duplicate observation metadata, so the output still lands. The item-run span already writes its metadata in the final update() after the backfill, so it needed no change.

Behavior notes

  • Each write that drops keys logs one langfuse_logger.warning, in the same format as langfuse-js: Dropped <n> metadata key(s) from observation '<name>' to stay within the span attribute limit of <limit> (SpanLimits.max_span_attributes / OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT). Dropped keys include: <up to 5 keys>. When the hard guard also drops non-metadata keys, it reads Dropped <n> metadata key(s) and <m> other attribute(s) from observation ....
  • It never raises, because raising would break user code and integrations.
  • The budget includes trace and propagated attributes (langfuse.trace.*), environment, release, and so on, because they all live on the same span.
  • Observation metadata keeps its per-key wire format and value serialization (a parallel PR handles those). Only experiment metadata changes format, see above.
  • Two writes racing on the same span from different threads can still overshoot the limit. That falls back to the OTel eviction behavior and doesn't break anything.
  • The hard guard uses the physical capacity, not the reserved slots, so attributes that don't count as observation metadata can still use up the reserved slots.
  • Copied trace and experiment metadata (langfuse.trace.metadata.*, langfuse.experiment.metadata, langfuse.experiment.item.metadata) is never trimmed by the metadata cap, because the server reads it from the trace root / experiment item root span.

Reserved slots

Capping metadata so the span holds exactly max_span_attributes isn't enough. The next new non-metadata attribute would overflow the span: without the hard guard, BoundedAttributes silently evicts the oldest one, and with it the new attribute (e.g. the output) would be dropped. Repro (limit 128): a generation started with input, model and 130 metadata keys ends up with 124 metadata keys and 128 attributes. A following update(output=...) then evicts langfuse.internal.is_app_root (dropped_attributes=1), and more updates (usage, cost, ...) would evict input, model and so on.

The metadata budget therefore also holds back one slot for each observation attribute that a later update may still write and that isn't on the span or in the current write yet. The set depends on the observation type, like langfuse-js #1004:

  • Span-like observations (span, agent, tool, chain, retriever, evaluator, guardrail) and events: type, level, status message, version, environment, input and output (7 keys, _RESERVED_SPAN_ATTRIBUTE_KEYS).
  • Generation-like observations (generation, embedding): those 7 plus model, model parameters, usage details, cost details, completion start time, prompt name and prompt version (14 keys, _RESERVED_OBSERVATION_ATTRIBUTE_KEYS).
  • Guarded writes where the observation type isn't known (as_root marking, propagated attributes, app-root marker): the 14-key set, the safe default.

Reserved keys already on the span cost nothing.

Cost: when metadata is very large, up to 7 (spans) or 14 (generations) fewer metadata keys are kept than strictly fit. With the fix, the repro above ends with 113 metadata keys, 118 attributes and dropped_attributes=0, with input, model, output and is_app_root all present. A plain span started with only input and 130 metadata keys keeps 7 more metadata keys than a generation started the same way.

Context

This is one of three small PRs that replace the abandoned single-JSON-blob approach in #1944 (LFE-17153).

Verification

  • tests/unit/test_metadata_attribute_limit.py: 16 tests, all pass, including the reserved set by observation type (a span keeps 7 more metadata keys than a generation; a generation still keeps room for model, usage, cost and output). New in this round: the hard guard (span full, then new non-metadata attributes: dropped_attributes == 0, earlier attributes kept, newest dropped, one warning), overwrites kept and the tail dropped at the helper level, the helper never raises, the exact warning format, and the experiment run keys surviving truncation on the task span (the user's experiment_name is overridden). The experiment test fails without the reordering.
  • New test_experiment_with_large_metadata_keeps_output_and_experiment_attributes: an experiment item with ~150 metadata keys including a nested dict. On the task span: output present, langfuse.experiment.item.metadata is one JSON string that round-trips to the item metadata, experiment metadata / id / name / item id / root observation id present, run keys in the observation metadata, dropped_attributes == 0. A child span created in the task has both blobs and isn't flooded. Fails without the write-order change.
  • Existing propagation and experiment tests in tests/unit/test_propagate_attributes.py and tests/unit/test_experiment.py now assert the single JSON attributes (168 passed).
  • uv run --frozen ruff check .: passes
  • uv run --frozen mypy langfuse --no-error-summary: passes
  • uv run --frozen ruff format --check .: passes for all changed files. It flags tests/unit/test_media.py, which is already unformatted on the base branch and was left alone to keep this PR scoped.
  • uv run --frozen pytest -n auto --dist worksteal tests/unit with dummy LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_BASE_URL: 798 passed, 2 skipped. Without those variables, the 18 tests in tests/unit/test_prompt.py error with "Langfuse client is not initialized", on the base branch too.
  • Skipped: e2e and live-provider suites. The change is SDK-local attribute filtering, so the unit tests with the in-memory exporter cover it.

🤖 Written by Claude (an AI agent) on behalf of Niklas

🤖 Generated with Claude Code

RetriggerConfidence Score: 4/5

The PR needs a fix for later output and usage writes on spans already filled by metadata before merging.

Summary

Adds a shared filter that drops excess new observation metadata before writing span attributes. Adds seven tests for limits, updates, overwrites, warnings, and propagated attributes.

  • The fix still allows later output and usage writes to evict earlier fields after metadata fills the span.
  • Tests that expect the default limit should set it explicitly.
  • niklassemmler explicitly accepts overflow from concurrent writes and leaves spans without SDK limits unchanged.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Create or update observation] --> B[Process media and mask values]
    B --> C[Count existing and new attributes]
    C --> D[Drop excess new metadata]
    D --> E[Write span attributes]
    E --> F{Does the write still exceed the limit?}
    F -->|No| G[Keep earlier fields]
    F -->|Yes: later non-metadata fields| H[OpenTelemetry evicts earlier fields]
Loading

Reviews (1) · Last reviewed commit: "fix(tracing): keep metadata within the s..." · Reviewed by Greptile

Each metadata key is written as its own OTel attribute. Once a span holds
more than SpanLimits.max_span_attributes (default 128) attributes, the OTel
SDK evicts the oldest ones, which are the Langfuse core attributes such as
input, model and version.

Before writing observation attributes, count the span's existing attribute
keys plus the new keys and drop the excess new metadata keys from the tail.
Non-metadata attributes and overwrites of existing keys are never dropped.
Log one warning per write that dropped keys instead of raising.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@niklassemmler
niklassemmler force-pushed the fix/metadata-attribute-limit branch from 8c564d9 to f097372 Compare October 8, 2026 08:42
@niklassemmler
niklassemmler changed the base branch from main to prepare-v5-release October 8, 2026 08:42
@niklassemmler
niklassemmler marked this pull request as ready for review October 8, 2026 08:42
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

No review was started: this request came from a bot account. Manual reviews can only be requested by someone with write access to this repository. Ask a maintainer to comment @claude review, or have your automation post the comment from a user account with write access.

Tip: disable this comment in your organization's Code Review settings.

Comment thread langfuse/_client/span.py Outdated
Comment on lines +95 to +101
new_metadata_keys = [key for key in new_keys if _is_observation_metadata_key(key)]
free_slots = max(
0, max_attributes - len(existing) - (len(new_keys) - len(new_metadata_keys))
)
dropped = new_metadata_keys[free_slots:]
if not dropped:
return attributes

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.

P1 Later updates lose earlier fields

When metadata fills a span at creation, a later update(output=..., usage_details=...) still exceeds the limit. The helper only drops new metadata keys, so an update without metadata returns unchanged. OpenTelemetry then evicts earlier core or trace attributes. The OpenAI integration follows this start-then-update pattern.

Reserve space for fields added later, or reclaim metadata space before adding them. Add a test that fills the span before updating its output and usage.

Knowledge Base Used: Observations, spans, and traces

Prompt To Fix With AI
This is a comment left during a code review.
Path: langfuse/_client/span.py
Line: 95-101

Comment:
**Later updates lose earlier fields**

When metadata fills a span at creation, a later `update(output=..., usage_details=...)` still exceeds the limit. The helper only drops new metadata keys, so an update without metadata returns unchanged. OpenTelemetry then evicts earlier core or trace attributes. The OpenAI integration follows this start-then-update pattern.

Reserve space for fields added later, or reclaim metadata space before adding them. Add a test that fills the span before updating its output and usage.

**Knowledge Base Used:** [Observations, spans, and traces](https://app.greptile.com/personal-org-4986/-/custom-context/knowledge-base/langfuse/langfuse-python/-/docs/observations-spans-and-traces.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +38 to +41
def test_metadata_over_limit_at_start_keeps_core_attributes(
langfuse_memory_client, get_span, caplog
):
metadata = {f"key_{i}": f"value_{i}" for i in range(130)}

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 Custom limits break tests

These tests assume a limit of 128, but langfuse_memory_client creates its provider without clearing or setting OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT. If the environment sets a valid custom limit such as 256, the overflow and warning assertions fail even when the code works.

Set the limit explicitly before creating the provider for these tests, as small_limit_client already does.

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/test_metadata_attribute_limit.py
Line: 38-41

Comment:
**Custom limits break tests**

These tests assume a limit of 128, but `langfuse_memory_client` creates its provider without clearing or setting `OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT`. If the environment sets a valid custom limit such as 256, the overflow and warning assertions fail even when the code works.

Set the limit explicitly before creating the provider for these tests, as `small_limit_client` already does.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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!

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Beyond the inline findings, I also checked whether the third call site's unfiltered-looking attributes dict (span.py:745-747) could pass None values into _drop_metadata_over_attribute_limit — it can't, since create_span_attributes/create_generation_attributes already strip None before returning. Also confirmed str.removeprefix is safe given this package requires Python >=3.11.

Extended reasoning...

The diff adds a helper that counts a span's existing OTel attributes plus pending new ones and, if over max_span_attributes, drops excess new observation-metadata keys (by key prefix) rather than letting the OTel SDK evict the oldest (core) attributes; it's wired into the three set_attributes call sites in span.py. No auth/crypto/injection surface; this is local attribute bookkeeping backed by a new unit test file. The already-identified candidate issues (reliance on private _limits.max_span_attributes, non-metadata keys still able to trigger eviction, propagate_attributes and cross-thread races not covered) are real residual limitations but are explicitly acknowledged as out of scope or accepted tradeoffs by the PR description, and none of them are regressions versus current main.

@niklassemmler

Copy link
Copy Markdown
Contributor Author

@claude review

🤖 Written by Claude (an AI agent) on behalf of Niklas

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread langfuse/_client/span.py
Comment thread langfuse/_client/span.py Outdated
Capping metadata at exactly the span attribute limit left no room for
attributes written in later updates, so e.g. update(output=...) made the
OTel SDK evict the oldest attribute (langfuse.internal.is_app_root, then
input, model, ...). Metadata now also leaves one slot for each reserved
observation attribute that is not on the span or in the current write
yet, matching langfuse-js.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@niklassemmler

Copy link
Copy Markdown
Contributor Author

@claude review

🤖 Written by Claude (an AI agent) on behalf of Niklas

The tests assert the OTel default of 128 attributes, so a custom
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT or OTEL_ATTRIBUTE_COUNT_LIMIT in the
environment made them fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@niklassemmler

Copy link
Copy Markdown
Contributor Author

@claude review

🤖 Written by Claude (an AI agent) on behalf of Niklas

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked whether the per-write warning logging added in _drop_metadata_over_attribute_limit (span.py:133-144) could spam logs without throttling on repeated overflowing writes — it logs once per call that actually drops keys, which matches the PR's stated design and isn't a bug.

Extended reasoning...

The diff adds attribute-budget enforcement for observation metadata in langfuse/_client/span.py (a new helper plus a reserved-keys tuple applied at three set_attributes call sites) with accompanying unit tests; no auth/crypto/injection surface is touched, this is purely SDK-local span-attribute bookkeeping. A prior run already flagged core-attribute eviction and orphaned media uploads, and the author pushed two follow-up commits (5b626a2, f9a20c2) that added the reserved-slot logic and pinned the test limit, addressing the second point; this run's new confirmed finding (reserved slots wasted for non-generation observation types) is a real but narrow design gap, not large enough to warrant restating here since it is posted inline.

Comment thread langfuse/_client/span.py Outdated
Comment on lines +72 to +86
_RESERVED_OBSERVATION_ATTRIBUTE_KEYS = (
LangfuseOtelSpanAttributes.OBSERVATION_TYPE,
LangfuseOtelSpanAttributes.OBSERVATION_LEVEL,
LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE,
LangfuseOtelSpanAttributes.VERSION,
LangfuseOtelSpanAttributes.ENVIRONMENT,
LangfuseOtelSpanAttributes.OBSERVATION_INPUT,
LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT,
LangfuseOtelSpanAttributes.OBSERVATION_MODEL,
LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS,
LangfuseOtelSpanAttributes.OBSERVATION_COST_DETAILS,
LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME,
LangfuseOtelSpanAttributes.OBSERVATION_MODEL_PARAMETERS,
LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_NAME,
LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_VERSION,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) Every span/event observation (non-generation) permanently loses metadata capacity for 7 reserved keys it will never write. _RESERVED_OBSERVATION_ATTRIBUTE_KEYS (span.py:72-86) always includes OBSERVATION_MODEL, MODEL_PARAMETERS, USAGE_DETAILS, COST_DETAILS, COMPLETION_START_TIME, PROMPT_NAME, PROMPT_VERSION, but update() for span/event types only ever calls create_span_attributes (span.py:758), which never emits those keys. _drop_metadata_over_attribute_limit is a module function with no access to self._observation_type, so it reserves all 14 slots unconditionally, dropping up to 7 more metadata keys than needed on every plain span or event. Fix: make the reserved-key set type-aware so span/event observations keep their full metadata budget.

Why this was flagged

Trigger: any non-generation observation (default as_type="span", or "event") with metadata large enough to approach the span attribute limit, e.g. the small_limit_client tests with limit=40. _drop_metadata_over_attribute_limit (span.py:116-121) counts all 14 _RESERVED_OBSERVATION_ATTRIBUTE_KEYS as reserved even though create_span_attributes (span.py:758) never writes OBSERVATION_MODEL/MODEL_PARAMETERS/USAGE_DETAILS/COST_DETAILS/COMPLETION_START_TIME/PROMPT_NAME/PROMPT_VERSION for this observation type. This wastes 7 of the 14 reserved slots that could otherwise hold metadata, so more metadata keys are dropped than the stated design (reserve only for fields that may still be written) intends. On base there was no reservation at all (and no dropping), so this is a new, avoidable over-drop introduced by the fix, beyond what the PR's own documented "up to 14 fewer metadata keys" cost already accounts for since it conflates generation-only keys with span-only observations.

Verification: nit. reserved_count (langfuse/_client/span.py:116-120) counts all 14 entries of _RESERVED_OBSERVATION_ATTRIBUTE_KEYS (span.py:72-86) unconditionally. For span/event, update() takes the else branch at span.py:756-773 calling create_span_attributes, which never writes the 7 generation-only reserved keys. These still count toward reserved_count, shrinking free_slots (lines 126-128), so up to 7 metadata keys are dropped that would have fit.

niklassemmler and others added 5 commits October 8, 2026 14:03
…ibute overflow

The Python OTel SDK evicts the oldest attribute once a span is full, while
OTel JS drops the newest. After capping metadata, the limit helper now also
drops excess new non-metadata keys from the tail when the span has no
physical capacity left, so Langfuse writes never evict existing attributes.
Overwrites of existing keys are always kept, and at most one warning is
logged per write.

Langfuse write paths on observation spans now go through the guard: the
observation constructor, update(), media-processed updates,
set_trace_as_public, the experiment item attribute writes, as_root marking,
propagated attributes in the span processor and propagate_attributes, and
the app-root marker.

The warning now names SpanLimits.max_span_attributes alongside
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, matching langfuse-js.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n limit

The task span metadata put experiment_name, experiment_run_name and the
dataset ids after item and experiment metadata, so the span attribute limit
dropped them first. Insert the run keys first so they survive truncation,
and again last so they still win over user keys of the same name. Mirrors
langfuse-js.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Span-like observations and events never write model, usage, cost,
completion start time, model parameters or prompt attributes, so reserving
slots for them wasted 7 metadata keys. Reserve the 7 shared keys (type,
level, status message, version, environment, input, output) for span-like
types and events, and all 14 for generation-like types and for writes whose
observation type is unknown (as_root, propagated attributes, app-root
marker). Mirrors langfuse-js.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Experiment metadata and experiment item metadata were propagated as one
attribute per flattened key (langfuse.experiment.item.metadata.<path>), so
large item metadata filled the task span and its children and left no room
for later attributes such as the output. Send each as a single serialized
JSON attribute (langfuse.experiment.metadata and
langfuse.experiment.item.metadata), like langfuse-js. The server parses
the blob at the bare prefix and flattens it to the same paths.

Experiment attributes are set by the SDK and skip the 200-character
validation of user-propagated values, matching langfuse-js. Remove the now
unused _flatten_and_serialize_metadata_values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…butes

The experiment task span got its observation metadata at start, before the
experiment attributes were copied onto it. The metadata cap could then fill
the span up to the reserved slots, and the copied attributes used those
slots, so the later output was dropped. Start the task span without
metadata and set it inside the propagation block, before the task runs, so
the cap counts the copied attributes and trims the observation metadata
instead. The experiment run keys still go first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@niklassemmler
niklassemmler requested a review from hassiebp October 8, 2026 12:49
@niklassemmler niklassemmler self-assigned this Oct 8, 2026
@hassiebp
hassiebp merged commit f91a6aa into prepare-v5-release Oct 9, 2026
16 checks passed
@hassiebp
hassiebp deleted the fix/metadata-attribute-limit branch October 9, 2026 08:47
@hassiebp hassiebp mentioned this pull request Oct 13, 2026
8 tasks done
hassiebp added a commit that referenced this pull request Oct 14, 2026
* chore!: require Python 3.11 or newer (#1927)

* chore!: require Python 3.11 or newer

Python 3.10 reaches end of life in October 2026. Raise requires-python
to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and
remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and
typing.NotRequired.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(observe): keep detecting legacy-marked coroutine functions

inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker,
which asgiref's markcoroutinefunction sets on Python < 3.12. Accept
both so such functions keep the async wrapper and their observation
ends after the coroutine runs.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(openai)!: drop support for OpenAI SDK versions below 1.0 (#1928)

* chore!: require Python 3.11 or newer

Python 3.10 reaches end of life in October 2026. Raise requires-python
to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and
remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and
typing.NotRequired.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(openai)!: drop support for OpenAI SDK versions below 1.0

The OpenAI integration now always uses the v1 client shapes. Remove the
pre-1.0 method definitions and every _is_openai_v1() branch, and raise
the dev dependency floor to openai>=1.0.0.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(observe): keep detecting legacy-marked coroutine functions

inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker,
which asgiref's markcoroutinefunction sets on Python < 3.12. Accept
both so such functions keep the async wrapper and their observation
ends after the coroutine runs.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(otel)!: send x-langfuse-ingestion-version: 4 by default (#1930)

* chore!: require Python 3.11 or newer

Python 3.10 reaches end of life in October 2026. Raise requires-python
to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and
remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and
typing.NotRequired.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(openai)!: drop support for OpenAI SDK versions below 1.0

The OpenAI integration now always uses the v1 client shapes. Remove the
pre-1.0 method definitions and every _is_openai_v1() branch, and raise
the dev dependency floor to openai>=1.0.0.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(otel)!: send x-langfuse-ingestion-version: 4 by default

The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so
servers in dual-write or preview mode process exported spans directly.
It is a no-op on events_only deployments, and additional_headers can
still override it.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(observe): keep detecting legacy-marked coroutine functions

inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker,
which asgiref's markcoroutinefunction sets on Python < 3.12. Accept
both so such functions keep the async wrapper and their observation
ends after the coroutine runs.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(otel): pin that a mixed-case ingestion-version override wins

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: re-run CI

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client)!: remove blocked_instrumentation_scopes (#1931)

* chore!: require Python 3.11 or newer

Python 3.10 reaches end of life in October 2026. Raise requires-python
to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and
remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and
typing.NotRequired.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(openai)!: drop support for OpenAI SDK versions below 1.0

The OpenAI integration now always uses the v1 client shapes. Remove the
pre-1.0 method definitions and every _is_openai_v1() branch, and raise
the dev dependency floor to openai>=1.0.0.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(otel)!: send x-langfuse-ingestion-version: 4 by default

The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so
servers in dual-write or preview mode process exported spans directly.
It is a no-op on events_only deployments, and additional_headers can
still override it.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client)!: remove blocked_instrumentation_scopes

blocked_instrumentation_scopes was deprecated in favor of
should_export_span. Remove it from Langfuse, the resource manager and
the span processor, and drop the leftover is_active entry from the
create_prompt docstring. LANGFUSE_HOST and host keep working, but now
log a deprecation warning when they decide the base URL.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(observe): keep detecting legacy-marked coroutine functions

inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker,
which asgiref's markcoroutinefunction sets on Python < 3.12. Accept
both so such functions keep the async wrapper and their observation
ends after the coroutine runs.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(otel): pin that a mixed-case ingestion-version override wins

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client): keep host and LANGFUSE_HOST working without a warning

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: re-run CI

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(tracing)!: JSON-serialize propagated metadata values (#1932)

* chore!: require Python 3.11 or newer

Python 3.10 reaches end of life in October 2026. Raise requires-python
to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and
remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and
typing.NotRequired.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(openai)!: drop support for OpenAI SDK versions below 1.0

The OpenAI integration now always uses the v1 client shapes. Remove the
pre-1.0 method definitions and every _is_openai_v1() branch, and raise
the dev dependency floor to openai>=1.0.0.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(otel)!: send x-langfuse-ingestion-version: 4 by default

The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so
servers in dual-write or preview mode process exported spans directly.
It is a no-op on events_only deployments, and additional_headers can
still override it.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client)!: remove blocked_instrumentation_scopes

blocked_instrumentation_scopes was deprecated in favor of
should_export_span. Remove it from Langfuse, the resource manager and
the span processor, and drop the leftover is_active entry from the
create_prompt docstring. LANGFUSE_HOST and host keep working, but now
log a deprecation warning when they decide the base URL.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(tracing)!: JSON-serialize propagated metadata values

propagate_attributes(metadata=...) coerced non-string values with str(),
so a boolean became "True" and a list became its Python repr. Serialize
them as JSON instead, like observation metadata, and drop None values
instead of sending "None".

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): serialize propagated metadata like JSON.stringify

Use compact separators, keep non-ASCII characters, and send None as
"null", so the Python and JS SDKs emit byte-identical propagated
metadata values.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(observe): keep detecting legacy-marked coroutine functions

inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker,
which asgiref's markcoroutinefunction sets on Python < 3.12. Accept
both so such functions keep the async wrapper and their observation
ends after the coroutine runs.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(otel): pin that a mixed-case ingestion-version override wins

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): keep the exact digits of large integer metadata values

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): match JS for nested big ints and non-finite metadata

Integers keep their exact digits as JSON numbers at any depth, and
propagated metadata values containing NaN or Infinity are dropped with a
warning instead of being stored, matching the JS SDK.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client): keep host and LANGFUSE_HOST working without a warning

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client)!: remove get_dataset_run, get_dataset_runs and delete_dataset_run (#1933)

* chore!: require Python 3.11 or newer

Python 3.10 reaches end of life in October 2026. Raise requires-python
to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and
remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and
typing.NotRequired.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(openai)!: drop support for OpenAI SDK versions below 1.0

The OpenAI integration now always uses the v1 client shapes. Remove the
pre-1.0 method definitions and every _is_openai_v1() branch, and raise
the dev dependency floor to openai>=1.0.0.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(otel)!: send x-langfuse-ingestion-version: 4 by default

The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so
servers in dual-write or preview mode process exported spans directly.
It is a no-op on events_only deployments, and additional_headers can
still override it.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client)!: remove blocked_instrumentation_scopes

blocked_instrumentation_scopes was deprecated in favor of
should_export_span. Remove it from Langfuse, the resource manager and
the span processor, and drop the leftover is_active entry from the
create_prompt docstring. LANGFUSE_HOST and host keep working, but now
log a deprecation warning when they decide the base URL.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(tracing)!: JSON-serialize propagated metadata values

propagate_attributes(metadata=...) coerced non-string values with str(),
so a boolean became "True" and a list became its Python repr. Serialize
them as JSON instead, like observation metadata, and drop None values
instead of sending "None".

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client)!: remove get_dataset_run, get_dataset_runs and delete_dataset_run

These helpers call the dataset-run endpoints that Langfuse v4 no longer
serves, so they always fail with a 404. Read experiment runs through
langfuse.api.experiments.list() and list_items() instead.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): serialize propagated metadata like JSON.stringify

Use compact separators, keep non-ASCII characters, and send None as
"null", so the Python and JS SDKs emit byte-identical propagated
metadata values.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(observe): keep detecting legacy-marked coroutine functions

inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker,
which asgiref's markcoroutinefunction sets on Python < 3.12. Accept
both so such functions keep the async wrapper and their observation
ends after the coroutine runs.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(otel): pin that a mixed-case ingestion-version override wins

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): keep the exact digits of large integer metadata values

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: re-run CI

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): match JS for nested big ints and non-finite metadata

Integers keep their exact digits as JSON numbers at any depth, and
propagated metadata values containing NaN or Infinity are dropped with a
warning instead of being stored, matching the JS SDK.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client): keep host and LANGFUSE_HOST working without a warning

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(experiments)!: record experiment runs via OTEL only (#1934)

* chore!: require Python 3.11 or newer

Python 3.10 reaches end of life in October 2026. Raise requires-python
to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and
remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and
typing.NotRequired.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(openai)!: drop support for OpenAI SDK versions below 1.0

The OpenAI integration now always uses the v1 client shapes. Remove the
pre-1.0 method definitions and every _is_openai_v1() branch, and raise
the dev dependency floor to openai>=1.0.0.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(otel)!: send x-langfuse-ingestion-version: 4 by default

The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so
servers in dual-write or preview mode process exported spans directly.
It is a no-op on events_only deployments, and additional_headers can
still override it.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client)!: remove blocked_instrumentation_scopes

blocked_instrumentation_scopes was deprecated in favor of
should_export_span. Remove it from Langfuse, the resource manager and
the span processor, and drop the leftover is_active entry from the
create_prompt docstring. LANGFUSE_HOST and host keep working, but now
log a deprecation warning when they decide the base URL.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(tracing)!: JSON-serialize propagated metadata values

propagate_attributes(metadata=...) coerced non-string values with str(),
so a boolean became "True" and a list became its Python repr. Serialize
them as JSON instead, like observation metadata, and drop None values
instead of sending "None".

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client)!: remove get_dataset_run, get_dataset_runs and delete_dataset_run

These helpers call the dataset-run endpoints that Langfuse v4 no longer
serves, so they always fail with a 404. Read experiment runs through
langfuse.api.experiments.list() and list_items() instead.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(experiments)!: record experiment runs via OTEL only

Generate the experiment id client-side instead of creating dataset run
items via POST /dataset-run-items. Dataset-backed runs derive a stable id
from project, dataset and run name (matching the server's derivation), so
re-runs with the same run name keep grouping into one experiment. Local
runs keep a random id.

- set langfuse.experiment.item.version from the pinned dataset version
- always persist run evaluations as scores on the experiment, incl. local data
- return experiment_id / experiment_url (v4 results page) on results;
  dataset_run_id / dataset_run_url remain as deprecated aliases

BREAKING CHANGE: run_experiment no longer creates Postgres dataset runs;
dataset_run_id / dataset_run_url are deprecated aliases that are now also
set for local-data experiments, and the URL points to the experiments page.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(experiments): read experiment e2e results via v4 APIs

Replace dataset run, dataset run item and legacy trace reads with the
experiments, observations and v3 scores APIs, and cover stable experiment
ids, run-level scores for local data and the recorded item version.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(experiments): pin the cross-SDK experiment id vectors

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): serialize propagated metadata like JSON.stringify

Use compact separators, keep non-ASCII characters, and send None as
"null", so the Python and JS SDKs emit byte-identical propagated
metadata values.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(observe): keep detecting legacy-marked coroutine functions

inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker,
which asgiref's markcoroutinefunction sets on Python < 3.12. Accept
both so such functions keep the async wrapper and their observation
ends after the coroutine runs.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(otel): pin that a mixed-case ingestion-version override wins

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): keep the exact digits of large integer metadata values

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(experiments): skip orphaned run scores and offline project lookups

Run-level scores are only sent when at least one item trace is sampled,
because a fully sampled-out experiment never reaches the server. Clients
with tracing disabled no longer resolve the project id.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: re-run CI

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): match JS for nested big ints and non-finite metadata

Integers keep their exact digits as JSON numbers at any depth, and
propagated metadata values containing NaN or Infinity are dropped with a
warning instead of being stored, matching the JS SDK.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client): keep host and LANGFUSE_HOST working without a warning

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(langchain)!: require LangChain v1 (langchain-core >= 1) (#1940)

* feat(langchain)!: drop LangChain v0 support

The CallbackHandler now imports only from langchain_core and no longer
requires the langchain package to be installed. Remove the
langchain.__version__ branch and the langchain.callbacks /
langchain.schema fallbacks for LangChain v0.

The serializer fallback tests now hide langchain_core, the module the
serializer actually imports, and reload the serializer module.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* build(deps)!: require langchain-core>=1 and langchain-openai>=1

Add langchain-core>=1,<2 to the dev dependencies and raise
langchain-openai from >=0.0.5,<0.4 to >=1,<2. The lock moves
langchain-openai 0.3.34 -> 1.1.12, which keeps langchain-core 1.2.22 and
openai 2.29.0 unchanged.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(batch-evaluation)!: evaluate observations via the v2 observations API (#1936)

* chore!: require Python 3.11 or newer

Python 3.10 reaches end of life in October 2026. Raise requires-python
to >=3.11, target py311 in ruff, drop 3.10 from the CI unit matrix, and
remove the pre-3.11 fallbacks for asyncio.create_task(context=...) and
typing.NotRequired.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(openai)!: drop support for OpenAI SDK versions below 1.0

The OpenAI integration now always uses the v1 client shapes. Remove the
pre-1.0 method definitions and every _is_openai_v1() branch, and raise
the dev dependency floor to openai>=1.0.0.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(otel)!: send x-langfuse-ingestion-version: 4 by default

The default OTLP exporter now sends x-langfuse-ingestion-version: 4 so
servers in dual-write or preview mode process exported spans directly.
It is a no-op on events_only deployments, and additional_headers can
still override it.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client)!: remove blocked_instrumentation_scopes

blocked_instrumentation_scopes was deprecated in favor of
should_export_span. Remove it from Langfuse, the resource manager and
the span processor, and drop the leftover is_active entry from the
create_prompt docstring. LANGFUSE_HOST and host keep working, but now
log a deprecation warning when they decide the base URL.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(tracing)!: JSON-serialize propagated metadata values

propagate_attributes(metadata=...) coerced non-string values with str(),
so a boolean became "True" and a list became its Python repr. Serialize
them as JSON instead, like observation metadata, and drop None values
instead of sending "None".

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client)!: remove get_dataset_run, get_dataset_runs and delete_dataset_run

These helpers call the dataset-run endpoints that Langfuse v4 no longer
serves, so they always fail with a 404. Read experiment runs through
langfuse.api.experiments.list() and list_items() instead.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(experiments)!: record experiment runs via OTEL only

Generate the experiment id client-side instead of creating dataset run
items via POST /dataset-run-items. Dataset-backed runs derive a stable id
from project, dataset and run name (matching the server's derivation), so
re-runs with the same run name keep grouping into one experiment. Local
runs keep a random id.

- set langfuse.experiment.item.version from the pinned dataset version
- always persist run evaluations as scores on the experiment, incl. local data
- return experiment_id / experiment_url (v4 results page) on results;
  dataset_run_id / dataset_run_url remain as deprecated aliases

BREAKING CHANGE: run_experiment no longer creates Postgres dataset runs;
dataset_run_id / dataset_run_url are deprecated aliases that are now also
set for local-data experiments, and the URL points to the experiments page.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(experiments): read experiment e2e results via v4 APIs

Replace dataset run, dataset run item and legacy trace reads with the
experiments, observations and v3 scores APIs, and cover stable experiment
ids, run-level scores for local data and the recorded item version.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(experiments): pin the cross-SDK experiment id vectors

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(batch-evaluation)!: evaluate observations via the v2 observations API

Batch evaluation now reads exclusively from GET /api/public/v2/observations
with cursor pagination, so it works on Langfuse platform v4.

BREAKING CHANGES:
- scope="traces" is replaced by scope="root_observations", which evaluates
  the logical root observation of each trace and attaches scores to the
  trace. scope="observations" is unchanged in intent.
- Mappers receive an ObservationV2. input/output are raw strings and only
  the requested field groups are populated.
- fetch_trace_fields is replaced by fields (v2 field groups), defaulting to
  "core,basic,io,metadata" in both the client and the runner.
- filter must be a JSON array in the v2 observations filter format.
- Resume tokens carry the pagination cursor and are also returned when
  max_items is reached while more items exist.
- The private _additional_trace_tags option is removed.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(batch-evaluation): rewrite e2e suite for v2 observations

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): serialize propagated metadata like JSON.stringify

Use compact separators, keep non-ASCII characters, and send None as
"null", so the Python and JS SDKs emit byte-identical propagated
metadata values.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(observe): keep detecting legacy-marked coroutine functions

inspect.iscoroutinefunction ignores asyncio's _is_coroutine marker,
which asgiref's markcoroutinefunction sets on Python < 3.12. Accept
both so such functions keep the async wrapper and their observation
ends after the coroutine runs.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(otel): pin that a mixed-case ingestion-version override wins

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): keep the exact digits of large integer metadata values

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(experiments): skip orphaned run scores and offline project lookups

Run-level scores are only sent when at least one item trace is sampled,
because a fully sampled-out experiment never reaches the server. Clients
with tracing disabled no longer resolve the project id.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(batch-evaluation): one root per trace and safe resume

- root_observations scope evaluates each trace once, preferring the
  physical root over SDK-marked app roots
- resuming reuses the token's filter and rejects a different one, since
  the cursor only continues the query that produced it
- the cursorless start-time fallback is inclusive so tied observations
  are not skipped

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: re-run CI

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: re-run CI

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): match JS for nested big ints and non-finite metadata

Integers keep their exact digits as JSON numbers at any depth, and
propagated metadata values containing NaN or Infinity are dropped with a
warning instead of being stored, matching the JS SDK.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(client): keep host and LANGFUSE_HOST working without a warning

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(tracing)!: remove trace-level input/output setters (#1935)

Remove the deprecated Langfuse.set_current_trace_io() and
span.set_trace_io(), the langfuse.trace.input/output attributes, and
their media handling. On Langfuse v4 a trace's input and output come
from its root observation: set them with update() on the root span or
update_current_span().

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test: run the e2e suites on Langfuse v4 events_only (#1937)

* feat(tracing)!: remove trace-level input/output setters

Remove the deprecated Langfuse.set_current_trace_io() and
span.set_trace_io(), the langfuse.trace.input/output attributes, and
their media handling. On Langfuse v4 a trace's input and output come
from its root observation: set them with update() on the root span or
update_current_span().

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test: read e2e assertions through the v4 observations API

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* ci: run e2e against an events_only server

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test: pin that create_score logs invalid input without enqueueing

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test: remove the v3 wait_for_trace helper

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test: keep scalar text IO as strings and read full response_format

The v2 observations API returns IO as raw strings, so only JSON objects
and arrays are parsed back; text like "2" stays a string. The
response_format assertion requests the untruncated metadata value.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test: dedupe observation rows and assert streamed text as a string

The events table can briefly return several rows for one span, so the
observation helper keeps the latest row per id. A streamed completion of
"2" is text and is now asserted as a string.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(batch-evaluation)!: evaluate observations only

Remove the scope parameter and root-observation mode, so batch
evaluation always runs on the observations matching the filter and
scores each observation. Resume tokens no longer carry a scope.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(batch-evaluation): evaluate duplicate observation rows once

The events table can briefly return several rows for one observation,
within a page or across a page boundary. Keep the most recently updated
row per id so each observation is evaluated and scored once. Also build
exception messages before raising.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing)!: require the gen_ai. prefix in the default span filter (#1929)

* fix(tracing)!: require the gen_ai. prefix in the default span filter

is_genai_span matched any attribute key starting with "gen_ai", so
lookalikes such as gen_ai_custom or gen_airflow.model exported unrelated
third-party spans. Match only the gen_ai.* namespace.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: re-run CI

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: re-run CI

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: re-run CI

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: re-run CI

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(otel)!: compress span exports with gzip by default (#1954)

The default OTLP exporter now gzips span batches unless configured
otherwise. Resolution order: the otel_compression argument,
LANGFUSE_OTEL_COMPRESSION, OTEL_EXPORTER_OTLP_TRACES_COMPRESSION,
OTEL_EXPORTER_OTLP_COMPRESSION, then gzip. Values are case-insensitive
and an invalid value falls through to the next setting, matching the JS
SDK. Set any of them to "none" to send uncompressed.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore(api)!: drop references to API types removed from the v4 spec (#1955)

* chore(api): drop references to API types removed from the v4 spec

langfuse.model no longer re-exports DatasetRun, Observation and
TraceWithFullDetails, which the regenerated API client removes. The api
docstrings point to the v2 observations, scores_v3 and experiments reads
instead of trace.get/list and the legacy v1 resources. Two unit tests no
longer mock the removed dataset_run_items resource.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: re-run CI

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(api): update API spec from langfuse/langfuse 21b9b33 (#1961)

* feat(api): update API spec from langfuse/langfuse 21b9b33

* fix(api): adapt the legacy score-create alias to the CreateScoresRequest union

scores.create now takes a request body that is one score or a list. The
legacy score_v1.create alias keeps its keyword signature, builds a
CreateScoreRequest from the passed arguments, and calls
scores.create(request=...). The postprocessor upgrades an alias applied
by an earlier template instead of leaving it in place.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: langfuse-bot <langfuse-bot@langfuse.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(api): update API spec from langfuse/langfuse 5beee5d (#1962)

Co-authored-by: langfuse-bot <langfuse-bot@langfuse.com>

* fix(tracing): keep metadata within the span attribute limit (#1957)

* fix(tracing): keep metadata within the span attribute limit

Each metadata key is written as its own OTel attribute. Once a span holds
more than SpanLimits.max_span_attributes (default 128) attributes, the OTel
SDK evicts the oldest ones, which are the Langfuse core attributes such as
input, model and version.

Before writing observation attributes, count the span's existing attribute
keys plus the new keys and drop the excess new metadata keys from the tail.
Non-metadata attributes and overwrites of existing keys are never dropped.
Log one warning per write that dropped keys instead of raising.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tracing): reserve attribute slots for later observation updates

Capping metadata at exactly the span attribute limit left no room for
attributes written in later updates, so e.g. update(output=...) made the
OTel SDK evict the oldest attribute (langfuse.internal.is_app_root, then
input, model, ...). Metadata now also leaves one slot for each reserved
observation attribute that is not on the span or in the current write
yet, matching langfuse-js.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(tracing): pin span attribute limit in metadata limit tests

The tests assert the OTel default of 128 attributes, so a custom
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT or OTEL_ATTRIBUTE_COUNT_LIMIT in the
environment made them fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tracing): drop newest attributes instead of evicting on span attribute overflow

The Python OTel SDK evicts the oldest attribute once a span is full, while
OTel JS drops the newest. After capping metadata, the limit helper now also
drops excess new non-metadata keys from the tail when the span has no
physical capacity left, so Langfuse writes never evict existing attributes.
Overwrites of existing keys are always kept, and at most one warning is
logged per write.

Langfuse write paths on observation spans now go through the guard: the
observation constructor, update(), media-processed updates,
set_trace_as_public, the experiment item attribute writes, as_root marking,
propagated attributes in the span processor and propagate_attributes, and
the app-root marker.

The warning now names SpanLimits.max_span_attributes alongside
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, matching langfuse-js.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(experiments): keep experiment run keys when metadata hits the span limit

The task span metadata put experiment_name, experiment_run_name and the
dataset ids after item and experiment metadata, so the span attribute limit
dropped them first. Insert the run keys first so they survive truncation,
and again last so they still win over user keys of the same name. Mirrors
langfuse-js.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tracing): reserve attribute slots by observation type

Span-like observations and events never write model, usage, cost,
completion start time, model parameters or prompt attributes, so reserving
slots for them wasted 7 metadata keys. Reserve the 7 shared keys (type,
level, status message, version, environment, input, output) for span-like
types and events, and all 14 for generation-like types and for writes whose
observation type is unknown (as_root, propagated attributes, app-root
marker). Mirrors langfuse-js.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(experiments): propagate experiment metadata as one JSON attribute

Experiment metadata and experiment item metadata were propagated as one
attribute per flattened key (langfuse.experiment.item.metadata.<path>), so
large item metadata filled the task span and its children and left no room
for later attributes such as the output. Send each as a single serialized
JSON attribute (langfuse.experiment.metadata and
langfuse.experiment.item.metadata), like langfuse-js. The server parses
the blob at the bare prefix and flattens it to the same paths.

Experiment attributes are set by the SDK and skip the 200-character
validation of user-propagated values, matching langfuse-js. Remove the now
unused _flatten_and_serialize_metadata_values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(experiments): write task span metadata after the experiment attributes

The experiment task span got its observation metadata at start, before the
experiment attributes were copied onto it. The metadata cap could then fill
the span up to the reserved slots, and the copied attributes used those
slots, so the later output was dropped. Start the task span without
metadata and set it inside the propagation block, before the task runs, so
the cap counts the copied attributes and trims the observation metadata
instead. The experiment run keys still go first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tracing): mask dict metadata per key when the mask function fails (#1956)

* fix(tracing): mask metadata per key when the mask function fails

When a custom mask function raised on dict metadata, the fallback string
was written to the bare langfuse.observation.metadata attribute. The
server parses that attribute as a JSON object, so the fallback was lost,
and nothing showed which keys had been masked. For dict metadata, write
the fallback under each key of the failed call instead, so it merges with
keys from earlier updates like any other update. Non-dict metadata, input
and output keep the plain-string fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tracing): apply mask to propagated trace metadata

propagate_attributes now applies the client's mask to each propagated
trace metadata value (keys are kept), on the raw value before
serialization and the 200 character check. If the mask raises, only
that value becomes the fallback string. The masked values are stored in
the context and baggage, so the current span, child spans, and outgoing
baggage all carry them. Matches langfuse-js #1003.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(tracing): document which client's mask applies to propagated metadata

The mask for propagated trace metadata comes from the client for the
public key in the execution context, otherwise the only initialized
client. With several clients and no public key in context, or with no
client yet, propagated metadata is not masked. Document this and the
workarounds, and pin the behavior with a test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* revert(tracing): do not mask propagated trace metadata

Propagated metadata is passed explicitly by the developer. Masking it
adds client-resolution ambiguity and baggage/length-limit ordering
complexity, and diverges from the JS SDK.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): keep string fallback when the mask fails on empty dict metadata

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(api): pin how POST /scores responses parse into the union (#1964)

* test(api): pin how POST /scores responses parse into the union

Cover the 200, 202 and 207 response shapes of scores.create, sync and
async, against a mocked transport. CI also runs them on pydantic 2.7,
whose union matching depends on the member order.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* test(api): assert the pydantic 2.7 overlay and describe its coverage

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* feat(tracing)!: JSON-encode observation and trace metadata values (#1958)

* fix(tracing): JSON-encode metadata attribute values

Per-key `langfuse.observation.metadata.<key>` attributes now carry
`json.dumps(value, cls=EventSerializer)` for every value, strings
included. Previously str and int were passed raw/natively, so ints
beyond int64 made the OTLP encoder drop the key, ints beyond 2^53 lost
precision on the server, and a server that JSON-decodes values could
not tell the string "123" from the number 123.

None values are still skipped so updates keep earlier keys. The bare
non-dict metadata attribute is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(tracing)!: JSON-encode non-dict metadata too

The bare `langfuse.observation.metadata` attribute (non-dict metadata) is
now sent as `json.dumps(metadata, cls=EventSerializer)`, strings
included, matching the per-key values and langfuse-js #1005. Previously
strings were passed through raw, so `metadata="foo"` sent `foo` in
Python but `"foo"` in JS. None is still not written. Input/output and
other `_serialize` callers are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(e2e): match metadata expectations to server decoding

The server JSON-decodes per-key metadata values only for Python SDK
major >= 5 (langfuse/langfuse#18436). On 4.x the experiment items API
returns the raw JSON string, so compare against the encoded value until
the version bump. test_concurrency now writes an int so it round-trips
the same way with and without server-side decoding.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tracing): write metadata JSON with compact separators and raw non-ASCII

Encode observation and trace metadata values with separators=(",", ":")
and ensure_ascii=False so the output is byte-identical to JS
JSON.stringify, matching propagated metadata (#1932).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(tracing): debug-log metadata keys skipped because their value is None

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(tracing)!: JSON-encode propagated trace metadata like observation metadata

propagate_attributes(metadata=...) now uses the same encoder as observation
metadata, so a string such as "123" stays a string once the server decodes
langfuse.trace.metadata.<key>. Ints outside the JS-safe range and NaN/Infinity
become JSON strings instead of exact digits or being dropped. The 200 character
limit applies to the encoded value.

Plain str values skip EventSerializer and go straight to json.dumps; the output
is byte-identical and pinned against EventSerializer and JSON.stringify.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(media): extract top-level data URIs from JSON-encoded metadata values

Metadata values are JSON-encoded, so a top-level data URI arrives quoted and the
exporter no longer recognized it, shipping the base64 inline. Decode the JSON
string for Langfuse metadata attributes, process the media, and re-encode the
reference string.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* style(tests): format span attribute limit test

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): drop None propagated metadata values with a warning

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* perf(media): extract JSON-encoded metadata data URIs without json.loads

Strip the quotes of a JSON-encoded top-level data URI in place instead of
decoding and re-encoding it. Values with backslashes, inner quotes, or no
;base64 header in the first 256 characters are left unchanged.

Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* fix(tracing): do not mask attributes the caller never set (#1973)

* fix(tracing): do not mask attributes the caller never set

`_process_media_and_apply_mask` runs for input, output and metadata on
every span creation and update, regardless of whether the caller provided
them. An unset attribute is still handed to the user's mask function as
`None`. A mask that assumes a dict throws, `_mask_attribute` then fails
closed, and the fallback string is not `None` -- so it survives the
`v is not None` filters in `create_span_attributes` /
`create_generation_attributes`, and lands on the span as an attribute the
caller never set. Each unset attribute also logs one ERROR per span,
which makes it look like the user's mask function is broken.

Repro with a mask that assumes a dict and only `output` set:

    mask receives [None, None, None, None, {'answer': '42'}, None]
    span carries langfuse.observation.input / .metadata =
        '<fully masked due to failed mask function>'
    plus 5 ERROR logs ("'NoneType' object is not a mapping")

Return early when `data is None`: there is nothing to process or mask.
Attributes that do have a value keep the existing fail-closed behaviour
and the per-key metadata fallback.

Adds two regression tests asserting that only attributes the caller set
reach the mask, and that unset attributes stay unset.

* push

---------

Co-authored-by: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com>

* test(tracing): expect JSON-encoded metadata in mask-unset test (#1985)

The test asserted raw metadata values, but metadata values are JSON-encoded
on prepare-v5-release, so it failed there.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>

* chore: merge main into prepare-v5-release (#1986)

* docs(api): document metadata is set / is not set filter operators (#1946)

Regenerated langfuse/api from the upstream Fern definition: baseline
langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17357 and #18404.
Split out of the bundled API-spec bot PR #1943.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(api): type Dataset JSON-schema response fields as objects (#1949)

Regenerated langfuse/api from the upstream Fern definition: baseline
langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17892 and the datasets.yml/opentelemetry.yml part of #18406.
Split out of the bundled API-spec bot PR #1943.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(api): share cursor pagination meta via CursorMetaResponse (#1948)

Regenerated langfuse/api from the upstream Fern definition: baseline
langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17888.
Split out of the bundled API-spec bot PR #1943.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(api): document fromStartTime as required on experiments endpoints (#1947)

* docs(api): document fromStartTime as required on experiments endpoints

Regenerated langfuse/api from the upstream Fern definition: baseline
langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17603.
Split out of the bundled API-spec bot PR #1943.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(api): use language-neutral wording for experiments time window

Regenerated from langfuse/langfuse branch
docs/experiments-from-start-time-neutral-wording. The fromStartTime
docstrings named `toStartTime`, but the Python parameter is
`to_start_time`; they now say "an upper bound" instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(api): type read-side metadata as optional objects (#1950)

* fix(api): type read-side metadata as optional objects

Regenerated langfuse/api from the upstream Fern definition: baseline
langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#18122 and the commons.yml part of #18406.
Split out of the bundled API-spec bot PR #1943.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(api): pin read-side metadata parsing on generated models

Cover omitted, null, empty and nested-JSON metadata plus rejection of
non-object metadata for Trace, Observation, ObservationV2, BaseScore,
BaseScoreV1 and every Score / ScoreV1 / GetScoresResponseData variant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Niklas Semmler <1880943+niklassemmler@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* ci: test on Python 3.15 and declare it supported (#1988)

* test: run unit tests without langchain and langgraph installed

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* build: lock dependencies with Python 3.15 wheels

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* ci: run unit tests on Python 3.15 and declare supported versions

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* feat(scores)!: send scores via POST /api/public/scores (#1987)

* feat(scores)!: send scores via POST /api/public/scores

Scores are now batched to the public scores endpoint instead of being
sent as score-create events to /api/public/ingestion. The consumer keeps
flush_at, flush_interval and the batch byte limit, retries transport
errors, 429 and 5xx with backoff, does not retry other 4xx, and logs
rejected items from a 207 partial result without retrying them.

Scores for traces that the tracer sampler drops are now dropped as well;
the trace id was previously never read, so sampling was skipped.

The trace-create ingestion path used for trace tags is removed.

BREAKING CHANGE: batch score delivery needs Langfuse platform v4.55.0 or
later. Custom score timestamps need the platform release that accepts
timestamp on POST /scores; older servers record the receive time.

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* chore(api)!: drop the legacy score create compatibility layer

Removes legacy.score_v1.create, the CreateScore* aliases it exposed, and
the script that patched them into the generated client. Score deletion
through legacy.score_v1.delete is unchanged.

BREAKING CHANGE: use api.scores.create to create scores.

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* chore(integrations)!: stop passing the deprecated usage argument

BREAKING CHANGE: OpenAI and LangChain generations only report usage_details.

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* style: format test_media

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* test(e2e): expect custom score timestamps to fail until the platform supports them

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* fix(scores): send naive score timestamps as UTC

The scores API rejects timestamps without a UTC offset. Naive datetimes
were serialized with the local offset; they are now treated as UTC.

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* feat(scores): hint at the minimum server version when a score batch gets a 400

Langfuse servers before v4.55.0 reject list bodies on POST /scores. The
first 400 in a process now logs that batched score creation requires
v4.55.0 or later and that the scores were dropped.

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* fix(scores): match the JS minimum server version hint and log repeats at debug

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* refactor(scores): drop the minimum server version hint

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* fix(scores): keep scores unless the trace-id sampler drops their trace

Re-running a parent-based, random or attribute-based sampler with only
the trace id can disagree with the decision the trace actually got, so
scores are only dropped for Langfuse's trace-id ratio sampler.

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* fix(scores): cap LANGFUSE_MAX_BATCH_SIZE_BYTES at the scores body limit

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* feat(api): update API spec from langfuse/langfuse 99da25d (#1990)

Co-authored-by: langfuse-bot <langfuse-bot@langfuse.com>

* chore: merge main into prepare-v5-release (#1992)

* docs(api): document metadata is set / is not set filter operators (#1946)

Regenerated langfuse/api from the upstream Fern definition: baseline
langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17357 and #18404.
Split out of the bundled API-spec bot PR #1943.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(api): type Dataset JSON-schema response fields as objects (#1949)

Regenerated langfuse/api from the upstream Fern definition: baseline
langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17892 and the datasets.yml/opentelemetry.yml part of #18406.
Split out of the bundled API-spec bot PR #1943.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(api): share cursor pagination meta via CursorMetaResponse (#1948)

Regenerated langfuse/api from the upstream Fern definition: baseline
langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17888.
Split out of the bundled API-spec bot PR #1943.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(api): document fromStartTime as required on experiments endpoints (#1947)

* docs(api): document fromStartTime as required on experiments endpoints

Regenerated langfuse/api from the upstream Fern definition: baseline
langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#17603.
Split out of the bundled API-spec bot PR #1943.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(api): use language-neutral wording for experiments time window

Regenerated from langfuse/langfuse branch
docs/experiments-from-start-time-neutral-wording. The fromStartTime
docstrings named `toStartTime`, but the Python parameter is
`to_start_time`; they now say "an upper bound" instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(api): type read-side metadata as optional objects (#1950)

* fix(api): type read-side metadata as optional objects

Regenerated langfuse/api from the upstream Fern definition: baseline
langfuse/langfuse 619dbe3 (last sync, #1866) plus langfuse/langfuse#18122 and the commons.yml part of #18406.
Split out of the bundled API-spec bot PR #1943.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(api): pin read-side metadata parsing on generated models

Cover omitted, null, empty and nested-JSON metadata plus rejection of
non-object metadata for Trace, Observation, ObservationV2, BaseScore,
BaseScoreV1 and every Score / ScoreV1 / GetScoresResponseData variant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Niklas Semmler <1880943+niklassemmler@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix: address v5 release review findings (#1993)

* fix: budget propagated trace metadata against the span attribute limit

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* fix: gate experiment run scores on the item span's sampling decision

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* fix(batch-evaluation): skip boundary duplicates on resume and fetch updatedAt for dedup

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* style: format span metadata key helper

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* fix: count experiment items as sampled only after experiment attributes are set

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* fix(batch-evaluation): request updatedAt when no field groups are given

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* feat(api): update API spec from langfuse/langfuse 738b05d (#1995)

Co-authored-by: langfuse-bot <langfuse-bot@langfuse.com>

* refactor(experiments): drop sampling-aware gating of run-level scores (#1996)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* test: read scores via api.scores.get_many in e2e helpers and docstring (#1998)

* test: read scores via api.scores.get_many in e2e helpers

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* docs: point Langfuse.api docstring at api.scores.get_many

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* feat(api): update API spec from langfuse/langfuse f0ea0c3 (#1999)

* feat(api): update API spec from langfuse/langfuse f0ea0c3

* test: drop the single-score case from the scores create response test

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

---------

Co-authored-by: langfuse-bot <langfuse-bot@langfuse.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>

* feat(api): update API spec from langfuse/langfuse 7cb2eed (#2000)

Co-authored-by: langfuse-bot <langfuse-bot@langfuse.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hassieb Pakzad <hassiebp@users.noreply.github.com>
Co-authored-by: langfuse-bot <accounts+githubbot@langfuse.com>
Co-authored-by: langfuse-bot <langfuse-bot@langfuse.com>
Co-authored-by: Niklas Semmler <1880943+niklassemmler@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Neumanncheng <93020150+Neumanncheng@users.noreply.github.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