Skip to content

How Herald passes calls on

Herald is the object behind all five interfaces. When a class calls track, Herald passes the call to every vendor you registered, and takes care of everything around it: threads, running vendors side by side, and failures.

flowchart LR
    VM[ViewModel] -- "track(CheckoutStarted)" --> H[Herald]
    H --> F[Firebase rules]
    H --> M[Mixpanel rules]
    H --> A[Adjust rules]
    F --> FS[(GA4)]
    M --> MS[(Mixpanel)]
    A -. no token, not sent .-> X[nothing]

What each call does

  1. Keeps vendor work off your UI. See Threads for how, on each platform.
  2. Calls every vendor that supports it. A vendor registered without properties, for example, is skipped for set.
  3. Waits for all of them. The call returns when every vendor has the call, so a second track that waits for the first never overtakes it at any vendor.
  4. Catches failures. If a vendor fails, the others carry on.
  5. Reports the failures to your error reporter, one at a time.

Herald doesn't decide which events a vendor sends. It passes everything on, and each vendor's factories decide.

Threads

Herald runs vendor calls on a coroutine dispatcher, Dispatchers.Default unless you pass another:

Herald {
    dispatcher(Dispatchers.IO)                              // or a thread of your own
    // dispatcher(UnconfinedTestDispatcher(testScheduler)) // in tests
}

A call suspends until every vendor has finished, so call Herald from a scope that lives long enough: viewModelScope for work that belongs to a screen, or an app-wide scope for work that must finish even if the screen closes.

There is no queue or dispatcher. Herald calls each vendor on the thread you call it from, one after another, and returns once every vendor has the call. Vendor SDKs do their own work in the background, so a call takes very little time, and calls never wait or throw.

The order is kept for you, with no await:

herald.setEnabled(false)
herald.track(event)  // reaches every vendor after collection is off

A provider of your own must return quickly too: do slow work, such as a network request, in a Task. See Custom providers.

There is no dispatcher. A call returns a Future that completes when every vendor has finished, and 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 can reach a vendor in either order, so await a call when the next one depends on it:

await herald.setEnabled(false);
await herald.track(event); // reaches every vendor after collection is off

There is no dispatcher. A call returns a promise that settles when every vendor has finished, and vendor SDKs do their work in native code, off the JavaScript thread. The promise never rejects, so it's safe not to await it.

A call starts at once and doesn't wait for earlier ones. Two calls you don't await can reach a vendor in either order, so await a call when the next one depends on it:

await herald.setEnabled(false);
await herald.track(event); // reaches every vendor after collection is off

Failures

Analytics must never break your app, so Herald catches failures at every step:

What fails What happens
a vendor call fails caught; the other vendors still run; reported to you
your error reporter throws caught; the other failures are still reported
the calling coroutine is cancelled (Android) the cancellation goes on as usual: that's your code stopping, not a vendor failing

Your error reporter gets the vendor's name, the operation that failed, and the error. The operation reads the same on every platform:

Operation Holds
Track(eventName) the event's name, never its parameters
SetProperty(propertyName) the property's name, never its value
Identify, Reset nothing, so never the user id
SetEnabled(enabled) whether analytics was being turned on or off
Start, Flush nothing

Because it holds names only, you can send it to a crash reporting tool without copying any personal data into it:

errorReporter { provider, operation, failure ->
    crashlytics.log("analytics: $provider failed on $operation")
    crashlytics.recordException(failure)
}
errorReporter: { failure in
    // "adjust failed on Track(checkout_started): ..."
    Crashlytics.crashlytics().record(error: failure.error, userInfo: ["call": "\(failure)"])
}
errorReporter: (failure) => FirebaseCrashlytics.instance.recordError(
  failure.error,
  failure.stackTrace,
  reason: '$failure', // "adjust failed on Track(checkout_started): ..."
),
errorReporter: (failure) => {
  const crashlytics = getCrashlytics();
  log(crashlytics, `${failure}`); // "adjust failed on Track(checkout_started): ..."
  if (failure.error instanceof Error) recordError(crashlytics, failure.error);
},

An event that reaches a required-mapping factory, such as RequireMappedFirebaseEventTrackerFactory, arrives here too, as an unhandled-event error. That's how Herald tells you an event reached a vendor that nobody wrote a factory for.

Providers

A provider is a name and at least one of the five interfaces. Herald refuses to build one with an empty name or no interfaces, because nothing could ever call it. Pick names you'd recognise in a crash log, and give each provider its own. Set up your app shows how to build them on your platform.

Next

The factory chain: how each vendor decides what to send.