MCP Analytics (מדידת השימוש בכלי ה-MCP)

summary:

מסך אדמין שמציג את נתוני השימוש בכלי ה-MCP מתוך PostHog — בריאות הכלים, עלות הניווט בריפו, ויכולות שסוכנים ביקשו — עם מצבי הכשל ומשתני הסביבה שהוא דורש.

מה זה

שרת ה-MCP מדווח ל-PostHog אירוע על כל קריאת כלי (מדידת שימוש (PostHog MCP Analytics)). המסך /admin/mcp קורא את הנתונים האלה חזרה ומציג אותם בוובאפ, כדי שלא יידרש להיכנס ל-PostHog בשביל לענות על שלוש שאלות:

  • באילו כלים באמת משתמשים, כמה מהקריאות נכשלות — ובאיזו הודעה.

  • כמה קריאות עולה לסוכן לנווט בריפו, ומה הסוכן ניסה להשיג באותו סשן.

  • אילו יכולות סוכנים ביקשו ולא קיבלו.

הטבלה של בריאות הכלים אומרת ”20% שגיאות“. מתחתיה, באותו טאב, יושבות הקריאות שנכשלו עצמן — כלי, זמן, לקוח וההודעה. בלעדיהן העמוד הוא דוח מסירה בלי פתק המסירה: מספר כשלים בלי שם השדה שנדחה.

דרך ה-UI: דף Settings ← כלי אדמין ← MCP Analytics. ישירות: GET /admin/mcp. הדף זמין רק לאדמינים (@admin_required).

מאיפה הנתונים מגיעים

לא משאילתה שכתובה אצלנו. הקוד קורא ל-endpoints שמורים ב-PostHog — שאילתות שחיות שם ולא בריפו:

אנדפוינט

מה מחזיר

טווח

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 יום

הערה

ck_mcp_navigation_cost (הגרסה הראשונה, עם עמודת file_reads המאוחדת) נשאר חי ב-PostHog ואינו נמחק — הוא הבסיס להשוואה מול הטור המפוצל. העמוד פשוט אינו קורא לו יותר. שם האנדפוינט הוא קבוע בקוד, ולכן החלפת הגרסה היא הדבר היחיד בעמוד הזה שכן דורש דיפלוי.

ההפרדה הזו היא העיקר: שינוי שאילתה אינו דורש דיפלוי. מי שרוצה לשנות טווח, לסנן, או להוסיף עמודה — עושה זאת ב-PostHog, והעמוד מציג את מה שחזר. הקוד אינו מכיר את ה-SQL, ולכן גם אינו יכול להיסחף ממנו.

הטווח של ck_mcp_missing_capabilities ארוך יותר בכוונה: דיווח על כלי חסר הוא אירוע נדיר, וחלון של 30 יום היה מוחק אותו לפני שמישהו הספיק לקרוא.

הערה

הטבלה של ”כלים חסרים“ ריקה עד שסוכן ידווח, וזה מצב תקין. האיסוף פעיל — 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 בתשובה — והעמוד מציג ”יש עוד“ לפיו.

חשוב

התקציב העליון לא זז כשנוסף האנדפוינט הרביעי, וזה לא פספוס. הוא נגזר מהמקרה הגרוע של קריאה בודדת (connect + read), וקריאה נוספת שרצה לצידה אינה מאריכה אותו. ה-pool מקבל worker לכל אנדפוינט, ולכן הרביעי אינו ממתין בתור. הרשימה עצמה יושבת ב-DASHBOARD_ENDPOINTS, וגם גודל ה-pool וגם הבדיקות נגזרים ממנה — כדי שהוספת אנדפוינט חמישי לא תשבור את הבטחת המקבול בשקט.

סימון ”טופל“ / ”נדחה“ בכלים חסרים

לכל שורה בטאב ”כלים חסרים“ יש שני כפתורים: ✓ לסימון שהדיווח טופל, ו-✗ לסימון שנדחה. לחיצה על כפתור שכבר פעיל מבטלת את הסימון וחוזרת ל“לא טופל“, ושני הכפתורים דוחקים זה את זה — דיווח אינו יכול להיות גם טופל וגם נדחה.

הסימון חי בדפדפן, לא בשרת. השורות מגיעות מ-PostHog ואי אפשר לכתוב אליהן, ולכן מה שנשמר הוא שכבה דקה מעליהן ב-localStorage תחת המפתח mcpMissingMarks — בדיוק כמו באנר ”שגיאות חדשות“ באותו עמוד. המשמעות המעשית: הסימון הוא של הדפדפן הזה בלבד. במכשיר אחר, או אחרי ניקוי נתוני אתר, הוא לא יהיה שם.

הזהות של שורה לצורך הסימון היא <זמן הדיווח>|<סשן>, מה-data-cap-key שנכתב על <tr>. שני הערכים אינם משתנים בין טעינות, ולכן הסימון נשאר על אותו דיווח גם כשמצטרפים דיווחים חדשים מעליו. שורה בלי חותמת זמן אינה ניתנת לסימון ומציגה מקף: שתי שורות כאלה היו מקבלות מפתח זהה ונסמנות יחד.

חשוב

מה שמצויר הוא מה שנקרא חזרה מהאחסון, ולא מה שנלחץ. setItem יכול לזרוק כשהמכסה מלאה או כשהדפדפן חוסם אחסון, ו“לא זרק“ עדיין אינו ”נשמר“. לכן כל כתיבה נקראת מיד לאחריה, ורק הערך שחזר קובע את מצב הכפתורים; כשהוא אינו מה שביקשנו, הכפתור נשאר כבוי ומופיעה הודעה מעל הטבלה. בלי זה לחיצה שלא נשמרה הייתה נראית בדיוק כמו לחיצה שנשמרה — עד הרענון הבא, שבו הסימון נעלם בלי הסבר.

האחסון חסום בתקרה של 500 רשומות: דיווח נושר מהשאילתה אחרי 90 יום ואינו חוזר, אבל הסימון שלו נשאר. הגזירה היא לפי חותמת הזמן שבתוך המפתח — הישנים נופלים ראשונים — והסימון שנלחץ זה עתה שורד אותה תמיד. בקצב הנוכחי התקרה לא תיגע בכלום במשך שנים; היא שם כדי שהגבול יהיה קיים.

ההתנהגות נבדקת בדפדפן אמיתי ב-tests/test_admin_mcp_tabs_browser.py — לחיצה, שרידות לרענון, ביטול, דחיקה הדדית, אחסון חסום ותקרת הרשומות. בדיקת שרת רואה כפתור; היא אינה יכולה לראות שהלחיצה נשמרה.

מיון והעתקה

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

חשוב

מיון של מה שנטען בלבד הוא מיון שקרי. טבלת הסשנים מציגה 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, שאינו זורק אלא מתיישב במקום אקראי. תאריכים מומרים לחותמת מספרית, כדי שמחרוזת בלי אזור זמן לא תפיל את ההשוואה כולה.

פרמטרים

פרמטר

ערך

tab

health / navigation / missing — הטאב שנפתח

tools_sort / tools_dir

עמודה מתוך TOOL_HEALTH_SORT_FIELDS, וכיוון desc/asc

sessions_sort / sessions_dir

עמודה מתוך NAVIGATION_SORT_FIELDS, ואותם כיוונים

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

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

הערה

עמודת p95/p50 נגזרת בקוד (with_latency_ratio) ואינה מגיעה מ-PostHog. היחס הוא מה שמפריד בין ”איטי תמיד“ לבין ”איטי לפעמים“, ואלה שתי שאלות דיבוג שונות. התא ריק כשאחד הערכים חסר או כש-p50 הוא אפס — חלוקה באפס זורקת, ו“אינסוף“ אינו מספר שאפשר למיין לפיו.

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

העתקה

לכל אחת משתי הטבלאות יש כפתור העתקה שמייצר טבלת Markdown עם שורת כותרת, כי היעד הוא הדבקה לצ’אט עם סוכן. שלושה כללים מייצרים אותה:

  1. מעתיקים את מה שרואים. השורות נקראות מה-DOM לפי סדר התצוגה, ולכן ההעתקה מכבדת את המיון ואת כל סינון שיתווסף בעתיד בלי שורת קוד נוספת. ”העתק הכל“ הוא כל השורות המוצגות, והמספר כתוב על הכפתור.

  2. הערכים מ-``data-v`` ולא מהטקסט המוצג. שם יושב המספר בלי ms, והכוונה המלאה ולא החתוכה ב-64 תווים. היחידה עוברת לשם העמודה דרך data-copy-head.

  3. רשימת האפשרויות נבנית מהשורות שיש (הכל / 25 / 10 / 5), ואופציה שאינה קטנה ממספר השורות אינה נוצרת. אין רשימה קשיחה שצריך לסנכרן ביום שמספר הכלים משתנה.

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

בטבלת ”כלים חסרים“ יש כפתור אייקון לכל שורה, שמעתיק את מה שהסוכן ביקש כטקסט גולמי.

אזהרה

navigator.clipboard.writeText זמין ב-secure context בלבד ונדחה ב-NotAllowedError כשההרשאה חסרה. אין נפילה אחורה ל-``document.execCommand``: מסלול חלופי שמצליח לפעמים ומעתיק משהו אחר ממה שביקשו הוא בדיוק סוג הכשל שאי אפשר לאבחן אחר כך. נכשל ← הכפתור אומר זאת.

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

משתני סביבה

שלושה, בשירות הוובאפ בלבד. הרשימה המלאה: משתני סביבה - רפרנס.

משתנה

הערה

POSTHOG_PERSONAL_API_KEY

מפתח אישי (phx_...) עם ה-scope endpoint:read. אינו POSTHOG_PROJECT_TOKEN, שהוא מפתח כתיבה של שירות ה-MCP

POSTHOG_PROJECT_ID

מזהה הפרויקט המספרי, לבניית נתיב ה-API. ספרות בלבד — ערך אחר נדחה, ראו ההערה למטה

POSTHOG_HOST

כתובת קריאת הנתונים: https://us.posthog.com

אזהרה

POSTHOG_HOST נושא ערכים שונים בשני השירותים, ואי אפשר להעתיק אותו ביניהם. בשירות ה-MCP זו כתובת שליחת האירועים (us.i.posthog.com); כאן זו כתובת קריאת הנתונים (us.posthog.com). אלה שני מארחים נפרדים אצל PostHog, ולא שם נרדף.

כתובת השליחה מחזירה 404 על נתיב ה-API — ו-404 פירושו בדרך כלל ”האנדפוינט אינו קיים“, כלומר הודעה ששולחת לחפש בדיוק במקום הלא נכון. לכן העמוד בודק את הערך לפני ששולח בקשה ואומר במפורש שזו כתובת שליחה. מאותה סיבה אין כאן ברירת מחדל: משתנה חסר הוא שגיאת קונפיגורציה מוצהרת, ולא נפילה שקטה לערך של שירות אחר.

בטבלת ה-Config Inspector הערך המוצג הוא של הוובאפ בלבד, כי הכלי קורא את os.environ של התהליך שלו.

אזהרה

POSTHOG_PROJECT_ID הוא הערך היחיד מבין השלושה שנכנס ל**נתיב הכתובת** שנשלחת. http_sync רושם את הכתובת המלאה כמאפיין http.url על ה-span, ולכן ערך שנחת שם בטעות — למשל מפתח שהודבק במשתנה הלא נכון — היה נכתב לערוץ תצפית. המפתח עצמו לעולם אינו מגיע לכתובת: הוא עובר בכותרת Authorization בלבד.

לכן הערך נבדק מול רשימה לבנה — ספרות ASCII בלבד — ונדחה כ-config_invalid לפני שנשלחת בקשה. רשימה לבנה ולא שחורה: היא מצהירה על הצורה הנתמכת במקום לנסות לזהות ”מה נראה כמו סוד“, וכל ניסיון לזהות סודות מפספס את הצורה שלא נחזתה מראש. ההודעה, כמו כל הודעות הקונפיגורציה כאן, אינה מצטטת את הערך.

str.isdigit() אינו מספיק לבדיקה הזו: הוא מחזיר True גם על ספרות יוניקוד שאינן ASCII ('١٢٣', '²').

מצבי כשל

השירות (services/mcp_analytics_service.py) לעולם אינו זורק; הוא מדווח כשל בשדה error_code. כל אנדפוינט מקבל את המצב שלו בנפרד, ולכן אנדפוינט אחד שנכשל אינו מחשיך את שני הטאבים האחרים.

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

התשובה אינה במבנה הצפוי

ראו את ההערה למטה

הערה

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, שמבטל את השכבה הפנימית. ראו Resilience לשירותים חיצוניים.

מה התקציב מבטיח, ומה לא

מצב

חישוב

תוצאה

קריאה אחת שצורכת את מלוא הזמן

connect + read

6.0 — נכנס

המקרה הגרוע המלא, עם ניסיון חוזר

2 × 6.0 ועוד backoff

מעל התקציב

הערה

המספר המדויק של המקרה הגרוע תלוי במדיניות ה-Retry שנקראת ממשתני סביבה: עם ברירות המחדל הוא 12.75 שניות, ובסביבת ה-CI — שמאפסת את ה-backoff ואת ה-jitter כדי שהטסטים ירוצו מהר — הוא 12.0. מה שנכון בכל סביבה הוא היחס, וזה מה שהבדיקה אוכפת: המקרה הגרוע חורג מהתקציב, וקריאה יחידה מלאה נכנסת מתחתיו.

הערה

ו-``read`` אינו תקרה על כל התשובה. הוא חל על כל קריאת socket בנפרד, ולכן שרת שמטפטף בייטים לאט יכול למשוך בקשה הרבה מעבר לערך שנקבע, בלי שאף אחד מהשניים ייגמר. זו סיבה נוספת לכך שהחישוב כאן הוא הנמקה בלבד: מה שחוסם בפועל הוא התקציב העליון, שנאכף על ההמתנה עצמה ולא על השקע.

וזו הצהרה מכוונת, לא פער שהוסתר. התקציב מבטיח שקריאה יחידה שלוקחת את מלוא הזמן אינה נחתכת; ניסיון חוזר כן עשוי להיחתך. זה הגיוני — ניסיון חוזר עוזר כש-503 חוזר מהר, ולא כש-PostHog לוקח שש שניות לענות, ובמקרה כזה הניסיון השני לא היה עוזר ממילא. להכניס את המקרה הגרוע מתחת לתקציב היה מחייב connect + read <= 3.125 — כלומר לחנוק שאילתה על 30 יום נתונים.

הקבועים יושבים בראש services/mcp_analytics_service.py. שתי הטענות נאכפות בבדיקה ולא בהערה: שקריאה מלאה אחת נכנסת, ושהמספר בטבלה למעלה נגזר מהמדיניות האמיתית ב-resilience.py ולא מועתק כקבוע.

הערה

שני הניסיונות נשמרים בכוונה: התיעוד של PostHog מגדיר את 503 query_capacity כשגיאה שכדאי לנסות אחריה שוב. לעומת זאת 400 — כולל query_timeout — אינו מנסה שוב, כי שאילתה שחרגה מהזמן תחרוג ממנו גם בפעם הבאה. ההבחנה מגיעה משכבת ה-HTTP המשותפת (http_sync) ולא מקוד ייעודי.

אבטחה

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

  • המפתח אינו נכנס לכתובת. הוא עובר ב-Authorization: Bearer, וה-limit נכנס לגוף ה-JSON — כך שלבקשה אין שורת שאילתה כלל. זה חשוב מפני ש-sentry-sdk רושם את שורת השאילתה של כל בקשה יוצאת, גם מוצלחת, ובקשה בלי שאילתה לא נושאת מה לרשום. ראו 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, ויש עליה טסט רגרסיה.

איך העמוד מציג את ההודעה, ואיך קוראים אותה

ההודעה מנוקה, ולא נחתכת. הודעת 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]

אזהרה

שני מקרים שבהם הניקוי מחק בעבר מידע במקום רעש, ושניהם מכוסים בבדיקות: הודעה שהגיעה עם \r\n השאירה \r בסוף כל שורה, ואז התבנית של שורת התיעוד — שנגמרת ב-$ — לא תפסה והקישור שרד בתא; וסוגריים ריקות בתוך הערך שנדחה (input_value=[]) נמחקו יחד עם ניקוי הסוגריים הריקות שנשארו מהסרת type=. שניהם אותו שורש: פעולה גורפת על טקסט שמכיל גם רעש וגם ערך.

הפירסור יושב ב-summarize_validation_message ו**נכשל בטוח**: מה שאינו נראה כמו הודעת Pydantic מוחזר כפי שהוא, בלי לגעת. הכלל הזה חשוב יותר מהניקוי עצמו — מאז שהשער נפתח לכל סוגי השגיאות, ההודעה בתא יכולה להיות של כל ספרייה, וקיצור שמנחש פורמט היה מוחק דווקא את מה שאי אפשר לאבחן בלעדיו. המבנה שהפירסור נשען עליו נמדד על pydantic 2.12.3 ולא נלקח מהתיעוד: הרינדור עצמו הוא ב-Rust ב-pydantic_core, ואין קוד פייתון לקרוא.

הערה

כיווניות. ההודעה והכוונה הן טקסט טכני באנגלית בתוך עמוד RTL, ולכן שני התאים נושאים direction: ltr עם unicode-bidi: isolate. בלי זה האלגוריתם הדו-כיווני של הדפדפן מזיז סוגריים וסימני שוויון לקצה הלא נכון, ו-[input_value='9'] מוצג שבור. זו תכונה של הטקסט ולא של העימוד, ולכן היא יושבת על התא ולא על הטבלה — סדר העמודות נשאר RTL.

הכוונה נפתחת בלחיצה. הטקסט מקוצץ בתא, והתא עצמו הוא <button> אמיתי — לא span עם tabindex. כך Enter ו-Space עובדים בלי JavaScript משלנו, וקורא מסך מכריז ”כפתור“ במקום להשמיע טקסט שלא ברור שאפשר ללחוץ עליו. הלחיצה פותחת חלון עם הטקסט המלא.

החלון הוא <dialog> נייטיב שנפתח ב-showModal(), ולא div שמוצג ומוסתר ביד. ההבדל אינו סגנוני: הדפדפן נועל את הפוקוס בתוך הדיאלוג, סוגר ב-Escape, מחזיר את הפוקוס לכפתור שפתח, ומצייר את הרקע המעומעם ב-::backdrop — ארבעה דברים שהגרסה הידנית הייתה צריכה לכתוב, ושלושה מהם היא לא כתבה. הבדיקה נמדדה בכרומיום אמיתי: Shift+Tab מכפתור הסגירה מתחלף בין הכפתור לדיאלוג עצמו, ואינו מגיע לתוכן שמאחור.

אזהרה

הטקסט בחלון נכתב ב-textContent, לא ב-innerHTML. זה מסלול שני לאותו טקסט שסוכן חיצוני כתב: ה-escape של Jinja מגן על הטבלה, והחלון נבנה ב-JavaScript ולכן צריך הגנה משלו. שני המסלולים נבדקים בנפרד בדפדפן אמיתי, ויש בדיקה שאוסרת innerHTML בסקריפט של העמוד.

”X שגיאות חדשות מאז הביקור האחרון“

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

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

מצב

מה קורה

ביקור ראשון (אין ערך שמור)

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

localStorage חסום (גלישה פרטית, הגדרות)

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

ההשוואה

על data-at שנושא את הערך הגולמי מ-PostHog, ולא על המחרוזת המוצגת: התצוגה מעוגלת לדקה ומנוסחת בעברית

הטבלה נחתכה בתקרה, וכל השורות שהוצגו חדשות

הניסוח הופך ל“לפחות X“. הבאנר סופר שורות ב-DOM, והשאילתה מוגבלת ל-TOOL_FAILURES_LIMIT — ולכן המספר הוא רצפה ולא ספירה

הערה

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

הטורים שנוספו, ומה הם לא אומרים

אאוטליין מול קריאת תוכן. עד עכשיו הייתה עמודה אחת, file_reads, שספרה את שתי הצורות יחד — בזמן שהן הפוכות במשמעות: אאוטליין הוא המפה (הפתרון), וקריאת תוכן היא הניווט היקר (הבעיה). עמודה שסופרת את שתיהן יחד לא יכולה להראות שהאחת החליפה את השנייה, וזה בדיוק המדד שהעמוד נבנה כדי למדוד.

ההבחנה מגיעה מ-ck_read_mode, מאפיין שהשרת מחשב בעצמו — ראו מצב הקריאה (ck_read_mode). היא אינה מגיעה מהפרמטרים של הקריאה: אלה חסומים בשער הפרטיות ונשארים חסומים.

אזהרה

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

טור הכוונה. מציג את המשפט שהסוכן כתב — לא סיכום ולא עיבוד, הטקסט עצמו, מקוצץ בתצוגה עם המלא ב-title. במקום מזהה סשן אטום רואים מה הסוכן ניסה לעשות.

הערה

שני הדברים האלה ריקים לכל מה שנאסף לפני שהם עלו לאוויר, וזה צפוי. בסשן ישן אין כוונה שמורה (התא ריק), ולא נשמר מצב קריאה — ולכן כל קריאות הקובץ שלו נספרות כקריאת תוכן. עמודת ”התחיל“ אומרת לאילו שורות זה נוגע. באותו אופן, קריאה שנכשלה לפני שההודעות נשמרו מוצגת עם — בעמודת ההודעה: הצינזור קרה בזמן השליחה ואינו הפיך. תא ריק אינו כשל — כשל מוצג כהודעה אדומה, וההבחנה הזו נבדקת.

מה שאי אפשר להביא לכאן, ולמה

סיכומי כוונה לסשן ו**אשכולות כוונות** הם כלי API של PostHog ולא שאילתות: אשכול כוונות הוא עבודת LLM שרצה ונשמרת אצלם, ודורשת הרצת חישוב. העמוד הזה מדבר עם PostHog רק דרך אנדפוינטים שמורים, שהם HogQL בלבד, ולכן אין דרך להביא אותם — וחיקוי חלקי היה מציג מספר שנראה כמו discovery rate בלי להיות אחד.

במקום זה, קישור יוצא. בתחתית טאב עלות הניווט יש שני קישורים לתצוגות המתאימות ב-PostHog. הם שווים בערכם ואף יותר: האשכולות נותנים discovery rate מול קטלוג הכלים, והתאמה בין תיאור הכלי לכוונה — כלומר האם התיאור של הכלי גורם לסוכנים למצוא אותו. פשוט לא כאן.

עיצוב

כל הצבעים במסך הם טוקנים (--card-bg, --glass, --text-primary וכו«) ולא ערכי HEX, לפי מערכת ערכות הנושא והטוקנים החדשה. לטוקנים הסמנטיים יש fallback לטוקן גלובלי, ולכן העמוד קריא גם בערכות שאינן מגדירות את כולם. אין בו בלוק override לכל ערכה — וזה בדיוק מה שקיבוע צבעים היה מחייב.

טסטים

  • tests/test_mcp_analytics_service.py — השירות, מצבי הכשל והתקציב.

  • tests/test_admin_mcp_page.py — העמוד, דרך בקשת HTTP ומול ה-DOM המרונדר.

  • tests/test_admin_mcp_tabs_browser.py — אינטראקציה בדפדפן אמיתי: מעבר טאבים, מצב ריק, גלישה אופקית בנייד, גלילת הטבלה הרחבה בתוך הכרטיס שלה, title על כוונה מקוצצת, וטקסט שנראה כמו תגית שנשאר צומת טקסט ב-DOM. מדולג כשאין Chromium.

  • tests/test_http_sync_adapter_retries.py — שכבת ה-Retry הכפולה שהעמוד הזה חשף, כולל בדיקה שהמסלול הקיים לא זז.

בדיקות העמוד עוברות דרך אותו ממשק שהמשתמש עובר בו ולא קוראות לפונקציות ישירות.

ראו גם