Skip to content

Commit 3dea6c4

Browse files
committed
feat/seongdong/실험 결과 자동계산, 이벤트 수집, 사용자 식별, feature flag, 분석 대시보드 지원 api, 의사결정 이력, 회고 기능 추가
1 parent db0f30d commit 3dea6c4

30 files changed

Lines changed: 1976 additions & 17 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@ bug-report*.html
2929
create_notion_page.py
3030
.claude/
3131

32-
lvup
32+
lvup
33+
docs/seongdong/

backend/app/api/v1/api.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
from fastapi import APIRouter
2-
from app.api.v1.endpoints import status, experiments, dashboard, bug_reports
2+
from app.api.v1.endpoints import (
3+
status, experiments, dashboard, bug_reports,
4+
events, feature_flags, analytics, decisions, reflections,
5+
)
36

47
api_router = APIRouter()
5-
api_router.include_router(status.router, prefix="/status", tags=["status"])
6-
api_router.include_router(experiments.router, prefix="/experiments", tags=["experiments"])
7-
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
8-
api_router.include_router(bug_reports.router, prefix="/bug-reports", tags=["bug-reports"])
8+
api_router.include_router(status.router, prefix="/status", tags=["status"])
9+
api_router.include_router(experiments.router, prefix="/experiments", tags=["experiments"])
10+
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
11+
api_router.include_router(bug_reports.router, prefix="/bug-reports", tags=["bug-reports"])
12+
api_router.include_router(events.router, prefix="", tags=["events"])
13+
api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"])
14+
api_router.include_router(analytics.router, prefix="/analytics", tags=["analytics"])
15+
api_router.include_router(decisions.router, prefix="", tags=["decisions"])
16+
api_router.include_router(reflections.router, prefix="/reflections", tags=["reflections"])
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from fastapi import APIRouter, Query
2+
from datetime import datetime
3+
from typing import Optional, List
4+
from app.schemas.analytics import (
5+
EventListResponse, TrendsResponse, FunnelResponse,
6+
RetentionResponse, FunnelRequest,
7+
)
8+
from app.services.analytics import analytics_service
9+
10+
router = APIRouter()
11+
12+
13+
@router.get("/events", response_model=EventListResponse)
14+
async def get_events(
15+
event_name: Optional[str] = Query(None),
16+
from_: Optional[datetime] = Query(None, alias="from"),
17+
to: Optional[datetime] = Query(None),
18+
page: int = Query(1, ge=1),
19+
limit: int = Query(20, ge=1, le=100),
20+
):
21+
return await analytics_service.get_events(event_name, from_, to, page, limit)
22+
23+
24+
@router.get("/event-names", response_model=List[str])
25+
async def get_event_names():
26+
return await analytics_service.get_event_names()
27+
28+
29+
@router.get("/trends", response_model=TrendsResponse)
30+
async def get_trends(
31+
event_name: str = Query(...),
32+
from_: datetime = Query(..., alias="from"),
33+
to: datetime = Query(...),
34+
granularity: str = Query("day", pattern="^(day|week)$"),
35+
):
36+
return await analytics_service.get_trends(event_name, from_, to, granularity)
37+
38+
39+
@router.post("/funnels", response_model=FunnelResponse)
40+
async def get_funnels(data: FunnelRequest):
41+
return await analytics_service.get_funnels(data.steps, data.from_, data.to)
42+
43+
44+
@router.get("/retention", response_model=RetentionResponse)
45+
async def get_retention(event_name: str = Query(...)):
46+
return await analytics_service.get_retention(event_name)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from fastapi import APIRouter
2+
from typing import List
3+
from app.schemas.decision import Decision, DecisionCreate, LearningNote, LearningNoteCreate
4+
from app.services.decision import decision_service
5+
6+
router = APIRouter()
7+
8+
9+
@router.post("/decisions", response_model=Decision, status_code=201)
10+
async def create_decision(data: DecisionCreate):
11+
return await decision_service.create_decision(data)
12+
13+
14+
@router.get("/experiments/{experiment_id}/decisions", response_model=List[Decision])
15+
async def list_decisions(experiment_id: str):
16+
return await decision_service.list_decisions(experiment_id)
17+
18+
19+
@router.post("/learning-notes", response_model=LearningNote, status_code=201)
20+
async def create_learning_note(data: LearningNoteCreate):
21+
return await decision_service.create_learning_note(data)
22+
23+
24+
@router.get("/experiments/{experiment_id}/learning-notes", response_model=List[LearningNote])
25+
async def list_learning_notes(experiment_id: str):
26+
return await decision_service.list_learning_notes(experiment_id)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from fastapi import APIRouter
2+
from app.schemas.event import EventCapture, PersonIdentify
3+
from app.services.event import event_service
4+
5+
router = APIRouter()
6+
7+
8+
@router.post("/capture", status_code=202)
9+
async def capture(data: EventCapture):
10+
await event_service.capture(data)
11+
return {"success": True, "message": "accepted"}
12+
13+
14+
@router.post("/identify", status_code=200)
15+
async def identify(data: PersonIdentify):
16+
await event_service.identify(data)
17+
return {"success": True, "message": "identified"}

backend/app/api/v1/endpoints/experiments.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
from fastapi import APIRouter, HTTPException, Query
22
from typing import List, Optional
3-
from app.schemas.experiment import Experiment, ExperimentCreate, ExperimentUpdate, AssignmentResponse, ExperimentStatus
3+
from app.schemas.experiment import (
4+
Experiment, ExperimentCreate, ExperimentUpdate,
5+
AssignmentResponse, ExperimentStatus, ExperimentResult,
6+
)
7+
from app.schemas.reflection import ReflectionWindowUpdate
48
from app.services.experiment import experiment_service
9+
from app.services.reflection import reflection_service
510

611
router = APIRouter()
712

@@ -43,3 +48,14 @@ async def assign_user(experiment_id: str, user_id: str):
4348
if not result:
4449
raise HTTPException(status_code=404, detail="Experiment or variants not found")
4550
return result
51+
52+
53+
@router.get("/{experiment_id}/result", response_model=ExperimentResult)
54+
async def get_result(experiment_id: str):
55+
return await experiment_service.get_result(experiment_id)
56+
57+
58+
@router.patch("/{experiment_id}/reflection-window")
59+
async def update_reflection_window(experiment_id: str, data: ReflectionWindowUpdate):
60+
await reflection_service.update_reflection_window(experiment_id, data)
61+
return {"success": True}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from fastapi import APIRouter, Query
2+
from typing import List
3+
from app.schemas.feature_flag import FeatureFlag, FeatureFlagCreate, FeatureFlagUpdate, FlagDecision
4+
from app.services.feature_flag import feature_flag_service
5+
6+
router = APIRouter()
7+
8+
9+
@router.get("/decide", response_model=dict)
10+
async def decide(flag_key: str = Query(...), user_id: str = Query(...)):
11+
variant = await feature_flag_service.decide(flag_key, user_id)
12+
return {"success": True, "data": {"variant": variant}}
13+
14+
15+
@router.get("/", response_model=List[FeatureFlag])
16+
async def list_flags():
17+
return await feature_flag_service.list()
18+
19+
20+
@router.post("/", response_model=FeatureFlag, status_code=201)
21+
async def create_flag(data: FeatureFlagCreate):
22+
return await feature_flag_service.create(data)
23+
24+
25+
@router.patch("/{flag_key}", response_model=FeatureFlag)
26+
async def update_flag(flag_key: str, data: FeatureFlagUpdate):
27+
return await feature_flag_service.update(flag_key, data)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from fastapi import APIRouter, Query
2+
from app.schemas.reflection import (
3+
Reflection, ReflectionCreate,
4+
ReflectionCheckResponse, ReflectionSummary,
5+
)
6+
from app.services.reflection import reflection_service
7+
8+
router = APIRouter()
9+
10+
11+
@router.post("/", response_model=Reflection, status_code=201)
12+
async def submit_reflection(data: ReflectionCreate):
13+
return await reflection_service.submit(data)
14+
15+
16+
@router.get("/check", response_model=ReflectionCheckResponse)
17+
async def check_reflection(
18+
user_id: str = Query(...),
19+
experiment_id: str = Query(...),
20+
):
21+
return await reflection_service.check(user_id, experiment_id)
22+
23+
24+
@router.get("/summary", response_model=ReflectionSummary)
25+
async def reflection_summary(experiment_id: str = Query(...)):
26+
return await reflection_service.summary(experiment_id)

backend/app/schemas/analytics.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from pydantic import BaseModel
2+
from datetime import datetime
3+
from typing import Optional, List, Any
4+
5+
6+
class EventLogItem(BaseModel):
7+
id: int
8+
user_id: str
9+
cohort_id: Optional[str] = None
10+
event_name: str
11+
properties: Optional[Any] = None
12+
event_time: datetime
13+
created_at: datetime
14+
15+
16+
class EventListResponse(BaseModel):
17+
total: int
18+
page: int
19+
limit: int
20+
items: List[EventLogItem]
21+
22+
23+
class TrendPoint(BaseModel):
24+
date: str
25+
count: int
26+
27+
28+
class TrendsResponse(BaseModel):
29+
event_name: str
30+
granularity: str
31+
data: List[TrendPoint]
32+
33+
34+
class FunnelStep(BaseModel):
35+
step: str
36+
users: int
37+
conversion_rate: Optional[float] = None
38+
39+
40+
class FunnelRequest(BaseModel):
41+
steps: List[str]
42+
from_: Optional[datetime] = None
43+
to: Optional[datetime] = None
44+
45+
class Config:
46+
populate_by_name = True
47+
48+
49+
class FunnelResponse(BaseModel):
50+
steps: List[FunnelStep]
51+
52+
53+
class RetentionCell(BaseModel):
54+
cohort_week: str
55+
week_num: int
56+
retained: int
57+
cohort_size: int
58+
retention_rate: float
59+
60+
61+
class RetentionResponse(BaseModel):
62+
event_name: str
63+
data: List[RetentionCell]

backend/app/schemas/decision.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from pydantic import BaseModel
2+
from datetime import datetime
3+
from typing import Optional
4+
from enum import Enum
5+
6+
7+
class DecisionType(str, Enum):
8+
SHIP = "SHIP"
9+
HOLD = "HOLD"
10+
ROLLBACK = "ROLLBACK"
11+
12+
13+
class DecisionCreate(BaseModel):
14+
experiment_id: str
15+
decision: DecisionType
16+
reason: str
17+
decided_by: str
18+
19+
20+
class Decision(BaseModel):
21+
id: str
22+
experiment_id: str
23+
decision: DecisionType
24+
reason: str
25+
decided_by: str
26+
decided_at: datetime
27+
28+
29+
class LearningNoteCreate(BaseModel):
30+
experiment_id: str
31+
content: str
32+
created_by: Optional[str] = None
33+
34+
35+
class LearningNote(BaseModel):
36+
id: str
37+
experiment_id: str
38+
content: str
39+
created_by: Optional[str] = None
40+
created_at: datetime

0 commit comments

Comments
 (0)