Merge pull request #1375 from zed-industries/active-user-counts

Max Brunsfeld created

Add an admin API for counting users with given amounts of activity

Change summary

crates/collab/src/api.rs | 40 +++++++++++++++++++++
crates/collab/src/db.rs  | 78 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 116 insertions(+), 2 deletions(-)

Detailed changes

crates/collab/src/api.rs 🔗

@@ -17,7 +17,7 @@ use axum::{
 use axum_extra::response::ErasedJson;
 use serde::{Deserialize, Serialize};
 use serde_json::json;
-use std::sync::Arc;
+use std::{sync::Arc, time::Duration};
 use time::OffsetDateTime;
 use tower::ServiceBuilder;
 use tracing::instrument;
@@ -43,6 +43,7 @@ pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Bod
             "/user_activity/timeline/:user_id",
             get(get_user_activity_timeline),
         )
+        .route("/user_activity/counts", get(get_active_user_counts))
         .route("/project_metadata", get(get_project_metadata))
         .layer(
             ServiceBuilder::new()
@@ -298,6 +299,43 @@ async fn get_user_activity_timeline(
     Ok(ErasedJson::pretty(summary))
 }
 
+#[derive(Deserialize)]
+struct ActiveUserCountParams {
+    #[serde(flatten)]
+    period: TimePeriodParams,
+    durations_in_minutes: String,
+}
+
+#[derive(Serialize)]
+struct ActiveUserSet {
+    active_time_in_minutes: u64,
+    user_count: usize,
+}
+
+async fn get_active_user_counts(
+    Query(params): Query<ActiveUserCountParams>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<ErasedJson> {
+    let durations_in_minutes = params.durations_in_minutes.split(',');
+    let mut user_sets = Vec::new();
+    for duration in durations_in_minutes {
+        let duration = duration
+            .parse()
+            .map_err(|_| anyhow!("invalid duration: {duration}"))?;
+        user_sets.push(ActiveUserSet {
+            active_time_in_minutes: duration,
+            user_count: app
+                .db
+                .get_active_user_count(
+                    params.period.start..params.period.end,
+                    Duration::from_secs(duration * 60),
+                )
+                .await?,
+        })
+    }
+    Ok(ErasedJson::pretty(user_sets))
+}
+
 #[derive(Deserialize)]
 struct GetProjectMetadataParams {
     project_id: u64,

crates/collab/src/db.rs 🔗

@@ -69,6 +69,14 @@ pub trait Db: Send + Sync {
         active_projects: &[(UserId, ProjectId)],
     ) -> Result<()>;
 
+    /// Get the number of users who have been active in the given
+    /// time period for at least the given time duration.
+    async fn get_active_user_count(
+        &self,
+        time_period: Range<OffsetDateTime>,
+        min_duration: Duration,
+    ) -> Result<usize>;
+
     /// Get the users that have been most active during the given time period,
     /// along with the amount of time they have been active in each project.
     async fn get_top_users_activity_summary(
@@ -593,6 +601,40 @@ impl Db for PostgresDb {
         Ok(())
     }
 
+    async fn get_active_user_count(
+        &self,
+        time_period: Range<OffsetDateTime>,
+        min_duration: Duration,
+    ) -> Result<usize> {
+        let query = "
+            WITH
+                project_durations AS (
+                    SELECT user_id, project_id, SUM(duration_millis) AS project_duration
+                    FROM project_activity_periods
+                    WHERE $1 < ended_at AND ended_at <= $2
+                    GROUP BY user_id, project_id
+                ),
+                user_durations AS (
+                    SELECT user_id, SUM(project_duration) as total_duration
+                    FROM project_durations
+                    GROUP BY user_id
+                    ORDER BY total_duration DESC
+                    LIMIT $3
+                )
+            SELECT count(user_durations.user_id)
+            FROM user_durations
+            WHERE user_durations.total_duration >= $3
+        ";
+
+        let count: i64 = sqlx::query_scalar(query)
+            .bind(time_period.start)
+            .bind(time_period.end)
+            .bind(min_duration.as_millis() as i64)
+            .fetch_one(&self.pool)
+            .await?;
+        Ok(count as usize)
+    }
+
     async fn get_top_users_activity_summary(
         &self,
         time_period: Range<OffsetDateTime>,
@@ -1544,7 +1586,7 @@ pub mod tests {
     }
 
     #[tokio::test(flavor = "multi_thread")]
-    async fn test_project_activity() {
+    async fn test_user_activity() {
         let test_db = TestDb::postgres().await;
         let db = test_db.db();
 
@@ -1641,6 +1683,32 @@ pub mod tests {
                 },
             ]
         );
+
+        assert_eq!(
+            db.get_active_user_count(t0..t6, Duration::from_secs(56))
+                .await
+                .unwrap(),
+            0
+        );
+        assert_eq!(
+            db.get_active_user_count(t0..t6, Duration::from_secs(54))
+                .await
+                .unwrap(),
+            1
+        );
+        assert_eq!(
+            db.get_active_user_count(t0..t6, Duration::from_secs(30))
+                .await
+                .unwrap(),
+            2
+        );
+        assert_eq!(
+            db.get_active_user_count(t0..t6, Duration::from_secs(10))
+                .await
+                .unwrap(),
+            3
+        );
+
         assert_eq!(
             db.get_user_activity_timeline(t3..t6, user_1).await.unwrap(),
             &[
@@ -2477,6 +2545,14 @@ pub mod tests {
             unimplemented!()
         }
 
+        async fn get_active_user_count(
+            &self,
+            _time_period: Range<OffsetDateTime>,
+            _min_duration: Duration,
+        ) -> Result<usize> {
+            unimplemented!()
+        }
+
         async fn get_top_users_activity_summary(
             &self,
             _time_period: Range<OffsetDateTime>,