Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions lib/langfuse/api_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,26 @@ def create_dataset_run_item(dataset_item_id:, run_name:, trace_id: nil,
raise ApiError, "HTTP request failed: #{e.message}"
end

# Fetch projects accessible with the current API keys
#
# @return [Hash] The parsed response body containing project data
# @raise [UnauthorizedError] if authentication fails
# @raise [ApiError] for other API errors
#
# @example
# data = api_client.get_projects
# project_id = data["data"][0]["id"]
def get_projects # rubocop:disable Naming/AccessorMethodName
response = connection.get("/api/public/projects")
handle_response(response)
rescue Faraday::RetriableResponse => e
logger.error("Faraday error: Retries exhausted - #{e.response.status}")
handle_response(e.response)
rescue Faraday::Error => e
logger.error("Faraday error: #{e.message}")
raise ApiError, "HTTP request failed: #{e.message}"
end

# Shut down the API client and release resources
#
# Shuts down the cache if it supports shutdown (e.g., SWR thread pool).
Expand Down
58 changes: 56 additions & 2 deletions lib/langfuse/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ def initialize(config)
cache: cache
)

@project_id = nil
# One-shot lookup: avoids repeated blocking API calls in URL helpers
# (trace_url, dataset_url, dataset_run_url) when the project endpoint is down.
@project_id_fetched = false

# Initialize score client for batching score events
@score_client = ScoreClient.new(api_client: @api_client, config: config)
end
Expand Down Expand Up @@ -235,16 +240,45 @@ def update_prompt(name:, version:, labels:)
build_prompt_client(prompt_data)
end

# Lazily-fetched project ID for URL generation
#
# Fetches the project ID from the API on first access and caches it.
# Returns nil if the API call fails (URL generation is non-critical).
#
# @return [String, nil] The Langfuse project ID
def project_id
return @project_id if @project_id_fetched

fetch_project_id
end

# Generate URL for viewing a trace in Langfuse UI
#
# @param trace_id [String] The trace ID (hex-encoded, 32 characters)
# @return [String] URL to view the trace
# @return [String, nil] URL to view the trace, or nil if project ID unavailable
#
# @example
# url = client.trace_url("abc123...")
# puts "View trace at: #{url}"
def trace_url(trace_id)
"#{config.base_url}/traces/#{trace_id}"
project_url("traces/#{trace_id}")
end

# Generate URL for viewing a dataset in Langfuse UI
#
# @param dataset_id [String] The dataset ID
# @return [String, nil] URL to view the dataset, or nil if project ID unavailable
def dataset_url(dataset_id)
project_url("datasets/#{dataset_id}")
end

# Generate URL for viewing a dataset run in Langfuse UI
#
# @param dataset_id [String] The dataset ID
# @param dataset_run_id [String] The dataset run ID
# @return [String, nil] URL to view the dataset run, or nil if project ID unavailable
def dataset_run_url(dataset_id:, dataset_run_id:)
project_url("datasets/#{dataset_id}/runs/#{dataset_run_id}")
end

# Create a score event and queue it for batching
Expand Down Expand Up @@ -562,6 +596,26 @@ def run_experiment(name:, task:, data: nil, dataset_name: nil, description: nil,

attr_reader :score_client

# Build a project-scoped URL, returning nil if project ID is unavailable
def project_url(path)
pid = project_id
return nil unless pid

"#{config.base_url}/project/#{pid}/#{path}"
end

# Fetch project ID from the API and cache it
#
# @return [String, nil] the project ID, or nil on failure
def fetch_project_id
data = api_client.get_projects
@project_id = data.dig("data", 0, "id")
rescue StandardError
nil
ensure
@project_id_fetched = true
end

def fetch_dataset_items_page(page:, limit:, **filters)
api_client.list_dataset_items(page: page, limit: limit, **filters)
end
Expand Down
9 changes: 9 additions & 0 deletions lib/langfuse/dataset_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ def items
end
end

# Generate URL for viewing this dataset in Langfuse UI
#
# @return [String, nil] URL to view the dataset, or nil if client unavailable
def url
return nil unless @client

@client.dataset_url(@id)
end

# Run an experiment against all items in this dataset
#
# @param name [String] experiment/run name (required)
Expand Down
16 changes: 14 additions & 2 deletions lib/langfuse/experiment_runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def initialize(client:, name:, items:, task:, evaluators: [], run_evaluators: []
@run_name = run_name || "#{name} - #{Time.now.utc.iso8601}"
@logger = Langfuse.configuration.logger
@dataset_run_id = nil
@dataset_id = nil
end
# rubocop:enable Metrics/ParameterLists

Expand All @@ -52,7 +53,8 @@ def execute
run_evaluations: run_evals,
run_name: @run_name,
description: @description,
dataset_run_id: @dataset_run_id
dataset_run_id: @dataset_run_id,
dataset_run_url: build_dataset_run_url
)
end

Expand Down Expand Up @@ -136,13 +138,17 @@ def persist_run_score(evaluation)
@logger.warn("Run score persistence failed for '#{evaluation.name}': #{e.message}")
end

# Invariant: all items in a single run belong to the same dataset.
def link_to_dataset_run(item, trace_id, observation_id)
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new comment states a single-run invariant (all items belong to the same dataset), but this method doesn’t enforce it. If a caller passes DatasetItemClient objects from different datasets, the run will still link items and later URL generation will use whichever dataset_id was captured first, producing an incorrect dataset_run_url. Consider validating that subsequent items match the initially captured dataset_id (and either raise or warn/skip linking when they don’t).

Suggested change
def link_to_dataset_run(item, trace_id, observation_id)
def link_to_dataset_run(item, trace_id, observation_id)
if @dataset_id && item.dataset_id && item.dataset_id != @dataset_id
@logger.warn(
"Dataset run item linking skipped: item dataset_id=#{item.dataset_id} " \
"does not match run dataset_id=#{@dataset_id}"
)
return nil
end

Copilot uses AI. Check for mistakes.
response = @client.create_dataset_run_item(
dataset_item_id: item.id, run_name: @run_name,
trace_id: trace_id, observation_id: observation_id,
metadata: @metadata, run_description: @description
)
@dataset_run_id ||= response&.dig("datasetRunId")
unless @dataset_run_id
@dataset_run_id = response&.dig("datasetRunId")
@dataset_id = item.dataset_id if @dataset_run_id
end
response
rescue StandardError => e
@logger.warn("Dataset run item linking failed: #{e.message}")
Expand Down Expand Up @@ -223,6 +229,12 @@ def item_metadata(item)
item.respond_to?(:metadata) ? item.metadata : nil
end

def build_dataset_run_url
return nil unless @dataset_run_id && @dataset_id

@client.dataset_run_url(dataset_id: @dataset_id, dataset_run_id: @dataset_run_id)
end

# @api private
def log_task_failure(task_error, index)
message = task_error.respond_to?(:message) ? task_error.message : task_error.to_s
Expand Down
65 changes: 65 additions & 0 deletions spec/langfuse/api_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2180,6 +2180,71 @@ def set(_key, value)
end
end

describe "#get_projects" do
let(:projects_response) do
{
"data" => [
{ "id" => "proj-abc-123", "name" => "my-project" }
]
}
end

context "with successful response" do
before do
stub_request(:get, "#{base_url}/api/public/projects")
.to_return(
status: 200,
body: projects_response.to_json,
headers: { "Content-Type" => "application/json" }
)
end

it "returns parsed response body" do
result = api_client.get_projects
expect(result["data"].first["id"]).to eq("proj-abc-123")
end

it "makes GET request to correct endpoint" do
api_client.get_projects
expect(
a_request(:get, "#{base_url}/api/public/projects")
).to have_been_made.once
end
end

context "when authentication fails" do
before do
stub_request(:get, "#{base_url}/api/public/projects")
.to_return(status: 401, body: { message: "Unauthorized" }.to_json)
end

it "raises UnauthorizedError" do
expect { api_client.get_projects }.to raise_error(Langfuse::UnauthorizedError)
end
end

context "when network error occurs" do
before do
stub_request(:get, "#{base_url}/api/public/projects")
.to_timeout
end

it "raises ApiError" do
expect { api_client.get_projects }.to raise_error(Langfuse::ApiError, /HTTP request failed/)
end
end

context "when retries exhausted" do
it "handles Faraday::RetriableResponse" do
mock_response = instance_double(Faraday::Response, status: 503, body: { "message" => "Service unavailable" })
retriable_error = Faraday::RetriableResponse.new("Retries exhausted", mock_response)
allow(api_client.connection).to receive(:get).and_raise(retriable_error)

expect { api_client.get_projects }.to raise_error(Langfuse::ApiError, /API request failed \(503\)/)
end
end
end

describe "#create_dataset_run_item" do
let(:run_item_response) do
{
Expand Down
20 changes: 17 additions & 3 deletions spec/langfuse/base_observation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -553,17 +553,24 @@ def version
config.secret_key = "sk_test_456"
config.base_url = "https://cloud.langfuse.com"
end

stub_request(:get, "https://cloud.langfuse.com/api/public/projects")
.to_return(
status: 200,
body: { "data" => [{ "id" => "proj-abc" }] }.to_json,
headers: { "Content-Type" => "application/json" }
)
end

after do
Langfuse.reset!
end

it "generates trace URL using client" do
it "generates trace URL with project ID" do
trace_id = observation.trace_id
url = observation.trace_url

expect(url).to eq("https://cloud.langfuse.com/traces/#{trace_id}")
expect(url).to eq("https://cloud.langfuse.com/project/proj-abc/traces/#{trace_id}")
end

it "uses configured base_url" do
Expand All @@ -573,10 +580,17 @@ def version
config.base_url = "https://custom.langfuse.com"
end

stub_request(:get, "https://custom.langfuse.com/api/public/projects")
.to_return(
status: 200,
body: { "data" => [{ "id" => "proj-xyz" }] }.to_json,
headers: { "Content-Type" => "application/json" }
)

trace_id = observation.trace_id
url = observation.trace_url

expect(url).to eq("https://custom.langfuse.com/traces/#{trace_id}")
expect(url).to eq("https://custom.langfuse.com/project/proj-xyz/traces/#{trace_id}")
end

it "calls client.trace_url with correct trace_id" do
Expand Down
Loading
Loading