From e9789ca9c41f69af9cae09a833fbc07a25dc4411 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 30 Jun 2026 16:10:45 +0530 Subject: [PATCH] feat: add captain usage model --- enterprise/app/models/captain/assistant.rb | 1 + enterprise/app/models/captain/usage.rb | 71 +++++++++++++++++++ .../app/models/enterprise/concerns/account.rb | 1 + 3 files changed, 73 insertions(+) create mode 100644 enterprise/app/models/captain/usage.rb diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 0735987de..4ba21a473 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -35,6 +35,7 @@ class Captain::Assistant < ApplicationRecord has_many :messages, as: :sender, dependent: :nullify has_many :copilot_threads, dependent: :destroy_async has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async + has_many :usages, class_name: 'Captain::Usage', dependent: :destroy_async store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name diff --git a/enterprise/app/models/captain/usage.rb b/enterprise/app/models/captain/usage.rb new file mode 100644 index 000000000..c5fa947b5 --- /dev/null +++ b/enterprise/app/models/captain/usage.rb @@ -0,0 +1,71 @@ +# == Schema Information +# +# Table name: captain_usages +# +# id :bigint not null, primary key +# bucket_started_at :datetime not null +# credits_used :integer default(0), not null +# usage_type :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# assistant_id :bigint not null +# +# Indexes +# +# index_captain_usages_on_account_id (account_id) +# index_captain_usages_on_assistant_id (assistant_id) +# index_captain_usages_unique_bucket (account_id,assistant_id,usage_type,bucket_started_at) UNIQUE +# +class Captain::Usage < ApplicationRecord + self.table_name = 'captain_usages' + + # Width of each reporting bucket. Usage is binned into 15-minute UTC buckets so + # it can be re-sliced into any caller timezone, including :45 offset zones + # (e.g. Nepal +5:45). Do not widen this without revisiting timezone slicing. + BUCKET_SIZE = 15.minutes + + # Only :assistant_response and :copilot_response are logged today. The + # remaining values are reserved for future usage sources: + # editor_actions, label_suggestion, audio_transcription + enum usage_type: { + assistant_response: 0, + copilot_response: 1 + } + + belongs_to :assistant, class_name: 'Captain::Assistant' + belongs_to :account + + # log is the only sanctioned write path; it uses upsert, which skips model + # validations. Column invariants are enforced via NOT NULL at the DB level. + # + # Records credit usage against the 15-minute bucket that contains +occurred_at+. + # Concurrent calls are safe: the row is created or its counter incremented in a + # single atomic statement keyed on the unique (account, assistant, type, bucket). + def self.log(account:, assistant:, usage_type:, credits: 1, occurred_at: Time.current) + now = Time.current + # rubocop:disable Rails/SkipsModelValidations + upsert( + { + account_id: account.id, + assistant_id: assistant.id, + usage_type: usage_types.fetch(usage_type.to_s), + bucket_started_at: bucket_for(occurred_at), + credits_used: credits, + created_at: now, + updated_at: now + }, + unique_by: 'index_captain_usages_unique_bucket', + on_duplicate: Arel.sql( + 'credits_used = captain_usages.credits_used + EXCLUDED.credits_used, updated_at = EXCLUDED.updated_at' + ) + ) + # rubocop:enable Rails/SkipsModelValidations + end + + # Floors +time+ to the start of its 15-minute UTC bucket. + def self.bucket_for(time) + utc = time.utc + utc - (utc.to_i % BUCKET_SIZE.to_i) + end +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 1ef112fb5..6aef3932b 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -13,6 +13,7 @@ module Enterprise::Concerns::Account has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool' + has_many :captain_usages, dependent: :destroy_async, class_name: 'Captain::Usage' has_many :copilot_threads, dependent: :destroy_async has_many :companies, dependent: :destroy_async