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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +399 -0
- data/bin/lf +15 -0
- data/lib/langfuse/cli/client.rb +233 -0
- data/lib/langfuse/cli/commands/config.rb +176 -0
- data/lib/langfuse/cli/commands/metrics.rb +193 -0
- data/lib/langfuse/cli/commands/observations.rb +161 -0
- data/lib/langfuse/cli/commands/scores.rb +122 -0
- data/lib/langfuse/cli/commands/sessions.rb +121 -0
- data/lib/langfuse/cli/commands/traces.rb +166 -0
- data/lib/langfuse/cli/config.rb +136 -0
- data/lib/langfuse/cli/formatters/csv_formatter.rb +46 -0
- data/lib/langfuse/cli/formatters/markdown_formatter.rb +56 -0
- data/lib/langfuse/cli/formatters/table_formatter.rb +43 -0
- data/lib/langfuse/cli/types.rb +98 -0
- data/lib/langfuse/cli/version.rb +5 -0
- data/lib/langfuse/cli.rb +142 -0
- data/lib/langfuse_cli.rb +10 -0
- metadata +303 -0
|
@@ -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
|