Skip to content

Custom providers

A provider is anything that implements one or more of Herald's five interfaces. Herald's vendor modules build theirs from factory chains, but a provider can be much simpler.

Your own backend

To send events to your own collection endpoint, implement EventTrackerService:

class BackendAnalytics(private val api: EventsApi) : EventTrackerService {
    override suspend fun track(event: Event) {
        api.send(event.name, event.parameters.mapValues { (_, value) -> value.asString })
    }
}
struct BackendAnalytics: EventTrackerService {
    let api: any EventsAPI

    func track(_ event: any Event) {
        var parameters: [String: String] = [:]
        for (key, value) in event.parameters {
            parameters[key] = value.asString
        }
        // A network request is slow, so it runs in a Task and `track` returns at once. A failure
        // reported from that Task still reaches Herald's error reporter.
        Task {
            do {
                try await api.send(name: event.name, parameters: parameters)
            } catch {
                Herald.reportFailure(error)
            }
        }
    }
}
final class const BackendAnalytics(final EventsApi api) implements EventTrackerService {
  @override
  Future<void> track(Event event) => api.send(event.name, {
    for (final MapEntry(:key, :value) in event.parameters.entries) key: value.asString,
  });
}
export class BackendAnalytics implements EventTrackerService {
  constructor(private readonly api: EventsApi) {}

  track(event: Event): Promise<void> {
    const parameters: Record<string, string> = {};
    for (const [key, value] of Object.entries(parametersOf(event))) {
      parameters[key] = AnalyticsValue.asString(value);
    }
    return this.api.send(event.name, parameters);
  }
}

Register it like any vendor, with only the interfaces it implements. It's called with the others, and its failures are caught and reported like theirs.

iOS

Herald calls each provider on your thread and waits for it to return, so a provider must return quickly. Do slow work, such as this network request, in a Task, and report a failure there with Herald.reportFailure. Herald still knows which provider and call it belongs to. Don't report from a completion handler on another queue: Herald can't tell where that report came from.

Wrapping a provider

EventTrackerService has a single method, so wrapping one takes a few lines. That's the place for rules that depend on the situation rather than on the event: sampling, a runtime switch, skipping repeated events, keeping personal data on the device.

/** Only a share of events, for a vendor that bills per event. */
fun EventTrackerService.sampled(rate: Double, random: Random = Random.Default) =
    EventTrackerService { event -> if (random.nextDouble() < rate) track(event) }

/** Everything except what [excluded] matches. */
fun EventTrackerService.except(excluded: (Event) -> Boolean) =
    EventTrackerService { event -> if (!excluded(event)) track(event) }
/// Sends only some events, for a vendor that charges per event.
struct SampledEventTracker: EventTrackerService {
    let inner: any EventTrackerService
    let rate: Double

    func track(_ event: any Event) {
        if Double.random(in: 0..<1) < rate {
            inner.track(event)
        }
    }
}

struct ExceptEventTracker: EventTrackerService {
    let inner: any EventTrackerService
    let excluded: @Sendable (any Event) -> Bool

    func track(_ event: any Event) {
        if !excluded(event) {
            inner.track(event)
        }
    }
}

extension EventTrackerService {
    func sampled(_ rate: Double) -> any EventTrackerService {
        SampledEventTracker(inner: self, rate: rate)
    }

    func except(_ excluded: @escaping @Sendable (any Event) -> Bool) -> any EventTrackerService {
        ExceptEventTracker(inner: self, excluded: excluded)
    }
}
/// Sends only some events, for a vendor that charges per event.
final class SampledEventTracker(
  final EventTrackerService inner,
  final double rate, [
  Random? random,
]) implements EventTrackerService {
  final Random _random = random ?? Random();

  @override
  Future<void> track(Event event) async {
    if (_random.nextDouble() < rate) await inner.track(event);
  }
}

final class const ExceptEventTracker(
  final EventTrackerService inner,
  final bool Function(Event event) excluded,
) implements EventTrackerService {
  @override
  Future<void> track(Event event) async {
    if (!excluded(event)) await inner.track(event);
  }
}

extension EventTrackerDecorators on EventTrackerService {
  EventTrackerService sampled(double rate) => SampledEventTracker(this, rate);

  EventTrackerService except(bool Function(Event event) excluded) =>
      ExceptEventTracker(this, excluded);
}
/** Sends only some events, for a vendor that charges per event. */
export class SampledEventTracker implements EventTrackerService {
  constructor(
    private readonly inner: EventTrackerService,
    private readonly rate: number,
    private readonly random: () => number = Math.random,
  ) {}

  async track(event: Event): Promise<void> {
    if (this.random() < this.rate) await this.inner.track(event);
  }
}

export class ExceptEventTracker implements EventTrackerService {
  constructor(
    private readonly inner: EventTrackerService,
    private readonly excluded: (event: Event) => boolean,
  ) {}

  async track(event: Event): Promise<void> {
    if (!this.excluded(event)) await this.inner.track(event);
  }
}

Then register the wrapped tracker instead of the original:

val herald = Herald {
    provider(name = "backend", events = BackendAnalytics(api).except { it is PiiEvent })
    provider(name = "adjust", events = adjustTracker.sampled(rate = 0.1))
}
let herald = Herald(providers: [
    HeraldProvider(
        name: "backend",
        events: BackendAnalytics(api: api).except { event in event is any PersonalDataEvent }
    ),
    HeraldProvider(name: "adjust", events: adjustTracker.sampled(0.1)),
])
final herald = Herald(
  providers: [
    HeraldProvider(
      name: 'backend',
      events: BackendAnalytics(api).except((event) => event is PiiEvent),
    ),
    HeraldProvider(name: 'adjust', events: adjustTracker.sampled(0.1)),
  ],
);
const herald = new Herald({
  providers: [
    new HeraldProvider({
      name: 'backend',
      events: new ExceptEventTracker(new BackendAnalytics(api), isPiiEvent),
    }),
    new HeraldProvider({ name: 'adjust', events: new SampledEventTracker(adjustTracker, 0.1) }),
  ],
});

Herald has no filtering or sampling of its own, on purpose. Wrappers can be combined, work with any provider, and need nothing new from Herald.

A full vendor module

For a vendor you'll use seriously, with its own types, consent and identity, write a module shaped like Herald's own:

  • factory interfaces, and composites that check the generic factory comes last;
  • a generic factory and a required-mapping factory, like GenericFirebaseEventTrackerFactory and RequireMappedFirebaseEventTrackerFactory;
  • one tracker per distinct vendor call;
  • services for lifecycle, identity and consent.

Each SDK's checklist for a new vendor lists every piece: Android, Flutter, React Native. On iOS, copy one of the vendor packages, such as herald-ios-mixpanel. They're written for vendors added to Herald, but work the same for one that lives in your app.