Skip to content

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:

class AppAdjustPropertySetterFactory : AdjustPropertySetterFactory {
    override fun create(property: Property): Resolution<AdjustPropertySetter> = when (property) {
        is AppTheme -> Resolution.Dropped   // a UI preference, not an attribution signal
        else -> Resolution.Declined
    }
}
struct AppAdjustPropertySetterFactory: AdjustPropertySetterFactory {
    func create(_ property: any Property) -> Resolution<any AdjustPropertySetter> {
        if property is AppTheme {
            return .dropped  // a UI preference, not an attribution signal
        }
        return .declined
    }
}
final class const AppAdjustPropertySetterFactory() implements AdjustPropertySetterFactory {
  @override
  Resolution<AdjustPropertySetter> create(Property property) => switch (property) {
    AppTheme() => .dropped(), // a UI preference, not an attribution signal
    _ => .declined(),
  };
}
export class AppAdjustPropertySetterFactory implements AdjustPropertySetterFactory {
  create(property: Property): Resolution<AdjustPropertySetter> {
    if (property instanceof AppTheme) {
      return Resolution.dropped(); // a UI preference, not an attribution signal
    }
    return Resolution.declined();
  }
}

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.