Skip to content

Migrating an app

Most apps that adopt Herald already call a vendor SDK from many places, directly or through their own wrapper. Moving everything at once is risky, so move one step at a time. You can release the app after every step.

1. Put Herald in front of the old path

Add Herald with a single provider that passes every call to the wrapper you already have. Nothing is sent differently yet; you've only added the new way in.

// Step 1: Herald forwards to the old wrapper, so migrated and unmigrated code report the same.
val herald = Herald {
    provider(
        name = "legacy",
        events = EventTrackerService { event ->
            legacy.logEvent(event.name, event.parameters.mapValues { (_, value) -> value.asString })
        },
    )
}
// Step 1: Herald forwards to the old wrapper, so migrated and unmigrated code report the same.
struct LegacyEventTracker: EventTrackerService {
    let legacy: LegacyAnalytics

    func track(_ event: any Event) {
        var params: [String: String] = [:]
        for (key, value) in event.parameters {
            params[key] = value.asString
        }
        legacy.logEvent(name: event.name, params: params)
    }
}

func migratingHerald(legacy: LegacyAnalytics) -> Herald {
    Herald(providers: [HeraldProvider(name: "legacy", events: LegacyEventTracker(legacy: legacy))])
}
// Step 1: Herald forwards to the old wrapper, so migrated and unmigrated code report the same.
final class const LegacyEventTracker(final LegacyAnalytics legacy) implements EventTrackerService {
  @override
  Future<void> track(Event event) => legacy.logEvent(event.name, {
    for (final MapEntry(:key, :value) in event.parameters.entries) key: value.asString,
  });
}

Herald migratingHerald(LegacyAnalytics legacy) => Herald(
  providers: [HeraldProvider(name: 'legacy', events: LegacyEventTracker(legacy))],
);
// Step 1: Herald forwards to the old wrapper, so migrated and unmigrated code report the same.
export class LegacyEventTracker implements EventTrackerService {
  constructor(private readonly legacy: LegacyAnalytics) {}

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

export function migratingHerald(legacy: LegacyAnalytics): Herald {
  return new Herald({
    providers: [new HeraldProvider({ name: 'legacy', events: new LegacyEventTracker(legacy) })],
  });
}

Give Herald to your classes through its interfaces, as in Set up your app. In debug builds, add the log provider to see every call in your log.

2. Move call sites to events, feature by feature

Replace direct calls with an event class and EventTrackerService, one feature at a time:

class CheckoutViewModel(private val analytics: LegacyAnalytics) {
    fun onCheckout(plan: String, seats: Int) =
        analytics.logEvent("checkout_started", mapOf("plan" to plan, "seats" to "$seats"))
}
class CheckoutViewModel(private val analytics: EventTrackerService) {
    suspend fun onCheckout(plan: String, seats: Int) {
        analytics.track(CheckoutStarted(plan, seats))
    }
}
final class CheckoutViewModel {
    private let analytics: LegacyAnalytics

    init(analytics: LegacyAnalytics) {
        self.analytics = analytics
    }

    func onCheckout(plan: String, seats: Int) {
        analytics.logEvent(
            name: "checkout_started", params: ["plan": plan, "seats": "\(seats)"])
    }
}
final class CheckoutViewModel {
    private let analytics: any EventTrackerService

    init(analytics: any EventTrackerService) {
        self.analytics = analytics
    }

    func onCheckout(plan: String, seats: Int) {
        analytics.track(CheckoutStarted(plan: plan, seats: seats))
    }
}
class CheckoutViewModel(final LegacyAnalytics analytics) {
  Future<void> onCheckout(String plan, int seats) =>
      analytics.logEvent('checkout_started', {'plan': plan, 'seats': '$seats'});
}
class CheckoutViewModel(final EventTrackerService analytics) {
  Future<void> onCheckout(String plan, int seats) => analytics.track(CheckoutStarted(plan, seats));
}
class CheckoutViewModel {
  constructor(private readonly analytics: LegacyAnalytics) {}

  onCheckout(plan: string, seats: number): Promise<void> {
    return this.analytics.logEvent('checkout_started', { plan, seats: String(seats) });
  }
}
export class CheckoutViewModel {
  constructor(private readonly analytics: EventTrackerService) {}

  onCheckout(plan: string, seats: number): Promise<void> {
    return this.analytics.track(new CheckoutStarted(plan, seats));
  }
}

Moved and unmoved code still go through the same wrapper, so dashboards don't change. Each moved feature's tests can now use FakeAnalyticsProvider.

3. Move vendors to Herald's vendor modules

Once your code calls Herald, move vendors over one at a time: register the vendor's Herald module, and remove that vendor from the old wrapper.

  • Start with the simplest vendor, usually the one that gets every event, like Firebase. Its generic factory sends each event under its own name, as your wrapper did.
  • Compare before and after with the log provider, or the vendor's own debug view.
  • Move special cases into factories as you go. Tokens, special events and revenue each go to the feature that owns the event.

Move sign-in, sign-out and the consent switch to IdentifiableUserService and ConsentService. Check the before-consent table: Herald's Adjust and AppsFlyer modules keep those vendors quiet until consent, which may differ from what your wrapper did.

5. Remove the bridge

When the wrapper has no vendors left, delete it and the provider that called it. From then on, adding or removing a vendor only changes the code that sets up Herald.