From efeaf1a2ed2150aff0c002441616b1e54c405ea3 Mon Sep 17 00:00:00 2001 From: jjunier Date: Fri, 22 May 2026 23:26:10 +0900 Subject: [PATCH 1/4] feat(events): implement event log storage [D2C-43] - add event_logs table schema and migration - add event log request and response schemas - implement shared event recording service - add POST /events API for frontend behavior events - add event log API tests --- apps/api/backend/api/routes/events.py | 19 +++ apps/api/backend/main.py | 3 + apps/api/backend/schemas/event_log.py | 31 +++++ .../api/backend/services/event_log_service.py | 120 ++++++++++++++++++ apps/api/tests/test_event_logs.py | 83 ++++++++++++ db/ddl/schema.sql | 34 ++++- db/migrations/20260522_add_event_logs.sql | 31 +++++ 7 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 apps/api/backend/api/routes/events.py create mode 100644 apps/api/backend/schemas/event_log.py create mode 100644 apps/api/backend/services/event_log_service.py create mode 100644 apps/api/tests/test_event_logs.py create mode 100644 db/migrations/20260522_add_event_logs.sql diff --git a/apps/api/backend/api/routes/events.py b/apps/api/backend/api/routes/events.py new file mode 100644 index 0000000..6ecefb4 --- /dev/null +++ b/apps/api/backend/api/routes/events.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, status + +from backend.schemas.event_log import ( + EventLogCreateRequest, + EventLogCreateResponse, +) +from backend.services.event_log_service import create_event_log + +router = APIRouter(prefix="/events", tags=["events"]) + + +@router.post( + "", + response_model=EventLogCreateResponse, + status_code=status.HTTP_201_CREATED, +) +def create_event(payload: EventLogCreateRequest) -> EventLogCreateResponse: + result = create_event_log(payload) + return EventLogCreateResponse(**result) \ No newline at end of file diff --git a/apps/api/backend/main.py b/apps/api/backend/main.py index a9273d8..72a8a27 100644 --- a/apps/api/backend/main.py +++ b/apps/api/backend/main.py @@ -16,6 +16,8 @@ from backend.api.routes.products import router as products_router from backend.api.routes.reviews import router as reviews_router from backend.api.routes.sessions import router as sessions_router +from backend.api.routes.events import router as events_router + app = FastAPI(title="D2C Commerce Prototype API") @@ -45,6 +47,7 @@ app.include_router(order_history_router) app.include_router(payments_router) app.include_router(reviews_router) +app.include_router(events_router) @app.get("/health") def health_check() -> dict[str, str]: diff --git a/apps/api/backend/schemas/event_log.py b/apps/api/backend/schemas/event_log.py new file mode 100644 index 0000000..e52e023 --- /dev/null +++ b/apps/api/backend/schemas/event_log.py @@ -0,0 +1,31 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, Field + + +class EventLogCreateRequest(BaseModel): + event_name: str = Field(..., min_length=1, max_length=100) + event_type: str = Field(..., min_length=1, max_length=50) + user_id: UUID | None = None + session_id: UUID | None = None + entity_type: str | None = Field(default=None, max_length=50) + entity_id: UUID | None = None + source: str = Field(..., min_length=1, max_length=50) + properties: dict[str, Any] = Field(default_factory=dict) + + +class EventLogCreateResponse(BaseModel): + event_id: UUID + event_name: str + event_type: str + user_id: UUID | None = None + session_id: UUID | None = None + entity_type: str | None = None + entity_id: UUID | None = None + occurred_at: datetime + source: str + properties: dict[str, Any] + created_at: datetime + message: str \ No newline at end of file diff --git a/apps/api/backend/services/event_log_service.py b/apps/api/backend/services/event_log_service.py new file mode 100644 index 0000000..b30626a --- /dev/null +++ b/apps/api/backend/services/event_log_service.py @@ -0,0 +1,120 @@ +import json +from typing import Any +from uuid import UUID, uuid4 + +from fastapi import HTTPException, status +from sqlalchemy import text + +from backend.db.connection import engine +from backend.schemas.event_log import EventLogCreateRequest + + +ALLOWED_EVENT_TYPES = {"user_behavior", "domain_event", "system_event"} +ALLOWED_SOURCES = {"frontend", "backend", "script"} + + +def record_event( + event_name: str, + event_type: str, + source: str, + user_id: UUID | None = None, + session_id: UUID | None = None, + entity_type: str | None = None, + entity_id: UUID | None = None, + properties: dict[str, Any] | None = None, +) -> dict[str, Any]: + if event_type not in ALLOWED_EVENT_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unsupported event type", + ) + + if source not in ALLOWED_SOURCES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unsupported event source", + ) + + event_id = uuid4() + normalized_properties = properties or {} + + insert_query = text(""" + INSERT INTO event_logs ( + event_id, + event_name, + event_type, + user_id, + session_id, + entity_type, + entity_id, + occurred_at, + source, + properties, + created_at + ) + VALUES ( + :event_id, + :event_name, + :event_type, + :user_id, + :session_id, + :entity_type, + :entity_id, + CURRENT_TIMESTAMP, + :source, + CAST(:properties AS JSONB), + CURRENT_TIMESTAMP + ) + RETURNING + event_id, + event_name, + event_type, + user_id, + session_id, + entity_type, + entity_id, + occurred_at, + source, + properties, + created_at + """) + + with engine.begin() as connection: + event = connection.execute( + insert_query, + { + "event_id": event_id, + "event_name": event_name, + "event_type": event_type, + "user_id": user_id, + "session_id": session_id, + "entity_type": entity_type, + "entity_id": entity_id, + "source": source, + "properties": json.dumps(normalized_properties, default=str), + }, + ).mappings().first() + + if event is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to record event log", + ) + + return { + **dict(event), + "message": "Event log recorded successfully", + } + + +def create_event_log(payload: EventLogCreateRequest) -> dict[str, Any]: + return record_event( + event_name=payload.event_name, + event_type=payload.event_type, + source=payload.source, + user_id=payload.user_id, + session_id=payload.session_id, + entity_type=payload.entity_type, + entity_id=payload.entity_id, + properties=payload.properties, + ) \ No newline at end of file diff --git a/apps/api/tests/test_event_logs.py b/apps/api/tests/test_event_logs.py new file mode 100644 index 0000000..4d3978a --- /dev/null +++ b/apps/api/tests/test_event_logs.py @@ -0,0 +1,83 @@ +from fastapi.testclient import TestClient + +from backend.main import app + +client = TestClient(app) + + +def test_create_event_log_returns_201() -> None: + response = client.post( + "/events", + json={ + "event_name": "product_detail_viewed", + "event_type": "user_behavior", + "source": "frontend", + "user_id": None, + "session_id": None, + "entity_type": "product", + "entity_id": "33333333-3333-3333-3333-000000000001", + "properties": { + "product_name": "Accessory Dock Pro", + "source_page": "product_list", + }, + }, + ) + + assert response.status_code == 201 + + +def test_create_event_log_returns_expected_fields() -> None: + response = client.post( + "/events", + json={ + "event_name": "product_detail_viewed", + "event_type": "user_behavior", + "source": "frontend", + "entity_type": "product", + "entity_id": "33333333-3333-3333-3333-000000000001", + "properties": { + "product_name": "Accessory Dock Pro", + }, + }, + ) + data = response.json() + + assert "event_id" in data + assert data["event_name"] == "product_detail_viewed" + assert data["event_type"] == "user_behavior" + assert data["source"] == "frontend" + assert data["entity_type"] == "product" + assert data["entity_id"] == "33333333-3333-3333-3333-000000000001" + assert "occurred_at" in data + assert "created_at" in data + assert data["message"] == "Event log recorded successfully" + + +def test_create_event_log_returns_400_for_unsupported_event_type() -> None: + response = client.post( + "/events", + json={ + "event_name": "product_detail_viewed", + "event_type": "invalid_type", + "source": "frontend", + "properties": {}, + }, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Unsupported event type" + + +def test_create_event_log_returns_400_for_unsupported_source() -> None: + response = client.post( + "/events", + json={ + "event_name": "product_detail_viewed", + "event_type": "user_behavior", + "source": "invalid_source", + "properties": {}, + }, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Unsupported event source" \ No newline at end of file diff --git a/db/ddl/schema.sql b/db/ddl/schema.sql index 63b727b..ef2a13b 100644 --- a/db/ddl/schema.sql +++ b/db/ddl/schema.sql @@ -252,6 +252,24 @@ CREATE TABLE reviews ( ON DELETE RESTRICT ); +-- ========================================= +-- 13. event_logs +-- ========================================= +CREATE TABLE IF NOT EXISTS event_logs ( + event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_name VARCHAR(100) NOT NULL, + event_type VARCHAR(50) NOT NULL, + user_id UUID NULL, + session_id UUID NULL, + entity_type VARCHAR(50) NULL, + entity_id UUID NULL, + occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + source VARCHAR(50) NOT NULL, + properties JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + + -- ========================================= -- Recommended indexes -- ========================================= @@ -263,9 +281,21 @@ CREATE INDEX idx_cart_items_cart_id ON cart_items(cart_id); CREATE INDEX idx_cart_items_product_id ON cart_items(product_id); CREATE INDEX idx_orders_user_id ON orders(user_id); CREATE INDEX idx_orders_cart_id ON orders(cart_id); -CREATE INDEX idx_orders_coupon_id ON orders(applied_coupon_id); +CREATE INDEX idx_orders_coupon_id ON orders(coupon_id); CREATE INDEX idx_order_items_order_id ON order_items(order_id); CREATE INDEX idx_order_items_product_id ON order_items(product_id); CREATE INDEX idx_payments_order_id ON payments(order_id); CREATE INDEX idx_reviews_user_id ON reviews(user_id); -CREATE INDEX idx_reviews_product_id ON reviews(product_id); \ No newline at end of file +CREATE INDEX idx_reviews_product_id ON reviews(product_id); + +CREATE INDEX IF NOT EXISTS idx_event_logs_event_name ON event_logs (event_name); + +CREATE INDEX IF NOT EXISTS idx_event_logs_event_type ON event_logs (event_type); + +CREATE INDEX IF NOT EXISTS idx_event_logs_user_id ON event_logs (user_id); + +CREATE INDEX IF NOT EXISTS idx_event_logs_session_id ON event_logs (session_id); + +CREATE INDEX IF NOT EXISTS idx_event_logs_entity ON event_logs (entity_type, entity_id); + +CREATE INDEX IF NOT EXISTS idx_event_logs_occurred_at ON event_logs (occurred_at); \ No newline at end of file diff --git a/db/migrations/20260522_add_event_logs.sql b/db/migrations/20260522_add_event_logs.sql new file mode 100644 index 0000000..116ede3 --- /dev/null +++ b/db/migrations/20260522_add_event_logs.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS event_logs ( + event_id UUID PRIMARY KEY, + event_name VARCHAR(100) NOT NULL, + event_type VARCHAR(50) NOT NULL, + user_id UUID NULL, + session_id UUID NULL, + entity_type VARCHAR(50) NULL, + entity_id UUID NULL, + occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + source VARCHAR(50) NOT NULL, + properties JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_event_logs_event_name +ON event_logs (event_name); + +CREATE INDEX IF NOT EXISTS idx_event_logs_event_type +ON event_logs (event_type); + +CREATE INDEX IF NOT EXISTS idx_event_logs_user_id +ON event_logs (user_id); + +CREATE INDEX IF NOT EXISTS idx_event_logs_session_id +ON event_logs (session_id); + +CREATE INDEX IF NOT EXISTS idx_event_logs_entity +ON event_logs (entity_type, entity_id); + +CREATE INDEX IF NOT EXISTS idx_event_logs_occurred_at +ON event_logs (occurred_at); \ No newline at end of file From b81efb23fadb610b91c285d311d5c932514c08a2 Mon Sep 17 00:00:00 2001 From: jjunier Date: Sat, 23 May 2026 22:05:58 +0900 Subject: [PATCH 2/4] fix(events): resolve pylint positional argument warning [D2C-43] --- apps/api/backend/services/event_log_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/backend/services/event_log_service.py b/apps/api/backend/services/event_log_service.py index b30626a..f2ebf6b 100644 --- a/apps/api/backend/services/event_log_service.py +++ b/apps/api/backend/services/event_log_service.py @@ -17,6 +17,7 @@ def record_event( event_name: str, event_type: str, source: str, + *, user_id: UUID | None = None, session_id: UUID | None = None, entity_type: str | None = None, From fe246f14baf0607a35893da6e0dbb30af33bab18 Mon Sep 17 00:00:00 2001 From: jjunier Date: Mon, 25 May 2026 23:11:44 +0900 Subject: [PATCH 3/4] feat(events): add frontend user behavior logging [D2C-44] - add frontend event log types and API client - record product list and product detail behavior events - record cart and checkout interaction events - record order history, coupon wallet, and review page events - keep event logging failures from blocking user flows --- apps/web/src/features/cart/CartPage.tsx | 50 ++++++++++++++++++ .../src/features/checkout/CheckoutPage.tsx | 51 +++++++++++++++++++ .../src/features/coupons/CouponWalletPage.tsx | 14 +++++ .../src/features/orders/OrderHistoryPage.tsx | 14 +++++ .../features/products/ProductDetailPage.tsx | 36 +++++++++++++ .../src/features/products/ProductListPage.tsx | 31 +++++++++++ .../src/features/reviews/ReviewCreatePage.tsx | 37 +++++++++++++- apps/web/src/services/eventLogApi.ts | 35 +++++++++++++ apps/web/src/types/eventLog.ts | 29 +++++++++++ 9 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/services/eventLogApi.ts create mode 100644 apps/web/src/types/eventLog.ts diff --git a/apps/web/src/features/cart/CartPage.tsx b/apps/web/src/features/cart/CartPage.tsx index fc56a67..674bb67 100644 --- a/apps/web/src/features/cart/CartPage.tsx +++ b/apps/web/src/features/cart/CartPage.tsx @@ -5,6 +5,7 @@ import { removeCartItem, updateCartItemQuantity, } from "../../services/cartApi"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { clearStoredCartId, clearStoredPendingOrder, @@ -123,6 +124,20 @@ export function CartPage() { loadCart(); }, [loadCart]); + useEffect(() => { + void recordUserBehaviorEvent({ + event_name: "cart_viewed", + user_id: userId, + session_id: null, + entity_type: storedCartId ? "cart" : null, + entity_id: storedCartId, + properties: { + page_path: window.location.pathname, + cart_id: storedCartId, + }, + }); + }, [storedCartId, userId]); + useEffect(() => { const nextDrafts = cartItems.reduce>((drafts, item) => { drafts[item.cart_item_id] = String(item.quantity); @@ -137,6 +152,24 @@ export function CartPage() { return; } + const targetItem = cartItems.find((item) => item.cart_item_id === cartItemId); + + void recordUserBehaviorEvent({ + event_name: "cart_item_remove_clicked", + user_id: userId, + session_id: null, + entity_type: "cart", + entity_id: storedCartId, + properties: { + page_path: window.location.pathname, + cart_id: storedCartId, + cart_item_id: cartItemId, + product_id: targetItem?.product_id ?? null, + product_name: targetItem?.product_name ?? null, + quantity: targetItem?.quantity ?? null, + }, + }); + try { setRemovingItemId(cartItemId); setActionMessage(null); @@ -161,6 +194,23 @@ export function CartPage() { const normalizedQuantity = Math.max(0, Math.min(99, nextQuantity)); + void recordUserBehaviorEvent({ + event_name: "cart_quantity_change_clicked", + user_id: userId, + session_id: null, + entity_type: "cart", + entity_id: storedCartId, + properties: { + page_path: window.location.pathname, + cart_id: storedCartId, + cart_item_id: item.cart_item_id, + product_id: item.product_id, + product_name: item.product_name ?? null, + previous_quantity: item.quantity, + next_quantity: normalizedQuantity, + }, + }); + try { setUpdatingItemId(item.cart_item_id); setActionMessage(null); diff --git a/apps/web/src/features/checkout/CheckoutPage.tsx b/apps/web/src/features/checkout/CheckoutPage.tsx index c08a509..6b4f134 100644 --- a/apps/web/src/features/checkout/CheckoutPage.tsx +++ b/apps/web/src/features/checkout/CheckoutPage.tsx @@ -6,6 +6,7 @@ import { enterCheckout, simulatePayment, } from "../../services/checkoutApi"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { clearStoredCartId, clearStoredPendingOrder, @@ -169,6 +170,20 @@ export function CheckoutPage() { loadCheckout(); }, [checkoutCartId, userId]); + useEffect(() => { + void recordUserBehaviorEvent({ + event_name: "checkout_started", + user_id: userId, + session_id: null, + entity_type: checkoutCartId ? "cart" : null, + entity_id: checkoutCartId, + properties: { + page_path: window.location.pathname, + cart_id: checkoutCartId, + }, + }); + }, [checkoutCartId, userId]); + const handleCouponCodeChange = (event: ChangeEvent) => { setCouponCode(event.target.value); }; @@ -225,6 +240,24 @@ export function CheckoutPage() { return; } + void recordUserBehaviorEvent({ + event_name: "order_create_clicked", + user_id: userId, + session_id: null, + entity_type: "cart", + entity_id: checkoutCartId, + properties: { + page_path: window.location.pathname, + cart_id: checkoutCartId, + coupon_name: + appliedCoupon?.coupon?.coupon_name ?? + appliedCoupon?.coupon_name ?? + appliedCoupon?.coupon_code ?? + null, + final_amount: finalAmount, + }, + }); + try { setIsCreatingOrder(true); setErrorMessage(null); @@ -273,6 +306,24 @@ export function CheckoutPage() { return; } + void recordUserBehaviorEvent({ + event_name: + simulateResult === "success" + ? "payment_success_clicked" + : "payment_fail_clicked", + user_id: userId, + session_id: null, + entity_type: "order", + entity_id: createdOrder.order_id, + properties: { + page_path: window.location.pathname, + cart_id: checkoutCartId, + order_id: createdOrder.order_id, + payment_method: PAYMENT_METHOD, + simulate_result: simulateResult, + }, + }); + try { setIsPaying(true); setErrorMessage(null); diff --git a/apps/web/src/features/coupons/CouponWalletPage.tsx b/apps/web/src/features/coupons/CouponWalletPage.tsx index 93ef1a7..3a3a85b 100644 --- a/apps/web/src/features/coupons/CouponWalletPage.tsx +++ b/apps/web/src/features/coupons/CouponWalletPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { getUserCoupons } from "../../services/couponApi"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { getStoredUser } from "../../stores/userStore"; import type { UsedCoupon, UserCoupon, UserCouponWalletResponse } from "../../types/coupon"; @@ -156,6 +157,19 @@ export function CouponWalletPage() { loadCoupons(); }, [userId]); + useEffect(() => { + void recordUserBehaviorEvent({ + event_name: "coupon_wallet_viewed", + user_id: userId, + session_id: null, + entity_type: null, + entity_id: null, + properties: { + page_path: window.location.pathname, + }, + }); + }, [userId]); + if (!userId) { return (
diff --git a/apps/web/src/features/orders/OrderHistoryPage.tsx b/apps/web/src/features/orders/OrderHistoryPage.tsx index e95f1f4..17b3658 100644 --- a/apps/web/src/features/orders/OrderHistoryPage.tsx +++ b/apps/web/src/features/orders/OrderHistoryPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { getOrderHistory } from "../../services/orderApi"; import { getStoredUser } from "../../stores/userStore"; import type { OrderHistoryItem, OrderItem } from "../../types/order"; @@ -296,6 +297,19 @@ export function OrderHistoryPage() { loadOrders(); }, [userId]); + useEffect(() => { + void recordUserBehaviorEvent({ + event_name: "order_history_viewed", + user_id: userId, + session_id: null, + entity_type: null, + entity_id: null, + properties: { + page_path: window.location.pathname, + }, + }); + }, [userId]); + if (!userId) { return (
diff --git a/apps/web/src/features/products/ProductDetailPage.tsx b/apps/web/src/features/products/ProductDetailPage.tsx index 81a6d00..5d776d3 100644 --- a/apps/web/src/features/products/ProductDetailPage.tsx +++ b/apps/web/src/features/products/ProductDetailPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Link, useLocation, useParams } from "react-router-dom"; import { addCartItem, createCart } from "../../services/cartApi"; import { getProductDetail } from "../../services/catalogApi"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { getProductReviews } from "../../services/reviewApi"; import { clearStoredCartId, @@ -138,6 +139,26 @@ export function ProductDetailPage() { loadProductDetail(); }, [productId]); + useEffect(() => { + if (!product) { + return; + } + + void recordUserBehaviorEvent({ + event_name: "product_detail_viewed", + user_id: user?.user_id ?? null, + session_id: null, + entity_type: "product", + entity_id: product.product_id, + properties: { + page_path: window.location.pathname, + product_id: product.product_id, + product_name: product.product_name, + source_page: "product_detail", + }, + }); + }, [product?.product_id, user?.user_id]); + useEffect(() => { async function loadProductReviews() { if (!productId) { @@ -210,6 +231,21 @@ export function ProductDetailPage() { return; } + void recordUserBehaviorEvent({ + event_name: "product_add_to_cart_clicked", + user_id: user?.user_id ?? null, + session_id: null, + entity_type: "product", + entity_id: product.product_id, + properties: { + page_path: window.location.pathname, + product_id: product.product_id, + product_name: product.product_name, + source_page: "product_detail", + quantity, + }, + }); + if (!user) { setCartMessage(null); setCartErrorMessage("로그인 후 장바구니에 상품을 담을 수 있습니다."); diff --git a/apps/web/src/features/products/ProductListPage.tsx b/apps/web/src/features/products/ProductListPage.tsx index 9bc4644..a5fa96b 100644 --- a/apps/web/src/features/products/ProductListPage.tsx +++ b/apps/web/src/features/products/ProductListPage.tsx @@ -10,6 +10,7 @@ import { import { Link } from "react-router-dom"; import { addCartItem, createCart } from "../../services/cartApi"; import { getCategories, getProducts } from "../../services/catalogApi"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { clearStoredCartId, clearStoredPendingOrder, @@ -58,6 +59,21 @@ export function ProductListPage() { const productListTopRef = useRef(null); + useEffect(() => { + const user = getStoredUser(); + + void recordUserBehaviorEvent({ + event_name: "product_list_viewed", + user_id: user?.user_id ?? null, + session_id: null, + entity_type: null, + entity_id: null, + properties: { + page_path: window.location.pathname, + }, + }); + }, []); + const scrollToProductListTop = useCallback(() => { requestAnimationFrame(() => { productListTopRef.current?.scrollIntoView({ @@ -152,6 +168,21 @@ export function ProductListPage() { const user = getStoredUser(); + void recordUserBehaviorEvent({ + event_name: "product_add_to_cart_clicked", + user_id: user?.user_id ?? null, + session_id: null, + entity_type: "product", + entity_id: product.product_id, + properties: { + page_path: window.location.pathname, + product_id: product.product_id, + product_name: product.product_name, + source_page: "product_list", + quantity: QUICK_ADD_QUANTITY, + }, + }); + if (!user) { setCartFeedbackMessage(null); setCartErrorMessage("로그인 후 장바구니에 상품을 담을 수 있습니다."); diff --git a/apps/web/src/features/reviews/ReviewCreatePage.tsx b/apps/web/src/features/reviews/ReviewCreatePage.tsx index 67bbc37..734ed1d 100644 --- a/apps/web/src/features/reviews/ReviewCreatePage.tsx +++ b/apps/web/src/features/reviews/ReviewCreatePage.tsx @@ -1,6 +1,7 @@ -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { ApiError } from "../../services/apiClient"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { createReview } from "../../services/reviewApi"; import { getStoredUser } from "../../stores/userStore"; @@ -42,9 +43,43 @@ export function ReviewCreatePage() { const isInvalidReviewTarget = !validProductId || !validOrderItemId; + useEffect(() => { + void recordUserBehaviorEvent({ + event_name: "review_create_page_viewed", + user_id: userId, + session_id: null, + entity_type: validProductId ? "product" : null, + entity_id: validProductId, + properties: { + page_path: window.location.pathname, + product_id: validProductId, + order_item_id: validOrderItemId, + product_name: productName, + }, + }); + }, [userId, validProductId, validOrderItemId, productName]); + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); + void recordUserBehaviorEvent({ + event_name: "review_submit_clicked", + user_id: userId, + session_id: null, + entity_type: validProductId ? "product" : null, + entity_id: validProductId, + properties: { + page_path: window.location.pathname, + product_id: validProductId, + order_item_id: validOrderItemId, + product_name: productName, + rating, + recommendation, + has_review_title: Boolean(reviewTitle.trim()), + has_review_content: Boolean(reviewContent.trim()), + }, + }); + setErrorMessage(null); setSuccessMessage(null); diff --git a/apps/web/src/services/eventLogApi.ts b/apps/web/src/services/eventLogApi.ts new file mode 100644 index 0000000..8da2a3a --- /dev/null +++ b/apps/web/src/services/eventLogApi.ts @@ -0,0 +1,35 @@ +import type { + EventLogCreateRequest, + EventLogCreateResponse, +} from "../types/eventLog"; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; + +export async function recordUserBehaviorEvent( + payload: Omit, +): Promise { + try { + const response = await fetch(`${API_BASE_URL}/events`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...payload, + event_type: "user_behavior", + source: "frontend", + properties: payload.properties ?? {}, + }), + }); + + if (!response.ok) { + console.error("Failed to record user behavior event", response.status); + return null; + } + + return (await response.json()) as EventLogCreateResponse; + } catch (error) { + console.error("Failed to record user behavior event", error); + return null; + } +} \ No newline at end of file diff --git a/apps/web/src/types/eventLog.ts b/apps/web/src/types/eventLog.ts new file mode 100644 index 0000000..d9cb138 --- /dev/null +++ b/apps/web/src/types/eventLog.ts @@ -0,0 +1,29 @@ +export type EventLogType = "user_behavior" | "domain_event" | "system_event"; + +export type EventLogSource = "frontend" | "backend" | "script"; + +export type EventLogCreateRequest = { + event_name: string; + event_type: EventLogType; + source: EventLogSource; + user_id?: string | null; + session_id?: string | null; + entity_type?: string | null; + entity_id?: string | null; + properties?: Record; +}; + +export type EventLogCreateResponse = { + event_id: string; + event_name: string; + event_type: EventLogType; + user_id: string | null; + session_id: string | null; + entity_type: string | null; + entity_id: string | null; + occurred_at: string; + source: EventLogSource; + properties: Record; + created_at: string; + message: string; +}; \ No newline at end of file From 80a565b0899607ff664017ed887d23e26642b097 Mon Sep 17 00:00:00 2001 From: jjunier Date: Mon, 25 May 2026 23:59:31 +0900 Subject: [PATCH 4/4] feat(events): add backend domain event logging [D2C-45] - add safe backend domain event recording helper - record order, payment, coupon, review, cart domain events - keep domain event logging failures from blocking API responses - add server logging for domain event recording failures --- .../api/backend/services/cart_item_service.py | 79 +++++++++++++++- apps/api/backend/services/cart_service.py | 67 +++++++------ apps/api/backend/services/coupon_service.py | 46 +++++++++ .../api/backend/services/event_log_service.py | 25 +++++ apps/api/backend/services/order_service.py | 45 +++++++++ apps/api/backend/services/payment_service.py | 94 +++++++++++++++++++ apps/api/backend/services/review_service.py | 45 ++++++++- 7 files changed, 370 insertions(+), 31 deletions(-) diff --git a/apps/api/backend/services/cart_item_service.py b/apps/api/backend/services/cart_item_service.py index f00e583..56fd833 100644 --- a/apps/api/backend/services/cart_item_service.py +++ b/apps/api/backend/services/cart_item_service.py @@ -9,6 +9,8 @@ CartItemCreateRequest, CartItemQuantityUpdateRequest, ) +from backend.services.event_log_service import record_domain_event_safely + MAX_CART_ITEM_QUANTITY = 99 @@ -16,6 +18,7 @@ def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, cart_query = text(""" SELECT cart_id, + user_id, cart_status FROM carts WHERE cart_id = :cart_id @@ -157,6 +160,24 @@ def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update cart item", ) + + record_domain_event_safely( + event_name="cart_item_quantity_changed", + user_id=cart["user_id"], + entity_type="cart", + entity_id=updated_item["cart_id"], + properties={ + "cart_id": updated_item["cart_id"], + "cart_item_id": updated_item["cart_item_id"], + "product_id": updated_item["product_id"], + "previous_quantity": existing_item["quantity"], + "next_quantity": updated_item["quantity"], + "quantity_delta": payload.quantity, + "unit_price": updated_item["unit_price"], + "currency": updated_item["currency"], + "change_source": "add_item_to_cart", + }, + ) return { "cart_item_id": updated_item["cart_item_id"], @@ -185,6 +206,21 @@ def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to add item to cart", ) + + record_domain_event_safely( + event_name="cart_item_added", + user_id=cart["user_id"], + entity_type="cart", + entity_id=created_item["cart_id"], + properties={ + "cart_id": created_item["cart_id"], + "cart_item_id": created_item["cart_item_id"], + "product_id": created_item["product_id"], + "quantity": created_item["quantity"], + "unit_price": created_item["unit_price"], + "currency": created_item["currency"], + }, + ) return { "cart_item_id": created_item["cart_item_id"], @@ -201,7 +237,8 @@ def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, def remove_item_from_cart(cart_id: UUID, cart_item_id: UUID) -> dict[str, Any]: cart_query = text(""" SELECT - cart_id + cart_id, + user_id FROM carts WHERE cart_id = :cart_id AND cart_status = 'active' @@ -214,7 +251,11 @@ def remove_item_from_cart(cart_id: UUID, cart_item_id: UUID) -> dict[str, Any]: AND cart_id = :cart_id RETURNING cart_item_id, - cart_id + cart_id, + product_id, + quantity, + unit_price, + currency """) with engine.begin() as connection: @@ -242,6 +283,21 @@ def remove_item_from_cart(cart_id: UUID, cart_item_id: UUID) -> dict[str, Any]: status_code=status.HTTP_404_NOT_FOUND, detail="Cart item not found", ) + + record_domain_event_safely( + event_name="cart_item_removed", + user_id=cart["user_id"], + entity_type="cart", + entity_id=deleted_item["cart_id"], + properties={ + "cart_id": deleted_item["cart_id"], + "cart_item_id": deleted_item["cart_item_id"], + "product_id": deleted_item["product_id"], + "quantity": deleted_item["quantity"], + "unit_price": deleted_item["unit_price"], + "currency": deleted_item["currency"], + }, + ) return { "cart_item_id": deleted_item["cart_item_id"], @@ -257,6 +313,7 @@ def update_cart_item_quantity( cart_query = text(""" SELECT cart_id, + user_id, cart_status FROM carts WHERE cart_id = :cart_id @@ -269,6 +326,7 @@ def update_cart_item_quantity( cart_item_id, cart_id, product_id, + quantity, unit_price, currency FROM cart_items @@ -335,6 +393,23 @@ def update_cart_item_quantity( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update cart item quantity", ) + + record_domain_event_safely( + event_name="cart_item_quantity_changed", + user_id=cart["user_id"], + entity_type="cart", + entity_id=updated_item["cart_id"], + properties={ + "cart_id": updated_item["cart_id"], + "cart_item_id": updated_item["cart_item_id"], + "product_id": updated_item["product_id"], + "previous_quantity": item["quantity"], + "next_quantity": updated_item["quantity"], + "unit_price": updated_item["unit_price"], + "currency": updated_item["currency"], + "change_source": "update_cart_item_quantity", + }, + ) return { "cart_item_id": updated_item["cart_item_id"], diff --git a/apps/api/backend/services/cart_service.py b/apps/api/backend/services/cart_service.py index f7e8efd..d4ff12b 100644 --- a/apps/api/backend/services/cart_service.py +++ b/apps/api/backend/services/cart_service.py @@ -7,6 +7,7 @@ from backend.db.connection import engine from backend.schemas.cart import CartCreateRequest +from backend.services.event_log_service import record_domain_event_safely def create_or_get_active_cart(payload: CartCreateRequest) -> dict[str, Any]: @@ -49,6 +50,8 @@ def create_or_get_active_cart(payload: CartCreateRequest) -> dict[str, Any]: checked_out_at """) + is_created = False + with engine.begin() as connection: existing = connection.execute( select_query, @@ -56,35 +59,43 @@ def create_or_get_active_cart(payload: CartCreateRequest) -> dict[str, Any]: ).mappings().first() if existing is not None: - return { - "cart_id": existing["cart_id"], - "user_id": existing["user_id"], - "cart_status": existing["cart_status"], - "created_at": existing["created_at"], - "updated_at": existing["updated_at"], - "checked_out_at": existing["checked_out_at"], - } - - created = connection.execute( - insert_query, - {"user_id": payload.user_id}, - ).mappings().first() - - if created is None: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create cart", - ) - - return { - "cart_id": created["cart_id"], - "user_id": created["user_id"], - "cart_status": created["cart_status"], - "created_at": created["created_at"], - "updated_at": created["updated_at"], - "checked_out_at": created["checked_out_at"], - } + cart = existing + else: + created = connection.execute( + insert_query, + {"user_id": payload.user_id}, + ).mappings().first() + + if created is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create cart", + ) + + cart = created + is_created = True + + if is_created: + record_domain_event_safely( + event_name="cart_created", + user_id=cart["user_id"], + entity_type="cart", + entity_id=cart["cart_id"], + properties={ + "cart_id": cart["cart_id"], + "cart_status": cart["cart_status"], + "created_at": cart["created_at"], + }, + ) + return { + "cart_id": cart["cart_id"], + "user_id": cart["user_id"], + "cart_status": cart["cart_status"], + "created_at": cart["created_at"], + "updated_at": cart["updated_at"], + "checked_out_at": cart["checked_out_at"], + } def get_cart_detail(cart_id: UUID) -> dict[str, Any]: cart_query = text(""" diff --git a/apps/api/backend/services/coupon_service.py b/apps/api/backend/services/coupon_service.py index 57c23e9..e05c798 100644 --- a/apps/api/backend/services/coupon_service.py +++ b/apps/api/backend/services/coupon_service.py @@ -1,3 +1,4 @@ +import logging from decimal import Decimal, ROUND_HALF_UP from typing import Any from uuid import UUID @@ -7,8 +8,34 @@ from backend.db.connection import engine from backend.schemas.coupon_apply import CouponApplyRequest +from backend.services.event_log_service import record_event +logger = logging.getLogger(__name__) + + +def record_domain_event_safely( + *, + event_name: str, + user_id: UUID | None, + entity_type: str | None, + entity_id: UUID | None, + properties: dict[str, Any], +) -> None: + try: + record_event( + event_name=event_name, + event_type="domain_event", + source="backend", + user_id=user_id, + session_id=None, + entity_type=entity_type, + entity_id=entity_id, + properties=properties, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to record domain event: %s", event_name) + def apply_coupon_to_cart(cart_id: UUID, payload: CouponApplyRequest) -> dict[str, Any]: cart_query = text(""" SELECT @@ -136,6 +163,25 @@ def apply_coupon_to_cart(cart_id: UUID, payload: CouponApplyRequest) -> dict[str final_amount = total_amount - discount_amount + record_domain_event_safely( + event_name="coupon_applied", + user_id=cart["user_id"], + entity_type="coupon", + entity_id=coupon["coupon_id"], + properties={ + "cart_id": cart_id, + "coupon_id": coupon["coupon_id"], + "coupon_name": coupon["coupon_name"], + "coupon_type": coupon["coupon_type"], + "discount_value": discount_value, + "minimum_order_amount": minimum_order_amount, + "total_amount": total_amount, + "discount_amount": discount_amount, + "final_amount": final_amount, + "currency": currency, + }, + ) + return { "cart_id": cart_id, "coupon": { diff --git a/apps/api/backend/services/event_log_service.py b/apps/api/backend/services/event_log_service.py index f2ebf6b..f139032 100644 --- a/apps/api/backend/services/event_log_service.py +++ b/apps/api/backend/services/event_log_service.py @@ -1,4 +1,5 @@ import json +import logging from typing import Any from uuid import UUID, uuid4 @@ -11,6 +12,7 @@ ALLOWED_EVENT_TYPES = {"user_behavior", "domain_event", "system_event"} ALLOWED_SOURCES = {"frontend", "backend", "script"} +logger = logging.getLogger(__name__) def record_event( @@ -108,6 +110,29 @@ def record_event( } +def record_domain_event_safely( + *, + event_name: str, + user_id: UUID | None, + entity_type: str | None, + entity_id: UUID | None, + properties: dict[str, Any], +) -> None: + try: + record_event( + event_name=event_name, + event_type="domain_event", + source="backend", + user_id=user_id, + session_id=None, + entity_type=entity_type, + entity_id=entity_id, + properties=properties, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to record domain event: %s", event_name) + + def create_event_log(payload: EventLogCreateRequest) -> dict[str, Any]: return record_event( event_name=payload.event_name, diff --git a/apps/api/backend/services/order_service.py b/apps/api/backend/services/order_service.py index 8c7c388..c6b510a 100644 --- a/apps/api/backend/services/order_service.py +++ b/apps/api/backend/services/order_service.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime, timedelta, timezone from decimal import Decimal, ROUND_HALF_UP from typing import Any @@ -8,10 +9,35 @@ from backend.db.connection import engine from backend.schemas.order import OrderCreateRequest +from backend.services.event_log_service import record_event KST = timezone(timedelta(hours=9)) +logger = logging.getLogger(__name__) + + +def record_domain_event_safely( + *, + event_name: str, + user_id: UUID | None, + entity_type: str | None, + entity_id: UUID | None, + properties: dict[str, Any], +) -> None: + try: + record_event( + event_name=event_name, + event_type="domain_event", + source="backend", + user_id=user_id, + session_id=None, + entity_type=entity_type, + entity_id=entity_id, + properties=properties, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to record domain event: %s", event_name) def now_kst_naive() -> datetime: return datetime.now(KST).replace(tzinfo=None) @@ -309,6 +335,25 @@ def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: } ) + record_domain_event_safely( + event_name="order_created", + user_id=created_order["user_id"], + entity_type="order", + entity_id=created_order["order_id"], + properties={ + "order_id": created_order["order_id"], + "cart_id": created_order["cart_id"], + "order_status": created_order["order_status"], + "subtotal_amount": created_order["subtotal_amount"], + "discount_amount": created_order["discount_amount"], + "total_amount": created_order["total_amount"], + "currency": created_order["currency"], + "coupon_id": coupon_id, + "coupon_name": coupon_name, + "item_count": len(created_items), + }, + ) + return { "order_id": created_order["order_id"], "user_id": created_order["user_id"], diff --git a/apps/api/backend/services/payment_service.py b/apps/api/backend/services/payment_service.py index ca7ae9c..9b48649 100644 --- a/apps/api/backend/services/payment_service.py +++ b/apps/api/backend/services/payment_service.py @@ -1,16 +1,43 @@ +import logging from datetime import datetime, timedelta, timezone from decimal import Decimal from typing import Any +from uuid import UUID from fastapi import HTTPException, status from sqlalchemy import text from backend.db.connection import engine from backend.schemas.payment import PaymentSimulationRequest +from backend.services.event_log_service import record_event KST = timezone(timedelta(hours=9)) +logger = logging.getLogger(__name__) + + +def record_domain_event_safely( + *, + event_name: str, + user_id: UUID | None, + entity_type: str | None, + entity_id: UUID | None, + properties: dict[str, Any], +) -> None: + try: + record_event( + event_name=event_name, + event_type="domain_event", + source="backend", + user_id=user_id, + session_id=None, + entity_type=entity_type, + entity_id=entity_id, + properties=properties, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to record domain event: %s", event_name) def now_kst_naive() -> datetime: return datetime.now(KST).replace(tzinfo=None) @@ -19,7 +46,9 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: order_query = text(""" SELECT order_id, + user_id, cart_id, + coupon_id, order_status, total_amount, currency @@ -181,6 +210,71 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: detail="Failed to create payment", ) + if created_payment["payment_status"] == "paid": + record_domain_event_safely( + event_name="payment_succeeded", + user_id=order["user_id"], + entity_type="payment", + entity_id=created_payment["payment_id"], + properties={ + "payment_id": created_payment["payment_id"], + "order_id": created_payment["order_id"], + "cart_id": order["cart_id"], + "payment_method": created_payment["payment_method"], + "payment_status": created_payment["payment_status"], + "requested_amount": requested_amount, + "approved_amount": created_payment["paid_amount"], + "currency": created_payment["currency"], + "pg_provider": created_payment["pg_provider"], + "transaction_id": created_payment["transaction_id"], + }, + ) + + record_domain_event_safely( + event_name="cart_checked_out", + user_id=order["user_id"], + entity_type="cart", + entity_id=order["cart_id"], + properties={ + "cart_id": order["cart_id"], + "order_id": created_payment["order_id"], + "payment_id": created_payment["payment_id"], + "cart_status": "checked_out", + }, + ) + + if order["coupon_id"] is not None: + record_domain_event_safely( + event_name="coupon_used", + user_id=order["user_id"], + entity_type="coupon", + entity_id=order["coupon_id"], + properties={ + "coupon_id": order["coupon_id"], + "order_id": created_payment["order_id"], + "payment_id": created_payment["payment_id"], + "cart_id": order["cart_id"], + }, + ) + else: + record_domain_event_safely( + event_name="payment_failed", + user_id=order["user_id"], + entity_type="payment", + entity_id=created_payment["payment_id"], + properties={ + "payment_id": created_payment["payment_id"], + "order_id": created_payment["order_id"], + "cart_id": order["cart_id"], + "payment_method": created_payment["payment_method"], + "payment_status": created_payment["payment_status"], + "requested_amount": requested_amount, + "approved_amount": created_payment["paid_amount"], + "currency": created_payment["currency"], + "failure_code": created_payment["failure_code"], + }, + ) + return { "payment_id": created_payment["payment_id"], "order_id": created_payment["order_id"], diff --git a/apps/api/backend/services/review_service.py b/apps/api/backend/services/review_service.py index ace3540..7f92d92 100644 --- a/apps/api/backend/services/review_service.py +++ b/apps/api/backend/services/review_service.py @@ -1,3 +1,4 @@ +import logging from typing import Any from decimal import Decimal from uuid import UUID @@ -11,7 +12,33 @@ ReviewDeleteRequest, ReviewUpdateRequest, ) - +from backend.services.event_log_service import record_event + + +logger = logging.getLogger(__name__) + + +def record_domain_event_safely( + *, + event_name: str, + user_id: UUID | None, + entity_type: str | None, + entity_id: UUID | None, + properties: dict[str, Any], +) -> None: + try: + record_event( + event_name=event_name, + event_type="domain_event", + source="backend", + user_id=user_id, + session_id=None, + entity_type=entity_type, + entity_id=entity_id, + properties=properties, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to record domain event: %s", event_name) def create_review(payload: ReviewCreateRequest) -> dict[str, Any]: user_query = text(""" @@ -158,6 +185,22 @@ def create_review(payload: ReviewCreateRequest) -> dict[str, Any]: detail="Failed to create review", ) + record_domain_event_safely( + event_name="review_created", + user_id=created_review["user_id"], + entity_type="review", + entity_id=created_review["review_id"], + properties={ + "review_id": created_review["review_id"], + "product_id": created_review["product_id"], + "order_item_id": created_review["order_item_id"], + "rating": created_review["rating"], + "review_status": created_review["review_status"], + "has_review_title": bool(created_review["review_title"]), + "has_review_content": bool(created_review["review_content"]), + }, + ) + return { "review_id": created_review["review_id"], "user_id": created_review["user_id"],