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

Check warning on line 28 in check_in.go

View check run for this annotation

Codecov / codecov/patch

check_in.go#L27-L28

Added lines #L27 - L28 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,
}

Check warning on line 37 in check_in.go

View check run for this annotation

Codecov / codecov/patch

check_in.go#L33-L37

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

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 47 in check_in.go

View check run for this annotation

Codecov / codecov/patch

check_in.go#L46-L47

Added lines #L46 - L47 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 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
57 changes: 57 additions & 0 deletions client_test.go
Expand Up @@ -326,6 +326,63 @@
}
}

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: 100,
},
monitorConfig: nil,
},
{
name: "Normal",
checkIn: &CheckIn{
MonitorSlug: "cron",
Status: CheckInStatusInProgress,
Duration: 100,
},
monitorConfig: &MonitorConfig{
Schedule: IntervalSchedule(1, MonitorScheduleUnitHour),
CheckInMargin: 10,
MaxRuntime: 5000,
Timezone: "Asia/Singapore",
},
},
tonyo marked this conversation as resolved.
Show resolved Hide resolved
}

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

Check failure on line 382 in client_test.go

View workflow job for this annotation

GitHub Actions / Lint

unnecessary trailing newline (whitespace)
}
}

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

Check warning on line 277 in hub.go

View check run for this annotation

Codecov / codecov/patch

hub.go#L273-L277

Added lines #L273 - L277 were not covered by tests

eventID := client.CaptureCheckIn(checkIn, monitorConfig, scope)
if eventID != nil {
hub.mu.Lock()
hub.lastEventID = *eventID
hub.mu.Unlock()
}

Check warning on line 284 in hub.go

View check run for this annotation

Codecov / codecov/patch

hub.go#L279-L284

Added lines #L279 - L284 were not covered by tests

return eventID

Check warning on line 286 in hub.go

View check run for this annotation

Codecov / codecov/patch

hub.go#L286

Added line #L286 was not covered by tests
}

// 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: "c2f0ce1334c74564bf6631f6161173f5",
CheckIn: &CheckIn{
MonitorSlug: "my-monitor",
Status: "ok",
Duration: 10,
},
MonitorConfig: nil,
},
{
Release: "1.0.0",
Environment: "dev",
Type: checkInType,
CheckInID: "c2f0ce1334c74564bf6631f6161173f5",
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: "c2f0ce1334c74564bf6631f6161173f5",
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 @@
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)

Check warning on line 60 in sentry.go

View check run for this annotation

Codecov / codecov/patch

sentry.go#L58-L60

Added lines #L58 - L60 were not covered by tests
}

// CaptureEvent captures an event on the currently active client if any.
//
// The event must already be assembled. Typically code would instead use
Expand Down