Hello world! In this article, we’re going to talk about how you can use Posthog with Rust to be able to track user behavior across an application. Analytics are more important than ever to be able to improve your product; Posthog makes it easy to do exactly that.
How does PostHog work?
Posthog works by allowing you to capture events and then convert those events into “insights” by aggregating the data. PostHog allows you to then query the data using HogQL (a DSL made by PostHog for querying data,, similar to SQL). Alhough there isn’t an officially supported Rust SDK for PostHog, they do have a community-supported one (posthog-rs
) that can capture events in your application.
We’ll also look at using reqwest
to set up our own custom client for sending requests to the PostHog API!
Capturing events
We can get started with the posthog-rs
client by creating it:
use posthog::{ client, Event };
use std::env;
let posthog_api_key = env::var("POSTHOG_API_KEY").expect("POSTHOG_API_KEY env var missing");
let client = client(posthog_api_key);
The event essentially allows us to add anything we want – for example, we can add user IDs, user cookies or timestamps and whatever else we need.
Next, we’ll want to make a function for sending an event to the PostHog API:
fn send_posthog_event(client: &posthog::Client) {
let mut event = Event::new("my_database_event", "1234");
event.insert_prop("action", "insert").unwrap();
client.capture(event).unwrap();
}
Now whenever we need to track an action, we can do so. Below is an example of using the client in an Axum handler function to be able to send an event:
use axum::{http::StatusCode, extract::State};
#[derive(Clone)]
struct AppState {
db: sqlx::PgPool,
posthog: posthog::Client
}
async fn add_to_database(
State(state): State<AppState>
) -> StatusCode {
let res = sqlx::query("SELECT * FROM USERS")
.fetch_all(&state.db)
.await
.unwrap();
send_posthog_event(&state.client);
StatusCode::OK
}
Using the posthog-rs client in an async context
It should be noted that the posthog-rs
client is not async-friendly due to using the blocking
feature of the reqwest
crate. This means that if you want to use it with async (for example in a web service), you will need to use tokio::task::block_in_place()
like so:
use axum::{Extension, StatusCode, response::IntoResponse};
async fn my_example_axum_handler(
Extension(client): Extension<posthog::Client>
) -> impl IntoResponse {
tokio::task::block_in_place(|| move {
let mut evt = Event::new("my_database_event", "Hello world!");
evt.insert_prop("action", "insert").unwrap();
client.capture(evt).unwrap();
});
StatusCode::OK
}
Alternatively, Shuttle has a fork of a fork of posthog-rs
that contains an async client. You can find out more here. Due to being a fork it’s not an officially published crate, but you can add it to your Rust project with this shell snippet:
cargo add posthog-async --git https://github.com/shuttle-hq/posthog-rs.git --branch main
Now when you refer to it in your Rust files, you can use posthog_async
as the dependency name.
Using a custom client
In this section, we’re going to create a custom client for working with Posthog. We’ll get started by installing reqwest
, which is a library for writing HTTP requests and is async by default (requiring a feature to be blocking):
cargo add reqwest
Next, we will want to write a function that we can use to make our request client easily. We’ll need to add bearer authorization on the header, which we can do like so:
let posthog_api_key = std::env::var("POSTHOG_API_KEY")
.expect("Could not find POSTHOG_API_KEY environment variable");
let client = reqwest::Client::builder()
.header("Authorization", format!("Bearer {}"))
.build().unwrap();
Now whenever we use client
, it will always have the appropriate header attached.
Using PostHog insights
Creating insights
Insights in Posthog are the main building blocks of dashboards and allow you to visualise how users use your product. To create insights, we can use our reqwest::Client
to send an API key manually. There’s quite a few endpoints – we’ll be making POST requests to /api/projects/:project_id/insights/
for this example.
To be able to set the JSON body, we will need to make use of the serde_json
crate which we can install with this shell snippet:
cargo add serde_json
Next, we’ll want to make a JSON map – which we can do below with the serde_json::json!()
macro:
use serde_json::json;
fn my_json_body() -> serde_json::Value {
json!({
"name": "my_insight",
})
}
You can find a more extensive list of request body parameters here. Once you’re finished creating the request body you want to send to Posthog, you can then create the request using the client we created.
async fn create_insight(
client: request::Client,
project_id: String
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://app.posthog.com/api/projects/{project_id}/insights/"
);
let json_body = my_json_body();
let response = client.post(&url).json(json_body).send.await?;
}
However, this is just the start of insight creation. To make our insight do anything, we need to add a funnel to it. Similarly to the last API endpoint where we attached some basic values, we’ll create a new JSON body using the json!()
macro and then send it:
async fn create_insight_funnel(
client: request::Client,
project_id: String
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://app.posthog.com/api/projects/{project_id}/insights/"
);
let json_body = json!({
events: [{"id":"my_database_event"}],
date_from: "-1m"
})
let response = client.post(&url).json(json_body).send.await?;
}
Here, we’ve specified in the JSON body we want to get events from up to a month ago and the name of the event we want to get is my_database_event
, which we created earlier. Note that the $pageview
event signifies any time a user views a page that PostHog is added to.
Retrieving an insight
Once you have some events and insights built up, it’s time to retrieve them! You can do this by making a GET request to the insights URL. See below:
async fn get_insights(
client: request::Client,
project_id: String
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://app.posthog.com/api/projects/{project_id}/insights/"
);
let response = client.get(&url).send.await?;
}
When you get the response, you will need to turn it into a struct that implements Deserialize
. Thankfully, reqwest
provides a method for turning the response into JSON-compatible structs. Let’s make a struct that represents some of the properties of the JSON response from PostHog. serde
completely ignores undeclared fields by default, allowing us to only take what we need.
#[derive(Deserialize, Debug)]
struct PosthogResponse {
count: i32,
next: String,
previous: String,
results: Vec<PosthogResult>
}
#[derive(Deserialize, Debug)]
struct PosthogResult {
id: String,
name: String,
deleted: bool
}
Now we can expand our previous function to include the struct conversion:
async fn get_insights(
client: request::Client,
project_id: String
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://app.posthog.com/api/projects/{project_id}/insights/"
);
let response = client.get(&url).send.await?;
let json: PosthogResponse = response.json().await?;
println!("{json:?}");
Ok(())
}
Finishing Up
Posthog makes it easy to track how users are using your application and find easy wins. While the Rust SDK is not complete, hopefully you’ve found some clarity in communicating with the Posthog API with Rust!
Read more: