Sentry knows an issue has 340 events and 12 users. Stripe knows what 200 customers pay. Neither knows the other exists, so the question a founder asks after every spike, "how much money is behind this?", is answered by hand, or not at all. This guide joins the two: by the end, for any Sentry issue, you can list the paying customers it hit and add up their MRR.
Four steps: tag events with the user, read the users an issue hit, find them in Stripe, sum what they pay. Then the caveats, which are most of the work.
1. Put the user on every Sentry event
Sentry only knows who an error hit if the SDK says so. Set the user at the start of each request, once the session is resolved, with the one thing Stripe also has: the email address.
# Rails: app/controllers/application_controller.rb
before_action { Sentry.set_user(id: current_user&.id, email: current_user&.email) if Sentry.initialized? }
// Browser or Node
Sentry.setUser({ id: user.id, email: user.email });
# Python
sentry_sdk.set_user({"id": user.id, "email": user.email})
Two things to decide here. First, whether an email address belongs in Sentry at all: it is personal data, and Sentry becomes a processor of it. If you would rather not, set only id and keep the id-to-email map on your side; every step below works the same with one more lookup. Second, background jobs: an error in a worker has no current user unless you set one from the job's arguments, so a billing job that fails for a customer is invisible to this join until you do.
Deploy, and from now on every event carries the user. Sentry counts distinct users per issue from this.
2. Read who an issue hit
Sentry's API lists the most common values of a tag on an issue, and user is a tag. With a user auth token that has event:read:
curl -s -H "Authorization: Bearer $SENTRY_TOKEN" \
"https://sentry.io/api/0/issues/$ISSUE_ID/tags/user/"
{
"key": "user",
"totalValues": 12,
"topValues": [
{ "value": "email:ada@cortex.ai", "name": "ada@cortex.ai", "count": 41 },
{ "value": "id:8812", "name": "8812", "count": 9 }
]
}
The value is email:… when the SDK set an email, id:… when it set only an id. topValues is the top of the list, not all of it: for an issue that hit hundreds of users, the long tail is missing. When you need every user, page through the issue's events instead (GET /api/0/issues/{id}/events/) and read user on each; it is slower and rate-limited, so do it for the issues that matter, not for all of them.
3. Find them in Stripe
The naive join is by exact email:
Stripe::Customer.search(query: "email:'ada@cortex.ai'").data.first
It works when the person who hit the error is the person who pays. In a B2B product they usually are not: the engineer who saw the 500 is not the CFO whose card is on file. So join by company instead. Take the domain of the email, drop the public mailboxes, and match it against the domain of each Stripe customer's email:
PUBLIC = %w[gmail.com googlemail.com yahoo.com hotmail.com outlook.com icloud.com proton.me].freeze
def company_domain(email)
domain = email.to_s.split("@", 2).last&.downcase
domain unless domain.nil? || PUBLIC.include?(domain)
end
# Once, and kept: every Stripe customer by company domain.
customers_by_domain = Stripe::Customer.list(limit: 100).auto_paging_each.group_by { |c| company_domain(c.email) }
A customer whose domain is public (a sole trader on Gmail) falls back to the exact-address match. A customer with several domains needs a domain in their Stripe metadata, and then that is the key, not the email.
4. Sum what they pay
MRR is not a Stripe field; it is the active subscriptions, normalised to a month:
def mrr_cents(customer)
Stripe::Subscription.list(customer: customer.id, status: "active").data.sum do |sub|
sub.items.data.sum do |item|
price = item.price
monthly = case price.recurring.interval
when "year" then price.unit_amount / 12.0
when "week" then price.unit_amount * 52 / 12.0
else price.unit_amount * (price.recurring.interval_count || 1)
end
monthly * item.quantity
end
end.round
end
Trialing subscriptions are not revenue yet, so status: "active" leaves them out; decide whether past_due counts (it is money you are owed, not money you have). Discounts, metered prices and tiered prices each need a line of their own; the code above is the 80% case.
Then, for one issue:
hits = sentry_get("issues/#{issue_id}/tags/user/")["topValues"]
emails = hits.map { |v| v["value"].delete_prefix("email:") }.select { |v| v.include?("@") }
accounts = emails.filter_map { |e| customers_by_domain[company_domain(e)] }.flatten.uniq(&:id)
total = accounts.sum { |c| mrr_cents(c) }
puts "#{issue_id}: #{accounts.size} paying customers, €#{total / 100} a month"
That line is the answer. The failed payments in the same window are one more query: Stripe::Event.list(type: "invoice.payment_failed", created: { gte: issue_first_seen }), filtered to those customers.
The caveats
- Users are not customers. One company sends many users; count companies, and count each once.
- Top values are not all values. For the big issues, page the events.
- Errors without a user. Background jobs, webhooks and the login page itself have none. Set the user from the job's arguments where you can; accept the gap where you cannot.
- The join drifts. Customers change domains, people move companies, someone signs up with a personal address. Keep a small table of overrides.
- It is a snapshot. Run once, the number is right today. To catch the next spike you have to run it on every new issue, and compare the rate with the week before to know whether it is a spike at all.
What Vesqo does with this
Vesqo runs this join for you, continuously. It reads the Sentry project's hourly error count and every new issue, reads the issue's top user values, and joins each email to an account by company domain, with the public mailboxes excluded and the exact address as the fallback. Accounts come from Stripe, with their subscriptions normalised to MRR. When the error rate after a release at least doubles against the same hours the week before, it writes a signal that names the paying customers in it, the MRR at stake, and the payments that failed in the window, and puts it in the morning brief ranked by that money. The Sentry integration and the Stripe integration pages say exactly what it reads; the demo has an afternoon with all of it in.