שיטות מעשיות למעקב אחרי אפליקציות של AI גנרטיבי ב-Java

1. סקירה כללית

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

בשיעור ה-Lab הזה תיצרו אפליקציית AI גנרטיבית פשוטה. פורסים אותו ב-Cloud Run. בנוסף, תוכלו להשתמש במוצרים ובשירותים של Google Cloud Observability כדי להוסיף לו יכולות חיוניות של מעקב ורישום ביומן.

מה תלמדו

  • כתיבת אפליקציה שמשתמשת ב-Vertex AI עם Cloud Shell Editor
  • שמירת קוד האפליקציה ב-GitHub
  • משתמשים ב-ה-CLI של gcloud כדי לפרוס את קוד המקור של האפליקציה ב-Cloud Run
  • הוספת יכולות מעקב ורישום ביומן לאפליקציית ה-AI הגנרטיבי
  • שימוש במדדים מבוססי-יומנים
  • הטמעה של רישום ביומן ומעקב באמצעות Open Telemetry SDK
  • קבלת תובנות לגבי טיפול בנתונים בהתאם לאתיקה של בינה מלאכותית

2. דרישות מוקדמות

אם עדיין אין לכם חשבון Google, אתם צריכים ליצור חשבון חדש.

3. הגדרת הפרויקט

  1. נכנסים למסוף Google Cloud באמצעות חשבון Google.
  2. יוצרים פרויקט חדש או בוחרים להשתמש מחדש בפרויקט קיים. רושמים את מזהה הפרויקט שיצרתם או בחרתם.
  3. מפעילים את החיוב בפרויקט.
    • העלות של השלמת ה-Lab הזה צריכה להיות פחות מ-5 $בעלויות החיוב.
    • כדי למחוק משאבים ולמנוע חיובים נוספים, אפשר לבצע את השלבים בסוף ה-Lab הזה.
    • משתמשים חדשים זכאים לתקופת ניסיון בחינם בשווי 300$.
  4. מוודאים שהחיוב מופעל בדף My projects (הפרויקטים שלי) בחיוב ב-Cloud
    • אם הפרויקט החדש מופיע עם הערך Billing is disabled בעמודה Billing account:
      1. לוחצים על סמל האפשרויות הנוספות (3 נקודות) בעמודה Actions.
      2. לוחצים על שינוי פרטי החיוב.
      3. בוחרים את החשבון לחיוב שבו רוצים להשתמש.
    • אם אתם משתתפים באירוע בשידור חי, סביר להניח שהחשבון ייקרא חשבון לחיוב ב-Google Cloud Platform לניסיון

4. הכנת Cloud Shell Editor

  1. עוברים אל Cloud Shell Editor. אם מוצגת ההודעה הבאה, שבה מתבקש אישור ל-Cloud Shell להתקשר אל gcloud עם פרטי הכניסה שלכם, לוחצים על Authorize כדי להמשיך.
    לוחצים כדי לתת הרשאה ל-Cloud Shell
  2. פותחים חלון טרמינל
    1. לוחצים על סמל האפשרויות הנוספות (3 קווים) סמל של תפריט האפשרויות הנוספות (3 קווים).
    2. לוחצים על Terminal (מסוף).
    3. לוחצים על New Terminal
      פתיחת טרמינל חדש ב-Cloud Shell Editor (טרמינל חדש).
  3. במסוף, מגדירים את מזהה הפרויקט:
    gcloud config set project [PROJECT_ID]
    
    מחליפים את [PROJECT_ID] במזהה הפרויקט. לדוגמה, אם מזהה הפרויקט הוא lab-example-project, הפקודה תהיה:
    gcloud config set project lab-project-id-example
    
    אם מוצגת ההודעה הבאה, שבה נאמר ש-gcloud מבקש את פרטי הכניסה שלכם ל-GCPI API, לוחצים על Authorize (אישור) כדי להמשיך.
    לוחצים כדי לתת הרשאה ל-Cloud Shell
    אם ההפעלה מצליחה, תוצג ההודעה הבאה:
    Updated property [core/project].
    
    אם מופיע WARNING ומוצגת השאלה Do you want to continue (Y/N)?, כנראה שהזנתם את מזהה הפרויקט בצורה שגויה. מקישים על N, מקישים על Enter ומנסים להריץ שוב את הפקודה gcloud config set project אחרי שמאתרים את מזהה הפרויקט הנכון.
  4. (אופציונלי) אם אתם מתקשים למצוא את מזהה הפרויקט, אתם יכולים להריץ את הפקודה הבאה כדי לראות את מזהה הפרויקט של כל הפרויקטים שלכם, ממוין לפי זמן היצירה בסדר יורד:
    gcloud projects list \
         --format='value(projectId,createTime)' \
         --sort-by=~createTime
    

5. הפעלת ממשקי Google APIs

במסוף, מפעילים את ממשקי Google API שנדרשים ל-Lab הזה:

gcloud services enable \
     run.googleapis.com \
     cloudbuild.googleapis.com \
     aiplatform.googleapis.com \
     logging.googleapis.com \
     monitoring.googleapis.com \
     cloudtrace.googleapis.com

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

Operation "operations/acf.p2-73d90d00-47ee-447a-b600" finished successfully.

אם קיבלתם הודעת שגיאה שמתחילה ב-ERROR: (gcloud.services.enable) HttpError accessing ומכילה פרטי שגיאה כמו בדוגמה שלמטה, נסו להריץ מחדש את הפקודה אחרי השהיה של דקה או שתיים.

"error": {
  "code": 429,
  "message": "Quota exceeded for quota metric 'Mutate requests' and limit 'Mutate requests per minute' of service 'serviceusage.googleapis.com' ...",
  "status": "RESOURCE_EXHAUSTED",
  ...
}

6. יצירת אפליקציית AI גנרטיבית

בשלב הזה תכתבו קוד של אפליקציה פשוטה שמבוססת על בקשות ומשתמשת במודל Gemini כדי להציג 10 עובדות מעניינות על חיה שתבחרו. כדי ליצור את קוד האפליקציה:

  1. בטרמינל, יוצרים את הספרייה codelab-o11y:
    mkdir "${HOME}/codelab-o11y"
    
  2. משנים את הספרייה הנוכחית ל-codelab-o11y:
    cd "${HOME}/codelab-o11y"
    
  3. מורידים את קוד ה-bootstrap של אפליקציית Java באמצעות Spring framework starter:
    curl https://start.spring.io/starter.zip \
        -d dependencies=web \
        -d javaVersion=17 \
        -d type=maven-project \
        -d bootVersion=3.4.1 -o java-starter.zip
    
  4. מבטלים את הארכיון של קוד ה-bootstrap לתיקייה הנוכחית:
    unzip java-starter.zip
    
  5. מסירים את קובץ הארכיון מהתיקייה:
    rm java-starter.zip
    
  6. יוצרים קובץ project.toml כדי להגדיר את גרסת Java Runtime שבה יש להשתמש כשפורסים את הקוד ב-Cloud Run:
    cat > "${HOME}/codelab-o11y/project.toml" << EOF
    [[build.env]]
        name = "GOOGLE_RUNTIME_VERSION"
        value = "17"
    EOF
    
  7. מוסיפים את יחסי התלות של Google Cloud SDK לקובץ pom.xml:
    1. מוסיפים את חבילת הליבה של Google Cloud:
      sed -i 's/<dependencies>/<dependencies>\
      \
              <dependency>\
                  <groupId>com.google.cloud<\/groupId>\
                  <artifactId>google-cloud-core<\/artifactId>\
                  <version>2.49.1<\/version>\
              <\/dependency>\
              /g' "${HOME}/codelab-o11y/pom.xml"
      
    2. מוסיפים חבילה של Vertex AI מ-Google Cloud:
      sed -i 's/<dependencies>/<dependencies>\
      \
              <dependency>\
                  <groupId>com.google.cloud<\/groupId>\
                  <artifactId>google-cloud-vertexai<\/artifactId>\
                  <version>1.16.0<\/version>\
              <\/dependency>\
              /g' "${HOME}/codelab-o11y/pom.xml"
      
  8. פותחים את הקובץ DemoApplication.java ב-Cloud Shell Editor:
    cloudshell edit "${HOME}/codelab-o11y/src/main/java/com/example/demo/DemoApplication.java"
    
    קוד מקור עם פיגומים של הקובץ DemoApplication.java אמור להופיע עכשיו בחלון העריכה מעל הטרמינל. קוד המקור של הקובץ ייראה כך:
    package com.example.demo;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    
    @SpringBootApplication
    public class DemoApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(DemoApplication.class, args);
        }
    }
    
  9. מחליפים את הקוד בעורך בגרסה שמוצגת למטה. כדי להחליף את הקוד, מוחקים את התוכן של הקובץ ומעתיקים את הקוד שלמטה אל העורך:
    package com.example.demo;
    
    import java.io.IOException;
    import java.util.Collections;
    
    import javax.annotation.PostConstruct;
    import javax.annotation.PreDestroy;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import org.springframework.web.bind.annotation.RestController;
    
    import com.google.cloud.ServiceOptions;
    import com.google.cloud.vertexai.VertexAI;
    import com.google.cloud.vertexai.api.GenerateContentResponse;
    import com.google.cloud.vertexai.generativeai.GenerativeModel;
    import com.google.cloud.vertexai.generativeai.ResponseHandler;
    
    @SpringBootApplication
    public class DemoApplication {
    
        public static void main(String[] args) {
            String port = System.getenv().getOrDefault("PORT", "8080");
            SpringApplication app = new SpringApplication(DemoApplication.class);
            app.setDefaultProperties(Collections.singletonMap("server.port", port));
            app.run(args);
        }
    }
    
    @RestController
    class HelloController {
        private final String projectId = ServiceOptions.getDefaultProjectId();
        private VertexAI vertexAI;
        private GenerativeModel model;
    
        @PostConstruct
        public void init() {
            vertexAI = new VertexAI(projectId, "us-central1");
            model = new GenerativeModel("gemini-1.5-flash", vertexAI);
        }
    
        @PreDestroy
        public void destroy() {
            vertexAI.close();
        }
    
        @GetMapping("/")
        public String getFacts(@RequestParam(defaultValue = "dog") String animal) throws IOException {
            String prompt = "Give me 10 fun facts about " + animal + ". Return this as html without backticks.";
            GenerateContentResponse response = model.generateContent(prompt);
            return ResponseHandler.getText(response);
        }
    }
    
    אחרי כמה שניות, Cloud Shell Editor ישמור את הקוד באופן אוטומטי.

פריסת הקוד של אפליקציית ה-AI הגנרטיבי ב-Cloud Run

  1. בחלון הטרמינל, מריצים את הפקודה לפריסת קוד המקור של האפליקציה ב-Cloud Run.
    gcloud run deploy codelab-o11y-service \
         --source="${HOME}/codelab-o11y/" \
         --region=us-central1 \
         --allow-unauthenticated
    
    אם מוצגת ההודעה הבאה, שמציינת שהפקודה תיצור מאגר חדש. לוחצים על Enter.
    Deploying from source requires an Artifact Registry Docker repository to store built containers.
    A repository named [cloud-run-source-deploy] in region [us-central1] will be created.
    
    Do you want to continue (Y/n)?
    
    תהליך הפריסה עשוי להימשך כמה דקות. אחרי שתהליך הפריסה יושלם, יוצגו נתונים שדומים לאלה שמופיעים בדוגמה הבאה:
    Service [codelab-o11y-service] revision [codelab-o11y-service-00001-t2q] has been deployed and is serving 100 percent of traffic.
    Service URL: https://codelab-o11y-service-12345678901.us-central1.run.app
    
  2. מעתיקים את כתובת ה-URL של שירות Cloud Run שמוצגת לכרטיסייה או לחלון נפרדים בדפדפן. לחלופין, מריצים את הפקודה הבאה בטרמינל כדי להדפיס את כתובת ה-URL של השירות, ולוחצים על כתובת ה-URL שמוצגת תוך כדי החזקת המקש Ctrl כדי לפתוח את כתובת ה-URL:
    gcloud run services list \
         --format='value(URL)' \
         --filter='SERVICE:"codelab-o11y-service"'
    
    כשפותחים את כתובת האתר, יכול להיות שתופיע שגיאה 500 או ההודעה:
    Sorry, this is just a placeholder...
    
    המשמעות היא שהפריסה של השירותים לא הסתיימה. ממתינים כמה רגעים ומרעננים את הדף. בסוף תראו טקסט שמתחיל במילים עובדות מעניינות על כלבים ומכיל 10 עובדות מעניינות על כלבים.

נסו לקיים אינטראקציה עם האפליקציה כדי לקבל עובדות מעניינות על בעלי חיים שונים. כדי לעשות זאת, מוסיפים את הפרמטר animal לכתובת ה-URL, כמו ?animal=[ANIMAL], כאשר [ANIMAL] הוא שם של חיה. לדוגמה, מוסיפים ?animal=cat כדי לקבל 10 עובדות משעשעות על חתולים או ?animal=sea turtle כדי לקבל 10 עובדות משעשעות על צבי ים.

7. ביקורת על הקריאות ל-Vertex API

ביקורת על קריאות ל-Google API מספקת תשובות לשאלות כמו "מי קרא ל-API מסוים, איפה ומתי?". ביקורת היא חשובה כשמנסים לפתור בעיות באפליקציה, כשבודקים את צריכת המשאבים או כשמבצעים ניתוח משפטי של תוכנה.

יומני ביקורת מאפשרים לעקוב אחרי פעילויות אדמין ופעילויות מערכת, וגם לרשום ביומן קריאות לפעולות API של 'קריאת נתונים' ו'כתיבת נתונים'. כדי לבצע ביקורת על בקשות ל-Vertex AI ליצירת תוכן, צריך להפעיל יומני ביקורת של 'קריאת נתונים' במסוף Cloud.

  1. לוחצים על הלחצן שלמטה כדי לפתוח את הדף Audit Logs במסוף Cloud

  2. מוודאים שהפרויקט שיצרתם לשיעור ה-Lab הזה נבחר בדף. הפרויקט שנבחר מוצג בפינה הימנית העליונה של הדף, מימין לתפריט ההמבורגר:
    התפריט הנפתח של הפרויקט במסוף Google Cloud
    אם צריך, בוחרים את הפרויקט הנכון מתיבת הבחירה.
  3. בטבלה Data Access audit logs configuration, בעמודה Service, מוצאים את השירות Vertex AI API ומסמנים את התיבה שמשמאל לשם השירות כדי לבחור אותו.
    בוחרים באפשרות Vertex AI API.
  4. בחלונית המידע שמשמאל, בוחרים בסוג הביקורת 'קריאת נתונים'.
    בדיקת יומני קריאת נתונים
  5. לוחצים על שמירה.

כדי ליצור יומני ביקורת, פותחים את כתובת ה-URL של השירות. כדי לקבל תוצאות שונות, צריך לרענן את הדף תוך כדי שינוי הערך של הפרמטר ?animal=.

עיון ביומני ביקורת

  1. לוחצים על הלחצן שלמטה כדי לפתוח את הדף Logs Explorer במסוף Cloud:

  2. מדביקים את המסנן הבא בחלונית Query (שאילתה).
    LOG_ID("cloudaudit.googleapis.com%2Fdata_access") AND
    protoPayload.serviceName="aiplatform.googleapis.com"
    
    חלונית השאילתה היא עורך שנמצא בחלק העליון של הדף Logs Explorer:
    שאילתות ביומני ביקורת
  3. לוחצים על Run query.
  4. בוחרים באחת מהרשומות ביומן הביקורת ומרחיבים את השדות כדי לבדוק את המידע שמתועד ביומן.
    אפשר לראות פרטים על קריאה ל-Vertex API, כולל השיטה והמודל שנעשה בהם שימוש. אפשר גם לראות את הזהות של מי שהפעיל את השיטה ואילו הרשאות אישרו את הקריאה.

8. רישום אינטראקציות עם AI גנרטיבי

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

ההטמעה משתמשת ב-Logback עם Spring Boot כדי להדפיס יומנים של אפליקציות לפלט רגיל. השיטה הזו כוללת את היכולת של Cloud Run לתעד מידע שמוצג בפלט רגיל ולהטמיע אותו ב-Cloud Logging באופן אוטומטי. כדי לתעד מידע כנתונים מובְנים, צריך לעצב את היומנים המודפסים בהתאם. כדי להוסיף לאפליקציה יכולות של רישום ביומן במבנה, פועלים לפי ההוראות שבהמשך.

  1. חוזרים לחלון (או לכרטיסייה) Cloud Shell בדפדפן.
  2. יוצרים ופותחים קובץ חדש LoggingEventGoogleCloudEncoder.java ב-Cloud Shell Editor:
    cloudshell edit "${HOME}/codelab-o11y/src/main/java/com/example/demo/LoggingEventGoogleCloudEncoder.java"
    
  3. מעתיקים ומדביקים את הקוד הבא כדי להטמיע מקודד Logback שמקודד את היומן כ-JSON עם מחרוזת, לפי פורמט היומן המובנה של Google Cloud:
    package com.example.demo;
    
    import static ch.qos.logback.core.CoreConstants.UTF_8_CHARSET;
    
    import java.time.Instant;
    import ch.qos.logback.core.encoder.EncoderBase;
    import ch.qos.logback.classic.Level;
    import ch.qos.logback.classic.spi.ILoggingEvent;
    import java.util.HashMap;
    
    import com.google.gson.Gson;
    
    public class LoggingEventGoogleCloudEncoder extends EncoderBase<ILoggingEvent>  {
        private static final byte[] EMPTY_BYTES = new byte[0];
        private final Gson gson = new Gson();
    
        @Override
        public byte[] headerBytes() {
            return EMPTY_BYTES;
        }
    
        @Override
        public byte[] encode(ILoggingEvent e) {
            var timestamp = Instant.ofEpochMilli(e.getTimeStamp());
            var fields = new HashMap<String, Object>() {
                {
                    put("timestamp", timestamp.toString());
                    put("severity", severityFor(e.getLevel()));
                    put("message", e.getMessage());
                }
            };
            var params = e.getKeyValuePairs();
            if (params != null && params.size() > 0) {
                params.forEach(kv -> fields.putIfAbsent(kv.key, kv.value));
            }
            var data = gson.toJson(fields) + "\n";
            return data.getBytes(UTF_8_CHARSET);
        }
    
        @Override
        public byte[] footerBytes() {
            return EMPTY_BYTES;
        }
    
        private static String severityFor(Level level) {
            switch (level.toInt()) {
                case Level.TRACE_INT:
                return "DEBUG";
                case Level.DEBUG_INT:
                return "DEBUG";
                case Level.INFO_INT:
                return "INFO";
                case Level.WARN_INT:
                return "WARNING";
                case Level.ERROR_INT:
                return "ERROR";
                default:
                return "DEFAULT";
            }
        }
    }
    
  4. יוצרים ופותחים קובץ חדש logback.xml ב-Cloud Shell Editor:
    cloudshell edit "${HOME}/codelab-o11y/src/main/resources/logback.xml"
    
  5. מעתיקים ומדביקים את קובץ ה-XML הבא כדי להגדיר את Logback לשימוש במקודד עם Logback appender שמדפיס יומנים לפלט רגיל:
    <?xml version="1.0" encoding="UTF-8"?>
    <configuration debug="true">
        <appender name="Console" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="com.example.demo.LoggingEventGoogleCloudEncoder"/>
        </appender>
    
        <root level="info">
            <appender-ref ref="Console" />
        </root>
    </configuration>
    
  6. פותחים מחדש את הקובץ DemoApplication.java ב-Cloud Shell Editor:
    cloudshell edit "${HOME}/codelab-o11y/src/main/java/com/example/demo/DemoApplication.java"
    
  7. כדי לרשום ביומן את הבקשה והתשובה של ה-AI הגנרטיבי, מחליפים את הקוד בעורך בגרסה שמוצגת בהמשך. כדי להחליף את הקוד, מוחקים את התוכן של הקובץ ומעתיקים את הקוד שלמטה אל העורך:
    package com.example.demo;
    
    import java.io.IOException;
    import java.util.Collections;
    
    import javax.annotation.PostConstruct;
    import javax.annotation.PreDestroy;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import org.springframework.web.bind.annotation.RestController;
    
    import com.google.cloud.ServiceOptions;
    import com.google.cloud.vertexai.VertexAI;
    import com.google.cloud.vertexai.api.GenerateContentResponse;
    import com.google.cloud.vertexai.generativeai.GenerativeModel;
    import com.google.cloud.vertexai.generativeai.ResponseHandler;
    
    @SpringBootApplication
    public class DemoApplication {
    
        public static void main(String[] args) {
            String port = System.getenv().getOrDefault("PORT", "8080");
            SpringApplication app = new SpringApplication(DemoApplication.class);
            app.setDefaultProperties(Collections.singletonMap("server.port", port));
            app.run(args);
        }
    }
    
    @RestController
    class HelloController {
        private final String projectId = ServiceOptions.getDefaultProjectId();
        private VertexAI vertexAI;
        private GenerativeModel model;
        private final Logger LOGGER = LoggerFactory.getLogger(HelloController.class);
    
        @PostConstruct
        public void init() {
            vertexAI = new VertexAI(projectId, "us-central1");
            model = new GenerativeModel("gemini-1.5-flash", vertexAI);
        }
    
        @PreDestroy
        public void destroy() {
            vertexAI.close();
        }
    
        @GetMapping("/")
        public String getFacts(@RequestParam(defaultValue = "dog") String animal) throws IOException {
            String prompt = "Give me 10 fun facts about " + animal + ". Return this as html without backticks.";
            GenerateContentResponse response = model.generateContent(prompt);
            LOGGER.atInfo()
                    .addKeyValue("animal", animal)
                    .addKeyValue("prompt", prompt)
                    .addKeyValue("response", response)
                    .log("Content is generated");
            return ResponseHandler.getText(response);
        }
    }
    

אחרי כמה שניות, Cloud Shell Editor שומר את השינויים באופן אוטומטי.

פריסת הקוד של אפליקציית ה-AI הגנרטיבי ב-Cloud Run

  1. בחלון הטרמינל, מריצים את הפקודה לפריסת קוד המקור של האפליקציה ב-Cloud Run.
    gcloud run deploy codelab-o11y-service \
         --source="${HOME}/codelab-o11y/" \
         --region=us-central1 \
         --allow-unauthenticated
    
    אם מוצגת ההודעה הבאה, שמציינת שהפקודה תיצור מאגר חדש. לוחצים על Enter.
    Deploying from source requires an Artifact Registry Docker repository to store built containers.
    A repository named [cloud-run-source-deploy] in region [us-central1] will be created.
    
    Do you want to continue (Y/n)?
    
    תהליך הפריסה עשוי להימשך כמה דקות. אחרי שתהליך הפריסה יושלם, יוצגו נתונים שדומים לאלה שמופיעים בדוגמה הבאה:
    Service [codelab-o11y-service] revision [codelab-o11y-service-00001-t2q] has been deployed and is serving 100 percent of traffic.
    Service URL: https://codelab-o11y-service-12345678901.us-central1.run.app
    
  2. מעתיקים את כתובת ה-URL של שירות Cloud Run שמוצגת לכרטיסייה או לחלון נפרדים בדפדפן. לחלופין, מריצים את הפקודה הבאה בטרמינל כדי להדפיס את כתובת ה-URL של השירות, ולוחצים על כתובת ה-URL שמוצגת תוך כדי החזקת המקש Ctrl כדי לפתוח את כתובת ה-URL:
    gcloud run services list \
         --format='value(URL)' \
         --filter='SERVICE:"codelab-o11y-service"'
    
    כשפותחים את כתובת האתר, יכול להיות שתופיע שגיאה 500 או ההודעה:
    Sorry, this is just a placeholder...
    
    המשמעות היא שהפריסה של השירותים לא הסתיימה. ממתינים כמה רגעים ומרעננים את הדף. בסוף תראו טקסט שמתחיל במילים עובדות מעניינות על כלבים ומכיל 10 עובדות מעניינות על כלבים.

כדי ליצור יומני אפליקציות, פותחים את כתובת ה-URL של השירות. כדי לקבל תוצאות שונות, צריך לרענן את הדף תוך כדי שינוי הערך של הפרמטר ?animal=.
כדי לראות את יומני האפליקציה:

  1. לוחצים על הלחצן שלמטה כדי לפתוח את הדף Logs Explorer במסוף Cloud:

  2. מדביקים את המסנן הבא בחלונית Query (#2 בממשק Log explorer):
    LOG_ID("run.googleapis.com%2Fstdout") AND
    severity=DEBUG
    
  3. לוחצים על Run query.

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

9. ספירת אינטראקציות עם AI גנרטיבי

‫Cloud Run כותב מדדים מנוהלים שאפשר להשתמש בהם כדי לעקוב אחרי שירותים שנפרסו. מדדי מעקב בניהול המשתמשים מאפשרים שליטה רבה יותר בנתונים ובתדירות העדכון של המדדים. כדי להטמיע מדד כזה, צריך לכתוב קוד שאוסף נתונים וכותב אותם ל-Cloud Monitoring. בשלב הבא (אופציונלי) מוסבר איך ליישם את התכונה באמצעות OpenTelemetry SDK.

בשלב הזה מוצגת חלופה להטמעת מדד משתמש בקוד – מדדים מבוססי-יומן. מדדים מבוססי-יומן מאפשרים לכם ליצור מדדי מעקב מתוך רשומות היומן שהאפליקציה שלכם כותבת ל-Cloud Logging. נשתמש ביומני האפליקציה שהטמענו בשלב הקודם כדי להגדיר מדד מבוסס-יומן מסוג counter. המדד יספור את מספר הקריאות ל-Vertex API שבוצעו בהצלחה.

  1. בודקים את החלון של Logs explorer שבו השתמשנו בשלב הקודם. בחלונית Query (שאילתה), מאתרים את התפריט הנפתח Actions (פעולות) ולוחצים עליו כדי לפתוח אותו. בצילום המסך שלמטה אפשר לראות איפה נמצא התפריט:
    סרגל הכלים של תוצאות השאילתה עם התפריט הנפתח &#39;פעולות&#39;
  2. בתפריט שנפתח, בוחרים באפשרות Create metric כדי לפתוח את החלונית Create log-based metric.
  3. כדי להגדיר מדד חדש של מונה בחלונית Create log-based metric (יצירת מדד שמבוסס על יומן):
    1. מגדירים את סוג המדד: בוחרים באפשרות מונה.
    2. מגדירים את השדות הבאים בקטע פרטים:
      • שם מדד היומן: מגדירים את השם לערך model_interaction_count. יש הגבלות מסוימות על שמות. פרטים נוספים זמינים במאמר פתרון בעיות בנושא הגבלות על שמות.
      • תיאור: מזינים תיאור למדד. לדוגמה, Number of log entries capturing successful call to model inference..
      • יחידות: משאירים את השדה הזה ריק או מזינים את הספרה 1.
    3. משאירים את הערכים בקטע בחירת מסנן. שימו לב שבשדה Build filter (יצירת מסנן) מופיע אותו מסנן שבו השתמשנו כדי לראות את יומני האפליקציה.
    4. (אופציונלי) מוסיפים תווית שתעזור לספור את מספר השיחות לכל חיה. הערה: התווית הזו יכולה להגדיל מאוד את הקרדינליות של המדד, ולא מומלץ להשתמש בה בסביבת הייצור:
      1. לוחצים על הוספת תווית.
      2. מגדירים את השדות הבאים בקטע תוויות:
        • שם התווית: מגדירים את השם לערך animal.
        • תיאור: מזינים את התיאור של התווית. לדוגמה, Animal parameter.
        • סוג התווית: בוחרים באפשרות STRING.
        • שם השדה: סוג jsonPayload.animal.
        • ביטוי רגולרי: משאירים את השדה ריק.
      3. לוחצים על סיום
    5. לוחצים על יצירת מדד כדי ליצור את המדד.

אפשר גם ליצור מדד מבוסס-יומן מהדף מדדים מבוססי-יומן, באמצעות gcloud logging metrics create פקודת CLI או באמצעות google_logging_metric משאב Terraform.

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

מזינים את שאילתת PromQL כדי לחפש את נתוני המדדים שמבוססים על יומנים. כדי להזין שאילתת PromQL:

  1. לוחצים על הלחצן שלמטה כדי לפתוח את הדף Metrics Explorer במסוף Cloud:

  2. בסרגל הכלים של החלונית ליצירת שאילתות, לוחצים על הלחצן ששמו < > MQL או < > PromQL. התמונה שלמטה מראה איפה נמצא הכפתור.
    המיקום של כפתור MQL בכלי לבחירת מדדים
  3. מוודאים שהאפשרות PromQL נבחרה במתג שפה. המתג לשפה נמצא באותה סרגל כלים שבו אפשר לעצב את השאילתה.
  4. מזינים את השאילתה בעורך השאילתות:
    sum(rate(logging_googleapis_com:user_model_interaction_count{monitored_resource="cloud_run_revision"}[${__interval}]))
    
    מידע נוסף על השימוש ב-PromQL זמין במאמר PromQL ב-Cloud Monitoring.
  5. לוחצים על הרצת השאילתה. יוצג תרשים קו שדומה לצילום המסך הזה:
    הצגת המדדים שנכללו בשאילתה

    שימו לב: כשהמתג הפעלה אוטומטית מופעל, הכפתור הפעלת שאילתה לא מוצג.

10. (אופציונלי) שימוש בטלמטריה פתוחה למעקב ולתיעוד

כמו שצוין בשלב הקודם, אפשר להטמיע מדדים באמצעות OpenTelemetry ‏ (Otel) SDK. מומלץ להשתמש ב-OTel בארכיטקטורות מרובות שירותים. בשלב הזה נראה איך מוסיפים אינסטרומנטציה של OTel לאפליקציית Spring Boot. בשלב הזה תבצעו את הפעולות הבאות:

  • הטמעה של יכולות מעקב אוטומטי באפליקציית Spring Boot
  • הטמעה של מדד מונה כדי לעקוב אחרי מספר הקריאות המוצלחות למודל
  • קורלציה בין מעקב לבין יומני אפליקציות

הארכיטקטורה המומלצת לשירותים ברמת המוצר היא שימוש ב-OTel collector כדי לאסוף את כל נתוני יכולת התצפית מכמה שירותים ולהטמיע אותם. הקוד בשלב הזה לא משתמש ב-collector כדי לפשט את התהליך. במקום זאת, הוא משתמש בייצוא OTel שכותב נתונים ישירות ל-Google Cloud.

הגדרת אפליקציית Spring Boot עם רכיבי OTel ומעקב אוטומטי

  1. חוזרים לחלון (או לכרטיסייה) Cloud Shell בדפדפן.
  2. בטרמינל, מעדכנים את הקובץ application.permissions עם פרמטרים נוספים של הגדרות:
    cat >> "${HOME}/codelab-o11y/src/main/resources/application.properties" << EOF
    otel.logs.exporter=none
    otel.traces.exporter=google_cloud_trace
    otel.metrics.exporter=google_cloud_monitoring
    otel.resource.attributes.service.name=codelab-o11y-service
    otel.traces.sampler=always_on
    EOF
    
    הפרמטרים האלה מגדירים ייצוא של נתוני יכולת צפייה ל-Cloud Trace ול-Cloud Monitoring, ומחילים דגימה של כל העקבות.
  3. מוסיפים את יחסי התלות הנדרשים של OpenTelemetry לקובץ pom.xml:
    sed -i 's/<dependencies>/<dependencies>\
    \
            <dependency>\
                <groupId>io.opentelemetry.instrumentation<\/groupId>\
                <artifactId>opentelemetry-spring-boot-starter<\/artifactId>\
            <\/dependency>\
            <dependency>\
                <groupId>com.google.cloud.opentelemetry<\/groupId>\
                <artifactId>exporter-auto<\/artifactId>\
                <version>0.33.0-alpha<\/version>\
            <\/dependency>\
            <dependency>\
                <groupId>com.google.cloud.opentelemetry<\/groupId>\
                <artifactId>exporter-trace<\/artifactId>\
                <version>0.33.0<\/version>\
            <\/dependency>\
            <dependency>\
                <groupId>com.google.cloud.opentelemetry<\/groupId>\
                <artifactId>exporter-metrics<\/artifactId>\
                <version>0.33.0<\/version>\
            <\/dependency>\
    /g' "${HOME}/codelab-o11y/pom.xml"
    
  4. מוסיפים את OpenTelemetry BOM לקובץ pom.xml:
    sed -i 's/<\/properties>/<\/properties>\
        <dependencyManagement>\
            <dependencies>\
                <dependency>\
                    <groupId>io.opentelemetry.instrumentation<\/groupId>\
                    <artifactId>opentelemetry-instrumentation-bom<\/artifactId>\
                    <version>2.12.0<\/version>\
                    <type>pom<\/type>\
                    <scope>import<\/scope>\
                <\/dependency>\
            <\/dependencies>\
        <\/dependencyManagement>\
    /g' "${HOME}/codelab-o11y/pom.xml"
    
  5. פותחים מחדש את הקובץ DemoApplication.java ב-Cloud Shell Editor:
    cloudshell edit "${HOME}/codelab-o11y/src/main/java/com/example/demo/DemoApplication.java"
    
  6. מחליפים את הקוד הנוכחי בגרסה שמגדילה מדד לבדיקת ביצועים. כדי להחליף את הקוד, מוחקים את התוכן של הקובץ ומעתיקים את הקוד שלמטה אל העורך:
    package com.example.demo;
    
    import io.opentelemetry.api.common.AttributeKey;
    import io.opentelemetry.api.common.Attributes;
    import io.opentelemetry.api.OpenTelemetry;
    import io.opentelemetry.api.metrics.LongCounter;
    
    import java.io.IOException;
    import java.util.Collections;
    
    import javax.annotation.PostConstruct;
    import javax.annotation.PreDestroy;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import org.springframework.web.bind.annotation.RestController;
    
    import com.google.cloud.ServiceOptions;
    import com.google.cloud.vertexai.VertexAI;
    import com.google.cloud.vertexai.api.GenerateContentResponse;
    import com.google.cloud.vertexai.generativeai.GenerativeModel;
    import com.google.cloud.vertexai.generativeai.ResponseHandler;
    
    
    @SpringBootApplication
    public class DemoApplication {
    
        public static void main(String[] args) {
            String port = System.getenv().getOrDefault("PORT", "8080");
            SpringApplication app = new SpringApplication(DemoApplication.class);
            app.setDefaultProperties(Collections.singletonMap("server.port", port));
            app.run(args);
        }
    }
    
    @RestController
    class HelloController {
        private final String projectId = ServiceOptions.getDefaultProjectId();
        private VertexAI vertexAI;
        private GenerativeModel model;
        private final Logger LOGGER = LoggerFactory.getLogger(HelloController.class);
        private static final String INSTRUMENTATION_NAME = "genai-o11y/java/workshop/example";
        private static final AttributeKey<String> ANIMAL = AttributeKey.stringKey("animal");
        private final LongCounter counter;
    
        public HelloController(OpenTelemetry openTelemetry) {
            this.counter = openTelemetry.getMeter(INSTRUMENTATION_NAME)
                    .counterBuilder("model_call_counter")
                    .setDescription("Number of successful model calls")
                    .build();
        }
    
        @PostConstruct
        public void init() {
            vertexAI = new VertexAI(projectId, "us-central1");
            model = new GenerativeModel("gemini-1.5-flash", vertexAI);
        }
    
        @PreDestroy
        public void destroy() {
            vertexAI.close();
        }
    
        @GetMapping("/")
        public String getFacts(@RequestParam(defaultValue = "dog") String animal) throws IOException {
            String prompt = "Give me 10 fun facts about " + animal + ". Return this as html without backticks.";
            GenerateContentResponse response = model.generateContent(prompt);
            LOGGER.atInfo()
                    .addKeyValue("animal", animal)
                    .addKeyValue("prompt", prompt)
                    .addKeyValue("response", response)
                    .log("Content is generated");
            counter.add(1, Attributes.of(ANIMAL, animal));
            return ResponseHandler.getText(response);
        }
    }
    
  7. פותחים מחדש את הקובץ LoggingEventGoogleCloudEncoder.java ב-Cloud Shell Editor:
    cloudshell edit "${HOME}/codelab-o11y/src/main/java/com/example/demo/LoggingEventGoogleCloudEncoder.java"
    
  8. מחליפים את הקוד הנוכחי בגרסה שמוסיפה מאפייני מעקב ליומני הרישום שנכתבו. הוספת המאפיינים מאפשרת לקשר בין היומנים לבין טווחי המעקב הנכונים. כדי להחליף את הקוד, מוחקים את התוכן של הקובץ ומעתיקים את הקוד שלמטה אל העורך:
    package com.example.demo;
    
    import static ch.qos.logback.core.CoreConstants.UTF_8_CHARSET;
    
    import java.time.Instant;
    import java.util.HashMap;
    
    import ch.qos.logback.core.encoder.EncoderBase;
    import ch.qos.logback.classic.Level;
    import ch.qos.logback.classic.spi.ILoggingEvent;
    import com.google.cloud.ServiceOptions;
    import io.opentelemetry.api.trace.Span;
    import io.opentelemetry.api.trace.SpanContext;
    import io.opentelemetry.context.Context;
    
    import com.google.gson.Gson;
    
    
    public class LoggingEventGoogleCloudEncoder extends EncoderBase<ILoggingEvent>  {
        private static final byte[] EMPTY_BYTES = new byte[0];
        private final Gson gson;
        private final String projectId;
        private final String tracePrefix;
    
    
        public LoggingEventGoogleCloudEncoder() {
            this.gson = new Gson();
            this.projectId = lookUpProjectId();
            this.tracePrefix = "projects/" + (projectId == null ? "" : projectId) + "/traces/";
        }
    
        private static String lookUpProjectId() {
            return ServiceOptions.getDefaultProjectId();
        }
    
        @Override
        public byte[] headerBytes() {
            return EMPTY_BYTES;
        }
    
        @Override
        public byte[] encode(ILoggingEvent e) {
            var timestamp = Instant.ofEpochMilli(e.getTimeStamp());
            var fields = new HashMap<String, Object>() {
                {
                    put("timestamp", timestamp.toString());
                    put("severity", severityFor(e.getLevel()));
                    put("message", e.getMessage());
                    SpanContext context = Span.fromContext(Context.current()).getSpanContext();
                    if (context.isValid()) {
                        put("logging.googleapis.com/trace", tracePrefix + context.getTraceId());
                        put("logging.googleapis.com/spanId", context.getSpanId());
                        put("logging.googleapis.com/trace_sampled", Boolean.toString(context.isSampled()));
                    }
                }
            };
            var params = e.getKeyValuePairs();
            if (params != null && params.size() > 0) {
                params.forEach(kv -> fields.putIfAbsent(kv.key, kv.value));
            }
            var data = gson.toJson(fields) + "\n";
            return data.getBytes(UTF_8_CHARSET);
        }
    
        @Override
        public byte[] footerBytes() {
            return EMPTY_BYTES;
        }
    
        private static String severityFor(Level level) {
            switch (level.toInt()) {
                case Level.TRACE_INT:
                return "DEBUG";
                case Level.DEBUG_INT:
                return "DEBUG";
                case Level.INFO_INT:
                return "INFO";
                case Level.WARN_INT:
                return "WARNING";
                case Level.ERROR_INT:
                return "ERROR";
                default:
                return "DEFAULT";
            }
        }
    }
    

אחרי כמה שניות, Cloud Shell Editor שומר את השינויים באופן אוטומטי.

פריסת הקוד של אפליקציית ה-AI הגנרטיבי ב-Cloud Run

  1. בחלון הטרמינל, מריצים את הפקודה לפריסת קוד המקור של האפליקציה ב-Cloud Run.
    gcloud run deploy codelab-o11y-service \
         --source="${HOME}/codelab-o11y/" \
         --region=us-central1 \
         --allow-unauthenticated
    
    אם מוצגת ההודעה הבאה, שמציינת שהפקודה תיצור מאגר חדש. לוחצים על Enter.
    Deploying from source requires an Artifact Registry Docker repository to store built containers.
    A repository named [cloud-run-source-deploy] in region [us-central1] will be created.
    
    Do you want to continue (Y/n)?
    
    תהליך הפריסה עשוי להימשך כמה דקות. אחרי שתהליך הפריסה יושלם, יוצגו נתונים שדומים לאלה שמופיעים בדוגמה הבאה:
    Service [codelab-o11y-service] revision [codelab-o11y-service-00001-t2q] has been deployed and is serving 100 percent of traffic.
    Service URL: https://codelab-o11y-service-12345678901.us-central1.run.app
    
  2. מעתיקים את כתובת ה-URL של שירות Cloud Run שמוצגת לכרטיסייה או לחלון נפרדים בדפדפן. לחלופין, מריצים את הפקודה הבאה בטרמינל כדי להדפיס את כתובת ה-URL של השירות, ולוחצים על כתובת ה-URL שמוצגת תוך כדי החזקת המקש Ctrl כדי לפתוח את כתובת ה-URL:
    gcloud run services list \
         --format='value(URL)' \
         --filter='SERVICE:"codelab-o11y-service"'
    
    כשפותחים את כתובת האתר, יכול להיות שתופיע שגיאה 500 או ההודעה:
    Sorry, this is just a placeholder...
    
    המשמעות היא שהפריסה של השירותים לא הסתיימה. ממתינים כמה רגעים ומרעננים את הדף. בסוף תראו טקסט שמתחיל במילים עובדות מעניינות על כלבים ומכיל 10 עובדות מעניינות על כלבים.

כדי ליצור נתוני טלמטריה, פותחים את כתובת ה-URL של השירות. כדי לקבל תוצאות שונות, צריך לרענן את הדף תוך כדי שינוי הערך של הפרמטר ?animal=.

עיון בנתוני מעקב של אפליקציות

  1. כדי לפתוח את הדף Trace explorer במסוף Cloud, לוחצים על הלחצן שלמטה:

  2. בוחרים באחד מהמעקבים האחרונים. אמורים להופיע 5 או 6 טווחי כתובות IP שדומים למה שרואים בצילום המסך שלמטה.
    תצוגה של יחידה לוגית למעקב של האפליקציה בכלי לבדיקת עקבות
  3. מחפשים את היחידה הלוגית למעקב שעוקבת אחרי הקריאה לגורם המטפל באירועים (השיטה fun_facts). זה יהיה התג האחרון עם השם /.
  4. בחלונית פרטי המעקב, בוחרים באפשרות יומנים ואירועים. יוצגו יומני אפליקציות שקשורים ליחידה הלוגית למעקב הספציפית הזו. הקורלציה מזוהה באמצעות מזהי העקבות והטווחים בעקבות וביומן. אמור להופיע יומן האפליקציה שכתבה את ההנחיה ואת התשובה של Vertex API.

עיון במדד של מונה

  1. לוחצים על הלחצן שלמטה כדי לפתוח את הדף Metrics Explorer במסוף Cloud:

  2. בסרגל הכלים של החלונית ליצירת שאילתות, לוחצים על הלחצן ששמו < > MQL או < > PromQL. התמונה שלמטה מראה איפה נמצא הכפתור.
    המיקום של כפתור MQL בכלי לבחירת מדדים
  3. מוודאים שהאפשרות PromQL נבחרה במתג שפה. המתג לשפה נמצא באותה סרגל כלים שבו אפשר לעצב את השאילתה.
  4. מזינים את השאילתה בעורך השאילתות:
    sum(rate(workload_googleapis_com:model_call_counter{monitored_resource="generic_task"}[${__interval}]))
    
  5. לוחצים על Run Query (הפעלת שאילתה). אם המתג Auto-run (הרצה אוטומטית) מופעל, הכפתור Run Query (הפעלת שאילתה) לא מוצג.

11. (אופציונלי) מידע רגיש מוסתר מיומנים

בשלב 10 רשמנו מידע על האינטראקציה של האפליקציה עם מודל Gemini. המידע הזה כלל את שם החיה, את ההנחיה בפועל ואת התשובה של המודל. אמנם שמירת המידע הזה ביומן אמורה להיות בטוחה, אבל זה לא בהכרח נכון לגבי תרחישים רבים אחרים. יכול להיות שההנחיה תכלול מידע אישי או מידע רגיש אחר שהמשתמש לא רוצה שיישמר. כדי לפתור את הבעיה הזו, אפשר לערפל את המידע האישי הרגיש שנכתב ב-Cloud Logging. כדי לצמצם את השינויים בקוד, מומלץ להשתמש בפתרון הבא.

  1. יצירת נושא PubSub לאחסון רשומות ביומן
  2. יוצרים sink ביומן שמפנה יומנים שהועברו לנושא PubSub.
  3. יוצרים צינור Dataflow שמשנה יומנים שמופנים לנושא PubSub באמצעות השלבים הבאים:
    1. קריאת רשומה ביומן מנושא PubSub
    2. בדיקת מטען הייעודי (payload) של הרשומה למידע רגיש באמצעות DLP inspection API
    3. מצנזרים את המידע הרגיש במטען הייעודי (payload) באמצעות אחת משיטות הצנזור של DLP
    4. כתיבת רשומה ביומן שעברה טשטוש ב-Cloud Logging
  4. פריסת צינור עיבוד הנתונים

12. (אופציונלי) ניקוי

כדי למנוע את הסיכון לחיובים על משאבים וממשקי API שנעשה בהם שימוש ב-codelab, מומלץ למחוק את המשאבים אחרי שמסיימים את ה-lab. הדרך הקלה ביותר לבטל את החיוב היא למחוק את הפרויקט שיצרתם בשביל ה-codelab.

  1. כדי למחוק את הפרויקט, מריצים את הפקודה למחיקת פרויקט בטרמינל:
    PROJECT_ID=$(gcloud config get-value project)
    gcloud projects delete ${PROJECT_ID} --quiet
    
    אם מוחקים את פרויקט ה-Cloud, החיוב על כל המשאבים וממשקי ה-API שנעשה בהם שימוש באותו פרויקט מופסק. ההודעה הבאה תוצג לכם, כש-PROJECT_ID הוא מזהה הפרויקט שלכם:
    Deleted [https://cloudresourcemanager.googleapis.com/v1/projects/PROJECT_ID].
    
    You can undo this operation for a limited period by running the command below.
        $ gcloud projects undelete PROJECT_ID
    
    See https://cloud.google.com/resource-manager/docs/creating-managing-projects for information on shutting down projects.
    
  2. (אופציונלי) אם מופיעה שגיאה, צריך לעיין בשלב 5 כדי למצוא את מזהה הפרויקט שבו השתמשתם במהלך הסדנה. מחליפים אותו בפקודה שמופיעה בהוראה הראשונה. לדוגמה, אם מזהה הפרויקט הוא lab-example-project, הפקודה תהיה:
    gcloud projects delete lab-project-id-example --quiet
    

13. מזל טוב

בשיעור ה-Lab הזה יצרתם אפליקציית AI גנרטיבי שמשתמשת במודל Gemini כדי לבצע חיזויים. והגדרנו באפליקציה יכולות בסיסיות של מעקב ורישום ביומן. פרסתם את האפליקציה ואת השינויים מקוד המקור ל-Cloud Run. אחר כך תוכלו להשתמש במוצרי Google Cloud Observability כדי לעקוב אחרי הביצועים של האפליקציה, וכך לוודא שהיא אמינה.

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

ריכזנו כאן כמה אפשרויות להמשך הלמידה: