From fa797d8ec6f2b5339b03085667f30a6289d09b45 Mon Sep 17 00:00:00 2001 From: David Traina <44659830+DavidTraina@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:56:44 -0500 Subject: [PATCH 1/3] perf(scores): build create_score body without Fern's dict() CreateScoreRequest.dict(by_alias=True) calls typing.get_type_hints on every call, so create_score spent about 60 us on the caller's thread. Keep the model for validation, but read its fields and rename them with an alias map resolved once at import. The queued score and the posted JSON are unchanged. --- langfuse/_client/client.py | 10 +++-- tests/unit/test_score_consumer.py | 69 ++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index c474f0640..0f2bb3fc8 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -112,6 +112,7 @@ Prompt_Text, ) from langfuse.api.core.datetime_utils import serialize_datetime +from langfuse.api.core.serialization import get_field_to_alias_mapping from langfuse.batch_evaluation import ( BatchEvaluationResult, BatchEvaluationResumeToken, @@ -149,6 +150,10 @@ TraceContext, ) +# Fern's dict() looks these aliases up with typing.get_type_hints on every call, +# which costs about 50 us per score on the caller's thread. +_CREATE_SCORE_REQUEST_ALIASES = get_field_to_alias_mapping(CreateScoreRequest) + def _get_evaluator_name(evaluator: Callable[..., Any]) -> str: return getattr(evaluator, "__name__", evaluator.__class__.__name__) @@ -1960,10 +1965,9 @@ def create_score( environment=environment or self._environment, timestamp=timestamp or _get_timestamp(), ) - # Fern's dict() keeps fields explicitly set to None, so drop them here. score = { - key: value - for key, value in request.dict(by_alias=True).items() + _CREATE_SCORE_REQUEST_ALIASES.get(field, field): value + for field, value in request if value is not None } # The API accepts any JSON value as metadata, the generated model only dicts. diff --git a/tests/unit/test_score_consumer.py b/tests/unit/test_score_consumer.py index a29adc535..51fce6a20 100644 --- a/tests/unit/test_score_consumer.py +++ b/tests/unit/test_score_consumer.py @@ -18,7 +18,8 @@ from langfuse._client.resource_manager import LangfuseResourceManager from langfuse._task_manager import score_consumer as score_consumer_module from langfuse._task_manager.score_consumer import ScoreConsumer -from langfuse.api import LangfuseAPI +from langfuse._utils.serializer import EventSerializer +from langfuse.api import CreateScoreRequest, LangfuseAPI ACCEPTED = (202, {"message": "Accepted"}) @@ -348,6 +349,72 @@ def test_create_score_posts_to_scores_endpoint(client_factory): assert sent[1]["timestamp"].endswith("Z") +def test_create_score_payload_matches_request_model_serialization(client_factory): + recorder = Recorder(_responder(ACCEPTED)) + client = client_factory(recorder, environment="staging") + timestamp = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + cases: List[Dict[str, Any]] = [ + dict( + name="numeric", + value=0.9, + trace_id="a" * 32, + observation_id="b" * 16, + data_type="NUMERIC", + comment="fine", + ), + dict(name="int-value", value=1, trace_id="a" * 32), + dict(name="boolean", value=True, trace_id="a" * 32, data_type="BOOLEAN"), + dict( + name="categorical", + value="good", + session_id="session-1", + data_type="CATEGORICAL", + config_id="config-1", + ), + dict( + name="text", + value="free text", + dataset_run_id="run-1", + data_type="TEXT", + environment="override", + ), + dict( + name="correction", + value="corrected output", + trace_id="a" * 32, + observation_id="b" * 16, + data_type="CORRECTION", + ), + dict( + name="dict-metadata", + value=0.5, + trace_id="a" * 32, + metadata={"evaluator": "judge", "nested": {"steps": [1, 2]}}, + ), + dict(name="list-metadata", value=0.5, trace_id="a" * 32, metadata=["a", 1]), + ] + + for i, case in enumerate(cases): + client.create_score(score_id=f"score-{i}", timestamp=timestamp, **case) + client.flush() + + expected = [] + for i, case in enumerate(cases): + fields = dict(case) + metadata = fields.pop("metadata", None) + fields.setdefault("environment", "staging") + request = CreateScoreRequest(id=f"score-{i}", timestamp=timestamp, **fields) + payload = { + k: v for k, v in request.dict(by_alias=True).items() if v is not None + } + if metadata is not None: + payload["metadata"] = metadata + expected.append(json.dumps(payload, cls=EventSerializer)) + # Compare JSON text, because 1 == 1.0 == True as Python values. + sent = [json.dumps(score) for body in recorder.bodies for score in body] + assert sent == expected + + @pytest.fixture def non_utc_local_timezone(monkeypatch): # Makes treating naive values as local time observable. From 63222e290e87c4da5f74bbe4b6b6f16220c14e39 Mon Sep 17 00:00:00 2001 From: David Traina <44659830+DavidTraina@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:29:52 -0500 Subject: [PATCH 2/3] test(scores): isolate score client tests from the global tracer provider Tests that build a client without a tracer provider used the global one, which another test may have set with a ratio sampler that drops their trace scores. Also clarify the alias map comment and stop shadowing create_score's value argument. --- langfuse/_client/client.py | 10 +++++----- tests/unit/test_score_consumer.py | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 0f2bb3fc8..2807d8736 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -150,8 +150,8 @@ TraceContext, ) -# Fern's dict() looks these aliases up with typing.get_type_hints on every call, -# which costs about 50 us per score on the caller's thread. +# Fern's dict() re-derives these aliases from the type hints on every call, +# which is most of its ~50 us cost per score, so look them up once here. _CREATE_SCORE_REQUEST_ALIASES = get_field_to_alias_mapping(CreateScoreRequest) @@ -1966,9 +1966,9 @@ def create_score( timestamp=timestamp or _get_timestamp(), ) score = { - _CREATE_SCORE_REQUEST_ALIASES.get(field, field): value - for field, value in request - if value is not None + _CREATE_SCORE_REQUEST_ALIASES.get(field, field): field_value + for field, field_value in request + if field_value is not None } # The API accepts any JSON value as metadata, the generated model only dicts. if metadata is not None: diff --git a/tests/unit/test_score_consumer.py b/tests/unit/test_score_consumer.py index 51fce6a20..a2818d495 100644 --- a/tests/unit/test_score_consumer.py +++ b/tests/unit/test_score_consumer.py @@ -289,6 +289,8 @@ def client_factory(): def create(recorder: Recorder, **kwargs: Any) -> Langfuse: with LangfuseResourceManager._lock: LangfuseResourceManager._instances.clear() + # The global provider may carry a sampler set by another test. + kwargs.setdefault("tracer_provider", TracerProvider()) client = Langfuse( public_key="pk-score-consumer", secret_key="sk-score-consumer", From 53abd91c094fda73b0fbbd050a51e052c0bdc383 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 08:10:42 +0000 Subject: [PATCH 3/3] perf(scores): build create_score body as a plain dict Constructing CreateScoreRequest still cost a few microseconds per score on the caller's thread. Build the API payload directly instead, with the same keys in the same order, so the queued score and the posted JSON stay byte-identical. Bools and ints are still sent as floats. The client no longer validates data_type or the value type. The server rejects invalid scores per item and the score consumer logs them. The invalid-input test now covers create_score logging instead of raising when enqueueing fails. Co-authored-by: hassieb.pakzad --- langfuse/_client/client.py | 44 ++++++++++++----------------- tests/unit/test_resource_manager.py | 14 ++++----- 2 files changed, 25 insertions(+), 33 deletions(-) diff --git a/langfuse/_client/client.py b/langfuse/_client/client.py index 2807d8736..2397289da 100644 --- a/langfuse/_client/client.py +++ b/langfuse/_client/client.py @@ -98,7 +98,6 @@ AsyncLangfuseAPI, CreateChatPromptRequest, CreateChatPromptType, - CreateScoreRequest, CreateTextPromptRequest, Dataset, DatasetItem, @@ -112,7 +111,6 @@ Prompt_Text, ) from langfuse.api.core.datetime_utils import serialize_datetime -from langfuse.api.core.serialization import get_field_to_alias_mapping from langfuse.batch_evaluation import ( BatchEvaluationResult, BatchEvaluationResumeToken, @@ -150,10 +148,6 @@ TraceContext, ) -# Fern's dict() re-derives these aliases from the type hints on every call, -# which is most of its ~50 us cost per score, so look them up once here. -_CREATE_SCORE_REQUEST_ALIASES = get_field_to_alias_mapping(CreateScoreRequest) - def _get_evaluator_name(evaluator: Callable[..., Any]) -> str: return getattr(evaluator, "__name__", evaluator.__class__.__name__) @@ -1951,28 +1945,26 @@ def create_score( timestamp = timestamp.replace(tzinfo=timezone.utc) try: - request = CreateScoreRequest( - id=score_id, - session_id=session_id, - dataset_run_id=dataset_run_id, - trace_id=trace_id, - observation_id=observation_id, - name=name, - value=value, - data_type=data_type, # type: ignore - comment=comment, - config_id=config_id, - environment=environment or self._environment, - timestamp=timestamp or _get_timestamp(), - ) + # The model turned bools and ints into floats (True -> 1.0); keep that. + if isinstance(value, (bool, int)): + value = float(value) + score = { - _CREATE_SCORE_REQUEST_ALIASES.get(field, field): field_value - for field, field_value in request - if field_value is not None + "id": score_id, + "traceId": trace_id, + "sessionId": session_id, + "observationId": observation_id, + "datasetRunId": dataset_run_id, + "name": name, + "value": value, + "comment": comment, + "environment": environment or self._environment, + "dataType": data_type, + "configId": config_id, + "timestamp": timestamp or _get_timestamp(), + "metadata": metadata, } - # The API accepts any JSON value as metadata, the generated model only dicts. - if metadata is not None: - score["metadata"] = metadata + score = {key: field for key, field in score.items() if field is not None} if self._resources is not None: # Force the score to be in sample if it was for a legacy trace ID, i.e. non-32 hexchar diff --git a/tests/unit/test_resource_manager.py b/tests/unit/test_resource_manager.py index 519b21e71..232ec4f2f 100644 --- a/tests/unit/test_resource_manager.py +++ b/tests/unit/test_resource_manager.py @@ -409,9 +409,7 @@ def test_at_fork_reinit_new_httpx_client_uses_configured_timeout_and_headers( client.shutdown() -def test_create_score_with_invalid_input_logs_error_without_enqueueing( - monkeypatch, caplog -): +def test_create_score_logs_error_instead_of_raising(monkeypatch, caplog): monkeypatch.setenv("LANGFUSE_MEDIA_UPLOAD_ENABLED", "false") with LangfuseResourceManager._lock: @@ -424,13 +422,15 @@ def test_create_score_with_invalid_input_logs_error_without_enqueueing( ) rm = client._resources assert rm is not None - enqueued = [] - monkeypatch.setattr(rm, "add_score_task", lambda event, **_: enqueued.append(event)) + + def fail(event, **_): + raise RuntimeError("enqueue failed") + + monkeypatch.setattr(rm, "add_score_task", fail) with caplog.at_level(logging.ERROR, logger="langfuse"): - client.create_score(name="invalid", value=object(), trace_id="a" * 32) + client.create_score(name="invalid", value=1.0, trace_id="a" * 32) - assert enqueued == [] assert rm._score_queue.empty() error_records = [r for r in caplog.records if r.levelno == logging.ERROR] assert len(error_records) == 1