Skip to content

Custom markers

A marker is an interface that says what an event means. Herald ships two, ScreenViewEvent and UserProperty, and nothing tied to a type of business. You can write your own, and it takes three things:

  • the marker interface;
  • a tracker for each vendor that should treat it differently;
  • a factory for it in that vendor's chain.

There are two good reasons to write one:

  1. A vendor has a special API for your concept. Your app knows what a refund is, and GA4 has its own refund event.
  2. You want a rule the compiler enforces, such as how event names are built.

A marker a vendor API needs

/** Money returned to a customer. Your app's concept, so your app's marker. */
interface RefundEvent : Event {
    val amount: Double
    val currency: String
    val transactionId: String
}

data class TicketRefunded(
    override val amount: Double,
    override val currency: String,
    override val transactionId: String,
) : RefundEvent {
    override val name = "ticket_refunded"
}
/// Money returned to a customer. Your app's idea, so your app's marker.
protocol RefundEvent: Event {
    var amount: Double { get }
    var currency: String { get }
    var transactionId: String { get }
}

struct TicketRefunded: RefundEvent {
    let amount: Double
    let currency: String
    let transactionId: String

    var name: String { "ticket_refunded" }
}
/// Money returned to a customer. Your app's idea, so your app's marker.
abstract interface class RefundEvent implements Event {
  double get amount;

  String get currency;

  String get transactionId;
}

final class const TicketRefunded({
  @override required final double amount,
  @override required final String currency,
  @override required final String transactionId,
}) extends Event implements RefundEvent {
  @override
  String get name => 'ticket_refunded';
}
/** Money returned to a customer. Your app's idea, so your app's marker. */
export interface RefundEvent extends Event {
  readonly isRefund: true;
  readonly amount: number;
  readonly currency: string;
  readonly transactionId: string;
}

// TypeScript interfaces are gone at run time, so the marker is a field, checked here.
export function isRefundEvent(event: Event): event is RefundEvent {
  return 'isRefund' in event && event.isRefund === true;
}

export class TicketRefunded implements RefundEvent {
  readonly name = 'ticket_refunded';
  readonly isRefund = true;

  constructor(
    readonly amount: number,
    readonly currency: string,
    readonly transactionId: string,
  ) {}
}

Only vendors that treat a refund specially need a tracker. Here GA4 gets its refund event, and every other vendor still sends ticket_refunded through its generic factory.

class RefundFirebaseEventTracker(
    private val event: RefundEvent,
    private val firebaseAnalytics: FirebaseAnalytics,
) : FirebaseEventTracker {

    override suspend fun track() {
        val bundle = event.parameters.toBundle().apply {
            putString(FirebaseAnalytics.Param.TRANSACTION_ID, event.transactionId)
            putDouble(FirebaseAnalytics.Param.VALUE, event.amount)
            putString(FirebaseAnalytics.Param.CURRENCY, event.currency)
        }
        firebaseAnalytics.logEvent(FirebaseAnalytics.Event.REFUND, bundle) // GA4's reserved refund
    }
}

class RefundFirebaseEventTrackerFactory(
    private val firebaseAnalytics: FirebaseAnalytics,
) : FirebaseEventTrackerFactory {

    override fun create(event: Event): Resolution<FirebaseEventTracker> = when (event) {
        is RefundEvent -> Resolution.Claimed(RefundFirebaseEventTracker(event, firebaseAnalytics))
        else -> Resolution.Declined
    }
}
struct RefundFirebaseEventTracker: FirebaseEventTracker {
    let event: any RefundEvent

    func track() {
        var parameters = event.parameters.toFirebaseParameters()
        parameters["transaction_id"] = event.transactionId
        parameters["value"] = event.amount
        parameters["currency"] = event.currency
        Analytics.logEvent("refund", parameters: parameters)  // GA4's reserved refund
    }
}

struct RefundFirebaseEventTrackerFactory: FirebaseEventTrackerFactory {
    func create(_ event: any Event) -> Resolution<any FirebaseEventTracker> {
        if let refund = event as? any RefundEvent {
            return .claimed([RefundFirebaseEventTracker(event: refund)])
        }
        return .declined
    }
}
final class const RefundFirebaseEventTracker(
  final RefundEvent event,
  final FirebaseAnalytics firebaseAnalytics,
) implements FirebaseEventTracker {
  @override
  Future<void> track() => firebaseAnalytics.logEvent(
    name: 'refund', // GA4's reserved refund
    parameters: {
      ...event.parameters.toFirebaseParameters(),
      'transaction_id': event.transactionId,
      'value': event.amount,
      'currency': event.currency,
    },
  );
}

final class const RefundFirebaseEventTrackerFactory(final FirebaseAnalytics firebaseAnalytics)
    implements FirebaseEventTrackerFactory {
  @override
  Resolution<FirebaseEventTracker> create(Event event) => switch (event) {
    RefundEvent() => .claimed([RefundFirebaseEventTracker(event, firebaseAnalytics)]),
    _ => .declined(),
  };
}
export class RefundFirebaseEventTracker implements FirebaseEventTracker {
  constructor(
    private readonly event: RefundEvent,
    private readonly firebaseAnalytics: Analytics,
  ) {}

  async track(): Promise<void> {
    // GA4's reserved refund event.
    logEvent(this.firebaseAnalytics, 'refund', {
      ...toFirebaseParameters(parametersOf(this.event)),
      transaction_id: this.event.transactionId,
      value: this.event.amount,
      currency: this.event.currency,
    });
  }
}

export class RefundFirebaseEventTrackerFactory implements FirebaseEventTrackerFactory {
  constructor(private readonly firebaseAnalytics: Analytics) {}

  create(event: Event): Resolution<FirebaseEventTracker> {
    if (isRefundEvent(event)) {
      return Resolution.claimed([new RefundFirebaseEventTracker(event, this.firebaseAnalytics)]);
    }
    return Resolution.declined();
  }
}

Put the factory before Herald's own factories in the Firebase chain, so yours is asked first.

A parameter says what a value is; a marker says what the event means. A Double parameter named amount is just a number to a vendor module. RefundEvent is what tells the Firebase module to call a different API.

A marker for a naming rule

If every event name is built from where it happened, two modules can't end up using the same name, because nobody types names by hand. No vendor asks for this, which is exactly why it belongs in your app, not in Herald:

/** Names an event by where it happened, so two modules cannot pick the same name by accident. */
interface StructuredEvent : Event {
    val screen: String
    val component: String
    val action: String

    override val name: String
        get() = listOf(screen, component, action).filter { it.isNotBlank() }.joinToString("_")
}

data class CheckoutPayButtonTapped(
    override val screen: String = "checkout",
    override val component: String = "pay_button",
    override val action: String = "tap",
) : StructuredEvent
/// Names an event by where it happened, so two features can't pick the same name.
protocol StructuredEvent: Event {
    var screen: String { get }
    var component: String { get }
    var action: String { get }
}

extension StructuredEvent {
    var name: String {
        var parts: [String] = []
        for part in [screen, component, action] where !part.isEmpty {
            parts.append(part)
        }
        return parts.joined(separator: "_")
    }
}

struct CheckoutPayButtonTapped: StructuredEvent {
    var screen: String { "checkout" }
    var component: String { "pay_button" }
    var action: String { "tap" }
}
/// Names an event by where it happened, so two features can't pick the same name.
abstract class StructuredEvent extends Event {
  const StructuredEvent();

  String get screen;

  String get component;

  String get action;

  @override
  String get name => [screen, component, action].where((part) => part.isNotEmpty).join('_');
}

final class const CheckoutPayButtonTapped() extends StructuredEvent {
  @override
  String get screen => 'checkout';

  @override
  String get component => 'pay_button';

  @override
  String get action => 'tap';
}
/** Names an event by where it happened, so two features can't pick the same name. */
export abstract class StructuredEvent implements Event {
  abstract readonly screen: string;
  abstract readonly component: string;
  abstract readonly action: string;

  get name(): string {
    return [this.screen, this.component, this.action].filter((part) => part !== '').join('_');
  }
}

export class CheckoutPayButtonTapped extends StructuredEvent {
  readonly screen = 'checkout';
  readonly component = 'pay_button';
  readonly action = 'tap';
}

Because name is built for you, every vendor already gets the right name with no tracker at all: the generic factories send checkout_pay_button_tap.

Write a tracker only when you want the parts sent as separate parameters instead of joined into the name. That's where your app makes choices a library shouldn't make for it:

class StructuredFirebaseEventTracker(
    private val event: StructuredEvent,
    private val firebaseAnalytics: FirebaseAnalytics,
) : FirebaseEventTracker {

    override suspend fun track() {
        val bundle: Bundle = event.parameters.toBundle().apply {
            putString("screen", event.screen)   // your call: spread the structure across
            putString("action", event.action)   // parameters, or let the name carry it alone
        }
        firebaseAnalytics.logEvent(event.component, bundle)
    }
}
struct StructuredFirebaseEventTracker: FirebaseEventTracker {
    let event: any StructuredEvent

    func track() {
        var parameters = event.parameters.toFirebaseParameters()
        parameters["screen"] = event.screen  // your call: spread the structure across
        parameters["action"] = event.action  // parameters, or let the name carry it alone
        Analytics.logEvent(event.component, parameters: parameters)
    }
}
final class const StructuredFirebaseEventTracker(
  final StructuredEvent event,
  final FirebaseAnalytics firebaseAnalytics,
) implements FirebaseEventTracker {
  @override
  Future<void> track() => firebaseAnalytics.logEvent(
    name: event.component,
    parameters: {
      ...event.parameters.toFirebaseParameters(),
      'screen': event.screen, // your call: spread the structure across
      'action': event.action, // parameters, or let the name carry it alone
    },
  );
}
export class StructuredFirebaseEventTracker implements FirebaseEventTracker {
  constructor(
    private readonly event: StructuredEvent,
    private readonly firebaseAnalytics: Analytics,
  ) {}

  async track(): Promise<void> {
    logEvent(this.firebaseAnalytics, this.event.component, {
      ...toFirebaseParameters(parametersOf(this.event)),
      screen: this.event.screen, // your call: spread the structure across
      action: this.event.action, // parameters, or let the name carry it alone
    });
  }
}

GA4 allows 25 parameters per event and 40 characters per name, and the lines marked above are where your app trades one against the other. Choices like that are why this marker lives in your code, not in Herald's core.

A marker for list values

Herald's values are single values: text, whole numbers, decimals and true/false. There is no list, because most vendors can't take one: Firebase and Adjust accept single values only. When a vendor that can, such as Mixpanel or Amplitude, should receive a list, a marker carries it:

  • The marker, for example ListPropertiesEvent, adds a map from name to list, such as decks to ["animals", "movies"]. The lists stay out of parameters, so vendors that can't take them never see them.
  • A tracker for that vendor sends the parameters and the lists together. It throws if a list has the same name as a parameter, so neither silently replaces the other.
  • Its factory goes first in that vendor's chain.

Every other vendor sends the event through its generic factory, parameters only. The log provider prints only parameters too, so in tests check the lists on the event itself, in FakeAnalyticsProvider.events.

Markers are yours, not the vendors'

A marker is your concept, in your words: RefundEvent, not a Firebase type. Your events never implement a type from a vendor module.

The vendor modules do ship types in their vendor's terms, such as AppsFlyerPurchaseEvent and AmplitudeRevenueEvent. They're final classes your events can't implement or extend: the vendor module's trackers take them, and the vendor's factory maps your event to them. See Revenue and Events know no vendors.