Link signups to users in telemetry via a stored device_id

Max Brunsfeld and Joseph Lyons created

Co-authored-by: Joseph Lyons <joseph@zed.dev>

Change summary

crates/client/src/client.rs                                     | 34 
crates/client/src/telemetry.rs                                  | 26 
crates/collab/migrations/20220913211150_create_signups.down.sql |  6 
crates/collab/migrations/20220913211150_create_signups.up.sql   | 12 
crates/collab/src/api.rs                                        | 80 +-
crates/collab/src/db.rs                                         | 41 
crates/collab/src/db_tests.rs                                   | 34 
7 files changed, 124 insertions(+), 109 deletions(-)

Detailed changes

crates/client/src/client.rs 🔗

@@ -14,9 +14,11 @@ use async_tungstenite::tungstenite::{
 };
 use futures::{future::LocalBoxFuture, FutureExt, SinkExt, StreamExt, TryStreamExt};
 use gpui::{
-    actions, serde_json::Value, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle,
-    AnyWeakViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
-    MutableAppContext, Task, View, ViewContext, ViewHandle,
+    actions,
+    serde_json::{json, Value},
+    AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AppContext,
+    AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task, View, ViewContext,
+    ViewHandle,
 };
 use http::HttpClient;
 use lazy_static::lazy_static;
@@ -52,13 +54,29 @@ lazy_static! {
 
 pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894";
 
-actions!(client, [Authenticate]);
+actions!(client, [Authenticate, TestTelemetry]);
 
-pub fn init(rpc: Arc<Client>, cx: &mut MutableAppContext) {
-    cx.add_global_action(move |_: &Authenticate, cx| {
-        let rpc = rpc.clone();
-        cx.spawn(|cx| async move { rpc.authenticate_and_connect(true, &cx).log_err().await })
+pub fn init(client: Arc<Client>, cx: &mut MutableAppContext) {
+    cx.add_global_action({
+        let client = client.clone();
+        move |_: &Authenticate, cx| {
+            let client = client.clone();
+            cx.spawn(
+                |cx| async move { client.authenticate_and_connect(true, &cx).log_err().await },
+            )
             .detach();
+        }
+    });
+    cx.add_global_action({
+        let client = client.clone();
+        move |_: &TestTelemetry, _| {
+            client.log_event(
+                "test_telemetry",
+                json!({
+                    "test_property": "test_value"
+                }),
+            )
+        }
     });
 }
 

crates/client/src/telemetry.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{http::HttpClient, ZED_SECRET_CLIENT_TOKEN};
+use crate::{http::HttpClient, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL};
 use gpui::{
     executor::Background,
     serde_json::{self, value::Map, Value},
@@ -22,7 +22,6 @@ pub struct Telemetry {
 
 #[derive(Default)]
 struct TelemetryState {
-    metrics_id: Option<i32>,
     device_id: Option<String>,
     app_version: Option<AppVersion>,
     os_version: Option<AppVersion>,
@@ -33,7 +32,6 @@ struct TelemetryState {
 #[derive(Serialize)]
 struct RecordEventParams {
     token: &'static str,
-    metrics_id: Option<i32>,
     device_id: Option<String>,
     app_version: Option<String>,
     os_version: Option<String>,
@@ -48,8 +46,13 @@ struct Event {
     properties: Option<Map<String, Value>>,
 }
 
-const MAX_QUEUE_LEN: usize = 30;
-const EVENTS_URI: &'static str = "https://zed.dev/api/telemetry";
+#[cfg(debug_assertions)]
+const MAX_QUEUE_LEN: usize = 1;
+
+#[cfg(not(debug_assertions))]
+const MAX_QUEUE_LEN: usize = 10;
+
+const EVENTS_URI: &'static str = "api/telemetry";
 const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(30);
 
 impl Telemetry {
@@ -61,7 +64,6 @@ impl Telemetry {
             state: Mutex::new(TelemetryState {
                 os_version: platform.os_version().log_err(),
                 app_version: platform.app_version().log_err(),
-                metrics_id: None,
                 device_id: None,
                 queue: Default::default(),
                 flush_task: Default::default(),
@@ -69,10 +71,6 @@ impl Telemetry {
         })
     }
 
-    pub fn set_metrics_id(&self, metrics_id: Option<i32>) {
-        self.state.lock().metrics_id = metrics_id;
-    }
-
     pub fn log_event(self: &Arc<Self>, kind: &str, properties: Value) {
         let mut state = self.state.lock();
         state.queue.push(Event {
@@ -88,6 +86,7 @@ impl Telemetry {
             },
         });
         if state.queue.len() >= MAX_QUEUE_LEN {
+            drop(state);
             self.flush();
         } else {
             let this = self.clone();
@@ -105,7 +104,6 @@ impl Telemetry {
         let client = self.client.clone();
         let app_version = state.app_version;
         let os_version = state.os_version;
-        let metrics_id = state.metrics_id;
         let device_id = state.device_id.clone();
         state.flush_task.take();
         self.executor
@@ -115,11 +113,13 @@ impl Telemetry {
                     events,
                     app_version: app_version.map(|v| v.to_string()),
                     os_version: os_version.map(|v| v.to_string()),
-                    metrics_id,
                     device_id,
                 })
                 .log_err()?;
-                let request = Request::post(EVENTS_URI).body(body.into()).log_err()?;
+                let request = Request::post(format!("{}/{}", *ZED_SERVER_URL, EVENTS_URI))
+                    .header("Content-Type", "application/json")
+                    .body(body.into())
+                    .log_err()?;
                 client.send(request).await.log_err();
                 Some(())
             })

crates/collab/migrations/20220913211150_create_signups.up.sql 🔗

@@ -1,12 +1,10 @@
-CREATE SEQUENCE metrics_id_seq;
-
 CREATE TABLE IF NOT EXISTS "signups" (
-    "id" SERIAL PRIMARY KEY NOT NULL,
+    "id" SERIAL PRIMARY KEY,
     "email_address" VARCHAR NOT NULL,
     "email_confirmation_code" VARCHAR(64) NOT NULL,
     "email_confirmation_sent" BOOLEAN NOT NULL,
-    "metrics_id" INTEGER NOT NULL DEFAULT nextval('metrics_id_seq'),
     "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "device_id" VARCHAR NOT NULL,
     "user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE,
     "inviting_user_id" INTEGER REFERENCES users (id) ON DELETE SET NULL,
 
@@ -23,11 +21,7 @@ CREATE UNIQUE INDEX "index_signups_on_email_address" ON "signups" ("email_addres
 CREATE INDEX "index_signups_on_email_confirmation_sent" ON "signups" ("email_confirmation_sent");
 
 ALTER TABLE "users"
-    ADD "github_user_id" INTEGER,
-    ADD "metrics_id" INTEGER DEFAULT nextval('metrics_id_seq');
+    ADD "github_user_id" INTEGER;
 
 CREATE INDEX "index_users_on_email_address" ON "users" ("email_address");
 CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id");
-
-UPDATE users
-SET metrics_id = nextval('metrics_id_seq');

crates/collab/src/api.rs 🔗

@@ -127,44 +127,52 @@ struct CreateUserParams {
     invite_count: i32,
 }
 
+#[derive(Serialize, Debug)]
+struct CreateUserResponse {
+    user: User,
+    signup_device_id: Option<String>,
+}
+
 async fn create_user(
     Json(params): Json<CreateUserParams>,
     Extension(app): Extension<Arc<AppState>>,
     Extension(rpc_server): Extension<Arc<rpc::Server>>,
-) -> Result<Json<User>> {
+) -> Result<Json<CreateUserResponse>> {
     let user = NewUserParams {
         github_login: params.github_login,
         github_user_id: params.github_user_id,
         invite_count: params.invite_count,
     };
-    let (user_id, inviter_id) =
-        // Creating a user via the normal signup process
-        if let Some(email_confirmation_code) = params.email_confirmation_code {
-            app.db
-                .create_user_from_invite(
-                    &Invite {
-                        email_address: params.email_address,
-                        email_confirmation_code,
-                    },
-                    user,
-                )
-                .await?
-        }
-        // Creating a user as an admin
-        else {
-            (
-                app.db
-                    .create_user(&params.email_address, false, user)
-                    .await?,
-                None,
+    let user_id;
+    let signup_device_id;
+    // Creating a user via the normal signup process
+    if let Some(email_confirmation_code) = params.email_confirmation_code {
+        let result = app
+            .db
+            .create_user_from_invite(
+                &Invite {
+                    email_address: params.email_address,
+                    email_confirmation_code,
+                },
+                user,
             )
-        };
-
-    if let Some(inviter_id) = inviter_id {
-        rpc_server
-            .invite_code_redeemed(inviter_id, user_id)
-            .await
-            .trace_err();
+            .await?;
+        user_id = result.0;
+        signup_device_id = Some(result.2);
+        if let Some(inviter_id) = result.1 {
+            rpc_server
+                .invite_code_redeemed(inviter_id, user_id)
+                .await
+                .trace_err();
+        }
+    }
+    // Creating a user as an admin
+    else {
+        user_id = app
+            .db
+            .create_user(&params.email_address, false, user)
+            .await?;
+        signup_device_id = None;
     }
 
     let user = app
@@ -173,7 +181,10 @@ async fn create_user(
         .await?
         .ok_or_else(|| anyhow!("couldn't find the user we just created"))?;
 
-    Ok(Json(user))
+    Ok(Json(CreateUserResponse {
+        user,
+        signup_device_id,
+    }))
 }
 
 #[derive(Deserialize)]
@@ -396,17 +407,12 @@ async fn get_user_for_invite_code(
     Ok(Json(app.db.get_user_for_invite_code(&code).await?))
 }
 
-#[derive(Serialize)]
-struct CreateSignupResponse {
-    metrics_id: i32,
-}
-
 async fn create_signup(
     Json(params): Json<Signup>,
     Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<CreateSignupResponse>> {
-    let metrics_id = app.db.create_signup(params).await?;
-    Ok(Json(CreateSignupResponse { metrics_id }))
+) -> Result<()> {
+    app.db.create_signup(params).await?;
+    Ok(())
 }
 
 async fn get_waitlist_summary(

crates/collab/src/db.rs 🔗

@@ -37,7 +37,7 @@ pub trait Db: Send + Sync {
     async fn get_user_for_invite_code(&self, code: &str) -> Result<User>;
     async fn create_invite_from_code(&self, code: &str, email_address: &str) -> Result<Invite>;
 
-    async fn create_signup(&self, signup: Signup) -> Result<i32>;
+    async fn create_signup(&self, signup: Signup) -> Result<()>;
     async fn get_waitlist_summary(&self) -> Result<WaitlistSummary>;
     async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>>;
     async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()>;
@@ -45,7 +45,7 @@ pub trait Db: Send + Sync {
         &self,
         invite: &Invite,
         user: NewUserParams,
-    ) -> Result<(UserId, Option<UserId>)>;
+    ) -> Result<(UserId, Option<UserId>, String)>;
 
     /// Registers a new project for the given user.
     async fn register_project(&self, host_user_id: UserId) -> Result<ProjectId>;
@@ -364,8 +364,8 @@ impl Db for PostgresDb {
 
     // signups
 
-    async fn create_signup(&self, signup: Signup) -> Result<i32> {
-        Ok(sqlx::query_scalar(
+    async fn create_signup(&self, signup: Signup) -> Result<()> {
+        sqlx::query(
             "
             INSERT INTO signups
             (
@@ -377,10 +377,11 @@ impl Db for PostgresDb {
                 platform_windows,
                 platform_unknown,
                 editor_features,
-                programming_languages
+                programming_languages,
+                device_id
             )
             VALUES
-                ($1, $2, 'f', $3, $4, $5, 'f', $6, $7)
+                ($1, $2, 'f', $3, $4, $5, 'f', $6, $7, $8)
             RETURNING id
             ",
         )
@@ -391,8 +392,10 @@ impl Db for PostgresDb {
         .bind(&signup.platform_windows)
         .bind(&signup.editor_features)
         .bind(&signup.programming_languages)
-        .fetch_one(&self.pool)
-        .await?)
+        .bind(&signup.device_id)
+        .execute(&self.pool)
+        .await?;
+        Ok(())
     }
 
     async fn get_waitlist_summary(&self) -> Result<WaitlistSummary> {
@@ -455,17 +458,17 @@ impl Db for PostgresDb {
         &self,
         invite: &Invite,
         user: NewUserParams,
-    ) -> Result<(UserId, Option<UserId>)> {
+    ) -> Result<(UserId, Option<UserId>, String)> {
         let mut tx = self.pool.begin().await?;
 
-        let (signup_id, metrics_id, existing_user_id, inviting_user_id): (
-            i32,
+        let (signup_id, existing_user_id, inviting_user_id, device_id): (
             i32,
             Option<UserId>,
             Option<UserId>,
+            String,
         ) = sqlx::query_as(
             "
-            SELECT id, metrics_id, user_id, inviting_user_id
+            SELECT id, user_id, inviting_user_id, device_id
             FROM signups
             WHERE
                 email_address = $1 AND
@@ -488,9 +491,9 @@ impl Db for PostgresDb {
         let user_id: UserId = sqlx::query_scalar(
             "
             INSERT INTO users
-                (email_address, github_login, github_user_id, admin, invite_count, invite_code, metrics_id)
+            (email_address, github_login, github_user_id, admin, invite_count, invite_code)
             VALUES
-                ($1, $2, $3, 'f', $4, $5, $6)
+            ($1, $2, $3, 'f', $4, $5)
             RETURNING id
             ",
         )
@@ -499,7 +502,6 @@ impl Db for PostgresDb {
         .bind(&user.github_user_id)
         .bind(&user.invite_count)
         .bind(random_invite_code())
-        .bind(metrics_id)
         .fetch_one(&mut tx)
         .await?;
 
@@ -550,7 +552,7 @@ impl Db for PostgresDb {
         }
 
         tx.commit().await?;
-        Ok((user_id, inviting_user_id))
+        Ok((user_id, inviting_user_id, device_id))
     }
 
     // invite codes
@@ -1567,7 +1569,6 @@ pub struct User {
     pub id: UserId,
     pub github_login: String,
     pub github_user_id: Option<i32>,
-    pub metrics_id: i32,
     pub email_address: Option<String>,
     pub admin: bool,
     pub invite_code: Option<String>,
@@ -1674,6 +1675,7 @@ pub struct Signup {
     pub platform_linux: bool,
     pub editor_features: Vec<String>,
     pub programming_languages: Vec<String>,
+    pub device_id: String,
 }
 
 #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, FromRow)]
@@ -1802,7 +1804,6 @@ mod test {
                         github_login: params.github_login,
                         github_user_id: Some(params.github_user_id),
                         email_address: Some(email_address.to_string()),
-                        metrics_id: id + 100,
                         admin,
                         invite_code: None,
                         invite_count: 0,
@@ -1884,7 +1885,7 @@ mod test {
 
         // signups
 
-        async fn create_signup(&self, _signup: Signup) -> Result<i32> {
+        async fn create_signup(&self, _signup: Signup) -> Result<()> {
             unimplemented!()
         }
 
@@ -1904,7 +1905,7 @@ mod test {
             &self,
             _invite: &Invite,
             _user: NewUserParams,
-        ) -> Result<(UserId, Option<UserId>)> {
+        ) -> Result<(UserId, Option<UserId>, String)> {
             unimplemented!()
         }
 

crates/collab/src/db_tests.rs 🔗

@@ -957,7 +957,7 @@ async fn test_invite_codes() {
         .create_invite_from_code(&invite_code, "u2@example.com")
         .await
         .unwrap();
-    let (user2, inviter) = db
+    let (user2, inviter, _) = db
         .create_user_from_invite(
             &user2_invite,
             NewUserParams {
@@ -1007,7 +1007,7 @@ async fn test_invite_codes() {
         .create_invite_from_code(&invite_code, "u3@example.com")
         .await
         .unwrap();
-    let (user3, inviter) = db
+    let (user3, inviter, _) = db
         .create_user_from_invite(
             &user3_invite,
             NewUserParams {
@@ -1072,7 +1072,7 @@ async fn test_invite_codes() {
         .create_invite_from_code(&invite_code, "u4@example.com")
         .await
         .unwrap();
-    let (user4, _) = db
+    let (user4, _, _) = db
         .create_user_from_invite(
             &user4_invite,
             NewUserParams {
@@ -1139,20 +1139,18 @@ async fn test_signups() {
     let db = postgres.db();
 
     // people sign up on the waitlist
-    let mut signup_metric_ids = Vec::new();
     for i in 0..8 {
-        signup_metric_ids.push(
-            db.create_signup(Signup {
-                email_address: format!("person-{i}@example.com"),
-                platform_mac: true,
-                platform_linux: i % 2 == 0,
-                platform_windows: i % 4 == 0,
-                editor_features: vec!["speed".into()],
-                programming_languages: vec!["rust".into(), "c".into()],
-            })
-            .await
-            .unwrap(),
-        );
+        db.create_signup(Signup {
+            email_address: format!("person-{i}@example.com"),
+            platform_mac: true,
+            platform_linux: i % 2 == 0,
+            platform_windows: i % 4 == 0,
+            editor_features: vec!["speed".into()],
+            programming_languages: vec!["rust".into(), "c".into()],
+            device_id: format!("device_id_{i}"),
+        })
+        .await
+        .unwrap();
     }
 
     assert_eq!(
@@ -1219,7 +1217,7 @@ async fn test_signups() {
 
     // user completes the signup process by providing their
     // github account.
-    let (user_id, inviter_id) = db
+    let (user_id, inviter_id, signup_device_id) = db
         .create_user_from_invite(
             &Invite {
                 email_address: signups_batch1[0].email_address.clone(),
@@ -1238,7 +1236,7 @@ async fn test_signups() {
     assert_eq!(user.github_login, "person-0");
     assert_eq!(user.email_address.as_deref(), Some("person-0@example.com"));
     assert_eq!(user.invite_count, 5);
-    assert_eq!(user.metrics_id, signup_metric_ids[0]);
+    assert_eq!(signup_device_id, "device_id_0");
 
     // cannot redeem the same signup again.
     db.create_user_from_invite(