Skip to content

stats 로깅 시스템 & 대시보드 api 추가#93

Open
kimgh06 wants to merge 12 commits into
developfrom
feature/stats-dashboard
Open

stats 로깅 시스템 & 대시보드 api 추가#93
kimgh06 wants to merge 12 commits into
developfrom
feature/stats-dashboard

Conversation

@kimgh06

@kimgh06 kimgh06 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

요약

  • 통계 대시보드에서 사용할 백엔드 API를 추가했습니다.
  • 앱 로그와 통계 이벤트를 DB에 기록하고, 대시보드용 집계 데이터를 조회할 수 있도록 repository를 추가했습니다.
  • lifecycle 이벤트를 stats recorder로 전달해 접속/이동/타일 오픈/깃발/폭발 등 주요 이벤트를 수집하도록 연결했습니다.
  • origin/develop 최신 변경을 병합하면서 발생한 conflict를 해결했습니다.

주요 변경

  • GET /stats/dashboard 엔드포인트 추가
  • app log/stat event 테이블 및 조회/집계 로직 추가
  • lifecycle sink를 통한 stats event 기록 추가
  • DB 로그 sink 추가
  • develop 리팩토링에 맞춰 get_db 사용 및 server 초기화/종료 흐름 정리

검증

  • uv run python -c "import server; print(server.app.title)"
  • uv run ruff check core/lifecycle/internal/hlife.py handler/cursor/internal/cursor_handler.py server.py utils/stats/internal/dashboard_repository.py handler/board/storage/test/test_record_repository.py
  • uv run --with pytest --with pytest-asyncio python -m pytest core/lifecycle/tests handler/board/storage/test -q
    • 41 passed

참고

  • Backend-only PR입니다.
  • 현재 PR은 feature/stats-dashboard에서 develop 대상으로 올라가 있습니다.

@kimgh06 kimgh06 changed the title feat: add stats dashboard backend stats 로깅 시스템 & 대시보드 api 추가 Jun 16, 2026
@kimgh06

kimgh06 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

#91 (comment)
lifecycle 발생 시점에 stats recorder가 구독해서 이벤트를 기록할 수 있게 만든 observer hook입니다.
hlife, rlife 내부에서 직접 stats/log 모듈을 import하면 core lifecycle이 관측 기능에 의존하게 돼서, 의존 방향을 끊으려고 sink를 둔 의도였습니다.
다만 현재 이름과 위치만 보면 용도가 잘 안 보이니, 필요하면 lifecycle observer 성격이 드러나도록 네이밍/주석을 보강하거나 develop 구조에 맞춰 더 적절한 위치로 옮기겠습니다.

@kimgh06 kimgh06 deployed to development June 23, 2026 13:27 — with GitHub Actions Active
@kimgh06

kimgh06 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

반영했습니다.

  • utils/logging/internal/db_sink.py

    • Loguru record 입력은 외부 라이브러리 경계라 Mapping[str, object]로 받고, 내부에서는 Protocol, JsonValue, JsonObject로 타입을 좁혔습니다.
    • context dict를 직접 조립하지 않고 AppLogContext dataclass로 한 번 감싼 뒤, DB 저장 직전에만 JSON dict로 변환하도록 변경했습니다.
    • SQLite write lock 대기 목적을 주석으로 보강했고, 로그 저장이 오래 붙잡히지 않도록 busy timeout을 5초에서 1초로 줄였습니다.
  • utils/stats/internal/dashboard_repository.py

    • 내부 집계 로직의 dict[str, Any] 흐름을 StatEvent, AppLog, ActiveCursor, PlayerStat, TileStat 등 dataclass 중심으로 변경했습니다.
    • API 응답은 기존 shape을 유지하되, 마지막 반환 직전에만 asdict()로 변환하도록 정리했습니다.
  • utils/stats/internal/event_recorder.py, utils/stats/internal/lifecycle_recorder.py

    • stat payload 타입을 dict[str, Any] 대신 JsonObject로 맞췄습니다.

@kimgh06 kimgh06 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅇㅇ

Comment thread server.py
continue

position = cursor.position
cursors.append(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

object형태로 핸들링

actor_id: str | None
tile_id: str | None
x: int | None
y: int | None

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버에서 핸들링하는 방향이랑 다름


async def insert_app_log(
db: DB,
*,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 왜 쓰나요?

from __future__ import annotations

import json
import sqlite3

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aiosqlite 사용

Comment on lines +127 to +129
if "locked" not in str(e).lower():
break
time.sleep(0.1 * (attempt + 1))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

빼셈, 만약 이거가 있어야 문제가 안 생긴다 해도 문제임
그냥 time sleep 자체가 안티패턴, 특히 그냥 안되서 기다리는거

Comment on lines +18 to +26
@dataclass(frozen=True)
class StatEventRecord:
event_type: str
actor_id: str | None
tile_id: str | None
x: int | None
y: int | None
value: int | None
payload: JsonObject | None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 높은 확율로 나중에 바꿔야 할탠데 일단 이렇게 만들어 놨으니까 지금은 통과

Comment thread server.py
Comment on lines +177 to +191
{
"connection_id": cursor_id,
"cursor_id": cursor_id,
"color": int(cursor.color),
"tile_id": f"tile:{position.x}:{position.y}",
"x": position.x,
"y": position.y,
"score": cursor.score,
"is_alive": cursor.is_alive,
"active_at": cursor.active_at.isoformat(),
"window": {
"width": cursor.width,
"height": cursor.height,
},
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 데이터 항상 struct한 형태로 핸들링

DBContextFactory = Callable[[], AbstractAsyncContextManager[DB]]


@dataclass(frozen=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

frozen쓰는건 좋음, 근데 이거 알고쓰고
쓸거면 slot도 같이 쓰셈


DB = aiosqlite.Connection
Row = aiosqlite.Row
JsonValue: TypeAlias = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

왜 힌팅을 이렇게 하는가? recursive한 구조가 굳이 필요했나?
json을 힌팅하기 위해 사용한 것 치고는 너무 많은 복잡성

LogRecord: TypeAlias = Mapping[str, object]


@runtime_checkable

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 뭐임?

LifecycleSink = Callable[[LifeCycle], None]


class LifecycleSinkRegistry:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 용도 뭐임?

Comment on lines +73 to +97
@staticmethod
async def wait_until_idle(timeout: float | None = None):
event = EventBroker._get_idle_event()
await asyncio.wait_for(event.wait(), timeout=timeout)

@staticmethod
def _get_idle_event():
loop = asyncio.get_running_loop()
if EventBroker._idle_event is None or EventBroker._idle_loop is not loop:
EventBroker._idle_event = asyncio.Event()
EventBroker._idle_loop = loop
if EventBroker.is_end():
EventBroker._idle_event.set()
return EventBroker._idle_event

@staticmethod
def _mark_running():
event = EventBroker._get_idle_event()
event.clear()

@staticmethod
def _mark_done():
if EventBroker.is_end():
event = EventBroker._get_idle_event()
event.set()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

브로커에 기능 추가되면 반드시 합리적인 이유 필요함

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants