Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

graphql: add debugMessages query #2052

Merged
merged 9 commits into from
Dec 15, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
928 changes: 916 additions & 12 deletions graphql2/generated.go

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions graphql2/gqlgen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ models:
model: github.com/target/goalert/graphql2.OnCallNotificationRuleInput
WeekdayFilter:
model: github.com/target/goalert/util/timeutil.WeekdayFilter
DebugMessage:
model: github.com/target/goalert/notification.RecentMessage
ID:
model:
- github.com/99designs/gqlgen/graphql.ID
Expand Down
109 changes: 108 additions & 1 deletion graphql2/graphqlapp/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,124 @@ package graphqlapp

import (
context "context"
"fmt"
"strings"

"github.com/google/uuid"
"github.com/target/goalert/graphql2"
"github.com/target/goalert/notification"
"github.com/target/goalert/notificationchannel"
"github.com/target/goalert/search"
"github.com/target/goalert/validation/validate"

"github.com/pkg/errors"
)

type Query App
type DebugMessage App

func (a *App) Query() graphql2.QueryResolver { return (*Query)(a) }
func (a *App) Query() graphql2.QueryResolver { return (*Query)(a) }
func (a *App) DebugMessage() graphql2.DebugMessageResolver { return (*DebugMessage)(a) }

func (a *App) formatNC(ctx context.Context, id string) (string, error) {
uid, err := uuid.Parse(id)
if err != nil {
return "", err
}

n, err := a.NCStore.FindOne(ctx, uid)
if err != nil {
return "", err
}
var typeName string
switch n.Type {
case notificationchannel.TypeSlack:
typeName = "Slack"
default:
typeName = string(n.Type)
}

return fmt.Sprintf("%s (%s)", n.Name, typeName), nil
}

func (a *DebugMessage) Destination(ctx context.Context, obj *notification.RecentMessage) (string, error) {
if !obj.Dest.Type.IsUserCM() {
return (*App)(a).formatNC(ctx, obj.Dest.ID)
}

var str strings.Builder
str.WriteString((*App)(a).FormatDestFunc(ctx, obj.Dest.Type, obj.Dest.Value))
switch obj.Dest.Type {
case notification.DestTypeSMS:
str.WriteString(" (SMS)")
case notification.DestTypeUserEmail:
str.WriteString(" (Email)")
case notification.DestTypeVoice:
str.WriteString(" (Voice)")
case notification.DestTypeUserWebhook:
str.Reset()
str.WriteString("Webhook")
default:
str.Reset()
str.WriteString(obj.Dest.Type.String())
}

return str.String(), nil
}
func (a *DebugMessage) Type(ctx context.Context, obj *notification.RecentMessage) (string, error) {
return strings.TrimPrefix(obj.Type.String(), "MessageType"), nil
}
func (a *DebugMessage) Status(ctx context.Context, obj *notification.RecentMessage) (string, error) {
var str strings.Builder
switch obj.Status.State {
case notification.StateUnknown:
str.WriteString("Unknown")
case notification.StateSending:
str.WriteString("Sending")
case notification.StatePending:
str.WriteString("Pending")
case notification.StateSent:
str.WriteString("Sent")
case notification.StateDelivered:
str.WriteString("Delivered")
case notification.StateFailedTemp:
str.WriteString("Failed (temporary)")
case notification.StateFailedPerm:
str.WriteString("Failed (permanent)")
}
if obj.Status.Details != "" {
str.WriteString(": ")
str.WriteString(obj.Status.Details)
}
return str.String(), nil
}
func (a *DebugMessage) Source(ctx context.Context, obj *notification.RecentMessage) (string, error) {
if obj.Status.SrcValue == "" {
return "", nil
}

return notification.Dest{Type: obj.Dest.Type, Value: obj.Status.SrcValue}.String(), nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should rely on the destination interface for formatting the source value. I sent a test message and got the following response:

Screen Shot 2021-12-07 at 4 25 22 PM

I would expect source to be the Twilio FromNumber

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, we should use the same formatting as destination and keep it as an empty string if value is blank.

}
func (a *DebugMessage) ProviderID(ctx context.Context, obj *notification.RecentMessage) (string, error) {
return obj.ProviderID.ExternalID, nil
}

func (a *Query) DebugMessages(ctx context.Context, input *graphql2.DebugMessagesInput) ([]notification.RecentMessage, error) {
var options notification.RecentMessageSearchOptions
if input == nil {
input = &graphql2.DebugMessagesInput{}
}
if input.CreatedAfter != nil {
options.After = *input.CreatedAfter
}
if input.CreatedBefore != nil {
options.Before = *input.CreatedBefore
}
if input.First != nil {
options.Limit = *input.First
}
return a.NotificationStore.RecentMessages(ctx, &options)
}

func (a *Query) AuthSubjectsForProvider(ctx context.Context, _first *int, _after *string, providerID string) (conn *graphql2.AuthSubjectConnection, err error) {
var first int
Expand Down
6 changes: 6 additions & 0 deletions graphql2/models_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions graphql2/schema.graphql
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
type Query {
phoneNumberInfo(number: String!): PhoneNumberInfo

# Returns the list of recent messages.
debugMessages(input: DebugMessagesInput): [DebugMessage!]!

# Returns the user with the given ID. If no ID is specified,
# the current user is implied.
user(id: ID): User
Expand Down Expand Up @@ -108,6 +111,28 @@ type Query {
generateSlackAppManifest: String!
}

input DebugMessagesInput {
first: Int = 15
createdBefore: ISOTimestamp
createdAfter: ISOTimestamp
}

type DebugMessage {
id: ID!
createdAt: ISOTimestamp!
updatedAt: ISOTimestamp!
type: String!
status: String!
userID: ID!
userName: String!
source: String!
destination: String!
serviceID: ID!
serviceName: String!
alertID: Int!
providerID: ID!
}

input SlackChannelSearchOptions {
first: Int = 15
after: String = ""
Expand Down
153 changes: 153 additions & 0 deletions notification/recentmessage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package notification

import (
"context"
"database/sql"
"fmt"
"text/template"
"time"

"github.com/pkg/errors"
"github.com/target/goalert/permission"
"github.com/target/goalert/search"
"github.com/target/goalert/util/log"
"github.com/target/goalert/validation/validate"
)

// A RecentMessage describes a message that is/was recently processed by the system.
type RecentMessage struct {
ID string
CreatedAt time.Time
UpdatedAt time.Time
Type MessageType
Status Status
UserID string
UserName string
Dest Dest
ServiceID string
ServiceName string
AlertID int
ProviderID ProviderMessageID
}

var searchTemplate = template.Must(template.New("search").Parse(`
SELECT
m.id, m.created_at, m.last_status_at, m.message_type,
m.last_status, m.status_details, m.provider_seq, m.src_value,
m.user_id, u.name, m.contact_method_id, m.channel_id, cm.type, c.type, cm.value, c.value,
m.service_id, s.name, m.alert_id, m.provider_msg_id
FROM outgoing_messages m
LEFT JOIN users u ON u.id = m.user_id
LEFT JOIN services s ON s.id = m.service_id
LEFT JOIN user_contact_methods cm ON cm.id = m.contact_method_id
LEFT JOIN notification_channels c ON c.id = m.channel_id
WHERE true
{{if not .Before.IsZero}}
AND m.created_at < :before
{{end}}
{{if not .After.IsZero}}
AND m.created_at >= :after
{{end}}
ORDER BY m.created_at DESC
LIMIT {{.Limit}}
`))

type RecentMessageSearchOptions struct {
Before time.Time
After time.Time
Limit int
}

type renderData RecentMessageSearchOptions

func (opts renderData) Normalize() (*renderData, error) {
if opts.Limit == 0 {
opts.Limit = search.DefaultMaxResults
}

// set limit higher than normal since this is for admin use
return &opts, validate.Range("Limit", opts.Limit, 0, 1000)
}

func (opts renderData) QueryArgs() []sql.NamedArg {
return []sql.NamedArg{
sql.Named("before", opts.Before),
sql.Named("after", opts.After),
}
}

func (db *DB) RecentMessages(ctx context.Context, opts *RecentMessageSearchOptions) ([]RecentMessage, error) {
err := permission.LimitCheckAny(ctx, permission.Admin)
if err != nil {
return nil, err
}
if opts == nil {
opts = &RecentMessageSearchOptions{}
}
data, err := (*renderData)(opts).Normalize()
if err != nil {
return nil, err
}
query, args, err := search.RenderQuery(ctx, searchTemplate, data)
if err != nil {
return nil, errors.Wrap(err, "render query")
}

rows, err := db.db.QueryContext(ctx, query, args...)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
defer rows.Close()

var msgs []RecentMessage
for rows.Next() {
var lastStatus sql.NullTime
var srcValue, userID, userName, serviceID, serviceName, cmID, chID, providerID, cmVal, chVal sql.NullString
var alertID sql.NullInt64
var dstType ScannableDestType
var msg RecentMessage
err := rows.Scan(
&msg.ID, &msg.CreatedAt, &lastStatus, &msg.Type,
&msg.Status.State, &msg.Status.Details, &msg.Status.Sequence, &srcValue,
&userID, &userName,
&cmID, &chID, &dstType.CM, &dstType.NC, &cmVal, &chVal,
&serviceID, &serviceName, &alertID, &providerID,
)
if err != nil {
return nil, err
}
if lastStatus.Valid {
msg.UpdatedAt = lastStatus.Time
} else {
msg.UpdatedAt = msg.CreatedAt
}
msg.Status.SrcValue = srcValue.String
msg.UserID = userID.String
msg.UserName = userName.String
msg.ServiceID = serviceID.String
msg.ServiceName = serviceName.String
msg.AlertID = int(alertID.Int64)
if providerID.Valid {
msg.ProviderID, err = ParseProviderMessageID(providerID.String)
if err != nil {
log.Log(ctx, fmt.Errorf("invalid provider message id '%s': %w", providerID.String, err))
}
}
msg.Dest.Type = dstType.DestType()
switch {
case msg.Dest.Type.IsUserCM():
msg.Dest.ID = cmID.String
msg.Dest.Value = cmVal.String
default:
msg.Dest.ID = chID.String
msg.Dest.Value = chVal.String
}

msgs = append(msgs, msg)
}

return msgs, err
}
37 changes: 37 additions & 0 deletions notification/status.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package notification

import "fmt"

// Status describes the current state of an outgoing message.
type Status struct {

Expand Down Expand Up @@ -70,3 +72,38 @@ const (
// will also fail.
StateFailedPerm
)

func (s *State) fromString(val string) error {
switch val {
case "pending":
*s = StatePending
case "sending", "queued_remotely":
*s = StateSending
case "sent":
*s = StateSent
case "delivered":
*s = StateDelivered
case "failed":
*s = StateFailedPerm
case "stale":
*s = StateFailedTemp
default:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this switch missing the bundled case?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh that's a good call, we may need to add a new state. Normally "bundled" messages aren't processed at all, but they would still be returned here.

return fmt.Errorf("unexpected value %q", val)
}

return nil
}

func (s *State) Scan(value interface{}) error {
switch v := value.(type) {
case []byte:
return s.fromString(string(v))
case string:
return s.fromString(v)
case nil:
*s = StateUnknown
return nil
default:
return fmt.Errorf("unexpected type %T", value)
}
}
2 changes: 2 additions & 0 deletions notification/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type Store interface {

// FindPendingNotifications will return destination info for alerts that are waiting to be sent
FindPendingNotifications(ctx context.Context, alertID int) ([]AlertPendingNotification, error)

RecentMessages(context.Context, *RecentMessageSearchOptions) ([]RecentMessage, error)
}

var _ Store = &DB{}
Expand Down
Loading