Repository: getsentry/sentry-go

Issue:
Cron implementation
Implement this feature: https://docs.sentry.io/product/crons/

(@cleptric) I would imagine something similar to what we did in PHP https://github.com/getsentry/sentry-php/pull/1467 & https://github.com/getsentry/sentry-php/pull/1511

Node implementation: https://github.com/getsentry/sentry-javascript/pull/8039

It turns out there's a parent issue: https://github.com/getsentry/sentry/issues/48560

UPDATE: Wrong parent issue 🤦

Discussion:
@tonyo: Thanks for opening the issue @aldy505 

Curious to hear about use cases for Go specifically. What kind of periodic jobs do you want to monitor? Do you use any frameworks for periodic tasks? @aldy505: @tonyo Sure!

> What kind of periodic jobs do you want to monitor? 

Basically a database clean-up job, there are a few tasks that I also need to create with Go in mind. I also have 1 job written in plain C to clean up self-hosted Github Actions runner's VM including downloaded Docker images and so on forth. It executes some commands and have some logics that's hard to understand if it was written in pure Bash.

> Do you use any frameworks for periodic tasks?

Nope. Just `time.Sleep` and infinite for loop. But maybe others are using some libraries like [robfig/cron](github.com/robfig/cron), similar to what I did here: https://github.com/getsentry/vroom/pull/284


Solution patch:
diff --git a/check_in.go b/check_in.go
new file mode 100644
index 0000000..aca7e36
--- /dev/null
+++ b/check_in.go
@@ -0,0 +1,117 @@
+package sentry
+
+import "time"
+
+type CheckInStatus string
+
+const (
+	CheckInStatusInProgress CheckInStatus = "in_progress"
+	CheckInStatusOK         CheckInStatus = "ok"
+	CheckInStatusError      CheckInStatus = "error"
+)
+
+type checkInScheduleType string
+
+const (
+	checkInScheduleTypeCrontab  checkInScheduleType = "crontab"
+	checkInScheduleTypeInterval checkInScheduleType = "interval"
+)
+
+type MonitorSchedule interface {
+	// scheduleType is a private method that must be implemented for monitor schedule
+	// implementation. It should never be called. This method is made for having
+	// specific private implementation of MonitorSchedule interface.
+	scheduleType() checkInScheduleType
+}
+
+type crontabSchedule struct {
+	Type  string `json:"type"`
+	Value string `json:"value"`
+}
+
+func (c crontabSchedule) scheduleType() checkInScheduleType {
+	return checkInScheduleTypeCrontab
+}
+
+// CrontabSchedule defines the MonitorSchedule with a cron format.
+// Example: "8 * * * *".
+func CrontabSchedule(scheduleString string) MonitorSchedule {
+	return crontabSchedule{
+		Type:  string(checkInScheduleTypeCrontab),
+		Value: scheduleString,
+	}
+}
+
+type intervalSchedule struct {
+	Type  string `json:"type"`
+	Value int64  `json:"value"`
+	Unit  string `json:"unit"`
+}
+
+func (i intervalSchedule) scheduleType() checkInScheduleType {
+	return checkInScheduleTypeInterval
+}
+
+type MonitorScheduleUnit string
+
+const (
+	MonitorScheduleUnitMinute MonitorScheduleUnit = "minute"
+	MonitorScheduleUnitHour   MonitorScheduleUnit = "hour"
+	MonitorScheduleUnitDay    MonitorScheduleUnit = "day"
+	MonitorScheduleUnitWeek   MonitorScheduleUnit = "week"
+	MonitorScheduleUnitMonth  MonitorScheduleUnit = "month"
+	MonitorScheduleUnitYear   MonitorScheduleUnit = "year"
+)
+
+// IntervalSchedule defines the MonitorSchedule with an interval format.
+//
+// Example:
+//
+//	IntervalSchedule(1, sentry.MonitorScheduleUnitDay)
+func IntervalSchedule(value int64, unit MonitorScheduleUnit) MonitorSchedule {
+	return intervalSchedule{
+		Type:  string(checkInScheduleTypeInterval),
+		Value: value,
+		Unit:  string(unit),
+	}
+}
+
+type MonitorConfig struct { //nolint: maligned // prefer readability over optimal memory layout
+	Schedule MonitorSchedule `json:"schedule,omitempty"`
+	// The allowed margin of minutes after the expected check-in time that
+	// the monitor will not be considered missed for.
+	CheckInMargin int64 `json:"check_in_margin,omitempty"`
+	// The allowed duration in minutes that the monitor may be `in_progress`
+	// for before being considered failed due to timeout.
+	MaxRuntime int64 `json:"max_runtime,omitempty"`
+	// A tz database string representing the timezone which the monitor's execution schedule is in.
+	// See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
+	Timezone string `json:"timezone,omitempty"`
+}
+
+type CheckIn struct { //nolint: maligned // prefer readability over optimal memory layout
+	// Check-In ID (unique and client generated)
+	ID EventID `json:"check_in_id"`
+	// The distinct slug of the monitor.
+	MonitorSlug string `json:"monitor_slug"`
+	// The status of the check-in.
+	Status CheckInStatus `json:"status"`
+	// The duration of the check-in. Will only take effect if the status is ok or error.
+	Duration time.Duration `json:"duration,omitempty"`
+}
+
+// serializedCheckIn is used by checkInMarshalJSON method on Event struct.
+// See https://develop.sentry.dev/sdk/check-ins/
+type serializedCheckIn struct { //nolint: maligned
+	// Check-In ID (unique and client generated).
+	CheckInID string `json:"check_in_id"`
+	// The distinct slug of the monitor.
+	MonitorSlug string `json:"monitor_slug"`
+	// The status of the check-in.
+	Status CheckInStatus `json:"status"`
+	// The duration of the check-in in seconds. Will only take effect if the status is ok or error.
+	Duration      float64        `json:"duration,omitempty"`
+	Release       string         `json:"release,omitempty"`
+	Environment   string         `json:"environment,omitempty"`
+	MonitorConfig *MonitorConfig `json:"monitor_config,omitempty"`
+}
diff --git a/client.go b/client.go
index 41458d3..5f844ce 100644
--- a/client.go
+++ b/client.go
@@ -413,6 +413,12 @@ func (client *Client) CaptureException(exception error, hint *EventHint, scope E
 	return client.CaptureEvent(event, hint, scope)
 }
 
+// CaptureCheckIn captures a check in.
+func (client *Client) CaptureCheckIn(checkIn *CheckIn, monitorConfig *MonitorConfig, scope EventModifier) *EventID {
+	event := client.EventFromCheckIn(checkIn, monitorConfig)
+	return client.CaptureEvent(event, nil, scope)
+}
+
 // CaptureEvent captures an event on the currently active client if any.
 //
 // The event must already be assembled. Typically code would instead use
@@ -524,6 +530,30 @@ func (client *Client) EventFromException(exception error, level Level) *Event {
 	return event
 }
 
+// EventFromCheckIn creates a new Sentry event from the given `check_in` instance.
+func (client *Client) EventFromCheckIn(checkIn *CheckIn, monitorConfig *MonitorConfig) *Event {
+	event := NewEvent()
+	checkInID := EventID(uuid())
+	if checkIn != nil {
+		if checkIn.ID != "" {
+			checkInID = checkIn.ID
+		}
+
+		event.CheckIn = &CheckIn{
+			ID:          checkInID,
+			MonitorSlug: checkIn.MonitorSlug,
+			Status:      checkIn.Status,
+			Duration:    checkIn.Duration,
+		}
+	}
+	event.MonitorConfig = monitorConfig
+
+	// EventID should be equal to CheckInID
+	event.EventID = checkInID
+
+	return event
+}
+
 // reverse reverses the slice a in place.
 func reverse(a []Exception) {
 	for i := len(a)/2 - 1; i >= 0; i-- {
diff --git a/hub.go b/hub.go
index ee252be..0930cf8 100644
--- a/hub.go
+++ b/hub.go
@@ -267,6 +267,25 @@ func (hub *Hub) CaptureException(exception error) *EventID {
 	return eventID
 }
 
+// CaptureCheckIn calls the method of the same name on currently bound Client instance
+// passing it a top-level Scope.
+// Returns EventID if the event was captured successfully, or nil otherwise.
+func (hub *Hub) CaptureCheckIn(checkIn *CheckIn, monitorConfig *MonitorConfig) *EventID {
+	client, scope := hub.Client(), hub.Scope()
+	if client == nil {
+		return nil
+	}
+
+	eventID := client.CaptureCheckIn(checkIn, monitorConfig, scope)
+	if eventID != nil {
+		hub.mu.Lock()
+		hub.lastEventID = *eventID
+		hub.mu.Unlock()
+	}
+
+	return eventID
+}
+
 // AddBreadcrumb records a new breadcrumb.
 //
 // The total number of breadcrumbs that can be recorded are limited by the
diff --git a/interfaces.go b/interfaces.go
index 4a75ec2..f872294 100644
--- a/interfaces.go
+++ b/interfaces.go
@@ -22,6 +22,9 @@ const eventType = "event"
 
 const profileType = "profile"
 
+// checkInType is the type of a check in event.
+const checkInType = "check_in"
+
 // Level marks the severity of the event.
 type Level string
 
@@ -315,6 +318,11 @@ type Event struct {
 	Spans           []*Span          `json:"spans,omitempty"`
 	TransactionInfo *TransactionInfo `json:"transaction_info,omitempty"`
 
+	// The fields below are only relevant for crons/check ins
+
+	CheckIn       *CheckIn       `json:"check_in,omitempty"`
+	MonitorConfig *MonitorConfig `json:"monitor_config,omitempty"`
+
 	// The fields below are not part of the final JSON payload.
 
 	sdkMetaData SDKMetaData
@@ -375,6 +383,8 @@ func (e *Event) MarshalJSON() ([]byte, error) {
 	// and a few type tricks.
 	if e.Type == transactionType {
 		return e.transactionMarshalJSON()
+	} else if e.Type == checkInType {
+		return e.checkInMarshalJSON()
 	}
 	return e.defaultMarshalJSON()
 }
@@ -449,6 +459,29 @@ func (e *Event) transactionMarshalJSON() ([]byte, error) {
 	return json.Marshal(x)
 }
 
+func (e *Event) checkInMarshalJSON() ([]byte, error) {
+	checkIn := serializedCheckIn{
+		CheckInID:     string(e.CheckIn.ID),
+		MonitorSlug:   e.CheckIn.MonitorSlug,
+		Status:        e.CheckIn.Status,
+		Duration:      e.CheckIn.Duration.Seconds(),
+		Release:       e.Release,
+		Environment:   e.Environment,
+		MonitorConfig: nil,
+	}
+
+	if e.MonitorConfig != nil {
+		checkIn.MonitorConfig = &MonitorConfig{
+			Schedule:      e.MonitorConfig.Schedule,
+			CheckInMargin: e.MonitorConfig.CheckInMargin,
+			MaxRuntime:    e.MonitorConfig.MaxRuntime,
+			Timezone:      e.MonitorConfig.Timezone,
+		}
+	}
+
+	return json.Marshal(checkIn)
+}
+
 // NewEvent creates a new Event.
 func NewEvent() *Event {
 	event := Event{
diff --git a/sentry.go b/sentry.go
index fe8940b..bb53823 100644
--- a/sentry.go
+++ b/sentry.go
@@ -54,6 +54,12 @@ func CaptureException(exception error) *EventID {
 	return hub.CaptureException(exception)
 }
 
+// CaptureCheckIn captures a (cron) monitor check-in.
+func CaptureCheckIn(checkIn *CheckIn, monitorConfig *MonitorConfig) *EventID {
+	hub := CurrentHub()
+	return hub.CaptureCheckIn(checkIn, monitorConfig)
+}
+
 // CaptureEvent captures an event on the currently active client if any.
 //
 // The event must already be assembled. Typically code would instead use



Test patch:
diff --git a/client_test.go b/client_test.go
index 5afce8a..2945043 100644
--- a/client_test.go
+++ b/client_test.go
@@ -326,6 +326,107 @@ func TestCaptureEventNil(t *testing.T) {
 	}
 }
 
+func TestCaptureCheckIn(t *testing.T) {
+	tests := []struct {
+		name          string
+		checkIn       *CheckIn
+		monitorConfig *MonitorConfig
+	}{
+		{
+			name:          "Nil CheckIn",
+			checkIn:       nil,
+			monitorConfig: nil,
+		},
+		{
+			name: "Nil MonitorConfig",
+			checkIn: &CheckIn{
+				ID:          "66e1a05b182346f2aee5fd7f0dc9b44e",
+				MonitorSlug: "cron",
+				Status:      CheckInStatusOK,
+				Duration:    time.Second * 10,
+			},
+			monitorConfig: nil,
+		},
+		{
+			name: "IntervalSchedule",
+			checkIn: &CheckIn{
+				ID:          "66e1a05b182346f2aee5fd7f0dc9b44e",
+				MonitorSlug: "cron",
+				Status:      CheckInStatusInProgress,
+				Duration:    time.Second * 10,
+			},
+			monitorConfig: &MonitorConfig{
+				Schedule:      IntervalSchedule(1, MonitorScheduleUnitHour),
+				CheckInMargin: 10,
+				MaxRuntime:    5000,
+				Timezone:      "Asia/Singapore",
+			},
+		},
+		{
+			name: "CronSchedule",
+			checkIn: &CheckIn{
+				ID:          "66e1a05b182346f2aee5fd7f0dc9b44e",
+				MonitorSlug: "cron",
+				Status:      CheckInStatusInProgress,
+				Duration:    time.Second * 10,
+			},
+			monitorConfig: &MonitorConfig{
+				Schedule:      CrontabSchedule("40 * * * *"),
+				CheckInMargin: 10,
+				MaxRuntime:    5000,
+				Timezone:      "Asia/Singapore",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			client, _, transport := setupClientTest()
+			client.CaptureCheckIn(tt.checkIn, tt.monitorConfig, nil)
+			if transport.lastEvent == nil {
+				t.Fatal("missing event")
+			}
+
+			if diff := cmp.Diff(transport.lastEvent.CheckIn, tt.checkIn); diff != "" {
+				t.Errorf("CheckIn mismatch (-want +got):\n%s", diff)
+			}
+
+			if diff := cmp.Diff(transport.lastEvent.MonitorConfig, tt.monitorConfig); diff != "" {
+				t.Errorf("CheckIn mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestCaptureCheckInExistingID(t *testing.T) {
+	client, _, _ := setupClientTest()
+
+	monitorConfig := &MonitorConfig{
+		Schedule:      IntervalSchedule(1, MonitorScheduleUnitDay),
+		CheckInMargin: 30,
+		MaxRuntime:    30,
+		Timezone:      "UTC",
+	}
+
+	checkInID := client.CaptureCheckIn(&CheckIn{
+		MonitorSlug: "cron",
+		Status:      CheckInStatusInProgress,
+		Duration:    time.Second,
+	}, monitorConfig, nil)
+
+	checkInID2 := client.CaptureCheckIn(&CheckIn{
+		ID:          *checkInID,
+		MonitorSlug: "cron",
+		Status:      CheckInStatusOK,
+		Duration:    time.Minute,
+	}, monitorConfig, nil)
+
+	if *checkInID != *checkInID2 {
+		t.Errorf("Expecting equivalent CheckInID: %s and %s", *checkInID, *checkInID2)
+	}
+}
+
 func TestSampleRateCanDropEvent(t *testing.T) {
 	client, scope, transport := setupClientTest()
 	client.options.SampleRate = 0.000000000000001
diff --git a/hub_test.go b/hub_test.go
index 23ae374..ac46893 100644
--- a/hub_test.go
+++ b/hub_test.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"sync"
 	"testing"
+	"time"
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
@@ -190,6 +191,18 @@ func TestLastEventIDUpdatesAfterCaptures(t *testing.T) {
 
 	eventID := hub.CaptureEvent(&Event{Message: "wat"})
 	assertEqual(t, *eventID, hub.LastEventID())
+
+	checkInID := hub.CaptureCheckIn(&CheckIn{
+		MonitorSlug: "job",
+		Status:      CheckInStatusOK,
+		Duration:    time.Second * 10,
+	}, &MonitorConfig{
+		Schedule:      CrontabSchedule("8 * * * *"),
+		CheckInMargin: 100,
+		MaxRuntime:    200,
+		Timezone:      "Asia/Singapore",
+	})
+	assertEqual(t, *checkInID, hub.LastEventID())
 }
 
 func TestLastEventIDNotChangedForTransactions(t *testing.T) {
diff --git a/marshal_test.go b/marshal_test.go
index 88d02df..ecf1d81 100644
--- a/marshal_test.go
+++ b/marshal_test.go
@@ -125,6 +125,87 @@ func TestTransactionEventMarshalJSON(t *testing.T) {
 	}
 }
 
+func TestCheckInEventMarshalJSON(t *testing.T) {
+	tests := []*Event{
+		{
+			Release:     "1.0.0",
+			Environment: "dev",
+			Type:        checkInType,
+			CheckIn: &CheckIn{
+				ID:          "c2f0ce1334c74564bf6631f6161173f5",
+				MonitorSlug: "my-monitor",
+				Status:      "ok",
+				Duration:    time.Second * 10,
+			},
+			MonitorConfig: nil,
+		},
+		{
+			Release:     "1.0.0",
+			Environment: "dev",
+			Type:        checkInType,
+			CheckIn: &CheckIn{
+				ID:          "c2f0ce1334c74564bf6631f6161173f5",
+				MonitorSlug: "my-monitor",
+				Status:      "ok",
+				Duration:    time.Second * 10,
+			},
+			MonitorConfig: &MonitorConfig{
+				Schedule: &intervalSchedule{
+					Type:  "interval",
+					Value: 1,
+					Unit:  "day",
+				},
+				CheckInMargin: 5,
+				MaxRuntime:    30,
+				Timezone:      "America/Los_Angeles",
+			},
+		},
+		{
+			Release:     "1.0.0",
+			Environment: "dev",
+			Type:        checkInType,
+			CheckIn: &CheckIn{
+				ID:          "c2f0ce1334c74564bf6631f6161173f5",
+				MonitorSlug: "my-monitor",
+				Status:      "ok",
+				Duration:    time.Second * 10,
+			},
+			MonitorConfig: &MonitorConfig{
+				Schedule: &crontabSchedule{
+					Type:  "crontab",
+					Value: "0 * * * *",
+				},
+				CheckInMargin: 5,
+				MaxRuntime:    30,
+				Timezone:      "America/Los_Angeles",
+			},
+		},
+	}
+
+	var buf bytes.Buffer
+	enc := json.NewEncoder(&buf)
+	enc.SetIndent("", "  ")
+	for i, tt := range tests {
+		i, tt := i, tt
+		t.Run("", func(t *testing.T) {
+			defer buf.Reset()
+			err := enc.Encode(tt)
+			if err != nil {
+				t.Fatal(err)
+			}
+			path := filepath.Join("testdata", "json", "checkin", fmt.Sprintf("%03d.json", i))
+			if *update {
+				WriteGoldenFile(t, path, buf.Bytes())
+			}
+			got := buf.String()
+			want := ReadOrGenerateGoldenFile(t, path, buf.Bytes())
+			if diff := cmp.Diff(want, got); diff != "" {
+				t.Fatalf("JSON mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
 func TestBreadcrumbMarshalJSON(t *testing.T) {
 	tests := []*Breadcrumb{
 		// complete
diff --git a/testdata/json/checkin/000.json b/testdata/json/checkin/000.json
new file mode 100644
index 0000000..deaf919
--- /dev/null
+++ b/testdata/json/checkin/000.json
@@ -0,0 +1,8 @@
+{
+  "check_in_id": "c2f0ce1334c74564bf6631f6161173f5",
+  "monitor_slug": "my-monitor",
+  "status": "ok",
+  "duration": 10,
+  "release": "1.0.0",
+  "environment": "dev"
+}
diff --git a/testdata/json/checkin/001.json b/testdata/json/checkin/001.json
new file mode 100644
index 0000000..64f133d
--- /dev/null
+++ b/testdata/json/checkin/001.json
@@ -0,0 +1,18 @@
+{
+  "check_in_id": "c2f0ce1334c74564bf6631f6161173f5",
+  "monitor_slug": "my-monitor",
+  "status": "ok",
+  "duration": 10,
+  "release": "1.0.0",
+  "environment": "dev",
+  "monitor_config": {
+    "schedule": {
+      "type": "interval",
+      "value": 1,
+      "unit": "day"
+    },
+    "check_in_margin": 5,
+    "max_runtime": 30,
+    "timezone": "America/Los_Angeles"
+  }
+}
diff --git a/testdata/json/checkin/002.json b/testdata/json/checkin/002.json
new file mode 100644
index 0000000..f46a69e
--- /dev/null
+++ b/testdata/json/checkin/002.json
@@ -0,0 +1,17 @@
+{
+  "check_in_id": "c2f0ce1334c74564bf6631f6161173f5",
+  "monitor_slug": "my-monitor",
+  "status": "ok",
+  "duration": 10,
+  "release": "1.0.0",
+  "environment": "dev",
+  "monitor_config": {
+    "schedule": {
+      "type": "crontab",
+      "value": "0 * * * *"
+    },
+    "check_in_margin": 5,
+    "max_runtime": 30,
+    "timezone": "America/Los_Angeles"
+  }
+}