Class: Kward::TelemetryStats

Inherits:
Object
  • Object
show all
Defined in:
lib/kward/telemetry/stats.rb

Defined Under Namespace

Classes: Result

Constant Summary collapse

DEFAULT_RANGE =
"1 week"
UNITS =
%w[minute hour day week month year].freeze
USAGE =
"Usage: /stats [N minutes|hours|days|weeks|months|years] (default: 1 week)".freeze
TOKEN_CSV_HEADER =
%w[bucket_start bucket_end provider model events input_tokens output_tokens cache_read_tokens cache_write_tokens total_tokens].freeze
TOKEN_BUCKETS =
%w[second minute hour day week month year].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(telemetry_logger: TelemetryLogger.new, clock: Time) ⇒ TelemetryStats

Returns a new instance of TelemetryStats.



37
38
39
40
# File 'lib/kward/telemetry/stats.rb', line 37

def initialize(telemetry_logger: TelemetryLogger.new, clock: Time)
  @telemetry_logger = telemetry_logger
  @clock = clock
end

Class Method Details

.calendar_start(now, count, unit) ⇒ Object



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/kward/telemetry/stats.rb', line 134

def self.calendar_start(now, count, unit)
  case unit
  when "minute"
    Time.utc(now.year, now.month, now.day, now.hour, now.min) - ((count - 1) * 60)
  when "hour"
    Time.utc(now.year, now.month, now.day, now.hour) - ((count - 1) * 60 * 60)
  when "day"
    Time.utc(now.year, now.month, now.day) - ((count - 1) * 24 * 60 * 60)
  when "week"
    start_of_week = Time.utc(now.year, now.month, now.day) - ((now.wday + 6) % 7 * 24 * 60 * 60)
    start_of_week - ((count - 1) * 7 * 24 * 60 * 60)
  when "month"
    shift_month_start(now, -(count - 1))
  when "year"
    Time.utc(now.year - count + 1, 1, 1)
  else
    raise ArgumentError, USAGE
  end
end

.format(result) ⇒ Object



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/kward/telemetry/stats.rb', line 92

def self.format(result)
  lines = []
  range = result.range
  lines << "Stats for #{range[:input]} (#{range[:start_at].iso8601} to #{range[:end_at].iso8601})"
  lines << "Log directory: #{result.log_dir}"
  lines << "Enabled categories: #{result.enabled_categories.join(", ")}"
  lines << "Records: #{result.record_count}"
  lines << ""
  lines << "Records by category:"
  lines.concat(format_counts(result.records_by_category))
  lines << ""
  lines << "Records by event:"
  lines.concat(format_counts(result.records_by_event))
  lines << ""
  lines << "Tokens:"
  lines.concat(format_tokens(result.tokens))
  lines << ""
  lines << "Performance:"
  lines.concat(format_performance(result.performance))
  lines << ""
  lines << "Tools:"
  lines.concat(format_tools(result.tools))
  lines << ""
  lines << "Errors:"
  lines.concat(format_errors(result.errors))
  lines.join("\n")
end

.format_counts(counts) ⇒ Object



161
162
163
164
165
# File 'lib/kward/telemetry/stats.rb', line 161

def self.format_counts(counts)
  return ["  none"] if counts.empty?

  counts.sort_by { |key, value| [-value, key.to_s] }.map { |key, value| "  #{key}: #{value}" }
end

.format_errors(errors) ⇒ Object



195
196
197
198
199
200
201
202
# File 'lib/kward/telemetry/stats.rb', line 195

def self.format_errors(errors)
  lines = ["  events: #{errors[:count]}"]
  lines << "  by event: #{inline_counts(errors[:byEvent])}"
  lines << "  by class: #{inline_counts(errors[:byClass])}"
  lines << "  by provider: #{inline_counts(errors[:byProvider])}"
  lines << "  by code: #{inline_counts(errors[:byCode])}"
  lines
end

.format_performance(performance) ⇒ Object



177
178
179
180
181
182
183
184
185
186
# File 'lib/kward/telemetry/stats.rb', line 177

def self.format_performance(performance)
  return ["  none"] if performance[:events].empty?

  lines = []
  performance[:events].sort.each do |event, stats|
    lines << "  #{event}: count=#{stats[:count]}, min=#{stats[:durationMs][:min]}, avg=#{stats[:durationMs][:avg]}, max=#{stats[:durationMs][:max]} ms"
    lines << "    statuses: #{inline_counts(stats[:statuses])}" unless stats[:statuses].empty?
  end
  lines
end

.format_tokens(tokens) ⇒ Object



167
168
169
170
171
172
173
174
175
# File 'lib/kward/telemetry/stats.rb', line 167

def self.format_tokens(tokens)
  lines = ["  model usage events: #{tokens[:modelUsageEvents]}"]
  if tokens[:totals].empty?
    lines << "  token totals: none"
  else
    tokens[:totals].sort.each { |key, value| lines << "  #{key}: #{value}" }
  end
  lines
end

.format_tools(tools) ⇒ Object



188
189
190
191
192
193
# File 'lib/kward/telemetry/stats.rb', line 188

def self.format_tools(tools)
  lines = ["  calls: #{tools[:calls]}", "  result bytes: #{tools[:resultBytes]}"]
  lines << "  by tool: #{inline_counts(tools[:byName])}"
  lines << "  by status: #{inline_counts(tools[:byStatus])}"
  lines
end

.inline_counts(counts) ⇒ Object



204
205
206
207
208
# File 'lib/kward/telemetry/stats.rb', line 204

def self.inline_counts(counts)
  return "none" if counts.empty?

  counts.sort_by { |key, value| [-value, key.to_s] }.map { |key, value| "#{key}=#{value}" }.join(", ")
end

.normalize_bucket(bucket) ⇒ Object

Raises:

  • (ArgumentError)


126
127
128
129
130
131
132
# File 'lib/kward/telemetry/stats.rb', line 126

def self.normalize_bucket(bucket)
  text = bucket.to_s.downcase.strip
  text = text.delete_suffix("s")
  raise ArgumentError, "Bucket must be one of: #{TOKEN_BUCKETS.join(", ")}" unless TOKEN_BUCKETS.include?(text)

  text
end

.normalize_unit(unit) ⇒ Object



120
121
122
123
124
# File 'lib/kward/telemetry/stats.rb', line 120

def self.normalize_unit(unit)
  text = unit.to_s.downcase
  text = text.delete_suffix("s")
  UNITS.include?(text) ? text : nil
end

.parse_range(argument, now: Time.now.utc) ⇒ Object

Raises:

  • (ArgumentError)


73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/kward/telemetry/stats.rb', line 73

def self.parse_range(argument, now: Time.now.utc)
  text = argument.to_s.strip
  text = DEFAULT_RANGE if text.empty?
  match = text.match(/\A(\d+)\s+([A-Za-z]+)\z/)
  raise ArgumentError, USAGE unless match

  count = match[1].to_i
  unit = normalize_unit(match[2])
  raise ArgumentError, USAGE unless count.positive? && unit

  {
    input: text,
    count: count,
    unit: unit,
    start_at: calendar_start(now.utc, count, unit),
    end_at: now.utc
  }
end

.shift_month_start(now, offset) ⇒ Object



154
155
156
157
158
159
# File 'lib/kward/telemetry/stats.rb', line 154

def self.shift_month_start(now, offset)
  month_index = (now.year * 12) + (now.month - 1) + offset
  year = month_index / 12
  month = (month_index % 12) + 1
  Time.utc(year, month, 1)
end

Instance Method Details

#collect(argument = "") ⇒ Object

Raises:

  • (ArgumentError)


50
51
52
53
54
55
56
57
# File 'lib/kward/telemetry/stats.rb', line 50

def collect(argument = "")
  categories = enabled_categories
  raise ArgumentError, "Telemetry logging is disabled. Enable logging and at least one category before using /stats." if categories.empty?

  range = self.class.parse_range(argument, now: @clock.now.utc)
  records = read_records(range[:start_at], range[:end_at], categories)
  build_result(range, categories, records)
end

#enabled_categoriesObject



42
43
44
# File 'lib/kward/telemetry/stats.rb', line 42

def enabled_categories
  @telemetry_logger.enabled_categories
end

#log_dirObject



46
47
48
# File 'lib/kward/telemetry/stats.rb', line 46

def log_dir
  @telemetry_logger.log_directory
end

#token_usage_csv(argument = "", bucket: nil) ⇒ Object

Raises:

  • (ArgumentError)


59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/kward/telemetry/stats.rb', line 59

def token_usage_csv(argument = "", bucket: nil)
  categories = enabled_categories
  raise ArgumentError, "Token telemetry logging is disabled. Enable logging and token logging before exporting token CSV." unless categories.include?("tokens")

  range = self.class.parse_range(argument, now: @clock.now.utc)
  bucket = self.class.normalize_bucket(bucket || range[:unit])
  buckets = token_usage_buckets(range, bucket)
  lines = [csv_row(TOKEN_CSV_HEADER)]
  buckets.each do |_key, values|
    lines << csv_row(TOKEN_CSV_HEADER.map { |column| token_csv_value(values, column) })
  end
  lines.join("\n") + "\n"
end