lf-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ require 'faraday'
2
+ require 'faraday/retry'
3
+ require 'json'
4
+
5
+ module Langfuse
6
+ module CLI
7
+ class Client
8
+ class APIError < StandardError; end
9
+ class AuthenticationError < APIError; end
10
+ class NotFoundError < APIError; end
11
+ class RateLimitError < APIError; end
12
+ class TimeoutError < APIError; end
13
+
14
+ attr_reader :host, :public_key
15
+
16
+ def initialize(config)
17
+ @host = config.host
18
+ @public_key = config.public_key
19
+ @secret_key = config.secret_key
20
+ @connection = build_connection
21
+ end
22
+
23
+ # Traces API
24
+ def list_traces(filters = {})
25
+ params = build_trace_params(filters)
26
+ paginate('/api/public/traces', params)
27
+ end
28
+
29
+ def get_trace(trace_id)
30
+ request(:get, "/api/public/traces/#{trace_id}")
31
+ end
32
+
33
+ # Sessions API
34
+ def list_sessions(filters = {})
35
+ params = build_session_params(filters)
36
+ paginate('/api/public/sessions', params)
37
+ end
38
+
39
+ def get_session(session_id)
40
+ request(:get, "/api/public/sessions/#{session_id}")
41
+ end
42
+
43
+ # Observations API
44
+ def list_observations(filters = {})
45
+ params = build_observation_params(filters)
46
+ paginate('/api/public/observations', params)
47
+ end
48
+
49
+ def get_observation(observation_id)
50
+ request(:get, "/api/public/observations/#{observation_id}")
51
+ end
52
+
53
+ # Metrics API
54
+ def query_metrics(query_params)
55
+ request(:post, '/api/public/metrics', query_params)
56
+ end
57
+
58
+ # Scores API
59
+ def list_scores(filters = {})
60
+ params = build_score_params(filters)
61
+ paginate('/api/public/scores', params)
62
+ end
63
+
64
+ def get_score(score_id)
65
+ request(:get, "/api/public/scores/#{score_id}")
66
+ end
67
+
68
+ private
69
+
70
+ def build_connection
71
+ Faraday.new(url: @host) do |conn|
72
+ # Set short timeouts to prevent hanging
73
+ conn.options.timeout = 2 # 2 seconds read timeout
74
+ conn.options.open_timeout = 2 # 2 seconds connection timeout
75
+
76
+ conn.request :authorization, :basic, @public_key, @secret_key
77
+ conn.request :json
78
+ conn.request :retry, {
79
+ max: 3,
80
+ interval: 0.5,
81
+ interval_randomness: 0.5,
82
+ backoff_factor: 2,
83
+ retry_statuses: [429, 500, 502, 503, 504],
84
+ methods: [:get, :post]
85
+ }
86
+ conn.response :json, content_type: /\bjson$/
87
+
88
+ # Enable debug logging if DEBUG=1
89
+ if ENV['DEBUG'] == '1'
90
+ conn.response :logger, nil, { headers: true, bodies: true }
91
+ end
92
+
93
+ conn.adapter Faraday.default_adapter
94
+ end
95
+ end
96
+
97
+ def request(method, path, params = {})
98
+ response = case method
99
+ when :get
100
+ @connection.get(path, params)
101
+ when :post
102
+ @connection.post(path, params)
103
+ when :put
104
+ @connection.put(path, params)
105
+ when :delete
106
+ @connection.delete(path, params)
107
+ else
108
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
109
+ end
110
+
111
+ handle_response(response)
112
+ rescue Faraday::TimeoutError => e
113
+ raise TimeoutError, "Request timed out. Please check your network connection and host URL."
114
+ rescue Faraday::ConnectionFailed => e
115
+ raise APIError, "Connection failed: #{e.message}"
116
+ end
117
+
118
+ def paginate(path, params = {})
119
+ page = params[:page] || 1
120
+ limit = params[:limit] || 50
121
+ requested_limit = limit # Remember the original limit to stop pagination
122
+ all_results = []
123
+
124
+ loop do
125
+ response = request(:get, path, params.merge(page: page, limit: limit))
126
+
127
+ # Handle both array and hash responses
128
+ data = response.is_a?(Hash) && response['data'] ? response['data'] : response
129
+ break if data.nil? || (data.is_a?(Array) && data.empty?)
130
+
131
+ all_results.concat(Array(data))
132
+
133
+ # Stop if we've collected enough results
134
+ break if all_results.length >= requested_limit
135
+
136
+ # Check if there are more pages
137
+ meta = response.is_a?(Hash) ? response['meta'] : nil
138
+ break unless meta && meta['totalPages'] && page < meta['totalPages']
139
+
140
+ page += 1
141
+ end
142
+
143
+ # Return only the requested number of results
144
+ all_results.take(requested_limit)
145
+ end
146
+
147
+ def handle_response(response)
148
+ case response.status
149
+ when 200..299
150
+ response.body
151
+ when 401
152
+ raise AuthenticationError, "Authentication failed. Check your API keys."
153
+ when 404
154
+ raise NotFoundError, "Resource not found: #{response.body}"
155
+ when 429
156
+ raise RateLimitError, "Rate limit exceeded. Please try again later."
157
+ when 400..499
158
+ error_message = extract_error_message(response.body)
159
+ raise APIError, "Client error (#{response.status}): #{error_message}"
160
+ when 500..599
161
+ error_message = extract_error_message(response.body)
162
+ raise APIError, "Server error (#{response.status}): #{error_message}"
163
+ else
164
+ raise APIError, "Unexpected response status: #{response.status}"
165
+ end
166
+ end
167
+
168
+ def extract_error_message(body)
169
+ return body unless body.is_a?(Hash)
170
+ body['message'] || body['error'] || body.to_s
171
+ end
172
+
173
+ # Parameter builders
174
+ def build_trace_params(filters)
175
+ params = {}
176
+ params[:userId] = filters[:user_id] if filters[:user_id]
177
+ params[:name] = filters[:name] if filters[:name]
178
+ params[:sessionId] = filters[:session_id] if filters[:session_id]
179
+ params[:tags] = filters[:tags] if filters[:tags]
180
+ params[:fromTimestamp] = parse_timestamp(filters[:from]) if filters[:from]
181
+ params[:toTimestamp] = parse_timestamp(filters[:to]) if filters[:to]
182
+ params[:page] = filters[:page] if filters[:page]
183
+ params[:limit] = filters[:limit] if filters[:limit]
184
+ params
185
+ end
186
+
187
+ def build_session_params(filters)
188
+ params = {}
189
+ params[:fromTimestamp] = parse_timestamp(filters[:from]) if filters[:from]
190
+ params[:toTimestamp] = parse_timestamp(filters[:to]) if filters[:to]
191
+ params[:page] = filters[:page] if filters[:page]
192
+ params[:limit] = filters[:limit] if filters[:limit]
193
+ params
194
+ end
195
+
196
+ def build_observation_params(filters)
197
+ params = {}
198
+ params[:name] = filters[:name] if filters[:name]
199
+ params[:userId] = filters[:user_id] if filters[:user_id]
200
+ params[:traceId] = filters[:trace_id] if filters[:trace_id]
201
+ params[:type] = filters[:type] if filters[:type]
202
+ params[:fromTimestamp] = parse_timestamp(filters[:from]) if filters[:from]
203
+ params[:toTimestamp] = parse_timestamp(filters[:to]) if filters[:to]
204
+ params[:page] = filters[:page] if filters[:page]
205
+ params[:limit] = filters[:limit] if filters[:limit]
206
+ params
207
+ end
208
+
209
+ def build_score_params(filters)
210
+ params = {}
211
+ params[:name] = filters[:name] if filters[:name]
212
+ params[:fromTimestamp] = parse_timestamp(filters[:from]) if filters[:from]
213
+ params[:toTimestamp] = parse_timestamp(filters[:to]) if filters[:to]
214
+ params[:page] = filters[:page] if filters[:page]
215
+ params[:limit] = filters[:limit] if filters[:limit]
216
+ params
217
+ end
218
+
219
+ def parse_timestamp(timestamp)
220
+ return timestamp if timestamp.is_a?(String) && timestamp.match?(/^\d{4}-\d{2}-\d{2}T/)
221
+
222
+ # Try to parse with chronic if available
223
+ begin
224
+ require 'chronic'
225
+ parsed = Chronic.parse(timestamp)
226
+ parsed&.iso8601
227
+ rescue LoadError
228
+ timestamp
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,176 @@
1
+ require 'thor'
2
+ require 'tty-prompt'
3
+
4
+ module Langfuse
5
+ module CLI
6
+ module Commands
7
+ class ConfigCommand < Thor
8
+ namespace :config
9
+
10
+ def self.exit_on_failure?
11
+ true
12
+ end
13
+
14
+ desc 'setup', 'Interactive configuration setup (supports env vars for non-interactive mode)'
15
+ long_desc <<-LONGDESC
16
+ Set up Langfuse CLI configuration interactively or via environment variables.
17
+
18
+ Environment variables (for non-interactive mode):
19
+ LANGFUSE_PROJECT_NAME - Your Langfuse project name
20
+ LANGFUSE_PUBLIC_KEY - Your Langfuse public key
21
+ LANGFUSE_SECRET_KEY - Your Langfuse secret key
22
+ LANGFUSE_HOST - Langfuse host URL (default: https://cloud.langfuse.com)
23
+ LANGFUSE_PROFILE - Profile name to save as (default: default)
24
+
25
+ Examples:
26
+ # Interactive mode
27
+ $ lf config setup
28
+
29
+ # Non-interactive mode via environment variables
30
+ $ LANGFUSE_PROJECT_NAME=my-project \\
31
+ LANGFUSE_PUBLIC_KEY=pk-lf-xxx \\
32
+ LANGFUSE_SECRET_KEY=sk-lf-xxx \\
33
+ LANGFUSE_PROFILE=my-profile \\
34
+ lf config setup
35
+ LONGDESC
36
+ def setup
37
+ prompt = TTY::Prompt.new
38
+
39
+ # Check for environment variables for non-interactive mode
40
+ project_name = ENV['LANGFUSE_PROJECT_NAME']
41
+ public_key = ENV['LANGFUSE_PUBLIC_KEY']
42
+ secret_key = ENV['LANGFUSE_SECRET_KEY']
43
+ host = ENV['LANGFUSE_HOST'] || 'https://cloud.langfuse.com'
44
+ profile_name = ENV['LANGFUSE_PROFILE'] || 'default'
45
+
46
+ # Determine if we're in non-interactive mode
47
+ non_interactive = !public_key.nil? && !secret_key.nil?
48
+
49
+ if non_interactive
50
+ puts "🔑 Running in non-interactive mode (using environment variables)\n\n"
51
+ else
52
+ puts "\n🔑 Langfuse CLI Configuration Setup\n\n"
53
+ end
54
+
55
+ # Prompt for missing values in interactive mode
56
+ unless non_interactive
57
+ project_name ||= prompt.ask('Enter your Langfuse project name:', required: false)
58
+
59
+ # Show URL hint if project name was provided
60
+ if project_name && !project_name.empty?
61
+ settings_url = "#{host}/project/#{project_name}/settings"
62
+ prompt.say("💡 Visit: #{settings_url}")
63
+ prompt.say(" (to get your API keys)\n")
64
+ end
65
+
66
+ public_key = prompt.ask('Enter your Langfuse public key:', required: true)
67
+ secret_key = prompt.mask('Enter your Langfuse secret key:', required: true)
68
+ host = prompt.ask('Enter host:', default: 'https://cloud.langfuse.com')
69
+ profile_name = prompt.ask('Save as profile name:', default: 'default')
70
+ end
71
+
72
+ # Test connection
73
+ begin
74
+ config = Config.new(
75
+ public_key: public_key,
76
+ secret_key: secret_key,
77
+ host: host
78
+ )
79
+
80
+ client = Client.new(config)
81
+ client.list_traces(limit: 1)
82
+ prompt.ok('Testing connection... Success!')
83
+
84
+ # Save configuration
85
+ config.save(profile_name)
86
+ prompt.ok("Configuration saved to ~/.langfuse/config.yml")
87
+ puts "\nYou're all set! Try: langfuse traces list"
88
+ rescue Client::TimeoutError => e
89
+ prompt.error("Connection test failed: #{e.message}")
90
+ prompt.error("The host '#{host}' may be incorrect or unreachable.")
91
+ exit 1
92
+ rescue Client::AuthenticationError => e
93
+ prompt.error("Connection test failed: #{e.message}")
94
+ prompt.error("Please check your credentials and try again.")
95
+ exit 1
96
+ rescue Client::APIError => e
97
+ prompt.error("Connection test failed: #{e.message}")
98
+ exit 1
99
+ end
100
+ end
101
+
102
+ desc 'set PROFILE', 'Set configuration for a profile'
103
+ option :public_key, type: :string, required: true, desc: 'Langfuse public key'
104
+ option :secret_key, type: :string, required: true, desc: 'Langfuse secret key'
105
+ option :host, type: :string, default: 'https://cloud.langfuse.com', desc: 'Langfuse host URL'
106
+ def set(profile)
107
+ prompt = TTY::Prompt.new
108
+
109
+ config = Config.new(
110
+ public_key: options[:public_key],
111
+ secret_key: options[:secret_key],
112
+ host: options[:host]
113
+ )
114
+
115
+ config.save(profile)
116
+ prompt.ok("Configuration saved for profile: #{profile}")
117
+ end
118
+
119
+ desc 'show [PROFILE]', 'Show configuration for a profile'
120
+ def show(profile = 'default')
121
+ config = Config.new(profile: profile)
122
+
123
+ puts "\nConfiguration for profile: #{profile}"
124
+ puts "─" * 50
125
+ puts "Host: #{config.host}"
126
+ puts "Public Key: #{mask_key(config.public_key)}"
127
+ puts "Secret Key: #{mask_key(config.secret_key)}"
128
+ puts "Output Format: #{config.output_format}"
129
+ puts "Page Limit: #{config.page_limit}"
130
+ puts "─" * 50
131
+ end
132
+
133
+ desc 'list', 'List all configuration profiles'
134
+ def list
135
+ config_file = File.expand_path('~/.langfuse/config.yml')
136
+
137
+ unless File.exist?(config_file)
138
+ puts "No configuration file found at #{config_file}"
139
+ puts "Run 'langfuse config setup' to create one."
140
+ return
141
+ end
142
+
143
+ require 'yaml'
144
+ config_data = YAML.load_file(config_file)
145
+ profiles = config_data['profiles'] || {}
146
+
147
+ if profiles.empty?
148
+ puts "No profiles configured."
149
+ puts "Run 'langfuse config setup' to create one."
150
+ return
151
+ end
152
+
153
+ puts "\nConfigured Profiles:"
154
+ puts "─" * 50
155
+
156
+ profiles.each do |name, profile_config|
157
+ puts "\n#{name}:"
158
+ puts " Host: #{profile_config['host']}"
159
+ puts " Public Key: #{mask_key(profile_config['public_key'])}"
160
+ end
161
+
162
+ puts "\n─" * 50
163
+ end
164
+
165
+ private
166
+
167
+ def mask_key(key)
168
+ return '' if key.nil? || key.empty?
169
+ return key if key.length < 8
170
+
171
+ "#{key[0..7]}#{'*' * (key.length - 8)}"
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,193 @@
1
+ require 'thor'
2
+ require 'json'
3
+ require 'sorbet-runtime'
4
+ require_relative '../types'
5
+
6
+ module Langfuse
7
+ module CLI
8
+ module Commands
9
+ class Metrics < Thor
10
+ extend T::Sig
11
+ namespace :metrics
12
+
13
+ def self.exit_on_failure?
14
+ true
15
+ end
16
+
17
+ desc 'query', 'Query metrics with custom parameters'
18
+ long_desc <<-LONGDESC
19
+ Query Langfuse metrics with flexible aggregations and dimensions.
20
+
21
+ REQUIRED OPTIONS:
22
+ --view: View type to query
23
+ Valid values: traces, observations, scores-numeric, scores-categorical
24
+
25
+ --measure: Metric to measure
26
+ Valid values: count, latency, value, tokens, cost
27
+
28
+ --aggregation: How to aggregate the measure
29
+ Valid values: count, sum, avg, p50, p95, p99, min, max, histogram
30
+
31
+ OPTIONAL:
32
+ --dimensions: Fields to group by (repeatable)
33
+ Examples: name, userId, sessionId, model, type
34
+
35
+ --from, --to: Time range (ISO 8601 or relative like "1 hour ago")
36
+
37
+ --granularity: Time bucketing
38
+ Valid values: minute, hour, day, week, month, auto
39
+
40
+ EXAMPLES:
41
+
42
+ # Count all traces
43
+ langfuse metrics query --view traces --measure count --aggregation count
44
+
45
+ # Average latency by trace name
46
+ langfuse metrics query --view observations --measure latency --aggregation avg --dimensions name
47
+
48
+ # Token usage with time range
49
+ langfuse metrics query --view observations --measure tokens --aggregation sum --from "2024-01-01" --to "2024-12-31"
50
+
51
+ # P95 latency grouped by model
52
+ langfuse metrics query --view observations --measure latency --aggregation p95 --dimensions model
53
+
54
+ API REFERENCE:
55
+ Full API documentation: https://api.reference.langfuse.com/
56
+ OpenAPI spec: https://cloud.langfuse.com/generated/api/openapi.yml
57
+ LONGDESC
58
+ option :view, type: :string, required: true,
59
+ enum: %w[traces observations scores-numeric scores-categorical],
60
+ desc: 'View type'
61
+ option :measure, type: :string, required: true,
62
+ enum: %w[count latency value tokens cost],
63
+ desc: 'Measure type'
64
+ option :aggregation, type: :string, required: true,
65
+ enum: %w[count sum avg p50 p95 p99 min max histogram],
66
+ desc: 'Aggregation function'
67
+ option :dimensions, type: :array, desc: 'Fields to group by (e.g., name userId sessionId model)'
68
+ option :from, type: :string, desc: 'Start timestamp (ISO 8601 or relative)'
69
+ option :to, type: :string, desc: 'End timestamp (ISO 8601 or relative)'
70
+ option :granularity, type: :string,
71
+ enum: %w[minute hour day week month auto],
72
+ desc: 'Time granularity'
73
+ option :limit, type: :numeric, desc: 'Limit number of results', default: 100
74
+ def query
75
+ query_params = build_query(options)
76
+ result = client.query_metrics(query_params)
77
+
78
+ # Extract data from result if it's wrapped in a data key
79
+ output_data = result.is_a?(Hash) && result['data'] ? result['data'] : result
80
+ output_result(output_data)
81
+ rescue Client::AuthenticationError => e
82
+ puts "Authentication Error: #{e.message}"
83
+ exit 1
84
+ rescue Client::APIError => e
85
+ puts "Error: #{e.message}"
86
+ exit 1
87
+ end
88
+
89
+ private
90
+
91
+ def client
92
+ @client ||= begin
93
+ config = load_config
94
+ unless config.valid?
95
+ error_message = "Missing required configuration: #{config.missing_fields.join(', ')}"
96
+ error_message += "\n\nPlease set environment variables or run: langfuse config setup"
97
+ raise Error, error_message
98
+ end
99
+ Client.new(config)
100
+ end
101
+ end
102
+
103
+ def load_config
104
+ Config.new(
105
+ profile: parent_options[:profile],
106
+ public_key: parent_options[:public_key],
107
+ secret_key: parent_options[:secret_key],
108
+ host: parent_options[:host],
109
+ format: parent_options[:format]
110
+ )
111
+ end
112
+
113
+ sig { params(opts: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, T.untyped]) }
114
+ def build_query(opts)
115
+ # Validate enum values
116
+ Types::MetricsView.deserialize(opts[:view])
117
+ Types::Measure.deserialize(opts[:measure])
118
+ Types::Aggregation.deserialize(opts[:aggregation])
119
+ Types::TimeGranularity.deserialize(opts[:granularity]) if opts[:granularity]
120
+
121
+ # Build query using struct
122
+ query = Types::MetricsQuery.new(
123
+ view: opts[:view],
124
+ measure: opts[:measure],
125
+ aggregation: opts[:aggregation],
126
+ dimensions: opts[:dimensions],
127
+ from_timestamp: opts[:from] ? parse_timestamp(opts[:from]) : nil,
128
+ to_timestamp: opts[:to] ? parse_timestamp(opts[:to]) : nil,
129
+ granularity: opts[:granularity],
130
+ limit: opts[:limit]
131
+ )
132
+
133
+ query.to_h
134
+ end
135
+
136
+ sig { params(timestamp: String).returns(T.nilable(String)) }
137
+ def parse_timestamp(timestamp)
138
+ return timestamp if timestamp.match?(/^\d{4}-\d{2}-\d{2}T/)
139
+
140
+ # Try to parse with chronic if available
141
+ begin
142
+ require 'chronic'
143
+ parsed = Chronic.parse(timestamp)
144
+ parsed&.iso8601
145
+ rescue LoadError
146
+ timestamp
147
+ end
148
+ end
149
+
150
+ def output_result(data)
151
+ formatted = format_output(data)
152
+
153
+ if parent_options[:output]
154
+ File.write(parent_options[:output], formatted)
155
+ puts "Output written to #{parent_options[:output]}" if parent_options[:verbose]
156
+ else
157
+ puts formatted
158
+ end
159
+ end
160
+
161
+ def format_output(data)
162
+ format_type = parent_options[:format] || 'table'
163
+
164
+ case format_type
165
+ when 'json'
166
+ JSON.pretty_generate(data)
167
+ when 'csv'
168
+ require_relative '../formatters/csv_formatter'
169
+ Formatters::CSVFormatter.format(data)
170
+ when 'markdown'
171
+ require_relative '../formatters/markdown_formatter'
172
+ Formatters::MarkdownFormatter.format(data)
173
+ else # table
174
+ require_relative '../formatters/table_formatter'
175
+ Formatters::TableFormatter.format(data)
176
+ end
177
+ end
178
+
179
+ def parent_options
180
+ @parent_options ||= begin
181
+ if parent.respond_to?(:options)
182
+ parent.options
183
+ else
184
+ {}
185
+ end
186
+ rescue
187
+ {}
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end