diff --git a/apps/checker/handlers/attempt_test.go b/apps/checker/handlers/attempt_test.go new file mode 100644 index 000000000..7aef589de --- /dev/null +++ b/apps/checker/handlers/attempt_test.go @@ -0,0 +1,216 @@ +package handlers_test + +import ( + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openstatushq/openstatus/apps/checker/handlers" + "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" +) + +type tinybirdRecorder struct { + mu sync.Mutex + events map[string][][]byte +} + +func newTinybirdRecorder(t *testing.T) (*tinybirdRecorder, tinybird.Client) { + t.Helper() + rec := &tinybirdRecorder{events: map[string][][]byte{}} + hclient := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response { + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + rec.mu.Lock() + name := req.URL.Query().Get("name") + rec.events[name] = append(rec.events[name], body) + rec.mu.Unlock() + + return &http.Response{ + StatusCode: http.StatusAccepted, + Body: io.NopCloser(strings.NewReader(`{}`)), + } + })} + + return rec, tinybird.NewClient(hclient, "apiKey") +} + +func (r *tinybirdRecorder) decode(t *testing.T, dataSource string) []map[string]any { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + rows := make([]map[string]any, 0, len(r.events[dataSource])) + for _, body := range r.events[dataSource] { + var row map[string]any + require.NoError(t, json.Unmarshal(body, &row)) + rows = append(rows, row) + } + + return rows +} + +func closedAddr(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := listener.Addr().String() + require.NoError(t, listener.Close()) + + return addr +} + +func serve(t *testing.T, path string, handler gin.HandlerFunc, body string) { + t.Helper() + router := gin.New() + router.POST(path, handler) + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, path, strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + require.Equal(t, http.StatusOK, w.Code) +} + +func TestHTTPCheckerHandler_Attempts(t *testing.T) { + t.Run("a check that passes on retry stores the failed attempt", func(t *testing.T) { + var calls atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + rec, client := newTinybirdRecorder(t) + h := handlers.Handler{TbClient: client, Secret: "test", Region: "ams"} + serve(t, "/checker", h.HTTPCheckerHandler, `{"url":"`+target.URL+`","method":"GET","monitorId":"1","workspaceId":"2","cronTimestamp":1000,"status":"active","retry":3,"timeout":5000}`) + + attempts := rec.decode(t, "ping_attempt__v0") + require.Len(t, attempts, 1) + assert.EqualValues(t, 1, attempts[0]["attempt"]) + assert.EqualValues(t, http.StatusServiceUnavailable, attempts[0]["statusCode"]) + assert.EqualValues(t, 1, attempts[0]["error"]) + assert.Equal(t, "error", attempts[0]["requestStatus"]) + assert.Equal(t, "ams", attempts[0]["region"]) + assert.EqualValues(t, 1000, attempts[0]["cronTimestamp"]) + + final := rec.decode(t, "ping_response__v8") + require.Len(t, final, 1) + assert.EqualValues(t, http.StatusOK, final[0]["statusCode"]) + assert.EqualValues(t, 2, final[0]["attempts"]) + assert.NotEqual(t, attempts[0]["id"], final[0]["id"]) + }) + + t.Run("the last failed attempt is only written to ping_response", func(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer target.Close() + + rec, client := newTinybirdRecorder(t) + h := handlers.Handler{TbClient: client, Secret: "test", Region: "ams"} + serve(t, "/checker", h.HTTPCheckerHandler, `{"url":"`+target.URL+`","method":"GET","monitorId":"1","workspaceId":"2","cronTimestamp":1000,"status":"error","retry":2,"timeout":5000}`) + + attempts := rec.decode(t, "ping_attempt__v0") + require.Len(t, attempts, 1) + assert.EqualValues(t, 1, attempts[0]["attempt"]) + + final := rec.decode(t, "ping_response__v8") + require.Len(t, final, 1) + assert.EqualValues(t, 2, final[0]["attempts"]) + }) + + t.Run("a transport error is stored as an attempt", func(t *testing.T) { + rec, client := newTinybirdRecorder(t) + h := handlers.Handler{TbClient: client, Secret: "test", Region: "ams"} + serve(t, "/checker", h.HTTPCheckerHandler, `{"url":"http://`+closedAddr(t)+`","method":"GET","monitorId":"1","workspaceId":"2","cronTimestamp":1000,"status":"error","retry":2,"timeout":1000}`) + + attempts := rec.decode(t, "ping_attempt__v0") + require.Len(t, attempts, 1) + assert.NotEmpty(t, attempts[0]["message"]) + assert.NotZero(t, attempts[0]["timestamp"]) + + final := rec.decode(t, "ping_response__v8") + require.Len(t, final, 1) + assert.EqualValues(t, 2, final[0]["attempts"]) + }) + + t.Run("a check that passes first time stores no attempt", func(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + rec, client := newTinybirdRecorder(t) + h := handlers.Handler{TbClient: client, Secret: "test", Region: "ams"} + serve(t, "/checker", h.HTTPCheckerHandler, `{"url":"`+target.URL+`","method":"GET","monitorId":"1","workspaceId":"2","status":"active","timeout":5000}`) + + assert.Empty(t, rec.decode(t, "ping_attempt__v0")) + final := rec.decode(t, "ping_response__v8") + require.Len(t, final, 1) + assert.EqualValues(t, 1, final[0]["attempts"]) + }) +} + +func TestTCPHandler_Attempts(t *testing.T) { + rec, client := newTinybirdRecorder(t) + h := handlers.Handler{TbClient: client, Secret: "test", Region: "ams"} + serve(t, "/checker/tcp", h.TCPHandler, `{"uri":"`+closedAddr(t)+`","monitorId":"1","workspaceId":"2","cronTimestamp":1000,"status":"error","retry":2,"timeout":1000}`) + + attempts := rec.decode(t, "tcp_attempt__v0") + require.Len(t, attempts, 1) + assert.EqualValues(t, 1, attempts[0]["attempt"]) + assert.EqualValues(t, 1000, attempts[0]["cronTimestamp"]) + assert.NotZero(t, attempts[0]["timestamp"]) + + final := rec.decode(t, "tcp_response__v0") + require.Len(t, final, 1) + assert.EqualValues(t, 2, final[0]["attempts"]) + assert.EqualValues(t, 1000, final[0]["cronTimestamp"]) + assert.NotZero(t, final[0]["timestamp"], "tcp_response__v0 partitions on timestamp") +} + +func TestGRPCHandler_Attempts(t *testing.T) { + rec, client := newTinybirdRecorder(t) + h := handlers.Handler{TbClient: client, Secret: "test", Region: "ams"} + serve(t, "/checker/grpc", h.GRPCHandler, `{"uri":"`+closedAddr(t)+`","tls":"plaintext","monitorId":"1","workspaceId":"2","cronTimestamp":1000,"status":"error","retry":2,"timeout":500}`) + + attempts := rec.decode(t, "grpc_attempt__v0") + require.Len(t, attempts, 1) + assert.EqualValues(t, 1, attempts[0]["attempt"]) + assert.Equal(t, "error", attempts[0]["requestStatus"]) + assert.NotZero(t, attempts[0]["grpcCode"]) + assert.NotEmpty(t, attempts[0]["timing"]) + + final := rec.decode(t, "grpc_response__v0") + require.Len(t, final, 1) + assert.EqualValues(t, 2, final[0]["attempts"]) +} + +func TestDNSHandler_Attempts(t *testing.T) { + rec, client := newTinybirdRecorder(t) + h := handlers.Handler{TbClient: client, Secret: "test", Region: "ams"} + serve(t, "/checker/dns", h.DNSHandler, `{"uri":"attempts.invalid","monitorId":"1","workspaceId":"2","cronTimestamp":1000,"status":"active","retry":2,"timeout":1}`) + + attempts := rec.decode(t, "dns_attempt__v0") + require.Len(t, attempts, 1) + assert.EqualValues(t, 1, attempts[0]["attempt"]) + assert.EqualValues(t, 1, attempts[0]["error"]) + assert.EqualValues(t, 1000, attempts[0]["cronTimestamp"]) + assert.IsType(t, "", attempts[0]["records"], "records stays a string column") + + final := rec.decode(t, "dns_response__v0") + require.Len(t, final, 1) + assert.EqualValues(t, 2, final[0]["attempts"]) + assert.NotEqual(t, attempts[0]["id"], final[0]["id"]) +} diff --git a/apps/checker/handlers/checker.go b/apps/checker/handlers/checker.go index 936c05595..ce5d04373 100644 --- a/apps/checker/handlers/checker.go +++ b/apps/checker/handlers/checker.go @@ -42,12 +42,15 @@ type PingData struct { Timestamp int64 `json:"timestamp"` StatusCode int `json:"statusCode,omitempty"` Error uint8 `json:"error"` + Attempt int `json:"attempt,omitempty"` + Attempts int `json:"attempts,omitempty"` } func (h Handler) HTTPCheckerHandler(c *gin.Context) { ctx := c.Request.Context() const defaultRetry = 3 dataSourceName := "ping_response__v8" + attemptDataSourceName := "ping_attempt__v0" if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) @@ -108,7 +111,30 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { res, err := checker.Http(attemptCtx, requestClient, req) if err != nil { - return struct{}{}, fmt.Errorf("unable to ping: %w", err) + err = fmt.Errorf("unable to ping: %w", err) + + if called < retry { + if id, idErr := uuid.NewV7(); idErr == nil { + h.sendAttempt(ctx, PingData{ + ID: id.String(), + URL: req.URL, + Method: req.Method, + Region: h.Region, + Message: err.Error(), + CronTimestamp: req.CronTimestamp, + Timestamp: time.Now().UTC().UnixMilli(), + MonitorID: req.MonitorID, + WorkspaceID: req.WorkspaceID, + Error: 1, + Assertions: assertionAsString, + Trigger: trigger, + RequestStatus: "error", + Attempt: called, + }, attemptDataSourceName) + } + } + + return struct{}{}, err } // In TB we need to store them as string @@ -164,6 +190,12 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { // let's retry at least once if the status code is not successful. if !isSuccessfull && called < retry { + attempt := data + attempt.Error = 1 + attempt.RequestStatus = "error" + attempt.Assertions = assertionAsString + attempt.Attempt = called + h.sendAttempt(ctx, attempt, attemptDataSourceName) return struct{}{}, fmt.Errorf("unable to ping: %v with status %v", res, res.Status) } @@ -188,6 +220,7 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { } data.Assertions = assertionAsString + data.Attempts = called if !isSuccessfull && req.Status != "error" { // Q: Why here we do not check if the status was previously active? @@ -282,6 +315,7 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { Body: "", Trigger: trigger, RequestStatus: "error", + Attempts: called, } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { diff --git a/apps/checker/handlers/dns.go b/apps/checker/handlers/dns.go index 22efe01c8..57623e06f 100644 --- a/apps/checker/handlers/dns.go +++ b/apps/checker/handlers/dns.go @@ -39,6 +39,9 @@ type DNSResponse struct { CronTimestamp int64 `json:"cronTimestamp"` Error uint8 `json:"error"` + + Attempt int `json:"attempt,omitempty"` + Attempts int `json:"attempts,omitempty"` } // dnsTinybirdEvent re-types Records as a JSON string so Tinybird stores it in @@ -62,6 +65,7 @@ func (h Handler) DNSHandler(c *gin.Context) { ctx := c.Request.Context() const defaultRetry = 3 dataSourceName := "dns_response__v0" + attemptDataSourceName := "dns_attempt__v0" // Authorization check if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { @@ -136,12 +140,51 @@ func (h Handler) DNSHandler(c *gin.Context) { Timestamp: time.Now().UTC().UnixMilli(), } + if len(req.RawAssertions) > 0 { + if j, err := json.Marshal(req.RawAssertions); err == nil { + data.Assertions = string(j) + } else { + log.Ctx(ctx).Error().Err(err).Msg("failed to marshal assertions") + } + } + var ( latency int64 isSuccessful = true called int ) + sendAttempt := func(start int64, response *checker.DnsResponse, message string) { + attemptID, err := uuid.NewV7() + if err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to generate attempt uuid") + + return + } + + attempt := data + attempt.ID = attemptID.String() + attempt.Timestamp = start + attempt.Latency = latency + attempt.Error = 1 + attempt.ErrorMessage = message + attempt.RequestStatus = "error" + attempt.Attempt = called + + if response != nil { + attempt.Records = checker.FormatDNSRecords(response) + } + + event, err := attempt.tinybirdEvent() + if err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to marshal dns records") + + return + } + + h.sendAttempt(ctx, event, attemptDataSourceName) + } + op := func() (*checker.DnsResponse, error) { called++ log.Ctx(ctx).Debug().Msgf("performing dns check for %s (attempt %d/%d)", req.URI, called, retry) @@ -152,6 +195,9 @@ func (h Handler) DNSHandler(c *gin.Context) { latency = time.Now().UTC().UnixMilli() - start if err != nil { + if called < retry { + sendAttempt(start, nil, err.Error()) + } return nil, err } if len(req.RawAssertions) > 0 { @@ -162,6 +208,7 @@ func (h Handler) DNSHandler(c *gin.Context) { } } if !isSuccessful && called < retry { + sendAttempt(start, response, "assertion failed") return nil, backoff.RetryAfter(1) } if !isSuccessful { @@ -173,18 +220,11 @@ func (h Handler) DNSHandler(c *gin.Context) { result, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry)), backoff.WithMaxElapsedTime(retryBudget)) data.Latency = latency + data.Attempts = called if result != nil { data.Records = checker.FormatDNSRecords(result) } - if len(req.RawAssertions) > 0 { - if j, err := json.Marshal(req.RawAssertions); err == nil { - data.Assertions = string(j) - } else { - log.Ctx(ctx).Error().Err(err).Msg("failed to marshal assertions") - } - } - // Status update logic switch { case !isSuccessful: diff --git a/apps/checker/handlers/grpc.go b/apps/checker/handlers/grpc.go index 3faf3113a..d76c94fe5 100644 --- a/apps/checker/handlers/grpc.go +++ b/apps/checker/handlers/grpc.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "fmt" "net/http" @@ -37,6 +38,9 @@ type GRPCData struct { GRPCCode int64 `json:"grpcCode"` Error uint8 `json:"error"` + + Attempt int `json:"attempt,omitempty"` + Attempts int `json:"attempts,omitempty"` } func grpcCheck(req request.GRPCCheckerRequest) (checker.GRPCResult, error) { @@ -49,9 +53,37 @@ func grpcCheck(req request.GRPCCheckerRequest) (checker.GRPCResult, error) { ) } +func (h Handler) sendGRPCAttempt( + ctx context.Context, res checker.GRPCResult, err error, data GRPCData, dataSourceName string, +) { + id, idErr := uuid.NewV7() + if idErr != nil { + log.Ctx(ctx).Error().Err(idErr).Msg("failed to generate attempt uuid") + + return + } + + timing, timingErr := json.Marshal(res.Timing) + if timingErr != nil { + log.Ctx(ctx).Error().Err(timingErr).Msg("error while parsing timing data") + } + + data.ID = id.String() + data.Timestamp = time.Now().UTC().UnixMilli() + data.Timing = string(timing) + data.Latency = res.Latency + data.GRPCCode = res.GRPCCode + data.ErrorMessage = err.Error() + data.Error = 1 + data.RequestStatus = "error" + + h.sendAttempt(ctx, data, dataSourceName) +} + func (h Handler) GRPCHandler(c *gin.Context) { ctx := c.Request.Context() dataSourceName := "grpc_response__v0" + attemptDataSourceName := "grpc_attempt__v0" if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) @@ -126,15 +158,32 @@ func (h Handler) GRPCHandler(c *gin.Context) { // from a connect or TLS one, and a timeout from a refused connection. op() // drops `res` when it returns an error to the retry loop, so keep the last // attempt's result. - var lastResult checker.GRPCResult + var ( + lastResult checker.GRPCResult + called int + ) op := func() (struct{}, error) { + called++ res, err := grpcCheck(req) lastResult = res if err != nil { + err = fmt.Errorf("unable to check grpc %s", err) + if called < retry { + h.sendGRPCAttempt(ctx, res, err, GRPCData{ + WorkspaceID: workspaceId, + MonitorID: monitorId, + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Trigger: trigger, + URI: req.URI, + Service: req.Service, + Attempt: called, + }, attemptDataSourceName) + } // Only a call that never reached the server is worth repeating. A // server answering NOT_SERVING will answer the same three more times. - return struct{}{}, fmt.Errorf("unable to check grpc %s", err) + return struct{}{}, err } timingAsString, err := json.Marshal(res.Timing) @@ -182,6 +231,7 @@ func (h Handler) GRPCHandler(c *gin.Context) { Trigger: trigger, URI: req.URI, RequestStatus: requestStatus, + Attempts: called, } response = checker.GRPCResponse{ @@ -261,6 +311,7 @@ func (h Handler) GRPCHandler(c *gin.Context) { Timing: string(timingAsString), GRPCCode: lastResult.GRPCCode, RequestStatus: "error", + Attempts: called, } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") diff --git a/apps/checker/handlers/handler.go b/apps/checker/handlers/handler.go index e15a75f23..7c5aea373 100644 --- a/apps/checker/handlers/handler.go +++ b/apps/checker/handlers/handler.go @@ -25,6 +25,15 @@ type Handler struct { // Authorization could be handle by middleware +// sendAttempt stores an attempt the retry loop is about to discard. Only +// attempts before the last are sent: the last one is the check's result and is +// written to the response datasource. +func (h Handler) sendAttempt(ctx context.Context, event any, dataSourceName string) { + if err := h.TbClient.SendEvent(ctx, event, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send attempt to tinybird") + } +} + // withMonitorLogger tags every log line of a check with the monitor it belongs // to: the probed URL alone does not say which workspace to look at. func withMonitorLogger(ctx context.Context, monitorID, workspaceID string) context.Context { diff --git a/apps/checker/handlers/icmp.go b/apps/checker/handlers/icmp.go index da751988f..6176b0f4f 100644 --- a/apps/checker/handlers/icmp.go +++ b/apps/checker/handlers/icmp.go @@ -39,11 +39,15 @@ type ICMPData struct { PacketsReceived uint8 `json:"packetsReceived"` Error uint8 `json:"error"` + + Attempt int `json:"attempt,omitempty"` + Attempts int `json:"attempts,omitempty"` } func (h Handler) ICMPHandler(c *gin.Context) { ctx := c.Request.Context() dataSourceName := "icmp_response__v0" + attemptDataSourceName := "icmp_attempt__v0" if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) @@ -113,10 +117,34 @@ func (h Handler) ICMPHandler(c *gin.Context) { retry = 3 } + var called int + op := func() (struct{}, error) { + called++ res, err := checker.PingICMP(req.Timeout, req.URI) if err != nil { - return struct{}{}, fmt.Errorf("unable to check icmp %s", err) + err = fmt.Errorf("unable to check icmp %s", err) + + if called < retry { + if id, idErr := uuid.NewV7(); idErr == nil { + h.sendAttempt(ctx, ICMPData{ + ID: id.String(), + WorkspaceID: workspaceId, + Timestamp: time.Now().UTC().UnixMilli(), + CronTimestamp: req.CronTimestamp, + ErrorMessage: err.Error(), + Region: h.Region, + MonitorID: monitorId, + Error: 1, + Trigger: trigger, + URI: req.URI, + RequestStatus: "error", + Attempt: called, + }, attemptDataSourceName) + } + } + + return struct{}{}, err } timingAsString, err := json.Marshal(res.Timing) @@ -161,6 +189,7 @@ func (h Handler) ICMPHandler(c *gin.Context) { Trigger: trigger, URI: req.URI, RequestStatus: requestStatus, + Attempts: called, } response = checker.ICMPResponse{ @@ -224,6 +253,7 @@ func (h Handler) ICMPHandler(c *gin.Context) { data := ICMPData{ ID: id.String(), WorkspaceID: workspaceId, + Timestamp: time.Now().UTC().UnixMilli(), CronTimestamp: req.CronTimestamp, ErrorMessage: err.Error(), Region: h.Region, @@ -232,6 +262,7 @@ func (h Handler) ICMPHandler(c *gin.Context) { Trigger: trigger, URI: req.URI, RequestStatus: "error", + Attempts: called, } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") diff --git a/apps/checker/handlers/tcp.go b/apps/checker/handlers/tcp.go index ed4ad54d2..de9db0e1e 100644 --- a/apps/checker/handlers/tcp.go +++ b/apps/checker/handlers/tcp.go @@ -34,11 +34,15 @@ type TCPData struct { CronTimestamp int64 `json:"cronTimestamp"` Error uint8 `json:"error"` + + Attempt int `json:"attempt,omitempty"` + Attempts int `json:"attempts,omitempty"` } func (h Handler) TCPHandler(c *gin.Context) { ctx := c.Request.Context() dataSourceName := "tcp_response__v0" + attemptDataSourceName := "tcp_attempt__v0" if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) @@ -110,11 +114,35 @@ func (h Handler) TCPHandler(c *gin.Context) { retry = 3 } + var called int + op := func() (struct{}, error) { + called++ res, err := checker.PingTCP(ctx, req.Timeout, req.URI) if err != nil { - return struct{}{}, fmt.Errorf("unable to check tcp %s", err) + err = fmt.Errorf("unable to check tcp %s", err) + + if called < retry { + if id, idErr := uuid.NewV7(); idErr == nil { + h.sendAttempt(ctx, TCPData{ + ID: id.String(), + WorkspaceID: workspaceId, + Timestamp: time.Now().UTC().UnixMilli(), + CronTimestamp: req.CronTimestamp, + ErrorMessage: err.Error(), + Region: h.Region, + MonitorID: monitorId, + Error: 1, + Trigger: trigger, + URI: req.URI, + RequestStatus: "error", + Attempt: called, + }, attemptDataSourceName) + } + } + + return struct{}{}, err } timingAsString, err := json.Marshal(res) @@ -154,6 +182,7 @@ func (h Handler) TCPHandler(c *gin.Context) { Trigger: trigger, URI: req.URI, RequestStatus: requestStatus, + Attempts: called, } response = checker.TCPResponse{ @@ -219,6 +248,7 @@ func (h Handler) TCPHandler(c *gin.Context) { data := TCPData{ ID: id.String(), WorkspaceID: workspaceId, + Timestamp: time.Now().UTC().UnixMilli(), CronTimestamp: req.CronTimestamp, ErrorMessage: err.Error(), Region: h.Region, @@ -227,6 +257,7 @@ func (h Handler) TCPHandler(c *gin.Context) { Trigger: trigger, URI: req.URI, RequestStatus: "error", + Attempts: called, } if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird")