Skip to content

Commit 830ebec

Browse files
committed
Add input/output support for traces
Traces were missing input/output fields, causing them to appear as null/undefined in the Langfuse UI. This change adds full support for trace input/output via both parameters and setter methods. Changes: - Add input/output parameters to Tracer#trace and Langfuse.trace - Add input=/output= setter methods to Trace class - Update exporter to parse and include input/output in trace-create events - Add comprehensive end-to-end tests verifying the fix Traces can now capture input/output in two ways: 1. Via parameters: Langfuse.trace(name: "...", input: {...}, output: {...}) 2. Via setters: trace.input = {...}; trace.output = {...}
1 parent 4c5e9de commit 830ebec

File tree

5 files changed

+232
-5
lines changed

5 files changed

+232
-5
lines changed

lib/langfuse.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ def tracer
9090
# @param name [String] Name of the trace
9191
# @param user_id [String, nil] Optional user ID
9292
# @param session_id [String, nil] Optional session ID
93+
# @param input [Object, nil] Optional input data (will be JSON-encoded)
94+
# @param output [Object, nil] Optional output data (will be JSON-encoded)
9395
# @param metadata [Hash, nil] Optional metadata hash
9496
# @param tags [Array<String>, nil] Optional tags array
9597
# @param context [OpenTelemetry::Context, nil] Optional parent context for distributed tracing
@@ -98,7 +100,7 @@ def tracer
98100
# @return [Object] The return value of the block
99101
#
100102
# @example
101-
# Langfuse.trace(name: "user-request", user_id: "user-123") do |trace|
103+
# Langfuse.trace(name: "user-request", user_id: "user-123", input: { query: "..." }) do |trace|
102104
# trace.span(name: "database-query") do |span|
103105
# # Do work
104106
# end
@@ -108,11 +110,13 @@ def tracer
108110
# end
109111
# end
110112
#
111-
def trace(name:, user_id: nil, session_id: nil, metadata: nil, tags: nil, context: nil, &)
113+
def trace(name:, user_id: nil, session_id: nil, input: nil, output: nil, metadata: nil, tags: nil, context: nil, &)
112114
tracer.trace(
113115
name: name,
114116
user_id: user_id,
115117
session_id: session_id,
118+
input: input,
119+
output: output,
116120
metadata: metadata,
117121
tags: tags,
118122
context: context,

lib/langfuse/exporter.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ def create_trace_event(span_data, attributes)
130130
name: span_data.name,
131131
user_id: attributes["langfuse.user_id"],
132132
session_id: attributes["langfuse.session_id"],
133+
input: parse_json_attribute(attributes["langfuse.input"]),
134+
output: parse_json_attribute(attributes["langfuse.output"]),
133135
metadata: parse_json_attribute(attributes["langfuse.metadata"]),
134136
tags: parse_json_attribute(attributes["langfuse.tags"]),
135137
timestamp: format_timestamp(span_data.start_timestamp)

lib/langfuse/trace.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,54 @@ def inject_context
132132
carrier
133133
end
134134

135+
# Set the input of this trace
136+
#
137+
# @param value [Object] The input value (will be JSON-encoded)
138+
# @return [void]
139+
#
140+
# @example
141+
# trace.input = { query: "What is Ruby?" }
142+
#
143+
def input=(value)
144+
@otel_span.set_attribute("langfuse.input", value.to_json)
145+
end
146+
147+
# Set the output of this trace
148+
#
149+
# @param value [Object] The output value (will be JSON-encoded)
150+
# @return [void]
151+
#
152+
# @example
153+
# trace.output = { answer: "Ruby is a programming language" }
154+
#
155+
def output=(value)
156+
@otel_span.set_attribute("langfuse.output", value.to_json)
157+
end
158+
159+
# Set metadata for this trace
160+
#
161+
# @param value [Hash] Metadata hash (will be JSON-encoded)
162+
# @return [void]
163+
#
164+
# @example
165+
# trace.metadata = { source: "api", cache: "miss" }
166+
#
167+
def metadata=(value)
168+
@otel_span.set_attribute("langfuse.metadata", value.to_json)
169+
end
170+
171+
# Set the level of this trace
172+
#
173+
# @param value [String] Level (debug, default, warning, error)
174+
# @return [void]
175+
#
176+
# @example
177+
# trace.level = "warning"
178+
#
179+
def level=(value)
180+
@otel_span.set_attribute("langfuse.level", value)
181+
end
182+
135183
private
136184

137185
# Build OTel attributes for a span

lib/langfuse/tracer.rb

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def initialize(otel_tracer: nil)
2929
# @param name [String] Name of the trace
3030
# @param user_id [String, nil] Optional user ID
3131
# @param session_id [String, nil] Optional session ID
32+
# @param input [Object, nil] Optional input data (will be JSON-encoded)
33+
# @param output [Object, nil] Optional output data (will be JSON-encoded)
3234
# @param metadata [Hash, nil] Optional metadata hash
3335
# @param tags [Array<String>, nil] Optional tags array
3436
# @param context [OpenTelemetry::Context, nil] Optional parent context for distributed tracing
@@ -37,16 +39,19 @@ def initialize(otel_tracer: nil)
3739
# @return [Object] The return value of the block
3840
#
3941
# @example
40-
# tracer.trace(name: "user-request", user_id: "user-123") do |trace|
42+
# tracer.trace(name: "user-request", user_id: "user-123", input: { query: "..." }) do |trace|
4143
# trace.span(name: "database-query") do |span|
4244
# # Do work
4345
# end
4446
# end
4547
#
46-
def trace(name:, user_id: nil, session_id: nil, metadata: nil, tags: nil, context: nil, &block)
48+
def trace(name:, user_id: nil, session_id: nil, input: nil, output: nil, metadata: nil, tags: nil, context: nil,
49+
&block)
4750
attributes = build_trace_attributes(
4851
user_id: user_id,
4952
session_id: session_id,
53+
input: input,
54+
output: output,
5055
metadata: metadata,
5156
tags: tags
5257
)
@@ -73,14 +78,18 @@ def default_tracer
7378
#
7479
# @param user_id [String, nil]
7580
# @param session_id [String, nil]
81+
# @param input [Object, nil]
82+
# @param output [Object, nil]
7683
# @param metadata [Hash, nil]
7784
# @param tags [Array<String>, nil]
7885
# @return [Hash]
79-
def build_trace_attributes(user_id:, session_id:, metadata:, tags:)
86+
def build_trace_attributes(user_id:, session_id:, input:, output:, metadata:, tags:)
8087
{
8188
"langfuse.type" => "trace",
8289
"langfuse.user_id" => user_id,
8390
"langfuse.session_id" => session_id,
91+
"langfuse.input" => input&.to_json,
92+
"langfuse.output" => output&.to_json,
8493
"langfuse.metadata" => metadata&.to_json,
8594
"langfuse.tags" => tags&.to_json
8695
}.compact

spec/langfuse/end_to_end_spec.rb

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
require "opentelemetry/sdk"
5+
6+
RSpec.describe "End-to-End Langfuse Integration" do
7+
let(:public_key) { "pk_test_123" }
8+
let(:secret_key) { "sk_test_456" }
9+
let(:base_url) { "https://api.langfuse.test" }
10+
11+
# Capture the actual request body sent to Langfuse
12+
let(:captured_request_body) { [] }
13+
14+
before do
15+
# Configure Langfuse with tracing enabled
16+
Langfuse.configure do |config|
17+
config.public_key = public_key
18+
config.secret_key = secret_key
19+
config.base_url = base_url
20+
config.tracing_enabled = true
21+
end
22+
23+
# Stub the ingestion API and capture request body
24+
stub_request(:post, "#{base_url}/api/public/ingestion")
25+
.to_return do |request|
26+
captured_request_body << JSON.parse(request.body)
27+
{ status: 200, body: {}.to_json }
28+
end
29+
end
30+
31+
after do
32+
Langfuse.reset!
33+
end
34+
35+
describe "Trace with Generation" do
36+
it "sends correct payload to Langfuse API" do
37+
# Simulate the spam-detection script
38+
Langfuse.trace(name: "spam-detection", user_id: "test-user") do |trace|
39+
messages = [
40+
{ role: :system, content: "You are a spam detector" },
41+
{ role: :user, content: "Buy meds!" }
42+
]
43+
44+
trace.generation(
45+
name: "classify",
46+
model: "gpt-4",
47+
input: messages
48+
) do |gen|
49+
gen.output = "95"
50+
gen.usage = {
51+
prompt_tokens: 50,
52+
completion_tokens: 2,
53+
total_tokens: 52
54+
}
55+
end
56+
end
57+
58+
# Force flush to send the data
59+
Langfuse.force_flush
60+
61+
# Verify request was made
62+
expect(WebMock).to have_requested(:post, "#{base_url}/api/public/ingestion").at_least_once
63+
64+
# Debug: Print the captured payloads
65+
puts "\n=== Captured Request Bodies ==="
66+
captured_request_body.each_with_index do |body, idx|
67+
puts "\n--- Request #{idx + 1} ---"
68+
puts JSON.pretty_generate(body)
69+
end
70+
puts "=== End Captured Bodies ===\n"
71+
72+
# Verify the payloads
73+
all_events = captured_request_body.flat_map { |req| req["batch"] }
74+
75+
# Should have 2 events: trace-create and generation-create
76+
expect(all_events.length).to be >= 2
77+
78+
trace_event = all_events.find { |e| e["type"] == "trace-create" }
79+
generation_event = all_events.find { |e| e["type"] == "generation-create" }
80+
81+
expect(trace_event).not_to be_nil
82+
expect(generation_event).not_to be_nil
83+
84+
# Verify trace data
85+
expect(trace_event["body"]["name"]).to eq("spam-detection")
86+
expect(trace_event["body"]["user_id"]).to eq("test-user")
87+
88+
# Verify generation data
89+
expect(generation_event["body"]["name"]).to eq("classify")
90+
expect(generation_event["body"]["model"]).to eq("gpt-4")
91+
92+
# THIS IS THE KEY TEST: Check if input/output are present and parsed correctly
93+
puts "\n=== Generation Event Body ==="
94+
puts JSON.pretty_generate(generation_event["body"])
95+
96+
# Input should be the messages array (parsed from JSON)
97+
expect(generation_event["body"]["input"]).to eq([
98+
{ "role" => "system", "content" => "You are a spam detector" },
99+
{ "role" => "user", "content" => "Buy meds!" }
100+
])
101+
102+
# Output should be the string "95" (parsed from JSON)
103+
expect(generation_event["body"]["output"]).to eq("95")
104+
105+
# Usage should be the hash (parsed from JSON)
106+
expect(generation_event["body"]["usage"]).to eq({
107+
"prompt_tokens" => 50,
108+
"completion_tokens" => 2,
109+
"total_tokens" => 52
110+
})
111+
end
112+
113+
it "supports trace input/output via parameters" do
114+
# Test traces with input/output passed as parameters
115+
Langfuse.trace(
116+
name: "test-trace",
117+
user_id: "user-123",
118+
input: { query: "What is Ruby?" },
119+
output: { answer: "A programming language" },
120+
metadata: { foo: "bar" }
121+
) do |trace|
122+
trace.span(name: "step-1", input: { query: "test" }) do |span|
123+
span.output = { result: "success" }
124+
end
125+
end
126+
127+
Langfuse.force_flush
128+
129+
all_events = captured_request_body.flat_map { |req| req["batch"] }
130+
131+
trace_event = all_events.find { |e| e["type"] == "trace-create" }
132+
span_event = all_events.find { |e| e["type"] == "span-create" }
133+
134+
# Trace should have input/output
135+
expect(trace_event["body"]["input"]).to eq({ "query" => "What is Ruby?" })
136+
expect(trace_event["body"]["output"]).to eq({ "answer" => "A programming language" })
137+
expect(trace_event["body"]["metadata"]).to eq({ "foo" => "bar" })
138+
139+
# Span should have input/output
140+
expect(span_event["body"]["input"]).to eq({ "query" => "test" })
141+
expect(span_event["body"]["output"]).to eq({ "result" => "success" })
142+
end
143+
144+
it "supports trace input/output via setters" do
145+
# Test traces with input/output set via setters
146+
Langfuse.trace(name: "test-trace-2", user_id: "user-456") do |trace|
147+
trace.input = { request: "calculate 2+2" }
148+
trace.output = { result: 4 }
149+
trace.metadata = { calculator: "v1" }
150+
end
151+
152+
Langfuse.force_flush
153+
154+
all_events = captured_request_body.flat_map { |req| req["batch"] }
155+
156+
trace_event = all_events.find { |e| e["type"] == "trace-create" && e["body"]["name"] == "test-trace-2" }
157+
158+
# Trace should have input/output set via setters
159+
expect(trace_event["body"]["input"]).to eq({ "request" => "calculate 2+2" })
160+
expect(trace_event["body"]["output"]).to eq({ "result" => 4 })
161+
expect(trace_event["body"]["metadata"]).to eq({ "calculator" => "v1" })
162+
end
163+
end
164+
end

0 commit comments

Comments
 (0)