Skip to content

Commit

Permalink
Implement event tracking telemetry and submission to Mixpanel
Browse files Browse the repository at this point in the history
  • Loading branch information
zargony committed Dec 19, 2024
1 parent ccec66d commit f3bf294
Show file tree
Hide file tree
Showing 10 changed files with 460 additions and 2 deletions.
1 change: 1 addition & 0 deletions firmware/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased

- Automatically refresh article and user information once a day
- Event tracking using Mixpanel for usage analytics

## 0.2.0 - 2024-11-27

Expand Down
3 changes: 3 additions & 0 deletions firmware/config-example.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"wifi-ssid": "My Wifi",
"wifi-password": "12345",

// Mixpanel project token for analytics (optional)
"mp-token": "00000000000000000000000000000000",

// Credentials for connecting to the Vereinsflieger API. See Vereinsflieger
// REST Documentation for details. Note that password needs to be given as
// its hex MD5 hash instead of plain text.
Expand Down
9 changes: 8 additions & 1 deletion firmware/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ impl fmt::Display for SensitiveString {
}

impl Deref for SensitiveString {
type Target = String;
type Target = str;

fn deref(&self) -> &Self::Target {
&self.0
Expand All @@ -65,6 +65,8 @@ pub struct Config {
pub wifi_ssid: String,
/// Wifi password
pub wifi_password: SensitiveString,
/// Mixpanel project token for analytics (optional)
pub mp_token: Option<String>,
/// Vereinsflieger API username
pub vf_username: String,
/// MD5 (hex) of Vereinsflieger API password
Expand All @@ -89,6 +91,11 @@ impl FromJsonObject for Config {
match &*key {
"wifi-ssid" => self.wifi_ssid = json.read().await?,
"wifi-password" => self.wifi_password = json.read().await?,
// Don't use telemetry in debug builds, unless explicitly specified
#[cfg(not(debug_assertions))]
"mp-token" => self.mp_token = Some(json.read().await?),
#[cfg(debug_assertions)]
"mp-token-debug" => self.mp_token = Some(json.read().await?),
"vf-username" => self.vf_username = json.read().await?,
"vf-password-md5" => self.vf_password_md5 = json.read().await?,
"vf-appkey" => self.vf_appkey = json.read().await?,
Expand Down
10 changes: 10 additions & 0 deletions firmware/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ mod error;
mod http;
mod json;
mod keypad;
mod mixpanel;
mod nfc;
mod pn532;
mod schedule;
mod screen;
mod telemetry;
mod time;
mod ui;
mod user;
Expand All @@ -63,6 +65,7 @@ use embassy_sync::{blocking_mutex::raw::NoopRawMutex, mutex::Mutex};
use esp_alloc as _;
use esp_backtrace as _;
use esp_hal::clock::CpuClock;
use esp_hal::efuse::Efuse;
use esp_hal::gpio::{Input, Level, Output, OutputOpenDrain, Pull};
use esp_hal::i2c::master::{Config as I2cConfig, I2c};
use esp_hal::peripherals::Peripherals;
Expand Down Expand Up @@ -214,6 +217,12 @@ async fn main(spawner: Spawner) {
config.vf_cid,
);

// Initialize telemetry
let device_id: const_hex::Buffer<6, false> =
const_hex::Buffer::new().const_format(&Efuse::read_base_mac_address());
let mut telemetry = telemetry::Telemetry::new(config.mp_token.as_deref(), device_id.as_str());
telemetry.track(telemetry::Event::SystemStart);

// Initialize buzzer
let mut buzzer = buzzer::Buzzer::new(peripherals.LEDC, peripherals.GPIO4);
let _ = buzzer.startup().await;
Expand All @@ -233,6 +242,7 @@ async fn main(spawner: Spawner) {
&mut vereinsflieger,
&mut articles,
&mut users,
&mut telemetry,
&mut schedule,
);

Expand Down
107 changes: 107 additions & 0 deletions firmware/src/mixpanel/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
mod proto_event;

use crate::http::{self, Http};
use crate::telemetry::Event;
use crate::time;
use core::fmt;
use embassy_time::Instant;
use log::{debug, warn};

/// Mixpanel API base URL
const BASE_URL: &str = "https://api-eu.mixpanel.com";

/// Mixpanel API error
#[derive(Debug)]
pub enum Error {
/// Current time is required but not set
CurrentTimeNotSet,
/// Failed to connect to API server
Connect(http::Error),
/// Failed to submit events to API server
Submit(http::Error),
}

impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CurrentTimeNotSet => write!(f, "Unknown current time"),
Self::Connect(err) => write!(f, "MP connect failed ({err})"),
Self::Submit(err) => write!(f, "MP submit failed ({err})"),
}
}
}

/// Mixpanel API client
#[derive(Debug)]
pub struct Mixpanel<'a> {
token: &'a str,
device_id: &'a str,
}

impl<'a> Mixpanel<'a> {
/// Create new Mixpanel API client using the given project token
pub fn new(token: &'a str, device_id: &'a str) -> Self {
Self { token, device_id }
}

/// Connect to API server
pub async fn connect<'conn>(
&'conn mut self,
http: &'conn mut Http<'_>,
) -> Result<Connection<'conn>, Error> {
Connection::new(self, http).await
}
}

/// Mixpanel API client connection
#[derive(Debug)]
pub struct Connection<'a> {
http: http::Connection<'a>,
token: &'a str,
device_id: &'a str,
}

impl Connection<'_> {
/// Submit tracked events
pub async fn submit(&mut self, events: &[(Instant, Event)]) -> Result<(), Error> {
use proto_event::{TrackRequest, TrackResponse};

if time::now().is_none() {
warn!("Mixpanel: Not submitting events. No current time set.");
return Err(Error::CurrentTimeNotSet);
}

debug!("Mixpanel: Submitting {} events...", events.len());
let response: TrackResponse = self
.http
.post(
"track?verbose=1",
&TrackRequest {
token: self.token,
device_id: self.device_id,
events,
},
)
.await
.map_err(Error::Submit)?;
debug!(
"Mixpanel: Submit successul, status {} {}",
response.status, response.error
);
Ok(())
}
}

impl<'a> Connection<'a> {
/// Connect to API server
async fn new(mp: &'a Mixpanel<'_>, http: &'a mut Http<'_>) -> Result<Self, Error> {
// Connect to API server
let connection = http.connect(BASE_URL).await.map_err(Error::Connect)?;

Ok(Self {
http: connection,
token: mp.token,
device_id: mp.device_id,
})
}
}
125 changes: 125 additions & 0 deletions firmware/src/mixpanel/proto_event.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use crate::json::{self, FromJsonObject, ToJson};
use crate::telemetry;
use crate::time::DateTimeExt;
use alloc::string::String;
use embassy_time::Instant;
use embedded_io_async::{BufRead, Write};

/// `track` request
#[derive(Debug)]
pub struct TrackRequest<'a> {
pub token: &'a str,
pub device_id: &'a str,
pub events: &'a [(Instant, telemetry::Event)],
}

impl ToJson for TrackRequest<'_> {
async fn to_json<W: Write>(
&self,
json: &mut json::Writer<W>,
) -> Result<(), json::Error<W::Error>> {
json.write_array(self.events.iter().map(|(time, event)| Event {
token: self.token,
device_id: self.device_id,
time,
telemetry: event,
}))
.await
}
}

/// `track` response
#[derive(Debug, Default)]
pub struct TrackResponse {
pub error: String,
pub status: u32,
}

impl FromJsonObject for TrackResponse {
type Context<'ctx> = ();

async fn read_next<R: BufRead>(
&mut self,
_key: String,
json: &mut json::Reader<R>,
_context: &Self::Context<'_>,
) -> Result<(), json::Error<R::Error>> {
_ = json.read_any().await?;
Ok(())
}
}

/// Event
#[derive(Debug)]
struct Event<'a> {
token: &'a str,
device_id: &'a str,
time: &'a Instant,
telemetry: &'a telemetry::Event,
}

impl ToJson for Event<'_> {
async fn to_json<W: Write>(
&self,
json: &mut json::Writer<W>,
) -> Result<(), json::Error<W::Error>> {
json.write_object()
.await?
.field("event", self.telemetry.event_name())
.await?
.field("properties", EventProperties { event: self })
.await?
.finish()
.await
}
}

/// Event properties
#[derive(Debug)]
struct EventProperties<'a> {
event: &'a Event<'a>,
}

impl ToJson for EventProperties<'_> {
async fn to_json<W: Write>(
&self,
json: &mut json::Writer<W>,
) -> Result<(), json::Error<W::Error>> {
// Convert relative `Instant` time to absolute `DateTime` (needs current time set)
let time = self
.event
.time
.to_datetime()
.ok_or(json::Error::InvalidType)?;

let mut object = json.write_object().await?;

// Reserved properties, see https://docs.mixpanel.com/docs/data-structure/property-reference/reserved-properties
object
.field("token", self.event.token)
.await?
.field("time", time.timestamp_millis())
.await?;
// Use user id as distinct id if event is associated with a user, use device id otherwise
match self.event.telemetry.user_id() {
Some(user_id) => object.field("distinct_id", user_id).await?,
None => object.field("distinct_id", self.event.device_id).await?,
};

// Global custom properties
object
.field("firmware_version", crate::VERSION_STR)
.await?
.field("firmware_git_sha", crate::GIT_SHA_STR)
.await?
.field("device_id", self.event.device_id)
.await?;
// Event-specific custom properties
self.event
.telemetry
.add_event_attributes(&mut object)
.await?;

object.finish().await
}
}
2 changes: 2 additions & 0 deletions firmware/src/screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ pub enum PleaseWait {
WifiConnecting,
UpdatingData,
Purchasing,
SubmittingTelemetry,
}

impl Screen for PleaseWait {
Expand All @@ -183,6 +184,7 @@ impl Screen for PleaseWait {
Self::WifiConnecting => "WLAN Verbindung\nwird aufgebaut",
Self::UpdatingData => "Daten-Aktualisierung",
Self::Purchasing => "Zahlung wird\nbearbeitet",
Self::SubmittingTelemetry => "Daten-Übertragung",
},
target,
)?;
Expand Down
Loading

0 comments on commit f3bf294

Please sign in to comment.