הקשר משתמש מבלי לשבור את העיצוב שלך

TL;DR: העברת מזהי משתמשים דרך הקוד שלך עובדת – אך MDC מאפשר ללוגים שלך לשאת הקשר משתמש מבלי לזהם את הלוגיקה העסקית.

בחלק 1 של סדרה זו (→ מדוע לוגים מפסיקים להיות שימושיים במערכות אמיתיות), ראינו כיצד דפוסי לוגינג בסיסיים נשברים ברגע שנכנסים לתמונה מקביליות, השהיות ומשתמשים מרובים.

בשלב זה, הלוגים עדיין היו נכונים מבחינה טכנית, אך הם כבר לא אפשרו לנו לשחזר מה קרה למשתמש או לבקשה בודדים. בואו נעשה את הצעד הבא.

מזהה משתמש כהקשר אבחוני

ראשית, שקלו את משתמש האפליקציה. ייתכן שהוא אינו עמית שלנו. ייתכן שהוא אינו עובד בחברה שלנו – או אפילו אינו ניתן להשגה. נניח שהוא מדווח על בעיה במעקב באגים ומצרף את מזהה המערכת שלו. מזהה זה יכול להיות:

  • שם משתמש,
  • מספר מערכת,
  • או כתובת דוא"ל.

בדוגמה שלנו, זו כתובת דוא"ל: mamian@zoo.gov.eu

כדי לעשות שימוש במידע זה, אנו זקוקים לדרך לסנן לוגים לפי מזהה משתמש.

אם קוד בעייתי יכול להיות מופעל רק על ידי משתמש מאומת, אנו כבר קרובים יותר למטרה. מרגע שמשתמש מתחבר ומאומת, כל מה שקורה מבוצע בהקשר שלו.

אז בואו ננסה להשתמש בזה.

❌ בעיה: העברת userId לכל מקום

הגישה הישירה ביותר היא להעביר userId באופן מפורש ולכלול אותו בכל הצהרת לוג:

public void modifyOrder(String userId) { try { var priceOfItem = getPriceOfItem(userId); var quantity = getQuantity(userId); var totalPrice = calculateTotalPrice(userId, priceOfItem, quantity); updateWith(totalPrice); } catch (OrderException e) { log.error("Failure: user (id = {})", userId); } } private int getPriceOfItem(String userId) { var priceOfItem = repository.getPriceOfItem(userId); logger.info("User (id = {}) called: Price of item = {}", userId, priceOfItem); return priceOfItem; } private int getQuantity(String userId) { var quantity = repository.getQuantity(userId); logger.info("User (id = {}) called: Items in order = [{}]", userId, quantity); return quantity; } private int calculateTotalPrice(String userId, int priceOfItem, int quantity) { var totalPrice = calculator.calculate(userId, price, quantity); logger.info("User (id = {}) called: Total price = {}", userId, totalPrice); return totalPrice; }

גישה זו עובדת, אך היא הופכת לבעייתית במהירות.

מעבר userId הוספה לכל מתודה הופכת לתכנון לקוי:

  • מה אם בהמשך נרצה להחליףמחרוזת דוא"ל עם UUID של מסד נתונים אומזהה משתמש אובייקט ערך?
  • מה יקרה אם נשכח להעביר אותו לשיטה אחת, או נשכח לתעד אותו בלוג?
  • מה יקרה אם שיקולי לוגים יחלחלו לתוך לוגיקת דומיין נקייה אחרת?

בפועל, פתרון זה הופך למתיש במהירות מפתיעה.

✅ פתרון: Mapped Diagnostic Context (MDC)

Mapped Diagnostic Context (MDC) הוא כלי קטן אך רב-עוצמה המובנה בכל ספריות הלוגינג הפופולריות של Java. הוא מאפשר לנו לאחסן ערכי אבחון בזיכרון מקומי לתהליכון (thread-local), בנפרד עבור כל תהליכון הרצה.

הרעיון פשוט:

  • כאשר ההרצה מתחילה, אנו מכניסים נתונים דיאגנוסטיים שימושיים ל-MDC,
  • כשהביצוע מסתיים, אנו מנקים את MDC.

מכיוון ש-MDC הוא מקומי לתהליכון:

  • נתונים דיאגנוסטיים מהרצה אחת אינם יכולים לדלוף לאחרת,
  • אך שכחה לנקות אותו היא מסוכנת, מכיוון שתהליכונים נעשים בהם שימוש חוזר.

היכן MDC משתלב באופן טבעי

ביישומים המבוססים על Spring, גבול הביצוע הנפוץ ביותר הוא בקשת HTTP. זה הופך את הטיפול בבקשות למקום אידיאלי לאתחול MDC בתחילת הדרך, ולניקויו כאשר התגובה נשלחת.

תבנית דומה חלה על:

  • משימות מתוזמנות,
  • צרכני תור,
  • או כל הרצה עם התחלה וסיום ברורים.

הנה דוגמה שמטפלת נכון במחזור החיים של MDC:

  1. אתחול מסנן המוזרק לאחר מכן באופן אוטומטי לכל זרימת קריאות REST API.
  2. איסוף מידע אודות משתמש מחובר.
  3. הכניסו את המשתמש המחובר ל-MDC בהתחלה, ואז נקו את ה-MDC בסוף.
  4. ה לבסוף ה-block קריטי כאן – הוא מבטיח ש-MDC תמיד מנוקה, גם כאשר מתרחשת חריגה.
@Component public class GlobalLoggingRequestFilter extends OncePerRequestFilter { private final AuthenticationService authService; public GlobalLoggingRequestFilter(AuthenticationService authService) { this.authService = authService; } private Optional<String> extractUserIdFrom(HttpServletRequest request) { // In a real application, this service call would parse JWT // or look up session data based on the request. return authService.extractUserId(request); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { // init MDC with user ID extractUserIdFrom(request).ifPresent(userId -> MDC.put("userId", userId)); // let the business logic be executed filterChain.doFilter(request, response); } finally { // finish and clean MDC context MDC.clear(); } } }

❌ בעיה: צימוד MDC עם קוד עסקי

בשלב זה, מזהה המשתמש זמין דרך MDC, כך שאין עוד צורך להעביר אותו דרך פרמטרים של מתודה.

אולם, אם ניגש ל-MDC באופן ידני בכל הצהרת לוג, אנו מציגים צורה חדשה של צימוד:

public void modifyOrder() { try { var priceOfItem = getPriceOfItem(); var quantity = getQuantity(); var totalPrice = calculateTotalPrice(priceOfItem, quantity); updateWith(totalPrice); } catch (OrderException e) { log.error("Failure: user (id = {})", MDC.get("userId")); } } private int getPriceOfItem() { var priceOfItem = repository.getPriceOfItem(); logger.info("User (id = {}) called: Price of item = {}", MDC.get("userId"), priceOfItem); return priceOfItem; } private int getQuantity() { var quantity = repository.getQuantity(); logger.info("User (id = {}) called: Items in order = [{}]", MDC.get("userId"), quantity); return quantity; } private int calculateTotalPrice(int priceOfItem, int quantity) { var totalPrice = calculator.calculate(price, quantity); logger.info("User (id = {}) called: Total price = {}", MDC.get("userId"), totalPrice); return totalPrice; }

הדבר עדיין מזהם את קוד הלוגינג, מצמיד לוגיקה עסקית לתשתית הלוגינג ודורש משמעת בכל מקום.

✅ פתרון: תן ל-logger לקרוא MDC עבורנו

למרבה המזל, Logback יכול לקרוא ערכי MDC באופן אוטומטי. עלינו רק לעדכן את תבנית הרישום:

%d{ISO8601} %-5level ${PID} [t=%thread] %-48logger{48} %msg : [u=%X{userId}] %n%throwable | | | +--- access variable from MDC | …uses conversion specifier %X | …accessing userId in our case | +--- visual separator

Here, %X{userId} מורה ל-Logback לקרוא את ה- userId ערך מ-MDC ולהוסיפו לכל שורת לוג. אין צורך בשינויים בקריאות הלוג.

קוד עסקי נקי יותר, לוגים עשירים יותר

עם תצורה זו במקום, קוד האפליקציה הופך שוב לנקי:

public void modifyOrder() { try { var priceOfItem = getPriceOfItem(); var quantity = getQuantity(); var totalPrice = calculateTotalPrice(priceOfItem, quantity); updateWith(totalPrice); } catch (OrderException e) { log.error("Failure"); } } private int getPriceOfItem() { var priceOfItem = repository.getPriceOfItem(userId); logger.info("Price of item = {}", priceOfItem); return priceOfItem; } private int getQuantity() { var quantity = repository.getQuantity(userId); logger.info("Items in order = [{}]", quantity); return quantity; } private int calculateTotalPrice(int priceOfItem, int quantity) { var totalPrice = calculator.calculate(userId, price, quantity); logger.info("Total price = {}", totalPrice); return totalPrice; }

והיומנים כוללים באופן אוטומטי הקשר משתמש:

2025-10-20 00:29:11,300 INFO 214731 [t=Thread-1] demo.PrintingDemo Price of item = 17 : [u=ferdynand@oo.pl2025-10-20 00:29:11,356 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [3] : [u=ferdynand@oo.pl] 2025-10-20 00:29:11,441 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 51 : [u=ferdynand@oo.pl] 2025-10-20 00:29:14,540 INFO 214731 [t=Thread-1] demo.PrintingDemo Price of item = 76 : [u=mamian@zoo.gov.eu2025-10-20 00:29:14,682 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [2] : [u=mamian@zoo.gov.eu] 2025-10-20 00:29:14,810 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = -152 : [u=mamian@zoo.gov.eu2025-10-20 00:29:16,884 INFO 214731 [t=Thread-1] demo.PrintingDemo מחיר הפריט = 81 : [u=mr.1337@pwnd.it2025-10-20 00:29:16,959 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [1] : [u=mr.1337@pwnd.it] 2025-10-20 00:29:16,979 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 81 : [u=mr.1337@pwnd.it]

סינון לוגים לפי מזהה משתמש חושף מיד את ההרצה הבעייתית:

2025-10-20 00:29:14,540 INFO 214731 [t=Thread-1] demo.PrintingDemo Price of item = 76 : [u=mamian@zoo.gov.eu2025-10-20 00:29:14,682 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [2] : [u=mamian@zoo.gov.eu] 2025-10-20 00:29:14,810 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = -152 : [u=mamian@zoo.gov.eu]

משהו בבירור אינו תקין – וכעת קל לזהות זאת.

מזהה משתמש לעומת GDPR

בהתאם לענף, תיעוד מזהה משתמש עשוי להיות מוגבל על ידי GDPR או תקנות אחרות.

במקרים כאלה, עדיין ניתן לרשום מטא-נתונים שאינם מזהים, כגון:

  • תפקיד משתמש,
  • שפה נבחרת,
  • מדינה,
  • מצב ממשק משתמש,
  • סוג התקן.

לדוגמה, במקום:

2025-10-20 00:29:11,441 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 51 : [u=ferdynand@oo.pl]

ניתן לרשום ביומן:

2025-10-20 00:29:11,441 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 51 : [role=CUSTOMER,lang=pl_PL,lc=pl,mode=DARK,dvc=MOBILE]

בפועל, זה בדרך כלל מספק הקשר מספק כדי להבין את הבעיה, מבלי לזהות אדם ספציפי.

לאן זה מוביל אותנו – ולאן לא

סינון לוגים לפי מזהה משתמש פותר בעיות ייצור אמיתיות רבות. הוא מאפשר לנו:

  • לתאם אירועים עבור משתמש בודד,
  • זיהוי מהיר של התנהגות שגויה,
  • צמצום הזמן המושקע בשחזור ידני של נתיבי ביצוע.

עם זאת, לגישה זו עדיין יש מגבלות. מה קורה כאשר:

  • משתמשים אינם מאומתים,
  • הביצוע משתרע על פני מספר תהליכונים,
  • העבודה מבוצעת באופן אסינכרוני?

בואו נשפר את הפתרון שלנו שוב, מה דעתכם?

מה הלאה

→ חלק 3: מזהי קורלציה ועקיבות מקצה לקצה