66
77 Stage 1 — Responses unit: evaluation_run.batch_job_id
88 Stage 2 — Embeddings unit: evaluation_run.embedding_batch_job_id
9- Stage 3 — Score + trace + cost (no marker; each step is idempotent)
9+ Stage 3 — Score + trace + cost + latency (no marker; each step is idempotent)
1010 Stage 4 — Persist score unit (summary + per-trace) via the shared save_score
1111 helper, so the cached trace unit (score_trace_url) exists before
1212 the run is advertised as complete and the read path (trace view /
100100 resolve_config_prompt ,
101101 select_judgeable_rows ,
102102)
103+ from app .crud .evaluations .latency import attach_latency , elapsed_ms , start_timer
103104from app .crud .evaluations .langfuse import (
104105 create_langfuse_dataset_run ,
105106 update_traces_with_cosine_scores ,
@@ -240,12 +241,14 @@ def failed_result(generated_output: str) -> ResponseResult:
240241 return failed_result ("ERROR: missing question in dataset item" )
241242
242243 try :
244+ started = start_timer ()
243245 result = _execute_llm_call_for_question (
244246 config = config ,
245247 question = question ,
246248 project_id = project_id ,
247249 organization_id = organization_id ,
248250 )
251+ latency_ms = elapsed_ms (started )
249252 except Exception as exc :
250253 logger .warning (
251254 f"[_llm_call_for_item] Item failed | item_id={ item_id } | error={ exc } " ,
@@ -265,6 +268,8 @@ def failed_result(generated_output: str) -> ResponseResult:
265268 return failed_result (f"ERROR: { result .error } " )
266269
267270 guardrail , input_to_llm , output_from_llm = _guardrail_fields (result )
271+ if result .guardrail_outcome is not None :
272+ latency_ms = None
268273
269274 # Tokens are billed even on an output block, so usage is read off every outcome.
270275 return build_response_result (
@@ -285,6 +290,7 @@ def failed_result(generated_output: str) -> ResponseResult:
285290 retrieved_chunks = extract_file_search_chunks (
286291 result .response .provider_raw_response if result .response else None
287292 ),
293+ latency_ms = latency_ms ,
288294 )
289295
290296
@@ -301,19 +307,23 @@ def _embedding_call_for_pair(
301307 return build_embedding_failure (item_id , "empty output or ground_truth" )
302308
303309 try :
310+ started = start_timer ()
304311 response = _create_embedding (
305312 openai_client ,
306313 model = embedding_model ,
307314 output_text = output_text ,
308315 ground_truth = ground_truth ,
309316 )
317+ latency_ms = elapsed_ms (started )
310318 except openai .OpenAIError as exc :
311319 logger .warning (
312320 f"[_embedding_call_for_pair] Item failed | item_id={ item_id } | error={ exc } "
313321 )
314322 return build_embedding_failure (item_id , str (exc ))
315323
316- return parse_embedding_pair (item_id = item_id , response = response )
324+ return parse_embedding_pair (
325+ item_id = item_id , response = response , latency_ms = latency_ms
326+ )
317327
318328
319329def _upload_unit_to_s3 (
@@ -644,7 +654,7 @@ def _attach_stage_costs(
644654 response_results : list [dict [str , Any ]],
645655 embedding_results : list [dict [str , Any ]] | None ,
646656) -> None :
647- """Attach the response- and embedding-stage costs (idempotent per stage)."""
657+ """Attach the response- and embedding-stage costs and latency (idempotent per stage)."""
648658 if response_results :
649659 attach_cost (
650660 session = session ,
@@ -675,6 +685,13 @@ def _attach_stage_costs(
675685 embedding_raw_results = embedding_raw ,
676686 )
677687
688+ attach_latency (
689+ eval_run = eval_run ,
690+ log_prefix = log_prefix ,
691+ response_results = response_results or None ,
692+ embedding_results = embedding_results or None ,
693+ )
694+
678695
679696def _score_cosine_path (
680697 * ,
@@ -745,14 +762,22 @@ def _score_judge_path(
745762
746763 # One combined judge call means all metric tokens land in single cost stage.
747764 if outcome .judge_results and judge_model :
765+ judge_usage_rows : list [dict [str , Any ]] = []
766+ for result in outcome .judge_results .values ():
767+ judge_usage_rows .append (
768+ {"usage" : result .usage , "latency_ms" : result .latency_ms }
769+ )
748770 attach_cost (
749771 session = session ,
750772 eval_run = eval_run ,
751773 log_prefix = log_prefix ,
752774 judge_model = judge_model ,
753- judge_results = [
754- {"usage" : result .usage } for result in outcome .judge_results .values ()
755- ],
775+ judge_results = judge_usage_rows ,
776+ )
777+ attach_latency (
778+ eval_run = eval_run ,
779+ log_prefix = log_prefix ,
780+ judge_results = judge_usage_rows ,
756781 )
757782 return outcome
758783
@@ -819,7 +844,7 @@ def _stage3_score_and_trace(
819844 embedding_results : list [EmbeddingResult ] | None ,
820845 log_prefix : str ,
821846) -> tuple [EvaluationRun , EvaluationScore , list [dict [str , Any ]]]:
822- """Stage 3 — cosine (v1) or judge (v2), create traces, attach costs. Idempotent.
847+ """Stage 3 — cosine (v1) or judge (v2), create traces, attach costs + latency . Idempotent.
823848
824849 Returns the run, the full score unit (summary_scores + per-trace records in the
825850 batch path's shape), and the Langfuse `write_items` (empty for v2). Everything
@@ -898,7 +923,7 @@ def attach_costs() -> None:
898923 else None
899924 )
900925
901- # Persist cost + unscoreable here; the score unit (summary + traces) is persisted
926+ # Persist cost + latency + unscoreable here; the score unit (summary + traces) is persisted
902927 # by the caller via save_score, before the completed transition, so it lands in
903928 # S3 like the batch path. This write must stay committed: save_score opens a
904929 # second session on the same row.
@@ -907,6 +932,7 @@ def attach_costs() -> None:
907932 eval_run = eval_run ,
908933 update = EvaluationRunUpdate (
909934 cost = eval_run .cost ,
935+ latency = eval_run .latency ,
910936 unscoreable = eval_run .unscoreable ,
911937 ),
912938 )
@@ -1036,7 +1062,7 @@ def run_fast_evaluation(
10361062 f"Score unit not persisted; run row is gone | eval_run_id={ eval_run .id } "
10371063 )
10381064
1039- # Stage 5 — mark completed. Cost was persisted in Stage 3, score in Stage 4,
1065+ # Stage 5 — mark completed. Cost + latency were persisted in Stage 3, score in Stage 4,
10401066 # so there's never a completed + NULL-score window.
10411067 eval_run = update_evaluation_run (
10421068 session = session ,
0 commit comments