Events and properties¶
Your app sends Herald two kinds of data:
- an event is something that happened, like a tap or a purchase;
- a property is something that stays true about the user or the session until it changes, like a subscription plan or a theme.
Both are small classes your app declares, in its own words.
Events¶
An Event has a name and, optionally, parameters. An event without parameters needs only its
name:
An event with details also has parameters, each with its own type:
data class PlanSelected(val plan: String, val seats: Int, val price: Double, val trial: Boolean) : Event {
override val name = "plan_selected"
override val parameters = parameters {
put("plan", plan) // AnalyticsValue.String
put("seats", seats) // AnalyticsValue.Int
put("price", price) // AnalyticsValue.Double
put("trial", trial) // AnalyticsValue.Boolean
}
}
final class const PlanSelected(
final String plan, {
required final int seats,
required final double price,
required final bool trial,
}) extends Event {
@override
String get name => 'plan_selected';
@override
Map<String, AnalyticsValue> get parameters => {
'plan': .string(plan), // AnalyticsString
'seats': .int(seats), // AnalyticsInt
'price': .double(price), // AnalyticsDouble
'trial': .bool(trial), // AnalyticsBool
};
}
export class PlanSelected implements Event {
readonly name = 'plan_selected';
constructor(
readonly plan: string,
readonly seats: number,
readonly price: number,
readonly trial: boolean,
) {}
get parameters(): EventParameters {
return {
plan: this.plan, // text
seats: int(this.seats), // a whole number
price: double(this.price), // a decimal number
trial: this.trial, // true or false
};
}
}
Give the class and the event name the same meaning. Your code and your factories recognise an event by its class, and vendors that simply log it see its name.
Values keep their type¶
Every parameter and property value keeps its type: text, whole number, decimal or true/false. In
Kotlin, the parameters { } builder takes the type from the value you pass, so you never name it
yourself. In Dart, you name it with a dot shorthand: .int(seats).
The type decides what a vendor's dashboard can do with the value, not just how it looks:
| Vendor | Receives |
|---|---|
| Firebase | whole numbers and decimals as numbers, so GA4 can sum and average them |
| Mixpanel, Amplitude, AppsFlyer | each value as its own JSON type |
| Adjust | text, because its callback parameters accept nothing else |
Whole numbers and decimals stay separate because they mean different things: 3 seats, a price of
3.0.
There is no list value, because Firebase and Adjust can't take one. For a vendor that can, send lists through a custom marker.
Kotlin
Values are AnalyticsValues: String, Int, Long, Float, Double or Boolean. Each is a
value class, so it costs no extra object. A Float reaches every vendor as the decimal you
wrote: 9.99f arrives as 9.99, not as 9.989999771118164, which is what a plain conversion
to Double would give.
Dart
Values are AnalyticsValues: .string, .int, .double or .bool. Dart has one whole-number
type and one decimal type, so four are enough.
A number doesn't say what it means
To a vendor module, an Int called amount is just a number. When a vendor needs to know a
value is revenue, your factory maps the event to that vendor's revenue type, which has a field
for it. See Revenue.
Screen views¶
Herald has two marker types: interfaces that tell vendor modules what an event or property means.
ScreenViewEvent is the first. Every app has screens, and some vendors have a special event for
them:
- GA4's
screen_view, with ascreen_nameparameter; - Amplitude's
[Amplitude] Screen Viewed.
Marking an event as a screen view lets those vendors receive it that way:
The event's name is the screen's name. Vendors with a special screen view event send it as the
screen name; vendors without one log it like any other event. To show a different name in one
vendor's screen reports, put a factory for that event before the vendor's screen-view factory.
Properties¶
A Property is a name and a typed value that describes the session or the user, rather than one
moment. UserProperty, the second marker type, says the value belongs to the person:
// Describes the session: a super property in Mixpanel, sent with every later event.
data class AppTheme(val dark: Boolean) : Property {
override val name = "app_theme"
override val value = AnalyticsValue.String(if (dark) "dark" else "light")
}
// Describes the person: Mixpanel's people profile, Amplitude's user properties.
class PurchaseCount(count: Int) : UserProperty {
override val name = "total_purchases"
override val value = AnalyticsValue.Int(count)
}
// Describes the session: a super property in Mixpanel, sent with every later event.
struct AppTheme: Property {
let dark: Bool
var name: String { "app_theme" }
var value: AnalyticsValue { .string(dark ? "dark" : "light") }
}
// Describes the person: Mixpanel's people profile, Amplitude's user properties.
struct PurchaseCount: UserProperty {
let count: Int
var name: String { "total_purchases" }
var value: AnalyticsValue { .int(count) }
}
// Describes the session: a super property in Mixpanel, sent with every later event.
final class const AppTheme({required final bool dark}) extends Property {
@override
String get name => 'app_theme';
@override
AnalyticsValue get value => .string(dark ? 'dark' : 'light');
}
// Describes the person: Mixpanel's people profile, Amplitude's user properties.
final class const PurchaseCount(final int count) extends UserProperty {
@override
String get name => 'total_purchases';
@override
AnalyticsValue get value => .int(count);
}
// Describes the session: a super property in Mixpanel, sent with every later event.
export class AppTheme implements Property {
readonly name = 'app_theme';
constructor(readonly dark: boolean) {}
get value() {
return this.dark ? 'dark' : 'light';
}
}
// Describes the person: Mixpanel's people profile, Amplitude's user properties.
export class PurchaseCount implements UserProperty {
readonly name = 'total_purchases';
readonly isUserProperty = true;
constructor(readonly count: number) {}
get value() {
return int(this.count);
}
}
That difference matters to vendors that store the two separately:
- Mixpanel saves a
UserPropertyon the person's profile, and any other property as a super property, sent with every later event; - Amplitude only has user properties, so both kinds go there;
- Firebase and Adjust don't separate them, so both kinds are treated the same.
Identity¶
Who the user is isn't a property. It's Identity(userId), which you pass to
IdentifiableUserService at sign-in, because vendors have their own calls for it, such as
Firebase's setUserId and AppsFlyer's setCustomerUserId. See Identity.
Next¶
Capabilities: how your classes send these.