feat: add analytics metric granularity query
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
use serde_json::Value;
|
||||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::domain::*;
|
||||
use crate::errors::RuntimeProfileFieldError;
|
||||
@@ -502,6 +503,81 @@ pub fn build_runtime_tracking_daily_stat_id(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn aggregate_runtime_tracking_daily_stats(
|
||||
stats: Vec<RuntimeAnalyticsDailyStatSnapshot>,
|
||||
event_key: &str,
|
||||
scope_kind: RuntimeTrackingScopeKind,
|
||||
scope_id: &str,
|
||||
granularity: AnalyticsGranularity,
|
||||
) -> Vec<AnalyticsBucketMetric> {
|
||||
let mut buckets: BTreeMap<(String, i64, i64), u64> = BTreeMap::new();
|
||||
let event_key = event_key.trim();
|
||||
let scope_id = scope_id.trim();
|
||||
|
||||
for stat in stats {
|
||||
if stat.event_key.trim() != event_key
|
||||
|| stat.scope_kind != scope_kind
|
||||
|| stat.scope_id.trim() != scope_id
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let dimension = build_analytics_date_dimension_from_date_key(stat.day_key);
|
||||
let (bucket_key, bucket_start_date_key, bucket_end_date_key) =
|
||||
analytics_bucket_for_dimension(&dimension, granularity);
|
||||
*buckets
|
||||
.entry((bucket_key, bucket_start_date_key, bucket_end_date_key))
|
||||
.or_insert(0) += u64::from(stat.count);
|
||||
}
|
||||
|
||||
buckets
|
||||
.into_iter()
|
||||
.map(
|
||||
|((bucket_key, bucket_start_date_key, bucket_end_date_key), value)| {
|
||||
AnalyticsBucketMetric {
|
||||
bucket_key,
|
||||
bucket_start_date_key,
|
||||
bucket_end_date_key,
|
||||
value,
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn analytics_bucket_for_dimension(
|
||||
dimension: &AnalyticsDateDimensionSnapshot,
|
||||
granularity: AnalyticsGranularity,
|
||||
) -> (String, i64, i64) {
|
||||
match granularity {
|
||||
AnalyticsGranularity::Day => (
|
||||
dimension.calendar_date.clone(),
|
||||
dimension.date_key,
|
||||
dimension.date_key,
|
||||
),
|
||||
AnalyticsGranularity::Week => (
|
||||
dimension.iso_week_key.to_string(),
|
||||
dimension.week_start_date_key,
|
||||
dimension.week_end_date_key,
|
||||
),
|
||||
AnalyticsGranularity::Month => (
|
||||
dimension.month_key.to_string(),
|
||||
dimension.month_start_date_key,
|
||||
dimension.month_end_date_key,
|
||||
),
|
||||
AnalyticsGranularity::Quarter => (
|
||||
dimension.quarter_key.to_string(),
|
||||
dimension.quarter_start_date_key,
|
||||
dimension.quarter_end_date_key,
|
||||
),
|
||||
AnalyticsGranularity::Year => (
|
||||
dimension.year_key.to_string(),
|
||||
dimension.year_start_date_key,
|
||||
dimension.year_end_date_key,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_task_config_record(
|
||||
snapshot: RuntimeProfileTaskConfigSnapshot,
|
||||
) -> RuntimeProfileTaskConfigRecord {
|
||||
|
||||
@@ -116,6 +116,24 @@ pub fn build_runtime_profile_task_center_get_input(
|
||||
Ok(RuntimeProfileTaskCenterGetInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_analytics_metric_query_input(
|
||||
event_key: String,
|
||||
scope_kind: RuntimeTrackingScopeKind,
|
||||
scope_id: String,
|
||||
granularity: AnalyticsGranularity,
|
||||
) -> Result<AnalyticsMetricQueryInput, RuntimeProfileFieldError> {
|
||||
let event_key = normalize_required_string(event_key)
|
||||
.ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
|
||||
let scope_id = normalize_required_string(scope_id)
|
||||
.ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?;
|
||||
Ok(AnalyticsMetricQueryInput {
|
||||
event_key,
|
||||
scope_kind,
|
||||
scope_id,
|
||||
granularity,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_task_claim_input(
|
||||
user_id: String,
|
||||
task_id: String,
|
||||
|
||||
@@ -58,6 +58,78 @@ pub struct AnalyticsDateDimensionSnapshot {
|
||||
pub year_end_date_key: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AnalyticsGranularity {
|
||||
Day,
|
||||
Week,
|
||||
Month,
|
||||
Quarter,
|
||||
Year,
|
||||
}
|
||||
|
||||
impl AnalyticsGranularity {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Day => "day",
|
||||
Self::Week => "week",
|
||||
Self::Month => "month",
|
||||
Self::Quarter => "quarter",
|
||||
Self::Year => "year",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_client_str(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"day" => Some(Self::Day),
|
||||
"week" => Some(Self::Week),
|
||||
"month" => Some(Self::Month),
|
||||
"quarter" => Some(Self::Quarter),
|
||||
"year" => Some(Self::Year),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeAnalyticsDailyStatSnapshot {
|
||||
pub event_key: String,
|
||||
pub scope_kind: RuntimeTrackingScopeKind,
|
||||
pub scope_id: String,
|
||||
pub day_key: i64,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AnalyticsBucketMetric {
|
||||
pub bucket_key: String,
|
||||
pub bucket_start_date_key: i64,
|
||||
pub bucket_end_date_key: i64,
|
||||
pub value: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AnalyticsMetricQueryRequest {
|
||||
pub event_key: String,
|
||||
pub scope_kind: RuntimeTrackingScopeKind,
|
||||
pub scope_id: String,
|
||||
pub granularity: AnalyticsGranularity,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AnalyticsMetricQueryResponse {
|
||||
pub buckets: Vec<AnalyticsBucketMetric>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AnalyticsMetricQueryProcedureResult {
|
||||
pub ok: bool,
|
||||
pub buckets: Vec<AnalyticsBucketMetric>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// 运行时平台主题。
|
||||
///
|
||||
/// 当前只冻结 light/dark 两种主题,避免各层散落字符串字面量。
|
||||
@@ -552,6 +624,15 @@ pub struct RuntimeProfileTaskCenterGetInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnalyticsMetricQueryInput {
|
||||
pub event_key: String,
|
||||
pub scope_kind: RuntimeTrackingScopeKind,
|
||||
pub scope_id: String,
|
||||
pub granularity: AnalyticsGranularity,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileTaskClaimInput {
|
||||
|
||||
105
server-rs/crates/module-runtime/tests/analytics_granularity.rs
Normal file
105
server-rs/crates/module-runtime/tests/analytics_granularity.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use module_runtime::{
|
||||
AnalyticsGranularity, RuntimeAnalyticsDailyStatSnapshot, RuntimeTrackingScopeKind,
|
||||
aggregate_runtime_tracking_daily_stats,
|
||||
};
|
||||
|
||||
fn stat(event_key: &str, scope_id: &str, day_key: i64, count: u32) -> RuntimeAnalyticsDailyStatSnapshot {
|
||||
RuntimeAnalyticsDailyStatSnapshot {
|
||||
event_key: event_key.to_string(),
|
||||
scope_kind: RuntimeTrackingScopeKind::User,
|
||||
scope_id: scope_id.to_string(),
|
||||
day_key,
|
||||
count,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_daily_stats_by_iso_week_bucket() {
|
||||
let buckets = aggregate_runtime_tracking_daily_stats(
|
||||
vec![
|
||||
stat("daily_login", "user-1", 20_517, 1), // 2026-03-05
|
||||
stat("daily_login", "user-1", 20_518, 2), // 2026-03-06
|
||||
stat("daily_login", "user-1", 20_524, 3), // 2026-03-12
|
||||
stat("daily_login", "user-2", 20_517, 9),
|
||||
],
|
||||
"daily_login",
|
||||
RuntimeTrackingScopeKind::User,
|
||||
"user-1",
|
||||
AnalyticsGranularity::Week,
|
||||
);
|
||||
|
||||
assert_eq!(buckets.len(), 2);
|
||||
assert_eq!(buckets[0].bucket_key, "202610");
|
||||
assert_eq!(buckets[0].bucket_start_date_key, 20_514); // 2026-03-02
|
||||
assert_eq!(buckets[0].bucket_end_date_key, 20_520); // 2026-03-08
|
||||
assert_eq!(buckets[0].value, 3);
|
||||
assert_eq!(buckets[1].bucket_key, "202611");
|
||||
assert_eq!(buckets[1].bucket_start_date_key, 20_521); // 2026-03-09
|
||||
assert_eq!(buckets[1].bucket_end_date_key, 20_527); // 2026-03-15
|
||||
assert_eq!(buckets[1].value, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_daily_stats_by_month_quarter_and_year_bucket() {
|
||||
let stats = vec![
|
||||
stat("play", "user-1", 20_545, 2), // 2026-04-02
|
||||
stat("play", "user-1", 20_573, 3), // 2026-04-30
|
||||
stat("play", "user-1", 20_574, 5), // 2026-05-01
|
||||
stat("play", "user-1", 20_818, 7), // 2026-12-31
|
||||
];
|
||||
|
||||
let month = aggregate_runtime_tracking_daily_stats(
|
||||
stats.clone(),
|
||||
"play",
|
||||
RuntimeTrackingScopeKind::User,
|
||||
"user-1",
|
||||
AnalyticsGranularity::Month,
|
||||
);
|
||||
assert_eq!(month.iter().map(|bucket| (&bucket.bucket_key, bucket.value)).collect::<Vec<_>>(), vec![(&"202604".to_string(), 5), (&"202605".to_string(), 5), (&"202612".to_string(), 7)]);
|
||||
assert_eq!(month[0].bucket_start_date_key, 20_544);
|
||||
assert_eq!(month[0].bucket_end_date_key, 20_573);
|
||||
|
||||
let quarter = aggregate_runtime_tracking_daily_stats(
|
||||
stats.clone(),
|
||||
"play",
|
||||
RuntimeTrackingScopeKind::User,
|
||||
"user-1",
|
||||
AnalyticsGranularity::Quarter,
|
||||
);
|
||||
assert_eq!(quarter.iter().map(|bucket| (&bucket.bucket_key, bucket.value)).collect::<Vec<_>>(), vec![(&"20262".to_string(), 10), (&"20264".to_string(), 7)]);
|
||||
|
||||
let year = aggregate_runtime_tracking_daily_stats(
|
||||
stats,
|
||||
"play",
|
||||
RuntimeTrackingScopeKind::User,
|
||||
"user-1",
|
||||
AnalyticsGranularity::Year,
|
||||
);
|
||||
assert_eq!(year.len(), 1);
|
||||
assert_eq!(year[0].bucket_key, "2026");
|
||||
assert_eq!(year[0].value, 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_granularity_keeps_each_day_bucket_and_filters_scope() {
|
||||
let buckets = aggregate_runtime_tracking_daily_stats(
|
||||
vec![
|
||||
stat("daily_login", "user-1", 20_517, 1),
|
||||
stat("daily_login", "user-1", 20_518, 2),
|
||||
stat("daily_login", "user-2", 20_517, 9),
|
||||
stat("other", "user-1", 20_517, 9),
|
||||
],
|
||||
"daily_login",
|
||||
RuntimeTrackingScopeKind::User,
|
||||
"user-1",
|
||||
AnalyticsGranularity::Day,
|
||||
);
|
||||
|
||||
assert_eq!(buckets.len(), 2);
|
||||
assert_eq!(buckets[0].bucket_key, "2026-03-05");
|
||||
assert_eq!(buckets[0].bucket_start_date_key, 20_517);
|
||||
assert_eq!(buckets[0].bucket_end_date_key, 20_517);
|
||||
assert_eq!(buckets[0].value, 1);
|
||||
assert_eq!(buckets[1].bucket_key, "2026-03-06");
|
||||
assert_eq!(buckets[1].value, 2);
|
||||
}
|
||||
Reference in New Issue
Block a user