Draft: Target in-app messages by plan tier

What does this MR do and why?

Lets an admin send a GitLab.com targeted message to every top-level group on chosen plan tiers, with content they write. Today targeted messages only reach namespaces from an uploaded CSV, capped at 50,000 IDs, and only show hardcoded Premium promo copy on the group Billing page. Notices that apply to one tier, like the 2026 rate limit changes, need both. The problem and open questions are in #634504.

This builds on !263236 (closed). The first commit here is that MR; review the second commit.

  • Rollout. Plan-wide messages and the new layout banner sit behind a new gitlab_com_derisk flag, targeted_messages_plan_audience, with the root group as the actor so it can roll out by percentage. targeted_messages_admin_ui is already on in production and only takes the :instance actor, so it can't do that.
  • Audience. A new audience column stores the choice explicitly. A plan_wide message reaches every top-level group on its tiers. A namespace_list message can use plans to narrow its list. A CSV that fails to parse, or a list that empties, can never turn a list message into a tier-wide one.
  • Tiers. These come from Plan::TIERS and the cached namespace.plan_tier, so trials and open source count as the tier they unlock.
  • Query. Notifications::TargetedMessagesFinder returns one relation that filters by schedule, tier and role in SQL. It only returns messages to members of the namespace.
  • Rendering. Group and project layouts render live messages on the server, so those pages make no extra request. While no message is live, a cached TargetedMessage.any_active? check skips the mount entirely.
  • Content. A plan-wide message needs a title and a button, and blank fields are stored as nil. Untitled listed messages keep the legacy promo copy, and only on the Billing page.
  • Admin. The form adds the audience choice, the new fields, the UTC time under each date field and a live preview, and maps every server error to its field. The list shows each message's audience.

References

Screenshots or screen recordings

52-second walkthrough of an earlier iteration, recorded locally against test data. The admin form now also has an audience choice.

Admin form: Premium and Ultimate owners Message list with audiences
02a-admin-form-paid-owners-top 02-admin-index
Free group owner, project page Premium developer, group page
04-free-owner-project 06-premium-developer-group

How to set up and validate locally

  1. Run GitLab with GITLAB_SIMULATE_SAAS=1 and enable both flags:

    Feature.enable(:targeted_messages_admin_ui)
    Feature.enable(:targeted_messages_plan_audience)
  2. Create three top-level groups on the Free, Premium and Ultimate plans, each with a project, and add members with different roles.

  3. In Admin > Targeted messages, choose Everyone on the selected plans, select Free, add a title and button, and start it now.

  4. Visit the Free group and its project as a member: the banner shows. Visit the Premium group: it doesn't. Visit the public Free group as a non-member: it doesn't.

  5. Create a second plan-wide message for Premium and Ultimate with Roles set to Owner. An Ultimate owner sees it and a Premium developer doesn't.

  6. Dismiss the Free message on the group page, then open the project: it stays hidden.

Tests

Run locally in Caproni with GitLab.com simulated:

  • Browser: 10 of 10 Playwright checks pass across five users on Free, Premium and Ultimate groups, including a user in both a Free and an Ultimate group, and a dismissal followed by a reload. During that run the layouts made no banner GraphQL request.
  • RSpec: every targeted message spec passes, including the new finder, helper and view specs, except the 3 environment-only GET renders described in !263236 (closed).
  • Jest: 54 of 54 pass.

Performance

Production traffic, from Kibana (pubsub-rails-inf-gprd*, 24 full hours to 12:00 UTC on 19 October 2026): signed-in users loaded 5.83M group and project HTML pages, 67 a second on average and 200 a second in the busiest hour.

Group page cost in Caproni dev mode, signed in, 12 loads each. Read the timings as relative.

Approach Page SQL queries (p50) Extra request
No message live (cached gate) 70 none
Client GraphQL request, the first draft of this MR 70 11 SQL queries, 64 ms
Server-rendered, this MR 71 none

Rendering on the server costs one query on the page and removes a request that, at the production peak, would have been about 200 extra GraphQL requests a second while a campaign runs. Between campaigns the layout adds one cache read.

Database queries

The banner runs these on group and project pages. The plan below is from local dev data. The db:gitlabcom-database-testing job is running for production-like plans.

Cached gate, TargetedMessage.any_active? (once a minute)
SELECT 1 FROM "targeted_messages" WHERE "targeted_messages"."starts_at" <= '2026-10-19 13:56:59.110723' AND (ends_at > '2026-10-19 13:56:59.110723') LIMIT 1
Finder, Notifications::TargetedMessagesFinder#execute
SELECT "targeted_messages".* FROM "targeted_messages" WHERE ("targeted_messages"."audience" = 0 AND "targeted_messages"."id" IN (SELECT "targeted_message_namespaces"."targeted_message_id" FROM "targeted_message_namespaces" LEFT JOIN targeted_message_dismissals ON targeted_message_namespaces.namespace_id = targeted_message_dismissals.namespace_id AND targeted_message_namespaces.targeted_message_id = targeted_message_dismissals.targeted_message_id AND targeted_message_dismissals.user_id = 2 WHERE "targeted_message_namespaces"."namespace_id" = 2 AND "targeted_message_dismissals"."id" IS NULL) OR "targeted_messages"."audience" = 1 AND ('free' = ANY(plans)) AND "targeted_messages"."id" NOT IN (SELECT "targeted_message_dismissals"."targeted_message_id" FROM "targeted_message_dismissals" WHERE "targeted_message_dismissals"."user_id" = 2)) AND "targeted_messages"."starts_at" <= '2026-10-19 13:56:59.110309' AND (ends_at > '2026-10-19 13:56:59.110309') AND (plans = '{}' OR 'free' = ANY(plans)) AND (roles = '{}' OR 50 = ANY(roles)) ORDER BY "targeted_messages"."id" ASC
Sort  (cost=64.87..64.88 rows=1 width=236) (actual time=0.018..0.019 rows=1 loops=1)
  Sort Key: targeted_messages.id
  Sort Method: quicksort  Memory: 25kB
  Buffers: shared hit=2
  ->  Seq Scan on targeted_messages  (cost=34.61..64.86 rows=1 width=236) (actual time=0.015..0.016 rows=1 loops=1)
        Filter: ((starts_at <= '2026-10-19 13:56:59.110309+00'::timestamp with time zone) AND (ends_at > '2026-10-19 13:56:59.110309+00'::timestamp with time zone) AND ((plans = '{}'::text[]) OR ('free'::text = ANY (plans))) AND ((roles = '{}'::smallint[]) OR (50 = ANY (roles))) AND (((audience = 0) AND (ANY (id = (hashed SubPlan 1).col1))) OR ((audience = 1) AND ('free'::text = ANY (plans)) AND (NOT (ANY (id = (hashed SubPlan 2).col1))))))
        Rows Removed by Filter: 1
        Buffers: shared hit=2
        SubPlan 1
          ->  Nested Loop Left Join  (cost=4.35..21.93 rows=1 width=8) (never executed)
                Join Filter: (targeted_message_namespaces.targeted_message_id = targeted_message_dismissals.targeted_message_id)
                Filter: (targeted_message_dismissals.id IS NULL)
                ->  Bitmap Heap Scan on targeted_message_namespaces  (cost=4.20..13.67 rows=6 width=16) (never executed)
                      Recheck Cond: (namespace_id = 2)
                      ->  Bitmap Index Scan on index_targeted_message_namespaces_on_namespace_id  (cost=0.00..4.20 rows=6 width=0) (never executed)
                            Index Cond: (namespace_id = 2)
                ->  Materialize  (cost=0.15..8.18 rows=1 width=24) (never executed)
                      ->  Index Scan using index_targeted_message_dismissals_on_user_ns_targeted_message on targeted_message_dismissals  (cost=0.15..8.17 rows=1 width=24) (never executed)
                            Index Cond: ((user_id = 2) AND (namespace_id = 2))
        SubPlan 2
          ->  Bitmap Heap Scan on targeted_message_dismissals targeted_message_dismissals_1  (cost=4.19..12.66 rows=5 width=8) (actual time=0.005..0.005 rows=0 loops=1)
                Recheck Cond: (user_id = 2)
                Buffers: shared hit=1
                ->  Bitmap Index Scan on index_targeted_message_dismissals_on_user_ns_targeted_message  (cost=0.00..4.19 rows=5 width=0) (actual time=0.004..0.004 rows=0 loops=1)
                      Index Cond: (user_id = 2)
                      Buffers: shared hit=1
Planning:
  Buffers: shared hit=235
Planning Time: 0.306 ms
Execution Time: 0.038 ms

Still open

  • The new flag needs a rollout issue.
  • Members of a project who aren't members of its group get NO_ACCESS on the group, so they don't see the banner on that project's pages.
  • The dismissal endpoint accepts any message and namespace ID and has no rate limit. That's existing behaviour, but more users will reach it now.
  • A database reviewer will want plans for the finder query on production data.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Sam Wiskow

Merge request reports

Loading
Loading