Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(crons): initial cron support #661

Merged
merged 14 commits into from Jul 18, 2023
110 changes: 110 additions & 0 deletions check_in.go
@@ -0,0 +1,110 @@
package sentry

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() checkInScheduleType
}

type crontabSchedule struct {
Type string `json:"type"`
Value string `json:"value"`
}

func (c crontabSchedule) scheduleType() checkInScheduleType {
tonyo marked this conversation as resolved.
Show resolved Hide resolved
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 {
tonyo marked this conversation as resolved.
Show resolved Hide resolved
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 allowed margin of minutes after the expected check-in time that
aldy505 marked this conversation as resolved.
Show resolved Hide resolved
// the monitor will not be considered missed for.
CheckInMargin int64 `json:"check_in_margin,omitempty"`
// The allowed allowed duration in minutes that the monitor may be `in_progress`
aldy505 marked this conversation as resolved.
Show resolved Hide resolved
// 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
// 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 int64 `json:"duration,omitempty"`
tonyo marked this conversation as resolved.
Show resolved Hide resolved
}

// 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 int64 `json:"duration,omitempty"`
tonyo marked this conversation as resolved.
Show resolved Hide resolved
Release string `json:"release,omitempty"`
Environment string `json:"environment,omitempty"`
MonitorConfig *MonitorConfig `json:"monitor_config,omitempty"`
}
16 changes: 16 additions & 0 deletions client.go
Expand Up @@ -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 {
Comment on lines +416 to +417
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a discussion in Discord (#off-topic) between me and @cleptric about what function name to implement (as the PHP and Node implementation is quite different), where Michi suggested to follow the PHP implementation as the event is the check in already. As for Go SDK, if this implementation folows PHP's, this would be something like:

event := &sentry.Event{
 // fill payloads
}

event.SetCheckIn(checkInPayload)

eventId :=  sentry.CaptureEvent(event)

It's kind of awkward and confusing for me, so I went ahead with the CaptureCheckIn as a method of Client struct.

event := client.EventFromCheckIn(checkIn, monitorConfig)
return client.processEvent(event, nil, scope)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's maybe make it consistent with other Client.Capture* methods.

Suggested change
return client.processEvent(event, nil, scope)
return client.CaptureEvent(event, nil, scope)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have to reread the code before committing the suggested changes. I don't remember if it's okay to call that method from here.

}

// CaptureEvent captures an event on the currently active client if any.
//
// The event must already be assembled. Typically code would instead use
Expand Down Expand Up @@ -524,6 +530,16 @@ 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()
event.CheckInID = uuid()
event.CheckIn = checkIn
event.MonitorConfig = monitorConfig

return event
}

// reverse reverses the slice a in place.
func reverse(a []Exception) {
for i := len(a)/2 - 1; i >= 0; i-- {
Expand Down
19 changes: 19 additions & 0 deletions hub.go
Expand Up @@ -267,6 +267,25 @@ func (hub *Hub) CaptureException(exception error) *EventID {
return eventID
}

// CaptureCheckIn calls the method of a same nname on currently bound Client instance
// passing it a top-level Scope.
// Returns EventID if successfully, or nil if there's no Client available.
aldy505 marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down
33 changes: 33 additions & 0 deletions interfaces.go
Expand Up @@ -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

Expand Down Expand Up @@ -315,6 +318,12 @@ type Event struct {
Spans []*Span `json:"spans,omitempty"`
TransactionInfo *TransactionInfo `json:"transaction_info,omitempty"`

// The fields below are only relevant for crons/check ins

CheckInID string `json:"check_in_id,omitempty"`
CheckIn *CheckIn `json:"check_in,omitempty"`
MonitorConfig *MonitorConfig `json:"monitor_config,omitempty"`
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I follow how transactions are keeping their data, it would mean that every entry on CheckIn would be spread out and might mess with other fields in the future, so I keep it enclosed here with omitempty tag.


// The fields below are not part of the final JSON payload.

sdkMetaData SDKMetaData
Expand Down Expand Up @@ -375,6 +384,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()
}
Expand Down Expand Up @@ -449,6 +460,28 @@ func (e *Event) transactionMarshalJSON() ([]byte, error) {
return json.Marshal(x)
}

func (e *Event) checkInMarshalJSON() ([]byte, error) {
checkIn := serializedCheckIn{
CheckInID: e.CheckInID,
MonitorSlug: e.CheckIn.MonitorSlug,
Status: e.CheckIn.Status,
Duration: e.CheckIn.Duration,
Release: e.Release,
Environment: e.Environment,
MonitorConfig: nil,
}

if e.MonitorConfig != nil {
checkIn.MonitorConfig = &MonitorConfig{}
checkIn.MonitorConfig.Schedule = e.MonitorConfig.Schedule
checkIn.MonitorConfig.CheckInMargin = e.MonitorConfig.CheckInMargin
checkIn.MonitorConfig.MaxRuntime = e.MonitorConfig.MaxRuntime
checkIn.MonitorConfig.Timezone = e.MonitorConfig.Timezone
tonyo marked this conversation as resolved.
Show resolved Hide resolved
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I know it looks bad. It's 10 PM on Tuesday and I can't think of anything better right now. So sorry 🙈


return json.Marshal(checkIn)
}

// NewEvent creates a new Event.
func NewEvent() *Event {
event := Event{
Expand Down
81 changes: 81 additions & 0 deletions marshal_test.go
Expand Up @@ -125,6 +125,87 @@ func TestTransactionEventMarshalJSON(t *testing.T) {
}
}

func TestCheckInEventMarshalJSON(t *testing.T) {
tests := []*Event{
{
Release: "1.0.0",
Environment: "dev",
Type: checkInType,
CheckInID: uuid(),
CheckIn: &CheckIn{
MonitorSlug: "my-monitor",
Status: "ok",
Duration: 10,
},
MonitorConfig: nil,
},
{
Release: "1.0.0",
Environment: "dev",
Type: checkInType,
CheckInID: uuid(),
CheckIn: &CheckIn{
MonitorSlug: "my-monitor",
Status: "ok",
Duration: 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,
CheckInID: uuid(),
CheckIn: &CheckIn{
MonitorSlug: "my-monitor",
Status: "ok",
Duration: 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
Expand Down
6 changes: 6 additions & 0 deletions sentry.go
Expand Up @@ -54,6 +54,12 @@ func CaptureException(exception error) *EventID {
return hub.CaptureException(exception)
}

// CaptureCheckIn captures a check in.
aldy505 marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down
8 changes: 8 additions & 0 deletions 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"
}
18 changes: 18 additions & 0 deletions testdata/json/checkin/001.json
@@ -0,0 +1,18 @@
{
"check_in_id": "0cde0b4e725d4504b2d07c303c2a06d5",
"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"
}
}
17 changes: 17 additions & 0 deletions testdata/json/checkin/002.json
@@ -0,0 +1,17 @@
{
"check_in_id": "6fa9f5f4da114025b5bf681d63716f6d",
"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"
}
}