From 1bce3cf79573ca36ce8b3160de92f6ff99fe2d50 Mon Sep 17 00:00:00 2001 From: kadekillary Date: Mon, 9 Feb 2026 07:46:59 -0600 Subject: [PATCH] feat(traces): add list_traces and get_trace endpoints Expose trace listing with full filter/pagination support and single-trace fetch via the public API, with corresponding Client delegation, tests, and API reference docs. --- docs/API_REFERENCE.md | 87 +++++++++ lib/langfuse/api_client.rb | 96 ++++++++++ lib/langfuse/client.rb | 29 +++ spec/langfuse/api_client_spec.rb | 301 +++++++++++++++++++++++++++++++ spec/langfuse/client_spec.rb | 109 +++++++++++ 5 files changed, 622 insertions(+) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index eedb9da..bb487f8 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -8,6 +8,7 @@ Complete method reference for the Langfuse Ruby SDK. - [Client Access](#client-access) - [Prompt Management](#prompt-management) - [Tracing & Observability](#tracing--observability) +- [Traces](#traces) - [Scoring](#scoring) - [Datasets](#datasets) - [Experiments](#experiments) @@ -513,6 +514,92 @@ obs.update( ) ``` +## Traces + +### `Client#list_traces` + +List traces in the project. + +**Signature:** + +```ruby +list_traces(page: nil, limit: nil, **filters) +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +| ---------------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `page` | Integer | No | Page number | +| `limit` | Integer | No | Results per page | +| `user_id` | String | No | Filter by user ID | +| `name` | String | No | Filter by trace name | +| `session_id` | String | No | Filter by session ID | +| `from_timestamp` | Time | No | Filter traces after this time | +| `to_timestamp` | Time | No | Filter traces before this time | +| `order_by` | String | No | Order by field | +| `tags` | Array | No | Filter by tags | +| `version` | String | No | Filter by version | +| `release` | String | No | Filter by release | +| `environment` | String | No | Filter by environment | +| `fields` | String | No | Comma-separated field groups to include (e.g. `"core,scores,metrics"`). Available: `core`, `io`, `scores`, `observations`, `metrics`. All fields returned if omitted. | +| `filter` | String | No | JSON string for advanced filtering | + +**Returns:** `Array` of trace data + +**Raises:** + +- `UnauthorizedError` if authentication fails +- `ApiError` for other API errors + +**Examples:** + +```ruby +# Basic listing +traces = client.list_traces(page: 1, limit: 20) + +# Filter by user and name +traces = client.list_traces(user_id: "user-123", name: "my-trace") + +# Time range with field selection +traces = client.list_traces( + from_timestamp: Time.utc(2025, 1, 1), + to_timestamp: Time.utc(2025, 1, 31), + fields: "core,scores" +) +``` + +### `Client#get_trace` + +Fetch a single trace by ID. + +**Signature:** + +```ruby +get_trace(id) # => Hash +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ----------- | +| `id` | String | Yes | Trace ID | + +**Returns:** `Hash` of trace data + +**Raises:** + +- `NotFoundError` if the trace doesn't exist +- `UnauthorizedError` if authentication fails +- `ApiError` for other API errors + +**Example:** + +```ruby +trace = client.get_trace("trace-uuid-123") +puts trace["name"] +``` + ## Scoring ### `Client#create_score` diff --git a/lib/langfuse/api_client.rb b/lib/langfuse/api_client.rb index ed13e06..837b41a 100644 --- a/lib/langfuse/api_client.rb +++ b/lib/langfuse/api_client.rb @@ -285,6 +285,85 @@ def shutdown cache.shutdown if cache.respond_to?(:shutdown) end + # List traces in the project + # + # @param page [Integer, nil] Optional page number for pagination + # @param limit [Integer, nil] Optional limit per page + # @param user_id [String, nil] Filter by user ID + # @param name [String, nil] Filter by trace name + # @param session_id [String, nil] Filter by session ID + # @param from_timestamp [Time, nil] Filter traces after this time + # @param to_timestamp [Time, nil] Filter traces before this time + # @param order_by [String, nil] Order by field + # @param tags [Array, nil] Filter by tags + # @param version [String, nil] Filter by version + # @param release [String, nil] Filter by release + # @param environment [String, nil] Filter by environment + # @param fields [String, nil] Comma-separated field groups to include (e.g. "core,scores,metrics") + # @param filter [String, nil] JSON string for advanced filtering + # @return [Array] Array of trace hashes + # @raise [UnauthorizedError] if authentication fails + # @raise [ApiError] for other API errors + # + # @example + # traces = api_client.list_traces(page: 1, limit: 10, name: "my-trace") + # rubocop:disable Metrics/ParameterLists + def list_traces(page: nil, limit: nil, user_id: nil, name: nil, session_id: nil, + from_timestamp: nil, to_timestamp: nil, order_by: nil, + tags: nil, version: nil, release: nil, environment: nil, + fields: nil, filter: nil) + result = list_traces_paginated( + page: page, limit: limit, user_id: user_id, name: name, + session_id: session_id, from_timestamp: from_timestamp, + to_timestamp: to_timestamp, order_by: order_by, tags: tags, + version: version, release: release, environment: environment, + fields: fields, filter: filter + ) + result["data"] || [] + end + # rubocop:enable Metrics/ParameterLists + + # Full paginated response including "meta" for internal pagination use + # + # @api private + # @return [Hash] Full response hash with "data" array and "meta" pagination info + # rubocop:disable Metrics/ParameterLists + def list_traces_paginated(page: nil, limit: nil, user_id: nil, name: nil, session_id: nil, + from_timestamp: nil, to_timestamp: nil, order_by: nil, + tags: nil, version: nil, release: nil, environment: nil, + fields: nil, filter: nil) + with_faraday_error_handling do + params = build_traces_params( + page: page, limit: limit, user_id: user_id, name: name, + session_id: session_id, from_timestamp: from_timestamp, + to_timestamp: to_timestamp, order_by: order_by, tags: tags, + version: version, release: release, environment: environment, + fields: fields, filter: filter + ) + response = connection.get("/api/public/traces", params) + handle_response(response) + end + end + # rubocop:enable Metrics/ParameterLists + + # Fetch a trace by ID + # + # @param id [String] Trace ID + # @return [Hash] The trace data + # @raise [NotFoundError] if the trace is not found + # @raise [UnauthorizedError] if authentication fails + # @raise [ApiError] for other API errors + # + # @example + # trace = api_client.get_trace("trace-uuid-123") + def get_trace(id) + with_faraday_error_handling do + encoded_id = URI.encode_uri_component(id) + response = connection.get("/api/public/traces/#{encoded_id}") + handle_response(response) + end + end + # List all datasets in the project # # @param page [Integer, nil] Optional page number for pagination @@ -525,6 +604,23 @@ def build_dataset_items_params(dataset_name:, page:, limit:, }.compact end + # Build query params for list_traces, mapping snake_case to camelCase + # rubocop:disable Metrics/ParameterLists + def build_traces_params(page:, limit:, user_id:, name:, session_id:, + from_timestamp:, to_timestamp:, order_by:, + tags:, version:, release:, environment:, fields:, filter:) + { + page: page, limit: limit, userId: user_id, name: name, + sessionId: session_id, + fromTimestamp: from_timestamp&.iso8601, + toTimestamp: to_timestamp&.iso8601, + orderBy: order_by, tags: tags, version: version, + release: release, environment: environment, fields: fields, + filter: filter + }.compact + end + # rubocop:enable Metrics/ParameterLists + # Fetch with SWR cache def fetch_with_swr_cache(cache_key, name, version, label) cache.fetch_with_stale_while_revalidate(cache_key) do diff --git a/lib/langfuse/client.rb b/lib/langfuse/client.rb index d071a49..fc201e7 100644 --- a/lib/langfuse/client.rb +++ b/lib/langfuse/client.rb @@ -439,6 +439,35 @@ def list_datasets(page: nil, limit: nil) api_client.list_datasets(page: page, limit: limit) end + # List traces in the project + # + # @param page [Integer, nil] Optional page number for pagination + # @param limit [Integer, nil] Optional limit per page + # @param filters [Hash] Additional filters (user_id, name, session_id, etc.) + # @return [Array] Array of trace hashes + # @raise [UnauthorizedError] if authentication fails + # @raise [ApiError] for other API errors + # + # @example + # traces = client.list_traces(page: 1, limit: 10, name: "my-trace") + def list_traces(page: nil, limit: nil, **filters) + api_client.list_traces(page: page, limit: limit, **filters) + end + + # Fetch a trace by ID + # + # @param id [String] Trace ID + # @return [Hash] The trace data + # @raise [NotFoundError] if the trace is not found + # @raise [UnauthorizedError] if authentication fails + # @raise [ApiError] for other API errors + # + # @example + # trace = client.get_trace("trace-uuid-123") + def get_trace(id) + api_client.get_trace(id) + end + # Create a new dataset item # # @param dataset_name [String] Name of the dataset to add item to (required) diff --git a/spec/langfuse/api_client_spec.rb b/spec/langfuse/api_client_spec.rb index 00da694..644c597 100644 --- a/spec/langfuse/api_client_spec.rb +++ b/spec/langfuse/api_client_spec.rb @@ -2335,4 +2335,305 @@ def set(_key, value) end end end + + describe "#list_traces" do + let(:traces_response) do + { + "data" => [ + { "id" => "trace-1", "name" => "trace-one" }, + { "id" => "trace-2", "name" => "trace-two" } + ], + "meta" => { "totalItems" => 2 } + } + end + + context "with successful response" do + before do + stub_request(:get, "#{base_url}/api/public/traces") + .to_return( + status: 200, + body: traces_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns array of traces" do + result = api_client.list_traces + expect(result).to be_an(Array) + expect(result.size).to eq(2) + end + + it "makes GET request to correct endpoint" do + api_client.list_traces + expect( + a_request(:get, "#{base_url}/api/public/traces") + ).to have_been_made.once + end + end + + context "with pagination" do + before do + stub_request(:get, "#{base_url}/api/public/traces") + .with(query: { page: "2", limit: "10" }) + .to_return( + status: 200, + body: traces_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + it "passes pagination parameters" do + api_client.list_traces(page: 2, limit: 10) + expect( + a_request(:get, "#{base_url}/api/public/traces") + .with(query: { page: "2", limit: "10" }) + ).to have_been_made.once + end + end + + context "with filter parameters" do + before do + stub_request(:get, "#{base_url}/api/public/traces") + .with(query: { userId: "user-1", name: "my-trace", sessionId: "sess-1", + tags: %w[tag1 tag2], version: "1.0", release: "prod", + environment: "production" }) + .to_return( + status: 200, + body: traces_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + it "maps snake_case params to camelCase query params" do + api_client.list_traces( + user_id: "user-1", name: "my-trace", session_id: "sess-1", + tags: %w[tag1 tag2], version: "1.0", release: "prod", + environment: "production" + ) + expect( + a_request(:get, "#{base_url}/api/public/traces") + .with(query: { userId: "user-1", name: "my-trace", sessionId: "sess-1", + tags: %w[tag1 tag2], version: "1.0", release: "prod", + environment: "production" }) + ).to have_been_made.once + end + end + + context "with timestamp parameters" do + let(:from_time) { Time.utc(2025, 1, 1, 12, 0, 0) } + let(:to_time) { Time.utc(2025, 1, 2, 12, 0, 0) } + + before do + stub_request(:get, "#{base_url}/api/public/traces") + .with(query: { fromTimestamp: from_time.iso8601, toTimestamp: to_time.iso8601 }) + .to_return( + status: 200, + body: traces_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + it "serializes timestamps to ISO 8601" do + api_client.list_traces(from_timestamp: from_time, to_timestamp: to_time) + expect( + a_request(:get, "#{base_url}/api/public/traces") + .with(query: { fromTimestamp: from_time.iso8601, toTimestamp: to_time.iso8601 }) + ).to have_been_made.once + end + end + + context "when authentication fails" do + before do + stub_request(:get, "#{base_url}/api/public/traces") + .to_return(status: 401, body: { message: "Unauthorized" }.to_json) + end + + it "raises UnauthorizedError" do + expect { api_client.list_traces }.to raise_error(Langfuse::UnauthorizedError) + end + end + + context "when network error occurs" do + before do + stub_request(:get, "#{base_url}/api/public/traces") + .to_timeout + end + + it "raises ApiError" do + expect { api_client.list_traces }.to raise_error(Langfuse::ApiError, /HTTP request failed/) + end + end + + context "with filter parameter" do + before do + stub_request(:get, "#{base_url}/api/public/traces") + .with(query: { filter: '[{"type":"string","key":"name","operator":"=","value":"test"}]' }) + .to_return( + status: 200, + body: traces_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + it "passes filter param to query string" do + filter_json = '[{"type":"string","key":"name","operator":"=","value":"test"}]' + api_client.list_traces(filter: filter_json) + expect( + a_request(:get, "#{base_url}/api/public/traces") + .with(query: { filter: filter_json }) + ).to have_been_made.once + 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.list_traces }.to raise_error(Langfuse::ApiError, /API request failed \(503\)/) + end + end + end + + describe "#list_traces_paginated" do + let(:traces_response) do + { + "data" => [ + { "id" => "trace-1", "name" => "trace-one" }, + { "id" => "trace-2", "name" => "trace-two" } + ], + "meta" => { "totalItems" => 2, "page" => 1, "limit" => 50, "totalPages" => 1 } + } + end + + context "with successful response" do + before do + stub_request(:get, "#{base_url}/api/public/traces") + .to_return( + status: 200, + body: traces_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns full hash with data and meta keys" do + result = api_client.list_traces_paginated + expect(result).to be_a(Hash) + expect(result).to have_key("data") + expect(result).to have_key("meta") + end + + it "includes data array" do + result = api_client.list_traces_paginated + expect(result["data"]).to be_an(Array) + expect(result["data"].size).to eq(2) + end + + it "includes meta pagination info" do + result = api_client.list_traces_paginated + expect(result["meta"]["totalItems"]).to eq(2) + expect(result["meta"]["totalPages"]).to eq(1) + end + end + + context "with filter parameter" do + before do + stub_request(:get, "#{base_url}/api/public/traces") + .with(query: { filter: '[{"type":"string","key":"name","operator":"=","value":"test"}]' }) + .to_return( + status: 200, + body: traces_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + it "passes filter param to query string" do + filter_json = '[{"type":"string","key":"name","operator":"=","value":"test"}]' + api_client.list_traces_paginated(filter: filter_json) + expect( + a_request(:get, "#{base_url}/api/public/traces") + .with(query: { filter: filter_json }) + ).to have_been_made.once + end + end + end + + describe "#get_trace" do + let(:trace_id) { "trace-abc-123" } + let(:trace_response) do + { + "id" => trace_id, + "name" => "my-trace", + "userId" => "user-1" + } + end + + context "with successful response" do + before do + stub_request(:get, "#{base_url}/api/public/traces/#{trace_id}") + .to_return( + status: 200, + body: trace_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns trace data" do + result = api_client.get_trace(trace_id) + expect(result["id"]).to eq(trace_id) + expect(result["name"]).to eq("my-trace") + end + + it "makes GET request to correct endpoint" do + api_client.get_trace(trace_id) + expect( + a_request(:get, "#{base_url}/api/public/traces/#{trace_id}") + ).to have_been_made.once + end + end + + context "when not found" do + before do + stub_request(:get, "#{base_url}/api/public/traces/#{trace_id}") + .to_return(status: 404, body: { message: "Not found" }.to_json) + end + + it "raises NotFoundError" do + expect { api_client.get_trace(trace_id) }.to raise_error(Langfuse::NotFoundError) + end + end + + context "when authentication fails" do + before do + stub_request(:get, "#{base_url}/api/public/traces/#{trace_id}") + .to_return(status: 401, body: { message: "Unauthorized" }.to_json) + end + + it "raises UnauthorizedError" do + expect { api_client.get_trace(trace_id) }.to raise_error(Langfuse::UnauthorizedError) + end + end + + context "when network error occurs" do + before do + stub_request(:get, "#{base_url}/api/public/traces/#{trace_id}") + .to_timeout + end + + it "raises ApiError" do + expect { api_client.get_trace(trace_id) }.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_trace(trace_id) }.to raise_error(Langfuse::ApiError, /API request failed \(503\)/) + end + end + end end diff --git a/spec/langfuse/client_spec.rb b/spec/langfuse/client_spec.rb index c4b00d5..10ebba4 100644 --- a/spec/langfuse/client_spec.rb +++ b/spec/langfuse/client_spec.rb @@ -1713,6 +1713,115 @@ class << self end end + describe "#list_traces" do + let(:client) { described_class.new(valid_config) } + let(:base_url) { valid_config.base_url } + let(:traces_response) do + { + "data" => [ + { "id" => "trace-1", "name" => "trace-one" }, + { "id" => "trace-2", "name" => "trace-two" } + ], + "meta" => { "totalItems" => 2 } + } + end + + before do + stub_request(:get, "#{base_url}/api/public/traces") + .to_return( + status: 200, + body: traces_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns array of trace data" do + result = client.list_traces + expect(result).to be_an(Array) + expect(result.size).to eq(2) + end + + it "returns raw hash data" do + result = client.list_traces + expect(result.first).to be_a(Hash) + expect(result.first["name"]).to eq("trace-one") + end + + it "passes through keyword arguments" do + stub_request(:get, "#{base_url}/api/public/traces") + .with(query: { page: "1", limit: "5", name: "my-trace" }) + .to_return( + status: 200, + body: traces_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + + client.list_traces(page: 1, limit: 5, name: "my-trace") + expect( + a_request(:get, "#{base_url}/api/public/traces") + .with(query: { page: "1", limit: "5", name: "my-trace" }) + ).to have_been_made.once + end + + it "passes filter parameter through to api_client" do + filter_json = '[{"type":"string","key":"name","operator":"=","value":"test"}]' + stub_request(:get, "#{base_url}/api/public/traces") + .with(query: { filter: filter_json }) + .to_return( + status: 200, + body: traces_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + + client.list_traces(filter: filter_json) + expect( + a_request(:get, "#{base_url}/api/public/traces") + .with(query: { filter: filter_json }) + ).to have_been_made.once + end + end + + describe "#get_trace" do + let(:client) { described_class.new(valid_config) } + let(:base_url) { valid_config.base_url } + let(:trace_response) do + { + "id" => "trace-123", + "name" => "my-trace", + "userId" => "user-1" + } + end + + context "with successful response" do + before do + stub_request(:get, "#{base_url}/api/public/traces/trace-123") + .to_return( + status: 200, + body: trace_response.to_json, + headers: { "Content-Type" => "application/json" } + ) + end + + it "returns trace hash" do + result = client.get_trace("trace-123") + expect(result).to be_a(Hash) + expect(result["id"]).to eq("trace-123") + expect(result["name"]).to eq("my-trace") + end + end + + context "when not found" do + before do + stub_request(:get, "#{base_url}/api/public/traces/missing") + .to_return(status: 404, body: { message: "Not found" }.to_json) + end + + it "raises NotFoundError" do + expect { client.get_trace("missing") }.to raise_error(Langfuse::NotFoundError) + end + end + end + describe "#create_dataset_item" do let(:client) { described_class.new(valid_config) } let(:base_url) { valid_config.base_url }