להכיר את OpenMeter: מאירוע שימוש ועד ליתרה של הלקוח
כשאנחנו נותנים למשתמש מכסה חודשית של מיליון טוקנים, צריך לדעת כמה הוא כבר צרך. לכאורה, אפשר להוסיף עמודה בטבלה ולהפחית ממנה אחרי כל פעולה.
אבל מה קורה אם המשתמש קנה תוספת שתוקפה שונה מתוקף החבילה? מאיזו יתרה נפחית קודם? ומה אם אנחנו רוצים להגביל אותו גם לשעה, בלי קשר למכסה החודשית?
ויש גם שאלות טכניות. אותה הודעת שימוש יכולה להגיע פעמיים, שתי בקשות יכולות להתבצע במקביל, ולפעמים נצטרך להסביר ללקוח איך בדיוק הגענו ליתרה שמוצגת לו.
לצורך הזה קיימת OpenMeter. זו מערכת קוד פתוח למדידת שימוש ולניהול זכאויות, תוכניות ומנגנוני חיוב. אפשר להשתמש בה לספירת קריאות API, טוקנים של מודלי שפה, זמן חישוב או כל יחידת צריכה אחרת שהאפליקציה שלנו יודעת לדווח עליה.
מה שמעניין בה מבחינה ארכיטקטונית הוא שהמדידה והיתרה אינן אותו דבר. כדי להבין איך היא עובדת, נתחיל מהאירוע שהאפליקציה שולחת ונעקוב אחריו פנימה.
קודם מדווחים מה קרה
נניח שפעולת צ׳אט צרכה 2,000 טוקני קלט. האפליקציה שולחת אירוע במבנה CloudEvents:
{
"specversion": "1.0",
"id": "request-123:input",
"source": "chat-service",
"type": "app.usage",
"subject": "customer-42",
"time": "2026-11-08T10:15:00Z",
"data": {
"addon": "chat",
"credit_type": "input_tokens",
"amount": "2000"
}
}
האירוע אומר מי השתמש, במה השתמש, מתי וכמה. הוא אינו אומר אם היה מותר לו לעשות זאת או מה המחיר של הפעולה.
השדה subject מזהה את בעל השימוש, וצריך להתאים למפתח השימוש שהוגדר אצל הלקוח. בתוך data נמצאים השדות העסקיים שלנו.
המזהה id חשוב במיוחד. אם שליחת האירוע הסתיימה ב־Timeout, אנחנו לא יודעים בהכרח אם השרת קיבל אותו. בניסיון חוזר צריך לשמור את אותה זהות אירוע, כדי שיהיה אפשר לזהות שמדובר בדיווח חוזר על אותה פעולה.
אם נייצר מזהה חדש בכל ניסיון, מבחינת המערכת אלה עלולים להיות שני אירועים שונים.
כעת צריך להסביר ל־OpenMeter מה למדוד מתוך האירוע. לשם כך מגדירים Meter:
{
"key": "usage",
"name": "Application usage",
"aggregation": "sum",
"event_type": "app.usage",
"value_property": "$.amount",
"dimensions": {
"addon": "$.addon",
"credit_type": "$.credit_type"
}
}
אלה שמות השדות ב־REST V3. הרעיון פשוט: בחר אירועים מסוג app.usage, קרא את הכמות מתוך amount, וחבר את הכמויות.
הממדים מאפשרים להפריד בין שימושים. כך אפשר להשתמש באותו Meter לטוקני קלט ופלט, ובשאילתה או בהגדרת היכולת לבחור רק את הסוג הרלוונטי.
עד כאן הגדרנו איך לספור. עוד לא הגדרנו למשתמש אפילו טוקן אחד שמותר לו לצרוך.
לאן האירוע מגיע?
מסלול המדידה המרכזי נראה כך:
Application
|
v
OpenMeter API
|
v
Kafka
|
v
Sink Worker
|
v
ClickHouse
ה־API מקבל את האירועים ומפרסם אותם ל־Kafka. משם תהליך רקע, שנקרא Sink Worker, קורא אותם, מבצע את העיבוד הנדרש ושומר את נתוני השימוש ב־ClickHouse. ה־Worker הוא צרכן Kafka שנכתב ב־Go.
זו הפרדה מכוונת בין קבלת האירוע לבין עיבודו. אפשר לקלוט דיווחים גם כשעיבוד הנתונים מפגר זמנית, ולכתוב למסד הנתונים בקבוצות במקום לבצע כתיבה נפרדת לכל אירוע. אפשר גם לחלק את עבודת הצרכנים באמצעות המחיצות של Kafka.
המשמעות המעשית היא שתשובת 202 Accepted אינה אומרת שהאירוע כבר מופיע בכל שאילתה או שהיתרה כבר התעדכנה. היא אומרת שהבקשה התקבלה לעיבוד.
אם נשלח אירוע ומיד נקרא את היתרה, ייתכן שנראה עדיין את הערך הקודם. זו תוצאה של המבנה האסינכרוני של המערכת, ולא בהכרח תקלה.
למה גם PostgreSQL וגם ClickHouse?
במערכת הזו יש שני סוגי עבודה שונים.
מצד אחד יש מידע עסקי: לקוחות, תוכניות, מנויים, זכאויות והקצאות. כשלקוח מחליף חבילה, למשל, אנחנו רוצים לעדכן את הרשומות הרלוונטיות באופן עקבי, בלי להישאר עם חצי שינוי.
מצד שני יש זרם של אירועי שימוש. עליו נרצה להריץ חישובים: כמה טוקנים צרך לקוח מסוים החודש, כמה מהם היו פלט, ואיך השימוש התחלק לאורך היום.
לשני סוגי העבודה האלה יש שמות מקובלים.
המונח OLTP, ראשי תיבות של Online Transaction Processing, מתאר עבודה תפעולית שוטפת: יצירת הזמנה, עדכון מנוי או רישום תשלום. בדרך כלל מדובר בהרבה פעולות קצרות, שכל אחת קוראת או משנה מספר קטן יחסית של רשומות. חשובה לנו היכולת לבצע שינויים באופן עקבי גם כשמשתמשים רבים עובדים במקביל.
לעומתו, OLAP, ראשי תיבות של Online Analytical Processing, מתאר עבודה אנליטית: סיכום, השוואה וקיבוץ של כמויות גדולות של נתונים. למשל, חישוב השימוש היומי של כל הלקוחות בחודש האחרון. התוצאה יכולה להיות טבלה קטנה, אבל כדי לחשב אותה ייתכן שצריך לקרוא מיליוני רשומות.
אפשר לראות את ההבדל בשתי שאלות פשוטות:
״לאיזו חבילה הלקוח הזה מנוי?״
לעומת:
״כמה טוקנים צרכו כל הלקוחות, בחלוקה לחבילה וליום?״
ב־OpenMeter, המידע העסקי והטרנזקציוני נשמר ב־PostgreSQL, ואילו אירועי השימוש והאגרגציות שלהם נשמרים ב־ClickHouse. חישוב היתרה מחבר בין שני העולמות: מה הלקוח קיבל וכמה הוא צרך.
אין פירוש הדבר ש־PostgreSQL אינה יכולה לבצע חישובים אנליטיים. היא בהחלט יכולה. ההפרדה מאפשרת להתאים את האחסון לסוג העבודה, וגם להרחיק את עומס הסריקה והסיכום מהפעולות התפעוליות.
אבל יש לזה מחיר: עדכון ההקצאה ועיבוד אירוע השימוש אינם חלק מטרנזקציה משותפת אחת. לכן צריך להבין עד כמה נתוני השימוש עדכניים כשמסתמכים עליהם לקבלת החלטה. בהמשך נראה איך זה משפיע על בדיקת היתרה.
איך ClickHouse עוזרת לספירה?
מסד הנתונים ClickHouse הוא מסד נתונים אנליטי בקוד פתוח, שמאפשר להריץ שאילתות SQL על כמויות גדולות של נתונים. אחד המאפיינים המרכזיים שלו הוא אחסון עמודתי.
מה זה אומר?
נניח שיש לנו טבלת אירועי שימוש, ובכל רשומה מופיעים מזהה לקוח, זמן, מודל, סוג טוקנים, כמות ועוד שדות.
באחסון שורתי, כמו האחסון הרגיל של טבלאות ב־PostgreSQL, הנתונים של אותה רשומה נשמרים יחד. זה מתאים מאוד למצב שבו אנחנו רוצים לקרוא רשומה מסוימת או לעדכן אותה.
באחסון עמודתי, הערכים של אותה עמודה נשמרים יחד: כמויות עם כמויות, זמנים עם זמנים וכן הלאה.
נניח שאלו אירועי השימוש שלנו:
| לקוח | סוג שימוש | כמות |
|---|---|---|
| אבי | קלט | 2,000 |
| דני | פלט | 500 |
| אבי | קלט | 3,000 |
באחסון שורתי, הנתונים מאורגנים בקירוב כך:
אבי, קלט, 2000
דני, פלט, 500
אבי, קלט, 3000
באחסון עמודתי, כמו ב־ClickHouse, כל עמודה נשמרת בנפרד:
לקוח: אבי, דני, אבי
סוג שימוש: קלט, פלט, קלט
כמות: 2000, 500, 3000
זו המחשה של צורת הארגון, לא תיאור מדויק של הקבצים בדיסק.
הארגון לפי עמודות מתאים במיוחד לחישובי אגרגציה, כמו סכום, ממוצע וספירה. כדי לחשב כמה טוקנים נצרכו, למשל, אפשר לקרוא ברצף את עמודת הכמות ולעבד הרבה ערכים יחד, בלי לקרוא את שאר פרטי האירועים. גם הדחיסה היעילה של העמודות מצמצמת את כמות הנתונים שצריך לקרוא.
לכן המבנה הזה מתאים לצורך של OpenMeter: לקבל מספר גדול של אירועי שימוש ולהפיק מהם במהירות נתונים מסוכמים לפי לקוח, סוג שימוש ותקופה.
בשלוש רשומות ההבדל זניח. במאות מיליוני רשומות, שכל אחת מהן מכילה עשרות שדות, החיסכון בקריאה יכול להיות משמעותי. ואם נרצה לסכם רק טוקני קלט, נצטרך גם את עמודת סוג השימוש. קוראים את העמודות שנדרשות לשאילתה.
זו גם הסיבה שהבחירה תלויה בסוג העבודה. אם היינו צריכים לשנות כל הזמן שדות ברשומות בודדות, אלה שיקולים אחרים. בזרם אירועי שימוש אנחנו בעיקר מוסיפים נתונים ומחשבים עליהם סכומים, ולכן המבנה הזה מתאים.
אבל אפשר להתקדם עוד צעד.
אם בכל בקשה להצגת יתרה נסרוק מחדש את כל אירועי החודש, נחזור פעמים רבות על אותה עבודה. במקום זאת אפשר לשמור אגרגציות ביניים, כלומר תוצאות חלקיות שכבר חושבו.
למשל, עבור לקוח וסוג שימוש מסוימים:
10:15 → 2,000 טוקנים
10:16 → 3,500 טוקנים
10:17 → 1,200 טוקנים
כדי לחשב את השימוש בשלוש הדקות האלה, אפשר לחבר שלושה סכומים, גם אם מאחוריהם היו אלפי אירועים.
מנגנון הזכאויות של OpenMeter משתמש בנתוני שימוש שמאוגדים ברזולוציה היסטורית של דקה. זו אינה המתנה של דקה לפני כל עדכון, אלא רמת הפירוט של הנתונים ההיסטוריים שמשמשים לחישוב.
כאן יש גם פשרה: מתוך סכום לדקה לבדו אי אפשר לשחזר כמה נצרך בכל שנייה בתוכה. לכן פעולות היסטוריות מסוימות, כמו החלת מענק או איפוס במועד מוגדר, כפופות לגרנולריות הזו.
זו דוגמה להחלטה ארכיטקטונית שמשפיעה על התנהגות המוצר. אופן שמירת הנתונים קובע גם אילו שאלות נוכל לשאול עליהם אחר כך.
ומה Redis עושה כאן?
קל לראות Redis ברשימת הרכיבים ולהניח שהיא מחזיקה את היתרות. אבל זו אינה דרך טובה להבין את הארכיטקטורה.
אחד השימושים שלה ב־OpenMeter הוא מניעת ספירה חוזרת של אירועים, כאשר מנגנון ה־Deduplication מוגדר להשתמש בה. היא יכולה לשמש גם לשמירת התקדמות של שאילתות ממושכות. השימושים האלה תלויים בתצורה; עצם הפעלת Redis אינה מפעילה אותם אוטומטית.
נחזור לאירוע שלנו. שלחנו 2,000 טוקנים, לא קיבלנו תשובה ושלחנו שוב.
מנגנון מניעת כפילויות צריך לזכור את זהות האירוע שכבר טופל. כשכמה מופעים של שירות העיבוד עובדים במקביל, מאגר משותף מאפשר להם להסתמך על אותו מידע, במקום שכל מופע יזכור רק את מה שעבר דרכו.
אבל לזיכרון הזה יש גבול. אם מפתחות מניעת הכפילויות נשמרים לזמן מוגבל, ניסיון חוזר שמגיע אחרי שהמפתח פג דורש התייחסות. צריך לתאם בין משך השמירה לבין מדיניות הניסיונות החוזרים של האפליקציה.
וגם כאן כדאי להבדיל בין שתי בעיות: שליחה חוזרת של אותה הודעה, לעומת קוד עסקי שיצר בטעות שתי הודעות עם מזהים שונים. מנגנון שמזהה כפילויות לפי זהות האירוע אינו יכול לדעת לבדו ששתי הודעות שונות מתארות למעשה אותה פעולה עסקית.
מהמדידה לחישוב היתרה
עכשיו יש לנו שימוש מדוד, אבל עדיין אין לנו יתרה.
כדי לחשב אותה, צריך לדעת לאיזו יכולת השימוש שייך, מה הלקוח קיבל, אילו הקצאות בתוקף ובאיזו תקופה אנחנו נמצאים.
ה־Feature מייצג יכולת של המוצר. במקרה של יכולת מדידה, מחברים אותו ל־Meter ומגדירים מסננים. למשל: רק טוקני הקלט של הצ׳אט.
ה־Customer מייצג את בעל הזכאות. זו יכולה להיות חברה או משתמש, לפי המודל שלנו.
ה־Entitlement מחבר את הלקוח ליכולת ומגדיר את תנאי הזכאות שלו. זכאות מסוג Metered יכולה להגדיר תקופת שימוש ומגבלה, וזכאות מסוג Boolean יכולה לייצג עצם גישה ליכולת ללא ספירה.
לבסוף, ה־Grant מייצג הקצאת יחידות שימוש.
נניח שהלקוח קיבל שתי הקצאות:
| הקצאה | כמות | תנאים |
|---|---|---|
| מכסת החבילה | 100,000 | מתחדשת לפי כללי התקופה |
| תוספת שנרכשה | 50,000 | תקפה עד מועד מוגדר |
אחרי שימוש של 20,000, לא מספיק לדעת שנשארו בסך הכול 130,000. צריך לדעת מאיזו הקצאה נצרכו היחידות, משום שלכל אחת יש כללי תוקף וחידוש שונים.
במנגנון ה־Grants אפשר להגדיר עדיפות לצריכה, תוקף וכללי Rollover. מנוע החישוב מפחית את השימוש מההקצאות לפי הכללים האלה. לכן היתרה היא תוצאה של חישוב לאורך זמן, ולא רק ערך שמקטינים בכל בקשה.
בארכיטקטורה מופיע גם Balance Worker, שמטפל בעדכוני חישובי היתרות ונעזר בנתוני השימוש ובמצב הזכאויות. לצידו קיימים תהליכים נפרדים לחיוב ולהתראות. החלוקה הזו מאפשרת להפריד בין עבודות בעלות אופי וקצב שונים.
אירוע אחד, כמה מכסות
אפשר לבחון את אותה צריכה מול יותר ממגבלה אחת.
למשל, מיליון טוקני קלט בחודש, עד 100,000 בשעה ועד 300,000 בחמש שעות.
במודל הזכאויות שבו אנחנו משתמשים, לכל צמד של לקוח ו־Feature יש זכאות פעילה אחת, ולזכאות המדידה יש תקופת שימוש אחת. לכן ניצור שלושה Features עם אותם מסננים, ונחבר לכל אחד זכאות עם התקופה וההקצאה המתאימות.
נשלח רק אירוע שימוש אחד. כל זכאות תבחן אותו בתוך התקופה שלה.
אם הפעולה צרכה 2,000 טוקנים, היא תקטין את היתרה הרלוונטית בכל אחת משלוש המכסות. אין צורך לשכפל את הדיווח, ואין משמעות לחיבור שלוש היתרות ליתרה אחת.
כך משתמש יכול להחזיק עוד 900,000 טוקנים במכסה החודשית, אבל להיות חסום משום שהגיע לתקרת השעה.
התקופות בדוגמה הזו חוזרות לפי עוגן זמן. חלון של חמש שעות שמתחיל בשמונה בבוקר יסתיים באחת בצהריים. זה שונה מחלון נע של חמש השעות האחרונות, ושונה גם מתקופה שמתחילה בפעולה הראשונה של המשתמש.
בדיקת יתרה אינה שריון יתרה
עד עכשיו תיארנו מערכת שמקבלת אירועים, מחשבת שימוש ומסיקה ממנו יתרות. קל להתפתות ולכתוב:
var balance = await GetBalanceAsync(customerId);
if (balance >= requiredAmount)
{
await ExecuteOperationAsync();
await ReportUsageAsync(requiredAmount);
}
הקוד הזה הוא המחשה בלבד, אבל הבעיה בו אמיתית.
אם שתי בקשות מגיעות יחד, שתיהן יכולות לקרוא את אותה יתרה. כל אחת עוברת את התנאי, ושתיהן מבצעות את הפעולה.
בנוסף, גם אירוע שכבר נשלח עשוי עדיין להיות בתהליך העיבוד שבין Kafka לנתוני המדידה. לכן קיימים כאן גם מרוץ בין בקשות וגם עיכוב בהשתקפות השימוש.
טרנזקציה ב־PostgreSQL אינה פותרת לבדה את כל התהליך הזה. היא אינה כוללת אוטומטית את ביצוע הפעולה העסקית, את Kafka ואת ClickHouse.
אם נדרשת תקרה קשיחה, צריך מנגנון תיאום או שריון לפני הביצוע. למשל, להקצות תקציב לפעולה באופן אטומי, ובסיומה להתאים אותו לצריכה בפועל. אם מותרת חריגה מוגבלת, אפשר להסתפק במדיניות פשוטה יותר, אבל זו צריכה להיות החלטה מפורשת.
בצ׳אט יש קושי נוסף: את כמות הפלט המדויקת נדע רק בסיום. אפשר להגביל מראש את מספר טוקני הפלט או לשריין תקציב מרבי, ולאחר מכן לדווח את השימוש האמיתי.
גם has_access=true אינו אומר שיש מספיק לכל פעולה אפשרית. הוא מתאר את מצב הגישה לפי הזכאות, ולא מבצע עבורנו השוואה לעלות של הבקשה המסוימת.
איפה Docker נכנסת לתמונה?
כדי להפעיל את המערכת, צריך להרים כמה תהליכים ושירותי תשתית. Docker מאפשרת לארוז ולהריץ אותם כקונטיינרים, ו־Docker Compose מתארת את השירותים, החיבורים וההגדרות שלהם.
ב־Quickstart של הפרויקט מופיעים שרת ה־API, Sink Worker, Balance Worker ותהליכים נוספים, לצד Kafka, ClickHouse, PostgreSQL ו־Redis. כמה מהתהליכים משתמשים באותה Image של OpenMeter, אבל מופעלים באמצעות פקודות שונות:
openmeter
openmeter-sink-worker
openmeter-balance-worker
openmeter-billing-worker
כלומר, Image אחת יכולה להכיל כמה תוכניות, וכל קונטיינר מפעיל את התפקיד שנבחר עבורו.
ההפרדה לקונטיינרים עוזרת בהפעלה ובניהול, אבל אינה יוצרת כשלעצמה שרידות. כמה קונטיינרים על מחשב אחד עדיין תלויים באותו מחשב.
בסביבת ייצור צריך לטפל גם באחסון מתמשך, גיבויים, זמינות של מסדי הנתונים ומדיניות השכפול של Kafka. ואם רוצים להוסיף עוד Workers, צריך לבדוק שהחלוקה למחיצות ושאר הרכיבים מאפשרים לנצל אותם.
מבחינת האפליקציה שלנו, הממשק נשאר ה־API של OpenMeter. היא לא צריכה לכתוב ישירות לטבלאות של ClickHouse או לעדכן יתרות ב־Redis.
מה כדאי לבדוק כשהמספרים לא מסתדרים?
ההפרדה בין הרכיבים נותנת לנו דרך מסודרת לאבחן בעיה.
אם היתרה לא ירדה, אפשר להתחיל בזהות הלקוח ובמסננים של ה־Feature. אירוע שנקלט בהצלחה לא בהכרח מתאים למדידה שהתכוונו אליה.
אם האירוע מתאים אבל עדיין לא מופיע במדידה, צריך לבדוק את מסלול העיבוד: האם הצרכן פעיל, האם נוצר פיגור ב־Kafka, והאם הכתיבה ל־ClickHouse מצליחה.
אם המדידה נכונה אבל היתרה מפתיעה, נבדוק את תקופת הזכאות ואת ההקצאות: מתי הן נכנסו לתוקף, איזו מהן נצרכת קודם, ומה קורה בגבול התקופה.
ויש עוד כשל שנמצא בכלל לפני OpenMeter: הפעולה העסקית הצליחה, אבל האפליקציה נפלה לפני שדיווחה עליה. מערכת מדידה אינה יכולה לספור אירוע שלא הגיע אליה. בתהליך רגיש כדאי לשמור דיווח שממתין לשליחה באופן עמיד, למשל באמצעות Outbox כאשר אפשר לקשור אותו לעדכון העסקי, ולשלוח אותו מחדש עם מזהה קבוע.
לכן הייתי בודק את האינטגרציה גם בתנאי כשל: שולח אותו אירוע פעמיים, מפעיל שתי בקשות במקביל, ומפסיק זמנית את העיבוד כדי לראות מה האפליקציה מציגה בזמן שהנתונים עדיין בדרך. אלה בדיקות שמגלות הרבה יותר משליחת אירוע אחד וראיית היתרה יורדת.
כשמסתכלים על OpenMeter מבחוץ, רואים API שמקבל שימוש ומחזיר יתרה. אחרי שמבינים איך היא בנויה, ברור יותר למה פעולה שנראית כמו חיסור פשוט מפעילה כמה מנגנונים שונים: קליטת אירועים, מניעת כפילויות, חישוב שימוש וניהול הקצאות לאורך זמן.
אפשר כמובן לבנות את כל זה בעצמנו. אבל עכשיו כבר ברור מה בדיוק אנחנו לוקחים על עצמנו כשאנחנו אומרים ״נוסיף עמודת יתרה ונוריד ממנה אחרי כל פעולה״.