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_deriskflag,targeted_messages_plan_audience, with the root group as the actor so it can roll out by percentage.targeted_messages_admin_uiis already on in production and only takes the:instanceactor, so it can't do that. - Audience. A new
audiencecolumn stores the choice explicitly. Aplan_widemessage reaches every top-level group on its tiers. Anamespace_listmessage can useplansto 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::TIERSand the cachednamespace.plan_tier, so trials and open source count as the tier they unlock. - Query.
Notifications::TargetedMessagesFinderreturns 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
- #634504
- &16371
- #514256 (message content), #528871 (page targets) and #527968 (CTAs) are partly covered here.
- https://gitlab.com/gitlab-org/gitlab/-/work_items/632721 computes plan, trial and edition for another audience.
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 |
|---|---|
![]() |
![]() |
| Free group owner, project page | Premium developer, group page |
|---|---|
![]() |
![]() |
How to set up and validate locally
-
Run GitLab with
GITLAB_SIMULATE_SAAS=1and enable both flags:Feature.enable(:targeted_messages_admin_ui) Feature.enable(:targeted_messages_plan_audience) -
Create three top-level groups on the Free, Premium and Ultimate plans, each with a project, and add members with different roles.
-
In Admin > Targeted messages, choose Everyone on the selected plans, select Free, add a title and button, and start it now.
-
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.
-
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.
-
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
GETrenders 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 1Finder, 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" ASCSort (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 msStill open
- The new flag needs a rollout issue.
- Members of a project who aren't members of its group get
NO_ACCESSon 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.



