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
115 changes: 115 additions & 0 deletions check_in.go
@@ -0,0 +1,115 @@
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 {
tonyo marked this conversation as resolved.
Show resolved Hide resolved
return checkInScheduleTypeCrontab

Check warning on line 33 in check_in.go

View check run for this annotation

Codecov / codecov/patch

check_in.go#L32-L33

Added lines #L32 - L33 were not covered by tests
}

// 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

Check warning on line 52 in check_in.go

View check run for this annotation

Codecov / codecov/patch

check_in.go#L51-L52

Added lines #L51 - L52 were not covered by tests
}

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
// 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"`
Copy link
Member

Choose a reason for hiding this comment

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

What I realized is that we don't have an option to pass an existing CheckInID via CheckIn now.
At the moment we basically create a new CheckInID every time, so there's no way to link the "in-progress" and "ok"/"error" status.
Could you handle this case please?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. I might want to add a test case for this one, or an example.

}

// 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"`
}
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.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
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
70 changes: 70 additions & 0 deletions client_test.go
Expand Up @@ -326,6 +326,76 @@ 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{
MonitorSlug: "cron",
Status: CheckInStatusOK,
Duration: time.Second * 10,
},
monitorConfig: nil,
},
{
name: "IntervalSchedule",
checkIn: &CheckIn{
MonitorSlug: "cron",
Status: CheckInStatusInProgress,
Duration: time.Second * 10,
},
monitorConfig: &MonitorConfig{
Schedule: IntervalSchedule(1, MonitorScheduleUnitHour),
CheckInMargin: 10,
MaxRuntime: 5000,
Timezone: "Asia/Singapore",
},
},
tonyo marked this conversation as resolved.
Show resolved Hide resolved
{
name: "CronSchedule",
checkIn: &CheckIn{
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 TestSampleRateCanDropEvent(t *testing.T) {
client, scope, transport := setupClientTest()
client.options.SampleRate = 0.000000000000001
Expand Down
19 changes: 19 additions & 0 deletions hub.go
Expand Up @@ -267,6 +267,25 @@
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
}

Check warning on line 277 in hub.go

View check run for this annotation

Codecov / codecov/patch

hub.go#L276-L277

Added lines #L276 - L277 were not covered by tests

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
13 changes: 13 additions & 0 deletions hub_test.go
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"sync"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
Expand Down Expand Up @@ -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) {
Expand Down
34 changes: 34 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,29 @@ 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.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{
Expand Down