Skip to content

CRM: Trial prospects worker#6498

Open
ukutaht wants to merge 7 commits into
masterfrom
score-trial-prospects
Open

CRM: Trial prospects worker#6498
ukutaht wants to merge 7 commits into
masterfrom
score-trial-prospects

Conversation

@ukutaht

@ukutaht ukutaht commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Changes

Context: Marko has wanted the CRM to be able to surface trial prospects better so we can support teams with high potential better during the trial.

This PR adds a postgres table and a background worker to score trial prospects by potential MRR based on actual usage. UI will be added in a follow-up PR. The UI will support ranking trialing teams by MRR, traffic, and signup date to keep an eye on active trials along with their potential if they choose to upgrade.

The background worker scores the whole trial population (= teams with no subscription and up to 30 days after trial expiry) every night. On each run previous scores are overwritten and stale entries removed from the table.

Migration will be extracted once PR is approved.

Tests

  • Automated tests have been added

Changelog

  • This PR does not make a user-facing change

Documentation

  • This change does not need a documentation update

Dark mode

  • This PR does not change the UI

@ukutaht ukutaht requested a review from a team July 9, 2026 13:41
@ukutaht ukutaht changed the title Trial prospects worker CRM: Trial prospects worker Jul 9, 2026
Comment thread lib/plausible/stats/clickhouse.ex
score =
TrialProspects.score(
traffic.estimated_monthly,
Teams.Billing.features_usage(team, site_ids),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one line starts 10 postgres queries, so I'm a bit worried about postgres stress at the time of this worker: the more teams are created, the more stress there'll be.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction, the more teams there are to score, the longer the period of stress will be: the intensity will remain constant due to sequential approach.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO sequential is better because it means the job runs longer but it will never occupy more than 1 database connection. Parallel processing would be faster but also create more db pressure while it's running.

Not too worried about occupying a single db connection at 4am UTC, it would be a problem if the job runs too long.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the job turns out to be too slow I can look into reducing the amount of postgres roundtrips in Billing.feature_usage

Comment on lines +18 to +26
def perform(_job) do
rows =
Date.utc_today()
|> trial_population()
|> Enum.flat_map(&score_team/1)

persist(rows)
:ok
end

@apata apata Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If any of the numerous postgres queries run as part of the job fails, the effort spent on the whole batch is wasted. Could we create separate jobs to score and insert / replace each team?

@ukutaht ukutaht Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point overall but feel like separating the jobs is a bit overkill. This functionality is not critical at all and failures have a low cost.

What do you think about limiting the blast radius so that in case something goes wrong during scoring we skip that team, log it to Sentry, and continue with the batch?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a reasonable alternative. Maybe a little retry with delay for each as well?

@apata apata left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discussed implementing this on a separate BI platform, but just to try something out first, I think it's worth it.

@zoldar zoldar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some optional suggestions

site_k = site_count_kind(site_count)
member_k = member_count_kind(member_count)

kind = feature_k |> higher_kind(site_k) |> higher_kind(member_k)

@zoldar zoldar Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
kind = feature_k |> higher_kind(site_k) |> higher_kind(member_k)
kind = Enum.max_by([feature_k, site_k, member_k], &@kind_rank[&1])

Comment thread extra/lib/plausible/customer_support/trial_prospect.ex Outdated
Comment thread extra/lib/plausible/workers/score_trial_prospects.ex Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants