RUBY ON RAILS · 18 MIN READ ·

Rails LLM Kostentracking: Spend per Tenant, Budgetlimieten en Realtime Quota-handhaving

Rails LLM kostentracking die een verrassing van 40k overleeft. Tokenboekhouding per tenant, budgetlimieten, quota-handhaving en finance-rapporten voor je AI-features.

Rails LLM Kostentracking: Spend per Tenant, Budgetlimieten en Realtime Quota-handhaving

Een founder stuurde me op een zondagochtend een bericht: zijn OpenAI-factuur van de afgelopen week was eenenveertigduizend dollar. Het product was een B2B-SaaS met een chat-met-je-documenten feature, driehonderd betalende tenants en een royale gratis proefperiode. Eén trial-account had een script opgezet dat vier dagen lang in een loop het chat-endpoint aanriep. Niemand merkte het, want de factuur kwam wekelijks en niets in de Rails-applicatie wist op request-niveau wat een individuele tenant had uitgegeven. Rails LLM kostentracking is een van die problemen die niemand bouwt totdat hij er één keer door wordt gebrand, en die brand is altijd duurder dan de week werk die het zou hebben gekost om het netjes te bouwen.

Na negentien jaar Rails — en de laatste drie daarvan met productie-AI-features voor klanten — behandel ik per-tenant LLM-boekhouding tegenwoordig zoals ik database-connection-pooling behandel: saaie infrastructuur die je bouwt vóór je het nodig hebt, omdat het achteraf inbouwen onder druk gewoon ellendig is. Deze post is de kostentrackingstack die ik standaard inzet bij elke Rails-app die in productie een LLM aanroept: het datamodel, de client-wrapper, de realtime quota-handhaver en de rapporten waar finance mee uit de voeten kan.

Waarom Rails LLM Kostentracking In Je App Hoort, Niet In Het Provider-dashboard

Het usage-dashboard van OpenAI vertelt je wat je organisatie heeft uitgegeven. Dat van Anthropic doet hetzelfde. Geen van beide vertelt je welke van jouw klanten die kosten heeft veroorzaakt, welke van jouw features het budget heeft opgemaakt, of welke tenant binnen twintig minuten meer gaat kosten dan hij je betaalt. Provider-dashboards zijn boekhouding; Rails LLM kostentracking is operations.

Er gaan drie dingen stuk als je alleen op het provider-dashboard vertrouwt. Het eerste is per-tenant economie — je kunt geen AI-feature prijzen als je de brutomarge per klant niet kent. Het tweede is misbruikdetectie — tegen de tijd dat een runaway loop opduikt in de billing-CSV van morgen, heb je er al voor betaald. Het derde is productcontrole — je kunt geen “je hebt 80% van je maandelijkse tokens gebruikt”-banner in de UI tonen zonder een interne teller. Geen van die problemen lost zichzelf op door op een factuur te wachten.

De oplossing is om elke LLM-call binnen je Rails-app te registreren, te koppelen aan een tenant en een feature, op request-tijd tokens om te zetten naar dollars, en dat bedrag te toetsen aan een per-tenant budget vóór je de volgende call doet. Het hele patroon is misschien vierhonderd regels Ruby en bespaart je het soort weekend dat ik in de opening beschreef.

Het Datamodel Voor Per-Tenant LLM-boekhouding

Begin met één tabel die per LLM-call één rij wegschrijft. Probeer niet slim te zijn met aggregaties voordat je de ruwe data hebt — Postgres aggregeert zo snel over miljoenen rijen dat je samenvattingen later altijd nog kunt backfillen.

# db/migrate/20260627000001_create_llm_usage_events.rb
class CreateLlmUsageEvents < ActiveRecord::Migration[8.0]
  def change
    create_table :llm_usage_events do |t|
      t.references :account, null: false, foreign_key: true
      t.references :user, foreign_key: true
      t.string  :feature,           null: false # "chat", "summarize", "embed"
      t.string  :provider,          null: false # "openai", "anthropic", "voyage"
      t.string  :model,             null: false # "gpt-4o", "claude-opus-4-7"
      t.integer :prompt_tokens,     null: false, default: 0
      t.integer :completion_tokens, null: false, default: 0
      t.integer :cached_tokens,     null: false, default: 0
      t.decimal :cost_usd, precision: 12, scale: 6, null: false, default: 0
      t.integer :latency_ms
      t.string  :request_id
      t.string  :error_code
      t.jsonb   :metadata, null: false, default: {}
      t.datetime :created_at, null: false
    end

    add_index :llm_usage_events, [:account_id, :created_at]
    add_index :llm_usage_events, [:account_id, :feature, :created_at]
    add_index :llm_usage_events, :request_id, unique: true, where: "request_id IS NOT NULL"
  end
end

Een paar keuzes die het aanstippen waard zijn. cost_usd is een decimal omdat floats niet voor geld zijn, en op zes decimalen kun je een fractie van een cent vastleggen zonder afrondingsruis. cached_tokens zit er apart in zodat je de waarde van Anthropic prompt caching richting de CFO kunt bewijzen. request_id draagt de identifier van de provider mee zodat je later je logs aan support-tickets kunt koppelen als een klant klaagt over een specifiek antwoord. De partial unique index op request_id zorgt dat je het wegschrijven veilig kunt retryen zonder dubbele rijen.

Een tweede, veel kleinere tabel houdt het per-tenant budget en het lopende totaal voor de huidige periode bij. Houd deze bewust gedenormaliseerd — bij quota-handhaving wil je één geïndexeerde lookup, niet een SUM over een miljoen rijen.

# db/migrate/20260627000002_create_llm_budgets.rb
class CreateLlmBudgets < ActiveRecord::Migration[8.0]
  def change
    create_table :llm_budgets do |t|
      t.references :account, null: false, foreign_key: true, index: { unique: true }
      t.decimal :monthly_limit_usd, precision: 10, scale: 2, null: false, default: 0
      t.decimal :current_spend_usd, precision: 12, scale: 6, null: false, default: 0
      t.date    :period_start,      null: false
      t.string  :status,            null: false, default: "active" # active, throttled, suspended
      t.timestamps
    end
  end
end

Je LLM-client Inpakken Met Tokenboekhouding

De wrapper is het hart van het systeem. Elke call naar OpenAI, Anthropic of je embedding-provider gaat door één methode, en die methode schrijft op de terugweg een usage-event weg. Als je code in controllers en jobs verspreid heeft staan die rechtstreeks OpenAI::Client.new.chat(...) aanroept, is je eerste refactor om dat door één service te trechteren.

# app/services/llm/client.rb
class Llm::Client
  PRICING = {
    "gpt-4o"           => { input: 0.0000025, output: 0.000010, cached: 0.00000125 },
    "gpt-4o-mini"      => { input: 0.00000015, output: 0.0000006, cached: 0.000000075 },
    "claude-opus-4-7"  => { input: 0.000015, output: 0.000075, cached: 0.0000015 },
    "claude-sonnet-4-6"=> { input: 0.000003, output: 0.000015, cached: 0.0000003 }
  }.freeze

  def initialize(account:, feature:, user: nil)
    @account = account
    @feature = feature
    @user    = user
  end

  def chat(model:, messages:, **options)
    Llm::QuotaGuard.new(@account).check!

    started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    response   = provider_for(model).chat(model: model, messages: messages, **options)
    latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).to_i

    record_usage!(model: model, response: response, latency_ms: latency_ms)
    response
  rescue Llm::ProviderError => e
    record_usage!(model: model, response: nil, latency_ms: nil, error: e)
    raise
  end

  private

  def record_usage!(model:, response:, latency_ms:, error: nil)
    usage = response&.dig("usage") || {}
    prompt    = usage["prompt_tokens"].to_i
    completion = usage["completion_tokens"].to_i
    cached    = usage.dig("prompt_tokens_details", "cached_tokens").to_i

    cost = calculate_cost(model: model, prompt: prompt, completion: completion, cached: cached)

    event = LlmUsageEvent.create!(
      account: @account, user: @user, feature: @feature,
      provider: provider_name_for(model), model: model,
      prompt_tokens: prompt, completion_tokens: completion, cached_tokens: cached,
      cost_usd: cost, latency_ms: latency_ms,
      request_id: response&.dig("id"),
      error_code: error&.code,
      metadata: { temperature: response&.dig("temperature") }.compact
    )

    Llm::BudgetUpdater.call(account: @account, delta_usd: cost)
    event
  end

  def calculate_cost(model:, prompt:, completion:, cached:)
    rates = PRICING.fetch(model) { raise "Unknown model: #{model}" }
    billed_input = prompt - cached
    (billed_input * rates[:input]) + (completion * rates[:output]) + (cached * rates[:cached])
  end
end

De allerbelangrijkste regel hierin is Llm::QuotaGuard.new(@account).check!. Quota-handhaving gebeurt vóór de API-call, niet erna. Als een tenant zijn budget heeft opgemaakt, wil je het request niet versturen, de kosten slikken en het pas op de terugweg opmerken. De provider geeft geen restitutie voor “ik ben van gedachten veranderd.”

Het tweede belangrijke detail is dat record_usage! ook draait wanneer de provider-call faalt. Mislukte calls kosten je bij sommige providers nog steeds tokens tot het punt van falen, en zelfs als dat niet zo is wil je het foutpercentage per tenant in je dashboard zien. Wikkel het geheel in een ensure als je gordel-en-bretels wilt, maar in de praktijk is de expliciete rescue leesbaarder.

Realtime Quota-handhaving Zonder Je Doorvoer Te Slopen

Een naïeve quota-check doet een SUM over llm_usage_events voor de huidige periode bij elk request. Dat werkt bij één tenant en tien requests per minuut. Het stort in bij vijfhonderd tenants en tienduizend requests per minuut. De oplossing is een teller in llm_budgets.current_spend_usd bijhouden en die atomair toetsen.

# app/services/llm/quota_guard.rb
class Llm::QuotaGuard
  class QuotaExceeded < StandardError; end

  def initialize(account)
    @account = account
    @budget  = account.llm_budget || account.create_llm_budget!(
      monthly_limit_usd: account.plan.monthly_llm_limit_usd,
      period_start: Date.current.beginning_of_month
    )
  end

  def check!
    rollover_if_new_period!
    return if @budget.monthly_limit_usd.zero? # 0 = onbeperkt (voorzichtig gebruiken)
    raise QuotaExceeded, "Account #{@account.id} suspended" if @budget.status == "suspended"

    if @budget.current_spend_usd >= @budget.monthly_limit_usd
      @budget.update!(status: "throttled")
      raise QuotaExceeded,
            "Account #{@account.id} over monthly limit (#{@budget.current_spend_usd} / #{@budget.monthly_limit_usd} USD)"
    end
  end

  private

  def rollover_if_new_period!
    return if @budget.period_start == Date.current.beginning_of_month

    @budget.with_lock do
      next if @budget.period_start == Date.current.beginning_of_month
      @budget.update!(
        period_start: Date.current.beginning_of_month,
        current_spend_usd: 0,
        status: "active"
      )
    end
  end
end

De bijbehorende updater gebruikt een atomaire UPDATE zodat twee gelijktijdige requests niet allebei kunnen denken dat zij degene zijn die de tenant over de limiet heeft geduwd.

# app/services/llm/budget_updater.rb
class Llm::BudgetUpdater
  def self.call(account:, delta_usd:)
    LlmBudget.where(account_id: account.id).update_all([
      "current_spend_usd = current_spend_usd + ?, updated_at = NOW()", delta_usd
    ])
  end
end

Die update_all stuurt één atomaire increment naar Postgres. Geen race, geen read-modify-write, geen with_lock. Voor de meeste productieworkloads is dit genoeg. Als je boven de paar honderd LLM-calls per seconde per tenant uitkomt, wil je de teller naar Redis verplaatsen met een periodieke flush naar Postgres — maar dat is een probleem dat je voorlopig nog niet hebt, en voortijdig oplossen maakt debuggen lastiger. Hetzelfde patroon hebben we besproken in Rails rate limiting met Rack::Attack, waar de afweging tussen nauwkeurigheid en latency in identieke vorm terugkomt.

Kosten Inzichtelijk Maken Voor De Juiste Mensen

Interne dashboards zijn het makkelijke deel. De lastigere keuze is wat je aan wie laat zien. Na veel klantopdrachten kom ik steeds op hetzelfde patroon uit: drie doelgroepen met drie verschillende views.

Developers zien per-feature spend in hun staging-omgeving, uitgesplitst naar model en naar tenant, met een dagelijkse Slack-samenvatting van de top vijf callers. De bedoeling is om het nieuwe RAG-endpoint dat tien keer meer tokens gebruikt dan verwacht te vangen voordat het naar productie gaat.

Tenants zien hun eigen gebruik in de app — een meter, een prognose, en een waarschuwing “je zit op 78% van je maandelijkse limiet”. De exacte formulering doet ertoe. Een meter die op 95% rood wordt verandert gedrag. Een rekening aan het einde van de maand niet. We behandelen deze UI als een product-feature, niet als een admin-toevoeging achteraf.

Finance krijgt een maandelijkse CSV met account_id, monthly_spend_usd, prompt_tokens, completion_tokens en cached_tokens. Dezelfde query voedt het brutomarge-spreadsheet en het customer-success-rapport “welke accounts lopen risico”. Eén query, drie gebruiken stroomafwaarts.

# app/queries/llm/monthly_spend_query.rb
class Llm::MonthlySpendQuery
  def self.call(period: Date.current.beginning_of_month..Date.current.end_of_month)
    LlmUsageEvent
      .where(created_at: period)
      .group(:account_id)
      .select(
        "account_id",
        "SUM(prompt_tokens)     AS prompt_tokens",
        "SUM(completion_tokens) AS completion_tokens",
        "SUM(cached_tokens)     AS cached_tokens",
        "SUM(cost_usd)          AS spend_usd"
      )
      .order("spend_usd DESC")
  end
end

Een Postgres-index op (account_id, created_at) houdt deze query tot ver in de tientallen miljoenen rijen onder honderd milliseconden. Zodra je daaroverheen groeit wordt dezelfde query een nightly materialized view — over dat patroon schreven we in pg_stat_statements voor het vinden van trage queries in productie.

Wat Te Doen Als Je Iemand Op Misbruik Betrapt

De eerste keer dat de quota-guard afgaat op een betalende klant, ben je geneigd zijn limiet op te schroeven. Doe dat niet, in elk geval niet voor je begrijpt wat er gebeurd is. De helft van de tijd is het echte groei en wil je upsellen. De andere helft is het een buggy integratie aan hun kant die je endpoint in een loop hamert, en de limiet ophogen maakt de bug alleen maar duurder.

Het patroon dat werkt is een driestappenplan. Schakel de LLM-features programmatisch uit voor het account — de guard doet dit al zodra status op throttled springt. Stuur één duidelijke, in gewoon Nederlands geschreven mail naar de account-eigenaar: “Je account heeft de maandelijkse AI-gebruikslimiet bereikt. Dit hebben we gezien, dit heeft het gekost, laat het weten als dit verwacht is.” Open intern een Slack-kanaal met de recente events, top-features en top-callende users van het account zodat support, sales en engineering samen kunnen beslissen.

Is het misbruik: opschorten, niets restitueren, documenteren. Is het groei: limiet ophogen, nieuwe limiet vastleggen en een upsell-gesprek inplannen. Het systeem is er niet om klanten te straffen; het is er om je de informatie te geven om snel de juiste beslissing te kunnen nemen.

FAQ

Hoe nauwkeurig is Rails LLM kostentracking ten opzichte van de factuur van de provider?

In de praktijk binnen een paar procent. De twee bronnen van afwijking zijn belastingen en kortingen die de provider bij facturatie toepast, en het zeldzame geval waarbij een request wel in rekening werd gebracht maar het antwoord nooit je code heeft bereikt. Ik reconcilieer maandelijks en heb nog nooit een verschil onder de 2% gezien dat de moeite waard was om uit te zoeken.

Moet ik de volledige prompt en completion-tekst opslaan?

Standaard niet. Het laat je database ontploffen, compliceert AVG, en is zelden de moeite waard. Sla het request_id op en een hash van de prompt. Heb je replay nodig voor evaluaties, spiegel prompts dan naar een aparte, access-controlled bucket — daarover schreven we in Rails LLM evals voor het testen van prompts in CI.

Hoe zit het met streaming responses waar tokenaantallen pas aan het einde komen?

De meeste providers sturen een laatste usage-chunk mee in de SSE-stream. De wrapper vangt die op bij het sluiten van de stream en schrijft daar het event weg. Wordt de stream halverwege afgekapt, schrijf dan wat je hebt weg met een error_code zodat je patronen van disconnects kunt herkennen.

Hoe track ik LLM-kosten als de call binnen een background job gebeurt?

Dezelfde wrapper. Geef account en feature door aan de job en instantieer Llm::Client.new(account: account, feature: "summarize") binnen perform. Probeer niet vanuit de worker buiten de wrapper om te registreren — je eindigt met twee codepaden voor dezelfde boekhoudlogica, en die lopen binnen drie sprints uiteen.

Hulp nodig bij het uitrollen van productiewaardige Rails LLM kostentracking — per-tenant boekhouding, realtime quota, finance-rapporten? TTB Software is gespecialiseerd in Rails AI-infrastructuur voor SaaS-teams die willen dat de AI-feature winstgevend is, niet alleen indrukwekkend. We doen dit al negentien jaar.

#rails-llm-cost-tracking #rails-ai-billing #openai-token-tracking #anthropic-usage-monitoring #rails-ai-quota #llm-budget-cap #rails-saas-ai-cost

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software