From cbe89ca6e52a6a303f4512cae9945a2e0c1cb8c3 Mon Sep 17 00:00:00 2001 From: shivanishendge <49693251+shivanishendge@users.noreply.github.com> Date: Tue, 2 Apr 2024 23:40:31 +0530 Subject: [PATCH] Add alert metadata (#3764) * alert metadata WIP * add alert metada changes * add alert type * add validation * generic api handler correction * add meta field to webhook notification * remove log statements * resolve go lint issues * Update migrate/migrations/20240212125328-alert-metadata.sql Co-authored-by: Nathaniel Caza * Update graphql2/schema.graphql Co-authored-by: Nathaniel Caza * Update graphql2/graphqlapp/alert.go Co-authored-by: Nathaniel Caza * change as per review * Add createMetaTx method * fix urlform values * fix generic api tests * fix lint error * remove commented code * move metadata out of CreateOrUpdate Since we're handling this separatly with a specific store method, we don't want to add a second place to set metadata here. If/when we update things using CreateOrUpdate, we'll make a Tx method for it. This way we don't need to update call sites across the app for the new signature. * update SetMetadata to support service permissions validation So this query is a bit more complicated, but we want to make sure no future bug allows a service/integration key to update the metadata of an alert that doesn't belong to it. Integration keys in GoAlert are tied to a single service, and all DB calls regarding alerts should be guarded such that only alerts belonging to that specific service may be modified, including the newly introduced metadata. The approach I took here allows us to pass a service ID, optionally (i.e., ONLY when a request comes from an integration), where the query will fail to insert/update any data if it's mismatched. If the service ID is null, it will skip this check (i.e., a user action). * fix type for manymetadata and add single query This is just updating the type from int to bigint (64 bit) and using @alert_ids instead of $1 so that sqlc generates a better name for the argument. * regen sqlc * move store methods to metadata.go For organization sake, moving the store methods to a separate file (we have a bad habbit of having the store.go files growing forever). I also implemented things using the `gadb` and taking a `DBTX` rather than a sql.Tx. This is the most recent pattern, although most DB code has not been refactored for this. I'm using the `map[string]string` Go type directly to get rid of any indirection on the metadata format external to this package. Internally, I added a `metadataDBFormat` that hopefully by name makes it clearer it's only intended to be used at rest. Compared to what was there before, I also: - Added assertions on the type string - Added a MetadataAlertID type that includes an alert ID for the dataloader - Added an Access Denied error if SetMetadataTx fails because of an invalid alert or service ID mismatch * move validation to metadata.go For this I just moved the validation to metadata.go and implemented it as a standalone function rather than a method. This was a mistake on my part of recommending a method -- we really don't need a specific type for metadata as it's just a `map[string]string` we also don't have a use-case to "normalize" the data, as we always store it as-is. This is different to things like services, schedules, etc... where we will do things like trim whitespace, remove double-spaces, to "normalize" the input. For this it's just pass or fail. * missing sig. updates Some leftover updates I forgot to commit * add ServiceNullUUID method to permission pkg Similar to the UserNullUUID -- a convenience/helper method so we can pull the service ID from the current context and pass it directly as a query argument. * update grafana call * regen sqlc * fix CreateOrUpdate call * add db in call for metadata * fix edge case handling in MetaValue ErrNoRows is handled by the method itself, so here we can just return any err we get. We also want to check if the map is nil before doing `md[key]` so we don't panic if the alert has no metadata. * move map handling out of Tx function This is a bit of an optimization -- we can handle allocating the memory, and processing the map outside of the transaction to keep the transaction as quick as possible. Additionally, we can do some early validation to jump out _before_ we get to the withContextTx call. This helps because to create the alert, we actually: - Start a transaction - Grab a global exclusive lock on the service's row - Create the alert itself - Add the log entry about the alert So if we already know the metadata is going to be rejected, we can skip that. Lastly, now that the alert store only takes `map[string]map` it simplifies the code here as well. * add create CreateOrUpdateWithMeta store method and update generic api handler call to point to new method * add comment * remove unused queries * clairify comment * tweak error message * cleanup graphql code - removed debug print - removed redundant nil check on Metadata() result - updated Metadata comment regarding return values * update documentation * remove changes to existing tests (will create new) * add metadata smoke test * add dataloader for batching metadata lookups * use dataloader * regen * add id column for switchover * regen sqlc --------- Co-authored-by: Forfold Co-authored-by: Nathaniel Caza --- alert/alert.go | 1 + alert/metadata.go | 158 ++++++ alert/queries.sql | 35 ++ alert/store.go | 18 + engine/sendmessage.go | 5 + gadb/models.go | 6 + gadb/queries.sql.go | 87 +++ genericapi/handler.go | 16 +- graphql2/generated.go | 500 +++++++++++++++++- graphql2/graphqlapp/alert.go | 53 +- graphql2/graphqlapp/dataloaders.go | 21 + graphql2/models_gen.go | 21 +- graphql2/schema.graphql | 16 + .../20240212125328-alert-metadata.sql | 10 + migrate/schema.sql | 19 +- notification/alert.go | 1 + notification/webhook/sender.go | 2 + permission/context.go | 9 + test/smoke/alertmetadata_test.go | 89 ++++ .../documentation/sections/IntegrationKeys.md | 22 + .../app/documentation/sections/Webhooks.md | 4 + web/src/schema.d.ts | 13 + 22 files changed, 1094 insertions(+), 12 deletions(-) create mode 100644 alert/metadata.go create mode 100644 migrate/migrations/20240212125328-alert-metadata.sql create mode 100644 test/smoke/alertmetadata_test.go 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