diff --git a/auth_prj/wsgi.py b/auth_prj/wsgi.py index b216de1..4018ba5 100644 --- a/auth_prj/wsgi.py +++ b/auth_prj/wsgi.py @@ -17,12 +17,17 @@ from django.core.wsgi import get_wsgi_application # ✅ DEBUG 모드 아닐 때만 OpenTelemetry 활성 if not settings.DEBUG: + import grpc from opentelemetry import trace from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.django import DjangoInstrumentor + from opentelemetry.instrumentation.requests import RequestsInstrumentor + from opentelemetry.instrumentation.logging import LoggingInstrumentor + from opentelemetry.instrumentation.dbapi import trace_integration + import MySQLdb trace.set_tracer_provider( TracerProvider( @@ -34,11 +39,25 @@ if not settings.DEBUG: ) ) + # TRACE_CA_CERT 설정에 따른 gRPC credentials 구성 + # - 값이 있고 파일 존재: TLS + 해당 CA 인증서 사용 + # - 값이 없거나 파일 없음: insecure 모드 (TLS 없이 연결) + credentials = None + ca_cert_path = os.getenv('TRACE_CA_CERT', '').strip() + if ca_cert_path and os.path.exists(ca_cert_path): + with open(ca_cert_path, 'rb') as f: + ca_cert = f.read() + credentials = grpc.ssl_channel_credentials(root_certificates=ca_cert) + insecure = False + else: + insecure = True + otlp_exporter = OTLPSpanExporter( # endpoint="http://jaeger-collector.istio-system:4317", # endpoint="jaeger-collector.observability.svc.cluster.local:4317", endpoint=settings.TRACE_ENDPOINT, - insecure=True, + insecure=insecure, + credentials=credentials, headers={ "x-scope-orgid": settings.SERVICE_PLATFORM, "x-service": settings.TRACE_SERVICE_NAME @@ -49,8 +68,23 @@ if not settings.DEBUG: BatchSpanProcessor(otlp_exporter) ) + # Django 요청/응답 추적 DjangoInstrumentor().instrument() + # HTTP 클라이언트 요청 추적 (requests 라이브러리) + RequestsInstrumentor().instrument() + + # 로그와 Trace 연동 (trace_id, span_id를 로그에 자동 추가) + LoggingInstrumentor().instrument(set_logging_format=True) + + # MySQL DB 쿼리 추적 + trace_integration( + MySQLdb, + "connect", + "mysql", + capture_parameters=True, # 쿼리 파라미터 캡처 + ) + from django.core.wsgi import get_wsgi_application diff --git a/requirements.txt b/requirements.txt index c9e8c28..6d9568d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,10 @@ opentelemetry-exporter-otlp-proto-common==1.34.0 opentelemetry-exporter-otlp-proto-grpc==1.34.0 opentelemetry-exporter-otlp-proto-http==1.34.0 opentelemetry-instrumentation==0.55b0 +opentelemetry-instrumentation-dbapi==0.55b0 opentelemetry-instrumentation-django==0.55b0 +opentelemetry-instrumentation-logging==0.55b0 +opentelemetry-instrumentation-requests==0.55b0 opentelemetry-instrumentation-wsgi==0.55b0 opentelemetry-proto==1.34.0 opentelemetry-sdk==1.34.0 diff --git a/users/urls.py b/users/urls.py index b1a7263..be8e0fc 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,6 +1,6 @@ from django.urls import path from .views import ( - RegisterView, MeView, ChangePasswordView, ExtendPasswordExpiryView, CustomTokenObtainPairView, + RegisterView, LogoutView, MeView, ChangePasswordView, ExtendPasswordExpiryView, CustomTokenObtainPairView, SSHKeyUploadView, SSHKeyInfoView, SSHKeyRetrieveView, UserListView, UserUpdateView, NHNCloudCredentialsView, NHNCloudPasswordView, @@ -22,6 +22,7 @@ urlpatterns = [ path('register/', RegisterView.as_view(), name='register'), # path('login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), + path('logout/', LogoutView.as_view(), name='logout'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('verify/', TokenVerifyView.as_view(), name='token_verify'), path('me/', MeView.as_view(), name='me'), diff --git a/users/views.py b/users/views.py index 898c7de..bf6858f 100644 --- a/users/views.py +++ b/users/views.py @@ -1,12 +1,16 @@ # views.py import logging import secrets +from contextlib import contextmanager from opentelemetry import trace # ✅ OpenTelemetry 트레이서 +from opentelemetry.trace import Status, StatusCode # ✅ Span 상태 설정용 from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import IsAuthenticated, BasePermission from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.exceptions import TokenError from rest_framework import generics from django.conf import settings from google.oauth2 import id_token @@ -15,7 +19,7 @@ from .serializers import RegisterSerializer, CustomTokenObtainPairSerializer, Us from .models import CustomUser, NHNCloudProject, KVMServer, SiteSettings logger = logging.getLogger(__name__) -tracer = trace.get_tracer(__name__) # ✅ 트레이서 생성 +tracer = trace.get_tracer(__name__) def get_request_info(request): @@ -25,60 +29,181 @@ def get_request_info(request): return email, ip, ua +def get_current_span(): + """현재 활성화된 span 가져오기""" + return trace.get_current_span() + + +@contextmanager +def child_span(name, **attributes): + """하위 span 생성 (비즈니스 로직 추적용)""" + with tracer.start_as_current_span(name) as span: + for key, value in attributes.items(): + if value is not None: + span.set_attribute(key, str(value) if not isinstance(value, (int, float, bool)) else value) + try: + yield span + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e)[:200])) + span.set_attribute("error", True) + span.set_attribute("error.type", type(e).__name__) + span.set_attribute("error.message", str(e)[:500]) + raise + + +def enrich_span(request, user=None, operation=None): + """현재 span에 상세 속성 추가""" + span = get_current_span() + if not span or not span.is_recording(): + return span + + email, ip, ua = get_request_info(request) + + if operation: + span.set_attribute("operation", operation) + span.update_name(operation) + + span.set_attribute("client.ip", ip) + span.set_attribute("client.user_agent", ua[:200] if ua else "unknown") + + if user and hasattr(user, 'id') and user.id: + span.set_attribute("user.id", user.id) + span.set_attribute("user.email", getattr(user, 'email', 'unknown')) + span.set_attribute("user.grade", getattr(user, 'grade', 'unknown')) + elif email != "anonymous": + span.set_attribute("user.email", email) + + return span + + +def span_set_attribute(key, value): + """현재 span에 속성 추가""" + span = get_current_span() + if span and span.is_recording() and value is not None: + span.set_attribute(key, str(value) if not isinstance(value, (int, float, bool)) else value) + + +def span_error(error_msg, status_code=400): + """현재 span에 에러 상태 설정""" + span = get_current_span() + if span and span.is_recording(): + span.set_status(Status(StatusCode.ERROR, str(error_msg)[:200])) + span.set_attribute("error", True) + span.set_attribute("error.message", str(error_msg)[:500]) + span.set_attribute("http.response.status_code", status_code) + + +def span_success(status_code=200): + """현재 span에 성공 상태 설정""" + span = get_current_span() + if span and span.is_recording(): + span.set_status(Status(StatusCode.OK)) + span.set_attribute("http.response.status_code", status_code) + + +def span_event(name, **attributes): + """현재 span에 이벤트 추가""" + span = get_current_span() + if span and span.is_recording(): + clean_attrs = {k: str(v)[:200] if isinstance(v, str) else v for k, v in attributes.items() if v is not None} + span.add_event(name, attributes=clean_attrs) + + +# 기존 코드 호환성을 위한 별칭 +def set_span_attributes(span, request, user=None): + """기존 코드 호환용 - enrich_span 사용 권장""" + return enrich_span(request, user) + + class RegisterView(APIView): def post(self, request): - with tracer.start_as_current_span("RegisterView POST") as span: # ✅ Span 생성 - email, ip, ua = get_request_info(request) + enrich_span(request, operation="auth.register") + email, ip, ua = get_request_info(request) + + with child_span("validate_input", input_email=request.data.get("email")): serializer = RegisterSerializer(data=request.data) - if serializer.is_valid(): + is_valid = serializer.is_valid() + + if is_valid: + with child_span("create_user") as span: user = serializer.save() - logger.info( - f"[REGISTER] user={user.email} | status=success | IP={ip} | UA={ua}" - ) - # ✅ Jaeger 이벤트 등록 - span.add_event("User registered", attributes={"email": user.email}) - return Response( - {"message": "User registered successfully."}, - status=status.HTTP_201_CREATED, - ) - logger.warning( - f"[REGISTER] user={email} | status=fail | IP={ip} | UA={ua} | detail={serializer.errors}" - ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + span.set_attribute("user.id", user.id) + span.set_attribute("user.email", user.email) + + logger.info(f"[REGISTER] user={user.email} | status=success | IP={ip} | UA={ua}") + span_event("user_registered", user_id=user.id, user_email=user.email) + span_success(201) + return Response({"message": "User registered successfully."}, status=status.HTTP_201_CREATED) + + logger.warning(f"[REGISTER] user={email} | status=fail | IP={ip} | UA={ua} | detail={serializer.errors}") + span_error(serializer.errors, 400) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class LogoutView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + enrich_span(request, request.user, operation="auth.logout") + email, ip, ua = get_request_info(request) + + refresh_token = request.data.get("refresh") + if not refresh_token: + logger.warning(f"[LOGOUT] user={email} | status=fail | reason=no_refresh_token | IP={ip}") + span_error("refresh token required", 400) + return Response({"error": "refresh token이 필요합니다."}, status=status.HTTP_400_BAD_REQUEST) + + with child_span("blacklist_token", user_email=email): + try: + token = RefreshToken(refresh_token) + token.blacklist() + except TokenError as e: + logger.warning(f"[LOGOUT] user={email} | status=fail | reason=invalid_token | IP={ip} | error={e}") + span_error(str(e), 400) + return Response({"error": "유효하지 않은 토큰입니다."}, status=status.HTTP_400_BAD_REQUEST) + + logger.info(f"[LOGOUT] user={email} | status=success | IP={ip} | UA={ua}") + span_event("user_logged_out", user_email=email) + span_success(200) + return Response({"message": "로그아웃 되었습니다."}, status=status.HTTP_200_OK) class MeView(APIView): permission_classes = [IsAuthenticated] def get(self, request): - with tracer.start_as_current_span("MeView GET") as span: # ✅ Span 생성 - email, ip, ua = get_request_info(request) - logger.debug(f"[ME GET] user={email} | IP={ip} | UA={ua}") + enrich_span(request, request.user, operation="auth.me.get") + email, ip, ua = get_request_info(request) + logger.debug(f"[ME GET] user={email} | IP={ip} | UA={ua}") + + with child_span("serialize_user", user_id=request.user.id): serializer = RegisterSerializer(request.user) - span.add_event( - "Me info retrieved", attributes={"email": email} - ) # ✅ Jaeger 이벤트 등록 - return Response(serializer.data) + data = serializer.data + + span_event("profile_retrieved", user_email=email) + span_success(200) + return Response(data) def put(self, request): - with tracer.start_as_current_span("MeView PUT") as span: # ✅ Span 생성 - email, ip, ua = get_request_info(request) - serializer = RegisterSerializer( - request.user, data=request.data, partial=True - ) - if serializer.is_valid(): + enrich_span(request, request.user, operation="auth.me.update") + email, ip, ua = get_request_info(request) + + with child_span("validate_input"): + serializer = RegisterSerializer(request.user, data=request.data, partial=True) + is_valid = serializer.is_valid() + + if is_valid: + with child_span("update_user", user_id=request.user.id): serializer.save() - logger.info( - f"[ME UPDATE] user={email} | status=success | IP={ip} | UA={ua}" - ) - span.add_event( - "Me info updated", attributes={"email": email} - ) # ✅ Jaeger 이벤트 등록 - return Response(serializer.data) - logger.warning( - f"[ME UPDATE] user={email} | status=fail | IP={ip} | UA={ua} | detail={serializer.errors}" - ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + logger.info(f"[ME UPDATE] user={email} | status=success | IP={ip} | UA={ua}") + span_event("profile_updated", user_email=email) + span_success(200) + return Response(serializer.data) + + logger.warning(f"[ME UPDATE] user={email} | status=fail | IP={ip} | UA={ua} | detail={serializer.errors}") + span_error(serializer.errors, 400) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class ChangePasswordView(APIView): @@ -92,26 +217,32 @@ class ChangePasswordView(APIView): ) from django.utils import timezone - with tracer.start_as_current_span("ChangePasswordView POST") as span: - email, ip, ua = get_request_info(request) - user = request.user + enrich_span(request, request.user, operation="auth.password.change") + email, ip, ua = get_request_info(request) + user = request.user + + with child_span("load_settings"): site_settings = SiteSettings.get_settings() - current_password = request.data.get("current_password") - new_password = request.data.get("new_password") - confirm_password = request.data.get("confirm_password") + current_password = request.data.get("current_password") + new_password = request.data.get("new_password") + confirm_password = request.data.get("confirm_password") - # 소셜 로그인 전용 계정인 경우 현재 비밀번호 필수 아님 - is_social_only = not user.has_usable_password() + # 소셜 로그인 전용 계정인 경우 현재 비밀번호 필수 아님 + is_social_only = not user.has_usable_password() + span_set_attribute("user.is_social_only", is_social_only) + with child_span("validate_input"): # 필수 필드 확인 if not is_social_only and not current_password: + span_error("missing_current_password", 400) return Response( {"error": "현재 비밀번호를 입력해주세요."}, status=status.HTTP_400_BAD_REQUEST ) if not new_password or not confirm_password: + span_error("missing_new_password", 400) return Response( {"error": "새 비밀번호와 비밀번호 확인은 필수입니다."}, status=status.HTTP_400_BAD_REQUEST @@ -119,60 +250,69 @@ class ChangePasswordView(APIView): # 새 비밀번호 확인 if new_password != confirm_password: + span_error("password_mismatch", 400) return Response( {"error": "새 비밀번호가 일치하지 않습니다."}, status=status.HTTP_400_BAD_REQUEST ) + with child_span("validate_policy"): # 비밀번호 정책 검증 is_valid, errors = validate_password_policy(new_password, site_settings) if not is_valid: + span_error(errors[0], 400) return Response( {"error": errors[0], "errors": errors}, status=status.HTTP_400_BAD_REQUEST ) - # 현재 비밀번호 확인 (소셜 로그인 전용이 아닌 경우) - if not is_social_only: + # 현재 비밀번호 확인 (소셜 로그인 전용이 아닌 경우) + if not is_social_only: + with child_span("verify_current_password"): if not user.check_password(current_password): logger.warning(f"[PASSWORD CHANGE] user={email} | status=fail | reason=wrong_password | IP={ip} | UA={ua}") + span_error("wrong_password", 401) return Response( {"error": "현재 비밀번호가 일치하지 않습니다."}, status=status.HTTP_401_UNAUTHORIZED ) - # 비밀번호 이력 검사 (재사용 방지) - if site_settings.password_history_count > 0: + # 비밀번호 이력 검사 (재사용 방지) + if site_settings.password_history_count > 0: + with child_span("check_password_history", history_count=site_settings.password_history_count): history_valid, history_error = check_password_history( user, new_password, site_settings.password_history_count ) if not history_valid: + span_error(history_error, 400) return Response( {"error": history_error}, status=status.HTTP_400_BAD_REQUEST ) - # 현재 비밀번호를 이력에 저장 (변경 전) - if user.has_usable_password() and site_settings.password_history_count > 0: - # 현재 비밀번호 해시 저장 + # 현재 비밀번호를 이력에 저장 (변경 전) + if user.has_usable_password() and site_settings.password_history_count > 0: + with child_span("save_password_history"): from .models import PasswordHistory PasswordHistory.objects.create( user=user, password_hash=user.password ) + with child_span("update_password", user_id=user.id): # 새 비밀번호 설정 user.set_password(new_password) user.password_changed_at = timezone.now() user.save(update_fields=['password', 'password_changed_at']) - action = "set" if is_social_only else "changed" - logger.info(f"[PASSWORD {action.upper()}] user={email} | status=success | IP={ip} | UA={ua}") - span.add_event(f"Password {action}", attributes={"email": email}) + action = "set" if is_social_only else "changed" + logger.info(f"[PASSWORD {action.upper()}] user={email} | status=success | IP={ip} | UA={ua}") + span_event(f"password_{action}", email=email) + span_success(200) - return Response({ - "message": "비밀번호가 설정되었습니다." if is_social_only else "비밀번호가 변경되었습니다." - }) + return Response({ + "message": "비밀번호가 설정되었습니다." if is_social_only else "비밀번호가 변경되었습니다." + }) class ExtendPasswordExpiryView(APIView): @@ -183,18 +323,22 @@ class ExtendPasswordExpiryView(APIView): from django.utils import timezone from datetime import timedelta - with tracer.start_as_current_span("ExtendPasswordExpiryView POST") as span: - email, ip, ua = get_request_info(request) - user = request.user + enrich_span(request, request.user, operation="auth.password.extend") + email, ip, ua = get_request_info(request) + user = request.user + + with child_span("load_settings"): site_settings = SiteSettings.get_settings() - # 만료 정책이 비활성화된 경우 - if site_settings.password_expiry_days <= 0: - return Response( - {"error": "비밀번호 만료 정책이 비활성화되어 있습니다."}, - status=status.HTTP_400_BAD_REQUEST - ) + # 만료 정책이 비활성화된 경우 + if site_settings.password_expiry_days <= 0: + span_error("policy_disabled", 400) + return Response( + {"error": "비밀번호 만료 정책이 비활성화되어 있습니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + with child_span("update_expiry", user_id=user.id, expiry_days=site_settings.password_expiry_days): # 비밀번호 변경일을 현재 시간으로 업데이트 (90일 연장) user.password_changed_at = timezone.now() user.save(update_fields=['password_changed_at']) @@ -202,174 +346,160 @@ class ExtendPasswordExpiryView(APIView): # 새로운 만료일 계산 new_expiry_date = user.password_changed_at + timedelta(days=site_settings.password_expiry_days) - logger.info(f"[PASSWORD EXTEND] user={email} | status=success | new_expiry={new_expiry_date.date()} | IP={ip} | UA={ua}") - span.add_event("Password expiry extended", attributes={"email": email, "new_expiry": str(new_expiry_date.date())}) + logger.info(f"[PASSWORD EXTEND] user={email} | status=success | new_expiry={new_expiry_date.date()} | IP={ip} | UA={ua}") + span_event("password_expiry_extended", email=email, new_expiry=str(new_expiry_date.date())) + span_success(200) - return Response({ - "message": f"비밀번호 유효기간이 {site_settings.password_expiry_days}일 연장되었습니다.", - "new_expiry_date": new_expiry_date.isoformat(), - "days_extended": site_settings.password_expiry_days, - }) + return Response({ + "message": f"비밀번호 유효기간이 {site_settings.password_expiry_days}일 연장되었습니다.", + "new_expiry_date": new_expiry_date.isoformat(), + "days_extended": site_settings.password_expiry_days, + }) class CustomTokenObtainPairView(TokenObtainPairView): serializer_class = CustomTokenObtainPairSerializer def post(self, request, *args, **kwargs): - with tracer.start_as_current_span( - "TokenObtainPairView POST" - ) as span: # ✅ Span 생성 - ip = request.META.get("REMOTE_ADDR", "unknown") - ua = request.META.get("HTTP_USER_AGENT", "unknown") - email = request.data.get("email", "unknown") - logger.info(f"[LOGIN] user={email} | status=attempt | IP={ip} | UA={ua}") + ip = request.META.get("REMOTE_ADDR", "unknown") + ua = request.META.get("HTTP_USER_AGENT", "unknown") + email = request.data.get("email", "unknown") + + enrich_span(request, operation="auth.login") + span_set_attribute("auth.email", email) + span_event("login_attempt", email=email, client_ip=ip) + + logger.info(f"[LOGIN] user={email} | status=attempt | IP={ip} | UA={ua}") + + with child_span("validate_credentials", email=email): response = super().post(request, *args, **kwargs) - if response.status_code == 200: - logger.info( - f"[LOGIN] user={email} | status=success | IP={ip} | UA={ua}" - ) - span.add_event( - "Login success", attributes={"email": email} - ) # ✅ Jaeger 이벤트 등록 - else: - logger.warning( - f"[LOGIN] user={email} | status=fail | IP={ip} | UA={ua} | detail={response.data}" - ) - span.add_event( - "Login failed", - attributes={"email": email, "reason": str(response.data)}, - ) # ✅ - return response + if response.status_code == 200: + with child_span("generate_token", email=email) as span: + # 토큰 정보 추가 + span.set_attribute("token.has_access", "access" in response.data) + span.set_attribute("token.has_refresh", "refresh" in response.data) + + logger.info(f"[LOGIN] user={email} | status=success | IP={ip} | UA={ua}") + span_event("login_success", email=email) + span_success(200) + else: + logger.warning(f"[LOGIN] user={email} | status=fail | IP={ip} | UA={ua} | detail={response.data}") + span_event("login_failed", email=email, reason=str(response.data)[:200]) + span_error(response.data, response.status_code) + + return response class SSHKeyUploadView(APIView): permission_classes = [IsAuthenticated] def post(self, request): - with tracer.start_as_current_span( - "SSHKeyUploadView POST" - ) as span: # ✅ Span 생성 - email, ip, ua = get_request_info(request) - private_key = request.data.get("private_key") - key_name = request.data.get("key_name") + enrich_span(request, request.user, operation="ssh.key.upload") + email, ip, ua = get_request_info(request) + private_key = request.data.get("private_key") + key_name = request.data.get("key_name") + with child_span("validate_input", key_name=key_name): if not private_key or not key_name: - logger.warning( - f"[SSH UPLOAD] user={email} | status=fail | reason=missing_key_or_name | IP={ip} | UA={ua}" - ) - return Response( - {"error": "private_key와 key_name 모두 필요합니다."}, - status=status.HTTP_400_BAD_REQUEST, - ) + logger.warning(f"[SSH UPLOAD] user={email} | status=fail | reason=missing_key_or_name | IP={ip} | UA={ua}") + span_error("missing_key_or_name", 400) + return Response({"error": "private_key와 key_name 모두 필요합니다."}, status=status.HTTP_400_BAD_REQUEST) - try: + try: + with child_span("encrypt_key", key_name=key_name): user = request.user user.save_private_key(private_key) user.encrypted_private_key_name = key_name - user.save( - update_fields=[ - "encrypted_private_key", - "encrypted_private_key_name", - ] - ) - logger.info( - f"[SSH UPLOAD] user={email} | status=success | key_name={key_name} | IP={ip} | UA={ua}" - ) - span.add_event( - "SSH key saved", attributes={"email": email, "key_name": key_name} - ) # ✅ - return Response({"message": "SSH key 저장 완료."}, status=201) - except Exception as e: - logger.exception( - f"[SSH UPLOAD] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" - ) - return Response( - {"error": f"암호화 또는 저장 실패: {str(e)}"}, status=500 - ) + + with child_span("save_to_db", user_id=user.id): + user.save(update_fields=["encrypted_private_key", "encrypted_private_key_name"]) + + logger.info(f"[SSH UPLOAD] user={email} | status=success | key_name={key_name} | IP={ip} | UA={ua}") + span_event("ssh_key_saved", email=email, key_name=key_name) + span_success(201) + return Response({"message": "SSH key 저장 완료."}, status=201) + except Exception as e: + logger.exception(f"[SSH UPLOAD] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}") + span_error(str(e), 500) + return Response({"error": f"암호화 또는 저장 실패: {str(e)}"}, status=500) def delete(self, request): - with tracer.start_as_current_span( - "SSHKeyUploadView DELETE" - ) as span: # ✅ Span 생성 - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="ssh.key.delete") + email, ip, ua = get_request_info(request) + + with child_span("delete_key", user_id=request.user.id): user = request.user user.encrypted_private_key = None user.encrypted_private_key_name = None user.last_used_at = None - user.save( - update_fields=[ - "encrypted_private_key", - "encrypted_private_key_name", - "last_used_at", - ] - ) - logger.info( - f"[SSH DELETE] user={email} | status=success | IP={ip} | UA={ua}" - ) - span.add_event( - "SSH key deleted", attributes={"email": email} - ) # ✅ Jaeger 이벤트 등록 - return Response({"message": "SSH key deleted."}, status=200) + user.save(update_fields=["encrypted_private_key", "encrypted_private_key_name", "last_used_at"]) + + logger.info(f"[SSH DELETE] user={email} | status=success | IP={ip} | UA={ua}") + span_event("ssh_key_deleted", email=email) + span_success(200) + return Response({"message": "SSH key deleted."}, status=200) class SSHKeyInfoView(APIView): permission_classes = [IsAuthenticated] def get(self, request): - with tracer.start_as_current_span("SSHKeyInfoView GET") as span: # ✅ Span 생성 - email, ip, ua = get_request_info(request) - logger.debug(f"[SSH INFO] user={email} | IP={ip} | UA={ua}") - user = request.user - span.add_event( - "SSH key info retrieved", attributes={"email": email} - ) # ✅ Jaeger 이벤트 등록 - return Response( - { - "has_key": bool(user.encrypted_private_key), - "encrypted_private_key_name": user.encrypted_private_key_name, - "last_used_at": user.last_used_at, - } - ) + enrich_span(request, request.user, operation="ssh.key.info") + email, ip, ua = get_request_info(request) + logger.debug(f"[SSH INFO] user={email} | IP={ip} | UA={ua}") + user = request.user + + with child_span("get_key_info", user_id=user.id): + has_key = bool(user.encrypted_private_key) + + span_set_attribute("ssh.has_key", has_key) + span_event("ssh_key_info_retrieved", email=email, has_key=has_key) + span_success(200) + + return Response({ + "has_key": has_key, + "encrypted_private_key_name": user.encrypted_private_key_name, + "last_used_at": user.last_used_at, + }) class SSHKeyRetrieveView(APIView): permission_classes = [IsAuthenticated] def get(self, request): - with tracer.start_as_current_span( - "SSHKeyRetrieveView GET" - ) as span: # ✅ Span 생성 - email, ip, ua = get_request_info(request) - user = request.user + enrich_span(request, request.user, operation="ssh.key.retrieve") + email, ip, ua = get_request_info(request) + user = request.user + + with child_span("check_key_exists", user_id=user.id): if not user.encrypted_private_key: logger.warning( f"[SSH RETRIEVE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) - span.add_event( - "SSH key retrieve failed", - attributes={"email": email, "reason": "not_found"}, - ) # ✅ + span_event("ssh_key_retrieve_failed", email=email, reason="not_found") + span_error("not_found", 404) return Response( {"error": "SSH 키가 등록되어 있지 않습니다."}, status=404 ) - try: + try: + with child_span("decrypt_key", user_id=user.id): decrypted_key = user.decrypt_private_key() - logger.info( - f"[SSH RETRIEVE] user={email} | status=success | IP={ip} | UA={ua}" - ) - span.add_event("SSH key retrieved", attributes={"email": email}) # ✅ - return Response({"ssh_key": decrypted_key}) - except Exception as e: - logger.exception( - f"[SSH RETRIEVE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" - ) - span.add_event( - "SSH key retrieve failed", - attributes={"email": email, "reason": str(e)}, - ) # ✅ - return Response({"error": f"복호화 실패: {str(e)}"}, status=500) + + logger.info( + f"[SSH RETRIEVE] user={email} | status=success | IP={ip} | UA={ua}" + ) + span_event("ssh_key_retrieved", email=email) + span_success(200) + return Response({"ssh_key": decrypted_key}) + except Exception as e: + logger.exception( + f"[SSH RETRIEVE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" + ) + span_event("ssh_key_retrieve_failed", email=email, reason=str(e)) + span_error(str(e), 500) + return Response({"error": f"복호화 실패: {str(e)}"}, status=500) # ============================================ @@ -391,11 +521,17 @@ class UserListView(generics.ListAPIView): permission_classes = [IsAuthenticated, IsAdminOrManager] def list(self, request, *args, **kwargs): - with tracer.start_as_current_span("UserListView GET") as span: - email, ip, ua = get_request_info(request) - logger.info(f"[USER LIST] admin={email} | IP={ip} | UA={ua}") - span.add_event("User list retrieved", attributes={"admin": email}) - return super().list(request, *args, **kwargs) + enrich_span(request, request.user, operation="admin.user.list") + email, ip, ua = get_request_info(request) + logger.info(f"[USER LIST] admin={email} | IP={ip} | UA={ua}") + + with child_span("query_users") as span: + response = super().list(request, *args, **kwargs) + span.set_attribute("user.count", len(response.data)) + + span_event("user_list_retrieved", admin=email, count=len(response.data)) + span_success(200) + return response class UserUpdateView(generics.RetrieveUpdateDestroyAPIView): @@ -405,38 +541,47 @@ class UserUpdateView(generics.RetrieveUpdateDestroyAPIView): permission_classes = [IsAuthenticated, IsAdminOrManager] def partial_update(self, request, *args, **kwargs): - with tracer.start_as_current_span("UserUpdateView PATCH") as span: - admin_email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="admin.user.update") + admin_email, ip, ua = get_request_info(request) + + with child_span("get_target_user"): instance = self.get_object() target_email = instance.email + span_set_attribute("target.user_id", instance.id) + span_set_attribute("target.email", target_email) - update_fields = [] - actions = [] + update_fields = [] + actions = [] - # is_active 수정 - is_active = request.data.get('is_active') - if is_active is not None: + # is_active 수정 + is_active = request.data.get('is_active') + if is_active is not None: + with child_span("update_active_status", is_active=is_active): instance.is_active = is_active update_fields.append('is_active') actions.append("activated" if is_active else "deactivated") - # grade 수정 (관리자만 admin 등급 부여 가능) - grade = request.data.get('grade') - if grade is not None: + # grade 수정 (관리자만 admin 등급 부여 가능) + grade = request.data.get('grade') + if grade is not None: + with child_span("update_grade", new_grade=grade): valid_grades = ['admin', 'manager', 'user'] if grade not in valid_grades: + span_error("invalid_grade", 400) return Response( {"error": f"유효하지 않은 등급입니다. 가능한 값: {valid_grades}"}, status=status.HTTP_400_BAD_REQUEST ) # admin 등급 부여는 admin만 가능 (manager는 불가) if grade == 'admin' and request.user.grade != 'admin': + span_error("permission_denied", 403) return Response( {"error": "admin 등급 부여는 admin만 가능합니다."}, status=status.HTTP_403_FORBIDDEN ) # 자기 자신의 등급 하향 방지 (실수로 관리자 권한 잃는 것 방지) if request.user.id == instance.id and instance.grade == 'admin' and grade != 'admin': + span_error("self_demote_blocked", 400) return Response( {"error": "자신의 admin 등급을 변경할 수 없습니다."}, status=status.HTTP_400_BAD_REQUEST @@ -445,10 +590,12 @@ class UserUpdateView(generics.RetrieveUpdateDestroyAPIView): update_fields.append('grade') actions.append(f"grade_changed_to_{grade}") - # 비밀번호 초기화 (관리자 전용) - new_password = request.data.get('new_password') - if new_password is not None: + # 비밀번호 초기화 (관리자 전용) + new_password = request.data.get('new_password') + if new_password is not None: + with child_span("reset_password", target_email=target_email): if len(new_password) < 8: + span_error("password_too_short", 400) return Response( {"error": "비밀번호는 최소 8자 이상이어야 합니다."}, status=status.HTTP_400_BAD_REQUEST @@ -460,46 +607,50 @@ class UserUpdateView(generics.RetrieveUpdateDestroyAPIView): f"[USER PASSWORD RESET] admin={admin_email} | target={target_email} | IP={ip} | UA={ua}" ) - if update_fields: + if update_fields: + with child_span("save_changes", fields=str(update_fields)): instance.save(update_fields=update_fields) - logger.info( - f"[USER UPDATE] admin={admin_email} | target={target_email} | actions={actions} | IP={ip} | UA={ua}" - ) - span.add_event( - "User updated", - attributes={"admin": admin_email, "target": target_email, "actions": str(actions)} - ) + logger.info( + f"[USER UPDATE] admin={admin_email} | target={target_email} | actions={actions} | IP={ip} | UA={ua}" + ) + span_event("user_updated", admin=admin_email, target=target_email, actions=str(actions)) - serializer = self.get_serializer(instance) - return Response(serializer.data) + span_success(200) + serializer = self.get_serializer(instance) + return Response(serializer.data) def destroy(self, request, *args, **kwargs): - with tracer.start_as_current_span("UserUpdateView DELETE") as span: - admin_email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="admin.user.delete") + admin_email, ip, ua = get_request_info(request) + + with child_span("get_target_user"): instance = self.get_object() target_email = instance.email + span_set_attribute("target.user_id", instance.id) + span_set_attribute("target.email", target_email) - # 자기 자신은 삭제 불가 - if request.user.id == instance.id: - return Response( - {"error": "자기 자신의 계정은 삭제할 수 없습니다."}, - status=status.HTTP_400_BAD_REQUEST - ) - - logger.info( - f"[USER DELETE] admin={admin_email} | target={target_email} | IP={ip} | UA={ua}" - ) - span.add_event( - "User deleted", - attributes={"admin": admin_email, "target": target_email} - ) - - instance.delete() + # 자기 자신은 삭제 불가 + if request.user.id == instance.id: + span_error("self_delete_blocked", 400) return Response( - {"message": f"사용자 {target_email}이(가) 삭제되었습니다."}, - status=status.HTTP_200_OK + {"error": "자기 자신의 계정은 삭제할 수 없습니다."}, + status=status.HTTP_400_BAD_REQUEST ) + with child_span("delete_user", target_email=target_email): + instance.delete() + + logger.info( + f"[USER DELETE] admin={admin_email} | target={target_email} | IP={ip} | UA={ua}" + ) + span_event("user_deleted", admin=admin_email, target=target_email) + span_success(200) + + return Response( + {"message": f"사용자 {target_email}이(가) 삭제되었습니다."}, + status=status.HTTP_200_OK + ) + # ============================================ # NHN Cloud 자격증명 관리 API @@ -511,58 +662,71 @@ class NHNCloudCredentialsView(APIView): def get(self, request): """자격증명 조회 (비밀번호 제외)""" - with tracer.start_as_current_span("NHNCloudCredentialsView GET") as span: - email, ip, ua = get_request_info(request) - user = request.user - logger.debug(f"[NHN CREDENTIALS GET] user={email} | IP={ip} | UA={ua}") - span.add_event("NHN credentials retrieved", attributes={"email": email}) + enrich_span(request, request.user, operation="nhn.credentials.get") + email, ip, ua = get_request_info(request) + user = request.user + logger.debug(f"[NHN CREDENTIALS GET] user={email} | IP={ip} | UA={ua}") - return Response({ - "has_credentials": bool(user.nhn_tenant_id and user.encrypted_nhn_api_password), - "tenant_id": user.nhn_tenant_id or "", - "username": user.nhn_username or "", - "storage_account": user.nhn_storage_account or "", - }) + with child_span("get_credentials", user_id=user.id): + has_credentials = bool(user.nhn_tenant_id and user.encrypted_nhn_api_password) + + span_set_attribute("nhn.has_credentials", has_credentials) + span_event("nhn_credentials_retrieved", email=email, has_credentials=has_credentials) + span_success(200) + + return Response({ + "has_credentials": has_credentials, + "tenant_id": user.nhn_tenant_id or "", + "username": user.nhn_username or "", + "storage_account": user.nhn_storage_account or "", + }) def post(self, request): """자격증명 저장""" - with tracer.start_as_current_span("NHNCloudCredentialsView POST") as span: - email, ip, ua = get_request_info(request) - user = request.user + enrich_span(request, request.user, operation="nhn.credentials.save") + email, ip, ua = get_request_info(request) + user = request.user - tenant_id = request.data.get("tenant_id") - username = request.data.get("username") - password = request.data.get("password") - storage_account = request.data.get("storage_account", "") + tenant_id = request.data.get("tenant_id") + username = request.data.get("username") + password = request.data.get("password") + storage_account = request.data.get("storage_account", "") + with child_span("validate_input"): if not tenant_id or not username or not password: logger.warning( f"[NHN CREDENTIALS SAVE] user={email} | status=fail | reason=missing_fields | IP={ip} | UA={ua}" ) + span_error("missing_fields", 400) return Response( {"error": "tenant_id, username, password는 필수입니다."}, status=status.HTTP_400_BAD_REQUEST, ) - try: + try: + with child_span("encrypt_and_save", tenant_id=tenant_id): user.save_nhn_credentials(tenant_id, username, password, storage_account) - logger.info( - f"[NHN CREDENTIALS SAVE] user={email} | status=success | IP={ip} | UA={ua}" - ) - span.add_event("NHN credentials saved", attributes={"email": email}) - return Response({"message": "NHN Cloud 자격증명이 저장되었습니다."}, status=201) - except Exception as e: - logger.exception( - f"[NHN CREDENTIALS SAVE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" - ) - return Response({"error": f"저장 실패: {str(e)}"}, status=500) + + logger.info( + f"[NHN CREDENTIALS SAVE] user={email} | status=success | IP={ip} | UA={ua}" + ) + span_event("nhn_credentials_saved", email=email, tenant_id=tenant_id) + span_success(201) + return Response({"message": "NHN Cloud 자격증명이 저장되었습니다."}, status=201) + except Exception as e: + logger.exception( + f"[NHN CREDENTIALS SAVE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" + ) + span_error(str(e), 500) + return Response({"error": f"저장 실패: {str(e)}"}, status=500) def delete(self, request): """자격증명 삭제""" - with tracer.start_as_current_span("NHNCloudCredentialsView DELETE") as span: - email, ip, ua = get_request_info(request) - user = request.user + enrich_span(request, request.user, operation="nhn.credentials.delete") + email, ip, ua = get_request_info(request) + user = request.user + with child_span("delete_credentials", user_id=user.id): user.nhn_tenant_id = None user.nhn_username = None user.encrypted_nhn_api_password = None @@ -571,9 +735,10 @@ class NHNCloudCredentialsView(APIView): 'nhn_tenant_id', 'nhn_username', 'encrypted_nhn_api_password', 'nhn_storage_account' ]) - logger.info(f"[NHN CREDENTIALS DELETE] user={email} | status=success | IP={ip} | UA={ua}") - span.add_event("NHN credentials deleted", attributes={"email": email}) - return Response({"message": "NHN Cloud 자격증명이 삭제되었습니다."}) + logger.info(f"[NHN CREDENTIALS DELETE] user={email} | status=success | IP={ip} | UA={ua}") + span_event("nhn_credentials_deleted", email=email) + span_success(200) + return Response({"message": "NHN Cloud 자격증명이 삭제되었습니다."}) class NHNCloudPasswordView(APIView): @@ -582,34 +747,40 @@ class NHNCloudPasswordView(APIView): def get(self, request): """복호화된 비밀번호 조회""" - with tracer.start_as_current_span("NHNCloudPasswordView GET") as span: - email, ip, ua = get_request_info(request) - user = request.user + enrich_span(request, request.user, operation="nhn.password.get") + email, ip, ua = get_request_info(request) + user = request.user + with child_span("check_credentials", user_id=user.id): if not user.encrypted_nhn_api_password: logger.warning( f"[NHN PASSWORD GET] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) + span_error("not_found", 404) return Response( {"error": "NHN Cloud 자격증명이 등록되어 있지 않습니다."}, status=404 ) - try: + try: + with child_span("decrypt_password"): decrypted_password = user.decrypt_nhn_password() - logger.info(f"[NHN PASSWORD GET] user={email} | status=success | IP={ip} | UA={ua}") - span.add_event("NHN password retrieved", attributes={"email": email}) - return Response({ - "tenant_id": user.nhn_tenant_id, - "username": user.nhn_username, - "password": decrypted_password, - "storage_account": user.nhn_storage_account or "", - }) - except Exception as e: - logger.exception( - f"[NHN PASSWORD GET] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" - ) - return Response({"error": f"복호화 실패: {str(e)}"}, status=500) + + logger.info(f"[NHN PASSWORD GET] user={email} | status=success | IP={ip} | UA={ua}") + span_event("nhn_password_retrieved", email=email) + span_success(200) + return Response({ + "tenant_id": user.nhn_tenant_id, + "username": user.nhn_username, + "password": decrypted_password, + "storage_account": user.nhn_storage_account or "", + }) + except Exception as e: + logger.exception( + f"[NHN PASSWORD GET] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" + ) + span_error(str(e), 500) + return Response({"error": f"복호화 실패: {str(e)}"}, status=500) # ============================================ @@ -622,60 +793,71 @@ class NHNCloudProjectListView(APIView): def get(self, request): """프로젝트 목록 조회""" - with tracer.start_as_current_span("NHNCloudProjectListView GET") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="nhn.project.list") + email, ip, ua = get_request_info(request) + + with child_span("query_projects", user_id=request.user.id) as span: projects = NHNCloudProject.objects.filter(user=request.user) + count = projects.count() + span.set_attribute("project.count", count) - logger.debug(f"[NHN PROJECT LIST] user={email} | count={projects.count()} | IP={ip} | UA={ua}") - span.add_event("NHN projects listed", attributes={"email": email, "count": projects.count()}) + logger.debug(f"[NHN PROJECT LIST] user={email} | count={count} | IP={ip} | UA={ua}") + span_event("nhn_projects_listed", email=email, count=count) + span_success(200) - data = [{ - "id": p.id, - "name": p.name, - "tenant_id": p.tenant_id, - "username": p.username, - "storage_account": p.storage_account or "", - "dns_appkey": p.dns_appkey or "", - "is_active": p.is_active, - "created_at": p.created_at.isoformat(), - } for p in projects] + data = [{ + "id": p.id, + "name": p.name, + "tenant_id": p.tenant_id, + "username": p.username, + "storage_account": p.storage_account or "", + "dns_appkey": p.dns_appkey or "", + "is_active": p.is_active, + "created_at": p.created_at.isoformat(), + } for p in projects] - return Response(data) + return Response(data) def post(self, request): """프로젝트 추가""" - with tracer.start_as_current_span("NHNCloudProjectListView POST") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="nhn.project.create") + email, ip, ua = get_request_info(request) - name = request.data.get("name") - tenant_id = request.data.get("tenant_id") - username = request.data.get("username") - password = request.data.get("password") - storage_account = request.data.get("storage_account", "") - dns_appkey = request.data.get("dns_appkey", "") + name = request.data.get("name") + tenant_id = request.data.get("tenant_id") + username = request.data.get("username") + password = request.data.get("password") + storage_account = request.data.get("storage_account", "") + dns_appkey = request.data.get("dns_appkey", "") + with child_span("validate_input", project_name=name): if not name or not tenant_id or not username or not password: logger.warning( f"[NHN PROJECT CREATE] user={email} | status=fail | reason=missing_fields | IP={ip} | UA={ua}" ) + span_error("missing_fields", 400) return Response( {"error": "name, tenant_id, username, password는 필수입니다."}, status=status.HTTP_400_BAD_REQUEST, ) + with child_span("check_duplicate", tenant_id=tenant_id): # 중복 체크 if NHNCloudProject.objects.filter(user=request.user, tenant_id=tenant_id).exists(): logger.warning( f"[NHN PROJECT CREATE] user={email} | status=fail | reason=duplicate | IP={ip} | UA={ua}" ) + span_error("duplicate_tenant_id", 400) return Response( {"error": "이미 등록된 Tenant ID입니다."}, status=status.HTTP_400_BAD_REQUEST, ) - try: + try: + with child_span("create_project", project_name=name) as span: # 첫 프로젝트면 자동 활성화 is_first = not NHNCloudProject.objects.filter(user=request.user).exists() + span.set_attribute("project.is_first", is_first) project = NHNCloudProject( user=request.user, @@ -689,28 +871,31 @@ class NHNCloudProjectListView(APIView): # 암호화된 비밀번호 설정 후 저장 project.encrypted_password = project.encrypt_password(password) project.save() + span.set_attribute("project.id", project.id) - logger.info( - f"[NHN PROJECT CREATE] user={email} | project={name} | is_active={is_first} | IP={ip} | UA={ua}" - ) - span.add_event("NHN project created", attributes={"email": email, "project": name}) + logger.info( + f"[NHN PROJECT CREATE] user={email} | project={name} | is_active={is_first} | IP={ip} | UA={ua}" + ) + span_event("nhn_project_created", email=email, project=name) + span_success(201) - return Response({ - "id": project.id, - "name": project.name, - "tenant_id": project.tenant_id, - "username": project.username, - "storage_account": project.storage_account or "", - "dns_appkey": project.dns_appkey or "", - "is_active": project.is_active, - "created_at": project.created_at.isoformat(), - }, status=status.HTTP_201_CREATED) + return Response({ + "id": project.id, + "name": project.name, + "tenant_id": project.tenant_id, + "username": project.username, + "storage_account": project.storage_account or "", + "dns_appkey": project.dns_appkey or "", + "is_active": project.is_active, + "created_at": project.created_at.isoformat(), + }, status=status.HTTP_201_CREATED) - except Exception as e: - logger.exception( - f"[NHN PROJECT CREATE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" - ) - return Response({"error": f"프로젝트 생성 실패: {str(e)}"}, status=500) + except Exception as e: + logger.exception( + f"[NHN PROJECT CREATE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" + ) + span_error(str(e), 500) + return Response({"error": f"프로젝트 생성 실패: {str(e)}"}, status=500) class NHNCloudProjectDetailView(APIView): @@ -726,63 +911,75 @@ class NHNCloudProjectDetailView(APIView): def get(self, request, project_id): """프로젝트 상세 조회""" - with tracer.start_as_current_span("NHNCloudProjectDetailView GET") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="nhn.project.get") + email, ip, ua = get_request_info(request) + span_set_attribute("project.id", project_id) + with child_span("get_project", project_id=project_id): project = self.get_project(request, project_id) if not project: + span_error("not_found", 404) return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) - span.add_event("NHN project retrieved", attributes={"email": email, "project": project.name}) + span_event("nhn_project_retrieved", email=email, project=project.name) + span_success(200) - return Response({ - "id": project.id, - "name": project.name, - "tenant_id": project.tenant_id, - "username": project.username, - "storage_account": project.storage_account or "", - "dns_appkey": project.dns_appkey or "", - "is_active": project.is_active, - "created_at": project.created_at.isoformat(), - }) + return Response({ + "id": project.id, + "name": project.name, + "tenant_id": project.tenant_id, + "username": project.username, + "storage_account": project.storage_account or "", + "dns_appkey": project.dns_appkey or "", + "is_active": project.is_active, + "created_at": project.created_at.isoformat(), + }) def put(self, request, project_id): """프로젝트 수정""" - with tracer.start_as_current_span("NHNCloudProjectDetailView PUT") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="nhn.project.update") + email, ip, ua = get_request_info(request) + span_set_attribute("project.id", project_id) + with child_span("get_project", project_id=project_id): project = self.get_project(request, project_id) if not project: logger.warning( f"[NHN PROJECT UPDATE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) + span_error("not_found", 404) return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) - # 수정 가능한 필드 - name = request.data.get("name") - tenant_id = request.data.get("tenant_id") - username = request.data.get("username") - password = request.data.get("password") # 비어있으면 변경 안함 - storage_account = request.data.get("storage_account") - dns_appkey = request.data.get("dns_appkey") + # 수정 가능한 필드 + name = request.data.get("name") + tenant_id = request.data.get("tenant_id") + username = request.data.get("username") + password = request.data.get("password") # 비어있으면 변경 안함 + storage_account = request.data.get("storage_account") + dns_appkey = request.data.get("dns_appkey") + with child_span("validate_input", project_name=name): # 필수 필드 검증 if not name or not tenant_id or not username: + span_error("missing_fields", 400) return Response( {"error": "name, tenant_id, username은 필수입니다."}, status=status.HTTP_400_BAD_REQUEST, ) + with child_span("check_duplicate", tenant_id=tenant_id): # tenant_id 중복 체크 (자기 자신 제외) if NHNCloudProject.objects.filter( user=request.user, tenant_id=tenant_id ).exclude(id=project_id).exists(): + span_error("duplicate_tenant_id", 400) return Response( {"error": "이미 등록된 Tenant ID입니다."}, status=status.HTTP_400_BAD_REQUEST, ) - try: + try: + with child_span("update_project", project_name=name): project.name = name project.tenant_id = tenant_id project.username = username @@ -797,57 +994,66 @@ class NHNCloudProjectDetailView(APIView): project.save() - logger.info( - f"[NHN PROJECT UPDATE] user={email} | project={name} | IP={ip} | UA={ua}" - ) - span.add_event("NHN project updated", attributes={"email": email, "project": name}) + logger.info( + f"[NHN PROJECT UPDATE] user={email} | project={name} | IP={ip} | UA={ua}" + ) + span_event("nhn_project_updated", email=email, project=name) + span_success(200) - return Response({ - "id": project.id, - "name": project.name, - "tenant_id": project.tenant_id, - "username": project.username, - "storage_account": project.storage_account or "", - "dns_appkey": project.dns_appkey or "", - "is_active": project.is_active, - "created_at": project.created_at.isoformat(), - }) + return Response({ + "id": project.id, + "name": project.name, + "tenant_id": project.tenant_id, + "username": project.username, + "storage_account": project.storage_account or "", + "dns_appkey": project.dns_appkey or "", + "is_active": project.is_active, + "created_at": project.created_at.isoformat(), + }) - except Exception as e: - logger.exception( - f"[NHN PROJECT UPDATE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" - ) - return Response({"error": f"프로젝트 수정 실패: {str(e)}"}, status=500) + except Exception as e: + logger.exception( + f"[NHN PROJECT UPDATE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" + ) + span_error(str(e), 500) + return Response({"error": f"프로젝트 수정 실패: {str(e)}"}, status=500) def delete(self, request, project_id): """프로젝트 삭제""" - with tracer.start_as_current_span("NHNCloudProjectDetailView DELETE") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="nhn.project.delete") + email, ip, ua = get_request_info(request) + span_set_attribute("project.id", project_id) + with child_span("get_project", project_id=project_id): project = self.get_project(request, project_id) if not project: logger.warning( f"[NHN PROJECT DELETE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) + span_error("not_found", 404) return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) - project_name = project.name - was_active = project.is_active + project_name = project.name + was_active = project.is_active + + with child_span("delete_project", project_name=project_name): project.delete() - # 삭제된 프로젝트가 활성이었으면 다른 프로젝트 활성화 - if was_active: + # 삭제된 프로젝트가 활성이었으면 다른 프로젝트 활성화 + if was_active: + with child_span("activate_another_project"): other_project = NHNCloudProject.objects.filter(user=request.user).first() if other_project: other_project.is_active = True other_project.save(update_fields=['is_active']) - logger.info( - f"[NHN PROJECT DELETE] user={email} | project={project_name} | IP={ip} | UA={ua}" - ) - span.add_event("NHN project deleted", attributes={"email": email, "project": project_name}) + logger.info( + f"[NHN PROJECT DELETE] user={email} | project={project_name} | IP={ip} | UA={ua}" + ) + span_event("nhn_project_deleted", email=email, project=project_name) + span_success(204) - return Response(status=status.HTTP_204_NO_CONTENT) + return Response(status=status.HTTP_204_NO_CONTENT) class NHNCloudProjectActivateView(APIView): @@ -856,34 +1062,40 @@ class NHNCloudProjectActivateView(APIView): def patch(self, request, project_id): """프로젝트 활성화 (기존 활성 해제)""" - with tracer.start_as_current_span("NHNCloudProjectActivateView PATCH") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="nhn.project.activate") + email, ip, ua = get_request_info(request) + span_set_attribute("project.id", project_id) + with child_span("get_project", project_id=project_id): try: project = NHNCloudProject.objects.get(id=project_id, user=request.user) except NHNCloudProject.DoesNotExist: logger.warning( f"[NHN PROJECT ACTIVATE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) + span_error("not_found", 404) return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) + with child_span("deactivate_others"): # 기존 활성 프로젝트 비활성화 NHNCloudProject.objects.filter(user=request.user, is_active=True).update(is_active=False) + with child_span("activate_project", project_name=project.name): # 새 프로젝트 활성화 project.is_active = True project.save(update_fields=['is_active']) - logger.info( - f"[NHN PROJECT ACTIVATE] user={email} | project={project.name} | IP={ip} | UA={ua}" - ) - span.add_event("NHN project activated", attributes={"email": email, "project": project.name}) + logger.info( + f"[NHN PROJECT ACTIVATE] user={email} | project={project.name} | IP={ip} | UA={ua}" + ) + span_event("nhn_project_activated", email=email, project=project.name) + span_success(200) - return Response({ - "message": "프로젝트가 활성화되었습니다.", - "id": project.id, - "name": project.name, - }) + return Response({ + "message": "프로젝트가 활성화되었습니다.", + "id": project.id, + "name": project.name, + }) class NHNCloudProjectPasswordView(APIView): @@ -892,34 +1104,41 @@ class NHNCloudProjectPasswordView(APIView): def get(self, request, project_id): """복호화된 비밀번호 조회""" - with tracer.start_as_current_span("NHNCloudProjectPasswordView GET") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="nhn.project.password") + email, ip, ua = get_request_info(request) + span_set_attribute("project.id", project_id) + with child_span("get_project", project_id=project_id): try: project = NHNCloudProject.objects.get(id=project_id, user=request.user) except NHNCloudProject.DoesNotExist: logger.warning( f"[NHN PROJECT PASSWORD] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) + span_error("not_found", 404) return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) - try: + try: + with child_span("decrypt_password", project_name=project.name): decrypted_password = project.decrypt_password() - logger.info(f"[NHN PROJECT PASSWORD] user={email} | project={project.name} | IP={ip} | UA={ua}") - span.add_event("NHN project password retrieved", attributes={"email": email, "project": project.name}) - return Response({ - "tenant_id": project.tenant_id, - "username": project.username, - "password": decrypted_password, - "storage_account": project.storage_account or "", - "dns_appkey": project.dns_appkey or "", - }) - except Exception as e: - logger.exception( - f"[NHN PROJECT PASSWORD] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" - ) - return Response({"error": f"복호화 실패: {str(e)}"}, status=500) + logger.info(f"[NHN PROJECT PASSWORD] user={email} | project={project.name} | IP={ip} | UA={ua}") + span_event("nhn_project_password_retrieved", email=email, project=project.name) + span_success(200) + + return Response({ + "tenant_id": project.tenant_id, + "username": project.username, + "password": decrypted_password, + "storage_account": project.storage_account or "", + "dns_appkey": project.dns_appkey or "", + }) + except Exception as e: + logger.exception( + f"[NHN PROJECT PASSWORD] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" + ) + span_error(str(e), 500) + return Response({"error": f"복호화 실패: {str(e)}"}, status=500) # ============================================ @@ -932,46 +1151,63 @@ class KVMServerListView(APIView): def get(self, request): """서버 목록 조회""" - with tracer.start_as_current_span("KVMServerListView GET") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="kvm.server.list") + email, ip, ua = get_request_info(request) + + with child_span("query_servers", user_id=request.user.id) as span: servers = KVMServer.objects.filter(user=request.user) + count = servers.count() + span.set_attribute("server.count", count) - logger.debug(f"[KVM SERVER LIST] user={email} | count={servers.count()} | IP={ip} | UA={ua}") - span.add_event("KVM servers listed", attributes={"email": email, "count": servers.count()}) + logger.debug(f"[KVM SERVER LIST] user={email} | count={count} | IP={ip} | UA={ua}") + span_event("kvm_servers_listed", email=email, count=count) + span_success(200) - serializer = KVMServerSerializer(servers, many=True) - return Response(serializer.data) + serializer = KVMServerSerializer(servers, many=True) + return Response(serializer.data) def post(self, request): """서버 추가""" - with tracer.start_as_current_span("KVMServerListView POST") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="kvm.server.create") + email, ip, ua = get_request_info(request) - # 중복 체크 - host = request.data.get("host") - port = request.data.get("port", 22) + # 중복 체크 + host = request.data.get("host") + port = request.data.get("port", 22) + + with child_span("check_duplicate", host=host, port=port): if KVMServer.objects.filter(user=request.user, host=host, port=port).exists(): logger.warning( f"[KVM SERVER CREATE] user={email} | status=fail | reason=duplicate | IP={ip} | UA={ua}" ) + span_error("duplicate_server", 400) return Response( {"error": "이미 등록된 서버입니다 (동일한 호스트:포트)."}, status=status.HTTP_400_BAD_REQUEST, ) + with child_span("validate_input"): serializer = KVMServerSerializer(data=request.data) - if serializer.is_valid(): - server = serializer.save(user=request.user) - logger.info( - f"[KVM SERVER CREATE] user={email} | server={server.name} | host={server.host} | IP={ip} | UA={ua}" - ) - span.add_event("KVM server created", attributes={"email": email, "server": server.name}) - return Response(KVMServerSerializer(server).data, status=status.HTTP_201_CREATED) + is_valid = serializer.is_valid() - logger.warning( - f"[KVM SERVER CREATE] user={email} | status=fail | reason=validation | IP={ip} | UA={ua}" + if is_valid: + with child_span("create_server", host=host) as span: + server = serializer.save(user=request.user) + span.set_attribute("server.id", server.id) + span.set_attribute("server.name", server.name) + + logger.info( + f"[KVM SERVER CREATE] user={email} | server={server.name} | host={server.host} | IP={ip} | UA={ua}" ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + span_event("kvm_server_created", email=email, server=server.name) + span_success(201) + return Response(KVMServerSerializer(server).data, status=status.HTTP_201_CREATED) + + logger.warning( + f"[KVM SERVER CREATE] user={email} | status=fail | reason=validation | IP={ip} | UA={ua}" + ) + span_error(str(serializer.errors), 400) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class KVMServerDetailView(APIView): @@ -987,74 +1223,97 @@ class KVMServerDetailView(APIView): def get(self, request, server_id): """서버 상세 조회""" - with tracer.start_as_current_span("KVMServerDetailView GET") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="kvm.server.get") + email, ip, ua = get_request_info(request) + span_set_attribute("server.id", server_id) + with child_span("get_server", server_id=server_id): server = self.get_server(request, server_id) if not server: + span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) - span.add_event("KVM server retrieved", attributes={"email": email, "server": server.name}) - return Response(KVMServerSerializer(server).data) + span_event("kvm_server_retrieved", email=email, server=server.name) + span_success(200) + return Response(KVMServerSerializer(server).data) def put(self, request, server_id): """서버 수정""" - with tracer.start_as_current_span("KVMServerDetailView PUT") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="kvm.server.update") + email, ip, ua = get_request_info(request) + span_set_attribute("server.id", server_id) + with child_span("get_server", server_id=server_id): server = self.get_server(request, server_id) if not server: logger.warning( f"[KVM SERVER UPDATE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) + span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) - # 호스트:포트 중복 체크 (자기 자신 제외) - host = request.data.get("host", server.host) - port = request.data.get("port", server.port) + # 호스트:포트 중복 체크 (자기 자신 제외) + host = request.data.get("host", server.host) + port = request.data.get("port", server.port) + + with child_span("check_duplicate", host=host, port=port): if KVMServer.objects.filter( user=request.user, host=host, port=port ).exclude(id=server_id).exists(): + span_error("duplicate_server", 400) return Response( {"error": "이미 등록된 서버입니다 (동일한 호스트:포트)."}, status=status.HTTP_400_BAD_REQUEST, ) + with child_span("validate_input"): serializer = KVMServerSerializer(server, data=request.data, partial=True) - if serializer.is_valid(): - server = serializer.save() - logger.info( - f"[KVM SERVER UPDATE] user={email} | server={server.name} | IP={ip} | UA={ua}" - ) - span.add_event("KVM server updated", attributes={"email": email, "server": server.name}) - return Response(KVMServerSerializer(server).data) + is_valid = serializer.is_valid() - logger.warning( - f"[KVM SERVER UPDATE] user={email} | status=fail | reason=validation | IP={ip} | UA={ua}" + if is_valid: + with child_span("update_server", server_name=server.name): + server = serializer.save() + + logger.info( + f"[KVM SERVER UPDATE] user={email} | server={server.name} | IP={ip} | UA={ua}" ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + span_event("kvm_server_updated", email=email, server=server.name) + span_success(200) + return Response(KVMServerSerializer(server).data) + + logger.warning( + f"[KVM SERVER UPDATE] user={email} | status=fail | reason=validation | IP={ip} | UA={ua}" + ) + span_error(str(serializer.errors), 400) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, server_id): """서버 삭제""" - with tracer.start_as_current_span("KVMServerDetailView DELETE") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="kvm.server.delete") + email, ip, ua = get_request_info(request) + span_set_attribute("server.id", server_id) + with child_span("get_server", server_id=server_id): server = self.get_server(request, server_id) if not server: logger.warning( f"[KVM SERVER DELETE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) + span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) - server_name = server.name + server_name = server.name + + with child_span("delete_server", server_name=server_name): server.delete() - logger.info( - f"[KVM SERVER DELETE] user={email} | server={server_name} | IP={ip} | UA={ua}" - ) - span.add_event("KVM server deleted", attributes={"email": email, "server": server_name}) + logger.info( + f"[KVM SERVER DELETE] user={email} | server={server_name} | IP={ip} | UA={ua}" + ) + span_event("kvm_server_deleted", email=email, server=server_name) + span_success(204) - return Response(status=status.HTTP_204_NO_CONTENT) + return Response(status=status.HTTP_204_NO_CONTENT) class KVMServerActivateView(APIView): @@ -1063,38 +1322,44 @@ class KVMServerActivateView(APIView): def patch(self, request, server_id): """서버 활성화 상태 토글""" - with tracer.start_as_current_span("KVMServerActivateView PATCH") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="kvm.server.activate") + email, ip, ua = get_request_info(request) + span_set_attribute("server.id", server_id) + with child_span("get_server", server_id=server_id): try: server = KVMServer.objects.get(id=server_id, user=request.user) except KVMServer.DoesNotExist: logger.warning( f"[KVM SERVER ACTIVATE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) + span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) - # 요청에서 is_active 값 가져오기 (없으면 토글) - is_active = request.data.get('is_active') + # 요청에서 is_active 값 가져오기 (없으면 토글) + is_active = request.data.get('is_active') + + with child_span("toggle_active", server_name=server.name) as span: if is_active is None: server.is_active = not server.is_active else: server.is_active = is_active - + span.set_attribute("server.is_active", server.is_active) server.save(update_fields=['is_active']) - action = "activated" if server.is_active else "deactivated" - logger.info( - f"[KVM SERVER ACTIVATE] user={email} | server={server.name} | action={action} | IP={ip} | UA={ua}" - ) - span.add_event(f"KVM server {action}", attributes={"email": email, "server": server.name}) + action = "activated" if server.is_active else "deactivated" + logger.info( + f"[KVM SERVER ACTIVATE] user={email} | server={server.name} | action={action} | IP={ip} | UA={ua}" + ) + span_event(f"kvm_server_{action}", email=email, server=server.name) + span_success(200) - return Response({ - "message": f"서버가 {'활성화' if server.is_active else '비활성화'}되었습니다.", - "id": server.id, - "name": server.name, - "is_active": server.is_active, - }) + return Response({ + "message": f"서버가 {'활성화' if server.is_active else '비활성화'}되었습니다.", + "id": server.id, + "name": server.name, + "is_active": server.is_active, + }) class KVMServerSSHKeyView(APIView): @@ -1103,47 +1368,56 @@ class KVMServerSSHKeyView(APIView): def get(self, request, server_id): """복호화된 SSH 키와 접속 정보 조회""" - with tracer.start_as_current_span("KVMServerSSHKeyView GET") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="kvm.server.ssh_key") + email, ip, ua = get_request_info(request) + span_set_attribute("server.id", server_id) + with child_span("get_server", server_id=server_id): try: server = KVMServer.objects.get(id=server_id, user=request.user) except KVMServer.DoesNotExist: logger.warning( f"[KVM SERVER SSH KEY] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" ) + span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) + with child_span("check_key_exists", server_name=server.name): if not server.encrypted_private_key: logger.warning( f"[KVM SERVER SSH KEY] user={email} | server={server.name} | status=fail | reason=no_key | IP={ip} | UA={ua}" ) + span_error("no_key", 404) return Response( {"error": "SSH 키가 등록되어 있지 않습니다."}, status=404 ) - try: + try: + with child_span("decrypt_key", server_name=server.name): decrypted_key = server.decrypt_private_key() - logger.info( - f"[KVM SERVER SSH KEY] user={email} | server={server.name} | IP={ip} | UA={ua}" - ) - span.add_event("KVM server SSH key retrieved", attributes={"email": email, "server": server.name}) - return Response({ - "id": server.id, - "name": server.name, - "host": server.host, - "port": server.port, - "username": server.username, - "private_key": decrypted_key, - "libvirt_uri": server.get_libvirt_uri(), - }) - except Exception as e: - logger.exception( - f"[KVM SERVER SSH KEY] user={email} | server={server.name} | status=fail | reason=exception | IP={ip} | UA={ua}" - ) - return Response({"error": f"복호화 실패: {str(e)}"}, status=500) + logger.info( + f"[KVM SERVER SSH KEY] user={email} | server={server.name} | IP={ip} | UA={ua}" + ) + span_event("kvm_server_ssh_key_retrieved", email=email, server=server.name) + span_success(200) + + return Response({ + "id": server.id, + "name": server.name, + "host": server.host, + "port": server.port, + "username": server.username, + "private_key": decrypted_key, + "libvirt_uri": server.get_libvirt_uri(), + }) + except Exception as e: + logger.exception( + f"[KVM SERVER SSH KEY] user={email} | server={server.name} | status=fail | reason=exception | IP={ip} | UA={ua}" + ) + span_error(str(e), 500) + return Response({"error": f"복호화 실패: {str(e)}"}, status=500) class KVMServerSSHKeyUploadView(APIView): @@ -1158,57 +1432,71 @@ class KVMServerSSHKeyUploadView(APIView): def post(self, request, server_id): """SSH 키 업로드""" - with tracer.start_as_current_span("KVMServerSSHKeyUploadView POST") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="kvm.server.ssh_key.upload") + email, ip, ua = get_request_info(request) + span_set_attribute("server.id", server_id) + with child_span("get_server", server_id=server_id): server = self.get_server(request, server_id) if not server: + span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) - private_key = request.data.get("private_key") - key_name = request.data.get("key_name") + private_key = request.data.get("private_key") + key_name = request.data.get("key_name") + with child_span("validate_input", key_name=key_name): if not private_key: logger.warning( f"[KVM SERVER SSH UPLOAD] user={email} | server={server.name} | status=fail | reason=missing_key | IP={ip} | UA={ua}" ) + span_error("missing_key", 400) return Response( {"error": "private_key는 필수입니다."}, status=status.HTTP_400_BAD_REQUEST, ) - try: + try: + with child_span("encrypt_and_save", server_name=server.name): server.save_ssh_key(private_key, key_name) - logger.info( - f"[KVM SERVER SSH UPLOAD] user={email} | server={server.name} | IP={ip} | UA={ua}" - ) - span.add_event("KVM server SSH key uploaded", attributes={"email": email, "server": server.name}) - return Response({"message": "SSH 키가 저장되었습니다."}, status=201) - except Exception as e: - logger.exception( - f"[KVM SERVER SSH UPLOAD] user={email} | server={server.name} | status=fail | reason=exception | IP={ip} | UA={ua}" - ) - return Response({"error": f"저장 실패: {str(e)}"}, status=500) + + logger.info( + f"[KVM SERVER SSH UPLOAD] user={email} | server={server.name} | IP={ip} | UA={ua}" + ) + span_event("kvm_server_ssh_key_uploaded", email=email, server=server.name) + span_success(201) + return Response({"message": "SSH 키가 저장되었습니다."}, status=201) + except Exception as e: + logger.exception( + f"[KVM SERVER SSH UPLOAD] user={email} | server={server.name} | status=fail | reason=exception | IP={ip} | UA={ua}" + ) + span_error(str(e), 500) + return Response({"error": f"저장 실패: {str(e)}"}, status=500) def delete(self, request, server_id): """SSH 키 삭제""" - with tracer.start_as_current_span("KVMServerSSHKeyUploadView DELETE") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="kvm.server.ssh_key.delete") + email, ip, ua = get_request_info(request) + span_set_attribute("server.id", server_id) + with child_span("get_server", server_id=server_id): server = self.get_server(request, server_id) if not server: + span_error("not_found", 404) return Response({"error": "서버를 찾을 수 없습니다."}, status=404) + with child_span("delete_key", server_name=server.name): server.encrypted_private_key = None server.encrypted_private_key_name = None server.last_used_at = None server.save(update_fields=['encrypted_private_key', 'encrypted_private_key_name', 'last_used_at']) - logger.info( - f"[KVM SERVER SSH DELETE] user={email} | server={server.name} | IP={ip} | UA={ua}" - ) - span.add_event("KVM server SSH key deleted", attributes={"email": email, "server": server.name}) - return Response({"message": "SSH 키가 삭제되었습니다."}) + logger.info( + f"[KVM SERVER SSH DELETE] user={email} | server={server.name} | IP={ip} | UA={ua}" + ) + span_event("kvm_server_ssh_key_deleted", email=email, server=server.name) + span_success(200) + return Response({"message": "SSH 키가 삭제되었습니다."}) # ============================================ @@ -1225,13 +1513,16 @@ class GoogleLoginView(APIView): """ def post(self, request): - with tracer.start_as_current_span("GoogleLoginView POST") as span: - ip = request.META.get("REMOTE_ADDR", "unknown") - ua = request.META.get("HTTP_USER_AGENT", "unknown") + enrich_span(request, operation="auth.google.login") + ip = request.META.get("REMOTE_ADDR", "unknown") + ua = request.META.get("HTTP_USER_AGENT", "unknown") - credential = request.data.get("credential") + credential = request.data.get("credential") + + with child_span("validate_input"): if not credential: logger.warning(f"[GOOGLE LOGIN] status=fail | reason=missing_credential | IP={ip} | UA={ua}") + span_error("missing_credential", 400) return Response( {"error": "Google credential이 필요합니다."}, status=status.HTTP_400_BAD_REQUEST @@ -1240,12 +1531,14 @@ class GoogleLoginView(APIView): # GOOGLE_CLIENT_ID 확인 if not settings.GOOGLE_CLIENT_ID: logger.error(f"[GOOGLE LOGIN] status=fail | reason=missing_client_id | IP={ip} | UA={ua}") + span_error("missing_client_id", 500) return Response( {"error": "서버에 Google Client ID가 설정되지 않았습니다."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - try: + try: + with child_span("verify_google_token"): # Google ID Token 검증 idinfo = id_token.verify_oauth2_token( credential, @@ -1259,68 +1552,79 @@ class GoogleLoginView(APIView): name = idinfo.get("name", "") picture = idinfo.get("picture", "") - if not email: - logger.warning(f"[GOOGLE LOGIN] status=fail | reason=no_email | IP={ip} | UA={ua}") - return Response( - {"error": "Google 계정에서 이메일을 가져올 수 없습니다."}, - status=status.HTTP_400_BAD_REQUEST - ) + span_set_attribute("google.email", email) + span_set_attribute("google.id", google_id) - logger.info(f"[GOOGLE LOGIN] email={email} | google_id={google_id} | status=token_verified | IP={ip} | UA={ua}") + if not email: + logger.warning(f"[GOOGLE LOGIN] status=fail | reason=no_email | IP={ip} | UA={ua}") + span_error("no_email", 400) + return Response( + {"error": "Google 계정에서 이메일을 가져올 수 없습니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + logger.info(f"[GOOGLE LOGIN] email={email} | google_id={google_id} | status=token_verified | IP={ip} | UA={ua}") + + with child_span("find_user", email=email, google_id=google_id): # 1. social_id로 기존 사용자 조회 (Google로 가입한 사용자) user = CustomUser.objects.filter(social_provider="google", social_id=google_id).first() - if user: - # 기존 Google 계정 사용자 로그인 - logger.info(f"[GOOGLE LOGIN] user={email} | status=existing_social_user | IP={ip} | UA={ua}") - # 프로필 이미지 업데이트 - if picture and user.profile_image != picture: - user.profile_image = picture - user.save(update_fields=["profile_image"]) - else: + if user: + # 기존 Google 계정 사용자 로그인 + logger.info(f"[GOOGLE LOGIN] user={email} | status=existing_social_user | IP={ip} | UA={ua}") + span_set_attribute("login.type", "existing_social_user") + # 프로필 이미지 업데이트 + if picture and user.profile_image != picture: + user.profile_image = picture + user.save(update_fields=["profile_image"]) + else: + with child_span("check_existing_email", email=email): # 2. 이메일로 기존 사용자 조회 existing_user = CustomUser.objects.filter(email=email).first() - if existing_user: - # 기존 계정이 일반 가입인 경우 (social_provider가 없음) - if not existing_user.social_provider: - logger.info(f"[GOOGLE LOGIN] user={email} | status=need_password | reason=email_exists_need_link | IP={ip} | UA={ua}") - return Response( - { - "error": "이미 해당 이메일로 가입된 계정이 있습니다.", - "code": "EMAIL_EXISTS_NEED_LINK", - "message": "기존 계정 비밀번호를 입력하면 Google 계정을 연동할 수 있습니다.", - "email": email, - "google_id": google_id, - "google_name": name, - "google_picture": picture, - }, - status=status.HTTP_409_CONFLICT - ) - # 다른 소셜 로그인으로 가입한 경우 (예: kakao) - elif existing_user.social_provider != "google": - logger.warning(f"[GOOGLE LOGIN] user={email} | status=fail | reason=other_social_provider | IP={ip} | UA={ua}") - return Response( - { - "error": f"해당 이메일은 {existing_user.social_provider} 계정으로 가입되어 있습니다.", - "code": "OTHER_SOCIAL_PROVIDER", - "message": f"{existing_user.social_provider} 로그인을 이용해주세요." - }, - status=status.HTTP_409_CONFLICT - ) - # Google social_id가 다른 경우 (동일 이메일, 다른 Google 계정) - else: - logger.warning(f"[GOOGLE LOGIN] user={email} | status=fail | reason=different_google_account | IP={ip} | UA={ua}") - return Response( - { - "error": "다른 Google 계정으로 이미 가입되어 있습니다.", - "code": "DIFFERENT_GOOGLE_ACCOUNT" - }, - status=status.HTTP_409_CONFLICT - ) + if existing_user: + # 기존 계정이 일반 가입인 경우 (social_provider가 없음) + if not existing_user.social_provider: + logger.info(f"[GOOGLE LOGIN] user={email} | status=need_password | reason=email_exists_need_link | IP={ip} | UA={ua}") + span_event("email_exists_need_link", email=email) + return Response( + { + "error": "이미 해당 이메일로 가입된 계정이 있습니다.", + "code": "EMAIL_EXISTS_NEED_LINK", + "message": "기존 계정 비밀번호를 입력하면 Google 계정을 연동할 수 있습니다.", + "email": email, + "google_id": google_id, + "google_name": name, + "google_picture": picture, + }, + status=status.HTTP_409_CONFLICT + ) + # 다른 소셜 로그인으로 가입한 경우 (예: kakao) + elif existing_user.social_provider != "google": + logger.warning(f"[GOOGLE LOGIN] user={email} | status=fail | reason=other_social_provider | IP={ip} | UA={ua}") + span_error("other_social_provider", 409) + return Response( + { + "error": f"해당 이메일은 {existing_user.social_provider} 계정으로 가입되어 있습니다.", + "code": "OTHER_SOCIAL_PROVIDER", + "message": f"{existing_user.social_provider} 로그인을 이용해주세요." + }, + status=status.HTTP_409_CONFLICT + ) + # Google social_id가 다른 경우 (동일 이메일, 다른 Google 계정) else: - # 3. 신규 사용자 자동 회원가입 (기존 계정이 없는 경우) + logger.warning(f"[GOOGLE LOGIN] user={email} | status=fail | reason=different_google_account | IP={ip} | UA={ua}") + span_error("different_google_account", 409) + return Response( + { + "error": "다른 Google 계정으로 이미 가입되어 있습니다.", + "code": "DIFFERENT_GOOGLE_ACCOUNT" + }, + status=status.HTTP_409_CONFLICT + ) + else: + # 3. 신규 사용자 자동 회원가입 (기존 계정이 없는 경우) + with child_span("create_new_user", email=email) as span: # 고유한 name 생성 (이메일 앞부분 + 랜덤 문자열) base_name = name or email.split("@")[0] unique_name = base_name @@ -1343,127 +1647,41 @@ class GoogleLoginView(APIView): # 비밀번호 없이 사용 불가하게 설정 user.set_unusable_password() user.save() - logger.info(f"[GOOGLE LOGIN] user={email} | status=new_user_created_pending | IP={ip} | UA={ua}") + span.set_attribute("user.id", user.id) - # 승인 대기 응답 반환 - span.add_event("Google login - pending approval", attributes={"email": email}) - return Response( - { - "error": "회원가입이 완료되었습니다. 관리자 승인 후 로그인할 수 있습니다.", - "code": "PENDING_APPROVAL", - "message": "관리자 승인 대기 중입니다.", - }, - status=status.HTTP_403_FORBIDDEN - ) + logger.info(f"[GOOGLE LOGIN] user={email} | status=new_user_created_pending | IP={ip} | UA={ua}") - # 사용자 활성 상태 확인 - if not user.is_active: - logger.warning(f"[GOOGLE LOGIN] user={email} | status=fail | reason=inactive_user | IP={ip} | UA={ua}") + # 승인 대기 응답 반환 + span_event("google_login_pending_approval", email=email) return Response( - {"error": "비활성화된 계정입니다. 관리자에게 문의하세요."}, + { + "error": "회원가입이 완료되었습니다. 관리자 승인 후 로그인할 수 있습니다.", + "code": "PENDING_APPROVAL", + "message": "관리자 승인 대기 중입니다.", + }, status=status.HTTP_403_FORBIDDEN ) + # 사용자 활성 상태 확인 + if not user.is_active: + logger.warning(f"[GOOGLE LOGIN] user={email} | status=fail | reason=inactive_user | IP={ip} | UA={ua}") + span_error("inactive_user", 403) + return Response( + {"error": "비활성화된 계정입니다. 관리자에게 문의하세요."}, + status=status.HTTP_403_FORBIDDEN + ) + + with child_span("generate_token", user_id=user.id): # JWT 토큰 발급 (커스텀 클레임 포함) refresh = CustomTokenObtainPairSerializer.get_token(user) access = refresh.access_token - # 기존 로그인 응답과 동일한 형식 - span.add_event("Google login success", attributes={"email": email}) - logger.info(f"[GOOGLE LOGIN] user={email} | status=success | IP={ip} | UA={ua}") - - return Response({ - "access": str(access), - "refresh": str(refresh), - "user": { - "id": user.id, - "email": user.email, - "name": user.name, - "grade": user.grade, - "profile_image": user.profile_image, - "social_provider": user.social_provider, - } - }, status=status.HTTP_200_OK) - - except ValueError as e: - # 토큰 검증 실패 - logger.warning(f"[GOOGLE LOGIN] status=fail | reason=invalid_token | error={str(e)} | IP={ip} | UA={ua}") - span.add_event("Google login failed", attributes={"reason": "invalid_token", "error": str(e)}) - return Response( - {"error": "유효하지 않은 Google 토큰입니다."}, - status=status.HTTP_401_UNAUTHORIZED - ) - except Exception as e: - logger.exception(f"[GOOGLE LOGIN] status=fail | reason=exception | IP={ip} | UA={ua}") - span.add_event("Google login failed", attributes={"reason": "exception", "error": str(e)}) - return Response( - {"error": f"로그인 처리 중 오류가 발생했습니다: {str(e)}"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - -class GoogleLinkWithPasswordView(APIView): - """ - 비밀번호 확인 후 Google 계정 연동 - - 일반 가입 사용자가 Google 로그인 시도 시 비밀번호 확인 후 연동 - """ - - def post(self, request): - with tracer.start_as_current_span("GoogleLinkWithPasswordView POST") as span: - ip = request.META.get("REMOTE_ADDR", "unknown") - ua = request.META.get("HTTP_USER_AGENT", "unknown") - - email = request.data.get("email") - password = request.data.get("password") - google_id = request.data.get("google_id") - google_picture = request.data.get("google_picture", "") - - if not email or not password or not google_id: - return Response( - {"error": "email, password, google_id는 필수입니다."}, - status=status.HTTP_400_BAD_REQUEST - ) - - try: - user = CustomUser.objects.get(email=email) - except CustomUser.DoesNotExist: - logger.warning(f"[GOOGLE LINK] email={email} | status=fail | reason=user_not_found | IP={ip} | UA={ua}") - return Response( - {"error": "사용자를 찾을 수 없습니다."}, - status=status.HTTP_404_NOT_FOUND - ) - - # 비밀번호 확인 - if not user.check_password(password): - logger.warning(f"[GOOGLE LINK] user={email} | status=fail | reason=wrong_password | IP={ip} | UA={ua}") - return Response( - {"error": "비밀번호가 일치하지 않습니다."}, - status=status.HTTP_401_UNAUTHORIZED - ) - - # 이미 다른 소셜 계정으로 연동된 경우 - if user.social_provider and user.social_provider != "google": - return Response( - {"error": f"이미 {user.social_provider} 계정으로 연동되어 있습니다."}, - status=status.HTTP_409_CONFLICT - ) - - # Google 연동 - user.social_provider = "google" - user.social_id = google_id - if google_picture: - user.profile_image = google_picture - user.save(update_fields=["social_provider", "social_id", "profile_image"]) - - logger.info(f"[GOOGLE LINK] user={email} | status=success | IP={ip} | UA={ua}") - span.add_event("Google account linked", attributes={"email": email}) - - # JWT 토큰 발급 (커스텀 클레임 포함) - refresh = CustomTokenObtainPairSerializer.get_token(user) - access = refresh.access_token + # 기존 로그인 응답과 동일한 형식 + span_event("google_login_success", email=email) + span_success(200) + logger.info(f"[GOOGLE LOGIN] user={email} | status=success | IP={ip} | UA={ua}") return Response({ - "message": "Google 계정이 연동되었습니다.", "access": str(access), "refresh": str(refresh), "user": { @@ -1476,6 +1694,109 @@ class GoogleLinkWithPasswordView(APIView): } }, status=status.HTTP_200_OK) + except ValueError as e: + # 토큰 검증 실패 + logger.warning(f"[GOOGLE LOGIN] status=fail | reason=invalid_token | error={str(e)} | IP={ip} | UA={ua}") + span_event("google_login_failed", reason="invalid_token", error=str(e)) + span_error("invalid_token", 401) + return Response( + {"error": "유효하지 않은 Google 토큰입니다."}, + status=status.HTTP_401_UNAUTHORIZED + ) + except Exception as e: + logger.exception(f"[GOOGLE LOGIN] status=fail | reason=exception | IP={ip} | UA={ua}") + span_event("google_login_failed", reason="exception", error=str(e)) + span_error(str(e), 500) + return Response( + {"error": f"로그인 처리 중 오류가 발생했습니다: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class GoogleLinkWithPasswordView(APIView): + """ + 비밀번호 확인 후 Google 계정 연동 + - 일반 가입 사용자가 Google 로그인 시도 시 비밀번호 확인 후 연동 + """ + + def post(self, request): + enrich_span(request, operation="auth.google.link_with_password") + ip = request.META.get("REMOTE_ADDR", "unknown") + ua = request.META.get("HTTP_USER_AGENT", "unknown") + + email = request.data.get("email") + password = request.data.get("password") + google_id = request.data.get("google_id") + google_picture = request.data.get("google_picture", "") + + with child_span("validate_input", email=email): + if not email or not password or not google_id: + span_error("missing_fields", 400) + return Response( + {"error": "email, password, google_id는 필수입니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + with child_span("find_user", email=email): + try: + user = CustomUser.objects.get(email=email) + except CustomUser.DoesNotExist: + logger.warning(f"[GOOGLE LINK] email={email} | status=fail | reason=user_not_found | IP={ip} | UA={ua}") + span_error("user_not_found", 404) + return Response( + {"error": "사용자를 찾을 수 없습니다."}, + status=status.HTTP_404_NOT_FOUND + ) + + with child_span("verify_password", email=email): + # 비밀번호 확인 + if not user.check_password(password): + logger.warning(f"[GOOGLE LINK] user={email} | status=fail | reason=wrong_password | IP={ip} | UA={ua}") + span_error("wrong_password", 401) + return Response( + {"error": "비밀번호가 일치하지 않습니다."}, + status=status.HTTP_401_UNAUTHORIZED + ) + + # 이미 다른 소셜 계정으로 연동된 경우 + if user.social_provider and user.social_provider != "google": + span_error("already_linked_other", 409) + return Response( + {"error": f"이미 {user.social_provider} 계정으로 연동되어 있습니다."}, + status=status.HTTP_409_CONFLICT + ) + + with child_span("link_google_account", user_id=user.id, google_id=google_id): + # Google 연동 + user.social_provider = "google" + user.social_id = google_id + if google_picture: + user.profile_image = google_picture + user.save(update_fields=["social_provider", "social_id", "profile_image"]) + + logger.info(f"[GOOGLE LINK] user={email} | status=success | IP={ip} | UA={ua}") + span_event("google_account_linked", email=email) + + with child_span("generate_token", user_id=user.id): + # JWT 토큰 발급 (커스텀 클레임 포함) + refresh = CustomTokenObtainPairSerializer.get_token(user) + access = refresh.access_token + + span_success(200) + return Response({ + "message": "Google 계정이 연동되었습니다.", + "access": str(access), + "refresh": str(refresh), + "user": { + "id": user.id, + "email": user.email, + "name": user.name, + "grade": user.grade, + "profile_image": user.profile_image, + "social_provider": user.social_provider, + } + }, status=status.HTTP_200_OK) + class GoogleLinkView(APIView): """ @@ -1485,12 +1806,15 @@ class GoogleLinkView(APIView): permission_classes = [IsAuthenticated] def post(self, request): - with tracer.start_as_current_span("GoogleLinkView POST") as span: - email, ip, ua = get_request_info(request) - user = request.user + enrich_span(request, request.user, operation="auth.google.link") + email, ip, ua = get_request_info(request) + user = request.user - credential = request.data.get("credential") + credential = request.data.get("credential") + + with child_span("validate_input"): if not credential: + span_error("missing_credential", 400) return Response( {"error": "Google credential이 필요합니다."}, status=status.HTTP_400_BAD_REQUEST @@ -1498,6 +1822,7 @@ class GoogleLinkView(APIView): # 이미 Google 연동된 경우 if user.social_provider == "google": + span_error("already_linked_google", 409) return Response( {"error": "이미 Google 계정이 연동되어 있습니다."}, status=status.HTTP_409_CONFLICT @@ -1505,18 +1830,21 @@ class GoogleLinkView(APIView): # 다른 소셜 계정으로 연동된 경우 if user.social_provider: + span_error("already_linked_other", 409) return Response( {"error": f"이미 {user.social_provider} 계정으로 연동되어 있습니다."}, status=status.HTTP_409_CONFLICT ) if not settings.GOOGLE_CLIENT_ID: + span_error("missing_client_id", 500) return Response( {"error": "서버에 Google Client ID가 설정되지 않았습니다."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - try: + try: + with child_span("verify_google_token"): # Google ID Token 검증 idinfo = id_token.verify_oauth2_token( credential, @@ -1528,22 +1856,29 @@ class GoogleLinkView(APIView): google_email = idinfo.get("email") picture = idinfo.get("picture", "") - # 이메일 일치 확인 - if google_email != user.email: - logger.warning(f"[GOOGLE LINK] user={email} | google_email={google_email} | status=fail | reason=email_mismatch | IP={ip} | UA={ua}") - return Response( - {"error": "로그인된 계정의 이메일과 Google 이메일이 일치하지 않습니다."}, - status=status.HTTP_400_BAD_REQUEST - ) + span_set_attribute("google.email", google_email) + span_set_attribute("google.id", google_id) + # 이메일 일치 확인 + if google_email != user.email: + logger.warning(f"[GOOGLE LINK] user={email} | google_email={google_email} | status=fail | reason=email_mismatch | IP={ip} | UA={ua}") + span_error("email_mismatch", 400) + return Response( + {"error": "로그인된 계정의 이메일과 Google 이메일이 일치하지 않습니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + with child_span("check_google_already_linked", google_id=google_id): # 해당 Google 계정이 다른 사용자에게 이미 연동되어 있는지 확인 existing_google_user = CustomUser.objects.filter(social_provider="google", social_id=google_id).exclude(id=user.id).first() if existing_google_user: + span_error("google_already_linked_other_user", 409) return Response( {"error": "해당 Google 계정은 이미 다른 사용자에게 연동되어 있습니다."}, status=status.HTTP_409_CONFLICT ) + with child_span("link_google_account", user_id=user.id): # Google 연동 user.social_provider = "google" user.social_id = google_id @@ -1551,27 +1886,30 @@ class GoogleLinkView(APIView): user.profile_image = picture user.save(update_fields=["social_provider", "social_id", "profile_image"]) - logger.info(f"[GOOGLE LINK] user={email} | status=success | IP={ip} | UA={ua}") - span.add_event("Google account linked", attributes={"email": email}) + logger.info(f"[GOOGLE LINK] user={email} | status=success | IP={ip} | UA={ua}") + span_event("google_account_linked", email=email) + span_success(200) - return Response({ - "message": "Google 계정이 연동되었습니다.", - "social_provider": user.social_provider, - "profile_image": user.profile_image, - }, status=status.HTTP_200_OK) + return Response({ + "message": "Google 계정이 연동되었습니다.", + "social_provider": user.social_provider, + "profile_image": user.profile_image, + }, status=status.HTTP_200_OK) - except ValueError as e: - logger.warning(f"[GOOGLE LINK] user={email} | status=fail | reason=invalid_token | error={str(e)} | IP={ip} | UA={ua}") - return Response( - {"error": "유효하지 않은 Google 토큰입니다."}, - status=status.HTTP_401_UNAUTHORIZED - ) - except Exception as e: - logger.exception(f"[GOOGLE LINK] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}") - return Response( - {"error": f"연동 처리 중 오류가 발생했습니다: {str(e)}"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + except ValueError as e: + logger.warning(f"[GOOGLE LINK] user={email} | status=fail | reason=invalid_token | error={str(e)} | IP={ip} | UA={ua}") + span_error("invalid_token", 401) + return Response( + {"error": "유효하지 않은 Google 토큰입니다."}, + status=status.HTTP_401_UNAUTHORIZED + ) + except Exception as e: + logger.exception(f"[GOOGLE LINK] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}") + span_error(str(e), 500) + return Response( + {"error": f"연동 처리 중 오류가 발생했습니다: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) class GoogleUnlinkView(APIView): @@ -1582,12 +1920,14 @@ class GoogleUnlinkView(APIView): permission_classes = [IsAuthenticated] def post(self, request): - with tracer.start_as_current_span("GoogleUnlinkView POST") as span: - email, ip, ua = get_request_info(request) - user = request.user + enrich_span(request, request.user, operation="auth.google.unlink") + email, ip, ua = get_request_info(request) + user = request.user + with child_span("validate_unlink", user_id=user.id): # Google 연동 확인 if user.social_provider != "google": + span_error("not_linked_google", 400) return Response( {"error": "Google 계정이 연동되어 있지 않습니다."}, status=status.HTTP_400_BAD_REQUEST @@ -1595,24 +1935,27 @@ class GoogleUnlinkView(APIView): # 비밀번호 설정 여부 확인 (소셜 전용 계정인 경우 연동 해제 불가) if not user.has_usable_password(): + span_error("no_password_set", 400) return Response( {"error": "비밀번호가 설정되어 있지 않아 연동 해제할 수 없습니다. 먼저 비밀번호를 설정해주세요."}, status=status.HTTP_400_BAD_REQUEST ) + with child_span("unlink_google_account", user_id=user.id): # 연동 해제 user.social_provider = None user.social_id = None # profile_image는 유지 (사용자가 원하면 별도로 삭제) user.save(update_fields=["social_provider", "social_id"]) - logger.info(f"[GOOGLE UNLINK] user={email} | status=success | IP={ip} | UA={ua}") - span.add_event("Google account unlinked", attributes={"email": email}) + logger.info(f"[GOOGLE UNLINK] user={email} | status=success | IP={ip} | UA={ua}") + span_event("google_account_unlinked", email=email) + span_success(200) - return Response({ - "message": "Google 계정 연동이 해제되었습니다.", - "social_provider": None, - }, status=status.HTTP_200_OK) + return Response({ + "message": "Google 계정 연동이 해제되었습니다.", + "social_provider": None, + }, status=status.HTTP_200_OK) # ============================================ @@ -1641,88 +1984,94 @@ class SiteSettingsView(APIView): def get(self, request): """사이트 설정 조회 (공개)""" - with tracer.start_as_current_span("SiteSettingsView GET") as span: - settings_obj = SiteSettings.get_settings() - span.add_event("Site settings retrieved") + enrich_span(request, operation="site.settings.get") - return Response({ - # 소셜 로그인 설정 - "google_login_enabled": settings_obj.google_login_enabled, - # 비밀번호 정책 설정 - "password_min_length": settings_obj.password_min_length, - "password_max_length": settings_obj.password_max_length, - "password_require_uppercase": settings_obj.password_require_uppercase, - "password_require_lowercase": settings_obj.password_require_lowercase, - "password_require_digit": settings_obj.password_require_digit, - "password_require_special": settings_obj.password_require_special, - "password_special_chars": settings_obj.password_special_chars, - "password_expiry_days": settings_obj.password_expiry_days, - "password_expiry_warning_days": settings_obj.password_expiry_warning_days, - "password_history_count": settings_obj.password_history_count, - # 로그인 보안 설정 - "login_max_failures": settings_obj.login_max_failures, - "login_lockout_minutes": settings_obj.login_lockout_minutes, - # 메타 정보 - "updated_at": settings_obj.updated_at.isoformat() if settings_obj.updated_at else None, - }) + with child_span("get_settings"): + settings_obj = SiteSettings.get_settings() + + span_event("site_settings_retrieved") + span_success(200) + + return Response({ + # 소셜 로그인 설정 + "google_login_enabled": settings_obj.google_login_enabled, + # 비밀번호 정책 설정 + "password_min_length": settings_obj.password_min_length, + "password_max_length": settings_obj.password_max_length, + "password_require_uppercase": settings_obj.password_require_uppercase, + "password_require_lowercase": settings_obj.password_require_lowercase, + "password_require_digit": settings_obj.password_require_digit, + "password_require_special": settings_obj.password_require_special, + "password_special_chars": settings_obj.password_special_chars, + "password_expiry_days": settings_obj.password_expiry_days, + "password_expiry_warning_days": settings_obj.password_expiry_warning_days, + "password_history_count": settings_obj.password_history_count, + # 로그인 보안 설정 + "login_max_failures": settings_obj.login_max_failures, + "login_lockout_minutes": settings_obj.login_lockout_minutes, + # 메타 정보 + "updated_at": settings_obj.updated_at.isoformat() if settings_obj.updated_at else None, + }) def patch(self, request): """사이트 설정 수정 (관리자 전용)""" - with tracer.start_as_current_span("SiteSettingsView PATCH") as span: - email, ip, ua = get_request_info(request) + enrich_span(request, request.user, operation="site.settings.update") + email, ip, ua = get_request_info(request) + + with child_span("get_settings"): settings_obj = SiteSettings.get_settings() - # 업데이트할 필드 목록 - updatable_fields = [ - 'google_login_enabled', - 'password_min_length', - 'password_max_length', - 'password_require_uppercase', - 'password_require_lowercase', - 'password_require_digit', - 'password_require_special', - 'password_special_chars', - 'password_expiry_days', - 'password_expiry_warning_days', - 'password_history_count', - 'login_max_failures', - 'login_lockout_minutes', - ] + # 업데이트할 필드 목록 + updatable_fields = [ + 'google_login_enabled', + 'password_min_length', + 'password_max_length', + 'password_require_uppercase', + 'password_require_lowercase', + 'password_require_digit', + 'password_require_special', + 'password_special_chars', + 'password_expiry_days', + 'password_expiry_warning_days', + 'password_history_count', + 'login_max_failures', + 'login_lockout_minutes', + ] - updated_fields = [] - for field in updatable_fields: - if field in request.data: - setattr(settings_obj, field, request.data[field]) - updated_fields.append(field) + updated_fields = [] + for field in updatable_fields: + if field in request.data: + setattr(settings_obj, field, request.data[field]) + updated_fields.append(field) - if updated_fields: + if updated_fields: + with child_span("save_settings", fields=str(updated_fields)): settings_obj.save() - logger.info( - f"[SITE SETTINGS UPDATE] admin={email} | fields={updated_fields} | IP={ip} | UA={ua}" - ) - span.add_event("Site settings updated", attributes={ - "admin": email, - "fields": str(updated_fields) - }) - return Response({ - "message": "설정이 저장되었습니다.", - # 소셜 로그인 설정 - "google_login_enabled": settings_obj.google_login_enabled, - # 비밀번호 정책 설정 - "password_min_length": settings_obj.password_min_length, - "password_max_length": settings_obj.password_max_length, - "password_require_uppercase": settings_obj.password_require_uppercase, - "password_require_lowercase": settings_obj.password_require_lowercase, - "password_require_digit": settings_obj.password_require_digit, - "password_require_special": settings_obj.password_require_special, - "password_special_chars": settings_obj.password_special_chars, - "password_expiry_days": settings_obj.password_expiry_days, - "password_expiry_warning_days": settings_obj.password_expiry_warning_days, - "password_history_count": settings_obj.password_history_count, - # 로그인 보안 설정 - "login_max_failures": settings_obj.login_max_failures, - "login_lockout_minutes": settings_obj.login_lockout_minutes, - # 메타 정보 - "updated_at": settings_obj.updated_at.isoformat() if settings_obj.updated_at else None, - }) + logger.info( + f"[SITE SETTINGS UPDATE] admin={email} | fields={updated_fields} | IP={ip} | UA={ua}" + ) + span_event("site_settings_updated", admin=email, fields=str(updated_fields)) + + span_success(200) + return Response({ + "message": "설정이 저장되었습니다.", + # 소셜 로그인 설정 + "google_login_enabled": settings_obj.google_login_enabled, + # 비밀번호 정책 설정 + "password_min_length": settings_obj.password_min_length, + "password_max_length": settings_obj.password_max_length, + "password_require_uppercase": settings_obj.password_require_uppercase, + "password_require_lowercase": settings_obj.password_require_lowercase, + "password_require_digit": settings_obj.password_require_digit, + "password_require_special": settings_obj.password_require_special, + "password_special_chars": settings_obj.password_special_chars, + "password_expiry_days": settings_obj.password_expiry_days, + "password_expiry_warning_days": settings_obj.password_expiry_warning_days, + "password_history_count": settings_obj.password_history_count, + # 로그인 보안 설정 + "login_max_failures": settings_obj.login_max_failures, + "login_lockout_minutes": settings_obj.login_lockout_minutes, + # 메타 정보 + "updated_at": settings_obj.updated_at.isoformat() if settings_obj.updated_at else None, + }) diff --git a/version b/version index 62b34e3..a82e5f5 100644 --- a/version +++ b/version @@ -1 +1 @@ -v0.0.31 +v0.0.32 \ No newline at end of file