Skip to content

Analytics

Waypoint emits five lifecycle events for every tour, so you can track how users engage with onboarding and feature tours. Implement WaypointAnalytics and hand it to rememberWaypointState, and Waypoint calls your implementation at the right moments.

The WaypointAnalytics interface

public interface WaypointAnalytics {
    public fun onTourStarted(tourId: String?, totalSteps: Int) {}
    public fun onTourCompleted(tourId: String?, totalSteps: Int) {}
    public fun onTourCancelled(tourId: String?, stepIndex: Int, totalSteps: Int) {}
    public fun onStepViewed(tourId: String?, stepIndex: Int, targetKey: Any?) {}
    public fun onStepCompleted(tourId: String?, stepIndex: Int, targetKey: Any?) {}
}

Every method has a default no-op body, so you only need to override the ones you care about.

Events

Event When it fires Parameters
onTourStarted state.start() accepts and the first step is entered. tourId, totalSteps
onTourCompleted User advances past the last step. tourId, totalSteps
onTourCancelled User skips, closes, or state.stop() is called mid-tour. tourId, stepIndex (where they stopped), totalSteps
onStepViewed A step becomes active (including the first step, and every transition). tourId, stepIndex, targetKey
onStepCompleted User advances off a step, or the tour completes on that step. tourId, stepIndex, targetKey

Note

onStepCompleted also fires for the last step just before onTourCompleted. You'll see one completion event per step the user actually finished. If the user cancels mid-step, onStepCompleted is not called for that step, but onTourCancelled carries the stepIndex so you know where they stopped.

Wiring it up

Pass analytics and tourId to rememberWaypointState. Without a tourId, events still fire but their tourId parameter is null, which makes them hard to attribute in a real analytics backend.

val tourState = rememberWaypointState(
    tourId = "onboarding_v2",
    analytics = ConsoleAnalytics,
) {
    step(Targets.Search) { title = "Search"; description = "Find anything." }
    step(Targets.Add) { title = "Create"; description = "Make something." }
    step(Targets.Profile) { title = "Profile"; description = "You." }
}

Example: console analytics

A minimal implementation that prints every event, useful during development:

object ConsoleAnalytics : WaypointAnalytics {
    override fun onTourStarted(tourId: String?, totalSteps: Int) {
        println("[tour] started id=$tourId steps=$totalSteps")
    }

    override fun onTourCompleted(tourId: String?, totalSteps: Int) {
        println("[tour] completed id=$tourId steps=$totalSteps")
    }

    override fun onTourCancelled(tourId: String?, stepIndex: Int, totalSteps: Int) {
        println("[tour] cancelled id=$tourId at=$stepIndex/$totalSteps")
    }

    override fun onStepViewed(tourId: String?, stepIndex: Int, targetKey: Any?) {
        println("[tour] step viewed id=$tourId index=$stepIndex target=$targetKey")
    }

    override fun onStepCompleted(tourId: String?, stepIndex: Int, targetKey: Any?) {
        println("[tour] step completed id=$tourId index=$stepIndex target=$targetKey")
    }
}

Example: Firebase

class FirebaseWaypointAnalytics(
    private val firebase: FirebaseAnalytics,
) : WaypointAnalytics {

    override fun onTourStarted(tourId: String?, totalSteps: Int) {
        firebase.logEvent("tour_started") {
            param("tour_id", tourId ?: "unknown")
            param("total_steps", totalSteps.toLong())
        }
    }

    override fun onTourCompleted(tourId: String?, totalSteps: Int) {
        firebase.logEvent("tour_completed") {
            param("tour_id", tourId ?: "unknown")
            param("total_steps", totalSteps.toLong())
        }
    }

    override fun onTourCancelled(tourId: String?, stepIndex: Int, totalSteps: Int) {
        firebase.logEvent("tour_cancelled") {
            param("tour_id", tourId ?: "unknown")
            param("step_index", stepIndex.toLong())
            param("total_steps", totalSteps.toLong())
            param("completion_pct", (stepIndex.toDouble() / totalSteps * 100).toLong())
        }
    }

    override fun onStepViewed(tourId: String?, stepIndex: Int, targetKey: Any?) {
        firebase.logEvent("tour_step_viewed") {
            param("tour_id", tourId ?: "unknown")
            param("step_index", stepIndex.toLong())
            param("step_key", targetKey?.toString() ?: "unknown")
        }
    }

    override fun onStepCompleted(tourId: String?, stepIndex: Int, targetKey: Any?) {
        firebase.logEvent("tour_step_completed") {
            param("tour_id", tourId ?: "unknown")
            param("step_index", stepIndex.toLong())
            param("step_key", targetKey?.toString() ?: "unknown")
        }
    }
}

Example: Mixpanel / PostHog / custom

Any analytics backend with a "track event" function works. Map each callback to a single event with the relevant properties.

class MixpanelWaypointAnalytics(
    private val mixpanel: MixpanelAPI,
) : WaypointAnalytics {

    override fun onTourStarted(tourId: String?, totalSteps: Int) {
        mixpanel.track("Tour Started", buildJsonObject {
            put("tour_id", tourId)
            put("total_steps", totalSteps)
        })
    }
    // ... other methods
}

Platform considerations

WaypointAnalytics is a plain Kotlin interface, so it's available on every Waypoint target (Android, iOS, Desktop, Web). The concrete backend you pick needs to support your target platforms, this is a per-backend concern, not a Waypoint concern.

A common pattern on KMP projects is to expect/actual the analytics implementation and have each platform wire up its own SDK:

// commonMain
expect fun platformAnalytics(): WaypointAnalytics

// androidMain
actual fun platformAnalytics(): WaypointAnalytics = FirebaseWaypointAnalytics(Firebase.analytics)

// iosMain
actual fun platformAnalytics(): WaypointAnalytics = AmplitudeWaypointAnalytics(...)

Interaction with host callbacks

WaypointHost (and WaypointMaterial3Host) also accept onTourComplete and onTourCancel callbacks. These fire at the same moments as onTourCompleted / onTourCancelled on the analytics implementation.

Use case Prefer
Send an analytics event WaypointAnalytics
Update UI state (hide a banner, show a toast) Host callback
Both Use both, they don't conflict
WaypointMaterial3Host(
    state = tourState,
    onTourComplete = { showToast("Tour complete, welcome aboard.") },
    onTourCancel = { showBanner("Take the tour any time from Settings.") },
) {
    MyScreen()
}

See also