漏斗数据
This commit is contained in:
parent
b666a5ad99
commit
dc44b0bf08
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user