44Provides Langfuse LLM observability for Claude sessions with trace structure:
55
661. Turn Traces (top-level generations):
7- - Each turn is a separate trace
8- - Named: claude_turn_1, claude_turn_2, etc.
9- - Contains authoritative usage data from Claude SDK
7+ - ONE trace per turn (SDK sends multiple AssistantMessages during streaming, but guard prevents duplicates)
8+ - Named: "claude_interaction" (turn number stored in metadata)
9+ - First AssistantMessage creates trace, subsequent ones ignored until end_turn() clears it
10+ - Final trace contains authoritative turn number and usage data from ResultMessage
1011 - Canonical format with separate cache token tracking for accurate cost
11- - All turns grouped by session_id via propagate_attributes()
12+ - All traces grouped by session_id via propagate_attributes()
1213
13142. Tool Spans (observations within turn traces):
1415 - Named: tool_Read, tool_Write, tool_Bash, etc.
1819
1920Architecture:
2021- Session-based grouping via propagate_attributes() with session_id and user_id
21- - Each turn is an independent trace (not nested under a session trace )
22+ - Each turn creates ONE independent trace (not nested under session)
2223- Langfuse automatically aggregates tokens and costs across all traces with same session_id
23- - Filter by session_id, user_id, or model in Langfuse UI
24+ - Filter by session_id, user_id, model, or metadata.turn in Langfuse UI
25+ - Sessions can be paused/resumed: each turn creates a trace regardless of session lifecycle
2426
2527Trace Hierarchy:
26- claude_turn_1 (trace - generation)
28+ claude_interaction (trace - generation, metadata: {turn: 1} )
2729├── tool_Read (observation - span)
2830└── tool_Write (observation - span)
2931
30- claude_turn_2 (trace - generation)
32+ claude_interaction (trace - generation, metadata: {turn: 2} )
3133└── tool_Bash (observation - span)
3234
33- claude_turn_3 (trace - generation)
34-
35- Usage Format (turn-level only):
35+ Usage Format:
3636{
3737 "input": int, # Regular input tokens
3838 "output": int, # Output tokens
@@ -183,71 +183,73 @@ async def initialize(self, prompt: str, namespace: str, model: str = None) -> bo
183183 self ._propagate_ctx = None
184184 return False
185185
186- def start_turn (self , turn_count : int , model : str , user_input : str | None = None ) -> None :
186+ def start_turn (self , model : str , user_input : str | None = None ) -> None :
187187 """Start tracking a new turn as a top-level trace.
188188
189189 Creates the turn generation as a TRACE (not an observation) so that each turn
190190 appears as a separate trace in Langfuse. Tools will be observations within the trace.
191191
192- Note: Cannot use 'with' context managers due to async streaming architecture.
192+ Prevents duplicate traces when SDK sends multiple AssistantMessages per turn during
193+ streaming. Only the first AssistantMessage creates a trace; subsequent ones are ignored
194+ until end_turn() clears the current trace.
195+
196+ Cannot use 'with' context managers due to async streaming architecture.
193197 Messages arrive asynchronously (AssistantMessage → ToolUseBlocks → ResultMessage)
194198 and the turn context must stay open across multiple async loop iterations.
195199
196200 Args:
197- turn_count: Current turn number
198201 model: Model name (e.g., "claude-3-5-sonnet-20241022")
199202 user_input: Optional actual user input/prompt (if available)
200203 """
201204 if not self .langfuse_client :
202205 return
203206
204- # Guard: Don't create a new turn if one is already active
205- # This prevents duplicate traces when AssistantMessage arrives multiple times
207+ # Guard: Prevent creating duplicate traces for the same turn
208+ # SDK sends multiple AssistantMessages during streaming - only create trace once
206209 if self ._current_turn_generation :
207- logging .debug (f "Langfuse: Turn already active, skipping start_turn for turn { turn_count } " )
210+ logging .debug ("Langfuse: Trace already active for current turn, skipping duplicate start_turn " )
208211 return
209212
210213 try :
211- # Build metadata
212- metadata = {"turn" : turn_count }
213-
214214 # Use pending initial prompt for turn 1 if available
215- if user_input is None and turn_count == 1 and self ._pending_initial_prompt :
215+ if user_input is None and self ._pending_initial_prompt :
216216 user_input = self ._pending_initial_prompt
217217 self ._pending_initial_prompt = None # Clear after use
218- logging .debug ("Langfuse: Using pending initial prompt for turn 1 " )
218+ logging .debug ("Langfuse: Using pending initial prompt" )
219219
220- # Use actual user input if provided, otherwise use placeholder
220+ # Use actual user input if provided, otherwise use generic placeholder
221221 if user_input :
222222 input_content = [{"role" : "user" , "content" : user_input }]
223- logging .info (f"Langfuse: Starting turn { turn_count } trace with model={ model } and actual user input" )
223+ logging .info (f"Langfuse: Starting turn trace with model={ model } and actual user input" )
224224 else :
225- input_content = [{"role" : "user" , "content" : f"Turn { turn_count } " }]
226- logging .info (f"Langfuse: Starting turn { turn_count } trace with model={ model } " )
225+ input_content = [{"role" : "user" , "content" : "User input " }]
226+ logging .info (f"Langfuse: Starting turn trace with model={ model } " )
227227
228228 # Create generation as a TRACE using start_as_current_observation()
229- # This makes claude_turn_X a top-level trace, not an observation
229+ # Name doesn't include turn number - that will be added to metadata in end_turn()
230+ # This makes the trace a top-level observation, not nested
230231 # Tools will automatically become child observations of this trace
231232 self ._current_turn_ctx = self .langfuse_client .start_as_current_observation (
232233 as_type = "generation" ,
233- name = f"claude_turn_ { turn_count } " ,
234+ name = "claude_interaction" , # Generic name, turn number added in metadata
234235 input = input_content ,
235236 model = model ,
236- metadata = metadata ,
237+ metadata = {}, # Turn number will be added in end_turn()
237238 )
238239 self ._current_turn_generation = self ._current_turn_ctx .__enter__ ()
239- logging .debug (f"Langfuse: Turn { turn_count } trace started, ready for tool spans " )
240+ logging .info (f"Langfuse: Created new trace (model= { model } ) " )
240241
241242 except Exception as e :
242243 logging .error (f"Langfuse: Failed to start turn: { e } " , exc_info = True )
243244
244245 def end_turn (self , turn_count : int , message : Any , usage : dict | None = None ) -> None :
245246 """Complete turn tracking with output and usage data (called when ResultMessage arrives).
246247
247- Updates the turn generation with the assistant's output and usage metrics, then closes it.
248+ Updates the turn generation with the assistant's output, usage metrics, and SDK's
249+ authoritative turn number in metadata, then closes it.
248250
249251 Args:
250- turn_count: Current turn number
252+ turn_count: Current turn number (from SDK's authoritative num_turns in ResultMessage)
251253 message: AssistantMessage from Claude SDK
252254 usage: Usage dict from ResultMessage with input_tokens, output_tokens, cache tokens, etc.
253255 """
@@ -291,8 +293,12 @@ def end_turn(self, turn_count: int, message: Any, usage: dict | None = None) ->
291293 if cache_creation > 0 :
292294 usage_details_dict ["cache_creation_input_tokens" ] = cache_creation
293295
294- # Update with output and usage_details (SDK v3 requires 'usage_details' parameter)
295- update_params = {"output" : output_text }
296+ # Update with output, usage_details, and turn number in metadata
297+ # SDK v3 requires 'usage_details' parameter for usage tracking
298+ update_params = {
299+ "output" : output_text ,
300+ "metadata" : {"turn" : turn_count } # Add SDK's authoritative turn number
301+ }
296302 if usage_details_dict :
297303 update_params ["usage_details" ] = usage_details_dict
298304 self ._current_turn_generation .update (** update_params )
@@ -310,7 +316,7 @@ def end_turn(self, turn_count: int, message: Any, usage: dict | None = None) ->
310316 if self .langfuse_client :
311317 try :
312318 self .langfuse_client .flush ()
313- logging .debug (f"Langfuse: Flushed turn { turn_count } data" )
319+ logging .info (f"Langfuse: Flushed turn { turn_count } data" )
314320 except Exception as e :
315321 logging .warning (f"Langfuse: Flush failed after turn { turn_count } : { e } " )
316322
0 commit comments