PostHog
PostHog 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.
Version 1.0.0 · 9 functions · 2 crates
Functions
What use posthog.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use posthog_capture, posthog_batch from posthog (Quillo 0.6).
posthog_capture(event: str, distinct_id: str, properties: any, options: any) → boolor an errorSends one event. properties is a map, and options may hold groups ({ company: "acme" }), timestamp (ISO 8601, for something that happened earlier) and uuid (so a repeat is kept once). Gives true.
posthog_batch(events: list) → anyor an errorSends many events: each { event, distinct_id, properties, timestamp, uuid, groups }, cut into batches of at most 1000 or 19 MB, as PostHog stops at 20. An event over 1 MB is an error before anything is sent. Gives { sent, batches }.
posthog_identify(distinct_id: str, set: any, set_once: any) → boolor an errorSets a person's properties: set overwrites, set_once keeps what is there. Sent as PostHog's $identify event.
posthog_alias(distinct_id: str, alias: str) → boolor an errorSays two ids are one person, as $create_alias. Do it once, when an anonymous visitor signs in.
posthog_group_identify(group_type: str, group_key: str, properties: any) → boolor an errorSets a group's properties ($groupidentify): group_type like "company", group_key like "acme". The event's distinct_id is PostHog's own for groups.
posthog_flags(distinct_id: str, options: any) → anyor an errorEvaluates every flag for a person through /decide: options may hold groups, person_properties and group_properties. Gives { flags: { key: true, false or a variant }, payloads: { key: value }, partial }: partial true means some could not be computed, so a flag not listed there is unknown, not off. A project over its flag quota is an error.
posthog_flag(distinct_id: str, flag: str, options: any) → anyor an errorOne flag: true, false, a variant's name, or null if PostHog has no such flag for them.
posthog_enabled(distinct_id: str, flag: str, fallback: bool, options: any) → boolWhether a flag is on for a person, never an error: on any failure, or when it could not be computed, it gives fallback, so a page does not break with PostHog.
posthog_flag_called(distinct_id: str, flag: str, value: any) → boolor an errorRecords that a person saw a flag ($feature_flag_called), which experiments are counted by.
Get it
$ quillo install posthog # posthog.qwer into this folder$ quillo install posthog --example # and its example, posthog.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/posthog.qwer$ curl -O https://quillolang.org/plugins/files/posthog.qweThen, in your program, all of it or only what you call:
use posthog.qweruse posthog_capture, posthog_batch from posthog // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
ureq = "2"serde_json = { version = "1", features = ["preserve_order"] }Example
// Product analytics from the server: events and a big batch, a person and a company described, and// feature flags deciding what a visitor sees.//// POSTHOG_API_KEY=phc_... POSTHOG_HOST=https://eu.i.posthog.com quillo run posthog.qwe// then open /events, /batch, /people, /flags and /failuresuse "posthog.qwer"route GET /events = { plain: posthog_capture("order placed", "user-1", { total: 12.5, items: 2 }, {})?, grouped: posthog_capture("seat added", "user-1", { seats: 5 }, { groups: { company: "acme" } })?, late: posthog_capture("imported", "user-1", {}, { timestamp: "2026-10-01T12:00:00Z", uuid: "0192a6a0-0000-7000-8000-000000000001" })?, no_name: posthog_capture("", "user-1", {}, {}).error, no_person: posthog_capture("x", "", {}, {}).error, not_map: posthog_capture("x", "user-1", [1], {}).error,}route GET /batch { let events = map(range(0, 2300), (i) => { event: "page viewed", distinct_id: `user-${i % 7}`, properties: { n: i } }) { sent: posthog_batch(events)?, grouped: posthog_batch([{ event: "x", distinct_id: "u", groups: { company: "acme" } }])?.sent, too_big: posthog_batch([{ event: "x", distinct_id: "u", properties: { blob: repeat("x", 1000001) } }]).error, no_id: posthog_batch([{ event: "x" }]).error, }}route GET /people = { identify: posthog_identify("user-1", { plan: "pro", email: "ada@example.com" }, { first_seen: "2026-11-01" })?, alias: posthog_alias("user-1", "anon-77")?, group: posthog_group_identify("company", "acme", { name: "Acme", seats: 12 })?, empty: posthog_identify("user-1", null, null).error, self: posthog_alias("user-1", "user-1").error,}route GET /flags { let f = posthog_flags("user-1", { groups: { company: "acme" }, person_properties: { plan: "pro" } })? { flags: f.flags, payload: f.payloads["new-checkout"], partial: f.partial, one: posthog_flag("user-1", "colour-test", {})?, unknown: posthog_flag("user-1", "no-such-flag", {})?, as_free: posthog_flag("user-2", "new-checkout", {})?, on: posthog_enabled("user-1", "new-checkout", false, {}), variant: posthog_enabled("user-1", "colour-test", false, {}), off: posthog_enabled("user-2", "new-checkout", true, {}), called: posthog_flag_called("user-1", "colour-test", "blue")?, some_failed: posthog_flags("partial", {})?.partial, fallback_partial: posthog_enabled("partial", "missing", true, {}), }}route GET /failures = { quota: posthog_flags("over-quota", {}).error, fallback: posthog_enabled("over-quota", "new-checkout", true, {}), bad_event: posthog_capture("refused", "user-1", {}, {}).error, waited: posthog_capture("busy", "user-1", {}, {})?, no_person: posthog_flags("", {}).error,}$ quillo run posthog.qwe$ curl 'localhost:8080/events'$ curl 'localhost:8080/batch'$ curl 'localhost:8080/people'$ curl 'localhost:8080/flags'$ 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
//! PostHog from the server: events and batches sent with their person and group properties, 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 default when flags cannot be reached.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};use std::time::Duration;// POSTHOG_API_KEY is the project's key (phc_...), safe for a browser, so this cannot read your// data. POSTHOG_HOST is the instance (https://us.i.posthog.com when not set; https://eu.i.posthog.com// for the EU cloud).const BATCH_EVENTS: usize = 1000;const BATCH_BYTES: usize = 19_000_000;const EVENT_BYTES: usize = 1_000_000;fn setting(k: &str) -> Option<String> { std::env::var(k).ok().filter(|v| !v.is_empty())}fn host() -> String { setting("POSTHOG_HOST").unwrap_or_else(|| "https://us.i.posthog.com".into()).trim_end_matches('/').to_string()}fn key() -> Result<String, String> { setting("POSTHOG_API_KEY").ok_or("POSTHOG_API_KEY is not set".to_string())}fn post(path: &str, body: &Value) -> Result<Value, String> { let url = format!("{}{}", host(), path); let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(30)).build(); let text = body.to_string(); // A capture has its own id, so PostHog keeps one of a repeated event. for attempt in 0..4u32 { match agent.post(&url).set("Content-Type", "application/json").set("User-Agent", "quillo-posthog/1.0").send_string(&text) { Ok(r) => { let t = r.into_string().map_err(|e| e.to_string())?; return if t.trim().is_empty() { Ok(Value::Null) } else { serde_json::from_str(&t).map_err(|_| format!("PostHog's answer is not JSON: {}", t.chars().take(200).collect::<String>())) }; } Err(ureq::Error::Status(429 | 500 | 502 | 503 | 504, r)) if attempt < 3 => { let wait = r.header("Retry-After").and_then(|s| s.parse::<u64>().ok()).map_or(300u64 << attempt, |s| s * 1000); std::thread::sleep(Duration::from_millis(wait.min(30_000))); } Err(ureq::Error::Status(c, r)) => { let t = r.into_string().unwrap_or_default(); let v: Value = serde_json::from_str(&t).unwrap_or(Value::Null); let what = v["detail"].as_str().map(str::to_string).unwrap_or_else(|| t.trim().chars().take(300).collect()); let code = v["code"].as_str().map_or(String::new(), |x| format!(" {}", x)); return Err(format!("PostHog {}{}: {}", c, code, what)); } Err(e) => return Err(format!("cannot reach PostHog at {}: {}", host(), e)), } } Err("PostHog stayed busy through four tries".into())}fn event_of(event: &str, distinct_id: &str, properties: Value, options: &Value) -> Result<Value, String> { if event.is_empty() { return Err("an event needs a name".into()); } if distinct_id.is_empty() { return Err("an event needs a distinct_id, or it belongs to no one".into()); } let mut props = match properties { Value::Object(m) => m, Value::Null => Map::new(), _ => return Err("properties must be a map".into()), }; if let Some(g) = options.get("groups").filter(|g| g.is_object()) { props.insert("$groups".into(), g.clone()); } let mut e = json!({ "event": event, "distinct_id": distinct_id, "properties": Value::Object(props) }); for k in ["timestamp", "uuid"] { if let Some(v) = options.get(k).filter(|v| v.is_string()) { e[k] = v.clone(); } } Ok(e)}/// Sends one event. `properties` is a map, and `options` may hold `groups` ({ company: "acme" }), `timestamp` (ISO 8601, for something that happened earlier) and `uuid` (so a repeat is kept once). Gives true.pub fn posthog_capture(event: &str, distinct_id: &str, properties: serde_json::Value, options: serde_json::Value) -> Result<bool, String> { let mut e = event_of(event, distinct_id, properties, &options)?; e["api_key"] = json!(key()?); post("/capture/", &e)?; Ok(true)}/// Sends many events: each { event, distinct_id, properties, timestamp, uuid, groups }, cut into batches of at most 1000 or 19 MB, as PostHog stops at 20. An event over 1 MB is an error before anything is sent. Gives { sent, batches }.pub fn posthog_batch(events: Vec<serde_json::Value>) -> Result<serde_json::Value, String> { let k = key()?; let mut made = vec![]; for (i, x) in events.iter().enumerate() { let name = x["event"].as_str().ok_or(format!("event {} has no `event`", i))?; let id = x["distinct_id"].as_str().ok_or(format!("event {} has no `distinct_id`", i))?; let e = event_of(name, id, x["properties"].clone(), x)?; let size = e.to_string().len(); if size > EVENT_BYTES { return Err(format!("event {} is {} bytes; PostHog takes at most {}", i, size, EVENT_BYTES)); } made.push((e, size)); } let mut batches = 0; let mut group: Vec<Value> = vec![]; let mut bytes = 0; for (e, size) in made { if group.len() == BATCH_EVENTS || bytes + size > BATCH_BYTES { post("/batch/", &json!({ "api_key": k, "batch": group }))?; batches += 1; group = vec![]; bytes = 0; } bytes += size; group.push(e); } if !group.is_empty() { post("/batch/", &json!({ "api_key": k, "batch": group }))?; batches += 1; } Ok(json!({ "sent": events.len(), "batches": batches }))}/// Sets a person's properties: `set` overwrites, `set_once` keeps what is there. Sent as PostHog's `$identify` event.pub fn posthog_identify(distinct_id: &str, set: serde_json::Value, set_once: serde_json::Value) -> Result<bool, String> { let mut p = Map::new(); if set.is_object() { p.insert("$set".into(), set); } if set_once.is_object() { p.insert("$set_once".into(), set_once); } if p.is_empty() { return Err("identify needs properties to set".into()); } posthog_capture("$identify", distinct_id, Value::Object(p), json!({}))}/// Says two ids are one person, as `$create_alias`. Do it once, when an anonymous visitor signs in.pub fn posthog_alias(distinct_id: &str, alias: &str) -> Result<bool, String> { if alias.is_empty() || alias == distinct_id { return Err("an alias must be another id".into()); } posthog_capture("$create_alias", distinct_id, json!({ "alias": alias }), json!({}))}/// Sets a group's properties (`$groupidentify`): `group_type` like "company", `group_key` like "acme". The event's distinct_id is PostHog's own for groups.pub fn posthog_group_identify(group_type: &str, group_key: &str, properties: serde_json::Value) -> Result<bool, String> { if group_type.is_empty() || group_key.is_empty() { return Err("a group needs a type and a key".into()); } let p = json!({ "$group_type": group_type, "$group_key": group_key, "$group_set": if properties.is_object() { properties } else { json!({}) } }); posthog_capture("$groupidentify", &format!("${}_{}", group_type, group_key), p, json!({}))}/// Evaluates every flag for a person through /decide: `options` may hold `groups`, `person_properties` and `group_properties`. Gives { flags: { key: true, false or a variant }, payloads: { key: value }, partial }: `partial` true means some could not be computed, so a flag not listed there is unknown, not off. A project over its flag quota is an error.pub fn posthog_flags(distinct_id: &str, options: serde_json::Value) -> Result<serde_json::Value, String> { if distinct_id.is_empty() { return Err("flags need a distinct_id".into()); } let mut b = json!({ "api_key": key()?, "distinct_id": distinct_id }); for k in ["groups", "person_properties", "group_properties"] { if let Some(v) = options.get(k).filter(|v| v.is_object()) { b[k] = v.clone(); } } let v = post("/decide/?v=3", &b)?; if v["quotaLimited"].as_array().map_or(false, |l| l.iter().any(|x| x == "feature_flags")) { return Err("PostHog's feature flag quota is spent: every flag would read as off".into()); } let mut payloads = Map::new(); for (k, p) in v["featureFlagPayloads"].as_object().into_iter().flatten() { // A payload arrives as JSON inside a string. let parsed = p.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_else(|| p.clone()); payloads.insert(k.clone(), parsed); } Ok(json!({ "flags": v["featureFlags"], "payloads": payloads, "partial": v["errorsWhileComputingFlags"] == json!(true) }))}/// One flag: true, false, a variant's name, or null if PostHog has no such flag for them.pub fn posthog_flag(distinct_id: &str, flag: &str, options: serde_json::Value) -> Result<serde_json::Value, String> { Ok(posthog_flags(distinct_id, options)?["flags"].get(flag).cloned().unwrap_or(Value::Null))}/// Whether a flag is on for a person, never an error: on any failure, or when it could not be computed, it gives `fallback`, so a page does not break with PostHog.pub fn posthog_enabled(distinct_id: &str, flag: &str, fallback: bool, options: serde_json::Value) -> bool { match posthog_flags(distinct_id, options) { Ok(f) => match f["flags"].get(flag) { Some(Value::Bool(b)) => *b, Some(Value::String(_)) => true, _ => fallback, }, Err(_) => fallback, }}/// Records that a person saw a flag (`$feature_flag_called`), which experiments are counted by.pub fn posthog_flag_called(distinct_id: &str, flag: &str, value: serde_json::Value) -> Result<bool, String> { let mut props = Map::new(); props.insert("$feature_flag".into(), json!(flag)); props.insert("$feature_flag_response".into(), value.clone()); props.insert(format!("$feature/{}", flag), value); posthog_capture("$feature_flag_called", distinct_id, Value::Object(props), json!({}))}Related
mixpanel.qwer · 1.0.0Mixpanel 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.4 views · 9 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 · 6 downloadsGoogle Analytics 4 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.3 views · 5 downloadsA/B tests ab_tests.qwer · 1.0.0A/B tests from start to end: variants that stay each user's, the visitors needed, and whether a difference is real by z-test, Welch's t-test, chance to win and a sample ratio check.7 views · 8 downloadsStatistical tests statistics.qwer · 1.0.0Statistical tests and regression beyond Quillo's stats: t-tests (one-sample, paired, Welch), ANOVA, chi-square tests, Mann-Whitney U, correlation with its p-value, confidence intervals, and multiple linear regression with standard errors and p-values.7 views · 7 downloadsThread 0 posts
On PostHog: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.