Mixpanel
Mixpanel from the server: events tracked and history imported by a service account, batches cut to 2000, stable $insert_ids so a retry is not counted twice, failed records read back by position, people and group profiles in Mixpanel's own operations, and the Query API for segmentation, funnels, retention, saved reports and profiles, without JQL.
Version 1.0.0 · 12 functions · 4 crates
Functions
What use mixpanel.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use mixpanel_insert_id, mixpanel_track from mixpanel (Quillo 0.6).
mixpanel_insert_id(parts: list) → strA stable $insert_id for an event, from what identifies it: Mixpanel keeps one of events with the same id, time and name, so a retry is not counted twice. At most 36 characters, of letters and digits, as it requires.
mixpanel_track(event: str, distinct_id: str, properties: any, options: any) → boolor an errorTracks an event that just happened: properties a map, and options may hold time (unix seconds, within the last 5 days), insert_id. Older events need mixpanel_import. Gives true.
mixpanel_track_many(events: list) → intor an errorTracks many events, each { event, distinct_id, properties, time, insert_id }, 2000 at a go. Gives how many went.
mixpanel_import(events: list) → anyor an errorImports history by service account: each { event, distinct_id, time (unix seconds or ms), properties, insert_id }, 2000 at a go, with strict on, so a bad record is named rather than dropped. Gives { imported, failed: [{ index, field, message }] }, index a position in events.
mixpanel_people(distinct_id: str, op: str, value: any, ignore_time: bool) → boolor an errorChanges a person's profile: op is "set", "set_once", "add", "union", "append", "remove", "unset" or "delete"; value a map, a list of names for "unset", or null for "delete". ignore_time keeps it from counting as their last activity. Gives true.
mixpanel_people_many(updates: list) → boolor an errorChanges many profiles, each { distinct_id, op, value }, 2000 at a go.
mixpanel_group_set(group_key: str, group_id: str, properties: any) → boolor an errorSets a group's profile, for group_key like "company_id" and group_id like "acme".
mixpanel_segmentation(event: str, from_date: str, to_date: str, params: any) → anyor an errorCounts of an event by day: params adds unit ("hour", "day", "week", "month"), on to segment by, type ("general", "unique", "average"), where. Gives { series, counts: { group: { date: n } } }.
mixpanel_funnel(funnel_id: int, from_date: str, to_date: str, params: any) → anyor an errorA saved funnel's numbers by date, as Mixpanel gives them.
mixpanel_retention(from_date: str, to_date: str, params: any) → anyor an errorRetention by cohort date, as Mixpanel gives it: params adds born_event, event, unit.
mixpanel_insights(bookmark_id: int) → anyor an errorA saved Insights report, by its bookmark id.
mixpanel_profiles(where_expression: str, max_items: int) → listor an errorProfiles through every page: where_expression like properties["plan"] == "pro" or "", max_items caps them (0: all). Each is { distinct_id, properties }.
Get it
$ quillo install mixpanel # mixpanel.qwer into this folder$ quillo install mixpanel --example # and its example, mixpanel.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/mixpanel.qwer$ curl -O https://quillolang.org/plugins/files/mixpanel.qweThen, in your program, all of it or only what you call:
use mixpanel.qweruse mixpanel_insert_id, mixpanel_track from mixpanel // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
ureq = "2"sha2 = "0.10"base64 = "0.22"serde_json = { version = "1", features = ["preserve_order"] }Example
// Product analytics in Mixpanel, from the server: events tracked, history imported, profiles kept,// and reports read back.//// MIXPANEL_TOKEN=... MIXPANEL_PROJECT_ID=... MIXPANEL_SERVICE_USER=... MIXPANEL_SERVICE_SECRET=... quillo run mixpanel.qwe// then open /track, /import, /people, /reports, /profiles, /ids and /failures//// MIXPANEL_API_URL and MIXPANEL_QUERY_URL pick a region or a test server.use "mixpanel.qwer"route GET /track = { one: mixpanel_track("Signed up", "user-1", { plan: "pro" }, {})?, many: mixpanel_track_many(map(range(0, 2500), (i) => { event: "Viewed", distinct_id: `user-${i % 5}`, properties: { n: i } }))?, old: mixpanel_track("Long ago", "user-1", {}, { time: 1600000000 }).error, no_name: mixpanel_track("", "user-1", {}, {}).error, bad_id: mixpanel_track("x", "user-1", {}, { insert_id: "not valid!" }).error,}route GET /import { let rows = map(range(0, 2100), (i) => { event: "Purchase", distinct_id: `user-${i}`, time: 1600000000 + i, properties: { total: i } }) let bad = rows + [{ event: "Purchase", distinct_id: "user-x", time: 1, properties: {} }] let r = mixpanel_import(bad)? { imported: r.imported, failed: r.failed, no_time: mixpanel_import([{ event: "x", distinct_id: "u" }]).error, repeat: mixpanel_import(slice(rows, 0, 1))?.imported, }}route GET /people = { set: mixpanel_people("user-1", "set", { "$name": "Ada", plan: "pro" }, false)?, once: mixpanel_people("user-1", "set_once", { first_seen: "2026-11-01" }, true)?, add: mixpanel_people("user-1", "add", { logins: 1 }, false)?, unset: mixpanel_people("user-1", "unset", ["old_field"], false)?, delete: mixpanel_people("user-2", "delete", null, false)?, many: mixpanel_people_many(map(range(0, 2100), (i) => { distinct_id: `u${i}`, op: "set", value: { n: i } }))?, group: mixpanel_group_set("company_id", "acme", { name: "Acme", seats: 12 })?, bad_op: mixpanel_people("u", "merge", {}, false).error, bad_value: mixpanel_people("u", "set", [1], false).error, bad_unset: mixpanel_people("u", "unset", {}, false).error,}route GET /reports = { segmentation: mixpanel_segmentation("Signed up", "2026-10-01", "2026-10-02", { unit: "day" })?, funnel: mixpanel_funnel(7, "2026-10-01", "2026-10-02", {})?.meta.dates, retention: mixpanel_retention("2026-10-01", "2026-10-02", { born_event: "Signed up" })?["2026-10-01"].counts, insights: mixpanel_insights(12345)?.series, bad_date: mixpanel_segmentation("x", "October", "2026-10-02", {}).error, bad_query: mixpanel_funnel(999, "2026-10-01", "2026-10-02", {}).error,}route GET /profiles { let pro = mixpanel_profiles("properties[\"plan\"] == \"pro\"", 0)? { pro: len(pro), first: pro[0], all: len(mixpanel_profiles("", 0)?), capped: len(mixpanel_profiles("", 1200)?) }}route GET /ids = { id: mixpanel_insert_id(["order", 1001]), same: mixpanel_insert_id(["order", 1001]) == mixpanel_insert_id(["order", 1001]), other: mixpanel_insert_id(["order", 1002]) != mixpanel_insert_id(["order", 1001]),}route GET /failures = { waited: mixpanel_track("Busy", "user-1", {}, {})?, refused: mixpanel_track("Refused", "user-1", {}, {}).error, wrong_account: mixpanel_insights(403).error,}$ quillo run mixpanel.qwe$ curl 'localhost:8080/track'$ curl 'localhost:8080/import'$ curl 'localhost:8080/people'$ curl 'localhost:8080/reports'$ curl 'localhost:8080/profiles'$ curl 'localhost:8080/ids'$ curl 'localhost:8080/failures'It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.
Source
//! Mixpanel from the server: events tracked and imported with a service account, batches cut to 2000, stable $insert_ids so a retry is not counted twice, failed records read back by position, people and group profiles in Mixpanel's own operations, and the Query API for segmentation, funnels, retention, saved reports and profiles, without JQL.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! sha2 = "0.10"//! base64 = "0.22"//! serde_json = { version = "1", features = ["preserve_order"] }use base64::{engine::general_purpose::STANDARD, Engine};use serde_json::{json, Map, Value};use sha2::{Digest, Sha256};use std::time::{Duration, SystemTime, UNIX_EPOCH};// MIXPANEL_TOKEN is the project's token, for /track and profiles. MIXPANEL_PROJECT_ID,// MIXPANEL_SERVICE_USER and MIXPANEL_SERVICE_SECRET are for /import and the Query API.// MIXPANEL_API_URL is ingestion (https://api.mixpanel.com; https://api-eu.mixpanel.com in the EU),// MIXPANEL_QUERY_URL queries (https://mixpanel.com/api; https://eu.mixpanel.com/api in the EU).const BATCH: usize = 2000;const FIVE_DAYS: i64 = 5 * 86400;fn setting(k: &str) -> Option<String> { std::env::var(k).ok().filter(|v| !v.is_empty())}fn ingest() -> String { setting("MIXPANEL_API_URL").unwrap_or_else(|| "https://api.mixpanel.com".into()).trim_end_matches('/').to_string()}fn query_root() -> String { setting("MIXPANEL_QUERY_URL").unwrap_or_else(|| "https://mixpanel.com/api".into()).trim_end_matches('/').to_string()}fn token() -> Result<String, String> { setting("MIXPANEL_TOKEN").ok_or("MIXPANEL_TOKEN is not set".to_string())}fn project() -> Result<String, String> { setting("MIXPANEL_PROJECT_ID").ok_or("MIXPANEL_PROJECT_ID is not set".to_string())}fn account() -> Result<String, String> { let u = setting("MIXPANEL_SERVICE_USER").ok_or("MIXPANEL_SERVICE_USER is not set: /import and queries need a service account")?; let s = setting("MIXPANEL_SERVICE_SECRET").ok_or("MIXPANEL_SERVICE_SECRET is not set")?; Ok(format!("Basic {}", STANDARD.encode(format!("{}:{}", u, s))))}fn enc(s: &str) -> String { s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{:02X}", b) }).collect()}fn now() -> i64 { SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)}// Answers on a 4xx with a body are given back: /import's holds the failed records.fn send(req: ureq::Request, body: Option<&str>, name: &str) -> Result<(u16, Value), String> { let kind = req.header("Content-Type").unwrap_or("application/json").to_string(); for attempt in 0..5u32 { let r = req.clone(); let res = match body { Some(b) => r.set("Content-Type", &kind).send_string(b), None => r.call(), }; match res { Ok(x) => { let c = x.status(); let t = x.into_string().map_err(|e| e.to_string())?; return Ok((c, serde_json::from_str(&t).unwrap_or(Value::String(t)))); } Err(ureq::Error::Status(429 | 502 | 503, x)) if attempt < 4 => { let wait = x.header("Retry-After").and_then(|s| s.parse::<u64>().ok()).map_or(500u64 << attempt, |s| s * 1000); std::thread::sleep(Duration::from_millis(wait.min(60_000))); } Err(ureq::Error::Status(c, x)) => { let t = x.into_string().unwrap_or_default(); return Ok((c, serde_json::from_str(&t).unwrap_or(Value::String(t)))); } Err(e) => return Err(format!("cannot reach Mixpanel's {}: {}", name, e)), } } Err("Mixpanel stayed busy through five tries".into())}fn agent() -> ureq::Agent { ureq::AgentBuilder::new().timeout(Duration::from_secs(60)).build()}fn refuse(c: u16, v: &Value) -> String { let what = v["error"].as_str().map(str::to_string).or(v.as_str().map(str::to_string)).unwrap_or_else(|| v.to_string()); let hint = if c == 401 { " (check the service account)" } else if c == 403 { " (the account cannot reach this project)" } else { "" }; format!("Mixpanel {}: {}{}", c, what, hint)}/// A stable $insert_id for an event, from what identifies it: Mixpanel keeps one of events with the same id, time and name, so a retry is not counted twice. At most 36 characters, of letters and digits, as it requires.pub fn mixpanel_insert_id(parts: Vec<serde_json::Value>) -> String { let joined = parts.iter().map(|p| p.as_str().map(str::to_string).unwrap_or_else(|| p.to_string())).collect::<Vec<_>>().join("\u{1f}"); Sha256::digest(joined.as_bytes())[..16].iter().map(|b| format!("{:02x}", b)).collect()}fn event_of(e: &Value, need_time: bool) -> Result<Value, String> { let name = e["event"].as_str().filter(|n| !n.is_empty()).ok_or("an event needs a name")?; let mut p: Map<String, Value> = e["properties"].as_object().cloned().unwrap_or_default(); if let Some(d) = e["distinct_id"].as_str() { p.insert("distinct_id".into(), json!(d)); } if !p.contains_key("distinct_id") { return Err(format!("event `{}` needs a distinct_id: \"\" is an event for no one, if you mean that", name)); } let t = p.get("time").and_then(|t| t.as_i64()).or(e["time"].as_i64()); let t = match (t, need_time) { (Some(t), _) => t, (None, false) => now(), (None, true) => return Err(format!("event `{}` needs a `time`: /import keeps history, so now is not assumed", name)), }; p.insert("time".into(), json!(t)); if !p.contains_key("$insert_id") { let id = e["insert_id"].as_str().map(str::to_string).unwrap_or_else(|| mixpanel_insert_id(vec![json!(name), p["distinct_id"].clone(), json!(t), Value::Object(p.clone())])); p.insert("$insert_id".into(), json!(id)); } if let Some(id) = p["$insert_id"].as_str() { if id.len() > 36 || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') { return Err(format!("$insert_id `{}` is at most 36 of letters, digits and -", id)); } } Ok(json!({ "event": name, "properties": p }))}fn track_list(events: Vec<Value>) -> Result<i64, String> { let tok = token()?; let url = format!("{}/track?verbose=1", ingest()); let mut sent = 0; for chunk in events.chunks(BATCH) { let list: Vec<Value> = chunk.iter().map(|e| { let mut x = e.clone(); x["properties"]["token"] = json!(tok); x }).collect(); let (c, v) = send(agent().post(&url), Some(&Value::Array(list).to_string()), "ingestion")?; if c != 200 || v["status"] != 1 { return Err(refuse(c, &v)); } sent += chunk.len() as i64; } Ok(sent)}/// Tracks an event that just happened: `properties` a map, and `options` may hold `time` (unix seconds, within the last 5 days), `insert_id`. Older events need mixpanel_import. Gives true.pub fn mixpanel_track(event: &str, distinct_id: &str, properties: serde_json::Value, options: serde_json::Value) -> Result<bool, String> { mixpanel_track_many(vec![json!({ "event": event, "distinct_id": distinct_id, "properties": properties, "time": options["time"], "insert_id": options["insert_id"] })])?; Ok(true)}/// Tracks many events, each { event, distinct_id, properties, time, insert_id }, 2000 at a go. Gives how many went.pub fn mixpanel_track_many(events: Vec<serde_json::Value>) -> Result<i64, String> { let mut made = vec![]; for e in &events { let x = event_of(e, false)?; if x["properties"]["time"].as_i64().map_or(false, |t| now() - t > FIVE_DAYS) { return Err(format!("`{}` is more than 5 days old: /track drops those, so use mixpanel_import", x["event"].as_str().unwrap_or(""))); } made.push(x); } track_list(made)}/// Imports history by service account: each { event, distinct_id, time (unix seconds or ms), properties, insert_id }, 2000 at a go, with `strict` on, so a bad record is named rather than dropped. Gives { imported, failed: [{ index, field, message }] }, `index` a position in `events`.pub fn mixpanel_import(events: Vec<serde_json::Value>) -> Result<serde_json::Value, String> { let auth = account()?; let url = format!("{}/import?strict=1&project_id={}", ingest(), enc(&project()?)); let made: Vec<Value> = events.iter().map(|e| event_of(e, true)).collect::<Result<_, _>>()?; let mut imported = 0; let mut failed = vec![]; for (n, chunk) in made.chunks(BATCH).enumerate() { let (c, v) = send(agent().post(&url).set("Authorization", &auth), Some(&Value::Array(chunk.to_vec()).to_string()), "ingestion")?; match c { 200 => imported += v["num_records_imported"].as_i64().unwrap_or(chunk.len() as i64), 400 if v["failed_records"].is_array() => { imported += v["num_records_imported"].as_i64().unwrap_or(0); for f in v["failed_records"].as_array().into_iter().flatten() { failed.push(json!({ "index": f["index"].as_i64().unwrap_or(0) + (n * BATCH) as i64, "field": f["field"], "message": f["message"] })); } } _ => return Err(refuse(c, &v)), } } Ok(json!({ "imported": imported, "failed": failed }))}fn people_op(op: &str) -> Result<String, String> { const OPS: &[&str] = &["set", "set_once", "add", "union", "append", "remove", "unset", "delete"]; if !OPS.contains(&op) { return Err(format!("`{}` is not a profile operation; it takes {}", op, OPS.join(", "))); } Ok(format!("${}", op))}fn engage(path: &str, updates: Vec<Value>) -> Result<bool, String> { for chunk in updates.chunks(BATCH) { let (c, v) = send(agent().post(&format!("{}{}?verbose=1", ingest(), path)), Some(&Value::Array(chunk.to_vec()).to_string()), "ingestion")?; if c != 200 || v["status"] != 1 { return Err(refuse(c, &v)); } } Ok(true)}/// Changes a person's profile: `op` is "set", "set_once", "add", "union", "append", "remove", "unset" or "delete"; `value` a map, a list of names for "unset", or null for "delete". `ignore_time` keeps it from counting as their last activity. Gives true.pub fn mixpanel_people(distinct_id: &str, op: &str, value: serde_json::Value, ignore_time: bool) -> Result<bool, String> { let tok = token()?; let name = people_op(op)?; let v = match op { "delete" => json!(""), "unset" if !value.is_array() => return Err("\"unset\" takes a list of property names".into()), "unset" => value, _ if !value.is_object() => return Err(format!("\"{}\" takes a map of properties", op)), _ => value, }; let mut u = json!({ "$token": tok, "$distinct_id": distinct_id, name: v }); if ignore_time { u["$ignore_time"] = json!(true); } engage("/engage", vec![u])}/// Changes many profiles, each { distinct_id, op, value }, 2000 at a go.pub fn mixpanel_people_many(updates: Vec<serde_json::Value>) -> Result<bool, String> { let tok = token()?; let mut list = vec![]; for u in &updates { let name = people_op(u["op"].as_str().ok_or("each update needs an `op`")?)?; list.push(json!({ "$token": tok, "$distinct_id": u["distinct_id"], name: u["value"] })); } engage("/engage", list)}/// Sets a group's profile, for `group_key` like "company_id" and `group_id` like "acme".pub fn mixpanel_group_set(group_key: &str, group_id: &str, properties: serde_json::Value) -> Result<bool, String> { if !properties.is_object() { return Err("a group's properties must be a map".into()); } engage("/groups", vec![json!({ "$token": token()?, "$group_key": group_key, "$group_id": group_id, "$set": properties })])}fn query_get(path: &str, params: &Value, base: Vec<(&str, String)>) -> Result<Value, String> { let mut q = vec![format!("project_id={}", enc(&project()?))]; for (k, v) in base { q.push(format!("{}={}", enc(k), enc(&v))); } for (k, v) in params.as_object().into_iter().flatten() { q.push(format!("{}={}", enc(k), enc(&v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())))); } let (c, v) = send(agent().get(&format!("{}/query{}?{}", query_root(), path, q.join("&"))).set("Authorization", &account()?).set("Accept", "application/json"), None, "Query API")?; if c != 200 { return Err(refuse(c, &v)); } Ok(v)}fn date_ok(d: &str) -> Result<String, String> { let b = d.as_bytes(); if d.len() != 10 || b[4] != b'-' || b[7] != b'-' { return Err(format!("`{}` is not a date like 2026-11-01", d)); } Ok(d.to_string())}/// Counts of an event by day: `params` adds `unit` ("hour", "day", "week", "month"), `on` to segment by, `type` ("general", "unique", "average"), `where`. Gives { series, counts: { group: { date: n } } }.pub fn mixpanel_segmentation(event: &str, from_date: &str, to_date: &str, params: serde_json::Value) -> Result<serde_json::Value, String> { let v = query_get("/segmentation", ¶ms, vec![("event", event.to_string()), ("from_date", date_ok(from_date)?), ("to_date", date_ok(to_date)?)])?; Ok(json!({ "series": v["data"]["series"], "counts": v["data"]["values"] }))}/// A saved funnel's numbers by date, as Mixpanel gives them.pub fn mixpanel_funnel(funnel_id: i64, from_date: &str, to_date: &str, params: serde_json::Value) -> Result<serde_json::Value, String> { query_get("/funnels", ¶ms, vec![("funnel_id", funnel_id.to_string()), ("from_date", date_ok(from_date)?), ("to_date", date_ok(to_date)?)])}/// Retention by cohort date, as Mixpanel gives it: `params` adds `born_event`, `event`, `unit`.pub fn mixpanel_retention(from_date: &str, to_date: &str, params: serde_json::Value) -> Result<serde_json::Value, String> { query_get("/retention", ¶ms, vec![("from_date", date_ok(from_date)?), ("to_date", date_ok(to_date)?)])}/// A saved Insights report, by its bookmark id.pub fn mixpanel_insights(bookmark_id: i64) -> Result<serde_json::Value, String> { query_get("/insights", &Value::Null, vec![("bookmark_id", bookmark_id.to_string())])}/// Profiles through every page: `where_expression` like `properties["plan"] == "pro"` or "", `max_items` caps them (0: all). Each is { distinct_id, properties }.pub fn mixpanel_profiles(where_expression: &str, max_items: i64) -> Result<Vec<serde_json::Value>, String> { let url = format!("{}/query/engage?project_id={}", query_root(), enc(&project()?)); let auth = account()?; let mut out = vec![]; let mut page = 0; let mut session = String::new(); loop { let mut form = vec![]; if !where_expression.is_empty() { form.push(format!("where={}", enc(where_expression))); } if !session.is_empty() { form.push(format!("session_id={}&page={}", enc(&session), page)); } let r = agent().post(&url).set("Authorization", &auth).set("Content-Type", "application/x-www-form-urlencoded"); let (c, v) = send(r, Some(&form.join("&")), "Query API")?; if c != 200 { return Err(refuse(c, &v)); } let rows = v["results"].as_array().cloned().unwrap_or_default(); let n = rows.len() as i64; for r in rows { out.push(json!({ "distinct_id": r["$distinct_id"], "properties": r["$properties"] })); } if max_items > 0 && out.len() as i64 >= max_items { out.truncate(max_items as usize); return Ok(out); } if n < v["page_size"].as_i64().unwrap_or(1000) { return Ok(out); } session = v["session_id"].as_str().unwrap_or("").to_string(); page += 1; }}Related
ga4.qwer · 1.0.0Google Analytics 4 from the server: the Measurement Protocol with its limits and reserved names checked first, since the live endpoint accepts anything and answers 204, its validation endpoint read back, and Data API reports by service-account JWT, returned as rows of maps with typed numbers, paged by offset, with the property's quota.4 views · 7 downloadsPostHog posthog.qwer · 1.0.0PostHog from the server: events and batches cut to fit, identify, alias and group identify in PostHog's own event forms, feature flags evaluated by /decide with their payloads parsed, partial failures and quota limits told apart, and a fallback so a page does not break with PostHog.3 views · 8 downloadsSegment segment.qwer · 1.0.0Segment's HTTP Tracking API without a library: track, identify, group, page, screen and alias with message ids that keep a retry from counting twice, the 32 KB and 500 KB limits met before Segment refuses, batches cut to fit, and a webhook's payload mapped by a table into messages, as a source function would.3 views · 8 downloadsThread 0 posts
On Mixpanel: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.