Set up your app¶
Your app sets Herald up in one place: the code that runs when the app starts, and whatever shares
objects with the rest of the app, such as dependency injection. That place creates the vendors,
builds one Herald from them, starts it, and hands it to the rest of the app. No other code names a
vendor or Herald itself.
Build Herald with your vendors¶
Herald sends every call to each vendor you register. A registered vendor is called a provider. Keeping each provider in its own small function keeps this part short:
val herald = Herald {
providers(
listOf(
firebaseProvider(firebaseAnalytics),
mixpanelProvider(mixpanel),
adjustProvider(adjust, adjustConfig, adjustTokens),
),
)
errorReporter { provider, operation, failure ->
Log.w("analytics", "$provider failed on $operation", failure)
}
}
providers(...) takes vendors that are already built, which is handy when your DI collects them
from several modules. provider(...) builds one in place from its parts. You can use both in
the same Herald { } block.
Each of those functions looks like the Firebase one from the
quick start, and each vendor page shows
its own. A provider has a name and the parts of Herald that the vendor supports: events, user
properties, sign-in, start-up and consent. Leave out what a vendor doesn't have. AppsFlyer keeps no
user properties, for example, so its provider has no properties.
Pick a name you'd recognise in a crash log: it's the name Herald reports when that vendor fails.
Give it to your classes¶
Your classes never ask for Herald itself. Each one asks for the small interface it needs, and
you pass the same Herald for all of them:
| A class that… | asks for |
|---|---|
| tracks events | EventTrackerService |
| sets user properties | PropertyTrackerService |
| signs users in and out | IdentifiableUserService |
| starts or flushes analytics | AnalyticsLifecycleService |
| records the user's consent | ConsentService |
That keeps each class honest about what it does, and easy to test with a fake.
Bind only Herald, never a vendor's own tracker
Each vendor's tracker also implements EventTrackerService. If you bind one of those too, a
class can end up with it and send events to that one vendor only. Nothing fails to compile and
nothing crashes. The events are just missing everywhere else.
val analyticsModule = module {
single {
Herald {
providers(getAll<Herald.Provider>()) // each vendor module contributes one
}
} binds arrayOf(
EventTrackerService::class,
PropertyTrackerService::class,
IdentifiableUserService::class,
AnalyticsLifecycleService::class,
ConsentService::class,
)
}
Each vendor module adds its provider with single<Herald.Provider>(named("firebase")) { … }.
Give each one a name like that: Koin keeps only one of two unnamed definitions of the same
type, so getAll would find just one provider.
@Module
@InstallIn(SingletonComponent::class)
object AnalyticsModule {
@Provides
@Singleton
fun herald(providers: Set<@JvmSuppressWildcards Herald.Provider>): Herald = Herald {
providers(providers) // each vendor module contributes one with @IntoSet
}
@Provides fun events(herald: Herald): EventTrackerService = herald
@Provides fun properties(herald: Herald): PropertyTrackerService = herald
@Provides fun identity(herald: Herald): IdentifiableUserService = herald
@Provides fun lifecycle(herald: Herald): AnalyticsLifecycleService = herald
@Provides fun consent(herald: Herald): ConsentService = herald
}
Each vendor module adds its provider with @Provides @IntoSet.
final class AppGraph {
private let herald: Herald
init(herald: Herald) {
self.herald = herald
}
// CheckoutViewModel asks for an EventTrackerService; Herald is one, so it's passed as that.
func checkoutViewModel() -> CheckoutViewModel {
CheckoutViewModel(analytics: herald)
}
}
With a container such as Factory or Swinject, register the one Herald and hand it out as
each of the protocols above, the same way.
final class const AppGraph(final Herald herald) {
// CheckoutViewModel asks for an EventTrackerService; Herald is one, so it is passed as that.
CheckoutViewModel checkoutViewModel() => CheckoutViewModel(herald);
}
With a package such as get_it or Riverpod, register the one Herald and hand it out as each of
the interfaces above, the same way.
export class AppGraph {
constructor(private readonly herald: Herald) {}
// CheckoutViewModel asks for an EventTrackerService; Herald is one, so it is passed as that.
checkoutViewModel(): CheckoutViewModel {
return new CheckoutViewModel(this.herald);
}
}
To reach it from components, put the one Herald in a React context, or use
HeraldScope, which hands it out as an
EventTrackerService.
Start it¶
Call start() once, when the app starts, and then apply the consent decision the user made last
time. Some vendors forget it between launches, and some of Herald's vendor modules keep a vendor
quiet at start-up on purpose, until consent is applied (see Consent).
Do it where the vendors' own setup guides initialise their SDKs, on every start of your app:
Do it in Application.onCreate(), in a scope that lives as long as the app. It runs on every
start, including ones without a screen, like a push notification or a WorkManager job:
class MyApplication : Application() {
// One scope for work that lives as long as the app.
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Injected by your DI setup.
lateinit var analytics: AnalyticsLifecycleService
lateinit var consent: ConsentService
lateinit var consentRepository: AnalyticsConsentRepository // however you store the answer
override fun onCreate() {
super.onCreate()
appScope.launch {
analytics.start() // some vendors start quiet here
consent.setEnabled(consentRepository.isEnabled()) // so re-apply the stored answer
}
}
}
Do it in your app delegate's application(_:didFinishLaunchingWithOptions:), after the
vendors' own setup. Calls keep their order, so there's nothing to wait for:
Do it in main(), before runApp, and await each call: Herald doesn't order calls you don't
await.
Do it in the app's entry file, which runs once each time the app starts, and await each call: Herald doesn't order calls you don't await.
export async function startAnalytics(
herald: Herald,
consentRepository: AnalyticsConsentRepository, // however you store the answer
): Promise<void> {
await herald.start(); // some vendors start quiet here
await herald.setEnabled(await consentRepository.isEnabled()); // so re-apply the stored answer
}
If a user is signed in, call identify(...) between the two calls. Some vendors forget the user
between launches, and AppsFlyer attaches the user id to the launch it sends when consent is
applied. See Identity.
If your app uses use cases, these two calls fit in one, like StartAnalyticsUseCase in the
Moove sample app.
Android: starting from a ViewModel
An app with a single activity and no background work can do the same from that activity's ViewModel instead. Moove does this: its main activity's ViewModel also handles deep links, so nothing is tracked before the vendors have started.
When a vendor fails¶
If a vendor fails, Herald catches the error and still sends to
the other vendors, so analytics never crashes your app. The downside is that a vendor failing on
every call looks just like one that works. That's why the example above passes an
errorReporter: send the errors to the crash or logging tool you already use.
- The operation says what failed:
Track(checkout_started),SetProperty(plan),Identify,Start, and so on. It holds names only, never parameter values or the user id, so it's safe to send to another tool. - Errors arrive one at a time, so your reporter doesn't need to be thread-safe.
- If your reporter fails, Herald catches that too, and doesn't wait for it.
Threads and order¶
Tracking from UI code is safe on every platform. How calls reach the vendors depends on the platform:
Herald calls every vendor at the same time, so one slow vendor doesn't hold up the others, and a
call returns when every vendor has finished. It never calls a vendor SDK on the main thread: it
uses Dispatchers.Default unless you pass another dispatcher:
- Calls are suspend functions, so two events from the same coroutine always reach a vendor in the order you tracked them.
- In tests, pass a test dispatcher, such as
StandardTestDispatcher(), so the calls run in a predictable order.
There is no queue: Herald calls each vendor on your thread, one after another, and vendor SDKs do their own work in the background.
- Calls keep their order. When
trackreturns, every vendor has the event, so the next call always reaches them after it. - Calls from different threads at the same moment can reach a vendor in either order, as on the other platforms.
Herald calls every vendor at the same time, and a call completes when every vendor has finished. There is no dispatcher: vendor plugins already do their work off the UI thread.
- A call starts at once and doesn't wait for earlier ones. Two calls you don't await, such
as
onPressed: () => analytics.track(event), can reach a vendor in either order. When order matters, await the first call.
Herald calls every vendor at the same time, and a call settles when every vendor has finished. There is no dispatcher: vendor SDKs do their work in native code, off the JavaScript thread.
- A call starts at once and doesn't wait for earlier ones. Two calls you don't await, such
as
onPress={() => analytics.track(event)}, can reach a vendor in either order. When order matters, await the first call.
Next¶
- Philosophy: why Herald works the way it does.
- Modular apps: feature modules that bring their own vendor rules.