Routing¶
Which events reach which vendor is decided in one place: each vendor's
factory chain. An event reaches a vendor when a factory in that
vendor's chain takes it, and not otherwise. Herald itself never filters.
So every routing decision sits next to the events it's about, and the code that sets up Herald never needs to know what your features track.
Two kinds of vendor¶
Most apps use vendors in two ways, and the end of each chain is what makes the difference:
- Vendors that get everything, for product analytics: usually Firebase, Amplitude or Mixpanel. Their chain ends with the generic factory, which sends any event under its own name.
- Vendors that get a few chosen events, usually attribution and marketing vendors such as Adjust and AppsFlyer. Their chain ends with nothing, so an event no factory took isn't sent.
// Firebase gets everything: events no factory took are sent under their own name.
val firebaseEvents = CompositeFirebaseEventTrackerFactory(
featureFirebaseFactories +
ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics) +
GenericFirebaseEventTrackerFactory(firebaseAnalytics), // everything else
)
// Adjust gets only events with a dashboard token.
val adjustEvents = CompositeAdjustEventTrackerFactory(
TokenAdjustEventTrackerFactory(listOf(AdjustToken<CheckoutCompleted>("abc123")), adjust),
) // nothing at the end: other events aren't sent
// Firebase gets everything: events no factory took are sent under their own name.
let firebaseEvents = CompositeFirebaseEventTrackerFactory(
featureFirebaseFactories + [
ScreenViewFirebaseEventTrackerFactory(),
GenericFirebaseEventTrackerFactory(), // everything else
])
// Adjust gets only events with a dashboard token.
let adjustEvents = CompositeAdjustEventTrackerFactory([
TokenAdjustEventTrackerFactory(tokens: [AdjustToken(CheckoutCompleted.self, "abc123")])
]) // nothing at the end: other events aren't sent
// Firebase gets everything: events no factory took are sent under their own name.
final firebaseEvents = CompositeFirebaseEventTrackerFactory([
...featureFirebaseFactories,
ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics),
GenericFirebaseEventTrackerFactory(firebaseAnalytics), // everything else
]);
// Adjust gets only events with a dashboard token.
final adjustEvents = CompositeAdjustEventTrackerFactory([
TokenAdjustEventTrackerFactory([AdjustToken<CheckoutCompleted>('abc123')]),
]); // nothing at the end: other events aren't sent
// Firebase gets everything: events no factory took are sent under their own name.
const firebaseEvents = new CompositeFirebaseEventTrackerFactory([
...featureFirebaseFactories,
new ScreenViewFirebaseEventTrackerFactory(firebaseAnalytics),
new GenericFirebaseEventTrackerFactory(firebaseAnalytics), // everything else
]);
// Adjust gets only events with a dashboard token.
const adjustEvents = new CompositeAdjustEventTrackerFactory([
new TokenAdjustEventTrackerFactory([new AdjustToken(CheckoutCompleted, 'abc123')]),
]); // nothing at the end: other events aren't sent
Adjust has no generic factory at all, because an Adjust event only exists with a token from the Adjust dashboard. So Adjust always works the second way.
Following one event¶
With those two chains, analytics.track(CheckoutStarted) goes like this:
| Firebase chain | Adjust chain |
|---|---|
feature factories: CheckoutStarted isn't theirs, so each declines |
TokenAdjustEventTrackerFactory: no token for CheckoutStarted, so it declines |
ScreenViewFirebaseEventTrackerFactory: not a screen view, so it declines |
end of the chain, so it's not sent |
GenericFirebaseEventTrackerFactory: takes it, and it's sent under its own name |
No shared configuration was involved, and nothing outside the checkout feature mentions
CheckoutStarted.
Keeping one event away from one vendor¶
In a chain that ends with the generic factory, every event is sent unless a factory takes it first. To keep one out, take it and answer Dropped, in the factory of the feature that owns the event:
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
}
}
CardNumberSeen is dropped, so the generic factory never sees it. The rule lives right beside the
event.
Properties work the same way:
Requiring every event to be mapped¶
If a chain ends with the vendor's required-mapping factory, such as
RequireMappedAdjustEventTrackerFactory, every event no factory took is reported to your error
reporter as an UnhandledEventException. Use it for a vendor where every event must be sent on
purpose.
Know what it costs first. Every module that tracks an event or sets a property must then answer
for it in that vendor's chain, the app module included. A property set on the home screen with no
factory is reported as a failure on every call, instead of being quietly skipped. To say "this one
isn't for that vendor", add a factory that answers Dropped.
When a factory can't express it¶
Factories route by event. To route by situation, such as a runtime flag, sampling or a group of users, wrap the vendor's tracker instead. See Custom providers.
To apply a rule to every vendor at once, wrap Herald itself: it's an EventTrackerService too.