Skip to content

Commit 58b88c4

Browse files
authored
Merge pull request #316 from Pseudo-Lab/feat/db-logging
feat(cert): Add DB access logging and pageview tracking
2 parents 804bc4a + 560fb8e commit 58b88c4

File tree

7 files changed

+301
-4
lines changed

7 files changed

+301
-4
lines changed

cert/.env.example

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,14 @@ APP_HOST=example.com
1616
ENVIRONMENT=dev
1717
NODE_ENV=development
1818

19-
CERT_TEMPLATE_ARCHIVE_PASSWORD=your_secure_password
19+
CERT_TEMPLATE_ARCHIVE_PASSWORD=your_secure_password
20+
21+
# DB configuration
22+
DB_HOST=your_db_host
23+
DB_PORT=your_db_port
24+
DB_NAME=your_db_name
25+
DB_USER=your_db_user
26+
DB_PASSWORD=your_db_password
27+
ACCESS_LOGGING_ENABLED=true
28+
ACCESS_LOGGING_EXCLUDE_PATHS=/health,/api/certs/all-projects,/api/log/pageview
29+
ACCESS_LOGGING_IP_SALT=your_ip_salt

cert/backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ readme = "README.md"
66
requires-python = ">=3.11"
77
dependencies = [
88
"aiohttp>=3.12.15",
9+
"asyncpg>=0.30.0",
910
"aiosmtplib>=4.0.1",
1011
"fastapi>=0.109.1",
1112
"gunicorn>=21.2.0",

cert/backend/src/main.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from fastapi.middleware.cors import CORSMiddleware
77

88
from .routers import certificate
9+
from .routers.logging import logging_router
10+
from .utils.access_log import access_log_middleware, close_access_log, init_access_log
911

1012

1113
def configure_logging() -> None:
@@ -35,6 +37,9 @@ def configure_logging() -> None:
3537
openapi_url=None if hide_docs else "/openapi.json",
3638
)
3739

40+
# Access log middleware
41+
app.middleware("http")(access_log_middleware)
42+
3843
# CORS 미들웨어 설정
3944
origins = os.getenv("CORS_ORIGINS", "").split(",")
4045
app.add_middleware(
@@ -47,6 +52,7 @@ def configure_logging() -> None:
4752

4853
# 모든 환경에서 /api 프리픽스 사용 (개발/프로덕션 통일)
4954
app.include_router(certificate.certificate_router, prefix="/api")
55+
app.include_router(logging_router, prefix="/api")
5056
logger.info("FastAPI app initialized", extra={"environment": os.getenv("ENVIRONMENT")})
5157

5258

@@ -64,3 +70,13 @@ async def read_root():
6470
async def health_check():
6571
"""헬스 체크 엔드포인트"""
6672
return {"status": "healthy"}
73+
74+
75+
@app.on_event("startup")
76+
async def setup_access_log():
77+
await init_access_log(app)
78+
79+
80+
@app.on_event("shutdown")
81+
async def teardown_access_log():
82+
await close_access_log(app)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from fastapi import APIRouter, HTTPException, Request
2+
from pydantic import BaseModel
3+
4+
from ..utils.access_log import log_page_view
5+
6+
7+
logging_router = APIRouter(prefix="/log", tags=["log"])
8+
9+
10+
class PageViewRequest(BaseModel):
11+
path: str
12+
13+
14+
@logging_router.post("/pageview")
15+
async def track_page_view(payload: PageViewRequest, request: Request):
16+
path = payload.path.strip()
17+
if not path.startswith("/"):
18+
raise HTTPException(status_code=400, detail="path must start with '/'")
19+
20+
await log_page_view(request, path)
21+
return {"status": "ok"}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import hashlib
2+
import logging
3+
import os
4+
import time
5+
from dataclasses import dataclass
6+
from typing import Optional, Set
7+
8+
import asyncpg
9+
from fastapi import FastAPI, Request, Response
10+
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
@dataclass(frozen=True)
16+
class AccessLogConfig:
17+
dsn: str
18+
exclude_paths: Set[str]
19+
ip_salt: str
20+
environment: str
21+
22+
23+
def _build_dsn() -> Optional[str]:
24+
host = os.getenv("DB_HOST")
25+
port = os.getenv("DB_PORT", "5432")
26+
name = os.getenv("DB_NAME")
27+
user = os.getenv("DB_USER")
28+
password = os.getenv("DB_PASSWORD")
29+
30+
if not all([host, port, name, user, password]):
31+
logger.warning(
32+
"Access logging disabled: database env vars missing",
33+
extra={
34+
"configured": {
35+
"DB_HOST": bool(host),
36+
"DB_PORT": bool(port),
37+
"DB_NAME": bool(name),
38+
"DB_USER": bool(user),
39+
"DB_PASSWORD": bool(password),
40+
}
41+
},
42+
)
43+
return None
44+
45+
return f"postgresql://{user}:{password}@{host}:{port}/{name}"
46+
47+
48+
def load_access_log_config() -> Optional[AccessLogConfig]:
49+
enabled = os.getenv("ACCESS_LOGGING_ENABLED", "true").lower() in {"1", "true", "yes"}
50+
if not enabled:
51+
logger.info("Access logging disabled via ACCESS_LOGGING_ENABLED")
52+
return None
53+
54+
dsn = _build_dsn()
55+
if not dsn:
56+
return None
57+
58+
exclude_raw = os.getenv("ACCESS_LOGGING_EXCLUDE_PATHS", "/health,/api/certs/all-projects,/api/log/pageview")
59+
exclude_paths = {path.strip() for path in exclude_raw.split(",") if path.strip()}
60+
ip_salt = os.getenv("ACCESS_LOGGING_IP_SALT", "")
61+
environment = os.getenv("ENVIRONMENT", "dev").lower()
62+
63+
return AccessLogConfig(
64+
dsn=dsn,
65+
exclude_paths=exclude_paths,
66+
ip_salt=ip_salt,
67+
environment=environment,
68+
)
69+
70+
71+
def _hash_ip(ip_address: Optional[str], salt: str) -> Optional[str]:
72+
if not ip_address:
73+
return None
74+
digest = hashlib.sha256(f"{salt}{ip_address}".encode("utf-8")).hexdigest()
75+
return digest
76+
77+
78+
async def init_access_log(app: FastAPI) -> None:
79+
config = load_access_log_config()
80+
if not config:
81+
app.state.access_log_pool = None
82+
app.state.access_log_config = None
83+
return
84+
85+
pool = await asyncpg.create_pool(dsn=config.dsn, min_size=1, max_size=5)
86+
app.state.access_log_pool = pool
87+
app.state.access_log_config = config
88+
89+
90+
async def close_access_log(app: FastAPI) -> None:
91+
pool = getattr(app.state, "access_log_pool", None)
92+
if pool:
93+
await pool.close()
94+
95+
96+
async def log_request(
97+
request: Request,
98+
response: Optional[Response],
99+
latency_ms: int,
100+
status_code: int,
101+
) -> None:
102+
config: Optional[AccessLogConfig] = getattr(request.app.state, "access_log_config", None)
103+
pool: Optional[asyncpg.Pool] = getattr(request.app.state, "access_log_pool", None)
104+
105+
if not config or not pool:
106+
return
107+
108+
if request.url.path in config.exclude_paths:
109+
return
110+
111+
ip_hash = _hash_ip(getattr(request.client, "host", None), config.ip_salt)
112+
user_agent = request.headers.get("user-agent")
113+
referrer = request.headers.get("referer") or request.headers.get("referrer")
114+
115+
try:
116+
async with pool.acquire() as conn:
117+
await conn.execute(
118+
"""
119+
INSERT INTO logging.access_log
120+
(path, method, status, latency_ms, ip_hash, user_agent, referrer)
121+
VALUES
122+
($1, $2, $3, $4, $5, $6, $7)
123+
""",
124+
request.url.path,
125+
request.method,
126+
status_code,
127+
latency_ms,
128+
ip_hash,
129+
user_agent,
130+
referrer,
131+
)
132+
133+
except Exception:
134+
logger.warning("Failed to write access log", exc_info=True)
135+
136+
137+
async def log_page_view(request: Request, page_path: str) -> None:
138+
config: Optional[AccessLogConfig] = getattr(request.app.state, "access_log_config", None)
139+
pool: Optional[asyncpg.Pool] = getattr(request.app.state, "access_log_pool", None)
140+
141+
if not config or not pool:
142+
return
143+
144+
ip_hash = _hash_ip(getattr(request.client, "host", None), config.ip_salt)
145+
user_agent = request.headers.get("user-agent")
146+
referrer = request.headers.get("referer") or request.headers.get("referrer")
147+
148+
try:
149+
async with pool.acquire() as conn:
150+
await conn.execute(
151+
"""
152+
INSERT INTO logging.access_log
153+
(path, method, status, latency_ms, ip_hash, user_agent, referrer)
154+
VALUES
155+
($1, $2, $3, $4, $5, $6, $7)
156+
""",
157+
page_path,
158+
"PAGEVIEW",
159+
200,
160+
None,
161+
ip_hash,
162+
user_agent,
163+
referrer,
164+
)
165+
except Exception:
166+
logger.warning("Failed to write pageview log", exc_info=True)
167+
168+
169+
async def access_log_middleware(request: Request, call_next):
170+
start = time.perf_counter()
171+
try:
172+
response = await call_next(request)
173+
except Exception:
174+
latency_ms = int((time.perf_counter() - start) * 1000)
175+
await log_request(request, None, latency_ms, status_code=500)
176+
raise
177+
178+
latency_ms = int((time.perf_counter() - start) * 1000)
179+
await log_request(request, response, latency_ms, status_code=response.status_code)
180+
return response

0 commit comments

Comments
 (0)