package handlers import ( "encoding/json" "fmt" "net/http" "time" "github.com/cenkalti/backoff/v5" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/pkg/assertions" otelOS "github.com/openstatushq/openstatus/apps/checker/pkg/otel" "github.com/openstatushq/openstatus/apps/checker/request" ) type statusCode int func (s statusCode) IsSuccessful() bool { return s >= 200 && s < 300 } type PingData struct { ID string `json:"id"` WorkspaceID string `json:"workspaceId"` MonitorID string `json:"monitorId"` URL string `json:"url"` Method string `json:"method"` Region string `json:"region"` Message string `json:"message,omitempty"` Timing string `json:"timing,omitempty"` Headers string `json:"headers,omitempty"` Assertions string `json:"assertions"` Body string `json:"body,omitempty"` Trigger string `json:"trigger,omitempty"` RequestStatus string `json:"requestStatus,omitempty"` Latency int64 `json:"latency"` CronTimestamp int64 `json:"cronTimestamp"` Timestamp int64 `json:"timestamp"` StatusCode int `json:"statusCode,omitempty"` Error uint8 `json:"error"` } func (h Handler) HTTPCheckerHandler(c *gin.Context) { ctx := c.Request.Context() const defaultRetry = 3 dataSourceName := "ping_response__v8" if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } if h.CloudProvider == "fly" { // if the request has been routed to a wrong region, we forward it to the correct one. region := c.GetHeader("fly-prefer-region") if region != "" && region != h.Region { c.Header("fly-replay", fmt.Sprintf("region=%s", region)) c.String(http.StatusAccepted, "Forwarding request to %s", region) return } } var req request.HttpCheckerRequest if err := c.ShouldBindJSON(&req); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } // We need a new client for each request to avoid connection reuse. requestClient := &http.Client{ Timeout: time.Duration(req.Timeout) * time.Millisecond, } // Configure redirect policy based on FollowRedirects setting if !req.FollowRedirects { requestClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } } else { // Explicitly limit the number of redirects to 10 (Go's default) requestClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return http.ErrUseLastResponse } return nil } } defer requestClient.CloseIdleConnections() // Might be a more efficient way to do it var i interface{} = req.RawAssertions jsonBytes, _ := json.Marshal(i) assertionAsString := string(jsonBytes) if assertionAsString == "null" { assertionAsString = "" } trigger := "cron" if req.Trigger != "" { trigger = req.Trigger } var called int var result checker.Response retry := defaultRetry if req.Retry != 0 { retry = int(req.Retry) } op := func() (struct{}, error) { called++ res, err := checker.Http(ctx, requestClient, req) if err != nil { return struct{}{}, fmt.Errorf("unable to ping: %w", err) } // In TB we need to store them as string timingAsString, err := json.Marshal(res.Timing) if err != nil { return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) } headersAsString, err := json.Marshal(res.Headers) if err != nil { return struct{}{}, fmt.Errorf("error while parsing headers %s: %w", req.URL, err) } id, err := uuid.NewV7() if err != nil { return struct{}{}, fmt.Errorf("error while generating uuid %w", err) } var requestStatus = "" switch req.Status { case "active": requestStatus = "success" case "error": requestStatus = "error" case "degraded": requestStatus = "degraded" } data := PingData{ ID: id.String(), Latency: res.Latency, StatusCode: res.Status, MonitorID: req.MonitorID, Region: h.Region, WorkspaceID: req.WorkspaceID, Timestamp: res.Timestamp, CronTimestamp: req.CronTimestamp, URL: req.URL, Method: req.Method, Timing: string(timingAsString), Headers: string(headersAsString), Body: string(res.Body), Trigger: trigger, RequestStatus: requestStatus, Message: res.Error, } var isSuccessfull bool = true isSuccessfull, err = EvaluateHTTPAssertions(req.RawAssertions, data, res) if err != nil { return struct{}{}, err } // let's retry at least once if the status code is not successful. if !isSuccessfull && called < retry { return struct{}{}, fmt.Errorf("unable to ping: %v with status %v", res, res.Status) } result = res result.Region = h.Region result.JobType = "http" // it's in error if not successful if isSuccessfull { data.Error = 0 if req.DegradedAfter != 0 && res.Latency > req.DegradedAfter { data.Body = res.Body } else { data.Body = "" } // Small trick to avoid sending the body at the moment to TB } else { data.Error = 1 result.Error = "Error" } data.Assertions = assertionAsString if !isSuccessfull && req.Status != "error" { // Q: Why here we do not check if the status was previously active? checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "error", StatusCode: res.Status, Region: h.Region, Message: res.Error, CronTimestamp: req.CronTimestamp, Latency: res.Latency, }) data.RequestStatus = "error" } // it's degraded if isSuccessfull && req.DegradedAfter > 0 && res.Latency > req.DegradedAfter && req.Status != "degraded" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "degraded", Region: h.Region, StatusCode: res.Status, CronTimestamp: req.CronTimestamp, Latency: res.Latency, }) data.RequestStatus = "degraded" } // it's active if isSuccessfull && req.DegradedAfter == 0 && req.Status != "active" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "active", Region: h.Region, StatusCode: res.Status, CronTimestamp: req.CronTimestamp, Latency: res.Latency, }) data.RequestStatus = "success" } // it's active if isSuccessfull && res.Latency < req.DegradedAfter && req.DegradedAfter != 0 && req.Status != "active" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "active", Region: h.Region, StatusCode: res.Status, CronTimestamp: req.CronTimestamp, Latency: res.Latency, }) data.RequestStatus = "success" } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } e, f := c.Get("event") if f { t := e.(map[string]any) t["checker"] = map[string]string{ "uri": req.URL, "workspace_id": req.WorkspaceID, "monitor_id":req.MonitorID, "trigger": trigger, "type": "http", } c.Set("event", t) } return struct{}{}, nil } if _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))); err != nil { id, e := uuid.NewV7() if e != nil { log.Ctx(ctx).Error().Err(e).Msg("failed to send event to tinybird") return } data := PingData{ ID: id.String(), URL: req.URL, Method: req.Method, Region: h.Region, Message: err.Error(), CronTimestamp: req.CronTimestamp, Timestamp: req.CronTimestamp, MonitorID: req.MonitorID, WorkspaceID: req.WorkspaceID, Error: 1, Assertions: assertionAsString, Body: "", Trigger: trigger, RequestStatus: "error", } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } if req.Status != "error" { checker.UpdateStatus(ctx, checker.UpdateData{ MonitorId: req.MonitorID, Status: "error", Message: err.Error(), Region: h.Region, CronTimestamp: req.CronTimestamp, }) } } if req.OtelConfig.Endpoint != "" { otelOS.RecordHTTPMetrics(ctx, req, result, h.Region) } returnData := c.Query("data") if returnData == "true" { if len(result.Body) > 1024 { result.Body = result.Body[:1000] } c.JSON(http.StatusOK, result) return } c.JSON(http.StatusOK, nil) } func EvaluateHTTPAssertions(raw []json.RawMessage, data PingData, res checker.Response) (bool, error) { // If there's a transport error, always fail regardless of assertions // This prevents false positives where empty responses might satisfy assertions if res.Error != "" { return false, nil } statusCode := statusCode(res.Status) if len(raw) == 0 { return statusCode.IsSuccessful(), nil } isSuccessful := true for _, a := range raw { var assert request.Assertion if err := json.Unmarshal(a, &assert); err != nil { return false, fmt.Errorf("unable to unmarshal assertion: %w", err) } switch assert.AssertionType { case request.AssertionHeader: var target assertions.HeaderTarget if err := json.Unmarshal(a, &target); err != nil { return false, fmt.Errorf("unable to unmarshal HeaderTarget: %w", err) } isSuccessful = isSuccessful && target.HeaderEvaluate(data.Headers) case request.AssertionTextBody: var target assertions.StringTargetType if err := json.Unmarshal(a, &target); err != nil { return false, fmt.Errorf("unable to unmarshal StringTargetType: %w", err) } isSuccessful = isSuccessful && target.StringEvaluate(data.Body) case request.AssertionStatus: var target assertions.StatusTarget if err := json.Unmarshal(a, &target); err != nil { return false, fmt.Errorf("unable to unmarshal StatusTarget: %w", err) } isSuccessful = isSuccessful && target.StatusEvaluate(int64(res.Status)) case request.AssertionJsonBody: // TODO: Implement JSON body assertion default: fmt.Println("unknown assertion type: ", assert.AssertionType) // TODO: Handle unknown assertion type } } return isSuccessful, nil }