The factory chain¶
For each vendor, an ordered list of small classes called factories decides what that vendor receives for each event. Herald calls that list a chain. It's where a vendor's rules meet your app's events.
A factory answers only for its own events¶
A factory looks at one event and gives one of three answers, as a Resolution:
| Answer | Meaning | The chain then |
|---|---|---|
| Claimed | "mine: send it with these trackers" | stops, and runs the trackers |
| Dropped | "mine: send nothing" | stops, and sends nothing |
| Declined | "not mine" | asks the next factory |
A factory answers only for the events it knows, and declines everything else. That's the whole idea: no factory has to know every event in the app, so each feature can ship the factory for its own events.
class CheckoutFirebaseEventTrackerFactory(
private val firebaseAnalytics: FirebaseAnalytics,
) : FirebaseEventTrackerFactory {
override fun create(event: Event): Resolution<FirebaseEventTracker> = when (event) {
is CheckoutCompleted -> Resolution.Claimed(PurchaseFirebaseEventTracker(event, firebaseAnalytics))
is PayTapped -> Resolution.Claimed(GenericEventTracker(event, firebaseAnalytics)) // Herald's own
is CardNumberSeen -> Resolution.Dropped // mine, and it goes nowhere
else -> Resolution.Declined // not mine: ask the next factory
}
}
struct CheckoutFirebaseEventTrackerFactory: FirebaseEventTrackerFactory {
func create(_ event: any Event) -> Resolution<any FirebaseEventTracker> {
if let checkout = event as? CheckoutCompleted {
return .claimed([PurchaseFirebaseEventTracker(event: checkout)])
}
if event is PayTapped {
return .claimed([GenericFirebaseEventTracker(event: event)]) // Herald's own
}
if event is CardNumberSeen {
return .dropped // mine, and it goes nowhere
}
return .declined // not mine: ask the next factory
}
}
final class const CheckoutFirebaseEventTrackerFactory(final FirebaseAnalytics firebaseAnalytics)
implements FirebaseEventTrackerFactory {
@override
Resolution<FirebaseEventTracker> create(Event event) => switch (event) {
CheckoutCompleted() => .claimed([PurchaseFirebaseEventTracker(event, firebaseAnalytics)]),
PayTapped() => .claimed([
GenericFirebaseEventTracker(event, firebaseAnalytics),
]), // Herald's own
CardNumberSeen() => .dropped(), // mine, and it goes nowhere
_ => .declined(), // not mine: ask the next factory
};
}
export class CheckoutFirebaseEventTrackerFactory implements FirebaseEventTrackerFactory {
constructor(private readonly firebaseAnalytics: Analytics) {}
create(event: Event): Resolution<FirebaseEventTracker> {
if (event instanceof CheckoutCompleted) {
return Resolution.claimed([new PurchaseFirebaseEventTracker(event, this.firebaseAnalytics)]);
}
if (event instanceof PayTapped) {
// Herald's own tracker
return Resolution.claimed([new GenericFirebaseEventTracker(event, this.firebaseAnalytics)]);
}
if (event instanceof CardNumberSeen) {
return Resolution.dropped(); // mine, and it goes nowhere
}
return Resolution.declined(); // not mine: ask the next factory
}
}
Claimedtakes one or more trackers, the vendor calls to make. It can't be empty; useDroppedto take an event and send nothing.- A factory recognises an event with a plain
when(Kotlin) orswitch(Dart) on its type, so no DI container is involved. - Pass several trackers to send one event to one vendor in more than one way. See One event, two vendor calls.
Trackers make the vendor call¶
A tracker is a small class that makes one vendor call for one event. Herald ships generic ones, such as Firebase's generic tracker, which logs an event under its own name. When a vendor needs something different, write your own:
class PurchaseFirebaseEventTracker(
private val event: CheckoutCompleted,
private val firebaseAnalytics: FirebaseAnalytics,
) : FirebaseEventTracker {
override suspend fun track() {
// GA4's recommended purchase event, with the typed parameters as its Bundle.
firebaseAnalytics.logEvent(FirebaseAnalytics.Event.PURCHASE, event.parameters.toBundle())
}
}
final class const PurchaseFirebaseEventTracker(
final CheckoutCompleted event,
final FirebaseAnalytics firebaseAnalytics,
) implements FirebaseEventTracker {
@override
Future<void> track() =>
// GA4's recommended purchase event, with the typed parameters.
firebaseAnalytics.logEvent(
name: 'purchase',
parameters: event.parameters.toFirebaseParameters(),
);
}
export class PurchaseFirebaseEventTracker implements FirebaseEventTracker {
constructor(
private readonly event: CheckoutCompleted,
private readonly firebaseAnalytics: Analytics,
) {}
async track(): Promise<void> {
// GA4's recommended purchase event, with the typed parameters.
logEvent(this.firebaseAnalytics, 'purchase', toFirebaseParameters(parametersOf(this.event)));
}
}
A tracker has a single method. A named class is easier to test and to find than a lambda.
One event, two vendor calls¶
Sometimes a vendor needs two calls for one thing that happened. Mixpanel is a good example: an event shows up in its event reports, but its revenue reports read charges saved on the buyer's profile. A purchase needs both.
Your event stays a normal event:
Herald's generic tracker already sends the event. For the charge, write a small tracker that calls Mixpanel's profile API:
// Adds the amount to the buyer's profile, which Mixpanel's revenue reports read.
export class ChargeMixpanelEventTracker implements MixpanelEventTracker {
constructor(
private readonly event: OrderPaid,
private readonly mixpanel: Mixpanel,
) {}
async track(): Promise<void> {
this.mixpanel.getPeople().trackCharge(this.event.total, {});
}
}
Then the Mixpanel factory takes the event and answers with both trackers:
class CheckoutMixpanelEventTrackerFactory(
private val mixpanel: MixpanelAPI,
) : MixpanelEventTrackerFactory {
override fun create(event: Event): Resolution<MixpanelEventTracker> = when (event) {
is OrderPaid -> Resolution.Claimed(
GenericEventTracker(event, mixpanel), // 1. the order_paid event, as usual
ChargeMixpanelEventTracker(event, mixpanel), // 2. a charge on the buyer's profile
)
else -> Resolution.Declined
}
}
// Mixpanel isn't marked Sendable, so a factory that stores it needs `@preconcurrency import Mixpanel`.
struct CheckoutMixpanelEventTrackerFactory: MixpanelEventTrackerFactory {
let mixpanel: MixpanelInstance
func create(_ event: any Event) -> Resolution<any MixpanelEventTracker> {
guard let order = event as? OrderPaid else {
return .declined
}
return .claimed([
// 1. the order_paid event, as usual; 2. a charge on the buyer's profile
GenericMixpanelEventTracker(event: order, mixpanel: mixpanel),
ChargeMixpanelEventTracker(event: order, mixpanel: mixpanel),
])
}
}
final class const CheckoutMixpanelEventTrackerFactory(final Mixpanel mixpanel)
implements MixpanelEventTrackerFactory {
@override
Resolution<MixpanelEventTracker> create(Event event) => switch (event) {
OrderPaid() => .claimed([
GenericMixpanelEventTracker(event, mixpanel), // 1. the order_paid event, as usual
ChargeMixpanelEventTracker(event, mixpanel), // 2. a charge on the buyer's profile
]),
_ => .declined(),
};
}
export class CheckoutMixpanelEventTrackerFactory implements MixpanelEventTrackerFactory {
constructor(private readonly mixpanel: Mixpanel) {}
create(event: Event): Resolution<MixpanelEventTracker> {
if (event instanceof OrderPaid) {
return Resolution.claimed([
new GenericMixpanelEventTracker(event, this.mixpanel), // 1. the order_paid event, as usual
new ChargeMixpanelEventTracker(event, this.mixpanel), // 2. a charge on the buyer's profile
]);
}
return Resolution.declined();
}
}
Herald runs both trackers one after the other, in that order, for this one vendor. If the first one
throws, the second doesn't run, and the failure goes to your error reporter. The other vendors are
unaffected:
Firebase and Amplitude still get order_paid from their own chains, as usual. The
Moove sample app does the same for its ticket purchases.
The chain¶
A chain is a composite factory: it asks its factories in order and returns the first answer that
isn't Declined:
val chain = CompositeFirebaseEventTrackerFactory(
CheckoutFirebaseEventTrackerFactory(firebaseAnalytics), // feature factories first
ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics), // then Herald's screen views
GenericFirebaseEventTrackerFactory(firebaseAnalytics), // then everything else
)
final chain = CompositeFirebaseEventTrackerFactory([
CheckoutFirebaseEventTrackerFactory(firebaseAnalytics), // feature factories first
ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics), // then Herald's screen views
GenericFirebaseEventTrackerFactory(firebaseAnalytics), // then everything else
]);
const chain = new CompositeFirebaseEventTrackerFactory([
new CheckoutFirebaseEventTrackerFactory(firebaseAnalytics), // feature factories first
new ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics), // then Herald's screen views
new GenericFirebaseEventTrackerFactory(firebaseAnalytics), // then everything else
]);
When every factory declines, the chain declines too, and the event isn't sent to that vendor.
What happens to events without a factory¶
You don't write a factory for every event, so what happens to the others? You choose, per vendor, with the last factory in its list:
- Send it anyway. End the list with the vendor's generic factory, and every event without a factory of its own goes out under its own name. That suits Firebase, Mixpanel and Amplitude, where you want everything.
- Skip it. End the list with nothing, and only the events a factory handled are sent. That suits Adjust and AppsFlyer, where each event needs setting up in the vendor's dashboard first.
- Report it. End the list with the vendor's required-mapping factory, such as
RequireMappedFirebaseEventTrackerFactory, and an event nobody handled goes to your error reporter. Use it when every event must be sent on purpose, so a forgotten one shows up instead of disappearing.
// Firebase: events without a factory of their own are still sent, under their own name.
val firebase = CompositeFirebaseEventTrackerFactory(
firebaseFactories + GenericFirebaseEventTrackerFactory(firebaseAnalytics),
)
// Adjust: nothing at the end, so only events a factory handled are sent.
val adjustRules = CompositeAdjustEventTrackerFactory(adjustFactories)
// Firebase: events without a factory of their own are still sent, under their own name.
let firebase = CompositeFirebaseEventTrackerFactory(
firebaseFactories + [GenericFirebaseEventTrackerFactory()])
// Adjust: nothing at the end, so only events a factory handled are sent.
let adjust = CompositeAdjustEventTrackerFactory(adjustFactories)
// Firebase: events without a factory of their own are still sent, under their own name.
final firebase = CompositeFirebaseEventTrackerFactory([
...firebaseFactories,
GenericFirebaseEventTrackerFactory(firebaseAnalytics),
]);
// Adjust: nothing at the end, so only events a factory handled are sent.
final adjust = CompositeAdjustEventTrackerFactory(adjustFactories);
// Firebase: events without a factory of their own are still sent, under their own name.
const firebase = new CompositeFirebaseEventTrackerFactory([
...firebaseFactories,
new GenericFirebaseEventTrackerFactory(firebaseAnalytics),
]);
// Adjust: nothing at the end, so only events a factory handled are sent.
const adjust = new CompositeAdjustEventTrackerFactory(adjustFactories);
The required-mapping version looks like this:
A catch-all goes last
Generic and required-mapping factories answer for everything, so nothing after them is ever
asked. They're marked FallbackFactory, and every composite checks when it's built. A
fallback anywhere but last, or two of them, fails at start-up with the factory and its
position named.
Order matters¶
Apart from the last factory, the order is up to you:
- Specific before general. A feature's factory placed before Herald's screen-view factory can change how that feature's screen views are sent.
- Mixpanel's properties need
UserPropertyMixpanelPropertySetterFactorybeforeGenericMixpanelPropertySetterFactory. AUserPropertyis also aProperty, so the generic factory would otherwise claim it, and the people profile would never be written. - DI sets have no order. When features add factories through a DI set, place the factories that must come first or last, like the screen-view and generic ones, around the set by hand. See Modular apps.
Properties have chains too¶
Properties work the same way: property setter factories answer with a Resolution, a composite
asks them in order, and a generic or required-mapping factory can close the list. Dropped keeps
one property away from one vendor.
Next¶
- Routing: chains in practice, per vendor.
- Custom markers: your own marker types, with their own vendor calls.