payment informatics #2 (#1705)

This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-06-29 21:21:26 -04:00
1 parent 3e4f998599
commit d99da56df0
38 files changed
+2022

No files matched your search

@@ -0,0 +1,58 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n payment_occurred_at AS `payment_occurred_at!: DateTime<Utc>`,\n amount_usd_cents,\n event_type AS `event_type!: PaymentEventType`,\n maybe_credits_granted AS `maybe_credits_granted: u32`\nFROM user_spend_events\nWHERE maybe_user_token = ?\n AND payments_namespace = ?\n AND is_production = TRUE\nORDER BY payment_occurred_at ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "payment_occurred_at!: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 1,
"name": "amount_usd_cents",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | NO_DEFAULT_VALUE",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 2,
"name": "event_type!: PaymentEventType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_credits_granted: u32",
"type_info": {
"type": "Long",
"flags": "UNSIGNED",
"char_set": 63,
"max_size": 10
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "1c997019b7ed18059bc08d857918c3ce3f5e6ed56d358894cbc496d345566519"
}
@@ -0,0 +1,113 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n maybe_user_token AS `user_token!: UserToken`,\n payments_namespace AS `payments_namespace!: PaymentsNamespace`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0\n AND event_type IN ('subscription_initial', 'subscription_renewal', 'subscription_proration_upgrade')\n THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `subscription_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 AND event_type = 'credit_pack_purchase'\n THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `credits_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,\n CAST(SUM(CASE WHEN amount_usd_cents < 0 THEN -amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,\n CAST(SUM(amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,\n CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,\n CAST(COALESCE(SUM(maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n AND payment_occurred_at >= ?\n AND payment_occurred_at < ?\nGROUP BY maybe_user_token, payments_namespace\nHAVING SUM(CASE WHEN amount_usd_cents <> 0 THEN 1 ELSE 0 END) > 0\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token!: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "payments_namespace!: PaymentsNamespace",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "subscription_spend_usd_cents!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 3,
"name": "credits_spend_usd_cents!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 4,
"name": "gross_spend_usd_cents!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 5,
"name": "refund_usd_cents!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 6,
"name": "net_spend_usd_cents!: i64",
"type_info": {
"type": "LongLong",
"flags": "BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 7,
"name": "payment_count!: u64",
"type_info": {
"type": "LongLong",
"flags": "UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
},
{
"ordinal": 8,
"name": "credits_granted!: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | UNSIGNED | BINARY",
"char_set": 63,
"max_size": 21
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
true,
true,
true,
true,
true,
true,
false
]
},
"hash": "83fc05a3594b379482745f851cd5a22786adde9968dd70490c91100cf21f8023"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO user_daily_spends\nSET\n user_token = ?,\n payments_namespace = ?,\n spend_date = ?,\n subscription_spend_usd_cents = ?,\n credits_spend_usd_cents = ?,\n gross_spend_usd_cents = ?,\n refund_usd_cents = ?,\n net_spend_usd_cents = ?,\n payment_count = ?,\n credits_granted = ?\nON DUPLICATE KEY UPDATE\n subscription_spend_usd_cents = VALUES(subscription_spend_usd_cents),\n credits_spend_usd_cents = VALUES(credits_spend_usd_cents),\n gross_spend_usd_cents = VALUES(gross_spend_usd_cents),\n refund_usd_cents = VALUES(refund_usd_cents),\n net_spend_usd_cents = VALUES(net_spend_usd_cents),\n payment_count = VALUES(payment_count),\n credits_granted = VALUES(credits_granted)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "b7a5fea76da70c03b8744c985940a5bcdabdb3316aa407c5ca883bca863ee633"
}
@@ -0,0 +1,25 @@
{
"db_name": "MySQL",
"query": "\nSELECT DISTINCT maybe_user_token AS `user_token!: UserToken`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n AND payments_namespace = ?\n AND maybe_user_token > ?\nORDER BY maybe_user_token\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_token!: UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
true
]
},
"hash": "bcd07050d5557bf59d6b2691478763b5b067447d295b002f6e766209ea077f42"
}
@@ -0,0 +1,25 @@
{
"db_name": "MySQL",
"query": "\nSELECT DISTINCT payments_namespace AS `payments_namespace!: PaymentsNamespace`\nFROM user_spend_events\nWHERE maybe_user_token IS NOT NULL\n AND is_production = TRUE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "payments_namespace!: PaymentsNamespace",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "cf8c702126bba73cd3abc2ed7202962b6da773460505222a75408dab1d26b70f"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO user_spend_summaries\nSET\n payments_namespace = ?,\n user_token = ?,\n lifetime_gross_spend_usd_cents = ?,\n lifetime_subscription_spend_usd_cents = ?,\n lifetime_credits_spend_usd_cents = ?,\n lifetime_refund_usd_cents = ?,\n lifetime_net_spend_usd_cents = ?,\n lifetime_payment_count = ?,\n lifetime_refund_count = ?,\n first_payment_at = ?,\n first_spend_usd_cents = ?,\n last_payment_at = ?,\n last_spend_usd_cents = ?,\n days_since_first_payment = ?,\n days_since_last_payment = ?,\n net_spend_this_year_usd_cents = ?,\n consecutive_active_weeks = ?,\n consecutive_inactive_weeks = ?,\n weeks_since_last_spend = ?,\n is_active_subscriber = ?,\n maybe_subscription_interval = ?,\n maybe_reengagement_score = ?\nON DUPLICATE KEY UPDATE\n lifetime_gross_spend_usd_cents = VALUES(lifetime_gross_spend_usd_cents),\n lifetime_subscription_spend_usd_cents = VALUES(lifetime_subscription_spend_usd_cents),\n lifetime_credits_spend_usd_cents = VALUES(lifetime_credits_spend_usd_cents),\n lifetime_refund_usd_cents = VALUES(lifetime_refund_usd_cents),\n lifetime_net_spend_usd_cents = VALUES(lifetime_net_spend_usd_cents),\n lifetime_payment_count = VALUES(lifetime_payment_count),\n lifetime_refund_count = VALUES(lifetime_refund_count),\n first_payment_at = VALUES(first_payment_at),\n first_spend_usd_cents = VALUES(first_spend_usd_cents),\n last_payment_at = VALUES(last_payment_at),\n last_spend_usd_cents = VALUES(last_spend_usd_cents),\n days_since_first_payment = VALUES(days_since_first_payment),\n days_since_last_payment = VALUES(days_since_last_payment),\n net_spend_this_year_usd_cents = VALUES(net_spend_this_year_usd_cents),\n consecutive_active_weeks = VALUES(consecutive_active_weeks),\n consecutive_inactive_weeks = VALUES(consecutive_inactive_weeks),\n weeks_since_last_spend = VALUES(weeks_since_last_spend),\n is_active_subscriber = VALUES(is_active_subscriber),\n maybe_subscription_interval = VALUES(maybe_subscription_interval),\n maybe_reengagement_score = VALUES(maybe_reengagement_score)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 22
},
"nullable": []
},
"hash": "f4df0d23d95aded2f55d464344b32a6db63e406cff009325ce92dbdd0932809c"
}
@@ -0,0 +1,47 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n maybe_stripe_subscription_status,\n maybe_stripe_recurring_interval,\n subscription_expires_at AS `subscription_expires_at!: DateTime<Utc>`\nFROM user_subscriptions\nWHERE user_token = ?\n AND subscription_namespace = ?\nORDER BY subscription_expires_at DESC\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "maybe_stripe_subscription_status",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "maybe_stripe_recurring_interval",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "subscription_expires_at!: DateTime<Utc>",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
true,
false
]
},
"hash": "f74615faa4321ec4d583829997e12cb8ae152afefd2574385c120c63278893d2"
}
Generated
+30
View File
@@ -13704,6 +13704,36 @@ dependencies = [
"url",
]
[[package]]
name = "user-spend-analytics-job"
version = "0.0.1"
dependencies = [
"actix-web",
"actix_helpers",
"anyhow",
"bootstrap",
"chrono",
"concurrency",
"easyenv",
"enums",
"errors",
"jobs_common",
"log",
"mysql_queries",
"pager",
"rootly_client",
"rootly_config",
"serde",
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"sqlx",
"tokens",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "user_input_common"
version = "0.0.1"
+1
View File
@@ -136,6 +136,7 @@ members = [
"crates/service/job/grok_api_job",
"crates/service/job/seedance2_pro_job",
"crates/service/job/video_thumbnail_job",
"crates/service/job/user_spend_analytics_job",
"crates/service/job/worldlabs_job",
"crates/service/web/dummy_service",
"crates/service/web/storyteller_web",
@@ -1,3 +1,5 @@
pub const ROOTLY_SERVICE_ID_STORYTELLER_WEB: &str = "9ebebb09-0c56-4c8a-8f57-d5f0f85f3f16";
pub const ROOTLY_SERVICE_ID_SEEDANCE2_PRO_JOB: &str = "2a78f884-c42c-4745-b826-b7a572780ea0";
pub const ROOTLY_SERVICE_ID_VIDEO_THUMBNAIL_JOB: &str = "9d22997c-4474-47cb-af7b-53119eed1a88";
// TODO: create the Rootly service for this job and replace this placeholder id.
pub const ROOTLY_SERVICE_ID_USER_SPEND_ANALYTICS_JOB: &str = "00000000-0000-0000-0000-000000000000";
@@ -42,7 +42,9 @@ pub mod uploaded_videos;
pub mod user_impersonation_requests;
pub mod user_referral_codes;
pub mod user_referrals;
pub mod user_daily_spends;
pub mod user_spend_events;
pub mod user_spend_summaries;
pub mod users;
pub mod vocoder;
pub mod voice_clone_requests;
@@ -0,0 +1,76 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use enums::common::payments_namespace::PaymentsNamespace;
use tokens::tokens::users::UserToken;
/// One (user, namespace) aggregate of spend activity for a single UTC day,
/// computed from `user_spend_events`. Only produced for (user, day) pairs that
/// actually had spend or refund activity (sparse — see the HAVING clause).
pub struct DailySpendAggregate {
pub user_token: UserToken,
pub payments_namespace: PaymentsNamespace,
pub subscription_spend_usd_cents: u64,
pub credits_spend_usd_cents: u64,
pub gross_spend_usd_cents: u64,
pub refund_usd_cents: u64,
pub net_spend_usd_cents: i64,
pub payment_count: u64,
pub credits_granted: u64,
}
pub struct AggregateDailySpendsForDateArgs<'c, E>
where
E: Executor<'c, Database = MySql>,
{
/// Inclusive start of the UTC day.
pub range_start: DateTime<Utc>,
/// Exclusive end of the UTC day (start + 1 day).
pub range_end: DateTime<Utc>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Aggregate spend events for one UTC day, grouped by (user, namespace). Rows
/// with neither spend nor refund are excluded so `user_daily_spends` stays
/// sparse. Only attributed events (non-null user) are counted.
pub async fn aggregate_daily_spends_for_date<'c, E>(
args: AggregateDailySpendsForDateArgs<'c, E>,
) -> Result<Vec<DailySpendAggregate>, sqlx::Error>
where
E: Executor<'c, Database = MySql>,
{
let rows = sqlx::query_as!(
DailySpendAggregate,
r#"
SELECT
maybe_user_token AS `user_token!: UserToken`,
payments_namespace AS `payments_namespace!: PaymentsNamespace`,
CAST(SUM(CASE WHEN amount_usd_cents > 0
AND event_type IN ('subscription_initial', 'subscription_renewal', 'subscription_proration_upgrade')
THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `subscription_spend_usd_cents!: u64`,
CAST(SUM(CASE WHEN amount_usd_cents > 0 AND event_type = 'credit_pack_purchase'
THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `credits_spend_usd_cents!: u64`,
CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `gross_spend_usd_cents!: u64`,
CAST(SUM(CASE WHEN amount_usd_cents < 0 THEN -amount_usd_cents ELSE 0 END) AS UNSIGNED) AS `refund_usd_cents!: u64`,
CAST(SUM(amount_usd_cents) AS SIGNED) AS `net_spend_usd_cents!: i64`,
CAST(SUM(CASE WHEN amount_usd_cents > 0 THEN 1 ELSE 0 END) AS UNSIGNED) AS `payment_count!: u64`,
CAST(COALESCE(SUM(maybe_credits_granted), 0) AS UNSIGNED) AS `credits_granted!: u64`
FROM user_spend_events
WHERE maybe_user_token IS NOT NULL
AND is_production = TRUE
AND payment_occurred_at >= ?
AND payment_occurred_at < ?
GROUP BY maybe_user_token, payments_namespace
HAVING SUM(CASE WHEN amount_usd_cents <> 0 THEN 1 ELSE 0 END) > 0
"#,
args.range_start,
args.range_end,
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows)
}
@@ -0,0 +1,2 @@
pub mod aggregate_daily_spends_for_date;
pub mod upsert_user_daily_spend;
@@ -0,0 +1,73 @@
use std::marker::PhantomData;
use chrono::NaiveDate;
use sqlx::{Executor, MySql};
use enums::common::payments_namespace::PaymentsNamespace;
use tokens::tokens::users::UserToken;
/// Upsert one `user_daily_spends` row (recompute-overwrite on the
/// (user, namespace, date) unique key).
pub struct UpsertUserDailySpendArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub user_token: &'e UserToken,
pub payments_namespace: PaymentsNamespace,
pub spend_date: NaiveDate,
pub subscription_spend_usd_cents: u64,
pub credits_spend_usd_cents: u64,
pub gross_spend_usd_cents: u64,
pub refund_usd_cents: u64,
pub net_spend_usd_cents: i64,
pub payment_count: u32,
pub credits_granted: u64,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn upsert_user_daily_spend<'e, 'c: 'e, E>(
args: UpsertUserDailySpendArgs<'e, 'c, E>,
) -> Result<(), sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
sqlx::query!(
r#"
INSERT INTO user_daily_spends
SET
user_token = ?,
payments_namespace = ?,
spend_date = ?,
subscription_spend_usd_cents = ?,
credits_spend_usd_cents = ?,
gross_spend_usd_cents = ?,
refund_usd_cents = ?,
net_spend_usd_cents = ?,
payment_count = ?,
credits_granted = ?
ON DUPLICATE KEY UPDATE
subscription_spend_usd_cents = VALUES(subscription_spend_usd_cents),
credits_spend_usd_cents = VALUES(credits_spend_usd_cents),
gross_spend_usd_cents = VALUES(gross_spend_usd_cents),
refund_usd_cents = VALUES(refund_usd_cents),
net_spend_usd_cents = VALUES(net_spend_usd_cents),
payment_count = VALUES(payment_count),
credits_granted = VALUES(credits_granted)
"#,
args.user_token.as_str(),
args.payments_namespace.to_str(),
args.spend_date,
args.subscription_spend_usd_cents,
args.credits_spend_usd_cents,
args.gross_spend_usd_cents,
args.refund_usd_cents,
args.net_spend_usd_cents,
args.payment_count,
args.credits_granted,
)
.execute(args.mysql_executor)
.await?;
Ok(())
}
@@ -0,0 +1,34 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use enums::common::payments_namespace::PaymentsNamespace;
pub struct ListPaymentsNamespacesWithSpendActivityArgs<'c, E>
where
E: Executor<'c, Database = MySql>,
{
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// The distinct payment namespaces that have any attributed spend activity.
pub async fn list_payments_namespaces_with_spend_activity<'c, E>(
args: ListPaymentsNamespacesWithSpendActivityArgs<'c, E>,
) -> Result<Vec<PaymentsNamespace>, sqlx::Error>
where
E: Executor<'c, Database = MySql>,
{
let rows = sqlx::query_scalar!(
r#"
SELECT DISTINCT payments_namespace AS `payments_namespace!: PaymentsNamespace`
FROM user_spend_events
WHERE maybe_user_token IS NOT NULL
AND is_production = TRUE
"#,
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows)
}
@@ -0,0 +1,55 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use enums::by_table::user_spend_events::payment_event_type::PaymentEventType;
use enums::common::payments_namespace::PaymentsNamespace;
use tokens::tokens::users::UserToken;
/// A single spend event, trimmed to the fields the summary aggregation needs.
pub struct UserSpendEventRow {
pub payment_occurred_at: DateTime<Utc>,
pub amount_usd_cents: i64,
pub event_type: PaymentEventType,
pub maybe_credits_granted: Option<u32>,
}
pub struct ListUserSpendEventsForUserArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub user_token: &'e UserToken,
pub payments_namespace: PaymentsNamespace,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn list_user_spend_events_for_user<'e, 'c: 'e, E>(
args: ListUserSpendEventsForUserArgs<'e, 'c, E>,
) -> Result<Vec<UserSpendEventRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let rows = sqlx::query_as!(
UserSpendEventRow,
r#"
SELECT
payment_occurred_at AS `payment_occurred_at!: DateTime<Utc>`,
amount_usd_cents,
event_type AS `event_type!: PaymentEventType`,
maybe_credits_granted AS `maybe_credits_granted: u32`
FROM user_spend_events
WHERE maybe_user_token = ?
AND payments_namespace = ?
AND is_production = TRUE
ORDER BY payment_occurred_at ASC
"#,
args.user_token.as_str(),
args.payments_namespace.to_str(),
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows)
}
@@ -0,0 +1,46 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use enums::common::payments_namespace::PaymentsNamespace;
use tokens::tokens::users::UserToken;
/// Keyset page of distinct user tokens with spend activity in one namespace.
/// Ordered by `user_token`; start with an empty `after_user_token`.
pub struct ListUserTokensWithSpendActivityArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub payments_namespace: PaymentsNamespace,
pub after_user_token: &'e str,
pub limit: i64,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn list_user_tokens_with_spend_activity<'e, 'c: 'e, E>(
args: ListUserTokensWithSpendActivityArgs<'e, 'c, E>,
) -> Result<Vec<UserToken>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let rows = sqlx::query_scalar!(
r#"
SELECT DISTINCT maybe_user_token AS `user_token!: UserToken`
FROM user_spend_events
WHERE maybe_user_token IS NOT NULL
AND is_production = TRUE
AND payments_namespace = ?
AND maybe_user_token > ?
ORDER BY maybe_user_token
LIMIT ?
"#,
args.payments_namespace.to_str(),
args.after_user_token,
args.limit,
)
.fetch_all(args.mysql_executor)
.await?;
Ok(rows)
}
@@ -0,0 +1,4 @@
pub mod list_payments_namespaces_with_spend_activity;
pub mod list_user_tokens_with_spend_activity;
pub mod list_user_spend_events_for_user;
pub mod upsert_user_spend_summary;
@@ -0,0 +1,129 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use enums::common::payments_namespace::PaymentsNamespace;
use tokens::tokens::users::UserToken;
/// Upsert one `user_spend_summaries` row (recompute-overwrite on the
/// (payments_namespace, user_token) unique key). Window/cadence/score fields are
/// all recomputed each run since they are time-relative.
pub struct UpsertUserSpendSummaryArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub payments_namespace: PaymentsNamespace,
pub user_token: &'e UserToken,
pub lifetime_gross_spend_usd_cents: u64,
pub lifetime_subscription_spend_usd_cents: u64,
pub lifetime_credits_spend_usd_cents: u64,
pub lifetime_refund_usd_cents: u64,
pub lifetime_net_spend_usd_cents: u64,
pub lifetime_payment_count: u32,
pub lifetime_refund_count: u32,
pub maybe_first_payment_at: Option<DateTime<Utc>>,
pub first_spend_usd_cents: u64,
pub maybe_last_payment_at: Option<DateTime<Utc>>,
pub last_spend_usd_cents: u64,
pub maybe_days_since_first_payment: Option<u32>,
pub maybe_days_since_last_payment: Option<u32>,
pub net_spend_this_year_usd_cents: i64,
pub consecutive_active_weeks: u32,
pub consecutive_inactive_weeks: u32,
pub maybe_weeks_since_last_spend: Option<u32>,
pub is_active_subscriber: bool,
pub maybe_subscription_interval: Option<&'e str>,
pub maybe_reengagement_score: Option<u32>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
pub async fn upsert_user_spend_summary<'e, 'c: 'e, E>(
args: UpsertUserSpendSummaryArgs<'e, 'c, E>,
) -> Result<(), sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
sqlx::query!(
r#"
INSERT INTO user_spend_summaries
SET
payments_namespace = ?,
user_token = ?,
lifetime_gross_spend_usd_cents = ?,
lifetime_subscription_spend_usd_cents = ?,
lifetime_credits_spend_usd_cents = ?,
lifetime_refund_usd_cents = ?,
lifetime_net_spend_usd_cents = ?,
lifetime_payment_count = ?,
lifetime_refund_count = ?,
first_payment_at = ?,
first_spend_usd_cents = ?,
last_payment_at = ?,
last_spend_usd_cents = ?,
days_since_first_payment = ?,
days_since_last_payment = ?,
net_spend_this_year_usd_cents = ?,
consecutive_active_weeks = ?,
consecutive_inactive_weeks = ?,
weeks_since_last_spend = ?,
is_active_subscriber = ?,
maybe_subscription_interval = ?,
maybe_reengagement_score = ?
ON DUPLICATE KEY UPDATE
lifetime_gross_spend_usd_cents = VALUES(lifetime_gross_spend_usd_cents),
lifetime_subscription_spend_usd_cents = VALUES(lifetime_subscription_spend_usd_cents),
lifetime_credits_spend_usd_cents = VALUES(lifetime_credits_spend_usd_cents),
lifetime_refund_usd_cents = VALUES(lifetime_refund_usd_cents),
lifetime_net_spend_usd_cents = VALUES(lifetime_net_spend_usd_cents),
lifetime_payment_count = VALUES(lifetime_payment_count),
lifetime_refund_count = VALUES(lifetime_refund_count),
first_payment_at = VALUES(first_payment_at),
first_spend_usd_cents = VALUES(first_spend_usd_cents),
last_payment_at = VALUES(last_payment_at),
last_spend_usd_cents = VALUES(last_spend_usd_cents),
days_since_first_payment = VALUES(days_since_first_payment),
days_since_last_payment = VALUES(days_since_last_payment),
net_spend_this_year_usd_cents = VALUES(net_spend_this_year_usd_cents),
consecutive_active_weeks = VALUES(consecutive_active_weeks),
consecutive_inactive_weeks = VALUES(consecutive_inactive_weeks),
weeks_since_last_spend = VALUES(weeks_since_last_spend),
is_active_subscriber = VALUES(is_active_subscriber),
maybe_subscription_interval = VALUES(maybe_subscription_interval),
maybe_reengagement_score = VALUES(maybe_reengagement_score)
"#,
args.payments_namespace.to_str(),
args.user_token.as_str(),
args.lifetime_gross_spend_usd_cents,
args.lifetime_subscription_spend_usd_cents,
args.lifetime_credits_spend_usd_cents,
args.lifetime_refund_usd_cents,
args.lifetime_net_spend_usd_cents,
args.lifetime_payment_count,
args.lifetime_refund_count,
args.maybe_first_payment_at,
args.first_spend_usd_cents,
args.maybe_last_payment_at,
args.last_spend_usd_cents,
args.maybe_days_since_first_payment,
args.maybe_days_since_last_payment,
args.net_spend_this_year_usd_cents,
args.consecutive_active_weeks,
args.consecutive_inactive_weeks,
args.maybe_weeks_since_last_spend,
args.is_active_subscriber,
args.maybe_subscription_interval,
args.maybe_reengagement_score,
)
.execute(args.mysql_executor)
.await?;
Ok(())
}
@@ -0,0 +1,55 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use enums::common::payments_namespace::PaymentsNamespace;
use tokens::tokens::users::UserToken;
/// The user's most-relevant subscription (latest-expiring), for deriving
/// `is_active_subscriber` and `maybe_subscription_interval` in the analytics job.
pub struct UserSubscriptionStatusRow {
pub maybe_stripe_subscription_status: Option<String>,
pub maybe_stripe_recurring_interval: Option<String>,
pub subscription_expires_at: DateTime<Utc>,
}
pub struct GetActiveSubscriptionStatusArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub user_token: &'e UserToken,
pub subscription_namespace: PaymentsNamespace,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Returns the latest-expiring subscription row for the user (or `None`). The
/// caller decides active-ness from the status + expiry.
pub async fn get_active_subscription_status_for_user<'e, 'c: 'e, E>(
args: GetActiveSubscriptionStatusArgs<'e, 'c, E>,
) -> Result<Option<UserSubscriptionStatusRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let row = sqlx::query_as!(
UserSubscriptionStatusRow,
r#"
SELECT
maybe_stripe_subscription_status,
maybe_stripe_recurring_interval,
subscription_expires_at AS `subscription_expires_at!: DateTime<Utc>`
FROM user_subscriptions
WHERE user_token = ?
AND subscription_namespace = ?
ORDER BY subscription_expires_at DESC
LIMIT 1
"#,
args.user_token.as_str(),
args.subscription_namespace.to_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(row)
}
@@ -1,3 +1,4 @@
pub mod get_active_subscription_status_for_user;
pub mod find_possibly_inactive_first_subscription_for_owner_user;
pub mod find_subscription_for_owner_user;
pub mod get_user_subscription_by_stripe_subscription_id;
@@ -0,0 +1,47 @@
[package]
name = "user-spend-analytics-job"
edition = "2021"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[[bin]]
name = "user-spend-analytics-job"
path = "src/main.rs"
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[dependencies]
# cargo-hakari workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
actix_helpers.workspace = true
bootstrap.workspace = true
concurrency.workspace = true
easyenv.workspace = true
enums.workspace = true
errors.workspace = true
jobs_common.workspace = true
mysql_queries.workspace = true
pager = { path = "../../../lib/pager" }
rootly_client.workspace = true
rootly_config.workspace = true
shared_env_var_config.workspace = true
server_environment = { path = "../../../lib/server_environment" }
tokens.workspace = true
# External
actix-web.workspace = true
anyhow.workspace = true
chrono.workspace = true
log.workspace = true
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio-rustls", "chrono" ] }
tokio.workspace = true
@@ -0,0 +1,22 @@
SERVER_ENVIRONMENT=development
RUST_LOG=info
# Cycle cadence + throttling
SLEEP_BETWEEN_CYCLES_SECONDS=86400
QUERY_DELAY_MILLIS=50
ERROR_RECOVERY_SECONDS=30
# Daily-spends backfill window (pick ONE; omit both = all-time, with a warning)
BACKFILL_DAYS=3
# BACKFILL_START_DATE=2026-02-01
# Summaries
SUMMARY_USER_PAGE_SIZE=500
# Reengagement score tunables
REENGAGEMENT_VALUE_CAP_DOLLARS=1000
REENGAGEMENT_ACTIVE_THRESHOLD_DAYS=14
REENGAGEMENT_DECAY_DAYS=350
# Paging (off in dev)
ENABLE_PAGING=false
@@ -0,0 +1,127 @@
use std::sync::Arc;
use actix_web::error::ResponseError;
use actix_web::http::header::ContentType;
use actix_web::http::StatusCode;
use actix_web::{web, HttpRequest, HttpResponse};
use log::error;
use actix_helpers::response_serializers::error_to_json_http_response::error_to_json_http_response;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use crate::http_server::http_server_shared_state::HttpServerSharedState;
#[derive(Serialize)]
pub struct HealthCheckResponse {
pub success: bool,
pub is_healthy: bool,
pub consecutive_failure_count: u64,
pub consecutive_success_count: u64,
pub total_failure_count: u64,
pub total_success_count: u64,
pub total_failure_ratio: f32,
pub total_success_ratio: f32,
}
#[derive(Debug, Serialize)]
pub enum HealthCheckError {
ServerError,
}
impl ResponseError for HealthCheckError {
fn status_code(&self) -> StatusCode {
match *self {
HealthCheckError::ServerError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
error_to_json_http_response(self)
}
}
impl std::fmt::Display for HealthCheckError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
pub async fn get_health_check_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<HttpServerSharedState>>,
) -> Result<HttpResponse, HealthCheckError> {
let job_stats = server_state
.job_stats
.get_status()
.map_err(|e| {
error!("Error serving health check status: {:?}", e);
HealthCheckError::ServerError
})?;
let total_tries = job_stats
.total_failure_count
.saturating_add(job_stats.total_success_count);
let total_success_ratio = if total_tries > 0 {
(job_stats.total_success_count as f32) / (total_tries as f32)
} else {
0.0
};
let total_failure_ratio = if total_tries > 0 {
1.0 - total_success_ratio
} else {
0.0
};
let is_healthy =
job_stats.consecutive_failure_count < server_state.consecutive_failure_unhealthy_threshold;
if !is_healthy {
let notification = NotificationDetailsBuilder::from_title(
format!("Health check unhealthy on {}", server_state.hostname))
.set_description(Some(format!(
"Health check returned unhealthy.\n\n\
Hostname: {}\n\
Consecutive failure count: {}\n\
Total failure count: {}\n\
Total success count: {}",
server_state.hostname,
job_stats.consecutive_failure_count,
job_stats.total_failure_count,
job_stats.total_success_count,
)))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some(http_request.method().to_string()))
.set_http_path(Some(http_request.path().to_string()))
.build();
if let Err(err) = server_state.pager.enqueue_page(notification) {
error!("Failed to enqueue health check alert: {:?}", err);
}
}
let response = HealthCheckResponse {
success: true,
is_healthy,
consecutive_failure_count: job_stats.consecutive_failure_count,
consecutive_success_count: job_stats.consecutive_success_count,
total_failure_count: job_stats.total_failure_count,
total_success_count: job_stats.total_success_count,
total_failure_ratio,
total_success_ratio,
};
let body = serde_json::to_string(&response).map_err(|_e| HealthCheckError::ServerError)?;
if is_healthy {
Ok(HttpResponse::Ok()
.content_type(ContentType::json())
.body(body))
} else {
Ok(HttpResponse::InternalServerError()
.content_type(ContentType::json())
.body(body))
}
}
@@ -0,0 +1 @@
pub mod health_check_handler;
@@ -0,0 +1,10 @@
use jobs_common::job_stats::JobStats;
use pager::client::pager::Pager;
#[derive(Clone)]
pub struct HttpServerSharedState {
pub job_stats: JobStats,
pub consecutive_failure_unhealthy_threshold: u64,
pub pager: Pager,
pub hostname: String,
}
@@ -0,0 +1,3 @@
pub mod endpoints;
pub mod http_server_shared_state;
pub mod run_http_server;
@@ -0,0 +1,76 @@
use std::sync::Arc;
use actix_web::dev::Server;
use actix_web::middleware::Logger;
use actix_web::{web, App, HttpResponse, HttpServer};
use log::info;
use bootstrap::bootstrap::ContainerEnvironment;
use errors::AnyhowResult;
use jobs_common::job_stats::JobStats;
use pager::client::pager::Pager;
use crate::http_server::endpoints::health_check_handler::get_health_check_handler;
use crate::http_server::http_server_shared_state::HttpServerSharedState;
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:13339";
const DEFAULT_NUM_WORKERS: usize = 2;
pub struct CreateServerArgs {
pub container_environment: ContainerEnvironment,
pub job_stats: JobStats,
pub pager: Pager,
}
pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server> {
let bind_address = easyenv::get_env_string_or_default("HTTP_BIND_ADDRESS", DEFAULT_BIND_ADDRESS);
let num_workers = easyenv::get_env_num("HTTP_NUM_WORKERS", DEFAULT_NUM_WORKERS)?;
let hostname = args.container_environment.hostname.clone();
let server_state = HttpServerSharedState {
job_stats: args.job_stats.clone(),
consecutive_failure_unhealthy_threshold: easyenv::get_env_num(
"CONSECUTIVE_FAILURE_UNHEALTHY_THRESHOLD",
3,
)?,
pager: args.pager,
hostname,
};
let server_state_arc = web::Data::new(Arc::new(server_state));
info!("Starting HTTP service (for k8s health checking).");
let log_format = "[%{HOSTNAME}e] IP=[%{X-Forwarded-For}i] \"%r\" %s %b \"%{Referer}i\" \"%{User-Agent}i\" %T";
let handle = HttpServer::new(move || {
App::new()
.app_data(server_state_arc.clone())
.wrap(Logger::new(&log_format).exclude("/_status"))
.service(
web::resource("/")
.route(web::get().to(|| HttpResponse::Ok()))
.route(web::head().to(|| HttpResponse::Ok())),
)
.service(
web::resource("/_status")
.route(web::get().to(get_health_check_handler))
.route(web::head().to(|| HttpResponse::Ok())),
)
})
.bind(&bind_address)
.unwrap_or_else(|err| {
eprintln!("FATAL: Failed to bind to address '{}': {}", bind_address, err);
eprintln!("The address is likely already in use by another process.");
std::process::exit(1);
})
.workers(num_workers)
.run();
Ok(handle)
}
pub async fn launch_http_server(args: CreateServerArgs) -> AnyhowResult<()> {
run_http_server(args)?.await?;
Ok(())
}
@@ -0,0 +1,17 @@
use log::error;
use pager::client::pager::Pager;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
/// Enqueue a pager alert for an error WITHOUT interrupting the job. The caller is
/// expected to keep going (next date / user / record) after a recovery sleep.
pub fn alert_pager(pager: &Pager, title: &str, detail: &str) {
let notification = NotificationDetailsBuilder::from_title(title.to_string())
.set_description(Some(detail.to_string()))
.set_urgency(Some(NotificationUrgency::Medium))
.build();
if let Err(pager_err) = pager.enqueue_page(notification) {
error!("Failed to enqueue pager alert: {:?}", pager_err);
}
}
@@ -0,0 +1,130 @@
use std::marker::PhantomData;
use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, TimeZone, Utc};
use log::{error, info, warn};
use mysql_queries::queries::user_daily_spends::aggregate_daily_spends_for_date::{
aggregate_daily_spends_for_date, AggregateDailySpendsForDateArgs,
};
use mysql_queries::queries::user_daily_spends::upsert_user_daily_spend::{
upsert_user_daily_spend, UpsertUserDailySpendArgs,
};
use crate::job::alert_on_error::alert_pager;
use crate::job_dependencies::JobDependencies;
const ALL_TIME_START: (i32, u32, u32) = (2020, 1, 1);
/// Backfill `user_daily_spends` for each UTC day from the configured start date
/// up to today, one date at a time. Days with no spend/refund produce no rows.
pub async fn backfill_daily_spends(deps: &JobDependencies) {
let start_date = resolve_start_date(deps);
let today = Utc::now().date_naive();
info!("Daily-spends backfill window: {start_date} .. {today} (UTC).");
let mut date = start_date;
let mut total_upserted = 0u64;
while date <= today {
if deps.application_shutdown.get() {
return;
}
match process_date(deps, date).await {
Ok(count) => {
total_upserted += count as u64;
if count > 0 {
info!(" {date}: upserted {count} daily row(s).");
}
}
Err(err) => {
error!("Daily-spends aggregation failed for {date}: {err:?}");
alert_pager(
&deps.pager,
"user-spend-analytics: daily aggregation error",
&format!("date={date}: {err:?}"),
);
let _ = deps.job_stats.increment_failure_count();
tokio::time::sleep(deps.error_recovery).await;
}
}
tokio::time::sleep(deps.query_delay).await;
date = match date.succ_opt() {
Some(next) => next,
None => break,
};
}
info!("Daily-spends backfill complete: {total_upserted} row(s) upserted.");
}
async fn process_date(deps: &JobDependencies, date: NaiveDate) -> Result<usize, sqlx::Error> {
let range_start: DateTime<Utc> =
Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).expect("valid midnight"));
let range_end = range_start + ChronoDuration::days(1);
let aggregates = aggregate_daily_spends_for_date(AggregateDailySpendsForDateArgs {
range_start,
range_end,
mysql_executor: &deps.mysql_pool,
phantom: PhantomData,
})
.await?;
let mut upserted = 0usize;
for aggregate in &aggregates {
if deps.application_shutdown.get() {
break;
}
let result = upsert_user_daily_spend(UpsertUserDailySpendArgs {
user_token: &aggregate.user_token,
payments_namespace: aggregate.payments_namespace,
spend_date: date,
subscription_spend_usd_cents: aggregate.subscription_spend_usd_cents,
credits_spend_usd_cents: aggregate.credits_spend_usd_cents,
gross_spend_usd_cents: aggregate.gross_spend_usd_cents,
refund_usd_cents: aggregate.refund_usd_cents,
net_spend_usd_cents: aggregate.net_spend_usd_cents,
payment_count: u32::try_from(aggregate.payment_count).unwrap_or(u32::MAX),
credits_granted: aggregate.credits_granted,
mysql_executor: &deps.mysql_pool,
phantom: PhantomData,
})
.await;
match result {
Ok(()) => {
upserted += 1;
let _ = deps.job_stats.increment_success_count();
}
Err(err) => {
error!("Daily upsert failed for user {} on {date}: {err:?}", aggregate.user_token.as_str());
alert_pager(
&deps.pager,
"user-spend-analytics: daily upsert error",
&format!("user={} date={date}: {err:?}", aggregate.user_token.as_str()),
);
let _ = deps.job_stats.increment_failure_count();
tokio::time::sleep(deps.error_recovery).await;
}
}
}
Ok(upserted)
}
/// `BACKFILL_START_DATE` wins; else `today - BACKFILL_DAYS`; else all-time (with a loud warning).
fn resolve_start_date(deps: &JobDependencies) -> NaiveDate {
if let Some(start_date) = deps.maybe_backfill_start_date {
return start_date;
}
let today = Utc::now().date_naive();
if let Some(days) = deps.maybe_backfill_days {
return today - ChronoDuration::days(days.max(0));
}
warn!(
"⚠️ Neither BACKFILL_START_DATE nor BACKFILL_DAYS is set — backfilling \
user_daily_spends for ALL TIME. This is expensive; set one to bound the window."
);
NaiveDate::from_ymd_opt(ALL_TIME_START.0, ALL_TIME_START.1, ALL_TIME_START.2).expect("valid date")
}
@@ -0,0 +1,363 @@
use std::collections::HashSet;
use std::marker::PhantomData;
use chrono::{DateTime, Datelike, Utc};
use log::{error, info};
use enums::by_table::user_spend_events::payment_event_type::PaymentEventType;
use enums::common::payments_namespace::PaymentsNamespace;
use mysql_queries::queries::user_spend_summaries::list_payments_namespaces_with_spend_activity::{
list_payments_namespaces_with_spend_activity, ListPaymentsNamespacesWithSpendActivityArgs,
};
use mysql_queries::queries::user_spend_summaries::list_user_spend_events_for_user::{
list_user_spend_events_for_user, ListUserSpendEventsForUserArgs, UserSpendEventRow,
};
use mysql_queries::queries::user_spend_summaries::list_user_tokens_with_spend_activity::{
list_user_tokens_with_spend_activity, ListUserTokensWithSpendActivityArgs,
};
use mysql_queries::queries::user_spend_summaries::upsert_user_spend_summary::{
upsert_user_spend_summary, UpsertUserSpendSummaryArgs,
};
use mysql_queries::queries::users::user_subscriptions::get_active_subscription_status_for_user::{
get_active_subscription_status_for_user, GetActiveSubscriptionStatusArgs,
};
use tokens::tokens::users::UserToken;
use crate::job::alert_on_error::alert_pager;
use crate::job::reengagement_score::reengagement_score;
use crate::job_dependencies::JobDependencies;
const DAYS_PER_WEEK: i64 = 7;
/// Recompute `user_spend_summaries` for every (user, namespace) with spend
/// activity. Window/cadence/score fields are time-relative, so all users are
/// refreshed each cycle against a single reference time. We iterate one namespace
/// at a time, keyset-paginating users by `user_token`.
pub async fn backfill_user_summaries(deps: &JobDependencies) {
let now = Utc::now();
info!("User-summaries backfill starting (reference time {now}).");
let namespaces = match list_payments_namespaces_with_spend_activity(
ListPaymentsNamespacesWithSpendActivityArgs {
mysql_executor: &deps.mysql_pool,
phantom: PhantomData,
},
)
.await
{
Ok(namespaces) => namespaces,
Err(err) => {
error!("User-summaries: listing namespaces failed: {err:?}");
alert_pager(&deps.pager, "user-spend-analytics: namespace list error", &format!("{err:?}"));
let _ = deps.job_stats.increment_failure_count();
return;
}
};
let mut total_upserted = 0u64;
for namespace in namespaces {
total_upserted += backfill_namespace(deps, namespace, now).await;
if deps.application_shutdown.get() {
return;
}
}
info!("User-summaries backfill complete: {total_upserted} user(s) upserted.");
}
async fn backfill_namespace(deps: &JobDependencies, namespace: PaymentsNamespace, now: DateTime<Utc>) -> u64 {
let mut after_user_token = String::new();
let mut upserted = 0u64;
loop {
if deps.application_shutdown.get() {
return upserted;
}
let user_tokens = match list_user_tokens_with_spend_activity(ListUserTokensWithSpendActivityArgs {
payments_namespace: namespace,
after_user_token: &after_user_token,
limit: deps.summary_user_page_size,
mysql_executor: &deps.mysql_pool,
phantom: PhantomData,
})
.await
{
Ok(user_tokens) => user_tokens,
Err(err) => {
error!("User-summaries: listing users for {} failed: {err:?}", namespace.to_str());
alert_pager(&deps.pager, "user-spend-analytics: user list error", &format!("ns={}: {err:?}", namespace.to_str()));
let _ = deps.job_stats.increment_failure_count();
return upserted; // can't paginate further this cycle
}
};
if user_tokens.is_empty() {
break;
}
for user_token in &user_tokens {
if deps.application_shutdown.get() {
return upserted;
}
match process_user(deps, namespace, user_token, now).await {
Ok(()) => {
upserted += 1;
let _ = deps.job_stats.increment_success_count();
}
Err(err) => {
error!("User-summaries: failed for {} ({}): {err:?}", user_token.as_str(), namespace.to_str());
alert_pager(
&deps.pager,
"user-spend-analytics: summary error",
&format!("user={} ns={}: {err:?}", user_token.as_str(), namespace.to_str()),
);
let _ = deps.job_stats.increment_failure_count();
tokio::time::sleep(deps.error_recovery).await;
}
}
tokio::time::sleep(deps.query_delay).await;
}
after_user_token = user_tokens.last().expect("non-empty page").as_str().to_string();
}
upserted
}
async fn process_user(
deps: &JobDependencies,
namespace: PaymentsNamespace,
user_token: &UserToken,
now: DateTime<Utc>,
) -> Result<(), sqlx::Error> {
let events = list_user_spend_events_for_user(ListUserSpendEventsForUserArgs {
user_token,
payments_namespace: namespace,
mysql_executor: &deps.mysql_pool,
phantom: PhantomData,
})
.await?;
let aggregate = compute_summary(&events, now);
// Authoritative subscription state (from user_subscriptions, not the events).
let subscription_row = get_active_subscription_status_for_user(GetActiveSubscriptionStatusArgs {
user_token,
subscription_namespace: namespace,
mysql_executor: &deps.mysql_pool,
phantom: PhantomData,
})
.await?;
let (is_active_subscriber, maybe_subscription_interval) = match subscription_row {
Some(row) => {
let active = matches!(
row.maybe_stripe_subscription_status.as_deref(),
Some("active") | Some("trialing")
) && row.subscription_expires_at > now;
(active, row.maybe_stripe_recurring_interval)
}
None => (false, None),
};
let score = reengagement_score(
aggregate.lifetime_net_spend_usd_cents,
aggregate.maybe_days_since_last_payment,
&deps.reengagement,
);
upsert_user_spend_summary(UpsertUserSpendSummaryArgs {
payments_namespace: namespace,
user_token,
lifetime_gross_spend_usd_cents: aggregate.lifetime_gross_spend_usd_cents,
lifetime_subscription_spend_usd_cents: aggregate.lifetime_subscription_spend_usd_cents,
lifetime_credits_spend_usd_cents: aggregate.lifetime_credits_spend_usd_cents,
lifetime_refund_usd_cents: aggregate.lifetime_refund_usd_cents,
lifetime_net_spend_usd_cents: aggregate.lifetime_net_spend_usd_cents,
lifetime_payment_count: aggregate.lifetime_payment_count,
lifetime_refund_count: aggregate.lifetime_refund_count,
maybe_first_payment_at: aggregate.maybe_first_payment_at,
first_spend_usd_cents: aggregate.first_spend_usd_cents,
maybe_last_payment_at: aggregate.maybe_last_payment_at,
last_spend_usd_cents: aggregate.last_spend_usd_cents,
maybe_days_since_first_payment: aggregate.maybe_days_since_first_payment,
maybe_days_since_last_payment: aggregate.maybe_days_since_last_payment,
net_spend_this_year_usd_cents: aggregate.net_spend_this_year_usd_cents,
consecutive_active_weeks: aggregate.consecutive_active_weeks,
consecutive_inactive_weeks: aggregate.consecutive_inactive_weeks,
maybe_weeks_since_last_spend: aggregate.maybe_weeks_since_last_spend,
is_active_subscriber,
maybe_subscription_interval: maybe_subscription_interval.as_deref(),
maybe_reengagement_score: Some(score),
mysql_executor: &deps.mysql_pool,
phantom: PhantomData,
})
.await?;
Ok(())
}
/// Derived summary fields, computed from a user's full event history.
struct SummaryAggregate {
lifetime_gross_spend_usd_cents: u64,
lifetime_subscription_spend_usd_cents: u64,
lifetime_credits_spend_usd_cents: u64,
lifetime_refund_usd_cents: u64,
lifetime_net_spend_usd_cents: u64,
lifetime_payment_count: u32,
lifetime_refund_count: u32,
maybe_first_payment_at: Option<DateTime<Utc>>,
first_spend_usd_cents: u64,
maybe_last_payment_at: Option<DateTime<Utc>>,
last_spend_usd_cents: u64,
maybe_days_since_first_payment: Option<u32>,
maybe_days_since_last_payment: Option<u32>,
net_spend_this_year_usd_cents: i64,
consecutive_active_weeks: u32,
consecutive_inactive_weeks: u32,
maybe_weeks_since_last_spend: Option<u32>,
}
/// Aggregate one user's events (assumed ascending by `payment_occurred_at`).
/// "Positive" events are payments; negatives are refunds/chargebacks. Weekly
/// cadence uses 7-day buckets relative to `now` (week 0 = the last 7 days).
fn compute_summary(events: &[UserSpendEventRow], now: DateTime<Utc>) -> SummaryAggregate {
let current_year = now.year();
let mut gross: i128 = 0;
let mut subscription: i128 = 0;
let mut credits: i128 = 0;
let mut refund: i128 = 0;
let mut net: i128 = 0;
let mut net_this_year: i128 = 0;
let mut payment_count: u32 = 0;
let mut refund_count: u32 = 0;
let mut first_payment: Option<&UserSpendEventRow> = None;
let mut last_payment: Option<&UserSpendEventRow> = None;
let mut active_weeks: HashSet<i64> = HashSet::new();
for event in events {
let amount = event.amount_usd_cents as i128;
net += amount;
if event.payment_occurred_at.year() == current_year {
net_this_year += amount;
}
if event.amount_usd_cents > 0 {
gross += amount;
payment_count += 1;
match event.event_type {
PaymentEventType::SubscriptionInitial
| PaymentEventType::SubscriptionRenewal
| PaymentEventType::SubscriptionProrationUpgrade => subscription += amount,
PaymentEventType::CreditPackPurchase => credits += amount,
_ => {}
}
if first_payment.is_none() {
first_payment = Some(event);
}
last_payment = Some(event);
let days_ago = (now - event.payment_occurred_at).num_days();
if days_ago >= 0 {
active_weeks.insert(days_ago / DAYS_PER_WEEK);
}
} else if event.amount_usd_cents < 0 {
refund += -amount;
refund_count += 1;
}
}
let maybe_first_payment_at = first_payment.map(|e| e.payment_occurred_at);
let maybe_last_payment_at = last_payment.map(|e| e.payment_occurred_at);
let first_spend_usd_cents = first_payment.map(|e| e.amount_usd_cents.max(0) as u64).unwrap_or(0);
let last_spend_usd_cents = last_payment.map(|e| e.amount_usd_cents.max(0) as u64).unwrap_or(0);
let maybe_days_since_first_payment =
maybe_first_payment_at.map(|t| (now - t).num_days().max(0) as u32);
let maybe_days_since_last_payment =
maybe_last_payment_at.map(|t| (now - t).num_days().max(0) as u32);
// Cadence from the set of active weeks-ago.
let maybe_weeks_since_last_spend = active_weeks.iter().min().map(|w| *w as u32);
let consecutive_active_weeks = {
let mut week = 0i64;
while active_weeks.contains(&week) {
week += 1;
}
week as u32
};
let consecutive_inactive_weeks = maybe_weeks_since_last_spend.unwrap_or(0);
SummaryAggregate {
lifetime_gross_spend_usd_cents: gross.max(0) as u64,
lifetime_subscription_spend_usd_cents: subscription.max(0) as u64,
lifetime_credits_spend_usd_cents: credits.max(0) as u64,
lifetime_refund_usd_cents: refund.max(0) as u64,
lifetime_net_spend_usd_cents: net.max(0) as u64,
lifetime_payment_count: payment_count,
lifetime_refund_count: refund_count,
maybe_first_payment_at,
first_spend_usd_cents,
maybe_last_payment_at,
last_spend_usd_cents,
maybe_days_since_first_payment,
maybe_days_since_last_payment,
net_spend_this_year_usd_cents: net_this_year.clamp(i64::MIN as i128, i64::MAX as i128) as i64,
consecutive_active_weeks,
consecutive_inactive_weeks,
maybe_weeks_since_last_spend,
}
}
#[cfg(test)]
mod tests {
use chrono::Duration as ChronoDuration;
use super::*;
fn event(days_ago: i64, amount_usd_cents: i64, event_type: PaymentEventType, now: DateTime<Utc>) -> UserSpendEventRow {
UserSpendEventRow {
payment_occurred_at: now - ChronoDuration::days(days_ago),
amount_usd_cents,
event_type,
maybe_credits_granted: None,
}
}
#[test]
fn aggregates_lifetime_and_cadence() {
let now = Utc::now();
// ascending by time => furthest in the past first
let events = vec![
event(40, 1999, PaymentEventType::SubscriptionInitial, now), // ~6 wks ago
event(20, 999, PaymentEventType::CreditPackPurchase, now), // ~3 wks (week 2)
event(5, 2999, PaymentEventType::SubscriptionRenewal, now), // week 0
event(3, -999, PaymentEventType::Refund, now), // refund, week 0
];
let agg = compute_summary(&events, now);
assert_eq!(agg.lifetime_gross_spend_usd_cents, 1999 + 999 + 2999);
assert_eq!(agg.lifetime_subscription_spend_usd_cents, 1999 + 2999);
assert_eq!(agg.lifetime_credits_spend_usd_cents, 999);
assert_eq!(agg.lifetime_refund_usd_cents, 999);
assert_eq!(agg.lifetime_net_spend_usd_cents, (1999 + 999 + 2999 - 999) as u64);
assert_eq!(agg.lifetime_payment_count, 3);
assert_eq!(agg.lifetime_refund_count, 1);
assert_eq!(agg.first_spend_usd_cents, 1999);
assert_eq!(agg.last_spend_usd_cents, 2999);
assert_eq!(agg.maybe_weeks_since_last_spend, Some(0));
assert_eq!(agg.consecutive_inactive_weeks, 0);
assert_eq!(agg.consecutive_active_weeks, 1);
}
#[test]
fn lapsed_user_has_inactive_streak() {
let now = Utc::now();
let events = vec![event(60, 4999, PaymentEventType::SubscriptionInitial, now)];
let agg = compute_summary(&events, now);
assert_eq!(agg.maybe_weeks_since_last_spend, Some(60 / 7));
assert_eq!(agg.consecutive_inactive_weeks, (60 / 7) as u32);
assert_eq!(agg.consecutive_active_weeks, 0);
assert_eq!(agg.maybe_days_since_last_payment, Some(60));
}
}
@@ -0,0 +1,38 @@
use std::time::Duration;
use log::{info, warn};
use crate::job::backfill_daily_spends::backfill_daily_spends;
use crate::job::backfill_user_summaries::backfill_user_summaries;
use crate::job_dependencies::JobDependencies;
const SHUTDOWN_POLL_SLICE: Duration = Duration::from_secs(2);
/// Run the two backfill tasks in order each cycle, then sleep. Errors inside the
/// tasks are paged and skipped (they never abort the cycle).
pub async fn main_loop(deps: JobDependencies) {
while !deps.application_shutdown.get() {
info!("==== Starting spend-analytics cycle ====");
backfill_daily_spends(&deps).await;
if deps.application_shutdown.get() {
break;
}
backfill_user_summaries(&deps).await;
info!("Cycle complete. Sleeping {:?} before next cycle.", deps.sleep_between_cycles);
sleep_interruptible(&deps, deps.sleep_between_cycles).await;
}
warn!("User spend analytics job main loop is shut down.");
}
/// Sleep in small slices so a shutdown signal is honored promptly.
async fn sleep_interruptible(deps: &JobDependencies, total: Duration) {
let mut remaining = total;
while remaining > Duration::ZERO && !deps.application_shutdown.get() {
let nap = remaining.min(SHUTDOWN_POLL_SLICE);
tokio::time::sleep(nap).await;
remaining = remaining.saturating_sub(nap);
}
}
@@ -0,0 +1,5 @@
pub mod alert_on_error;
pub mod backfill_daily_spends;
pub mod backfill_user_summaries;
pub mod main_loop;
pub mod reengagement_score;
@@ -0,0 +1,86 @@
use crate::job_dependencies::ReengagementConfig;
const MAX_SCORE: f64 = 1000.0;
const MAX_VALUE_COMPONENT: f64 = 700.0;
const MAX_RECENCY_COMPONENT: f64 = 300.0;
/// Re-engagement score in `0..=1000`; higher = better re-engagement target.
///
/// `0` means "not a target": still active, never paid, or no net spend. Otherwise
/// the score is a value component (dominant, log-scaled lifetime net spend) plus a
/// recency component that decays with time since the user lapsed. A big spender who
/// lapsed a while ago still outranks a small spender who just lapsed.
pub fn reengagement_score(
lifetime_net_spend_usd_cents: u64,
maybe_days_since_last_payment: Option<u32>,
config: &ReengagementConfig,
) -> u32 {
let days_since_last = match maybe_days_since_last_payment {
Some(days) => days,
None => return 0, // never paid -> not a re-engagement target
};
if lifetime_net_spend_usd_cents == 0 {
return 0;
}
if days_since_last < config.active_threshold_days {
return 0; // still active -> no need to re-engage
}
let dollars = lifetime_net_spend_usd_cents as f64 / 100.0;
let value_component = (MAX_VALUE_COMPONENT * (1.0 + dollars).ln()
/ (1.0 + config.value_cap_dollars).ln())
.clamp(0.0, MAX_VALUE_COMPONENT);
let days_lapsed = (days_since_last - config.active_threshold_days) as f64;
let recency_fraction = (1.0 - days_lapsed / config.decay_days).max(0.0);
let recency_component = MAX_RECENCY_COMPONENT * recency_fraction;
(value_component + recency_component).round().clamp(0.0, MAX_SCORE) as u32
}
#[cfg(test)]
mod tests {
use super::*;
const CONFIG: ReengagementConfig = ReengagementConfig {
value_cap_dollars: 1000.0,
active_threshold_days: 14,
decay_days: 350.0,
};
#[test]
fn never_paid_scores_zero() {
assert_eq!(reengagement_score(50_000, None, &CONFIG), 0);
}
#[test]
fn still_active_scores_zero() {
assert_eq!(reengagement_score(50_000, Some(3), &CONFIG), 0);
}
#[test]
fn zero_net_spend_scores_zero() {
assert_eq!(reengagement_score(0, Some(90), &CONFIG), 0);
}
#[test]
fn whale_recently_lapsed_outranks_whale_long_lapsed() {
let recent = reengagement_score(100_000, Some(35), &CONFIG); // $1000, ~3wks
let old = reengagement_score(100_000, Some(379), &CONFIG); // $1000, ~1yr
assert!(recent > old, "recent={recent} old={old}");
assert!(recent > 900, "recent={recent}");
}
#[test]
fn whale_long_lapsed_outranks_small_recent() {
let whale_old = reengagement_score(100_000, Some(379), &CONFIG); // $1000, ~1yr -> ~700
let small_recent = reengagement_score(2_000, Some(35), &CONFIG); // $20, ~3wks
assert!(whale_old > small_recent, "whale_old={whale_old} small_recent={small_recent}");
}
#[test]
fn score_never_exceeds_1000() {
assert!(reengagement_score(100_000_000, Some(15), &CONFIG) <= 1000);
}
}
@@ -0,0 +1,45 @@
use std::time::Duration;
use chrono::NaiveDate;
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
use jobs_common::job_stats::JobStats;
use pager::client::pager::Pager;
use server_environment::ServerEnvironment;
use sqlx::MySqlPool;
/// Tunables for the `maybe_reengagement_score` (0..=1000). Higher = better
/// re-engagement target. See `crate::job::reengagement_score`.
pub struct ReengagementConfig {
/// Lifetime net spend (USD) at which the value component saturates at its max.
pub value_cap_dollars: f64,
/// A user is only a re-engagement target once lapsed this many days.
pub active_threshold_days: u32,
/// Days after the lapse threshold over which the recency bonus decays to zero.
pub decay_days: f64,
}
pub struct JobDependencies {
pub mysql_pool: MySqlPool,
pub server_environment: ServerEnvironment,
pub job_stats: JobStats,
/// How long to sleep after a full cycle (daily by default).
pub sleep_between_cycles: Duration,
/// Throttle between individual units of work (dates / users) so we don't hammer the DB.
pub query_delay: Duration,
/// How long to back off after an error before continuing to the next unit.
pub error_recovery: Duration,
/// Explicit daily-spends backfill start date (UTC). Takes precedence over `maybe_backfill_days`.
pub maybe_backfill_start_date: Option<NaiveDate>,
/// Look back this many days from today (UTC) if no explicit start date.
pub maybe_backfill_days: Option<i64>,
/// Page size when iterating users for the summaries backfill.
pub summary_user_page_size: i64,
pub reengagement: ReengagementConfig,
pub application_shutdown: RelaxedAtomicBool,
pub pager: Pager,
}
@@ -0,0 +1,184 @@
// Never allow these
#![forbid(private_bounds)]
#![forbid(private_interfaces)]
#![forbid(unused_must_use)]
// Always allow
#![allow(dead_code)]
#![allow(non_snake_case)]
#[macro_use] extern crate serde_derive;
use std::time::Duration;
use anyhow::anyhow;
use chrono::NaiveDate;
use log::{info, warn};
use sqlx::mysql::MySqlPoolOptions;
use sqlx::Executor;
use bootstrap::bootstrap::{bootstrap, BootstrapArgs};
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
use errors::AnyhowResult;
use jobs_common::job_stats::JobStats;
use server_environment::ServerEnvironment;
use shared_env_var_config::logging::DEFAULT_RUST_LOG;
use shared_env_var_config::mysql::env_get_mysql_connection_string_or_default;
use tokio::signal::unix::{signal, SignalKind};
use crate::http_server::run_http_server::{launch_http_server, CreateServerArgs};
use crate::job::main_loop::main_loop;
use crate::job_dependencies::{JobDependencies, ReengagementConfig};
use crate::startup::build_pager::build_pager;
pub mod http_server;
pub mod job;
pub mod job_dependencies;
pub mod startup;
#[tokio::main]
async fn main() -> AnyhowResult<()> {
let container_environment = bootstrap(BootstrapArgs {
app_name: "user-spend-analytics-job",
default_logging_override: Some(DEFAULT_RUST_LOG),
config_search_directories: &[".", "./config", "crates/service/job/user_spend_analytics_job/config"],
ignore_legacy_dot_env_file: true,
})?;
info!("Hostname: {}", &container_environment.hostname);
let _k8s_node_name = easyenv::get_env_string_optional("K8S_NODE_NAME");
let _k8s_pod_name = easyenv::get_env_string_optional("K8S_POD_NAME");
let db_connection_string = env_get_mysql_connection_string_or_default();
info!("Connecting to database...");
// Low connection ceiling + a UTC session so day/week bucketing is deterministic.
let mysql_pool = MySqlPoolOptions::new()
.max_connections(2)
.after_connect(|conn, _meta| {
Box::pin(async move {
conn.execute("SET time_zone = '+00:00'").await?;
Ok(())
})
})
.connect(&db_connection_string)
.await?;
info!("Connected to MySQL.");
let server_environment = ServerEnvironment::from_str(
&easyenv::get_env_string_required("SERVER_ENVIRONMENT")?,
)
.ok_or(anyhow!("invalid server environment"))?;
// ---- Job timing / throttling ----
let sleep_between_cycles = easyenv::get_env_duration_seconds_or_default(
"SLEEP_BETWEEN_CYCLES_SECONDS",
Duration::from_secs(60 * 60 * 24), // daily
);
let query_delay = Duration::from_millis(easyenv::get_env_num("QUERY_DELAY_MILLIS", 50)?);
let error_recovery = easyenv::get_env_duration_seconds_or_default(
"ERROR_RECOVERY_SECONDS",
Duration::from_secs(30),
);
let summary_user_page_size: i64 = easyenv::get_env_num("SUMMARY_USER_PAGE_SIZE", 500)?;
// ---- Daily-spends backfill window ----
let maybe_backfill_start_date: Option<NaiveDate> = easyenv::get_env_string_optional("BACKFILL_START_DATE")
.and_then(|s| NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok());
let maybe_backfill_days: Option<i64> = easyenv::get_env_string_optional("BACKFILL_DAYS")
.and_then(|s| s.trim().parse().ok());
// ---- Reengagement score tunables ----
let reengagement = ReengagementConfig {
value_cap_dollars: easyenv::get_env_string_optional("REENGAGEMENT_VALUE_CAP_DOLLARS")
.and_then(|s| s.trim().parse().ok())
.unwrap_or(1000.0),
active_threshold_days: easyenv::get_env_num("REENGAGEMENT_ACTIVE_THRESHOLD_DAYS", 14)?,
decay_days: easyenv::get_env_string_optional("REENGAGEMENT_DECAY_DAYS")
.and_then(|s| s.trim().parse().ok())
.unwrap_or(350.0),
};
let (pager, pager_worker) = build_pager(server_environment, &container_environment.hostname);
info!("Spawning pager worker.");
// The pager worker uses a blocking Condvar::wait(); it must run on a dedicated OS thread.
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("pager worker tokio runtime");
rt.block_on(pager_worker.run());
});
let application_shutdown = RelaxedAtomicBool::new(false);
let job_stats = JobStats::new();
let pager_for_shutdown = pager.clone();
let create_server_args = CreateServerArgs {
container_environment: container_environment.clone(),
job_stats: job_stats.clone(),
pager: pager.clone(),
};
let job_dependencies = JobDependencies {
mysql_pool,
server_environment,
job_stats,
sleep_between_cycles,
query_delay,
error_recovery,
maybe_backfill_start_date,
maybe_backfill_days,
summary_user_page_size,
reengagement,
application_shutdown: application_shutdown.clone(),
pager,
};
// HTTP server runs on a separate OS thread with its own actix System.
std::thread::spawn(move || {
let actix_runtime = actix_web::rt::System::new();
let http_server_handle = launch_http_server(create_server_args);
actix_runtime.block_on(http_server_handle)
.expect("HTTP server should not exit.");
warn!("HTTP server thread is shut down.");
});
// Listen for SIGTERM (k8s pod termination) and SIGINT (Ctrl-C) to trigger
// graceful shutdown of the main loop.
let application_shutdown_for_signal = application_shutdown.clone();
tokio::spawn(async move {
let mut sigterm = match signal(SignalKind::terminate()) {
Ok(stream) => stream,
Err(err) => {
warn!("Failed to install SIGTERM handler: {:?}", err);
return;
}
};
let mut sigint = match signal(SignalKind::interrupt()) {
Ok(stream) => stream,
Err(err) => {
warn!("Failed to install SIGINT handler: {:?}", err);
return;
}
};
tokio::select! {
_ = sigterm.recv() => info!("Received SIGTERM. Shutting down..."),
_ = sigint.recv() => info!("Received SIGINT. Shutting down..."),
}
application_shutdown_for_signal.set(true);
});
main_loop(job_dependencies).await;
info!("Shutting down pager worker...");
pager_for_shutdown.shutdown_worker();
info!("User spend analytics job exiting.");
Ok(())
}
@@ -0,0 +1,65 @@
use log::{info, warn};
use pager::client::pager::Pager;
use pager::client::pager_builder::PagerBuilder;
use pager::worker::pager_worker::PagerWorker;
use rootly_client::creds::rootly_api_key::RootlyApiKey;
use rootly_config::services::ROOTLY_SERVICE_ID_USER_SPEND_ANALYTICS_JOB;
use rootly_config::urgencies::{ROOTLY_URGENCY_ID_HIGH, ROOTLY_URGENCY_ID_LOW, ROOTLY_URGENCY_ID_MEDIUM};
use shared_env_var_config::paging::{env_enable_paging_default_false, env_optional_rootly_api_key, env_optional_rootly_notification_target_id, env_optional_rootly_notification_target_type};
pub fn build_pager(
server_environment: server_environment::ServerEnvironment,
hostname: &str,
) -> (Pager, PagerWorker) {
let is_paging_enabled = env_enable_paging_default_false();
info!("Paging enabled: {}", is_paging_enabled);
let environment = if server_environment.is_deployed_in_production() {
"production"
} else {
"development"
};
let builder = PagerBuilder::new()
.application_name("user-spend-analytics-job".to_string())
.environment(environment.to_string())
.hostname(hostname.to_string())
.service_id(ROOTLY_SERVICE_ID_USER_SPEND_ANALYTICS_JOB.to_string());
// If paging is globally disabled, use a NoOp pager regardless of API key.
if !is_paging_enabled {
warn!("ENABLE_PAGING is false. Pager will be NoOp.");
return builder.build_with_worker();
}
let maybe_api_key = env_optional_rootly_api_key();
match maybe_api_key {
Some(api_key) => {
info!("Rootly API key found. Configuring pager with Rootly backend.");
build_rootly_pager(builder, api_key)
}
None => {
warn!("ROOTLY_API_KEY not set. Pager will not send real pages.");
builder.build_with_worker()
}
}
}
fn build_rootly_pager(builder: PagerBuilder, api_key: String) -> (Pager, PagerWorker) {
let mut rootly_builder = builder
.rootly(RootlyApiKey::new(api_key))
.urgency_id_high(ROOTLY_URGENCY_ID_HIGH.to_string())
.urgency_id_medium(ROOTLY_URGENCY_ID_MEDIUM.to_string())
.urgency_id_low(ROOTLY_URGENCY_ID_LOW.to_string());
let target_type = env_optional_rootly_notification_target_type();
let target_id = env_optional_rootly_notification_target_id();
if let (Some(t_type), Some(t_id)) = (target_type, target_id) {
rootly_builder = rootly_builder.notification_target(t_type, t_id);
}
rootly_builder.build_with_worker()
}
@@ -0,0 +1 @@
pub mod build_pager;