Files
worker/checks/calls/init.go
Gleb Tv 2c884c5612
Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
refactor: adopt worker module path
2026-07-13 17:56:12 +03:00

688 строки
22 KiB
Go

// Package calls provides functionality.
package calls
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"strings"
"time"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
"rocketgit.ru/rsmon/worker/app/models"
"rocketgit.ru/rsmon/worker/internal/checkresult"
"rocketgit.ru/rsmon/worker/storage"
)
const (
verdictError = "error"
verdictWarning = "warning"
verdictNormal = "normal"
)
// Result is a result of an AI health check
type Result struct {
checkresult.CheckResult
// Captured data
Screenshot string `json:"screenshot"` // base64 encoded screenshot
DOM string `json:"dom"` // HTML content
NetworkLogs string `json:"network_logs"` // JSON array of network logs
ConsoleLogs string `json:"console_logs"` // JSON array of console logs
URL string `json:"url"` // Final URL after redirects
StatusCode int `json:"status_code"` // HTTP status code
Title string `json:"title"` // Page title
// LLM analysis
LLMResponse string `json:"llm_response"` // Full LLM response
LLMVerdict string `json:"llm_verdict"` // "normal", "error", "warning"
LLMReasoning string `json:"llm_reasoning"` // LLM's explanation
HasChanges bool `json:"has_changes"` // Whether changes were detected from previous
// Previous screenshot comparison
PreviousURL string `json:"previous_url"` // URL of previous screenshot (if any)
PreviousTime string `json:"previous_time"` // Timestamp of previous screenshot
// Metadata
CheckID uint `json:"check_id"` // For database reference
ScreenshotID uint `json:"screenshot_id"` // ID of stored screenshot
}
// NetworkLog represents a network request log
type NetworkLog struct {
URL string `json:"url"`
Method string `json:"method"`
StatusCode int `json:"status_code"`
Type string `json:"type"`
Size int64 `json:"size"`
Time float64 `json:"time"`
}
// ConsoleLog represents a console log entry
type ConsoleLog struct {
Type string `json:"type"`
Value string `json:"value"`
}
// ScreenshotRecord represents a stored screenshot in the database
type ScreenshotRecord struct {
ID uint `gorm:"primarykey"`
CreatedAt time.Time `json:"created_at"`
CheckID uint `json:"check_id"`
MonitorID uint `json:"monitor_id"`
URL string `json:"url"`
// ScreenshotPath stores the S3 object path (e.g., "screenshots/monitor_123/2025-01-15_123456.jpg")
ScreenshotPath string `json:"screenshot_path" gorm:"type:varchar(500)"`
DOM string `gorm:"type:longtext" json:"dom"`
NetworkLogs string `gorm:"type:longtext" json:"network_logs"`
ConsoleLogs string `gorm:"type:longtext" json:"console_logs"`
StatusCode int `json:"status_code"`
Title string `json:"title"`
LLMVerdict string `json:"llm_verdict"`
LLMReasoning string `json:"llm_reasoning"`
}
// Perform executes the AI health check
func Perform(c *models.Check) *Result {
result := &Result{
CheckID: uint(c.ID),
}
result.Warnings = []string{}
result.Infos = []string{}
start := time.Now()
// Get the target URL
targetURL := getTargetURL(c)
result.URL = targetURL
// Step 1: Capture screenshot, DOM, and logs
screenshotData, domData, networkLogs, consoleLogs, statusCode, title, err := captureWebData(targetURL)
if err != nil {
result.Error = fmt.Errorf("failed to capture web data: %w", err)
result.Duration = time.Since(start)
result.State = "FAIL"
return result
}
result.Screenshot = screenshotData
result.DOM = domData
result.NetworkLogs = networkLogs
result.ConsoleLogs = consoleLogs
result.StatusCode = statusCode
result.Title = title
// Step 2: Store current screenshot in database (if available)
var screenshotID uint
if isDBAvailable() {
screenshotID, err = storeScreenshot(uint(c.MonitorID), uint(c.ID), result)
if err != nil {
result.Warnings = append(result.Warnings, "Failed to store screenshot: "+err.Error())
log.Printf("[ai-check] Failed to store screenshot: %v", err)
} else {
result.ScreenshotID = screenshotID
result.Infos = append(result.Infos, "Screenshot stored for reference")
}
} else {
result.Infos = append(result.Infos, "Database not available - running in standalone mode")
}
// Step 3: Get previous screenshots for comparison (if DB available)
var previousScreenshots []ScreenshotRecord
if isDBAvailable() {
previousScreenshots, err = getPreviousScreenshots(uint(c.MonitorID), 3)
if err != nil {
result.Warnings = append(result.Warnings, "Could not retrieve previous screenshots: "+err.Error())
}
}
if len(previousScreenshots) > 0 {
result.PreviousURL = previousScreenshots[0].URL
result.PreviousTime = previousScreenshots[0].CreatedAt.Format(time.RFC3339)
result.Infos = append(result.Infos, fmt.Sprintf("Comparing with %d previous screenshot(s)", len(previousScreenshots)))
}
// Step 4: Analyze with LLM
llmVerdict, llmReasoning, hasChanges, err := analyzeWithLLM(targetURL, result, previousScreenshots)
if err != nil {
result.Error = fmt.Errorf("LLM analysis failed: %w", err)
result.LLMResponse = err.Error()
result.Duration = time.Since(start)
result.State = "FAIL"
return result
}
result.LLMVerdict = llmVerdict
result.LLMReasoning = llmReasoning
result.HasChanges = hasChanges
// Update the screenshot record with LLM analysis
if screenshotID > 0 {
_ = updateScreenshotWithLLM(screenshotID, llmVerdict, llmReasoning)
}
// Determine final state based on LLM verdict
switch llmVerdict {
case verdictError:
result.State = "ERR"
result.Error = errors.New(llmReasoning)
case verdictWarning:
result.State = "WARN"
result.Warnings = append(result.Warnings, llmReasoning)
default:
result.State = "OK"
if hasChanges {
result.Infos = append(result.Infos, "Visual changes detected from previous screenshot")
}
}
result.Duration = time.Since(start)
return result
}
// getTargetURL constructs the target URL from the check
func getTargetURL(c *models.Check) string {
if c.URL != nil && *c.URL != "" {
return *c.URL
}
// Construct from monitor host
host := c.Monitor.Host
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
return "https://" + host
}
return host
}
// captureWebData captures screenshot, DOM, network and console logs using chromedp
func captureWebData(targetURL string) (screenshot, dom, networkLogsJSON, consoleLogsJSON string, statusCode int, title string, err error) {
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Create chromedp context with options for headless chrome
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx,
chromedp.NoDefaultBrowserCheck,
chromedp.NoFirstRun,
chromedp.DisableGPU,
chromedp.IgnoreCertErrors,
chromedp.Flag("headless", "new"),
chromedp.Flag("no-sandbox", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.Flag("hide-scrollbars", true),
chromedp.Flag("mute-audio", true),
chromedp.WindowSize(1920, 1080),
)
defer allocCancel()
taskCtx, taskCancel := chromedp.NewContext(allocCtx)
defer taskCancel()
// Collect network logs
networkLogs := []NetworkLog{}
consoleLogs := []ConsoleLog{}
var finalStatusCode int
// Listen for network events
chromedp.ListenTarget(taskCtx, func(ev interface{}) {
switch e := ev.(type) {
case *network.EventRequestWillBeSent:
// Track the main request
if e.Type == network.ResourceTypeDocument {
networkLogs = append(networkLogs, NetworkLog{
URL: e.Request.URL,
Method: e.Request.Method,
Type: string(e.Type),
})
}
case *network.EventResponseReceived:
// Update the main request with status code
for i, log := range networkLogs {
if log.URL == e.Response.URL && log.Method == "" { // Not yet updated
networkLogs[i].StatusCode = int(e.Response.Status)
networkLogs[i].Size = int64(e.Response.EncodedDataLength)
// Capture final status code for main document
if e.Type == network.ResourceTypeDocument && finalStatusCode == 0 {
finalStatusCode = int(e.Response.Status)
}
break
}
}
case *runtime.EventConsoleAPICalled:
// Capture console logs
value := ""
for _, arg := range e.Args {
// Get the string representation of the argument
value += arg.Value.String()
}
if value != "" && value != "undefined" {
consoleLogs = append(consoleLogs, ConsoleLog{
Type: "console." + string(e.Type),
Value: strings.TrimSpace(value),
})
}
}
})
// Run the browser tasks
var buf []byte
var html string
var pageTitle string
tasks := chromedp.Tasks{
network.Enable(),
chromedp.Navigate(targetURL),
chromedp.WaitReady("body", chromedp.ByQuery),
chromedp.Sleep(2 * time.Second), // Wait for page to fully render
chromedp.Title(&pageTitle),
chromedp.OuterHTML(":root", &html, chromedp.ByQueryAll),
chromedp.FullScreenshot(&buf, 90), // 90% quality JPEG
}
if err := chromedp.Run(taskCtx, tasks); err != nil {
// Check if it's a timeout error
if errors.Is(err, context.DeadlineExceeded) {
return "", "", "", "", 0, "", fmt.Errorf("page load timeout after 30s")
}
return "", "", "", "", 0, "", fmt.Errorf("chromedp error: %w", err)
}
// Encode screenshot as base64
screenshot = base64.StdEncoding.EncodeToString(buf)
dom = html
// Convert logs to JSON
networkBytes, _ := json.Marshal(networkLogs)
networkLogsJSON = string(networkBytes)
consoleBytes, _ := json.Marshal(consoleLogs)
consoleLogsJSON = string(consoleBytes)
// Set status code if we got it from network events
if finalStatusCode == 0 {
finalStatusCode = 200 // Assume OK if we got this far
}
return screenshot, dom, networkLogsJSON, consoleLogsJSON, finalStatusCode, pageTitle, nil
}
// storeScreenshot stores the screenshot data in S3 and metadata in the database
func storeScreenshot(monitorID, checkID uint, result *Result) (uint, error) {
// Initialize storage if not already initialized
if !storage.IsAvailable() {
if err := storage.Init(); err != nil {
return 0, fmt.Errorf("failed to initialize storage: %w", err)
}
}
// Generate a unique filename for the screenshot
// Format: screenshots/monitor_<monitor_id>/YYYY-MM-DD/<uuid>.jpg
timestamp := time.Now().Format("2006-01-02")
screenshotID := uuid.New().String()
objectPath := fmt.Sprintf("screenshots/monitor_%d/%s/%s.jpg", monitorID, timestamp, screenshotID)
// Decode base64 screenshot data
screenshotBytes, err := base64.StdEncoding.DecodeString(result.Screenshot)
if err != nil {
return 0, fmt.Errorf("failed to decode screenshot: %w", err)
}
// Upload screenshot to S3
ctx := context.Background()
reader := bytes.NewReader(screenshotBytes)
objectSize := int64(len(screenshotBytes))
_, err = storage.PutObject(ctx, objectPath, reader, objectSize, minio.PutObjectOptions{
ContentType: "image/jpeg",
})
if err != nil {
return 0, fmt.Errorf("failed to upload screenshot to S3: %w", err)
}
log.Printf("[ai-check] Stored screenshot at S3 path: %s (size: %d bytes)", objectPath, objectSize)
// Store metadata in database
record := &ScreenshotRecord{
MonitorID: monitorID,
CheckID: checkID,
URL: result.URL,
ScreenshotPath: objectPath,
DOM: truncateString(result.DOM, 50000), // Limit DOM size
NetworkLogs: result.NetworkLogs,
ConsoleLogs: result.ConsoleLogs,
StatusCode: result.StatusCode,
Title: result.Title,
}
// Auto migrate the table
if err := models.DB().AutoMigrate(&ScreenshotRecord{}); err != nil {
return 0, fmt.Errorf("failed to auto-migrate: %w", err)
}
if err := models.DB().Create(record).Error; err != nil {
return 0, fmt.Errorf("failed to insert screenshot metadata: %w", err)
}
return record.ID, nil
}
// loadScreenshotData loads screenshot data from S3 for a given record
func loadScreenshotData(record *ScreenshotRecord) (string, error) {
if record.ScreenshotPath == "" {
return "", errors.New("no screenshot path in record")
}
if !storage.IsAvailable() {
if err := storage.Init(); err != nil {
return "", fmt.Errorf("failed to initialize storage: %w", err)
}
}
ctx := context.Background()
object, err := storage.GetObject(ctx, record.ScreenshotPath, minio.GetObjectOptions{})
if err != nil {
return "", fmt.Errorf("failed to get screenshot from S3: %w", err)
}
defer object.Close() //nolint:errcheck // accepted lint exception
// Read the object data
var buf bytes.Buffer
_, err = io.Copy(&buf, object)
if err != nil {
return "", fmt.Errorf("failed to read screenshot data: %w", err)
}
// Encode as base64 for LLM analysis
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
// updateScreenshotWithLLM updates a screenshot record with LLM analysis
func updateScreenshotWithLLM(screenshotID uint, verdict, reasoning string) error {
return models.DB().Model(&ScreenshotRecord{}).
Where("id = ?", screenshotID).
Updates(map[string]interface{}{
"llm_verdict": verdict,
"llm_reasoning": reasoning,
}).Error
}
// isDBAvailable checks if the database is available
func isDBAvailable() bool {
return models.IsDBAvailable()
}
// getPreviousScreenshots retrieves previous screenshots for comparison
func getPreviousScreenshots(monitorID uint, limit int) ([]ScreenshotRecord, error) {
var records []ScreenshotRecord
err := models.DB().Model(&ScreenshotRecord{}).
Where("monitor_id = ? AND llm_verdict != ''", monitorID).
Order("created_at DESC").
Limit(limit).
Find(&records).Error
if err != nil {
return nil, err
}
return records, nil
}
// truncateString truncates a string to a maximum length
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "... (truncated)"
}
// analyzeWithLLM sends the screenshot and data to the LLM for analysis
func analyzeWithLLM(targetURL string, current *Result, previous []ScreenshotRecord) (verdict, reasoning string, hasChanges bool, err error) { //nolint:lll
// Get LLM configuration from environment
llmKey := firstEnv("LLM_APIKEY", "LLAMA_KEY")
llmURL := firstEnv("LLM_URL", "LLAMA_URL")
llmModel := firstEnv("LLM_MODEL", "LLAMA_MODEL")
if llmKey == "" || llmURL == "" {
return "", "", false, errors.New("LLM credentials not configured (LLAMA_KEY, LLAMA_URL)")
}
if llmModel == "" {
llmModel = "llama3.2-vision" // Default model
}
// Create OpenAI client with custom base URL
client := openai.NewClient(
option.WithBaseURL(llmURL),
option.WithAPIKey(llmKey),
)
// Build the system prompt
systemPrompt := buildSystemPrompt(len(previous) > 0)
// Build user message with current screenshot
userContent := buildUserMessage(targetURL, current, previous)
// Prepare messages
messages := []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage(systemPrompt),
openai.UserMessage(userContent),
}
// Call LLM with timeout
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
params := openai.ChatCompletionNewParams{
Messages: messages,
Model: llmModel,
MaxTokens: openai.Int(2000),
Temperature: openai.Float(0.3), // Lower temperature for more consistent analysis
}
completion, err := client.Chat.Completions.New(ctx, params)
if err != nil {
return "", "", false, fmt.Errorf("LLM request failed: %w", err)
}
if len(completion.Choices) == 0 {
return "", "", false, errors.New("LLM returned no choices")
}
response := completion.Choices[0].Message.Content
// Parse the LLM response to extract verdict and reasoning
return parseLLMResponse(response)
}
func firstEnv(keys ...string) string {
for _, key := range keys {
if value := os.Getenv(key); value != "" {
return value
}
}
return ""
}
// buildSystemPrompt creates the system prompt for the LLM
func buildSystemPrompt(hasPrevious bool) string {
basePrompt := `
You are a web application health monitoring assistant. Your task is to analyze website screenshots
and determine if the website appears to be functioning normally or if there are issues.
Consider the following aspects:
1. Visual layout - Is the page rendered correctly? Are elements properly aligned?
2. Error messages - Are there any visible error messages, 404s, 500s, or similar?
3. Loading states - Is the page stuck loading or showing spinners?
4. Broken elements - Are there broken images, missing styles, or layout shifts?
5. Content availability - Is the expected content visible?
Respond in the following JSON format:
{
"verdict": "normal" | "warning" | "error",
"reasoning": "Brief explanation of your assessment",
"has_changes": true | false
}
Verdict guidelines:
- "error": Page is clearly broken (error messages, completely failed to load, blank page)
- "warning": Page loads but has issues (partial content, broken elements, degraded appearance)
- "normal": Page appears to be functioning correctly`
if hasPrevious {
basePrompt += `
You will also be shown previous screenshots of the same website. Compare the current screenshot with previous ones and report any significant visual changes that might indicate problems (layout shifts, missing elements, color changes, etc.).` //nolint:lll
}
return basePrompt
}
// buildUserMessage creates the user message with screenshot data
func buildUserMessage(targetURL string, current *Result, previous []ScreenshotRecord) string {
var sb strings.Builder
_, _ = fmt.Fprintf(&sb, "Analyze the screenshot of: %s\n\n", targetURL)
_, _ = fmt.Fprintf(&sb, "HTTP Status Code: %d\n", current.StatusCode)
_, _ = fmt.Fprintf(&sb, "Page Title: %s\n\n", current.Title)
// Add console errors if any
var consoleErrors []ConsoleLog
json.Unmarshal([]byte(current.ConsoleLogs), &consoleErrors) //nolint:errcheck // accepted lint exception
errorCount := 0
for _, log := range consoleErrors {
if strings.Contains(log.Type, "error") || strings.Contains(log.Type, "warn") {
errorCount++
}
}
if errorCount > 0 {
_, _ = fmt.Fprintf(&sb, "Console Errors/Warnings: %d\n\n", errorCount)
}
// Add network errors
var networkLogs []NetworkLog
json.Unmarshal([]byte(current.NetworkLogs), &networkLogs) //nolint:errcheck // accepted lint exception
failedRequests := 0
for _, req := range networkLogs {
if req.StatusCode >= 400 {
failedRequests++
}
}
if failedRequests > 0 {
_, _ = fmt.Fprintf(&sb, "Failed Network Requests: %d\n\n", failedRequests)
}
sb.WriteString("Current screenshot (base64):\n")
sb.WriteString(current.Screenshot)
sb.WriteString("\n\n")
// Add previous screenshots if available (load from S3)
if len(previous) > 0 {
_, _ = fmt.Fprintf(&sb, "For comparison, here are %d previous screenshot(s):\n\n", len(previous))
for i, prev := range previous { //nolint:gocritic // range copy is acceptable here
// Load screenshot data from S3
screenshotData, err := loadScreenshotData(&prev)
if err != nil {
log.Printf("[ai-check] Failed to load previous screenshot %d: %v", i+1, err)
_, _ = fmt.Fprintf(&sb, "Previous screenshot #%d (from %s): [error loading screenshot]\n\n",
i+1, prev.CreatedAt.Format("2006-01-02 15:04:05"))
continue
}
_, _ = fmt.Fprintf(&sb, "Previous screenshot #%d (from %s):\n", i+1, prev.CreatedAt.Format("2006-01-02 15:04:05"))
sb.WriteString(screenshotData)
sb.WriteString("\n\n")
}
}
return sb.String()
}
// parseLLMResponse parses the LLM response to extract structured data
func parseLLMResponse(response string) (verdict, reasoning string, hasChanges bool, err error) {
// Try to extract JSON from the response
response = strings.TrimSpace(response)
// Look for JSON block
jsonStart := strings.Index(response, "{")
jsonEnd := strings.LastIndex(response, "}")
if jsonStart == -1 || jsonEnd == -1 {
// No JSON found, try to parse text response
return parseTextResponse(response)
}
jsonStr := response[jsonStart : jsonEnd+1]
var parsed struct {
Verdict string `json:"verdict"`
Reasoning string `json:"reasoning"`
HasChanges bool `json:"has_changes"`
}
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
// JSON parse failed, fall back to text parsing
return parseTextResponse(response)
}
// Validate verdict
switch parsed.Verdict {
case "normal", "ok", "healthy":
return verdictNormal, parsed.Reasoning, parsed.HasChanges, nil
case "warning", "warn":
return verdictWarning, parsed.Reasoning, parsed.HasChanges, nil
case "error", "fail", "unhealthy":
return verdictError, parsed.Reasoning, parsed.HasChanges, nil
default:
// Unknown verdict, default to normal with warning
if parsed.Verdict != "" {
return verdictWarning, parsed.Reasoning, parsed.HasChanges, nil
}
return verdictNormal, "Unable to determine specific issues from analysis", false, nil
}
}
// parseTextResponse parses a non-JSON response
func parseTextResponse(response string) (verdict, reasoning string, hasChanges bool, err error) {
responseLower := strings.ToLower(response)
// Look for keywords
hasError := strings.Contains(responseLower, "error") ||
strings.Contains(responseLower, "broken") ||
strings.Contains(responseLower, "failed") ||
strings.Contains(responseLower, "not working")
hasWarning := strings.Contains(responseLower, "warning") ||
strings.Contains(responseLower, "issue") ||
strings.Contains(responseLower, "problem") ||
strings.Contains(responseLower, "degraded")
hasChange := strings.Contains(responseLower, "change") ||
strings.Contains(responseLower, "different") ||
strings.Contains(responseLower, "modified")
if hasError {
return verdictError, response, hasChange, nil
} else if hasWarning {
return verdictWarning, response, hasChange, nil
}
return verdictNormal, response, hasChange, nil
}