diff --git a/alert/alert.go b/alert/alert.go index bec98f3f83..ad5e2798a8 100644 --- a/alert/alert.go +++ b/alert/alert.go @@ -57,6 +57,7 @@ func (a Alert) Normalize() (*Alert, error) { } a.Summary = strings.Replace(a.Summary, "\n", " ", -1) a.Summary = strings.Replace(a.Summary, " ", " ", -1) + err := validate.Many( validate.Text("Summary", a.Summary, 1, MaxSummaryLength), validate.Text("Details", a.Details, 0, MaxDetailsLength), diff --git a/alert/metadata.go b/alert/metadata.go new file mode 100644 index 0000000000..efb0493914 --- /dev/null +++ b/alert/metadata.go @@ -0,0 +1,158 @@ +package alert + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "github.com/sqlc-dev/pqtype" + "github.com/target/goalert/gadb" + "github.com/target/goalert/permission" + "github.com/target/goalert/validation" + "github.com/target/goalert/validation/validate" +) + +const metaV1 = "alert_meta_v1" + +type metadataDBFormat struct { + Type string + AlertMetaV1 map[string]string +} + +// Metadata returns the metadata for a single alert. If err == nil, meta is guaranteed to be non-nil. If the alert has no metadata, an empty map is returned. +func (s *Store) Metadata(ctx context.Context, db gadb.DBTX, alertID int) (meta map[string]string, err error) { + err = permission.LimitCheckAny(ctx, permission.System, permission.User) + if err != nil { + return nil, err + } + + md, err := gadb.New(db).AlertMetadata(ctx, int64(alertID)) + if errors.Is(err, sql.ErrNoRows) || !md.Valid { + return map[string]string{}, nil + } + if err != nil { + return nil, err + } + + var doc metadataDBFormat + err = json.Unmarshal(md.RawMessage, &doc) + if err != nil { + return nil, err + } + + if doc.Type != metaV1 || doc.AlertMetaV1 == nil { + return nil, errors.New("unsupported metadata type") + } + + return doc.AlertMetaV1, nil +} + +type MetadataAlertID struct { + // AlertID is the ID of the alert. + ID int64 + Meta map[string]string +} + +func (s Store) FindManyMetadata(ctx context.Context, db gadb.DBTX, alertIDs []int) ([]MetadataAlertID, error) { + err := permission.LimitCheckAny(ctx, permission.System, permission.User) + if err != nil { + return nil, err + } + + err = validate.Range("AlertIDs", len(alertIDs), 1, maxBatch) + if err != nil { + return nil, err + } + ids := make([]int64, len(alertIDs)) + for i, id := range alertIDs { + ids[i] = int64(id) + } + + rows, err := gadb.New(db).AlertManyMetadata(ctx, ids) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + + res := make([]MetadataAlertID, len(rows)) + for i, r := range rows { + var doc metadataDBFormat + err = json.Unmarshal(r.Metadata.RawMessage, &doc) + if err != nil { + return nil, err + } + + if doc.Type != metaV1 || doc.AlertMetaV1 == nil { + return nil, errors.New("unsupported metadata type") + } + + res[i] = MetadataAlertID{ + ID: r.AlertID, + Meta: doc.AlertMetaV1, + } + } + + return res, nil +} + +func (s Store) SetMetadataTx(ctx context.Context, db gadb.DBTX, alertID int, meta map[string]string) error { + err := permission.LimitCheckAny(ctx, permission.User, permission.Service) + if err != nil { + return err + } + + err = ValidateMetadata(meta) + if err != nil { + return err + } + + var doc metadataDBFormat + doc.Type = metaV1 + doc.AlertMetaV1 = meta + + md, err := json.Marshal(&doc) + if err != nil { + return err + } + + rowCount, err := gadb.New(db).AlertSetMetadata(ctx, gadb.AlertSetMetadataParams{ + ID: int64(alertID), + ServiceID: permission.ServiceNullUUID(ctx), // only provide service_id restriction if request is from a service + Metadata: pqtype.NullRawMessage{Valid: true, RawMessage: json.RawMessage(md)}, + }) + if err != nil { + return err + } + + if rowCount == 0 { + // shouldn't happen, but just in case + return permission.NewAccessDenied("alert closed, invalid, or wrong service") + } + + return nil +} + +func ValidateMetadata(m map[string]string) error { + if m == nil { + return validation.NewFieldError("Meta", "cannot be nil") + } + + var totalSize int + for k, v := range m { + err := validate.ASCII("Meta[]", k, 1, 255) + if err != nil { + return err + } + + totalSize += len(k) + len(v) + } + + if totalSize > 32768 { + return validation.NewFieldError("Meta", "cannot exceed 32KiB in size") + } + + return nil +} diff --git a/alert/queries.sql b/alert/queries.sql index 0a3e0f89c3..8ce3b714fb 100644 --- a/alert/queries.sql +++ b/alert/queries.sql @@ -60,3 +60,38 @@ ON CONFLICT (alert_id) RETURNING alert_id; +-- name: AlertMetadata :one +SELECT + metadata +FROM + alert_data +WHERE + alert_id = $1; + +-- name: AlertManyMetadata :many +SELECT + alert_id, + metadata +FROM + alert_data +WHERE + alert_id = ANY (@alert_ids::bigint[]); + +-- name: AlertSetMetadata :execrows +INSERT INTO alert_data(alert_id, metadata) +SELECT + a.id, + $2 +FROM + alerts a +WHERE + a.id = $1 + AND a.status != 'closed' + AND (a.service_id = $3 + OR $3 IS NULL) -- ensure the alert is associated with the service, if coming from an integration +ON CONFLICT (alert_id) + DO UPDATE SET + metadata = $2 + WHERE + alert_data.alert_id = $1; + diff --git a/alert/store.go b/alert/store.go index 92d19960e2..1748090dc8 100644 --- a/alert/store.go +++ b/alert/store.go @@ -610,6 +610,15 @@ func (s *Store) CreateOrUpdateTx(ctx context.Context, tx *sql.Tx, a *Alert) (*Al // In the case that Status is closed but a matching alert is not present, nil is returned. // Otherwise the current alert is returned. func (s *Store) CreateOrUpdate(ctx context.Context, a *Alert) (*Alert, bool, error) { + return s.createOrUpdate(ctx, a, nil) +} + +// CreateOrUpdateWithMeta behaves the same as CreateOrUpdate, but also sets metadata on the alert if it is new. +func (s *Store) CreateOrUpdateWithMeta(ctx context.Context, a *Alert, meta map[string]string) (*Alert, bool, error) { + return s.createOrUpdate(ctx, a, meta) +} + +func (s *Store) createOrUpdate(ctx context.Context, a *Alert, meta map[string]string) (*Alert, bool, error) { err := permission.LimitCheckAny(ctx, permission.System, permission.Admin, @@ -631,10 +640,19 @@ func (s *Store) CreateOrUpdate(ctx context.Context, a *Alert) (*Alert, bool, err return nil, false, err } + // Set metadata only if meta is not nil and isNew is true + if meta != nil && isNew { + err = s.SetMetadataTx(ctx, tx, n.ID, meta) + if err != nil { + return nil, false, err + } + } + err = tx.Commit() if err != nil { return nil, false, err } + if n == nil { return nil, false, nil } diff --git a/engine/sendmessage.go b/engine/sendmessage.go index 96ad9aad98..008e732e1c 100644 --- a/engine/sendmessage.go +++ b/engine/sendmessage.go @@ -71,6 +71,10 @@ func (p *Engine) sendMessage(ctx context.Context, msg *message.Message) (*notifi // set to nil if it's the current message stat = nil } + meta, err := p.a.Metadata(ctx, p.b.db, msg.AlertID) + if err != nil { + return nil, errors.Wrap(err, "lookup alert metadata") + } notifMsg = notification.Alert{ Dest: msg.Dest, AlertID: msg.AlertID, @@ -79,6 +83,7 @@ func (p *Engine) sendMessage(ctx context.Context, msg *message.Message) (*notifi CallbackID: msg.ID, ServiceID: a.ServiceID, ServiceName: name, + Meta: meta, OriginalStatus: stat, } diff --git a/gadb/models.go b/gadb/models.go index 93fb327c6f..8020c14e8c 100644 --- a/gadb/models.go +++ b/gadb/models.go @@ -761,6 +761,12 @@ type Alert struct { Summary string } +type AlertDatum struct { + AlertID int64 + ID int64 + Metadata pqtype.NullRawMessage +} + type AlertFeedback struct { AlertID int64 ID int64 diff --git a/gadb/queries.sql.go b/gadb/queries.sql.go index 02c9f0d9e2..c1a281e26a 100644 --- a/gadb/queries.sql.go +++ b/gadb/queries.sql.go @@ -479,6 +479,93 @@ func (q *Queries) AlertLogLookupCMType(ctx context.Context, id uuid.UUID) (EnumU return cm_type, err } +const alertManyMetadata = `-- name: AlertManyMetadata :many +SELECT + alert_id, + metadata +FROM + alert_data +WHERE + alert_id = ANY ($1::bigint[]) +` + +type AlertManyMetadataRow struct { + AlertID int64 + Metadata pqtype.NullRawMessage +} + +func (q *Queries) AlertManyMetadata(ctx context.Context, alertIds []int64) ([]AlertManyMetadataRow, error) { + rows, err := q.db.QueryContext(ctx, alertManyMetadata, pq.Array(alertIds)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AlertManyMetadataRow + for rows.Next() { + var i AlertManyMetadataRow + if err := rows.Scan(&i.AlertID, &i.Metadata); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const alertMetadata = `-- name: AlertMetadata :one +SELECT + metadata +FROM + alert_data +WHERE + alert_id = $1 +` + +func (q *Queries) AlertMetadata(ctx context.Context, alertID int64) (pqtype.NullRawMessage, error) { + row := q.db.QueryRowContext(ctx, alertMetadata, alertID) + var metadata pqtype.NullRawMessage + err := row.Scan(&metadata) + return metadata, err +} + +const alertSetMetadata = `-- name: AlertSetMetadata :execrows +INSERT INTO alert_data(alert_id, metadata) +SELECT + a.id, + $2 +FROM + alerts a +WHERE + a.id = $1 + AND a.status != 'closed' + AND (a.service_id = $3 + OR $3 IS NULL) -- ensure the alert is associated with the service, if coming from an integration +ON CONFLICT (alert_id) + DO UPDATE SET + metadata = $2 + WHERE + alert_data.alert_id = $1 +` + +type AlertSetMetadataParams struct { + ID int64 + Metadata pqtype.NullRawMessage + ServiceID uuid.NullUUID +} + +func (q *Queries) AlertSetMetadata(ctx context.Context, arg AlertSetMetadataParams) (int64, error) { + result, err := q.db.ExecContext(ctx, alertSetMetadata, arg.ID, arg.Metadata, arg.ServiceID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const allPendingMsgDests = `-- name: AllPendingMsgDests :many SELECT DISTINCT usr.name AS user_name, diff --git a/genericapi/handler.go b/genericapi/handler.go index 3b6a385c39..c918c71604 100644 --- a/genericapi/handler.go +++ b/genericapi/handler.go @@ -90,6 +90,15 @@ func (h *Handler) ServeCreateAlert(w http.ResponseWriter, r *http.Request) { action := r.FormValue("action") dedup := r.FormValue("dedup") + meta := make(map[string]string) + for _, v := range r.Form["meta"] { + key, val, ok := strings.Cut(v, "=") + if !ok { + continue + } + meta[key] = val + } + ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) if ct == "application/json" { data, err := io.ReadAll(r.Body) @@ -100,6 +109,7 @@ func (h *Handler) ServeCreateAlert(w http.ResponseWriter, r *http.Request) { var b struct { Summary, Details, Action, Dedup *string + Meta map[string]string } err = json.Unmarshal(data, &b) if err != nil { @@ -119,6 +129,9 @@ func (h *Handler) ServeCreateAlert(w http.ResponseWriter, r *http.Request) { if b.Action != nil { action = *b.Action } + if b.Meta != nil { + meta = b.Meta + } } status := alert.StatusTriggered @@ -145,13 +158,12 @@ func (h *Handler) ServeCreateAlert(w http.ResponseWriter, r *http.Request) { } err = retry.DoTemporaryError(func(int) error { - createdAlert, isNew, err := h.c.AlertStore.CreateOrUpdate(ctx, a) + createdAlert, isNew, err := h.c.AlertStore.CreateOrUpdateWithMeta(ctx, a, meta) if createdAlert != nil { resp.AlertID = createdAlert.ID resp.ServiceID = createdAlert.ServiceID resp.IsNew = isNew } - return err }, retry.Log(ctx), diff --git a/graphql2/generated.go b/graphql2/generated.go index cb9eb7fb99..d94a24809d 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -104,6 +104,8 @@ type ComplexityRoot struct { CreatedAt func(childComplexity int) int Details func(childComplexity int) int ID func(childComplexity int) int + Meta func(childComplexity int) int + MetaValue func(childComplexity int, key string) int Metrics func(childComplexity int) int NoiseReason func(childComplexity int) int PendingNotifications func(childComplexity int) int @@ -137,6 +139,11 @@ type ComplexityRoot struct { PageInfo func(childComplexity int) int } + AlertMetadata struct { + Key func(childComplexity int) int + Value func(childComplexity int) int + } + AlertMetric struct { ClosedAt func(childComplexity int) int Escalated func(childComplexity int) int @@ -777,6 +784,8 @@ type AlertResolver interface { PendingNotifications(ctx context.Context, obj *alert.Alert) ([]AlertPendingNotification, error) Metrics(ctx context.Context, obj *alert.Alert) (*alertmetrics.Metric, error) NoiseReason(ctx context.Context, obj *alert.Alert) (*string, error) + Meta(ctx context.Context, obj *alert.Alert) ([]AlertMetadata, error) + MetaValue(ctx context.Context, obj *alert.Alert, key string) (string, error) } type AlertLogEntryResolver interface { Message(ctx context.Context, obj *alertlog.Entry) (string, error) @@ -1067,6 +1076,25 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Alert.ID(childComplexity), true + case "Alert.meta": + if e.complexity.Alert.Meta == nil { + break + } + + return e.complexity.Alert.Meta(childComplexity), true + + case "Alert.metaValue": + if e.complexity.Alert.MetaValue == nil { + break + } + + args, err := ec.field_Alert_metaValue_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Alert.MetaValue(childComplexity, args["key"].(string)), true + case "Alert.metrics": if e.complexity.Alert.Metrics == nil { break @@ -1205,6 +1233,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.AlertLogEntryConnection.PageInfo(childComplexity), true + case "AlertMetadata.key": + if e.complexity.AlertMetadata.Key == nil { + break + } + + return e.complexity.AlertMetadata.Key(childComplexity), true + + case "AlertMetadata.value": + if e.complexity.AlertMetadata.Value == nil { + break + } + + return e.complexity.AlertMetadata.Value(childComplexity), true + case "AlertMetric.closedAt": if e.complexity.AlertMetric.ClosedAt == nil { break @@ -4588,6 +4630,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { rc := graphql.GetOperationContext(ctx) ec := executionContext{rc, e, 0, 0, make(chan graphql.DeferredResult)} inputUnmarshalMap := graphql.BuildUnmarshalerMap( + ec.unmarshalInputAlertMetadataInput, ec.unmarshalInputAlertMetricsOptions, ec.unmarshalInputAlertRecentEventsOptions, ec.unmarshalInputAlertSearchOptions, @@ -4799,6 +4842,21 @@ func (ec *executionContext) dir_experimental_args(ctx context.Context, rawArgs m return args, nil } +func (ec *executionContext) field_Alert_metaValue_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 string + if tmp, ok := rawArgs["key"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("key")) + arg0, err = ec.unmarshalNString2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["key"] = arg0 + return args, nil +} + func (ec *executionContext) field_Alert_recentEvents_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -6965,6 +7023,108 @@ func (ec *executionContext) fieldContext_Alert_noiseReason(ctx context.Context, return fc, nil } +func (ec *executionContext) _Alert_meta(ctx context.Context, field graphql.CollectedField, obj *alert.Alert) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Alert_meta(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Alert().Meta(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]AlertMetadata) + fc.Result = res + return ec.marshalOAlertMetadata2ᚕgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertMetadataᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Alert_meta(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Alert", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "key": + return ec.fieldContext_AlertMetadata_key(ctx, field) + case "value": + return ec.fieldContext_AlertMetadata_value(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AlertMetadata", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Alert_metaValue(ctx context.Context, field graphql.CollectedField, obj *alert.Alert) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Alert_metaValue(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Alert().MetaValue(rctx, obj, fc.Args["key"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Alert_metaValue(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Alert", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Alert_metaValue_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _AlertConnection_nodes(ctx context.Context, field graphql.CollectedField, obj *AlertConnection) (ret graphql.Marshaler) { fc, err := ec.fieldContext_AlertConnection_nodes(ctx, field) if err != nil { @@ -7030,6 +7190,10 @@ func (ec *executionContext) fieldContext_AlertConnection_nodes(ctx context.Conte return ec.fieldContext_Alert_metrics(ctx, field) case "noiseReason": return ec.fieldContext_Alert_noiseReason(ctx, field) + case "meta": + return ec.fieldContext_Alert_meta(ctx, field) + case "metaValue": + return ec.fieldContext_Alert_metaValue(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Alert", field.Name) }, @@ -7460,6 +7624,94 @@ func (ec *executionContext) fieldContext_AlertLogEntryConnection_pageInfo(ctx co return fc, nil } +func (ec *executionContext) _AlertMetadata_key(ctx context.Context, field graphql.CollectedField, obj *AlertMetadata) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AlertMetadata_key(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Key, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AlertMetadata_key(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AlertMetadata", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AlertMetadata_value(ctx context.Context, field graphql.CollectedField, obj *AlertMetadata) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AlertMetadata_value(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Value, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AlertMetadata_value(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AlertMetadata", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _AlertMetric_escalated(ctx context.Context, field graphql.CollectedField, obj *alertmetrics.Metric) (ret graphql.Marshaler) { fc, err := ec.fieldContext_AlertMetric_escalated(ctx, field) if err != nil { @@ -14622,6 +14874,10 @@ func (ec *executionContext) fieldContext_Mutation_updateAlerts(ctx context.Conte return ec.fieldContext_Alert_metrics(ctx, field) case "noiseReason": return ec.fieldContext_Alert_noiseReason(ctx, field) + case "meta": + return ec.fieldContext_Alert_meta(ctx, field) + case "metaValue": + return ec.fieldContext_Alert_metaValue(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Alert", field.Name) }, @@ -14757,6 +15013,10 @@ func (ec *executionContext) fieldContext_Mutation_escalateAlerts(ctx context.Con return ec.fieldContext_Alert_metrics(ctx, field) case "noiseReason": return ec.fieldContext_Alert_noiseReason(ctx, field) + case "meta": + return ec.fieldContext_Alert_meta(ctx, field) + case "metaValue": + return ec.fieldContext_Alert_metaValue(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Alert", field.Name) }, @@ -15112,6 +15372,10 @@ func (ec *executionContext) fieldContext_Mutation_createAlert(ctx context.Contex return ec.fieldContext_Alert_metrics(ctx, field) case "noiseReason": return ec.fieldContext_Alert_noiseReason(ctx, field) + case "meta": + return ec.fieldContext_Alert_meta(ctx, field) + case "metaValue": + return ec.fieldContext_Alert_metaValue(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Alert", field.Name) }, @@ -18596,6 +18860,10 @@ func (ec *executionContext) fieldContext_Query_alert(ctx context.Context, field return ec.fieldContext_Alert_metrics(ctx, field) case "noiseReason": return ec.fieldContext_Alert_noiseReason(ctx, field) + case "meta": + return ec.fieldContext_Alert_meta(ctx, field) + case "metaValue": + return ec.fieldContext_Alert_metaValue(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Alert", field.Name) }, @@ -30373,6 +30641,40 @@ func (ec *executionContext) fieldContext___Type_specifiedByURL(ctx context.Conte // region **************************** input.gotpl ***************************** +func (ec *executionContext) unmarshalInputAlertMetadataInput(ctx context.Context, obj interface{}) (AlertMetadataInput, error) { + var it AlertMetadataInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"key", "value"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "key": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("key")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Key = data + case "value": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("value")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Value = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputAlertMetricsOptions(ctx context.Context, obj interface{}) (AlertMetricsOptions, error) { var it AlertMetricsOptions asMap := map[string]interface{}{} @@ -30808,7 +31110,7 @@ func (ec *executionContext) unmarshalInputCreateAlertInput(ctx context.Context, asMap[k] = v } - fieldsInOrder := [...]string{"summary", "details", "serviceID", "sanitize", "dedup"} + fieldsInOrder := [...]string{"summary", "details", "serviceID", "sanitize", "dedup", "meta"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -30850,6 +31152,13 @@ func (ec *executionContext) unmarshalInputCreateAlertInput(ctx context.Context, return it, err } it.Dedup = data + case "meta": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("meta")) + data, err := ec.unmarshalOAlertMetadataInput2ᚕgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertMetadataInputᚄ(ctx, v) + if err != nil { + return it, err + } + it.Meta = data } } @@ -34627,6 +34936,75 @@ func (ec *executionContext) _Alert(ctx context.Context, sel ast.SelectionSet, ob continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "meta": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Alert_meta(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "metaValue": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Alert_metaValue(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) default: panic("unknown field " + strconv.Quote(field.Name)) @@ -34896,6 +35274,50 @@ func (ec *executionContext) _AlertLogEntryConnection(ctx context.Context, sel as return out } +var alertMetadataImplementors = []string{"AlertMetadata"} + +func (ec *executionContext) _AlertMetadata(ctx context.Context, sel ast.SelectionSet, obj *AlertMetadata) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, alertMetadataImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("AlertMetadata") + case "key": + out.Values[i] = ec._AlertMetadata_key(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "value": + out.Values[i] = ec._AlertMetadata_value(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var alertMetricImplementors = []string{"AlertMetric"} func (ec *executionContext) _AlertMetric(ctx context.Context, sel ast.SelectionSet, obj *alertmetrics.Metric) graphql.Marshaler { @@ -42681,6 +43103,15 @@ func (ec *executionContext) marshalNAlertLogEntryConnection2ᚖgithubᚗcomᚋta return ec._AlertLogEntryConnection(ctx, sel, v) } +func (ec *executionContext) marshalNAlertMetadata2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertMetadata(ctx context.Context, sel ast.SelectionSet, v AlertMetadata) graphql.Marshaler { + return ec._AlertMetadata(ctx, sel, &v) +} + +func (ec *executionContext) unmarshalNAlertMetadataInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertMetadataInput(ctx context.Context, v interface{}) (AlertMetadataInput, error) { + res, err := ec.unmarshalInputAlertMetadataInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalNAlertPendingNotification2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertPendingNotification(ctx context.Context, sel ast.SelectionSet, v AlertPendingNotification) graphql.Marshaler { return ec._AlertPendingNotification(ctx, sel, &v) } @@ -45980,6 +46411,73 @@ func (ec *executionContext) marshalOAlert2ᚖgithubᚗcomᚋtargetᚋgoalertᚋa return ec._Alert(ctx, sel, v) } +func (ec *executionContext) marshalOAlertMetadata2ᚕgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertMetadataᚄ(ctx context.Context, sel ast.SelectionSet, v []AlertMetadata) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNAlertMetadata2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertMetadata(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) unmarshalOAlertMetadataInput2ᚕgithubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertMetadataInputᚄ(ctx context.Context, v interface{}) ([]AlertMetadataInput, error) { + if v == nil { + return nil, nil + } + var vSlice []interface{} + if v != nil { + vSlice = graphql.CoerceList(v) + } + var err error + res := make([]AlertMetadataInput, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNAlertMetadataInput2githubᚗcomᚋtargetᚋgoalertᚋgraphql2ᚐAlertMetadataInput(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + func (ec *executionContext) marshalOAlertMetric2ᚖgithubᚗcomᚋtargetᚋgoalertᚋalertᚋalertmetricsᚐMetric(ctx context.Context, sel ast.SelectionSet, v *alertmetrics.Metric) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/graphql2/graphqlapp/alert.go b/graphql2/graphqlapp/alert.go index d2f688850c..d97c5f7e49 100644 --- a/graphql2/graphqlapp/alert.go +++ b/graphql2/graphqlapp/alert.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "sort" "strconv" "strings" "time" @@ -403,12 +404,38 @@ func (m *Mutation) CreateAlert(ctx context.Context, input graphql2.CreateAlertIn a.Dedup = alert.NewUserDedup(*input.Dedup) } + var meta map[string]string + if input.Meta != nil { + meta = make(map[string]string, len(input.Meta)) + for _, m := range input.Meta { + meta[m.Key] = m.Value + } + + // early validation of metadata, not required, but prevents starting a transaction and holding the service lock if we know metadata is invalid + err := alert.ValidateMetadata(meta) + if err != nil { + return nil, err + } + } + var newAlert *alert.Alert err := withContextTx(ctx, m.DB, func(ctx context.Context, tx *sql.Tx) error { var err error newAlert, err = m.AlertStore.CreateTx(ctx, tx, a) - return err + if err != nil { + return err + } + + if meta != nil { + err = m.AlertStore.SetMetadataTx(ctx, tx, newAlert.ID, meta) + if err != nil { + return err + } + } + + return nil }) + if err != nil { return nil, err } @@ -588,3 +615,27 @@ func (m *Mutation) UpdateAlertsByService(ctx context.Context, args graphql2.Upda return true, nil } + +func (a *Alert) Meta(ctx context.Context, alert *alert.Alert) ([]graphql2.AlertMetadata, error) { + md, err := (*App)(a).FindOneAlertMetadata(ctx, alert.ID) + if err != nil { + return nil, err + } + var alertMeta []graphql2.AlertMetadata + for k, v := range md { + alertMeta = append(alertMeta, graphql2.AlertMetadata{Key: k, Value: v}) + } + sort.Slice(alertMeta, func(i, j int) bool { + return alertMeta[i].Key < alertMeta[j].Key + }) + return alertMeta, nil +} + +func (a *Alert) MetaValue(ctx context.Context, alert *alert.Alert, key string) (string, error) { + md, err := (*App)(a).FindOneAlertMetadata(ctx, alert.ID) + if err != nil { + return "", err + } + + return md[key], nil +} diff --git a/graphql2/graphqlapp/dataloaders.go b/graphql2/graphqlapp/dataloaders.go index 8b88090e3d..7bfc257e38 100644 --- a/graphql2/graphqlapp/dataloaders.go +++ b/graphql2/graphqlapp/dataloaders.go @@ -39,6 +39,7 @@ const ( dataLoaderKeyNC dataLoaderAlertMetrics dataLoaderAlertFeedback + dataLoaderAlertMetadata dataLoaderKeyLast // always keep as last ) @@ -57,6 +58,9 @@ func (a *App) registerLoaders(ctx context.Context) context.Context { ctx = context.WithValue(ctx, dataLoaderKeyNC, dataloader.NewStoreLoader(ctx, a.NCStore.FindMany)) ctx = context.WithValue(ctx, dataLoaderAlertMetrics, dataloader.NewStoreLoaderInt(ctx, a.AlertMetricsStore.FindMetrics)) ctx = context.WithValue(ctx, dataLoaderAlertFeedback, dataloader.NewStoreLoaderInt(ctx, a.AlertStore.Feedback)) + ctx = context.WithValue(ctx, dataLoaderAlertMetadata, dataloader.NewStoreLoaderInt(ctx, func(ctx context.Context, i []int) ([]alert.MetadataAlertID, error) { + return a.AlertStore.FindManyMetadata(ctx, a.DB, i) + })) return ctx } @@ -70,6 +74,23 @@ func (a *App) closeLoaders(ctx context.Context) { } } +func (app *App) FindOneAlertMetadata(ctx context.Context, id int) (map[string]string, error) { + loader, ok := ctx.Value(dataLoaderAlertMetadata).(*dataloader.Loader[int, alert.MetadataAlertID]) + if !ok { + return app.AlertStore.Metadata(ctx, app.DB, id) + } + + md, err := loader.FetchOne(ctx, id) + if err != nil { + return nil, err + } + if md == nil { + return map[string]string{}, nil + } + + return md.Meta, nil +} + func (app *App) FindOneNotificationMessageStatus(ctx context.Context, id string) (*notification.SendResult, error) { loader, ok := ctx.Value(dataLoaderKeyNotificationMessageStatus).(*dataloader.Loader[string, notification.SendResult]) if !ok { diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index 4ec4382022..e4f9334c4e 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -42,6 +42,16 @@ type AlertLogEntryConnection struct { PageInfo *PageInfo `json:"pageInfo"` } +type AlertMetadata struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type AlertMetadataInput struct { + Key string `json:"key"` + Value string `json:"value"` +} + type AlertMetricsOptions struct { RInterval timeutil.ISORInterval `json:"rInterval"` FilterByServiceID []string `json:"filterByServiceID,omitempty"` @@ -119,11 +129,12 @@ type ConfigValueInput struct { } type CreateAlertInput struct { - Summary string `json:"summary"` - Details *string `json:"details,omitempty"` - ServiceID string `json:"serviceID"` - Sanitize *bool `json:"sanitize,omitempty"` - Dedup *string `json:"dedup,omitempty"` + Summary string `json:"summary"` + Details *string `json:"details,omitempty"` + ServiceID string `json:"serviceID"` + Sanitize *bool `json:"sanitize,omitempty"` + Dedup *string `json:"dedup,omitempty"` + Meta []AlertMetadataInput `json:"meta,omitempty"` } type CreateBasicAuthInput struct { diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index 45f327a216..8abfb62f48 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -375,6 +375,11 @@ input UpdateAlertsByServiceInput { newStatus: AlertStatus! } +input AlertMetadataInput { + key: String! + value: String! +} + input CreateAlertInput { summary: String! details: String @@ -387,6 +392,8 @@ input CreateAlertInput { # # It can also be used to close an alert using closeMatchingAlert mutation. dedup: String + + meta: [AlertMetadataInput!] } input CloseMatchingAlertInput { @@ -904,6 +911,10 @@ type Alert { metrics: AlertMetric noiseReason: String + + meta: [AlertMetadata!] + + metaValue(key: String!): String! } type AlertMetric { @@ -953,6 +964,11 @@ type AlertState { repeatCount: Int! } +type AlertMetadata { + key: String! + value: String! +} + type Service { id: ID! name: String! diff --git a/migrate/migrations/20240212125328-alert-metadata.sql b/migrate/migrations/20240212125328-alert-metadata.sql new file mode 100644 index 0000000000..298796fb9f --- /dev/null +++ b/migrate/migrations/20240212125328-alert-metadata.sql @@ -0,0 +1,10 @@ +-- +migrate Up +CREATE TABLE alert_data( + alert_id bigint PRIMARY KEY REFERENCES alerts(id) ON DELETE CASCADE, + metadata jsonb, + id bigserial UNIQUE NOT NULL +); + +-- +migrate Down +DROP TABLE alert_data; + diff --git a/migrate/schema.sql b/migrate/schema.sql index 6ed084dbba..6cd7f1eb4a 100644 --- a/migrate/schema.sql +++ b/migrate/schema.sql @@ -1,7 +1,7 @@ -- This file is auto-generated by "make db-schema"; DO NOT EDIT --- DATA=9259496ed3f79d063dc4da353bf700f0fb6d02165790c6ee1be701bde0122f04 - --- DISK=8d3e3dc715eda5c7f63ab884369b26da25031195c5433eb6ff086f68be94b263 - --- PSQL=8d3e3dc715eda5c7f63ab884369b26da25031195c5433eb6ff086f68be94b263 - +-- DATA=34da50576abbe903785cbec5da675a7277f8791c987d34f0d19c40380c9794fe - +-- DISK=401084b52ec0b9853e11226b90ef04487a196ebc27a2320897a8fadade948f92 - +-- PSQL=401084b52ec0b9853e11226b90ef04487a196ebc27a2320897a8fadade948f92 - -- -- pgdump-lite database dump -- @@ -1254,6 +1254,19 @@ AS $function$ -- Tables +CREATE TABLE alert_data ( + alert_id bigint NOT NULL, + id bigint DEFAULT nextval('alert_data_id_seq'::regclass) NOT NULL, + metadata jsonb, + CONSTRAINT alert_data_alert_id_fkey FOREIGN KEY (alert_id) REFERENCES alerts(id) ON DELETE CASCADE, + CONSTRAINT alert_data_id_key UNIQUE (id), + CONSTRAINT alert_data_pkey PRIMARY KEY (alert_id) +); + +CREATE UNIQUE INDEX alert_data_id_key ON public.alert_data USING btree (id); +CREATE UNIQUE INDEX alert_data_pkey ON public.alert_data USING btree (alert_id); + + CREATE TABLE alert_feedback ( alert_id bigint NOT NULL, id bigint DEFAULT nextval('alert_feedback_id_seq'::regclass) NOT NULL, diff --git a/notification/alert.go b/notification/alert.go index 66bb30eae7..fb12323eaa 100644 --- a/notification/alert.go +++ b/notification/alert.go @@ -9,6 +9,7 @@ type Alert struct { Details string ServiceID string ServiceName string + Meta map[string]string // OriginalStatus is the status of the first Alert notification to this Dest for this AlertID. OriginalStatus *SendResult diff --git a/notification/webhook/sender.go b/notification/webhook/sender.go index 992669fbb6..17c7842813 100644 --- a/notification/webhook/sender.go +++ b/notification/webhook/sender.go @@ -24,6 +24,7 @@ type POSTDataAlert struct { Details string ServiceID string ServiceName string + Meta map[string]string } // POSTDataAlertBundle represents fields in outgoing alert bundle notification. @@ -111,6 +112,7 @@ func (s *Sender) Send(ctx context.Context, msg notification.Message) (*notificat Summary: m.Summary, ServiceID: m.ServiceID, ServiceName: m.ServiceName, + Meta: m.Meta, } case notification.AlertBundle: payload = POSTDataAlertBundle{ diff --git a/permission/context.go b/permission/context.go index d2507baad7..ffc9985e0e 100644 --- a/permission/context.go +++ b/permission/context.go @@ -175,6 +175,15 @@ func UserNullUUID(ctx context.Context) uuid.NullUUID { return uuid.NullUUID{} } +// ServiceNullUUID will return the ServiceID associated with a context as a NullUUID. +func ServiceNullUUID(ctx context.Context) uuid.NullUUID { + if id, err := uuid.Parse(ServiceID(ctx)); err == nil { + return uuid.NullUUID{UUID: id, Valid: true} + } + + return uuid.NullUUID{} +} + // SystemComponentName will return the component name used to initiate a context. func SystemComponentName(ctx context.Context) string { name, _ := ctx.Value(contextKeySystem).(string) diff --git a/test/smoke/alertmetadata_test.go b/test/smoke/alertmetadata_test.go new file mode 100644 index 0000000000..420f98ce8e --- /dev/null +++ b/test/smoke/alertmetadata_test.go @@ -0,0 +1,89 @@ +package smoke + +import ( + "bytes" + "encoding/json" + "net/http" + "sort" + "testing" + + "github.com/stretchr/testify/require" + "github.com/target/goalert/test/smoke/harness" +) + +// TestAlertMetadata tests that creating an alert with metadata results in the metadata being included in the alert. +// +// - Create with GraphQL +// - Create with Generic API +// - Verify metadata is included in alert from GraphQL +func TestAlertMetadata(t *testing.T) { + const sql = ` + insert into escalation_policies (id, name) + values + ({{uuid "eid"}}, 'esc policy'); + insert into services (id, escalation_policy_id, name) + values + ({{uuid "sid"}}, {{uuid "eid"}}, 'service'); + insert into integration_keys (id, type, name, service_id) + values + ({{uuid "int_key"}}, 'generic', 'my key', {{uuid "sid"}}); + ` + + h := harness.NewHarness(t, sql, "") + defer h.Close() + + h.GraphQLQuery2(`mutation{createAlert(input:{serviceID:"` + h.UUID("sid") + `",summary:"gql",meta:[{key:"gql", value: "gqlvalue"}]}){id}}`) + + resp, err := http.Post(h.URL()+"/api/v2/generic/incoming?summary=gen_form&meta=form1key=form1value&meta=form2key=form2value&token="+h.UUID("int_key"), "", nil) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, resp.StatusCode) + + var bod struct { + Summary string `json:"summary"` + Meta map[string]string `json:"meta"` + } + bod.Summary = "gen_json" + bod.Meta = map[string]string{"jsonkey": "jsonvalue"} + data, err := json.Marshal(bod) + require.NoError(t, err) + resp, err = http.Post(h.URL()+"/api/v2/generic/incoming?token="+h.UUID("int_key"), "application/json", bytes.NewReader(data)) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, resp.StatusCode) + + res := h.GraphQLQuery2(`query{alerts{nodes{alertID summary meta{key value}}}}`) + + var result struct { + Alerts struct { + Nodes []struct { + AlertID int + Summary string + Meta []struct { + Key string + Value string + } + } + } + } + require.NoError(t, json.Unmarshal(res.Data, &result), "failed to parse response: %s", string(res.Data)) + + sort.Slice(result.Alerts.Nodes, func(i, j int) bool { return result.Alerts.Nodes[i].AlertID < result.Alerts.Nodes[j].AlertID }) + require.Len(t, result.Alerts.Nodes, 3) + + require.Equal(t, "gql", result.Alerts.Nodes[0].Summary) + require.Len(t, result.Alerts.Nodes[0].Meta, 1) + require.Equal(t, "gql", result.Alerts.Nodes[0].Meta[0].Key) + require.Equal(t, "gqlvalue", result.Alerts.Nodes[0].Meta[0].Value) + + require.Equal(t, "gen_form", result.Alerts.Nodes[1].Summary) + require.Len(t, result.Alerts.Nodes[1].Meta, 2) + require.Equal(t, "form1key", result.Alerts.Nodes[1].Meta[0].Key) + require.Equal(t, "form1value", result.Alerts.Nodes[1].Meta[0].Value) + require.Equal(t, "form2key", result.Alerts.Nodes[1].Meta[1].Key) + require.Equal(t, "form2value", result.Alerts.Nodes[1].Meta[1].Value) + + require.Equal(t, "gen_json", result.Alerts.Nodes[2].Summary) + require.Len(t, result.Alerts.Nodes[2].Meta, 1) + require.Equal(t, "jsonkey", result.Alerts.Nodes[2].Meta[0].Key) + require.Equal(t, "jsonvalue", result.Alerts.Nodes[2].Meta[0].Value) + +} diff --git a/web/src/app/documentation/sections/IntegrationKeys.md b/web/src/app/documentation/sections/IntegrationKeys.md index 603174d7b4..d30efcfe75 100644 --- a/web/src/app/documentation/sections/IntegrationKeys.md +++ b/web/src/app/documentation/sections/IntegrationKeys.md @@ -11,6 +11,28 @@ | `details` | _optional_ | Additional information about the alert, supports markdown. | | `action` | _optional_ | If set to `close`, it will close any matching alerts. | | `dedup` | _optional_ | All calls for the same service with the same `dedup` string will update the same alert (if open) or create a new one. Defaults to using summary & details together. | +| `meta` | _optional_ | Additional key/value metadata to attach to the alert. | + +#### Metadata + +Metadata can be used to attach additional information to the alert, form or query parameters can specify the metadata like this: + +```url +/api/v2/generic/incomming?token=&meta=example_key=example_value&meta=example_key2=example_value2 +``` + +Or as a map for JSON requests: +```json +{ + "summary": "test", + "details": "test", + "meta": { + "example_key": "example_value", + "example_key2": "example_value2" + } +} +``` + ### Response: diff --git a/web/src/app/documentation/sections/Webhooks.md b/web/src/app/documentation/sections/Webhooks.md index 327a06e22d..9f4c74e43f 100644 --- a/web/src/app/documentation/sections/Webhooks.md +++ b/web/src/app/documentation/sections/Webhooks.md @@ -38,6 +38,10 @@ Triggered for notification of a single alert. "AlertID": 79685, "Summary": "Example Summary", "Details": "Example Details..." + "Meta": { + "example_field": "example_value", + "example_field2": "example_value2" + } } ``` diff --git a/web/src/schema.d.ts b/web/src/schema.d.ts index 934944196f..a87d04152f 100644 --- a/web/src/schema.d.ts +++ b/web/src/schema.d.ts @@ -5,6 +5,8 @@ export interface Alert { createdAt: ISOTimestamp details: string id: string + meta?: null | AlertMetadata[] + metaValue: string metrics?: null | AlertMetric noiseReason?: null | string pendingNotifications: AlertPendingNotification[] @@ -38,6 +40,16 @@ export interface AlertLogEntryConnection { pageInfo: PageInfo } +export interface AlertMetadata { + key: string + value: string +} + +export interface AlertMetadataInput { + key: string + value: string +} + export interface AlertMetric { closedAt: ISOTimestamp escalated: boolean @@ -162,6 +174,7 @@ export type ContactMethodType = export interface CreateAlertInput { dedup?: null | string details?: null | string + meta?: null | AlertMetadataInput[] sanitize?: null | boolean serviceID: string summary: string