An error tracker orders issues by what it can count: events, users, first seen. A small team fixing three bugs this week wants them ordered by what each costs, and the tracker cannot know that. The result is a queue where a crash on a free-plan screen with a thousand events sits above a silent failure that stops your biggest customer exporting invoices.
This guide is a score that orders by cost, how to compute it from Sentry and Stripe, and how to get it into the tool where the team actually picks work.
Why not event count
Events measure how often code ran into the bug, not who it hurt. A retrying background job produces a thousand events and no customer notices. One failed export produces one event and a cancellation. Users is better than events, but a hundred free-plan users and one enterprise admin count the same. Neither number is wrong; they are answers to a different question.
The score
For each open issue, four things, all from the last seven days:
| What | Where from | |
|---|---|---|
| M | MRR of the paying customers it hit | Sentry users → Stripe, joined by company |
| B | Payments that failed in the issue's window for those customers | Stripe invoice.payment_failed |
| S | Support conversations that mention it | Help desk, tagged or searched |
| V | Velocity: events this week against last | Sentry |
Then:
score = M × velocity_factor + B × 4 + S × 50
velocity_factor = 1.0 (steady), 1.5 (up 2×), 2.0 (up 5× or new this week)
The weights are opinions, and yours will differ; the shape is what matters. Money exposed is the base. A blocked payment is worse than an exposure, because it is money that already tried to move, so it counts several times over. A support conversation is a customer who took the time to complain, worth a fixed amount regardless of what they pay, so the free-plan bug that fills the inbox still rises. Velocity says whether it is getting worse.
An issue with no paying customers, no failed payments and no conversations scores zero, however many events it has. That is on purpose: it goes in a second list, ordered by users, for the time left over.
Computing it
A weekly script, thirty lines, run Monday morning:
issues = sentry_get("projects/#{ORG}/#{PROJECT}/issues/", query: "is:unresolved", statsPeriod: "14d")
rows = issues.map do |issue|
emails = sentry_get("issues/#{issue['id']}/tags/user/")["topValues"]
.map { |v| v["value"].delete_prefix("email:") }.select { |v| v.include?("@") }
accounts = emails.filter_map { |e| account_for(e) }.uniq # your account table, joined by company domain
paying = accounts.select { |a| a.mrr_cents.positive? }
m = paying.sum(&:mrr_cents)
b = failed_payments_since(issue["firstSeen"], paying).sum(&:amount_cents)
s = conversations_mentioning(issue["title"], since: 7.days.ago).size
this_week, last_week = issue["stats"]["14d"].last(7).sum(&:last), issue["stats"]["14d"].first(7).sum(&:last)
v = last_week.zero? ? 2.0 : [ this_week.to_f / last_week, 1 ].max.clamp(1, 2)
{ id: issue["id"], title: issue["title"], paying: paying.size, mrr: m, blocked: b, support: s, score: m * v + b * 4 + s * 5000 }
end
rows.sort_by { |r| -r[:score] }.first(10).each do |r|
puts format("%-40s %2d paying €%5d MRR €%5d blocked %2d support → %d", r[:title][0, 40], r[:paying], r[:mrr] / 100, r[:blocked] / 100, r[:support], r[:score] / 100)
end
(account_for, failed_payments_since and conversations_mentioning are yours; the account join is the first, the other two are one API call each.)
The output is the list the week starts from:
NoMethodError in CheckoutController#create 3 paying € 897 MRR € 798 blocked 6 support → 5296
Timeout in ExportsController#index 1 paying € 499 MRR € 0 blocked 2 support → 1499
TypeError in JobsController#show 0 paying € 0 MRR € 0 blocked 0 support → 0
The third line has 1,200 events. It waits.
Getting it into the tracker
A score nobody sees changes nothing. Three ways to put it where work is picked, in order of effort:
- A label. The script sets a Sentry tag or a Linear/Jira label (
revenue:high,revenue:none) and the board is filtered by it. - The priority field. Top three by score become P1 in the tracker, next five P2, the rest untouched. Linear's and Jira's APIs both take this in one call.
- The ticket itself. For each of the top issues that has no ticket, open one with the numbers in the description: which customers, what they pay, what failed, what support said. The engineer picking it up should not have to rebuild the case.
Whichever you pick, put the numbers in the ticket. "Fix checkout" and "Fix checkout: Cortex (€499), Acme Labs (€299), two payments blocked, six conversations" get very different attention.
Pitfalls
- The biggest customer wins everything. If one account is a third of MRR, every bug that touches them tops the list. Cap M per account (at, say, the 90th percentile of MRR) so a small bug for a big customer does not outrank a big bug for ten medium ones.
- The funnel bugs. A bug on the signup or checkout page hits nobody who pays yet, and scores zero. Give conversion-path issues a floor: count each blocked signup as a fraction of an average new customer's MRR.
- Stale users. Sentry's top user values for an old issue include people from months ago. Read the user tags with a
statsPeriod, or page only recent events. - Support matching by title. Searching conversations for the exception message finds nothing; search for what the customer sees ("checkout", "export", "spinning"). Tag conversations by feature as you resolve them, and match on the tag.
What Vesqo does with this
Vesqo scores this way without a script. Every issue it reads from Sentry is set beside the accounts it hit and what they pay; a regression signal says which customers are in it, the MRR at stake, the payments that failed in the window and what support heard; the morning brief orders the day's signals by that money, across every product in the workspace; and an automation opens the issue in Linear, Jira or GitHub with the write-up already in it. The incident prioritization use case shows the brief with two bugs in the right order.