Feature - Campaign Objective Track- #729 - #4820
Merged
Merged
Conversation
change: Added a way to send campaign objective goals on ReactNative Sample for test purposes. change: Added BeamableAnalyticsLoader to the local stack template
DiasAtBeamable
requested review from
PedroRauizBeamable,
felipe-beamable and
gabrielbeamable
August 28, 2026 21:46
Contributor
|
Web SDK Sample Build link |
Contributor
|
Lightbeam link |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brief Description
Makes in-game mail a first-class, measurable campaign channel, and adds the local-stack plumbing
needed to actually see the resulting numbers.
Push campaigns get their
Openedstage for free: the handset echoes the notification payload backfrom the tap handler. In-game mail has no handset, so until now a campaign delivered over the in-game
rail reported zero engagement forever — not because nobody read it, but because nothing was there to
say so. This PR puts that report where the read actually happens (the
Unread -Readtransition),in the SDK rather than in each game, so a game writes no tracking code at all.
Mail carries campaign attribution
Message/SendMailRequest/SendMailObjectRequestgainmetadata(regeneratedModels.gs.csfor both
beamable.commonand the Unitycom.beamablecopy, plus the web__generated__schemas).This is the field the message rail stamps
beam_outreach/trackIdonto.MailMessage.metadatais now populated byAbsMailApi's converter — it was being dropped on thefloor before, so the attribution never reached the client at all.
Automatic funnel reporting (C# + web)
AbsMailApiremembers attribution for campaign mail it fetches (bounded at 256 entries,insertion-order eviction) and emits an
Openedon anUnread -Readupdate.MailOpenedFunnelEvent(new) is shaped byte-identically to what the iOS and Android pushSDKs already send — same
notification_funnelcategory, sameoutreachId/trackIdparams — sothe platform's campaign consumer attributes it with no ingest change.
web/src/services/MailService.ts(new) is the TypeScript equivalent, with the same attributionmap, the same eviction bound and the same once-only reporting.
web/src/services/AnalyticsService.ts(new) closes a real gap: the web SDK could only queryanalytics (
analyticsPostQuery) and had no way to emit at all, so a web or React Native gamecould not report funnel stages the way Unity and native could. Registered in
BeamUtils/ServiceMapasbeam.analyticsandbeam.mail.unread — including the case where a game registered
MailServicebut notAnalyticsService, wherereading
beam.analyticsthrows rather than returning undefined (unregistered services are backedby a throwing getter). That is caught, and the read still succeeds.
Durable delivery, declared
FederatedMessageRailgainsDeliveryModeKey/DeliveryDurable. A durable rail (in-game maillands in a mailbox and waits) has no in-flight state and will never send a separate receipt, so the
platform emits
DeliveredalongsideSentinstead of leaving that stage structurally zero forever.Worth surfacing in any UI: for a durable rail Sent and Delivered track ~1:1, and that is honest.
CLI / local stack
beam local upnow runsBeamableAnalyticsLoader(port 5020, from the project's ownlaunchSettings.json, so no collision with 5000 / 5030 / 5045). Without it, events reach ActiveMQand stop:
POST /analytics/queryreturns nothing and everything warehouse-backed — the Campaignbuilder's analytics-event picker, Campaign Analytics — is empty locally for no visible reason.
Added to the process/orphan-sweep image lists too, or it would be invisible to
ps.sts:RoleSessionName, so thepreflight was reporting AccessDenied on the
beamable-local-analytics-*roles while an analyticsloader on the same machine was writing to those exact buckets. The probe now retries under the
backend's own session name (
platform-service-assume-role, mirrored fromAmazonAccountManager)after trying its own, direct and chained. A check that does not ask the question the way production
asks it is not a check.
appsettings.Local.json—they are a different role from the one in Scala's
awsglobal.conf, so a green check there saidnothing about whether the gateway and loader could reach theirs. Reported as warnings: a
developer who never runs the loader should not be blocked from bringing the stack up.
FileSystemWatchercallbacks run onthread-pool threads, so an exception escaping a rebuild took down the whole
beam project rungroup — every service and extension, not just the failing one. Now caught and logged, and concurrent
rebuilds are serialised (two events could collide writing
metadata.json).React Native sample
campaign lane's watched-analytics objective can be validated from a real device. Everything else in
the sample emits events as a side effect of doing something, which is useless for testing a
condition like
amount >= 10. Deliberately sends no category: category routes the funnel,while an objective matches on event name and params — setting one would corrupt the funnel you are
trying to read. Raw JSON mode exists to exercise the nested-object path (
ParamFlattenerwalksthose into
details.price, a different code path from typing the dotted key).Openedis reported by the SDK, and thescreen contains no analytics code, which is the point.
config.beam.jsonis the only thing that setsthe SDK's realm and host (
env.local/VITE_API_BASEdoes not, despite appearances — that path isfor the web/Unity-WebView variant), a stale git-ignored
web/distsilently falls back to prod, andconfig is read at bundle time. Each of those failed with an error that pointed somewhere else.
Testing
web/tests/services/MailService.test.ts(new, 8 cases): reports on campaign mail, reports nothingfor ordinary mail, nothing when only half the attribution is present, no double-report on re-read,
only on a transition to Read, one event per mail in a bulk update, and still marks read when the
analytics accessor throws.
cli/tests/AwsPreflightSessionNameTests.cs(new, 4 cases): pins the session-name candidate list andthe exact
assume-roleargument string. The literalplatform-service-assume-roleis hard-codedrather than referenced — it has to match an allowed value in an AWS trust policy, so a rename is a
breaking infrastructure change and should fail here, not at runtime as a fake permissions error.
LocalStackBuildStepTests/LocalStackLivenessTestsextended for the analytics loader step.Checklist
Notes
When you are merging a feature branch into
main, please squash merge and make sure the final commit contains any relevent JIRA ticket number. If you are merging frommaintostaging, orstagingtoproduction, please use a regular merge commit.Reviewer notes
_attributed, TSattributed) are intentionally best-effort andbounded rather than exact LRUs — a second data structure is not worth it for a metric the server also
dedupes on
(outreachId, stage).notification_funnelandplatform-service-assume-roleare cross-repo wire contracts (iOSBeamableAnalytics.funnelCategory, AndroidFUNNEL_CATEGORY, BeamableAPI'sAmazonAccountManager).Renaming either breaks attribution or access silently; both are pinned by tests/comments for that
reason.
local emulator for S3 Tables or Athena, so it uses the shared
beamable-local-analytics-*buckets.It can be opted out with
"enabled": falseon the step in the manifest.Models.gs.cschanges are generated, and duplicated by design into the Unity SDK copy.Tech-debt: none knowingly introduced. The nearest thing is the zone-extension hot-reload
TODOalready tracked in
PortalExtensionDiscoveryService(NotifyServeris realm-scoped), which this PRtouches but does not resolve.