漏斗数据

This commit is contained in:
zhx 2026-07-05 00:08:30 +08:00
parent b666a5ad99
commit dc44b0bf08
7 changed files with 885 additions and 0 deletions

View File

@ -44,6 +44,16 @@ type SelfGameStatisticsQuery struct {
GameID string
}
type AppTrackingFunnelQuery struct {
AppCode string
StatTZ string
StartMS int64
EndMS int64
RegionID int64
RegionIDs []int64
CountryID int64
}
type PlatformGrantStatisticsQuery struct {
AppCode string
StatTZ string
@ -167,6 +177,37 @@ func (s *DashboardService) SelfGameStatisticsOverview(ctx context.Context, query
return overview, nil
}
func (s *DashboardService) AppTrackingFunnel(ctx context.Context, query AppTrackingFunnelQuery) (map[string]any, error) {
values := url.Values{}
values.Set("app_code", query.AppCode)
if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" {
values.Set("stat_tz", statTZ)
}
if query.StartMS > 0 {
values.Set("start_ms", strconv.FormatInt(query.StartMS, 10))
}
if query.EndMS > 0 {
values.Set("end_ms", strconv.FormatInt(query.EndMS, 10))
}
if query.RegionID > 0 {
values.Set("region_id", strconv.FormatInt(query.RegionID, 10))
} else if len(query.RegionIDs) > 0 {
parts := make([]string, 0, len(query.RegionIDs))
for _, regionID := range query.RegionIDs {
if regionID > 0 {
parts = append(parts, strconv.FormatInt(regionID, 10))
}
}
if len(parts) > 0 {
values.Set("region_ids", strings.Join(parts, ","))
}
}
if query.CountryID > 0 {
values.Set("country_id", strconv.FormatInt(query.CountryID, 10))
}
return s.statisticsGET(ctx, "/internal/v1/statistics/app/funnel", values)
}
func (s *DashboardService) PlatformGrantUsers(ctx context.Context, query PlatformGrantStatisticsQuery) (map[string]any, error) {
page, err := s.statisticsGET(ctx, "/internal/v1/statistics/platform-grants/users", platformGrantValues(query, false))
if err != nil {

View File

@ -65,6 +65,25 @@ func (h *Handler) Overview(c *gin.Context) {
response.OK(c, result)
}
func (h *Handler) Funnel(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
result, err := h.service.Funnel(c.Request.Context(), access, FunnelQuery{
StatTZ: c.Query("stat_tz"),
StartMS: queryInt64(c, "start_ms"),
EndMS: queryInt64(c, "end_ms"),
AppCodes: splitCSV(c.Query("app_codes")),
RegionID: queryInt64(c, "region_id"),
})
if err != nil {
response.ServerError(c, "获取埋点漏斗数据失败")
return
}
response.OK(c, result)
}
func (h *Handler) Kpi(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {

View File

@ -9,6 +9,7 @@ import (
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/databi/social/master", middleware.RequirePermission("overview:view"), h.Master)
protected.GET("/admin/databi/social/overview", middleware.RequirePermission("overview:view"), h.Overview)
protected.GET("/admin/databi/social/funnel", middleware.RequirePermission("overview:view"), h.Funnel)
protected.GET("/admin/databi/social/kpi", middleware.RequirePermission("overview:view"), h.Kpi)
protected.GET("/admin/databi/social/kpi-targets", middleware.RequirePermission("overview:view"), h.ListKpiTargets)
protected.PUT("/admin/databi/social/kpi-targets", middleware.RequirePermission(kpiManagePermission), h.ReplaceKpiTargets)

View File

@ -26,6 +26,12 @@ const (
overviewConcurrency = 4
)
var appTrackingFunnelSupportedApps = map[string]struct{}{
"fami": {},
"huwaa": {},
"lalu": {},
}
// LegacyRegionCatalogSource 由 payment.MongoMoneyRegionSource 实现;
// 社交 BI 与财务范围共用同一套 legacy 区域目录与合成 region_id 口径。
type LegacyRegionCatalogSource interface {
@ -266,6 +272,32 @@ type OverviewResult struct {
Access AccessInfo `json:"access"`
}
type FunnelQuery struct {
StatTZ string
StartMS int64
EndMS int64
AppCodes []string
RegionID int64
}
type AppFunnel struct {
AppCode string `json:"app_code"`
AppName string `json:"app_name"`
Kind string `json:"kind"`
Restricted bool `json:"restricted"`
AllowedRegionIDs []int64 `json:"allowed_region_ids,omitempty"`
Steps []map[string]any `json:"steps"`
Cohorts []map[string]any `json:"cohorts"`
Totals map[string]any `json:"totals"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Error string `json:"error,omitempty"`
}
type FunnelResult struct {
Apps []AppFunnel `json:"apps"`
Access AccessInfo `json:"access"`
}
// Overview 服务端并发聚合各 App 的统计总览hyapp App 走 statistics-service
// legacy App 走 dashboard 外部源;国家行按区域目录卷积出大区行,并按用户数据范围裁剪。
func (s *Service) Overview(ctx context.Context, access repository.MoneyAccess, query OverviewQuery) (*OverviewResult, error) {
@ -298,6 +330,113 @@ func (s *Service) Overview(ctx context.Context, access repository.MoneyAccess, q
}, nil
}
func (s *Service) Funnel(ctx context.Context, access repository.MoneyAccess, query FunnelQuery) (*FunnelResult, error) {
apps, err := s.listAllowedApps(ctx, query.AppCodes, access)
if err != nil {
return nil, err
}
apps = filterAppTrackingFunnelApps(apps)
catalog, err := s.regionCatalog(ctx, apps)
if err != nil {
return nil, err
}
results := make([]AppFunnel, len(apps))
var wg sync.WaitGroup
semaphore := make(chan struct{}, overviewConcurrency)
for index, app := range apps {
wg.Add(1)
go func(index int, app AppInfo) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
results[index] = s.appFunnel(ctx, app, catalog[app.AppCode], access, query)
}(index, app)
}
wg.Wait()
return &FunnelResult{
Apps: results,
Access: AccessInfo{All: access.All, Scopes: scopeInfos(access.Scopes)},
}, nil
}
func (s *Service) appFunnel(ctx context.Context, app AppInfo, regions []RegionInfo, access repository.MoneyAccess, query FunnelQuery) AppFunnel {
out := AppFunnel{
AppCode: app.AppCode,
AppName: app.AppName,
Kind: app.Kind,
Steps: []map[string]any{},
Cohorts: []map[string]any{},
Totals: map[string]any{},
}
if app.Kind != appKindHyapp {
out.Error = "该 App 暂未接入 App 埋点漏斗"
return out
}
if !supportsAppTrackingFunnel(app.AppCode) {
out.Error = "该 App 暂未接入 App 埋点漏斗"
return out
}
allowAll, allowedIDs := allowedRegions(access, app.AppCode)
allowedIDs = normalizeRegionIDs(regions, allowedIDs)
if !allowAll && len(allowedIDs) == 0 {
out.Error = "没有该 App 的数据权限"
return out
}
if query.RegionID > 0 && !access.Allows(app.AppCode, query.RegionID) {
out.Error = "没有该区域的数据权限"
return out
}
if query.RegionID > 0 && !regionInCatalog(regions, query.RegionID) {
out.Error = "该 App 未配置此区域"
return out
}
restricted := !allowAll && query.RegionID == 0
if restricted {
out.Restricted = true
out.AllowedRegionIDs = allowedIDs
}
regionIDs := []int64(nil)
if restricted {
regionIDs = allowedIDs
}
funnel, err := s.dashboards.AppTrackingFunnel(ctx, dashboard.AppTrackingFunnelQuery{
AppCode: app.AppCode,
StatTZ: query.StatTZ,
StartMS: query.StartMS,
EndMS: query.EndMS,
RegionID: query.RegionID,
RegionIDs: regionIDs,
})
if err != nil {
out.Error = fmt.Sprintf("获取埋点漏斗失败: %v", err)
return out
}
out.Steps = anyRowSlice(funnel["steps"])
out.Cohorts = anyRowSlice(funnel["cohorts"])
if totals, ok := funnel["totals"].(map[string]any); ok {
out.Totals = totals
}
out.UpdatedAtMS = time.Now().UnixMilli()
return out
}
func filterAppTrackingFunnelApps(apps []AppInfo) []AppInfo {
out := make([]AppInfo, 0, len(apps))
for _, app := range apps {
if app.Kind == appKindHyapp && supportsAppTrackingFunnel(app.AppCode) {
out = append(out, app)
}
}
return out
}
func supportsAppTrackingFunnel(appCode string) bool {
_, ok := appTrackingFunnelSupportedApps[appctx.Normalize(appCode)]
return ok
}
func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []RegionInfo, access repository.MoneyAccess, query OverviewQuery) AppOverview {
out := AppOverview{
AppCode: app.AppCode,

View File

@ -35,6 +35,7 @@ func newQueryHTTPServer(addr string, repo *mysqlstorage.Repository) (*queryHTTPS
mux.HandleFunc("/internal/v1/statistics/platform-grants/users", s.platformGrantUsers)
mux.HandleFunc("/internal/v1/statistics/platform-grants/records", s.platformGrantRecords)
mux.HandleFunc("/internal/v1/statistics/app/events", s.appEvents)
mux.HandleFunc("/internal/v1/statistics/app/funnel", s.appFunnel)
mux.HandleFunc("/internal/v1/statistics/self-game/events", s.selfGameEvents)
mux.HandleFunc("/internal/v1/statistics/self-games/overview", s.selfGameOverview)
s.server = &http.Server{Handler: mux, ReadHeaderTimeout: 2 * time.Second}
@ -143,6 +144,33 @@ func (s *queryHTTPServer) platformGrantRecords(w http.ResponseWriter, r *http.Re
writeJSON(w, http.StatusOK, page)
}
func (s *queryHTTPServer) appFunnel(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
startMS := parseInt64(query.Get("start_ms"))
endMS := parseInt64(query.Get("end_ms"))
if startMS <= 0 {
now := time.Now().UTC()
startMS = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
}
if endMS <= startMS {
endMS = startMS + 24*time.Hour.Milliseconds()
}
funnel, err := s.repo.QueryAppTrackingFunnel(r.Context(), mysqlstorage.AppTrackingFunnelQuery{
AppCode: query.Get("app_code"),
StatTZ: query.Get("stat_tz"),
StartMS: startMS,
EndMS: endMS,
CountryID: parseInt64(query.Get("country_id")),
RegionID: parseInt64(query.Get("region_id")),
RegionIDs: parseInt64CSV(query.Get("region_ids")),
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, funnel)
}
type selfGameH5EventRequest struct {
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
@ -334,6 +362,18 @@ func parsePositiveInt(value string) int {
return parsed
}
func parseInt64CSV(value string) []int64 {
parts := strings.Split(value, ",")
out := make([]int64, 0, len(parts))
for _, part := range parts {
parsed := parseInt64(part)
if parsed > 0 {
out = append(out, parsed)
}
}
return out
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)

View File

@ -323,6 +323,80 @@ type PlatformGrantCoinQuery struct {
PageSize int
}
type AppTrackingFunnelQuery struct {
AppCode string
StatTZ string
StartMS int64
EndMS int64
CountryID int64
RegionID int64
RegionIDs []int64
}
type AppTrackingFunnel struct {
AppCode string `json:"app_code"`
StatTZ string `json:"stat_tz"`
StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"`
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
RegionIDs []int64 `json:"region_ids,omitempty"`
Steps []AppTrackingFunnelStep `json:"steps"`
Cohorts []AppTrackingFunnelCohort `json:"cohorts"`
Totals AppTrackingFunnelTotals `json:"totals"`
}
type AppTrackingFunnelStep struct {
Key string `json:"key"`
EventName string `json:"event_name"`
Label string `json:"label"`
EventCount int64 `json:"event_count"`
UserCount int64 `json:"user_count"`
DeviceCount int64 `json:"device_count"`
OverallConversionRate float64 `json:"overall_conversion_rate"`
PreviousConversionRate float64 `json:"previous_conversion_rate"`
DropoffUsers int64 `json:"dropoff_users"`
IsFailure bool `json:"is_failure,omitempty"`
}
type AppTrackingFunnelCohort struct {
Dimension string `json:"dimension"`
DimensionLabel string `json:"dimension_label"`
Value string `json:"value"`
Label string `json:"label"`
BaseUsers int64 `json:"base_users"`
D1RetentionUsers int64 `json:"d1_retention_users"`
D1RetentionRate float64 `json:"d1_retention_rate"`
Steps []AppTrackingCohortStepCount `json:"steps"`
}
type AppTrackingCohortStepCount struct {
EventName string `json:"event_name"`
Label string `json:"label"`
UserCount int64 `json:"user_count"`
}
type AppTrackingFunnelTotals struct {
LoginStartUsers int64 `json:"login_start_users"`
LoginSuccessUsers int64 `json:"login_success_users"`
ProfileCompleteUsers int64 `json:"profile_complete_users"`
RoomImpressionUsers int64 `json:"room_impression_users"`
RoomClickUsers int64 `json:"room_click_users"`
RoomJoinSuccessUsers int64 `json:"room_join_success_users"`
RoomJoinFailUsers int64 `json:"room_join_fail_users"`
Stay30sUsers int64 `json:"stay_30s_users"`
Stay3mUsers int64 `json:"stay_3m_users"`
Stay10mUsers int64 `json:"stay_10m_users"`
SendMessageUsers int64 `json:"send_message_users"`
FollowHostUsers int64 `json:"follow_host_users"`
AddFriendUsers int64 `json:"add_friend_users"`
CheckInUsers int64 `json:"check_in_users"`
PushPermissionUsers int64 `json:"push_permission_users"`
D1RetentionBaseUsers int64 `json:"d1_retention_base_users"`
D1RetentionUsers int64 `json:"d1_retention_users"`
D1RetentionRate float64 `json:"d1_retention_rate"`
}
type PlatformGrantCoinUser struct {
UserID int64 `json:"user_id"`
CountryID int64 `json:"country_id"`
@ -654,6 +728,45 @@ func (r *Repository) QuerySelfGameOverview(ctx context.Context, query SelfGameOv
return overview, nil
}
func (r *Repository) QueryAppTrackingFunnel(ctx context.Context, query AppTrackingFunnelQuery) (AppTrackingFunnel, error) {
startMS, endMS := normalizeRange(query.StartMS, query.EndMS)
app := appcode.Normalize(query.AppCode)
if app == "" {
app = "lalu"
}
statTZ := normalizeStatTZ(query.StatTZ)
out := AppTrackingFunnel{
AppCode: app,
StatTZ: statTZ,
StartMS: startMS,
EndMS: endMS,
CountryID: query.CountryID,
RegionID: query.RegionID,
RegionIDs: normalizeTrackingRegionIDs(query.RegionIDs),
Steps: []AppTrackingFunnelStep{},
Cohorts: []AppTrackingFunnelCohort{},
}
steps, err := r.queryAppTrackingFunnelSteps(ctx, app, startMS, endMS, query.CountryID, query.RegionID, out.RegionIDs)
if err != nil {
return AppTrackingFunnel{}, err
}
out.Steps = buildAppTrackingFunnelSteps(steps)
out.Totals = appTrackingTotals(out.Steps)
d1Base, d1Users, err := r.queryAppTrackingD1Total(ctx, app, startMS, endMS, query.CountryID, query.RegionID, out.RegionIDs)
if err != nil {
return AppTrackingFunnel{}, err
}
out.Totals.D1RetentionBaseUsers = d1Base
out.Totals.D1RetentionUsers = d1Users
out.Totals.D1RetentionRate = ratio(d1Users, d1Base)
cohorts, err := r.queryAppTrackingD1Cohorts(ctx, app, startMS, endMS, query.CountryID, query.RegionID, out.RegionIDs)
if err != nil {
return AppTrackingFunnel{}, err
}
out.Cohorts = cohorts
return out, nil
}
func (r *Repository) QueryPlatformGrantCoinUsers(ctx context.Context, query PlatformGrantCoinQuery) (PlatformGrantCoinUserPage, error) {
app, statTZ, startDay, endDay, page, pageSize, offset := normalizePlatformGrantCoinQuery(query)
filter, args := platformGrantCoinFilter(app, statTZ, startDay, endDay, query.CountryID, query.RegionID, 0, query.EventType)
@ -1928,6 +2041,433 @@ func buildSelfGameFunnel(events []SelfGameEventStat) ([]SelfGameFunnelStep, Self
}
}
type appTrackingStepDef struct {
Key string
EventName string
Label string
IsFailure bool
}
var appTrackingStepDefs = []appTrackingStepDef{
{Key: "login_start", EventName: "login_start", Label: "登录开始"},
{Key: "login_success", EventName: "login_success", Label: "登录成功"},
{Key: "profile_start", EventName: "profile_start", Label: "资料开始"},
{Key: "profile_complete", EventName: "profile_complete", Label: "资料完成"},
{Key: "home_room_impression", EventName: "home_room_impression", Label: "首页房间曝光"},
{Key: "home_room_click", EventName: "home_room_click", Label: "首页房间点击"},
{Key: "room_join_success", EventName: "room_join_success", Label: "进房成功"},
{Key: "room_join_fail", EventName: "room_join_fail", Label: "进房失败", IsFailure: true},
{Key: "stay_30s", EventName: "stay_30s", Label: "停留 30 秒"},
{Key: "stay_3m", EventName: "stay_3m", Label: "停留 3 分钟"},
{Key: "stay_10m", EventName: "stay_10m", Label: "停留 10 分钟"},
{Key: "mic_up", EventName: "mic_up", Label: "上麦"},
{Key: "send_message", EventName: "send_message", Label: "发消息"},
{Key: "follow_host", EventName: "follow_host", Label: "关注主播"},
{Key: "add_friend", EventName: "add_friend", Label: "加好友"},
{Key: "check_in", EventName: "check_in", Label: "签到"},
{Key: "push_permission", EventName: "push_permission", Label: "Push 授权"},
}
type appTrackingCohortDef struct {
Key string
Label string
}
var appTrackingCohortDefs = []appTrackingCohortDef{
{Key: "country", Label: "国家"},
{Key: "language", Label: "语言"},
{Key: "channel", Label: "渠道"},
{Key: "login_method", Label: "登录方式"},
{Key: "first_room_stay", Label: "首房停留时长"},
}
type appTrackingStepAggregate struct {
EventName string
EventCount int64
UserCount int64
DeviceCount int64
}
func (r *Repository) queryAppTrackingFunnelSteps(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) ([]appTrackingStepAggregate, error) {
filter, args := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
names := appTrackingEventNames()
placeholders := sqlPlaceholders(len(names))
for _, name := range names {
args = append(args, name)
}
rows, err := r.db.QueryContext(ctx, `
SELECT event_name, COUNT(*), COUNT(DISTINCT `+appTrackingIdentitySQL("")+`), COUNT(DISTINCT NULLIF(device_id, ''))
FROM app_tracking_events
WHERE `+filter+` AND event_name IN (`+placeholders+`)
GROUP BY event_name`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []appTrackingStepAggregate{}
for rows.Next() {
var item appTrackingStepAggregate
if err := rows.Scan(&item.EventName, &item.EventCount, &item.UserCount, &item.DeviceCount); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func buildAppTrackingFunnelSteps(aggregates []appTrackingStepAggregate) []AppTrackingFunnelStep {
byEvent := map[string]appTrackingStepAggregate{}
for _, item := range aggregates {
byEvent[item.EventName] = item
}
out := make([]AppTrackingFunnelStep, 0, len(appTrackingStepDefs))
var baseUsers int64
var previousUsers int64
for index, def := range appTrackingStepDefs {
aggregate := byEvent[def.EventName]
if index == 0 {
baseUsers = aggregate.UserCount
}
step := AppTrackingFunnelStep{
Key: def.Key,
EventName: def.EventName,
Label: def.Label,
EventCount: aggregate.EventCount,
UserCount: aggregate.UserCount,
DeviceCount: aggregate.DeviceCount,
OverallConversionRate: ratio(aggregate.UserCount, baseUsers),
PreviousConversionRate: ratio(aggregate.UserCount, previousUsers),
IsFailure: def.IsFailure,
}
if previousUsers > aggregate.UserCount {
step.DropoffUsers = previousUsers - aggregate.UserCount
}
out = append(out, step)
previousUsers = aggregate.UserCount
}
return out
}
func appTrackingTotals(steps []AppTrackingFunnelStep) AppTrackingFunnelTotals {
byEvent := map[string]int64{}
for _, step := range steps {
byEvent[step.EventName] = step.UserCount
}
return AppTrackingFunnelTotals{
LoginStartUsers: byEvent["login_start"],
LoginSuccessUsers: byEvent["login_success"],
ProfileCompleteUsers: byEvent["profile_complete"],
RoomImpressionUsers: byEvent["home_room_impression"],
RoomClickUsers: byEvent["home_room_click"],
RoomJoinSuccessUsers: byEvent["room_join_success"],
RoomJoinFailUsers: byEvent["room_join_fail"],
Stay30sUsers: byEvent["stay_30s"],
Stay3mUsers: byEvent["stay_3m"],
Stay10mUsers: byEvent["stay_10m"],
SendMessageUsers: byEvent["send_message"],
FollowHostUsers: byEvent["follow_host"],
AddFriendUsers: byEvent["add_friend"],
CheckInUsers: byEvent["check_in"],
PushPermissionUsers: byEvent["push_permission"],
}
}
func (r *Repository) queryAppTrackingD1Total(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) (int64, int64, error) {
filter, args := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
args = append(args, app)
row := r.db.QueryRowContext(ctx, `
WITH base AS (
SELECT DISTINCT stat_day, `+appTrackingIdentitySQL("")+` AS identity
FROM app_tracking_events
WHERE `+filter+` AND event_name = 'login_success'
)
SELECT COUNT(*),
COALESCE(SUM(CASE WHEN EXISTS (
SELECT 1 FROM app_tracking_events n
WHERE n.app_code = ? AND n.stat_day = DATE_ADD(base.stat_day, INTERVAL 1 DAY)
AND `+appTrackingIdentitySQL("n")+` = base.identity
LIMIT 1
) THEN 1 ELSE 0 END),0)
FROM base`, args...)
var baseUsers, retainedUsers int64
if err := row.Scan(&baseUsers, &retainedUsers); err != nil {
return 0, 0, err
}
return baseUsers, retainedUsers, nil
}
func (r *Repository) queryAppTrackingD1Cohorts(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) ([]AppTrackingFunnelCohort, error) {
out := []AppTrackingFunnelCohort{}
for _, def := range appTrackingCohortDefs {
rows, err := r.queryAppTrackingD1CohortDimension(ctx, app, startMS, endMS, countryID, regionID, regionIDs, def)
if err != nil {
return nil, err
}
stepCounts, err := r.queryAppTrackingCohortSteps(ctx, app, startMS, endMS, countryID, regionID, regionIDs, def)
if err != nil {
return nil, err
}
for index := range rows {
rows[index].Steps = buildAppTrackingCohortSteps(stepCounts[rows[index].Value])
}
out = append(out, rows...)
}
return out, nil
}
func (r *Repository) queryAppTrackingD1CohortDimension(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64, def appTrackingCohortDef) ([]AppTrackingFunnelCohort, error) {
filter, filterArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
sourceSQL := appTrackingD1CohortSourceSQL(def.Key, filter)
args := appTrackingCohortSourceArgs(def.Key, filterArgs)
args = append(args, app)
rows, err := r.db.QueryContext(ctx, `
WITH base AS (`+sourceSQL+`)
SELECT value, COUNT(*),
COALESCE(SUM(CASE WHEN EXISTS (
SELECT 1 FROM app_tracking_events n
WHERE n.app_code = ? AND n.stat_day = DATE_ADD(base.stat_day, INTERVAL 1 DAY)
AND `+appTrackingIdentitySQL("n")+` = base.identity
LIMIT 1
) THEN 1 ELSE 0 END),0)
FROM base
GROUP BY value
ORDER BY COUNT(*) DESC, value ASC
LIMIT 50`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []AppTrackingFunnelCohort{}
for rows.Next() {
var value sql.NullString
var baseUsers, retainedUsers int64
if err := rows.Scan(&value, &baseUsers, &retainedUsers); err != nil {
return nil, err
}
cohortValue := normalizeCohortValue(value.String)
out = append(out, AppTrackingFunnelCohort{
Dimension: def.Key,
DimensionLabel: def.Label,
Value: cohortValue,
Label: cohortValue,
BaseUsers: baseUsers,
D1RetentionUsers: retainedUsers,
D1RetentionRate: ratio(retainedUsers, baseUsers),
Steps: []AppTrackingCohortStepCount{},
})
}
return out, rows.Err()
}
func (r *Repository) queryAppTrackingCohortSteps(ctx context.Context, app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64, def appTrackingCohortDef) (map[string]map[string]int64, error) {
filter, filterArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
cohortSQL := appTrackingStepCohortSourceSQL(def.Key, filter)
args := appTrackingCohortSourceArgs(def.Key, filterArgs)
eventFilter, eventArgs := appTrackingFilter(app, startMS, endMS, countryID, regionID, regionIDs)
args = append(args, eventArgs...)
names := appTrackingEventNames()
placeholders := sqlPlaceholders(len(names))
for _, name := range names {
args = append(args, name)
}
rows, err := r.db.QueryContext(ctx, `
WITH cohort AS (`+cohortSQL+`),
events AS (
SELECT DISTINCT `+appTrackingIdentitySQL("")+` AS identity, event_name
FROM app_tracking_events
WHERE `+eventFilter+` AND event_name IN (`+placeholders+`)
)
SELECT cohort.value, events.event_name, COUNT(DISTINCT cohort.identity)
FROM cohort
JOIN events ON events.identity = cohort.identity
GROUP BY cohort.value, events.event_name`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]map[string]int64{}
for rows.Next() {
var value sql.NullString
var eventName string
var userCount int64
if err := rows.Scan(&value, &eventName, &userCount); err != nil {
return nil, err
}
cohortValue := normalizeCohortValue(value.String)
if out[cohortValue] == nil {
out[cohortValue] = map[string]int64{}
}
out[cohortValue][eventName] = userCount
}
return out, rows.Err()
}
func buildAppTrackingCohortSteps(counts map[string]int64) []AppTrackingCohortStepCount {
out := make([]AppTrackingCohortStepCount, 0, len(appTrackingStepDefs))
for _, def := range appTrackingStepDefs {
out = append(out, AppTrackingCohortStepCount{
EventName: def.EventName,
Label: def.Label,
UserCount: counts[def.EventName],
})
}
return out
}
func appTrackingD1CohortSourceSQL(dimension string, filter string) string {
if dimension == "first_room_stay" {
return `
SELECT base.stat_day, base.identity, ` + appTrackingStayBucketSQL("stay.stay_rank") + ` AS value
FROM (
SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity
FROM app_tracking_events
WHERE ` + filter + ` AND event_name = 'login_success'
) base
LEFT JOIN (
SELECT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity,
MAX(CASE event_name WHEN 'stay_10m' THEN 3 WHEN 'stay_3m' THEN 2 WHEN 'stay_30s' THEN 1 ELSE 0 END) AS stay_rank
FROM app_tracking_events
WHERE ` + filter + ` AND event_name IN ('stay_30s', 'stay_3m', 'stay_10m')
GROUP BY stat_day, identity
) stay ON stay.stat_day = base.stat_day AND stay.identity = base.identity`
}
return `
SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity, ` + appTrackingCohortValueSQL(dimension, "") + ` AS value
FROM app_tracking_events
WHERE ` + filter + ` AND event_name = 'login_success'`
}
func appTrackingStepCohortSourceSQL(dimension string, filter string) string {
if dimension == "first_room_stay" {
return `
SELECT DISTINCT base.identity, ` + appTrackingStayBucketSQL("stay.stay_rank") + ` AS value
FROM (
SELECT DISTINCT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity
FROM app_tracking_events
WHERE ` + filter + ` AND event_name = 'login_success'
) base
LEFT JOIN (
SELECT stat_day, ` + appTrackingIdentitySQL("") + ` AS identity,
MAX(CASE event_name WHEN 'stay_10m' THEN 3 WHEN 'stay_3m' THEN 2 WHEN 'stay_30s' THEN 1 ELSE 0 END) AS stay_rank
FROM app_tracking_events
WHERE ` + filter + ` AND event_name IN ('stay_30s', 'stay_3m', 'stay_10m')
GROUP BY stat_day, identity
) stay ON stay.stat_day = base.stat_day AND stay.identity = base.identity`
}
return `
SELECT DISTINCT ` + appTrackingIdentitySQL("") + ` AS identity, ` + appTrackingCohortValueSQL(dimension, "") + ` AS value
FROM app_tracking_events
WHERE ` + filter + ` AND event_name = 'login_success'`
}
func appTrackingCohortSourceArgs(dimension string, filterArgs []any) []any {
args := append([]any{}, filterArgs...)
if dimension == "first_room_stay" {
args = append(args, filterArgs...)
}
return args
}
func appTrackingStayBucketSQL(rankExpr string) string {
return "CASE COALESCE(" + rankExpr + ",0) WHEN 3 THEN '10m+' WHEN 2 THEN '3m-10m' WHEN 1 THEN '30s-3m' ELSE '<30s' END"
}
func appTrackingCohortValueSQL(dimension string, alias string) string {
prefix := ""
if alias != "" {
prefix = alias + "."
}
jsonValue := func(path string) string {
return "NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(" + prefix + "properties_json, '$." + path + "'))), '')"
}
switch dimension {
case "country":
return "COALESCE(" + jsonValue("country") + ", IF(" + prefix + "country_id > 0, CAST(" + prefix + "country_id AS CHAR), 'unknown'))"
case "language":
return "COALESCE(NULLIF(TRIM(" + prefix + "language), ''), " + jsonValue("language") + ", 'unknown')"
case "channel":
return "COALESCE(" + jsonValue("channel") + ", " + jsonValue("utm_source") + ", 'unknown')"
case "login_method":
return "COALESCE(" + jsonValue("login_method") + ", " + jsonValue("login_provider") + ", " + jsonValue("method") + ", " + jsonValue("provider") + ", 'unknown')"
default:
return "'unknown'"
}
}
func appTrackingFilter(app string, startMS, endMS int64, countryID int64, regionID int64, regionIDs []int64) (string, []any) {
filter := "app_code = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?"
args := []any{app, startMS, endMS}
if countryID > 0 {
filter += " AND country_id = ?"
args = append(args, countryID)
}
if regionID > 0 {
filter += " AND region_id = ?"
args = append(args, regionID)
return filter, args
}
regionIDs = normalizeTrackingRegionIDs(regionIDs)
if len(regionIDs) > 0 {
filter += " AND region_id IN (" + sqlPlaceholders(len(regionIDs)) + ")"
for _, id := range regionIDs {
args = append(args, id)
}
}
return filter, args
}
func normalizeTrackingRegionIDs(regionIDs []int64) []int64 {
out := make([]int64, 0, len(regionIDs))
seen := map[int64]struct{}{}
for _, id := range regionIDs {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func appTrackingIdentitySQL(alias string) string {
prefix := ""
if alias != "" {
prefix = alias + "."
}
return "CASE WHEN " + prefix + "user_id > 0 THEN CONCAT('u:', " + prefix + "user_id) ELSE CONCAT('d:', " + prefix + "device_id) END"
}
func appTrackingEventNames() []string {
out := make([]string, 0, len(appTrackingStepDefs))
for _, def := range appTrackingStepDefs {
out = append(out, def.EventName)
}
return out
}
func sqlPlaceholders(count int) string {
if count <= 0 {
return "NULL"
}
parts := make([]string, count)
for index := range parts {
parts[index] = "?"
}
return strings.Join(parts, ",")
}
func normalizeCohortValue(value string) string {
value = strings.TrimSpace(value)
if value == "" || strings.EqualFold(value, "null") {
return "unknown"
}
return value
}
func normalizeRange(startMS, endMS int64) (int64, int64) {
if startMS <= 0 {
now := time.Now().UTC()

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"math"
"runtime"
"strconv"
"strings"
"testing"
"time"
@ -153,6 +154,110 @@ func TestConsumeAppTrackingEventsStoresRawEventsAndDeduplicates(t *testing.T) {
}
}
func TestQueryAppTrackingFunnelAggregatesStepsAndD1Cohorts(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_app_tracking_funnel_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
event := func(userID int64, deviceID string, eventName string, offset time.Duration, properties string) AppTrackingEvent {
return AppTrackingEvent{
AppCode: "lalu",
EventID: "evt-" + strconv.FormatInt(userID, 10) + "-" + deviceID + "-" + eventName + "-" + strconv.FormatInt(int64(offset/time.Second), 10),
EventName: eventName,
UserID: userID,
DeviceID: deviceID,
Language: "en-US",
CountryID: 86,
RegionID: 210,
Properties: json.RawMessage(properties),
OccurredAtMS: start.Add(offset).UnixMilli(),
}
}
user1Props := `{"country":"US","channel":"ads","login_method":"google"}`
user2Props := `{"country":"BR","channel":"organic","login_method":"phone"}`
events := []AppTrackingEvent{
event(1, "dev-1", "login_start", 0, user1Props),
event(1, "dev-1", "login_success", time.Second, user1Props),
event(1, "dev-1", "profile_start", 2*time.Second, user1Props),
event(1, "dev-1", "profile_complete", 3*time.Second, user1Props),
event(1, "dev-1", "home_room_impression", 4*time.Second, user1Props),
event(1, "dev-1", "home_room_click", 5*time.Second, user1Props),
event(1, "dev-1", "room_join_success", 6*time.Second, user1Props),
event(1, "dev-1", "stay_30s", 30*time.Second, user1Props),
event(1, "dev-1", "stay_3m", 3*time.Minute, user1Props),
event(1, "dev-1", "send_message", 4*time.Minute, user1Props),
event(1, "dev-1", "follow_host", 5*time.Minute, user1Props),
event(1, "dev-1", "push_permission", 6*time.Minute, user1Props),
event(1, "dev-1", "send_message", 24*time.Hour+time.Second, user1Props),
event(2, "dev-2", "login_start", 0, user2Props),
event(2, "dev-2", "login_success", time.Second, user2Props),
event(2, "dev-2", "home_room_impression", 2*time.Second, user2Props),
event(2, "dev-2", "home_room_click", 3*time.Second, user2Props),
event(2, "dev-2", "room_join_fail", 4*time.Second, user2Props),
}
excluded := event(3, "dev-3", "login_start", 0, user1Props)
excluded.EventID = "evt-excluded-region-login-start"
excluded.RegionID = 999
events = append(events, excluded)
if _, err := repository.ConsumeAppTrackingEvents(appcode.WithContext(ctx, "lalu"), events); err != nil {
t.Fatalf("consume app tracking events: %v", err)
}
funnel, err := repository.QueryAppTrackingFunnel(ctx, AppTrackingFunnelQuery{
AppCode: "lalu",
StartMS: start.UnixMilli(),
EndMS: start.Add(24 * time.Hour).UnixMilli(),
RegionIDs: []int64{210},
})
if err != nil {
t.Fatalf("query app tracking funnel: %v", err)
}
if funnel.Totals.LoginStartUsers != 2 || funnel.Totals.LoginSuccessUsers != 2 || funnel.Totals.RoomJoinSuccessUsers != 1 || funnel.Totals.RoomJoinFailUsers != 1 {
t.Fatalf("unexpected funnel totals: %+v", funnel.Totals)
}
if funnel.Totals.D1RetentionBaseUsers != 2 || funnel.Totals.D1RetentionUsers != 1 || math.Abs(funnel.Totals.D1RetentionRate-0.5) > 0.0001 {
t.Fatalf("unexpected d1 totals: %+v", funnel.Totals)
}
stepByEvent := map[string]AppTrackingFunnelStep{}
for _, step := range funnel.Steps {
stepByEvent[step.EventName] = step
}
if stepByEvent["login_start"].UserCount != 2 || stepByEvent["profile_complete"].UserCount != 1 || stepByEvent["stay_3m"].UserCount != 1 {
t.Fatalf("unexpected step counts: %+v", stepByEvent)
}
if !stepByEvent["room_join_fail"].IsFailure {
t.Fatalf("room_join_fail must be marked as failure: %+v", stepByEvent["room_join_fail"])
}
cohort := func(dimension, value string) AppTrackingFunnelCohort {
for _, item := range funnel.Cohorts {
if item.Dimension == dimension && item.Value == value {
return item
}
}
t.Fatalf("missing cohort %s=%s in %+v", dimension, value, funnel.Cohorts)
return AppTrackingFunnelCohort{}
}
us := cohort("country", "US")
if us.BaseUsers != 1 || us.D1RetentionUsers != 1 || math.Abs(us.D1RetentionRate-1) > 0.0001 {
t.Fatalf("unexpected US cohort: %+v", us)
}
stay := cohort("first_room_stay", "3m-10m")
if stay.BaseUsers != 1 || stay.D1RetentionUsers != 1 {
t.Fatalf("unexpected stay cohort: %+v", stay)
}
}
func TestConsumeAppTrackingEventsRejectsInvalidProperties(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)