Skip to content

Feature - Campaign Objective Track- #729 - #4820

Merged
DiasAtBeamable merged 4 commits into
mainfrom
feature/campaign-objective-track
Sep 1, 2026
Merged

Feature - Campaign Objective Track- #729#4820
DiasAtBeamable merged 4 commits into
mainfrom
feature/campaign-objective-track

Conversation

@DiasAtBeamable

Copy link
Copy Markdown
Contributor

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 Opened stage for free: the handset echoes the notification payload back
from 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 -Read transition),
in the SDK rather than in each game, so a game writes no tracking code at all.

Mail carries campaign attribution

  • Message / SendMailRequest / SendMailObjectRequest gain metadata (regenerated Models.gs.cs
    for both beamable.common and the Unity com.beamable copy, plus the web __generated__ schemas).
    This is the field the message rail stamps beam_outreach / trackId onto.
  • MailMessage.metadata is now populated by AbsMailApi's converter — it was being dropped on the
    floor before, so the attribution never reached the client at all.

Automatic funnel reporting (C# + web)

  • AbsMailApi remembers attribution for campaign mail it fetches (bounded at 256 entries,
    insertion-order eviction) and emits an Opened on an Unread -Read update.
  • MailOpenedFunnelEvent (new) is shaped byte-identically to what the iOS and Android push
    SDKs already send — same notification_funnel category, same outreachId / trackId params — so
    the platform's campaign consumer attributes it with no ingest change.
  • web/src/services/MailService.ts (new) is the TypeScript equivalent, with the same attribution
    map, 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 query
    analytics (analyticsPostQuery) and had no way to emit at all, so a web or React Native game
    could not report funnel stages the way Unity and native could. Registered in BeamUtils /
    ServiceMap as beam.analytics and beam.mail.
  • Reporting is fire-and-forget on both sides. A failed metrics call must never make a mail look
    unread — including the case where a game registered MailService but not AnalyticsService, where
    reading beam.analytics throws rather than returning undefined (unregistered services are backed
    by a throwing getter). That is caught, and the read still succeeds.

Durable delivery, declared

  • FederatedMessageRail gains DeliveryModeKey / DeliveryDurable. A durable rail (in-game mail
    lands in a mailbox and waits) has no in-flight state and will never send a separate receipt, so the
    platform emits Delivered alongside Sent instead 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 up now runs BeamableAnalyticsLoader (port 5020, from the project's own
    launchSettings.json, so no collision with 5000 / 5030 / 5045). Without it, events reach ActiveMQ
    and stop: POST /analytics/query returns nothing and everything warehouse-backed — the Campaign
    builder'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.
  • AWS preflight session-name fix. Some trust policies condition on sts:RoleSessionName, so the
    preflight was reporting AccessDenied on the beamable-local-analytics-* roles while an analytics
    loader 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 from AmazonAccountManager)
    after trying its own, direct and chained. A check that does not ask the question the way production
    asks it is not a check.
  • The C#-host analytics roles are now checked too, read from BeamableAPI's appsettings.Local.json
    they are a different role from the one in Scala's awsglobal.conf, so a green check there said
    nothing 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.
  • Portal extension hot-reload no longer kills the process. FileSystemWatcher callbacks run on
    thread-pool threads, so an exception escaping a rebuild took down the whole beam project run
    group — 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

  • Objective events tab — fires arbitrary named events with parameters (key/value or raw JSON) so a
    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 (ParamFlattener walks
    those into details.price, a different code path from typing the dotted key).
  • Inbox — tap an unread message to open it; the funnel Opened is reported by the SDK, and the
    screen contains no analytics code, which is the point.
  • README — rewrites "Pointing at a local stack". config.beam.json is the only thing that sets
    the SDK's realm and host (env.local / VITE_API_BASE does not, despite appearances — that path is
    for the web/Unity-WebView variant), a stale git-ignored web/dist silently falls back to prod, and
    config 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 nothing
    for 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 and
    the exact assume-role argument string. The literal platform-service-assume-role is hard-coded
    rather 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 / LocalStackLivenessTests extended for the analytics loader step.

Checklist

  • Have you added appropriate text to the CHANGELOG.md files?

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 from main to staging, or staging to production, please use a regular merge commit.

Reviewer notes

  • The two attribution caches (C# _attributed, TS attributed) are intentionally best-effort and
    bounded rather than exact LRUs — a second data structure is not worth it for a metric the server also
    dedupes on (outreachId, stage).
  • notification_funnel and platform-service-assume-role are cross-repo wire contracts (iOS
    BeamableAnalytics.funnelCategory, Android FUNNEL_CATEGORY, BeamableAPI's AmazonAccountManager).
    Renaming either breaks attribution or access silently; both are pinned by tests/comments for that
    reason.
  • The analytics loader is the only step in the local stack that talks to real AWS — there is no
    local emulator for S3 Tables or Athena, so it uses the shared beamable-local-analytics-* buckets.
    It can be opted out with "enabled": false on the step in the manifest.
  • Models.gs.cs changes 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 TODO
already tracked in PortalExtensionDiscoveryService (NotifyServer is realm-scoped), which this PR
touches but does not resolve.

change: Added a way to send campaign objective goals on ReactNative Sample for test purposes.

change: Added BeamableAnalyticsLoader to the local stack template
@github-actions

Copy link
Copy Markdown
Contributor

Web SDK Sample Build link

@DiasAtBeamable DiasAtBeamable changed the title Feature/campaign objective track Feature - Campaign Objective Track- #729 Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Lightbeam link

@DiasAtBeamable
DiasAtBeamable merged commit de73b6e into main Sep 1, 2026
32 checks passed
@DiasAtBeamable
DiasAtBeamable deleted the feature/campaign-objective-track branch September 1, 2026 12:01
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.

1 participant