-
Notifications
You must be signed in to change notification settings - Fork 22
Report native Langfuse generation metrics #220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -119,6 +119,7 @@ impl LangfuseClient { | |
| // `body["model"]`; without it Langfuse can't map pricing and every | ||
| // generation's cost is $0. | ||
| let call_models = collect_call_models(observations); | ||
| let first_deltas = collect_first_deltas(observations); | ||
|
|
||
| let mut batch = Vec::with_capacity(observations.len() + 2); | ||
| batch.push(json!({ | ||
|
|
@@ -156,7 +157,12 @@ impl LangfuseClient { | |
| if is_run_lifecycle(&obs.event) { | ||
| continue; | ||
| } | ||
| batch.push(observation_event(&trace_id, obs, &call_models)); | ||
| batch.push(observation_event( | ||
| &trace_id, | ||
| obs, | ||
| &call_models, | ||
| &first_deltas, | ||
| )); | ||
| } | ||
|
|
||
| Ok(json!({ "batch": batch })) | ||
|
|
@@ -465,10 +471,25 @@ fn collect_call_models(observations: &[AgentObservation]) -> BTreeMap<&str, &str | |
| models | ||
| } | ||
|
|
||
| /// First streamed output for each model call. This is the observable TTFT; | ||
| /// non-streaming calls have no first-delta timestamp to report. | ||
| fn collect_first_deltas(observations: &[AgentObservation]) -> BTreeMap<&str, u64> { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scope first-delta timestamps to each call instance
Additional
|
||
| let mut first = BTreeMap::new(); | ||
| for obs in observations { | ||
| if let AgentEvent::ModelDelta { call_id, delta, .. } = &obs.event | ||
| && (!delta.text.is_empty() || !delta.reasoning.is_empty() || delta.tool_call.is_some()) | ||
| { | ||
| first.entry(call_id.as_str()).or_insert(obs.ts_ms); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scope first-delta timestamps to each call instance The map is collected across the entire observation slice and keyed only by [RULE] invocation-scoping · There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correlate first deltas with the specific model invocation A [RULE] invocation-correlation · |
||
| } | ||
| } | ||
| first | ||
| } | ||
|
|
||
| fn observation_event( | ||
| trace_id: &str, | ||
| obs: &AgentObservation, | ||
| call_models: &BTreeMap<&str, &str>, | ||
| first_deltas: &BTreeMap<&str, u64>, | ||
| ) -> Value { | ||
| let timestamp = iso_ms(obs.ts_ms); | ||
| // Every per-call observation nests under its run's span so the trace renders | ||
|
|
@@ -527,7 +548,10 @@ fn observation_event( | |
| // existed. | ||
| "startTime": started_at_ms.map(iso_ms).unwrap_or_else(|| timestamp.clone()), | ||
| "endTime": timestamp, | ||
| "completionStartTime": first_deltas.get(call_id.as_str()).map(|ms| iso_ms(*ms)), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correlate first deltas with the specific model invocation This lookup uses only Additional
|
||
| "usage": usage.map(langfuse_usage), | ||
| "usageDetails": usage.map(langfuse_usage_details), | ||
| "costDetails": usage.and_then(langfuse_cost_details), | ||
| "input": input, | ||
| "output": output, | ||
| "metadata": metadata, | ||
|
|
@@ -646,6 +670,23 @@ fn langfuse_usage(usage: Usage) -> Value { | |
| }) | ||
| } | ||
|
|
||
| fn langfuse_usage_details(usage: Usage) -> Value { | ||
| json!({ | ||
| "input": usage.input_tokens.saturating_sub(usage.cache_read_tokens), | ||
| "output": usage.output_tokens, | ||
| "total": usage.total_tokens, | ||
| "cache_read_input_tokens": usage.cache_read_tokens, | ||
| "cache_creation_input_tokens": usage.cache_creation_tokens, | ||
| "reasoning_output_tokens": usage.reasoning_tokens, | ||
| }) | ||
| } | ||
|
|
||
| fn langfuse_cost_details(usage: Usage) -> Option<Value> { | ||
| usage | ||
| .charged_amount | ||
| .map(|amount| json!({ "total": amount.micros as f64 / 1_000_000.0 })) | ||
| } | ||
|
|
||
| /// Drops every `null`-valued key from a top-level JSON object, in place at | ||
| /// one level (not recursive). Used before every ingestion body so Langfuse | ||
| /// never sees an explicit `null` for a field this exporter chose not to | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Scope first-delta timestamps to each call instance
The first-delta map is collected once for the entire observation slice and is keyed only by
call_id. Call IDs are reused across turns, so a later generation can inherit an earlier invocation's first-delta timestamp (and potentially a timestamp after its own completion). Track first deltas per concrete call instance, using the invocation's run/turn scope together with the call ID, and look up that scoped key when emitting each generation.[RULE] invocation-scoped-correlation ·