diff --git a/ds_new_user_analy.py b/ds_new_user_analy.py index a1d4f9a..91746b0 100644 --- a/ds_new_user_analy.py +++ b/ds_new_user_analy.py @@ -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: