Refactor new user retention metrics: update filters, add country data, and enhance retention checks. 튜토 완료 수집 조건 변경, 득템 기준 변경 (장비만)

This commit is contained in:
Gnill82
2025-08-30 14:54:30 +09:00
parent a45c7581d5
commit 1f3a359abd

View File

@ -309,7 +309,7 @@ def get_fixed_metrics_config() -> Dict[str, Dict]:
"agg_type": "count",
"filters": [
{"term": {"body.action_type.keyword": "Complete"}},
{"term": {"body.stage_type.keyword": "result"}}
{"term": {"body.stage_type.keyword": "tutorial_escape_portal"}}
]
},
"guide_quest_stage": {
@ -334,7 +334,8 @@ def get_fixed_metrics_config() -> Dict[str, Dict]:
"index": "ds-logs-live-item_get",
"time_range": "d0",
"agg_type": "max",
"field": "body.item_grade"
"field": "body.item_grade",
"filters": [{"term": {"body.base_type": 2}}]
},
"blueprint_use_count": {
"index": "ds-logs-live-craft_from_blueprint",
@ -509,11 +510,21 @@ def get_fixed_metrics_config() -> Dict[str, Dict]:
"field": "body.season_pass_step"
},
# ==================== 리텐션 판정 (retention_d1 삭제됨) ====================
"retention_check": {
"index": "ds-logs-live-login_comp",
# ==================== 리텐션 판정 ====================
"retention_check_d1": {
"index": "ds-logs-live-heartbeat",
"time_range": "d1",
"agg_type": "exists"
},
"retention_check_d2_6": {
"index": "ds-logs-live-heartbeat",
"time_range": "d2_6",
"agg_type": "exists"
},
"retention_check_d7_plus": {
"index": "ds-logs-live-heartbeat",
"time_range": "d7_plus",
"agg_type": "exists"
}
}
@ -631,6 +642,7 @@ def get_new_user_cohort_optimized(
'create_time_kst': format_kst_time(new_user_map[uid]["create_time"]),
'create_time_dt': datetime.fromisoformat(new_user_map[uid]["create_time"].replace('Z', '+00:00')),
'language': 'N/A',
'country': 'N/A',
'device': 'N/A',
'nickname': 'N/A'
}
@ -665,14 +677,14 @@ def get_new_user_cohort_optimized(
"top_hits": {
"size": 1,
"sort": [{"@timestamp": {"order": "asc"}}],
"_source": ["body.device_mod", "body.nickname", "auth.id"]
"_source": ["body.device_mod", "body.nickname", "auth.id", "country"]
}
},
"latest_info": {
"top_hits": {
"size": 1,
"sort": [{"@timestamp": {"order": "desc"}}],
"_source": ["body.nickname", "body.language", "auth.id"]
"_source": ["body.nickname", "body.language", "auth.id", "country"]
}
}
}
@ -701,6 +713,11 @@ def get_new_user_cohort_optimized(
cohort[uid]['device'] = user_hit.get('body', {}).get('device_mod', 'N/A')
cohort[uid]['nickname'] = latest_info_hit.get('body', {}).get('nickname') or user_hit.get('body', {}).get('nickname', 'N/A')
# country 수집 (login_comp에서)
country = latest_info_hit.get('country') or user_hit.get('country')
if country:
cohort[uid]['country'] = country
# auth.id 수집 (1순위)
auth_id = latest_info_hit.get('auth', {}).get('id') or user_hit.get('auth', {}).get('id')
if auth_id:
@ -713,9 +730,9 @@ def get_new_user_cohort_optimized(
logger.info(f"login_comp에서 {len(login_comp_collected)}명의 정보 수집 완료")
# Step 4: log_return_to_lobby 인덱스에서 차선 정보 수집 (auth.id 2순위, language fallback)
# login_comp에서 수집되지 않은 유저 + language가 N/A인 유저들 처리
missing_uids = [uid for uid in uid_list if uid not in login_comp_collected or cohort[uid]['language'] == 'N/A']
# Step 4: log_return_to_lobby 인덱스에서 차선 정보 수집 (auth.id 2순위)
# login_comp에서 수집되지 않은 유저들 처리
missing_uids = [uid for uid in uid_list if uid not in login_comp_collected]
if missing_uids:
logger.info(f"log_return_to_lobby 인덱스에서 {len(missing_uids)}명의 차선 정보 수집 중 (auth.id 2순위)...")
@ -772,11 +789,11 @@ def get_new_user_cohort_optimized(
if cohort[uid]['nickname'] == 'N/A':
cohort[uid]['nickname'] = info_hit.get('body', {}).get('nickname', 'N/A')
# language fallback: country 값 사용 (language가 N/A인 경우에만)
if cohort[uid]['language'] == 'N/A':
# country 수집 (없는 경우에만)
if cohort[uid]['country'] == 'N/A':
country = info_hit.get('country')
if country:
cohort[uid]['language'] = f"country-{country}"
cohort[uid]['country'] = country
# auth.id 수집 (2순위, 없는 경우에만)
if cohort[uid]['auth_id'] == 'N/A':
@ -822,13 +839,22 @@ def build_fixed_msearch_queries(
d0_end = (create_time_dt + timedelta(hours=24)).strftime('%Y-%m-%dT%H:%M:%SZ')
d1_start = d0_end
d1_end = (create_time_dt + timedelta(hours=48)).strftime('%Y-%m-%dT%H:%M:%SZ')
d2_start = d1_end
d6_end = (create_time_dt + timedelta(hours=168)).strftime('%Y-%m-%dT%H:%M:%SZ') # 168시간 = 7일
d7_plus_start = d6_end
for metric_name, config in metrics_config.items():
# 시간 범위 선택
if config["time_range"] == "d0":
time_filter = {"range": {"@timestamp": {"gte": d0_start, "lt": d0_end}}}
else: # d1
elif config["time_range"] == "d1":
time_filter = {"range": {"@timestamp": {"gte": d1_start, "lt": d1_end}}}
elif config["time_range"] == "d2_6":
time_filter = {"range": {"@timestamp": {"gte": d2_start, "lt": d6_end}}}
elif config["time_range"] == "d7_plus":
time_filter = {"range": {"@timestamp": {"gte": d7_plus_start}}}
else: # 기본값
time_filter = {"range": {"@timestamp": {"gte": d0_start, "lt": d0_end}}}
# 사용자 식별 필터
if "target_field" in config:
@ -1085,7 +1111,9 @@ def process_fixed_batch(
'nickname': user_data['nickname'],
'create_time': user_data.get('create_time_kst', 'N/A'),
'retention_status': 'Retained_d0', # 기본값, 나중에 업데이트
'last_active_day': 0, # 마지막 접속일 추가
'language': user_data['language'],
'country': user_data.get('country', 'N/A'),
'device': user_data['device'],
'active_seconds': user_session_metrics.get('active_seconds', 0),
'total_playtime_minutes': user_session_metrics.get('total_playtime_minutes', 0),
@ -1205,9 +1233,21 @@ def process_fixed_batch(
else:
result[metric_name] = 0
# 리텐션 상태 업데이트
if metric_name == "retention_check" and result[metric_name] > 0:
result['retention_status'] = 'Retained_d1'
# 모든 메트릭 처리 후 리텐션 상태 판정
# D7+ > D2~6 > D1 > D0 순서로 판정 (마지막 접속일 기준)
if result.get('retention_check_d7_plus', 0) > 0:
result['retention_status'] = 'Retained_d7+'
result['last_active_day'] = 7 # 7+ 표시
elif result.get('retention_check_d2_6', 0) > 0:
result['retention_status'] = 'Retained_d2~6'
result['last_active_day'] = 2 # 정확한 날짜는 알 수 없으므로 2로 표시
elif result.get('retention_check_d1', 0) > 0:
result['retention_status'] = 'Retained_d1'
result['last_active_day'] = 1
else:
# D0에만 접속 (기본값 유지)
result['retention_status'] = 'Retained_d0'
result['last_active_day'] = 0
# 계산된 지표
if result.get('dungeon_entry_count', 0) > 0:
@ -1346,9 +1386,9 @@ def write_fixed_results(results: List[Dict], output_path: str) -> None:
logger.error("저장할 결과 데이터가 없습니다.")
return
# 수정된 헤더 (first_login_time 제거, create_time만 사용)
# 수정된 헤더 (first_login_time 제거, create_time만 사용, country 및 last_active_day 추가)
headers = [
'uid', 'auth_id', 'nickname', 'create_time', 'retention_status', 'language', 'device',
'uid', 'auth_id', 'nickname', 'create_time', 'retention_status', 'last_active_day', 'language', 'country', 'device',
'active_seconds', 'total_playtime_minutes', 'session_count', 'avg_session_length', 'logout_abnormal',
'dungeon_entry_count', 'dungeon_first_mode', 'dungeon_first_stalker', 'dungeon_first_result',
'dungeon_escape_count', 'dungeon_escape_rate', 'avg_survival_time', 'max_survival_time',
@ -1464,14 +1504,19 @@ def main():
# 요약
if results:
total_users = len(results)
retained_d0 = sum(1 for r in results if r.get('retention_status') == 'Retained_d0')
retained_d1 = sum(1 for r in results if r.get('retention_status') == 'Retained_d1')
retention_rate = (retained_d1 / total_users) * 100
retained_d2_6 = sum(1 for r in results if r.get('retention_status') == 'Retained_d2~6')
retained_d7_plus = sum(1 for r in results if r.get('retention_status') == 'Retained_d7+')
logger.info("=" * 80)
logger.info("분석 요약")
logger.info("=" * 80)
logger.info(f"총 신규 유저: {total_users:,}")
logger.info(f"D+1 리텐션: {retained_d1:,}명 ({retention_rate:.1f}%)")
logger.info(f"D+0 리텐션: {retained_d0:,}명 ({(retained_d0/total_users)*100:.1f}%)")
logger.info(f"D+1 리텐션: {retained_d1:,}명 ({(retained_d1/total_users)*100:.1f}%)")
logger.info(f"D+2~6 리텐션: {retained_d2_6:,}명 ({(retained_d2_6/total_users)*100:.1f}%)")
logger.info(f"D+7+ 리텐션: {retained_d7_plus:,}명 ({(retained_d7_plus/total_users)*100:.1f}%)")
logger.info(f"평균 활동 시간: {sum(r.get('active_seconds', 0) for r in results) / total_users / 60:.1f}")
except KeyboardInterrupt: