.. _webapp-mcp-analytics:
MCP Analytics (מדידת השימוש בכלי ה-MCP)
========================================
:summary: מסך אדמין שמציג את נתוני השימוש בכלי ה-MCP מתוך PostHog — בריאות הכלים, עלות הניווט בריפו, ויכולות שסוכנים ביקשו — עם מצבי הכשל ומשתני הסביבה שהוא דורש.
מה זה
-----
שרת ה-MCP מדווח ל-PostHog אירוע על כל קריאת כלי (:ref:`mcp-analytics`). המסך
``/admin/mcp`` קורא את הנתונים האלה חזרה ומציג אותם בוובאפ, כדי שלא יידרש
להיכנס ל-PostHog בשביל לענות על שלוש שאלות:
- באילו כלים באמת משתמשים, כמה מהקריאות נכשלות — **ובאיזו הודעה**.
- כמה קריאות עולה לסוכן לנווט בריפו, ומה הסוכן ניסה להשיג באותו סשן.
- אילו יכולות סוכנים ביקשו ולא קיבלו.
הטבלה של בריאות הכלים אומרת "20% שגיאות". מתחתיה, באותו טאב, יושבות הקריאות
שנכשלו עצמן — כלי, זמן, לקוח וההודעה. בלעדיהן העמוד הוא דוח מסירה בלי פתק
המסירה: מספר כשלים בלי שם השדה שנדחה.
**דרך ה-UI:** דף **Settings** ← **כלי אדמין** ← **MCP Analytics**. ישירות:
``GET /admin/mcp``. הדף זמין רק לאדמינים (``@admin_required``).
.. _mcp-analytics-endpoints:
מאיפה הנתונים מגיעים
---------------------
לא משאילתה שכתובה אצלנו. הקוד קורא ל-**endpoints שמורים** ב-PostHog —
שאילתות שחיות שם ולא בריפו:
.. list-table::
:header-rows: 1
:widths: 30 45 25
* - אנדפוינט
- מה מחזיר
- טווח
* - ``ck_mcp_tool_health``
- שורה לכל כלי: קריאות, שגיאות, אחוז, p50, p95, סשנים, נראה לאחרונה
- 30 יום
* - ``ck_mcp_tool_failures``
- שורה לכל קריאה שנכשלה: זמן, כלי, לקוח, סוג השגיאה וההודעה עצמה
- 30 יום
* - ``ck_mcp_navigation_cost_v2``
- שורה לכל סשן: קריאות, חיפושים, **אאוטליין**, **קריאת תוכן**, שגיאות,
זמן כולל, וה**כוונה** שהסוכן כתב
- 30 יום
* - ``ck_mcp_missing_capabilities``
- שורה לכל דיווח מהכלי הווירטואלי ``get_more_tools``
- 90 יום
.. note::
``ck_mcp_navigation_cost`` (הגרסה הראשונה, עם עמודת ``file_reads`` המאוחדת)
**נשאר חי ב-PostHog** ואינו נמחק — הוא הבסיס להשוואה מול הטור המפוצל.
העמוד פשוט אינו קורא לו יותר. שם האנדפוינט הוא קבוע בקוד, ולכן החלפת
הגרסה היא הדבר היחיד בעמוד הזה שכן דורש דיפלוי.
ההפרדה הזו היא העיקר: **שינוי שאילתה אינו דורש דיפלוי.** מי שרוצה לשנות טווח,
לסנן, או להוסיף עמודה — עושה זאת ב-PostHog, והעמוד מציג את מה שחזר. הקוד אינו
מכיר את ה-SQL, ולכן גם אינו יכול להיסחף ממנו.
הטווח של ``ck_mcp_missing_capabilities`` ארוך יותר בכוונה: דיווח על כלי חסר הוא
אירוע נדיר, וחלון של 30 יום היה מוחק אותו לפני שמישהו הספיק לקרוא.
.. note::
**הטבלה של "כלים חסרים" ריקה עד שסוכן ידווח, וזה מצב תקין.** האיסוף פעיל —
``report_missing=True`` ב-``mcp_server/analytics.py`` מוסיף את הכלי הווירטואלי
``get_more_tools`` — אבל הצינור הפתוח אינו אומר שיש מה להציג. העמוד מבחין בין
השניים במפורש: "אף סוכן עוד לא דיווח" הוא **הצלחה** עם אפס שורות, ולא כשל.
ההבחנה הזו נבדקת ב-``tests/test_admin_mcp_page.py``, כי בלעדיה תקלה תיראה
בדיוק כמו שקט.
תקרת שורות ומאיפה מגיעה הספירה
--------------------------------
``ck_mcp_navigation_cost_v2`` מחזיר שורה לכל סשן, ובלי הגבלה הוא מחזיר את כולן —
כמות שגדלה בלי תקרה. הקוד מבקש ``limit`` (בגוף הבקשה, ראו למטה), ולכן מוצג רק
החלק העליון של הרשימה, שכבר ממוינת ``ORDER BY started DESC``.
הספירה המלאה **אינה** מגיעה מהמונה של ה-API: אין בתשובה שדה כזה. היא נגזרת
בשאילתה עצמה ב-``count() OVER () AS total_sessions``, ולכן כל שורה נושאת את
הספירה של כל הסשנים ולא של מה שהוחזר. כך שורת החיווי אומרת "מוצגים N מתוך M"
בלי לשלם על העברת כל השורות.
ל-``ck_mcp_tool_failures`` יש תקרה משלו, נמוכה יותר: הוא יושב בתוך טאב קיים
ולא לבד. אין לו עמודת ספירה כוללת, ולכן מה שאומר שהרשימה נחתכה הוא ``hasMore``
בתשובה — והעמוד מציג "יש עוד" לפיו.
.. important::
**התקציב העליון לא זז כשנוסף האנדפוינט הרביעי, וזה לא פספוס.** הוא נגזר
מהמקרה הגרוע של קריאה **בודדת** (``connect + read``), וקריאה נוספת שרצה
לצידה אינה מאריכה אותו. ה-pool מקבל worker לכל אנדפוינט, ולכן הרביעי אינו
ממתין בתור. הרשימה עצמה יושבת ב-``DASHBOARD_ENDPOINTS``, וגם גודל ה-pool
וגם הבדיקות נגזרים ממנה — כדי שהוספת אנדפוינט חמישי לא תשבור את הבטחת
המקבול בשקט.
.. _mcp-analytics-marking:
סימון "טופל" / "נדחה" בכלים חסרים
----------------------------------
לכל שורה בטאב "כלים חסרים" יש שני כפתורים: ✓ לסימון שהדיווח טופל, ו-✗ לסימון שנדחה. לחיצה על כפתור שכבר פעיל מבטלת את הסימון וחוזרת ל"לא טופל", ושני הכפתורים דוחקים זה את זה — דיווח אינו יכול להיות גם טופל וגם נדחה.
**הסימון חי בדפדפן, לא בשרת.** השורות מגיעות מ-PostHog ואי אפשר לכתוב אליהן, ולכן מה שנשמר הוא שכבה דקה מעליהן ב-``localStorage`` תחת המפתח ``mcpMissingMarks`` — בדיוק כמו באנר "שגיאות חדשות" באותו עמוד. המשמעות המעשית: הסימון הוא **של הדפדפן הזה בלבד**. במכשיר אחר, או אחרי ניקוי נתוני אתר, הוא לא יהיה שם.
הזהות של שורה לצורך הסימון היא ``<זמן הדיווח>|<סשן>``, מה-``data-cap-key`` שנכתב על ``
``. שני הערכים אינם משתנים בין טעינות, ולכן הסימון נשאר על אותו דיווח גם כשמצטרפים דיווחים חדשים מעליו. שורה **בלי** חותמת זמן אינה ניתנת לסימון ומציגה מקף: שתי שורות כאלה היו מקבלות מפתח זהה ונסמנות יחד.
.. important::
**מה שמצויר הוא מה שנקרא חזרה מהאחסון, ולא מה שנלחץ.** ``setItem`` יכול לזרוק כשהמכסה מלאה או כשהדפדפן חוסם אחסון, ו"לא זרק" עדיין אינו "נשמר". לכן כל כתיבה נקראת מיד לאחריה, ורק הערך שחזר קובע את מצב הכפתורים; כשהוא אינו מה שביקשנו, הכפתור נשאר כבוי ומופיעה הודעה מעל הטבלה. בלי זה לחיצה שלא נשמרה הייתה נראית בדיוק כמו לחיצה שנשמרה — עד הרענון הבא, שבו הסימון נעלם בלי הסבר.
האחסון חסום בתקרה של 500 רשומות: דיווח נושר מהשאילתה אחרי 90 יום ואינו חוזר, אבל הסימון שלו נשאר. הגזירה היא לפי חותמת הזמן שבתוך המפתח — הישנים נופלים ראשונים — והסימון שנלחץ זה עתה שורד אותה תמיד. בקצב הנוכחי התקרה לא תיגע בכלום במשך שנים; היא שם כדי שהגבול יהיה קיים.
ההתנהגות נבדקת בדפדפן אמיתי ב-``tests/test_admin_mcp_tabs_browser.py`` — לחיצה, שרידות לרענון, ביטול, דחיקה הדדית, אחסון חסום ותקרת הרשומות. בדיקת שרת רואה כפתור; היא אינה יכולה לראות שהלחיצה נשמרה.
.. _mcp-analytics-sorting:
מיון והעתקה
------------
שתי הטבלאות הגדולות — בריאות הכלים ועלות הניווט — ניתנות למיון לפי כל עמודה מספרית או עמודת תאריך. כותרת עמודה כזו היא **קישור**, והמיון נעשה **בשרת**.
.. important::
**מיון של מה שנטען בלבד הוא מיון שקרי.** טבלת הסשנים מציגה ``NAVIGATION_COST_LIMIT`` שורות מתוך אוכלוסייה גדולה יותר, ומיון בדפדפן על מה שהוצג היה מחזיר את המקסימום **מתוך 50** ומציג אותו כמקסימום — בלי שום סימן על המסך שהשורה שחיפשו לא נטענה כלל.
לכן בקשת מיון מושכת ``NAVIGATION_SORT_FETCH_LIMIT`` שורות במקום ``NAVIGATION_COST_LIMIT``, ממיינת על כולן, ורק אז חותכת חזרה לגודל התצוגה. **מסלול ברירת המחדל — בלי פרמטרי מיון — לא משתנה בכלל.**
התקרה הגדולה אינה הבטחה שמשכנו את הכל, והקוד אינו מתייחס אליה ככזו: ``holds_every_row`` משווה את מספר השורות שביד מול ``hasMore`` ומול הספירה שהשאילתה עצמה גוזרת ב-``count() OVER ()``. כשהן אינן מתלכדות — מכל סיבה, כולל תקרה של PostHog שאיננו מכירים — העמוד מציג תווית גלויה ליד הכותרת במקום להציג מיון חלקי כמיון מלא.
**תאים ריקים יורדים לסוף בשני כיווני המיון** ואינם משתתפים בהשוואה. ריק אינו אפס: סשנים שנרשמו לפני שהאיסוף עלה לאוויר אין להם כוונה שמורה ומצב הקריאה שלהם לא נשמר, ואילו ריק היה ממוין כאפס הם היו קופצים לראש מיון "הכי מעט קריאות תוכן" ונראים כמו ממצא.
הערכים מגיעים מ-JSON של PostHog, כלומר מחוץ לתהליך, ולכן ``sort_value`` פוסלת כל מה שאינו מספר סופי — כולל ``bool`` (ב-פייתון ``isinstance(True, int)`` הוא ``True``) ו-``NaN``, שאינו זורק אלא מתיישב במקום אקראי. תאריכים מומרים לחותמת מספרית, כדי שמחרוזת בלי אזור זמן לא תפיל את ההשוואה כולה.
פרמטרים
~~~~~~~~
.. list-table::
:header-rows: 1
:widths: 30 70
* - פרמטר
- ערך
* - ``tab``
- ``health`` / ``navigation`` / ``missing`` — הטאב שנפתח
* - ``tools_sort`` / ``tools_dir``
- עמודה מתוך ``TOOL_HEALTH_SORT_FIELDS``, וכיוון ``desc``/``asc``
* - ``sessions_sort`` / ``sessions_dir``
- עמודה מתוך ``NAVIGATION_SORT_FIELDS``, ואותם כיוונים
זוג פרמטרים לכל טבלה ולא פרמטר משותף: לשתי הטבלאות יש עמודה בשם ``errors``, ופרמטר אחד היה דו-משמעי. כל ערך שאינו ברשימה הלבנה פשוט אינו מיון — הקישור נבנה בשרת ומשמר גם את מצב הטבלה השנייה, ולכן מיון של טבלה אחת אינו מוחק את המיון של השנייה.
לחיצה חוזרת על אותה עמודה הופכת כיוון, ולחיצה שלישית **חוזרת לברירת המחדל**; לצד הכותרת מופיע גם קישור איפוס מפורש, כי "להפוך כיוון" לבדו אינו מאפשר לבטל מיון.
.. note::
עמודת ``p95/p50`` נגזרת בקוד (``with_latency_ratio``) ואינה מגיעה מ-PostHog. היחס הוא מה שמפריד בין "איטי תמיד" לבין "איטי לפעמים", ואלה שתי שאלות דיבוג שונות. התא ריק כשאחד הערכים חסר או כש-p50 הוא אפס — חלוקה באפס זורקת, ו"אינסוף" אינו מספר שאפשר למיין לפיו.
עמודת "שגיאות" ממוינת לפי **המספר המוחלט** ולא לפי האחוז. העמוד עצמו מסביר שהאחוז מטעה על מדגם קטן, ומיון הוא דירוג — כלומר בדיוק השימוש שבו ההטעיה הזו הכי יקרה.
העתקה
~~~~~~
לכל אחת משתי הטבלאות יש כפתור העתקה שמייצר **טבלת Markdown** עם שורת כותרת, כי היעד הוא הדבקה לצ'אט עם סוכן. שלושה כללים מייצרים אותה:
1. **מעתיקים את מה שרואים.** השורות נקראות מה-DOM לפי סדר התצוגה, ולכן ההעתקה מכבדת את המיון ואת כל סינון שיתווסף בעתיד בלי שורת קוד נוספת. "העתק הכל" הוא כל השורות **המוצגות**, והמספר כתוב על הכפתור.
2. **הערכים מ-``data-v`` ולא מהטקסט המוצג.** שם יושב המספר בלי ``ms``, והכוונה המלאה ולא החתוכה ב-64 תווים. היחידה עוברת לשם העמודה דרך ``data-copy-head``.
3. **רשימת האפשרויות נבנית מהשורות שיש** (הכל / 25 / 10 / 5), ואופציה שאינה קטנה ממספר השורות אינה נוצרת. אין רשימה קשיחה שצריך לסנכרן ביום שמספר הכלים משתנה.
בסוף ההעתקה נוספת שורת הסייג מהכותרת התחתונה, מאותה מחרוזת שמוצגת בעמוד — בלעדיה מי שמדביק מספרי סשנים לצ'אט מאבד בדיוק את ההסתייגות שהעמוד נבנה כדי לשמר.
בטבלת "כלים חסרים" יש כפתור אייקון לכל שורה, שמעתיק את מה שהסוכן ביקש כטקסט גולמי.
.. warning::
``navigator.clipboard.writeText`` זמין ב-secure context בלבד ונדחה ב-``NotAllowedError`` כשההרשאה חסרה. **אין נפילה אחורה ל-``document.execCommand``:** מסלול חלופי שמצליח לפעמים ומעתיק משהו אחר ממה שביקשו הוא בדיוק סוג הכשל שאי אפשר לאבחן אחר כך. נכשל ← הכפתור אומר זאת.
הפקד עצמו נשלח מהשרת ``hidden`` ונחשף ב-JS, כי בלי JS אין העתקה וכפתור מת גרוע מכפתור שאינו שם.
.. _mcp-analytics-config:
משתני סביבה
------------
שלושה, **בשירות הוובאפ בלבד**. הרשימה המלאה: :doc:`../environment-variables`.
.. list-table::
:header-rows: 1
:widths: 32 68
* - משתנה
- הערה
* - ``POSTHOG_PERSONAL_API_KEY``
- מפתח אישי (``phx_...``) עם ה-scope ``endpoint:read``. **אינו** ``POSTHOG_PROJECT_TOKEN``, שהוא מפתח כתיבה של שירות ה-MCP
* - ``POSTHOG_PROJECT_ID``
- מזהה הפרויקט המספרי, לבניית נתיב ה-API. **ספרות בלבד** — ערך אחר נדחה, ראו ההערה למטה
* - ``POSTHOG_HOST``
- כתובת קריאת הנתונים: ``https://us.posthog.com``
.. warning::
``POSTHOG_HOST`` **נושא ערכים שונים בשני השירותים**, ואי אפשר להעתיק אותו
ביניהם. בשירות ה-MCP זו כתובת שליחת האירועים (``us.i.posthog.com``); כאן זו
כתובת קריאת הנתונים (``us.posthog.com``). אלה שני מארחים נפרדים אצל PostHog,
ולא שם נרדף.
כתובת השליחה מחזירה ``404`` על נתיב ה-API — ו-``404`` פירושו בדרך כלל
"האנדפוינט אינו קיים", כלומר הודעה ששולחת לחפש בדיוק במקום הלא נכון. לכן
העמוד בודק את הערך **לפני** ששולח בקשה ואומר במפורש שזו כתובת שליחה. מאותה
סיבה אין כאן ברירת מחדל: משתנה חסר הוא שגיאת קונפיגורציה מוצהרת, ולא נפילה
שקטה לערך של שירות אחר.
בטבלת ה-Config Inspector הערך המוצג הוא של הוובאפ בלבד, כי הכלי קורא את
``os.environ`` של התהליך שלו.
.. warning::
``POSTHOG_PROJECT_ID`` הוא הערך היחיד מבין השלושה שנכנס ל**נתיב הכתובת**
שנשלחת. ``http_sync`` רושם את הכתובת המלאה כמאפיין ``http.url`` על ה-span,
ולכן ערך שנחת שם בטעות — למשל מפתח שהודבק במשתנה הלא נכון — היה נכתב לערוץ
תצפית. המפתח עצמו לעולם אינו מגיע לכתובת: הוא עובר בכותרת ``Authorization``
בלבד.
לכן הערך נבדק מול **רשימה לבנה** — ספרות ASCII בלבד — ונדחה כ-``config_invalid``
לפני שנשלחת בקשה. רשימה לבנה ולא שחורה: היא מצהירה על הצורה הנתמכת במקום
לנסות לזהות "מה נראה כמו סוד", וכל ניסיון לזהות סודות מפספס את הצורה שלא
נחזתה מראש. ההודעה, כמו כל הודעות הקונפיגורציה כאן, אינה מצטטת את הערך.
``str.isdigit()`` אינו מספיק לבדיקה הזו: הוא מחזיר ``True`` גם על ספרות
יוניקוד שאינן ASCII (``'١٢٣'``, ``'²'``).
מצבי כשל
---------
השירות (``services/mcp_analytics_service.py``) **לעולם אינו זורק**; הוא מדווח
כשל בשדה ``error_code``. כל אנדפוינט מקבל את המצב שלו בנפרד, ולכן אנדפוינט אחד
שנכשל אינו מחשיך את שני הטאבים האחרים.
.. list-table::
:header-rows: 1
:widths: 28 30 42
* - ``error_code``
- מתי
- מה לעשות
* - ``config_missing``
- אחד משלושת המשתנים ריק
- ההודעה נוקבת בשם המשתנה החסר
* - ``config_invalid``
- ``POSTHOG_HOST`` אינו כתובת ``https`` נקייה, או ``POSTHOG_PROJECT_ID`` אינו מספר
- ההודעה נוקבת בשם המשתנה ובצורה הנדרשת, ואינה מצטטת את הערך
* - ``host_is_ingestion``
- ``POSTHOG_HOST`` הוא כתובת שליחה
- להחליף ל-``https://us.posthog.com`` בשירות הוובאפ
* - ``unauthorized``
- ``401``/``403``
- המפתח פג, בוטל, או חסר לו ``endpoint:read``
* - ``endpoint_not_found``
- ``404``
- האנדפוינט אינו קיים בפרויקט. **לבדוק קודם את ה-host**
* - ``query_failed``
- ``400``, או ``error`` בגוף תשובת ``200``
- השאילתה ב-PostHog. ההודעה נגזרת מה-``code`` שהתשובה נשאה
* - ``unavailable``
- ``503``, שגיאת רשת, מפסק פתוח, או חריגה מתקציב הזמן
- זמני. לרענן בעוד רגע
* - ``bad_payload``
- התשובה אינה במבנה הצפוי
- ראו את ההערה למטה
.. note::
``bad_payload`` אינו קוסמטי. חוזה ה-API מבטיח את ``results`` בלבד; ``columns``
אינו מובטח בו, ולכן חיבור שלהם אינו יכול להיות ``zip`` תמים — ``zip`` על
אורכים שונים חותך בשקט ומייצר שורה שחסרה בה עמודה, בלי שאיש ישים לב. אי-התאמה
מדווחת ככשל מפורש.
מאותה משפחה: קוד סטטוס ``200`` אינו ראיה להצלחה. ``error`` בגוף התשובה הוא
ערוץ כשל נפרד, וקורא שבודק רק את הסטטוס יציג שגיאה כטבלה ריקה.
תקציב זמן
----------
העמוד קורא לכל האנדפוינטים **במקביל** — ``DASHBOARD_ENDPOINTS`` היא הרשימה,
וגם גודל ה-pool נגזר ממנה — אבל המקבול מקצר את הסכום ולא את המקרה הגרוע:
קריאה שנתקעת נתקעת באותה מידה גם כשאחרות רצות לצידה. לכן יש
שלוש הגנות נפרדות — ``timeout`` לבקשה בודדת, ``max_attempts`` ללולאת ה-retry,
ותקציב עליון שנאכף על ההמתנה עצמה וחותך בוודאות.
שתי מלכודות שהופכות את שתי ההגנות הראשונות לחלשות ממה שהן נראות, ושתיהן נמדדו:
**``timeout`` סקלרי אינו תקרה אחת אלא שתיים.** ``requests`` ממיר ערך יחיד
ל-``connect`` **ו**-``read`` נפרדים, ו-urllib3 מקבל ``total=None`` — כלומר אין
שום חסם על הסכום. ``timeout=3.0`` מתיר עד שש שניות לבקשה, לא שלוש. לכן הערך כאן
הוא טאפל מפורש: ``connect`` קצר, כי החיבור ל-PostHog מהיר או שהוא נכשל, ו-``read``
נדיב יותר, כי שם רצה השאילתה.
**``max_attempts`` לבדו אינו מספר הבקשות.** ה-Session של ``http_sync`` מרכיב על
ה-adapter ``urllib3.Retry`` משלו, ושתי השכבות מוכפלות: ``max_attempts=2`` מייצר
עד שש בקשות רשת. לכן הקריאה כאן מעבירה גם ``adapter_retries=False``, שמבטל את
השכבה הפנימית. ראו :doc:`../resilience`.
.. list-table:: מה התקציב מבטיח, ומה לא
:header-rows: 1
:widths: 46 30 24
* - מצב
- חישוב
- תוצאה
* - קריאה אחת שצורכת את מלוא הזמן
- ``connect + read``
- **6.0** — נכנס
* - המקרה הגרוע המלא, עם ניסיון חוזר
- ``2 × 6.0`` ועוד backoff
- **מעל התקציב**
.. note::
המספר המדויק של המקרה הגרוע תלוי במדיניות ה-Retry שנקראת ממשתני סביבה:
עם ברירות המחדל הוא ``12.75`` שניות, ובסביבת ה-CI — שמאפסת את ה-backoff
ואת ה-jitter כדי שהטסטים ירוצו מהר — הוא ``12.0``. מה שנכון בכל סביבה
הוא **היחס**, וזה מה שהבדיקה אוכפת: המקרה הגרוע חורג מהתקציב, וקריאה
יחידה מלאה נכנסת מתחתיו.
.. note::
**ו-``read`` אינו תקרה על כל התשובה.** הוא חל על כל קריאת socket בנפרד,
ולכן שרת שמטפטף בייטים לאט יכול למשוך בקשה הרבה מעבר לערך שנקבע, בלי
שאף אחד מהשניים ייגמר. זו סיבה נוספת לכך שהחישוב כאן הוא הנמקה בלבד:
מה שחוסם בפועל הוא התקציב העליון, שנאכף על ההמתנה עצמה ולא על השקע.
**וזו הצהרה מכוונת, לא פער שהוסתר.** התקציב מבטיח שקריאה יחידה שלוקחת את מלוא
הזמן אינה נחתכת; ניסיון חוזר כן עשוי להיחתך. זה הגיוני — ניסיון חוזר עוזר
כש-``503`` חוזר מהר, ולא כש-PostHog לוקח שש שניות לענות, ובמקרה כזה הניסיון
השני לא היה עוזר ממילא. להכניס את המקרה הגרוע מתחת לתקציב היה מחייב
``connect + read <= 3.125`` — כלומר לחנוק שאילתה על 30 יום נתונים.
הקבועים יושבים בראש ``services/mcp_analytics_service.py``. שתי הטענות נאכפות
בבדיקה ולא בהערה: שקריאה מלאה אחת נכנסת, ושהמספר בטבלה למעלה נגזר מהמדיניות
האמיתית ב-``resilience.py`` ולא מועתק כקבוע.
.. note::
שני הניסיונות נשמרים בכוונה: התיעוד של PostHog מגדיר את ``503 query_capacity``
כשגיאה שכדאי לנסות אחריה שוב. לעומת זאת ``400`` — כולל ``query_timeout`` —
אינו מנסה שוב, כי שאילתה שחרגה מהזמן תחרוג ממנו גם בפעם הבאה. ההבחנה מגיעה
משכבת ה-HTTP המשותפת (``http_sync``) ולא מקוד ייעודי.
אבטחה
------
- **המפתח אינו מגיע לדפדפן.** השליפה נעשית בצד השרת והתבנית מקבלת שורות מוכנות.
- **המפתח אינו נכנס לכתובת.** הוא עובר ב-``Authorization: Bearer``, וה-``limit``
נכנס לגוף ה-JSON — כך שלבקשה אין שורת שאילתה כלל. זה חשוב מפני ש-``sentry-sdk``
רושם את שורת השאילתה של כל בקשה יוצאת, גם מוצלחת, ובקשה בלי שאילתה לא נושאת
מה לרשום. ראו :doc:`../sentry`.
- **תוכן שסוכן כתב מרונדר כטקסט.** ``capability`` בטאב השלישי ו-``intent``
בטור הכוונה הם שניהם טקסט חופשי שסוכן חיצוני חיבר. שניהם מרונדרים כטקסט
בלבד — בלי ``|safe`` ובלי הזרקה ל-DOM מ-JavaScript, וזה נכון גם לתוכן וגם
לתכונת ה-``title`` שנושאת את הטקסט המלא. "רק אדמינים רואים" אינו הגנה;
אדמינים הם היעד (הכלל המלא: ``bugbot-rules/xss-innerhtml.md`` בריפו
``amir-bug-patterns``, לא בריפו הזה). נבדק גם ב-DOM המרונדר
וגם בדפדפן אמיתי, כי מה שקובע הוא מה שהדפדפן בונה ולא מה שהמחרוזת מכילה.
- **הקישורים היוצאים נבנים מקונפיגורציה מאומתת בלבד.** ``POSTHOG_HOST``
ו-``POSTHOG_PROJECT_ID`` עוברים את אותה ולידציה שמגנה על הבקשה עצמה, ואם
אחד מהם פסול לא נבנה קישור כלל. המפתח אינו משתתף בבנייה — כתובת שנפתחת
בדפדפן היא בדיוק המקום שבו סוד לא יכול להיות, כי הוא נכתב להיסטוריה,
ל-``Referer`` ולכל ערוץ תצפית שרושם כתובות.
- **פרטי הזדהות ב-``POSTHOG_HOST`` נדחים, כולל שם משתמש ריק.** הכתובת
``https://:secret@us.posthog.com`` מוחזרת מ-``urlsplit`` עם ``username=''``
— מחרוזת ריקה, כלומר **falsy** — ולכן בדיקה על הערך בלבד אישרה אותה,
והסיסמה נחתה בקישורים שמרונדרים לעמוד. הבדיקה היא ``is not None`` על
``username`` **וגם** על ``password``, ויש עליה טסט רגרסיה.
.. _mcp-analytics-reading:
איך העמוד מציג את ההודעה, ואיך קוראים אותה
--------------------------------------------
**ההודעה מנוקה, ולא נחתכת.** הודעת ``ValidationError`` של Pydantic היא ארבע
שורות, ושלוש מהן רעש בהקשר של טבלה: כותרת (``1 validation error for
get_fileArguments``) שנושאת את שם הכלי — שכבר יש לו עמודה משלו; הזחות; ושורת
``For further information visit https://errors.pydantic.dev/...`` שזהה בכל
שגיאה מאותו סוג. מה שנשאר הוא שורה אחת לכל שדה שנדחה::
lines.1 · Input should be a valid integer [input_value='9', input_type=str]
.. warning::
שני מקרים שבהם הניקוי מחק בעבר מידע במקום רעש, ושניהם מכוסים בבדיקות:
הודעה שהגיעה עם ``\r\n`` השאירה ``\r`` בסוף כל שורה, ואז התבנית של שורת
התיעוד — שנגמרת ב-``$`` — לא תפסה והקישור שרד בתא; וסוגריים ריקות **בתוך**
הערך שנדחה (``input_value=[]``) נמחקו יחד עם ניקוי הסוגריים הריקות שנשארו
מהסרת ``type=``. שניהם אותו שורש: פעולה גורפת על טקסט שמכיל גם רעש וגם ערך.
הפירסור יושב ב-``summarize_validation_message`` ו**נכשל בטוח**: מה שאינו נראה
כמו הודעת Pydantic מוחזר כפי שהוא, בלי לגעת. הכלל הזה חשוב יותר מהניקוי
עצמו — מאז שהשער נפתח לכל סוגי השגיאות, ההודעה בתא יכולה להיות של כל ספרייה,
וקיצור שמנחש פורמט היה מוחק דווקא את מה שאי אפשר לאבחן בלעדיו. המבנה שהפירסור
נשען עליו **נמדד** על ``pydantic 2.12.3`` ולא נלקח מהתיעוד: הרינדור עצמו הוא
ב-Rust ב-``pydantic_core``, ואין קוד פייתון לקרוא.
.. note::
**כיווניות.** ההודעה והכוונה הן טקסט טכני באנגלית בתוך עמוד RTL, ולכן שני
התאים נושאים ``direction: ltr`` עם ``unicode-bidi: isolate``. בלי זה
האלגוריתם הדו-כיווני של הדפדפן מזיז סוגריים וסימני שוויון לקצה הלא נכון,
ו-``[input_value='9']`` מוצג שבור. זו תכונה של הטקסט ולא של העימוד, ולכן
היא יושבת על התא ולא על הטבלה — סדר העמודות נשאר RTL.
**הכוונה נפתחת בלחיצה.** הטקסט מקוצץ בתא, והתא עצמו הוא ``