token_hawk 0.1.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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +51 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +201 -0
  6. data/Rakefile +12 -0
  7. data/app/assets/stylesheets/token_hawk/application.css +106 -0
  8. data/app/controllers/token_hawk/application_controller.rb +6 -0
  9. data/app/controllers/token_hawk/dashboard_controller.rb +12 -0
  10. data/app/controllers/token_hawk/domains_controller.rb +15 -0
  11. data/app/controllers/token_hawk/pricing_controller.rb +9 -0
  12. data/app/controllers/token_hawk/recent_controller.rb +12 -0
  13. data/app/views/layouts/token_hawk/application.html.erb +251 -0
  14. data/app/views/token_hawk/dashboard/index.html.erb +53 -0
  15. data/app/views/token_hawk/domains/show.html.erb +48 -0
  16. data/app/views/token_hawk/pricing/index.html.erb +27 -0
  17. data/app/views/token_hawk/recent/index.html.erb +36 -0
  18. data/config/routes.rb +8 -0
  19. data/db/migrate/01_create_token_hawk_calls.rb +22 -0
  20. data/db/migrate/02_create_token_hawk_llm_rates.rb +16 -0
  21. data/docs/cli.md +98 -0
  22. data/docs/configuration.md +90 -0
  23. data/docs/dashboard.md +73 -0
  24. data/docs/subscribe_hooks.md +81 -0
  25. data/exe/token_hawk +15 -0
  26. data/lib/generators/token_hawk/install/install_generator.rb +42 -0
  27. data/lib/generators/token_hawk/install/templates/create_token_hawk_calls.rb +18 -0
  28. data/lib/generators/token_hawk/install/templates/token_hawk.rb +12 -0
  29. data/lib/token_hawk/adapters/base.rb +23 -0
  30. data/lib/token_hawk/call.rb +15 -0
  31. data/lib/token_hawk/call_record.rb +18 -0
  32. data/lib/token_hawk/cli/commands/costs.rb +92 -0
  33. data/lib/token_hawk/cli/commands/efficiency.rb +54 -0
  34. data/lib/token_hawk/cli/commands/recent.rb +52 -0
  35. data/lib/token_hawk/cli/formatter.rb +13 -0
  36. data/lib/token_hawk/cli.rb +34 -0
  37. data/lib/token_hawk/configuration.rb +19 -0
  38. data/lib/token_hawk/cost.rb +18 -0
  39. data/lib/token_hawk/dashboard_query.rb +32 -0
  40. data/lib/token_hawk/domain_query.rb +35 -0
  41. data/lib/token_hawk/engine.rb +7 -0
  42. data/lib/token_hawk/pricing.rb +41 -0
  43. data/lib/token_hawk/recent_query.rb +16 -0
  44. data/lib/token_hawk/response_adapter.rb +23 -0
  45. data/lib/token_hawk/storage/active_record.rb +54 -0
  46. data/lib/token_hawk/storage/memory.rb +33 -0
  47. data/lib/token_hawk/version.rb +5 -0
  48. data/lib/token_hawk.rb +122 -0
  49. data/sig/token_hawk.rbs +4 -0
  50. metadata +172 -0
data/docs/dashboard.md ADDED
@@ -0,0 +1,73 @@
1
+ # Dashboard
2
+
3
+ TokenHawk ships a mountable Rails engine with a read-only browser dashboard.
4
+
5
+ ---
6
+
7
+ ## Mounting
8
+
9
+ ```ruby
10
+ # config/routes.rb
11
+ mount TokenHawk::Engine, at: "/token_hawk"
12
+ ```
13
+
14
+ Visit `/token_hawk` to see the overview.
15
+
16
+ ---
17
+
18
+ ## Authentication
19
+
20
+ The engine has no built-in authentication — it relies on the host app's. Protect the mount point before deploying to production.
21
+
22
+ **Devise example:**
23
+
24
+ ```ruby
25
+ authenticate :user, ->(u) { u.admin? } do
26
+ mount TokenHawk::Engine, at: "/token_hawk"
27
+ end
28
+ ```
29
+
30
+ **HTTP Basic (simple staging protection):**
31
+
32
+ ```ruby
33
+ # config/initializers/token_hawk.rb
34
+ TokenHawk::Engine.middleware.use Rack::Auth::Basic, "TokenHawk" do |u, p|
35
+ ActiveSupport::SecurityUtils.secure_compare(p, ENV.fetch("TOKEN_HAWK_PASSWORD"))
36
+ end
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Views
42
+
43
+ ### Overview (`/token_hawk`)
44
+
45
+ - Total spend for the current calendar month
46
+ - Top 3 domains by cost
47
+ - Daily cost breakdown for the current month
48
+
49
+ ### Domain detail (`/token_hawk/domains/:domain`)
50
+
51
+ - Total cost, call count, and average cost per call
52
+ - Top tag key-value pairs within the domain
53
+ - 10 most recent calls
54
+
55
+ Returns 404 for domains with no recorded calls.
56
+
57
+ ### Recent calls (`/token_hawk/recent`)
58
+
59
+ - Reverse-chronological call log
60
+ - Columns: timestamp, domain, model, input tokens, output tokens, cost, latency
61
+ - Supports `?domain=X` query param to filter
62
+
63
+ ### Pricing reference (`/token_hawk/pricing`)
64
+
65
+ - All models in the built-in pricing table
66
+ - Input and output rates per 1M tokens
67
+ - Note on rate vintage (rates reflect the gem version release date)
68
+
69
+ ---
70
+
71
+ ## Styling
72
+
73
+ The engine ships a minimal self-contained stylesheet (`token_hawk/application.css`). It uses system fonts and a neutral palette that won't clash with most host app designs. No Tailwind or external CSS framework required.
@@ -0,0 +1,81 @@
1
+ # Subscribe Hooks
2
+
3
+ TokenHawk fires events after every recorded call. Subscribe to react in real time — ship metrics, trigger alerts, or log to a secondary store.
4
+
5
+ ---
6
+
7
+ ## Usage
8
+
9
+ ```ruby
10
+ TokenHawk.subscribe(:call_recorded) do |call|
11
+ # call is a TokenHawk::Call value object (frozen, immutable)
12
+ end
13
+ ```
14
+
15
+ Place subscriptions in an initializer so they're registered at boot.
16
+
17
+ ---
18
+
19
+ ## The `Call` object
20
+
21
+ | Attribute | Type | Description |
22
+ |-----------|------|-------------|
23
+ | `domain` | Symbol | Attribution domain |
24
+ | `tags` | Hash | Arbitrary key-value metadata |
25
+ | `vendor` | String | `"anthropic"` or `"openai"` |
26
+ | `model` | String | Model identifier |
27
+ | `input_tokens` | Integer | Prompt tokens |
28
+ | `output_tokens` | Integer | Completion tokens |
29
+ | `total_cost_cents` | Integer | Calculated cost in cents |
30
+ | `latency_ms` | Integer | Wall-clock time of the LLM call |
31
+ | `created_at` | Time | When the call was recorded |
32
+
33
+ ---
34
+
35
+ ## Examples
36
+
37
+ ### StatsD / Datadog
38
+
39
+ ```ruby
40
+ TokenHawk.subscribe(:call_recorded) do |call|
41
+ tags = ["domain:#{call.domain}", "vendor:#{call.vendor}", "model:#{call.model}"]
42
+ StatsD.increment("llm.calls", tags: tags)
43
+ StatsD.gauge("llm.cost_cents", call.total_cost_cents, tags: tags)
44
+ StatsD.timing("llm.latency_ms", call.latency_ms, tags: tags)
45
+ end
46
+ ```
47
+
48
+ ### Budget alerting
49
+
50
+ ```ruby
51
+ TokenHawk.subscribe(:call_recorded) do |call|
52
+ monthly = TokenHawk::CallRecord
53
+ .since(Time.now.beginning_of_month)
54
+ .for_domain(call.domain)
55
+ .sum(:total_cost_cents)
56
+
57
+ if monthly > 10_000 # $100
58
+ SlackNotifier.alert("#{call.domain} has exceeded $100 this month")
59
+ end
60
+ end
61
+ ```
62
+
63
+ ### Secondary logging
64
+
65
+ ```ruby
66
+ TokenHawk.subscribe(:call_recorded) do |call|
67
+ Rails.logger.info({
68
+ event: "llm_call",
69
+ domain: call.domain,
70
+ model: call.model,
71
+ total_cost_cents: call.total_cost_cents,
72
+ latency_ms: call.latency_ms
73
+ }.to_json)
74
+ end
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Error handling
80
+
81
+ Exceptions raised inside a subscriber are rescued and do not propagate to the caller. If `config.log_failures` is true, the error message is passed to `config.failure_logger`.
data/exe/token_hawk ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "token_hawk"
5
+ require "token_hawk/cli"
6
+
7
+ rails_env = File.expand_path("config/environment.rb", Dir.pwd)
8
+ if File.exist?(rails_env)
9
+ require rails_env
10
+ elsif ENV["DATABASE_URL"]
11
+ require "active_record"
12
+ ActiveRecord::Base.establish_connection(ENV["DATABASE_URL"])
13
+ end
14
+
15
+ TokenHawk::CLI.start(ARGV)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+ require "active_record"
6
+
7
+ module TokenHawk
8
+ module Generators
9
+ class InstallGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+ desc "Install TokenHawk: copies migrations, creates initializer, mounts engine."
14
+
15
+ def self.next_migration_number(path)
16
+ next_migration_number = current_migration_number(path) + 1
17
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
18
+ end
19
+
20
+ def create_migrations
21
+ migration_template "create_token_hawk_calls.rb", "db/migrate/create_token_hawk_calls.rb"
22
+ end
23
+
24
+ def create_initializer
25
+ template "token_hawk.rb", "config/initializers/token_hawk.rb"
26
+ end
27
+
28
+ def mount_engine
29
+ route 'mount TokenHawk::Engine => "/token_hawk"'
30
+ end
31
+
32
+ def show_readme
33
+ say "\n✅ TokenHawk installed. Next steps:", :green
34
+ say " 1. bin/rails db:migrate"
35
+ say " 2. Attribute LLM calls — two interfaces:"
36
+ say " TokenHawk.track(domain: :my_feature) { client.messages.create(...) }"
37
+ say " TokenHawk.record(domain: :my_feature, model: \"...\", input_tokens: N, output_tokens: N, latency_ms: N)"
38
+ say " 3. Visit /token_hawk for the dashboard\n"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,18 @@
1
+ class CreateTokenHawkCalls < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :token_hawk_calls do |t|
4
+ t.string :domain, null: false
5
+ t.text :tags, null: false, default: "{}"
6
+ t.string :vendor, null: false
7
+ t.string :model, null: false
8
+ t.integer :input_tokens, null: false
9
+ t.integer :output_tokens, null: false
10
+ t.integer :total_cost_cents, null: false
11
+ t.integer :latency_ms, null: false
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :token_hawk_calls, :domain
16
+ add_index :token_hawk_calls, :created_at
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ TokenHawk.configure do |config|
4
+ # Storage backend: :active_record (default) or :memory
5
+ # config.storage = :active_record
6
+
7
+ # Log telemetry failures — set to false to silence them entirely
8
+ # config.log_failures = true
9
+
10
+ # Custom failure logger — must respond to #call
11
+ # config.failure_logger = ->(msg) { Rails.logger.warn(msg) }
12
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ module Adapters
5
+ class Base
6
+ def vendor_name
7
+ raise NotImplementedError, "#{self.class} must implement vendor_name"
8
+ end
9
+
10
+ def extract_model(response)
11
+ raise NotImplementedError, "#{self.class} must implement extract_model"
12
+ end
13
+
14
+ def extract_input_tokens(response)
15
+ raise NotImplementedError, "#{self.class} must implement extract_input_tokens"
16
+ end
17
+
18
+ def extract_output_tokens(response)
19
+ raise NotImplementedError, "#{self.class} must implement extract_output_tokens"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ Call = Data.define(
5
+ :domain,
6
+ :tags,
7
+ :vendor,
8
+ :model,
9
+ :input_tokens,
10
+ :output_tokens,
11
+ :total_cost_cents,
12
+ :latency_ms,
13
+ :created_at
14
+ )
15
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module TokenHawk
6
+ class CallRecord < ActiveRecord::Base
7
+ self.table_name = "token_hawk_calls"
8
+
9
+ serialize :tags, coder: JSON
10
+
11
+ scope :for_domain, ->(name) { where(domain: name) }
12
+ scope :since, ->(time) { where("created_at >= ?", time) }
13
+
14
+ def self.with_tag(key, value)
15
+ all.select { |r| r.tags[key.to_s] == value }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../formatter"
5
+
6
+ module TokenHawk
7
+ class CLI
8
+ module Commands
9
+ class Costs
10
+ include Formatter
11
+ def initialize(options = {})
12
+ @options = options
13
+ end
14
+
15
+ def run
16
+ results = grouped_results(scoped_records)
17
+ @options["format"] == "json" ? print_json(results) : print_table(results)
18
+ end
19
+
20
+ private
21
+
22
+ def scoped_records
23
+ scope = CallRecord.all
24
+ scope = scope.for_domain(@options["domain"]) if @options["domain"]
25
+ scope = scope.since(Time.parse(@options["since"])) if @options["since"]
26
+ scope
27
+ end
28
+
29
+ def grouped_results(scope)
30
+ return ruby_grouped_results(scope.to_a) if ruby_path?
31
+
32
+ sql_grouped_results(scope)
33
+ end
34
+
35
+ def ruby_path?
36
+ @options["by"] == "tag" || tag_filter?
37
+ end
38
+
39
+ def tag_filter?
40
+ @options["tag"] && !@options["tag"].empty? && @options["by"] != "tag"
41
+ end
42
+
43
+ def ruby_grouped_results(records)
44
+ records = filter_by_tag(records) if tag_filter?
45
+ return [] if records.empty?
46
+
47
+ grouped = if @options["by"] == "tag"
48
+ records.group_by { |r| r.tags[@options["tag"].to_s].to_s }
49
+ else
50
+ records.group_by(&:domain)
51
+ end
52
+
53
+ grouped
54
+ .map { |key, recs| { "group" => key.to_s, "total_cost_cents" => recs.sum(&:total_cost_cents), "call_count" => recs.size } }
55
+ .sort_by { |r| -r["total_cost_cents"] }
56
+ end
57
+
58
+ def filter_by_tag(records)
59
+ key, value = @options["tag"].split("=", 2)
60
+ records.select { |r| r.tags[key.to_s] == value }
61
+ end
62
+
63
+ def sql_grouped_results(scope)
64
+ grouped = scope.group(group_expression)
65
+ costs = grouped.sum(:total_cost_cents)
66
+ counts = grouped.count
67
+
68
+ costs
69
+ .map { |key, total| { "group" => key.to_s, "total_cost_cents" => total, "call_count" => counts[key] || 0 } }
70
+ .sort_by { |r| -r["total_cost_cents"] }
71
+ end
72
+
73
+ def group_expression
74
+ @options["by"] == "day" ? Arel.sql("DATE(created_at)") : :domain
75
+ end
76
+
77
+ def print_table(results)
78
+ return puts("No records found.") if results.empty?
79
+
80
+ width = results.map { |r| r["group"].length }.max + 2
81
+ results.each do |row|
82
+ puts format("%-#{width}s %8s %4d calls", row["group"], format_cost(row["total_cost_cents"]), row["call_count"])
83
+ end
84
+ end
85
+
86
+ def print_json(results)
87
+ puts JSON.generate(results)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../formatter"
5
+
6
+ module TokenHawk
7
+ class CLI
8
+ module Commands
9
+ class Efficiency
10
+ include Formatter
11
+ def initialize(options = {})
12
+ @options = options
13
+ end
14
+
15
+ def run
16
+ results = ranked_results
17
+ @options["format"] == "json" ? print_json(results) : print_table(results)
18
+ end
19
+
20
+ private
21
+
22
+ def ranked_results
23
+ costs = CallRecord.group(:domain).sum(:total_cost_cents)
24
+ counts = CallRecord.group(:domain).count
25
+
26
+ costs
27
+ .map do |domain, total|
28
+ count = counts[domain] || 1
29
+ {
30
+ "domain" => domain.to_s,
31
+ "cost_per_call_cents" => (total.to_f / count).round(2),
32
+ "call_count" => count
33
+ }
34
+ end
35
+ .sort_by { |r| -r["cost_per_call_cents"] }
36
+ end
37
+
38
+ def print_table(results)
39
+ return puts("No records found.") if results.empty?
40
+
41
+ width = results.map { |r| r["domain"].length }.max + 2
42
+ results.each do |row|
43
+ puts format("%-#{width}s %8s/call %4d calls",
44
+ row["domain"], format_cost(row["cost_per_call_cents"].ceil), row["call_count"])
45
+ end
46
+ end
47
+
48
+ def print_json(results)
49
+ puts JSON.generate(results)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../formatter"
5
+
6
+ module TokenHawk
7
+ class CLI
8
+ module Commands
9
+ class Recent
10
+ include Formatter
11
+ def initialize(options = {})
12
+ @options = options
13
+ end
14
+
15
+ def run
16
+ records = CallRecord.order(created_at: :desc).limit(limit)
17
+ @options["format"] == "json" ? print_json(records) : print_table(records)
18
+ end
19
+
20
+ private
21
+
22
+ def limit
23
+ @options.fetch("limit", 20).to_i
24
+ end
25
+
26
+ def print_table(records)
27
+ return puts("No records found.") if records.empty?
28
+
29
+ records.each do |r|
30
+ puts format("%s %-24s %-30s %s",
31
+ r.created_at.strftime("%Y-%m-%d %H:%M:%S"),
32
+ r.domain,
33
+ r.model,
34
+ format_cost(r.total_cost_cents))
35
+ end
36
+ end
37
+
38
+ def print_json(records)
39
+ rows = records.map do |r|
40
+ {
41
+ "domain" => r.domain,
42
+ "model" => r.model,
43
+ "total_cost_cents" => r.total_cost_cents,
44
+ "created_at" => r.created_at.iso8601
45
+ }
46
+ end
47
+ puts JSON.generate(rows)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ class CLI
5
+ module Formatter
6
+ def format_cost(cents)
7
+ return "$#{"%.2f" % (cents / 100.0)}" if cents >= 100
8
+
9
+ "#{cents}¢"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module TokenHawk
6
+ class CLI < Thor
7
+ desc "costs", "Show LLM costs grouped by domain, day, or tag"
8
+ method_option :by, type: :string, default: "domain", desc: "Group by: domain, day, or tag"
9
+ method_option :domain, type: :string, desc: "Filter to a specific domain"
10
+ method_option :since, type: :string, desc: "Filter to records since a time (e.g. '30 days ago')"
11
+ method_option :tag, type: :string, desc: "Filter by tag key=value"
12
+ method_option :format, type: :string, default: "text", desc: "Output format: text or json"
13
+ def costs
14
+ Commands::Costs.new(options).run
15
+ end
16
+
17
+ desc "efficiency", "Show cost per call by domain, descending"
18
+ method_option :format, type: :string, default: "text", desc: "Output format: text or json"
19
+ def efficiency
20
+ Commands::Efficiency.new(options).run
21
+ end
22
+
23
+ desc "recent", "Show recent LLM calls"
24
+ method_option :limit, type: :numeric, default: 20, desc: "Number of records to show"
25
+ method_option :format, type: :string, default: "text", desc: "Output format: text or json"
26
+ def recent
27
+ Commands::Recent.new(options).run
28
+ end
29
+ end
30
+ end
31
+
32
+ require_relative "cli/commands/costs"
33
+ require_relative "cli/commands/efficiency"
34
+ require_relative "cli/commands/recent"
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ # Configuration options for TokenHawk
5
+ #
6
+ # @attr storage [Symbol] Storage backend (:active_record, :memory)
7
+ # @attr log_failures [Boolean] Whether to log failures
8
+ # @attr failure_logger [Proc] Callable that receives error messages
9
+ class Configuration
10
+ attr_accessor :storage, :log_failures, :failure_logger, :pricing
11
+
12
+ def initialize
13
+ @storage = :active_record
14
+ @log_failures = true
15
+ @failure_logger = ->(msg) { warn msg }
16
+ @pricing = {}
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ module Cost
5
+ def self.calculate(model:, input_tokens:, output_tokens:)
6
+ rates = Pricing.for_model(model)
7
+
8
+ unless rates
9
+ warn "[TokenHawk] Looks like you may need to add an entry for #{model.inspect} — " \
10
+ "cost recorded as 0 and vendor set to \"unknown\". " \
11
+ "In your initializer: config.pricing[#{model.inspect}] = { vendor: \"anthropic\", input: 0.0, output: 0.0 }"
12
+ return 0
13
+ end
14
+
15
+ ((input_tokens * rates[:input] + output_tokens * rates[:output]) / 1000.0).round
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ class DashboardQuery
5
+ def initialize(since:)
6
+ @since = since
7
+ end
8
+
9
+ def monthly_total
10
+ scope.sum(:total_cost_cents)
11
+ end
12
+
13
+ def top_domains
14
+ scope.group(:domain)
15
+ .sum(:total_cost_cents)
16
+ .sort_by { |_, cost| -cost }
17
+ .first(3)
18
+ end
19
+
20
+ def daily_costs
21
+ scope.group(Arel.sql("DATE(created_at)"))
22
+ .sum(:total_cost_cents)
23
+ .sort_by { |date, _| date }
24
+ end
25
+
26
+ private
27
+
28
+ def scope
29
+ CallRecord.since(@since)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ class DomainQuery
5
+ def initialize(domain:)
6
+ @domain = domain.to_s
7
+ @scope = CallRecord.for_domain(@domain)
8
+ raise ActiveRecord::RecordNotFound, "No records for domain: #{@domain}" if @scope.empty?
9
+ end
10
+
11
+ def total_cost
12
+ @scope.sum(:total_cost_cents)
13
+ end
14
+
15
+ def call_count
16
+ @scope.count
17
+ end
18
+
19
+ def cost_per_call
20
+ (total_cost.to_f / call_count).round(2)
21
+ end
22
+
23
+ def top_tags
24
+ @scope.to_a
25
+ .flat_map { |r| r.tags.to_a }
26
+ .tally
27
+ .sort_by { |_, count| -count }
28
+ .map(&:first)
29
+ end
30
+
31
+ def recent_calls
32
+ @scope.order(created_at: :desc).limit(10)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace TokenHawk
6
+ end
7
+ end