From 1708729d27a600da495a6efb7f9b87dd99e68f9b Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 27 Sep 2023 17:02:15 +0545 Subject: [PATCH] chore: refactor events/notifications.go --- events/notifications.go | 402 +++------------------------------------- notification/events.go | 386 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+), 376 deletions(-) create mode 100644 notification/events.go diff --git a/events/notifications.go b/events/notifications.go index a30b2316e..b1d9b3fc8 100644 --- a/events/notifications.go +++ b/events/notifications.go @@ -1,21 +1,12 @@ package events import ( - "encoding/json" "fmt" - "net/url" - "strings" - "time" - - "github.com/flanksource/commons/template" - cUtils "github.com/flanksource/commons/utils" - "github.com/flanksource/duty" - "github.com/flanksource/duty/models" + "github.com/flanksource/incident-commander/api" "github.com/flanksource/incident-commander/db" "github.com/flanksource/incident-commander/logs" - pkgNotification "github.com/flanksource/incident-commander/notification" - pkgResponder "github.com/flanksource/incident-commander/responder" + "github.com/flanksource/incident-commander/notification" "github.com/flanksource/incident-commander/teams" "github.com/flanksource/incident-commander/utils/expression" "github.com/google/uuid" @@ -55,216 +46,11 @@ func NewNotificationSendConsumerAsync() AsyncEventConsumer { } } -func sendNotifications(ctx api.Context, events []api.Event) []api.Event { - var failedEvents []api.Event - for _, e := range events { - var props NotificationEventPayload - props.FromMap(e.Properties) - - notificationContext := pkgNotification.NewContext(ctx, props.NotificationID) - notificationContext.WithSource(props.EventName, props.ID) - logs.IfError(notificationContext.StartLog(), "error persisting start of notification send history") - - if err := sendNotification(notificationContext, e); err != nil { - e.Error = err.Error() - failedEvents = append(failedEvents, e) - notificationContext.WithError(err.Error()) - } - - logs.IfError(notificationContext.EndLog(), "error persisting end of notification send history") - } - - return failedEvents -} - -// NotificationEventPayload holds data to create a notification -type NotificationEventPayload struct { - ID uuid.UUID `json:"id"` // Resource id. depends what it is based on the original event. - EventName string `json:"event_name"` // The name of the original event this notification is for. - PersonID *uuid.UUID `json:"person_id,omitempty"` // The person recipient. - TeamID string `json:"team_id,omitempty"` // The team recipient. - NotificationName string `json:"notification_name,omitempty"` // Name of the notification of a team or a custom service of the notification. - NotificationID uuid.UUID `json:"notification_id,omitempty"` // ID of the notification. - EventCreatedAt time.Time `json:"event_created_at"` // Timestamp at which the original event was created -} - -// NotificationTemplate holds in data for notification -// that'll be used by struct templater. -type NotificationTemplate struct { - Title string `template:"true"` - Message string `template:"true"` - Properties map[string]string `template:"true"` -} - -func (t *NotificationEventPayload) AsMap() map[string]string { - m := make(map[string]string) - b, _ := json.Marshal(&t) - _ = json.Unmarshal(b, &m) - return m -} - -func (t *NotificationEventPayload) FromMap(m map[string]string) { - b, _ := json.Marshal(m) - _ = json.Unmarshal(b, &t) -} - -// labelsTemplate is a helper func to generate the template for displaying labels -func labelsTemplate(field string) string { - return fmt.Sprintf("{{if %s}}### Labels: \n{{range $k, $v := %s}}**{{$k}}**: {{$v}} \n{{end}}{{end}}", field, field) -} - -// defaultTitleAndBody returns the default title and body for notification -// based on the given event. -func defaultTitleAndBody(event string) (title string, body string) { - switch event { - case EventCheckPassed: - title = "Check {{.check.name}} has passed" - body = fmt.Sprintf(`Canary: {{.canary.name}} -{{if .agent}}Agent: {{.agent.name}}{{end}} -{{if .status.message}}Message: {{.status.message}} {{end}} -%s - -[Reference]({{.permalink}})`, labelsTemplate(".check.labels")) - - case EventCheckFailed: - title = "Check {{.check.name}} has failed" - body = fmt.Sprintf(`Canary: {{.canary.name}} -{{if .agent}}Agent: {{.agent.name}}{{end}} -Error: {{.status.error}} -%s - -[Reference]({{.permalink}})`, labelsTemplate(".check.labels")) - - case EventComponentStatusHealthy, EventComponentStatusUnhealthy, EventComponentStatusInfo, EventComponentStatusWarning, EventComponentStatusError: - title = "Component {{.component.name}} status updated to {{.component.status}}" - body = fmt.Sprintf("%s\n[Reference]({{.permalink}})", labelsTemplate(".component.labels")) - - case EventIncidentCommentAdded: - title = "{{.author.name}} left a comment on {{.incident.incident_id}}: {{.incident.title}}" - body = "{{.comment.comment}}\n\n[Reference]({{.permalink}})" - - case EventIncidentCreated: - title = "{{.incident.incident_id}}: {{.incident.title}} ({{.incident.severity}}) created" - body = "Type: {{.incident.type}}\n\n[Reference]({{.permalink}})" - - case EventIncidentDODAdded: - title = "Definition of Done added | {{.incident.incident_id}}: {{.incident.title}}" - body = "Evidence: {{.evidence.description}}\n\n[Reference]({{.permalink}})" - - case EventIncidentDODPassed, EventIncidentDODRegressed: - title = "Definition of Done {{if .evidence.done}}passed{{else}}regressed{{end}} | {{.incident.incident_id}}: {{.incident.title}}" - body = `Evidence: {{.evidence.description}} -Hypothesis: {{.hypothesis.title}} - -[Reference]({{.permalink}})` - - case EventIncidentResponderAdded: - title = "New responder added to {{.incident.incident_id}}: {{.incident.title}}" - body = "Responder {{.responder.name}}\n\n[Reference]({{.permalink}})" - - case EventIncidentResponderRemoved: - title = "Responder removed from {{.incident.incident_id}}: {{.incident.title}}" - body = "Responder {{.responder.name}}\n\n[Reference]({{.permalink}})" - - case EventIncidentStatusCancelled, EventIncidentStatusClosed, EventIncidentStatusInvestigating, EventIncidentStatusMitigated, EventIncidentStatusOpen, EventIncidentStatusResolved: - title = "{{.incident.title}} status updated" - body = "New Status: {{.incident.status}}\n\n[Reference]({{.permalink}})" - - case EventPlaybookSpecApprovalUpdated, EventPlaybookApprovalInserted: - // Not applicable - } - - return title, body -} - -func sendNotification(ctx *pkgNotification.Context, event api.Event) error { - var props NotificationEventPayload - props.FromMap(event.Properties) - - originalEvent := api.Event{Name: props.EventName, CreatedAt: props.EventCreatedAt} - celEnv, err := getEnvForEvent(ctx.Context, originalEvent, event.Properties) - if err != nil { - return err - } - - templater := template.StructTemplater{ - Values: celEnv, - ValueFunctions: true, - DelimSets: []template.Delims{ - {Left: "{{", Right: "}}"}, - {Left: "$(", Right: ")"}, - }, - } - - notification, err := pkgNotification.GetNotification(ctx.Context, props.NotificationID.String()) - if err != nil { - return err - } - - defaultTitle, defaultBody := defaultTitleAndBody(props.EventName) - - data := NotificationTemplate{ - Title: cUtils.Coalesce(notification.Title, defaultTitle), - Message: cUtils.Coalesce(notification.Template, defaultBody), - Properties: notification.Properties, - } - - if err := templater.Walk(&data); err != nil { - return fmt.Errorf("error templating notification: %w", err) - } - - if props.PersonID != nil { - ctx.WithPersonID(props.PersonID).WithRecipientType(pkgNotification.RecipientTypePerson) - var emailAddress string - if err := ctx.DB().Model(&models.Person{}).Select("email").Where("id = ?", props.PersonID).Find(&emailAddress).Error; err != nil { - return fmt.Errorf("failed to get email of person(id=%s); %v", props.PersonID, err) - } - - smtpURL := fmt.Sprintf("%s?ToAddresses=%s", api.SystemSMTP, url.QueryEscape(emailAddress)) - return pkgNotification.Send(ctx, "", smtpURL, data.Title, data.Message, data.Properties) - } - - if props.TeamID != "" { - ctx.WithRecipientType(pkgNotification.RecipientTypeTeam) - teamSpec, err := teams.GetTeamSpec(ctx.Context, props.TeamID) - if err != nil { - return fmt.Errorf("failed to get team(id=%s); %v", props.TeamID, err) - } - - for _, cn := range teamSpec.Notifications { - if cn.Name != props.NotificationName { - continue - } - - if err := templater.Walk(&cn); err != nil { - return fmt.Errorf("error templating notification: %w", err) - } - - return pkgNotification.Send(ctx, cn.Connection, cn.URL, data.Title, data.Message, data.Properties, cn.Properties) - } - } - - for _, cn := range notification.CustomNotifications { - ctx.WithRecipientType(pkgNotification.RecipientTypeCustom) - if cn.Name != props.NotificationName { - continue - } - - if err := templater.Walk(&cn); err != nil { - return fmt.Errorf("error templating notification: %w", err) - } - - return pkgNotification.Send(ctx, cn.Connection, cn.URL, data.Title, data.Message, data.Properties, cn.Properties) - } - - return nil -} - -// addNotificationEvent responds to a event that can possible generate a notification. +// addNotificationEvent responds to a event that can possibly generate a notification. // If a notification is found for the given event and passes all the filters, then -// a new notification event is created. +// a new `notification.send` event is created. func addNotificationEvent(ctx api.Context, event api.Event) error { - notificationIDs, err := pkgNotification.GetNotificationIDsForEvent(ctx, event.Name) + notificationIDs, err := notification.GetNotificationIDsForEvent(ctx, event.Name) if err != nil { return err } @@ -273,13 +59,13 @@ func addNotificationEvent(ctx api.Context, event api.Event) error { return nil } - celEnv, err := getEnvForEvent(ctx, event, event.Properties) + celEnv, err := notification.GetEnvForEvent(ctx, event, event.Properties) if err != nil { return err } for _, id := range notificationIDs { - n, err := pkgNotification.GetNotification(ctx, id) + n, err := notification.GetNotification(ctx, id) if err != nil { return err } @@ -305,7 +91,7 @@ func addNotificationEvent(ctx api.Context, event api.Event) error { return fmt.Errorf("failed to parse resource id: %v", err) } - prop := NotificationEventPayload{ + prop := notification.NotificationEventPayload{ EventName: event.Name, NotificationID: n.ID, ID: resourceID, @@ -340,7 +126,7 @@ func addNotificationEvent(ctx api.Context, event api.Event) error { return fmt.Errorf("failed to parse resource id: %v", err) } - prop := NotificationEventPayload{ + prop := notification.NotificationEventPayload{ EventName: event.Name, NotificationID: n.ID, ID: resourceID, @@ -371,7 +157,7 @@ func addNotificationEvent(ctx api.Context, event api.Event) error { return fmt.Errorf("failed to parse resource id: %v", err) } - prop := NotificationEventPayload{ + prop := notification.NotificationEventPayload{ EventName: event.Name, NotificationID: n.ID, ID: resourceID, @@ -392,162 +178,26 @@ func addNotificationEvent(ctx api.Context, event api.Event) error { return nil } -// getEnvForEvent gets the environment variables for the given event -// that'll be passed to the cel expression or to the template renderer as a view. -func getEnvForEvent(ctx api.Context, event api.Event, properties map[string]string) (map[string]any, error) { - env := make(map[string]any) - - if strings.HasPrefix(event.Name, "check.") { - checkID := properties["id"] - - check, err := duty.FindCachedCheck(ctx, checkID) - if err != nil { - return nil, fmt.Errorf("error finding check: %v", err) - } else if check == nil { - return nil, fmt.Errorf("check(id=%s) not found", checkID) - } - - canary, err := duty.FindCachedCanary(ctx, check.CanaryID.String()) - if err != nil { - return nil, fmt.Errorf("error finding canary: %v", err) - } else if canary == nil { - return nil, fmt.Errorf("canary(id=%s) not found", check.CanaryID) - } - - agent, err := duty.FindCachedAgent(ctx, check.AgentID.String()) - if err != nil { - return nil, fmt.Errorf("error finding agent: %v", err) - } else if agent != nil { - env["agent"] = agent.AsMap() - } - - summary, err := duty.CheckSummary(ctx, checkID) - if err != nil { - return nil, fmt.Errorf("failed to get check summary: %w", err) - } else if summary != nil { - check.Uptime = summary.Uptime - check.Latency = summary.Latency - } - - // We fetch the latest check_status at the time of event creation - var checkStatus models.CheckStatus - if err := ctx.DB().Where("check_id = ?", checkID).Where("created_at >= ?", event.CreatedAt).Order("created_at").First(&checkStatus).Error; err != nil { - return nil, fmt.Errorf("failed to get check status: %w", err) - } - - env["status"] = checkStatus.AsMap() - env["canary"] = canary.AsMap("spec") - env["check"] = check.AsMap("spec") - env["permalink"] = fmt.Sprintf("%s/health?layout=table&checkId=%s&timeRange=1h", api.PublicWebURL, check.ID) - } - - if event.Name == "incident.created" || strings.HasPrefix(event.Name, "incident.status.") { - incidentID := properties["id"] - - incident, err := duty.FindCachedIncident(ctx, incidentID) - if err != nil { - return nil, fmt.Errorf("error finding incident(id=%s): %v", incidentID, err) - } else if incident == nil { - return nil, fmt.Errorf("incident(id=%s) not found", incidentID) - } - - env["incident"] = incident.AsMap() - env["permalink"] = fmt.Sprintf("%s/incidents/%s", api.PublicWebURL, incident.ID) - } - - if strings.HasPrefix(event.Name, "incident.responder.") { - responderID := properties["id"] - responder, err := pkgResponder.FindResponderByID(ctx, responderID) - if err != nil { - return nil, fmt.Errorf("error finding responder(id=%s): %v", responderID, err) - } else if responder == nil { - return nil, fmt.Errorf("responder(id=%s) not found", responderID) - } - - incident, err := duty.FindCachedIncident(ctx, responder.IncidentID.String()) - if err != nil { - return nil, fmt.Errorf("error finding incident(id=%s): %v", responder.IncidentID, err) - } else if incident == nil { - return nil, fmt.Errorf("incident(id=%s) not found", responder.IncidentID) - } - - env["incident"] = incident.AsMap() - env["responder"] = responder.AsMap() - env["permalink"] = fmt.Sprintf("%s/incidents/%s", api.PublicWebURL, incident.ID) - } - - if strings.HasPrefix(event.Name, "incident.comment.") { - var comment models.Comment - if err := ctx.DB().Where("id = ?", properties["id"]).Find(&comment).Error; err != nil { - return nil, fmt.Errorf("error getting comment (id=%s)", properties["id"]) - } - - incident, err := duty.FindCachedIncident(ctx, comment.IncidentID.String()) - if err != nil { - return nil, fmt.Errorf("error finding incident(id=%s): %v", comment.IncidentID, err) - } else if incident == nil { - return nil, fmt.Errorf("incident(id=%s) not found", comment.IncidentID) - } - - author, err := duty.FindPerson(ctx, comment.CreatedBy.String()) - if err != nil { - return nil, fmt.Errorf("error getting comment author (id=%s)", comment.CreatedBy) - } else if author == nil { - return nil, fmt.Errorf("comment author(id=%s) not found", comment.CreatedBy) - } - - // TODO: extract out mentioned users' emails from the comment body - - env["incident"] = incident.AsMap() - env["comment"] = comment.AsMap() - env["author"] = author.AsMap() - env["permalink"] = fmt.Sprintf("%s/incidents/%s", api.PublicWebURL, incident.ID) - } - - if strings.HasPrefix(event.Name, "incident.dod.") { - var evidence models.Evidence - if err := ctx.DB().Where("id = ?", properties["id"]).Find(&evidence).Error; err != nil { - return nil, err - } - - var hypotheses models.Hypothesis - if err := ctx.DB().Where("id = ?", evidence.HypothesisID).Find(&evidence).Find(&hypotheses).Error; err != nil { - return nil, err - } - - incident, err := duty.FindCachedIncident(ctx, hypotheses.IncidentID.String()) - if err != nil { - return nil, fmt.Errorf("error finding incident(id=%s): %v", hypotheses.IncidentID, err) - } else if incident == nil { - return nil, fmt.Errorf("incident(id=%s) not found", hypotheses.IncidentID) - } - - env["evidence"] = evidence.AsMap() - env["hypotheses"] = hypotheses.AsMap() - env["incident"] = incident.AsMap() - env["permalink"] = fmt.Sprintf("%s/incidents/%s", api.PublicWebURL, incident.ID) - } - - if strings.HasPrefix(event.Name, "component.status.") { - componentID := properties["id"] +// sendNotifications sends a notification for each of the given events - one at a time. +// It returns any events that failed to send. +func sendNotifications(ctx api.Context, events []api.Event) []api.Event { + var failedEvents []api.Event + for _, e := range events { + var props notification.NotificationEventPayload + props.FromMap(e.Properties) - component, err := duty.FindCachedComponent(ctx, componentID) - if err != nil { - return nil, fmt.Errorf("error finding component(id=%s): %v", componentID, err) - } else if component == nil { - return nil, fmt.Errorf("component(id=%s) not found", componentID) - } + notificationContext := notification.NewContext(ctx, props.NotificationID) + notificationContext.WithSource(props.EventName, props.ID) + logs.IfError(notificationContext.StartLog(), "error persisting start of notification send history") - agent, err := duty.FindCachedAgent(ctx, component.AgentID.String()) - if err != nil { - return nil, fmt.Errorf("error finding agent: %v", err) - } else if agent != nil { - env["agent"] = agent.AsMap() + if err := notification.SendNotification(notificationContext, e); err != nil { + e.Error = err.Error() + failedEvents = append(failedEvents, e) + notificationContext.WithError(err.Error()) } - env["component"] = component.AsMap("checks", "incidents", "analysis", "components", "order", "relationship_id", "children", "parents") - env["permalink"] = fmt.Sprintf("%s/topology/%s", api.PublicWebURL, componentID) + logs.IfError(notificationContext.EndLog(), "error persisting end of notification send history") } - return env, nil + return failedEvents } diff --git a/notification/events.go b/notification/events.go new file mode 100644 index 000000000..5fc016fab --- /dev/null +++ b/notification/events.go @@ -0,0 +1,386 @@ +package notification + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "github.com/flanksource/commons/template" + "github.com/flanksource/commons/utils" + "github.com/flanksource/duty" + "github.com/flanksource/duty/models" + "github.com/google/uuid" + + "github.com/flanksource/incident-commander/api" + pkgResponder "github.com/flanksource/incident-commander/responder" + "github.com/flanksource/incident-commander/teams" +) + +// List of all events that can create notifications ... +const ( + EventCheckPassed = "check.passed" + EventCheckFailed = "check.failed" + + EventComponentStatusHealthy = "component.status.healthy" + EventComponentStatusUnhealthy = "component.status.unhealthy" + EventComponentStatusInfo = "component.status.info" + EventComponentStatusWarning = "component.status.warning" + EventComponentStatusError = "component.status.error" + + EventIncidentCommentAdded = "incident.comment.added" + EventIncidentCreated = "incident.created" + EventIncidentDODAdded = "incident.dod.added" + EventIncidentDODPassed = "incident.dod.passed" + EventIncidentDODRegressed = "incident.dod.regressed" + EventIncidentResponderAdded = "incident.responder.added" + EventIncidentResponderRemoved = "incident.responder.removed" + EventIncidentStatusCancelled = "incident.status.cancelled" + EventIncidentStatusClosed = "incident.status.closed" + EventIncidentStatusInvestigating = "incident.status.investigating" + EventIncidentStatusMitigated = "incident.status.mitigated" + EventIncidentStatusOpen = "incident.status.open" + EventIncidentStatusResolved = "incident.status.resolved" +) + +// NotificationTemplate holds in data for notification +// that'll be used by struct templater. +type NotificationTemplate struct { + Title string `template:"true"` + Message string `template:"true"` + Properties map[string]string `template:"true"` +} + +// NotificationEventPayload holds data to create a notification. +type NotificationEventPayload struct { + ID uuid.UUID `json:"id"` // Resource id. depends what it is based on the original event. + EventName string `json:"event_name"` // The name of the original event this notification is for. + PersonID *uuid.UUID `json:"person_id,omitempty"` // The person recipient. + TeamID string `json:"team_id,omitempty"` // The team recipient. + NotificationName string `json:"notification_name,omitempty"` // Name of the notification of a team or a custom service of the notification. + NotificationID uuid.UUID `json:"notification_id,omitempty"` // ID of the notification. + EventCreatedAt time.Time `json:"event_created_at"` // Timestamp at which the original event was created +} + +func (t *NotificationEventPayload) AsMap() map[string]string { + m := make(map[string]string) + b, _ := json.Marshal(&t) + _ = json.Unmarshal(b, &m) + return m +} + +func (t *NotificationEventPayload) FromMap(m map[string]string) { + b, _ := json.Marshal(m) + _ = json.Unmarshal(b, &t) +} + +// SendNotification generates the notification from the given event and sends it. +func SendNotification(ctx *Context, event api.Event) error { + var props NotificationEventPayload + props.FromMap(event.Properties) + + originalEvent := api.Event{Name: props.EventName, CreatedAt: props.EventCreatedAt} + celEnv, err := GetEnvForEvent(ctx.Context, originalEvent, event.Properties) + if err != nil { + return err + } + + templater := template.StructTemplater{ + Values: celEnv, + ValueFunctions: true, + DelimSets: []template.Delims{ + {Left: "{{", Right: "}}"}, + {Left: "$(", Right: ")"}, + }, + } + + notification, err := GetNotification(ctx.Context, props.NotificationID.String()) + if err != nil { + return err + } + + defaultTitle, defaultBody := defaultTitleAndBody(props.EventName) + + data := NotificationTemplate{ + Title: utils.Coalesce(notification.Title, defaultTitle), + Message: utils.Coalesce(notification.Template, defaultBody), + Properties: notification.Properties, + } + + if err := templater.Walk(&data); err != nil { + return fmt.Errorf("error templating notification: %w", err) + } + + if props.PersonID != nil { + ctx.WithPersonID(props.PersonID).WithRecipientType(RecipientTypePerson) + var emailAddress string + if err := ctx.DB().Model(&models.Person{}).Select("email").Where("id = ?", props.PersonID).Find(&emailAddress).Error; err != nil { + return fmt.Errorf("failed to get email of person(id=%s); %v", props.PersonID, err) + } + + smtpURL := fmt.Sprintf("%s?ToAddresses=%s", api.SystemSMTP, url.QueryEscape(emailAddress)) + return Send(ctx, "", smtpURL, data.Title, data.Message, data.Properties) + } + + if props.TeamID != "" { + ctx.WithRecipientType(RecipientTypeTeam) + teamSpec, err := teams.GetTeamSpec(ctx.Context, props.TeamID) + if err != nil { + return fmt.Errorf("failed to get team(id=%s); %v", props.TeamID, err) + } + + for _, cn := range teamSpec.Notifications { + if cn.Name != props.NotificationName { + continue + } + + if err := templater.Walk(&cn); err != nil { + return fmt.Errorf("error templating notification: %w", err) + } + + return Send(ctx, cn.Connection, cn.URL, data.Title, data.Message, data.Properties, cn.Properties) + } + } + + for _, cn := range notification.CustomNotifications { + ctx.WithRecipientType(RecipientTypeCustom) + if cn.Name != props.NotificationName { + continue + } + + if err := templater.Walk(&cn); err != nil { + return fmt.Errorf("error templating notification: %w", err) + } + + return Send(ctx, cn.Connection, cn.URL, data.Title, data.Message, data.Properties, cn.Properties) + } + + return nil +} + +// labelsTemplate is a helper func to generate the template for displaying labels +func labelsTemplate(field string) string { + return fmt.Sprintf("{{if %s}}### Labels: \n{{range $k, $v := %s}}**{{$k}}**: {{$v}} \n{{end}}{{end}}", field, field) +} + +// defaultTitleAndBody returns the default title and body for notification +// based on the given event. +func defaultTitleAndBody(event string) (title string, body string) { + switch event { + case EventCheckPassed: + title = "Check {{.check.name}} has passed" + body = fmt.Sprintf(`Canary: {{.canary.name}} +{{if .agent}}Agent: {{.agent.name}}{{end}} +{{if .status.message}}Message: {{.status.message}} {{end}} +%s + +[Reference]({{.permalink}})`, labelsTemplate(".check.labels")) + + case EventCheckFailed: + title = "Check {{.check.name}} has failed" + body = fmt.Sprintf(`Canary: {{.canary.name}} +{{if .agent}}Agent: {{.agent.name}}{{end}} +Error: {{.status.error}} +%s + +[Reference]({{.permalink}})`, labelsTemplate(".check.labels")) + + case EventComponentStatusHealthy, EventComponentStatusUnhealthy, EventComponentStatusInfo, EventComponentStatusWarning, EventComponentStatusError: + title = "Component {{.component.name}} status updated to {{.component.status}}" + body = fmt.Sprintf("%s\n[Reference]({{.permalink}})", labelsTemplate(".component.labels")) + + case EventIncidentCommentAdded: + title = "{{.author.name}} left a comment on {{.incident.incident_id}}: {{.incident.title}}" + body = "{{.comment.comment}}\n\n[Reference]({{.permalink}})" + + case EventIncidentCreated: + title = "{{.incident.incident_id}}: {{.incident.title}} ({{.incident.severity}}) created" + body = "Type: {{.incident.type}}\n\n[Reference]({{.permalink}})" + + case EventIncidentDODAdded: + title = "Definition of Done added | {{.incident.incident_id}}: {{.incident.title}}" + body = "Evidence: {{.evidence.description}}\n\n[Reference]({{.permalink}})" + + case EventIncidentDODPassed, EventIncidentDODRegressed: + title = "Definition of Done {{if .evidence.done}}passed{{else}}regressed{{end}} | {{.incident.incident_id}}: {{.incident.title}}" + body = `Evidence: {{.evidence.description}} +Hypothesis: {{.hypothesis.title}} + +[Reference]({{.permalink}})` + + case EventIncidentResponderAdded: + title = "New responder added to {{.incident.incident_id}}: {{.incident.title}}" + body = "Responder {{.responder.name}}\n\n[Reference]({{.permalink}})" + + case EventIncidentResponderRemoved: + title = "Responder removed from {{.incident.incident_id}}: {{.incident.title}}" + body = "Responder {{.responder.name}}\n\n[Reference]({{.permalink}})" + + case EventIncidentStatusCancelled, EventIncidentStatusClosed, EventIncidentStatusInvestigating, EventIncidentStatusMitigated, EventIncidentStatusOpen, EventIncidentStatusResolved: + title = "{{.incident.title}} status updated" + body = "New Status: {{.incident.status}}\n\n[Reference]({{.permalink}})" + } + + return title, body +} + +// GetEnvForEvent gets the environment variables for the given event +// that'll be passed to the cel expression or to the template renderer as a view. +func GetEnvForEvent(ctx api.Context, event api.Event, properties map[string]string) (map[string]any, error) { + env := make(map[string]any) + + if strings.HasPrefix(event.Name, "check.") { + checkID := properties["id"] + + check, err := duty.FindCachedCheck(ctx, checkID) + if err != nil { + return nil, fmt.Errorf("error finding check: %v", err) + } else if check == nil { + return nil, fmt.Errorf("check(id=%s) not found", checkID) + } + + canary, err := duty.FindCachedCanary(ctx, check.CanaryID.String()) + if err != nil { + return nil, fmt.Errorf("error finding canary: %v", err) + } else if canary == nil { + return nil, fmt.Errorf("canary(id=%s) not found", check.CanaryID) + } + + agent, err := duty.FindCachedAgent(ctx, check.AgentID.String()) + if err != nil { + return nil, fmt.Errorf("error finding agent: %v", err) + } else if agent != nil { + env["agent"] = agent.AsMap() + } + + summary, err := duty.CheckSummary(ctx, checkID) + if err != nil { + return nil, fmt.Errorf("failed to get check summary: %w", err) + } else if summary != nil { + check.Uptime = summary.Uptime + check.Latency = summary.Latency + } + + // We fetch the latest check_status at the time of event creation + var checkStatus models.CheckStatus + if err := ctx.DB().Where("check_id = ?", checkID).Where("created_at >= ?", event.CreatedAt).Order("created_at").First(&checkStatus).Error; err != nil { + return nil, fmt.Errorf("failed to get check status: %w", err) + } + + env["status"] = checkStatus.AsMap() + env["canary"] = canary.AsMap("spec") + env["check"] = check.AsMap("spec") + env["permalink"] = fmt.Sprintf("%s/health?layout=table&checkId=%s&timeRange=1h", api.PublicWebURL, check.ID) + } + + if event.Name == "incident.created" || strings.HasPrefix(event.Name, "incident.status.") { + incidentID := properties["id"] + + incident, err := duty.FindCachedIncident(ctx, incidentID) + if err != nil { + return nil, fmt.Errorf("error finding incident(id=%s): %v", incidentID, err) + } else if incident == nil { + return nil, fmt.Errorf("incident(id=%s) not found", incidentID) + } + + env["incident"] = incident.AsMap() + env["permalink"] = fmt.Sprintf("%s/incidents/%s", api.PublicWebURL, incident.ID) + } + + if strings.HasPrefix(event.Name, "incident.responder.") { + responderID := properties["id"] + responder, err := pkgResponder.FindResponderByID(ctx, responderID) + if err != nil { + return nil, fmt.Errorf("error finding responder(id=%s): %v", responderID, err) + } else if responder == nil { + return nil, fmt.Errorf("responder(id=%s) not found", responderID) + } + + incident, err := duty.FindCachedIncident(ctx, responder.IncidentID.String()) + if err != nil { + return nil, fmt.Errorf("error finding incident(id=%s): %v", responder.IncidentID, err) + } else if incident == nil { + return nil, fmt.Errorf("incident(id=%s) not found", responder.IncidentID) + } + + env["incident"] = incident.AsMap() + env["responder"] = responder.AsMap() + env["permalink"] = fmt.Sprintf("%s/incidents/%s", api.PublicWebURL, incident.ID) + } + + if strings.HasPrefix(event.Name, "incident.comment.") { + var comment models.Comment + if err := ctx.DB().Where("id = ?", properties["id"]).Find(&comment).Error; err != nil { + return nil, fmt.Errorf("error getting comment (id=%s)", properties["id"]) + } + + incident, err := duty.FindCachedIncident(ctx, comment.IncidentID.String()) + if err != nil { + return nil, fmt.Errorf("error finding incident(id=%s): %v", comment.IncidentID, err) + } else if incident == nil { + return nil, fmt.Errorf("incident(id=%s) not found", comment.IncidentID) + } + + author, err := duty.FindPerson(ctx, comment.CreatedBy.String()) + if err != nil { + return nil, fmt.Errorf("error getting comment author (id=%s)", comment.CreatedBy) + } else if author == nil { + return nil, fmt.Errorf("comment author(id=%s) not found", comment.CreatedBy) + } + + // TODO: extract out mentioned users' emails from the comment body + + env["incident"] = incident.AsMap() + env["comment"] = comment.AsMap() + env["author"] = author.AsMap() + env["permalink"] = fmt.Sprintf("%s/incidents/%s", api.PublicWebURL, incident.ID) + } + + if strings.HasPrefix(event.Name, "incident.dod.") { + var evidence models.Evidence + if err := ctx.DB().Where("id = ?", properties["id"]).Find(&evidence).Error; err != nil { + return nil, err + } + + var hypotheses models.Hypothesis + if err := ctx.DB().Where("id = ?", evidence.HypothesisID).Find(&evidence).Find(&hypotheses).Error; err != nil { + return nil, err + } + + incident, err := duty.FindCachedIncident(ctx, hypotheses.IncidentID.String()) + if err != nil { + return nil, fmt.Errorf("error finding incident(id=%s): %v", hypotheses.IncidentID, err) + } else if incident == nil { + return nil, fmt.Errorf("incident(id=%s) not found", hypotheses.IncidentID) + } + + env["evidence"] = evidence.AsMap() + env["hypotheses"] = hypotheses.AsMap() + env["incident"] = incident.AsMap() + env["permalink"] = fmt.Sprintf("%s/incidents/%s", api.PublicWebURL, incident.ID) + } + + if strings.HasPrefix(event.Name, "component.status.") { + componentID := properties["id"] + + component, err := duty.FindCachedComponent(ctx, componentID) + if err != nil { + return nil, fmt.Errorf("error finding component(id=%s): %v", componentID, err) + } else if component == nil { + return nil, fmt.Errorf("component(id=%s) not found", componentID) + } + + agent, err := duty.FindCachedAgent(ctx, component.AgentID.String()) + if err != nil { + return nil, fmt.Errorf("error finding agent: %v", err) + } else if agent != nil { + env["agent"] = agent.AsMap() + } + + env["component"] = component.AsMap("checks", "incidents", "analysis", "components", "order", "relationship_id", "children", "parents") + env["permalink"] = fmt.Sprintf("%s/topology/%s", api.PublicWebURL, componentID) + } + + return env, nil +}