Livery Server-driven Remote Compose templates GitHub

Analytics and experiments#

In one line: send Livery's events to any analytics pipeline, run A/B tests over designs and placements from the catalog, and keep frequency caps across a user's devices. Firebase is built in (livery-firebase); anything else plugs in through an interface.

Analytics#

Every slot reports TemplateEvents: Impression, Viewable, Action, Dismiss, Fallback, Error, Exposure and Leave, each with its placement, decision, design, template and the experiment arms that shaped it. AnalyticsEventSink turns them into flat events (livery_impression, livery_action, … with placement, design_id, template, action, arg_<name>, duration_ms, exp_<experiment> parameters) that fit Firebase's and most pipelines' limits, and hands them to every AnalyticsSink you give it:

val events = AnalyticsEventSink(
  FirebaseAnalyticsSink(Firebase.analytics),            // livery-firebase
  AmplitudeAnalyticsSink(amplitude),                    // livery-amplitude
  AnalyticsSink { name, params -> segment.track(name, params) },  // or your warehouse, …
)
val planner = SlotPlanner(repository, events, clock)   // with experiments: see below

What a tap reports, and the integrations for Datadog and Sentry, are on Interaction tracking.

A pipeline that throws doesn't stop the others. To also keep Livery's raw events (a debug log, a test probe), combine sinks with eventSinks(events, EventSink { log(it) }).

A/B tests#

Experiments live in the catalog, in experiments/<any>.json, and ship in the manifest:

{ "id": "tile_look", "allocationBasisPoints": 5000,
  "conditions": { "platforms": ["android"] },
  "arms": [
    { "id": "control" },
    { "id": "retro", "designs": { "live/status-tile@1": "tile-retro" } }
  ] }
val experiments = Experiments(
  manifest = { repository.manifest() },
  context = { DecisionContext(now, "android", locale, appVersion, bucketingId, segments) },
  assigner = FirstAssigner(RemoteConfigAssigner(Firebase.remoteConfig), LocalBucketing),
  events = events,
)
// Slots report through experiments.sink: every event passes on, and an arm's first impression
// adds its exposure. A widget, which reports no impressions, calls experiments.exposed(…) itself.
SlotPlanner(repository, experiments.sink, clock, experiments)
LocalRulesDecider({ repository.manifest() }, caps, experiments = LocalBucketing)

Assignment is local by default (LocalBucketing): no service, offline, the same arm on Android, iOS and the server for the same bucketing id. To run experiments from Firebase A/B Testing or your own service, implement ExperimentAssigner. RemoteConfigAssigner reads the Remote Config parameter livery_exp_<experiment>, which holds the arm id. An assigner answers only from what's on the device. Call Remote Config's fetchAndActivate() at app start as usual; until it has an answer, the next assigner in the FirstAssigner does.

Frequency caps across devices#

A placement rule's frequency ({ "maxImpressions": 2, "perSeconds": 604800 }: twice a week) counts impressions on the device. KeyValueFrequencyStore keeps that count across restarts. To cap per user rather than per device, use SyncedFrequencyStore with a FrequencyBackend. Firestore is built in, and anything with "push records, pull records since" fits:

val caps = SyncedFrequencyStore(kv, FirestoreFrequencyBackend(Firebase.firestore, user.uid), deviceId, clock)
val decider = LocalRulesDecider({ repository.manifest() }, caps, experiments = assigner)
caps.sync()   // at app start, on a schedule, after a placement draws

Decisions read only the local count, so a slot never waits on the network. Impressions queue while offline and go up on the next sync(), and each one is counted once however often it syncs. A cap is at most one sync out of date. With Firestore, store impressions under livery/<userId>/impressions and write security rules so each user reads and writes only their own.

Deployment checklist#

Planned#

Edit this page