Pantrix UI

Connecting events to analytics

Which events Pantrix's components report, what an event carries and what it never carries, and how an app connects these events to its own analytics product (Firebase, Amplitude, PostHog…). The problem is not Pantrix's own: every Compose Multiplatform app with analytics writes a class that turns the interface's events into the events that go to the product, and the real decisions are in that class.

Everything said about the components and the sample was seen in tests that run in this repository, or on devices. The products' calls were read in their own documentation and were not run here. Which was seen where is written in section 7.

1. The problem

A component library says "this button was pressed". What goes to the product is the app's decision:

If these decisions are not made in one place, every screen sends its own event under its own name, personal keys leak into the product, and a network outage leaves gaps in the reports.

2. Pantrix's events

The event

Every component gives what the user did to the theme's analytics as a PantrixUiEvent:

FieldWhatExample
componentThe name the component reports underButton, SegmentButton, Chart
actionWhat happenedclick, select, toggle, open
testTagThe component's automation tagCheckout.Button_pay
screenThe TestScope chain, that is, the screen's nameCheckout
propsValues from a closed setvariant=Solid, segment=express
PantrixTheme(analytics = PantrixAnalytics { event -> /* give it to the connector */ }) {
    App()
}

Identity: the tag and the screen

What never goes into an event

The three kinds of properties

KindKeysPersonal?
Pantrix's words and true/false values (20)action, checked, collapsed, copied, destructive, direction, duration, external, folded, format, part, reselected, revealed, selected, shown, state, to, tone, variant, viaNo: their values are the library's own words (Solid, back, true)
Numbers (8)block, cell, count, index, level, rejected, rows, sizeNo
The app's keys (14)row, column, option, node, series, slice, stage, task, tile, destination, section, page, command, segmentThey can be: each is the key the app gives a thing

page is of two kinds. In PantrixCarousel, PantrixPagination and PantrixPageJump it is a number, while in PantrixBreadcrumb it is the key the app gives the page. The sample therefore does not count it among Pantrix's values.

The components that send the app's keys:

KeyComponentIts value
row, columnPantrixDataTableThe row's and the column's key
optionPantrixSelect, PantrixMultiSelectThe option's key
nodePantrixTreeView, PantrixSankeyChartThe node's key; in a Sankey, a flow is source>target
seriesThe charts with axes, PantrixScatterChart, PantrixRadarChart, PantrixPolarChartThe series' key
slicePantrixPieChartThe slice's key
stagePantrixFunnelChartThe stage's key
taskPantrixGanttChartThe task's key
tilePantrixTreemapThe tile's key
destinationPantrixNavigationBar, PantrixNavigationRail, PantrixSidebarThe destination's key
sectionPantrixAccordion, PantrixSidebarThe section's key
pagePantrixBreadcrumbThe page's key
commandPantrixCommandPaletteThe command's key
segmentPantrixSegmentButtonThe option's tagKey, or else its index

The library always gives these keys to the theme's analytics; whether they go to the product is the connector's decision. In the components' tests the values of row were people's names (ada, grace): every event of a table that keys its users by name carries that name.

Events that come without the user doing anything

ComponentEventWhen
PantrixCarouselchange, via=autoWhen it turns by itself
PantrixSnackbarHostdismiss, via=timeoutWhen its time runs out
PantrixElasticDatePickerautoRefreshWhen it refreshes by itself
PantrixDialog, PantrixSheet, PantrixMenu, PantrixPopover, PantrixCommandPaletteopenWhen the program opens it, too
PantrixPageScaffold, PantrixLazyPageScaffoldshowEach time a page with a tagKey comes into the composition: a screen's view

A connector that does not want to count these as the user's interaction looks at via, or gives them a separate name in its plan.

The components' events

The table was written from the events seen in the components' tests and from their sources. The "Name" column is the event's component. The components not in the table do not report events themselves; the components inside them report their own: PantrixTopBar's back button (PantrixBackButton) is a PantrixIconButton, and reports that one's event.

ComponentNameactionprops
PantrixAccordionAccordionexpand, collapsesection
PantrixAlertAlertdismisstone, variant
PantrixBreadcrumbBreadcrumbselectpage (a key), folded
PantrixButtonButtonclickvariant
PantrixCardCardclickvariant
PantrixCarouselCarouselchange, pause, playpage (a number), via
PantrixChart, PantrixLineChart, PantrixBarChart, PantrixAreaChart, PantrixRangeChart, PantrixWaterfallChart, PantrixCandlestickChartChartselect, toggle, zoom, exportseries, shown, to, format
PantrixCheckbox, PantrixTriStateCheckboxCheckboxtogglestate
PantrixChipChipclick, toggle, removeselected
PantrixCodeBlockCodeBlockcopycopied
PantrixCommandPaletteCommandPaletteopen, run, dismisscommand, via
PantrixDataTableDataTableselect, selectAll, sort, expand, collapse, openrow, column, selected, rows, direction
PantrixDatePickerDatePickerselect, yearspart
PantrixDateTextField, PantrixDateFilledTextField, PantrixTimeTextField, PantrixTimeFilledTextFieldThe component's name (DateTextField…)focus, open, select—
PantrixDialogDialogopen, dismissvia
PantrixElasticDatePickerElasticDatePickeropen, select, refresh, autoRefresh—
PantrixFileDropFileDropbrowse, drop, filescount, rejected, via
PantrixFunnelChartFunnelselect, exportstage, format
PantrixGanttChartGanttselect, zoom, exporttask, to, format
PantrixGauge, PantrixMeterGroup, PantrixProgressCircle, PantrixSparklineThe component's name (Gauge…)exportformat
PantrixHeatmapHeatmapselect, exportcell, format
PantrixIconButtonIconButtonclickvariant
PantrixJsonViewJsonViewexpand, collapse, copylevel, copied
PantrixLinkLinkclickexternal
PantrixMenuMenu; its item MenuItemopen, dismiss; the item: clickthe item: destructive
PantrixMultiSelectMultiSelect; its search field MultiSelectSearchopen, toggle; the search: focus, clearoption, selected
PantrixNavigationBar, PantrixNavigationRailThe component's nameselectdestination, reselected
PantrixPageJumpPageJumpfocus, selectpage (a number)
PantrixPageScaffold, PantrixLazyPageScaffoldPageScaffoldshow—
PantrixPageSizePageSizeopen, selectsize
PantrixPaginationPaginationselectpage (a number), via
PantrixPieChartPieChartselect, toggle, exportslice, shown, format
PantrixPolarChart, PantrixRadarChartThe component's nameselect, toggle, exportseries, shown, format
PantrixPopoverPopoveropen, dismiss—
PantrixRadioButtonRadioButtonselect—
PantrixRadioGroupRadioGroupselectindex
PantrixSankeyChartSankeyselect, exportnode, format
PantrixScatterChartScatterChartselect, toggle, zoom, exportseries, shown, to, format
PantrixSecureTextField, PantrixFilledSecureTextFieldThe component's namefocus, clear, togglerevealed
PantrixSegmentButtonSegmentButtonselectsegment
PantrixSelectSelect; its search field SelectSearchopen, select; the search: focus, clearoption
PantrixSheetSheetopen, dismissvia
PantrixSidebarSidebarselect, expand, collapsedestination, section, collapsed
PantrixSnackbarHostSnackbaropen, action, dismisstone, duration, action, via
PantrixStatusTrackerStatusTrackerselect, exportblock, format
PantrixSwitchButtonSwitchButtontogglechecked
PantrixTabsTabsselectindex, via
PantrixTextField, PantrixFilledTextField, PantrixAutoResizeTextField, PantrixFilledAutoResizeTextFieldThe component's namefocus, clear—
PantrixTimePickerTimePickerselectpart
PantrixTreemapTreemapselect, exporttile, format
PantrixTreeViewTreeViewselect, expand, collapse, check, unchecknode

3. The connector

The connector is the PantrixAnalytics given to the theme: it takes Pantrix's events and builds the events that go to the product itself. The sample's connector is ProductAnalytics, and all the decisions are gathered in its constructor:

@Composable
fun App() {
    val scope = rememberCoroutineScope()
    val analytics = remember(scope) {
        ProductAnalytics(
            plan = CHECKOUT_PLAN,            // which event under which name, with which values
            transport = productTransport,    // the one call that gives to the product (section 4)
            clock = Clock.System,            // the moment the event happened
            scope = scope,                   // the timer and the sending stop with the screen
            policy = SendPolicy()            // batches of 20, 30 seconds, a queue of 500 events
        )
    }
    // On going to the background the waiting events are sent: there may be no other chance.
    LifecycleEventEffect(Lifecycle.Event.ON_STOP) { scope.launch { analytics.flush() } }
    PantrixTheme(analytics = analytics, testTagPolicy = TestTagPolicy.Off) {
        CheckoutScreen()
    }
}

The tracking plan

val CHECKOUT_PLAN = TrackingPlan(
    listOf(
        PlannedEvent(pantrixTestTagOf("IconButton", "more", "Checkout"), "click", "quantity_increased"),
        PlannedEvent(
            tag = pantrixTestTagOf("SegmentButton", "shipping", "Checkout"),
            action = "select",
            name = "shipping_chosen",
            notPersonal = setOf("segment")
        ),
        PlannedEvent(
            tag = pantrixTestTagOf("Button", "pay", "Checkout"),
            action = "click",
            name = "checkout_pay_tapped",
            keep = setOf("variant")
        )
    )
)

The general event

Every event the plan does not name goes as a single ui_interaction, and carries these: ui_component, ui_action, ui_screen, ui_tag (only if it was built from a key), and Pantrix's words and numbers. The app's keys and the tags built from the place of the call do not go in.

There are two reasons for a single general event. Products limit the separate event names (500 in Firebase), and a name for each component would share that limit with the plan's names. And in the product's reports only the plan's names show in the product's language; the rest is filtered in one place.

Consent has three values: Unknown, Granted, Denied. Until it is Granted no event enters the queue, and each is entered in the log as "not collected". When consent is taken back, the waiting events are thrown away and the timers stop. A batch on its way at that moment, if it fails, is not tried again but thrown away. The product's own SDK has its consent too, and the two must change together (section 4).

Sending and time

With no network

4. Connecting to the product

The connector's one point of contact with the product is Transport:

fun interface Transport {
    /** Gives [batch] to the product: true if it arrived. */
    suspend fun send(batch: List<ProductEvent>): Boolean
}

A ProductEvent has a name (name), properties (properties, all String) and a time (at). If the sender returns false or throws an error, the batch waits and is tried again.

SDKs with a queue of their own

The three SDKs below all gather the events themselves and send them batch by batch. The connector's queue is then a second queue. The recommended setup: keep the plan and the consent in the connector, give every event to the SDK at once (SendPolicy(batch = 1)), and leave the gathering, the retrying and the sending in the background to the SDK. The sender returns true when the SDK has taken the event.

Firebase (Android)

val transport = Transport { batch ->
    batch.forEach { event ->
        Firebase.analytics.logEvent(event.name) {
            event.properties.forEach { (key, value) -> param(key, value) }
        }
    }
    true
}

Amplitude (Android, Kotlin SDK)

val amplitude = Amplitude(AMPLITUDE_API_KEY, applicationContext) {
    flushQueueSize = 20          // its default is 30
    flushIntervalMillis = 30000  // its default is 30000
}
val transport = Transport { batch ->
    batch.forEach { event ->
        amplitude.track(event.name, mutableMapOf<String, Any?>().apply { putAll(event.properties) })
    }
    true
}

PostHog (Android)

PostHogAndroid.setup(context, PostHogAndroidConfig(apiKey = POSTHOG_API_KEY, host = POSTHOG_HOST))
val transport = Transport { batch ->
    batch.forEach { event -> PostHog.capture(event = event.name, properties = event.properties) }
    true
}

The other platforms

The products' iOS and web SDKs are written in Swift and in JavaScript. Common code calls them in two ways:

5. Testing

6. Without the library

The one part of the connector bound to Pantrix is track(event: PantrixUiEvent), which takes the event, and the four fields the plan reads from that event (component, action, testTag, screen), with props. An app that takes events from another source writes productEventOf with its own event type. The queue, the consent, the sending and Transport stay as they are: the sample's files ProductAnalytics.kt, SendPolicy.kt, Transport.kt and Consent.kt are not bound to Compose either.

7. What was verified and what was not

Verified

Read, not run