Skip to content

Commit

Permalink
feat(crons): initial cron support (#661)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Anton Ovchinnikov <anton@tonyo.info>
  • Loading branch information
aldy505 and tonyo committed Jul 18, 2023
1 parent b6dfea7 commit 4b3a135
Show file tree
Hide file tree
Showing 11 changed files with 443 additions and 0 deletions.
117 changes: 117 additions & 0 deletions 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"`
}
30 changes: 30 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 {
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,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-- {
Expand Down
101 changes: 101 additions & 0 deletions client_test.go
Expand Up @@ -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
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 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
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
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,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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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{
Expand Down

0 comments on commit 4b3a135

Please sign in to comment.