package kinesis import ( "encoding/base64" "encoding/json" "fmt" "strings" "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/sqs" "github.com/aws/aws-sdk-go/service/sqs/sqsiface" "github.com/google/uuid" "github.com/pkg/errors" "github.com/featurebasedb/featurebase/v3/logger" ) // ErrorType signifies the type of error encountered. type ErrorType string // These are the currently supported ErrorType values that can be emitted // to an ErrorStore. const ( RecoverableErrorType = ErrorType("Error") PanicErrorType = ErrorType("Panic") // Runtime errors. ) // ErrorStore is an abstraction over a resource like external storage, a database, // a queue, etc. that can receive and store error messages. type ErrorStore interface { // Available checks if the backing resource can receive error // messages via Push. Available() bool // Push emits an error message of type ErrorType to the backing resource. // // The caller should NOT assume that Available is called implicitly // to check for ErrorStore availability. Push(ErrorType, string, logger.Logger) error } // SinkErrorPayload contains all data about a single IDK error message that will be // emitted to an ErrorStore; includes a timestamp and a valid sink ID. // // This payload is not meant to be used outside the context of error propogation to // an ErrorStore. type SinkErrorPayload struct { SinkId string `json:"sink_id"` ErrorType ErrorType `json:"error_type"` ErrorMessage string `json:"error_msg"` Timestamp string `json:"time"` } // SinkErrorQueue is an ErrorStore implementation that uses an SQS queue as its backing // resource to emit error and panic messages to. // // It also maps 1-to-1 to a Kinesis stream via a unique sink ID. type SinkErrorQueue struct { sinkId string name string url string queue sqsiface.SQSAPI } // NewSinkErrorQueue attempts to construct a SinkErrorQueue instance from an AWS SQS client, // a queue name, and a sink ID. // // On success, callers can assume that a backing SQS queue resource exists and is fully initialized. // // Returns nil and an SQS error if an SQS queue URL cannot be resolved from the queue name and/or // the AWS SQS client. // // This method assumes the sink ID argument is valid. func NewSinkErrorQueue(queue sqsiface.SQSAPI, queueName, sinkId string) (*SinkErrorQueue, error) { input := &sqs.GetQueueUrlInput{QueueName: &queueName} output, err := queue.GetQueueUrl(input) if err != nil { return nil, err } return &SinkErrorQueue{sinkId, queueName, *output.QueueUrl, queue}, nil } // SinkErrorQueueFrom always constructs a SinkErrorQueue instance from an AWS SQS client and a // kinesis.Source. // // Unlike NewSinkErrorQueue, this does NOT return an error if a queue URL cannot be resolved // from the queue name and/or the AWS SQS client. Instead it will collapse to a SinkErrorQueue // instance with a backing SQS resource that is ALWAYS unavailable. Attempting to invoke Push // on this instance will not result in an error; instead it will just emit a warning that no // backing SQS resource could be written to. // // This does check if the sink ID has a valid form: 'PREFIX'-VALID_UUID. If not, this collapses // to a SinkErrorQueue instance that is ALWAYS unavailable. func SinkErrorQueueFrom(queue sqsiface.SQSAPI, source *Source) *SinkErrorQueue { // The below failure conditions are handled by collapsing to a no-op ErrorStore.Push implementation. // - A missing SQS queue name. // - An invalid sink ID (i.e. not a valid UUID); valid sink ID: "PREFIX"-UUID. // - Unable to resolve queue URL from queue name. // // Means downstream ErrorStreamLogger behaves identical to its embedded Logger. if source.ErrorQueueName == "" { return &SinkErrorQueue{} } sinkId := strings.Join(strings.Split(source.StreamName, "-")[1:], "-") _, err := uuid.Parse(sinkId) if err != nil { // Keep invalid sink ID around in case something downstream wants to log. return &SinkErrorQueue{sinkId, source.ErrorQueueName, "", nil} } sinkErrorQueue, err := NewSinkErrorQueue(queue, source.ErrorQueueName, sinkId) if err != nil { return &SinkErrorQueue{sinkId, source.ErrorQueueName, "", nil} } return sinkErrorQueue } // Available checks that a valid SQS queue resource exists. func (seq *SinkErrorQueue) Available() bool { return seq.url != "" && seq.queue != nil } // Push attempts to emit a single error message of type ErrorType to a SQS queue resource. // // If the backing SQS queue resource is not available, this function is a no-op and does NOT // return an error. Instead it emits a warning to the logger.Logger instance specified by the log // argument. // // The warning can be ignored entirely by passing a nil log argument. func (seq *SinkErrorQueue) Push(errorType ErrorType, message string, log logger.Logger) error { if !seq.Available() { if log != nil { log.Warnf("Not pushing errors to an SQS queue='%+v' due to unavailability.", seq) } return nil } payload := SinkErrorPayload{ SinkId: seq.sinkId, ErrorType: errorType, ErrorMessage: message, Timestamp: time.Now().Format(time.RFC3339), } payloadBytes, err := json.Marshal(&payload) if err != nil { msgTemplate := "Unable to marshal payload='%+v' to send message='%s' to queue='%+v'." return errors.Wrap(err, fmt.Sprintf(msgTemplate, payload, message, seq.queue)) } encodedPayload := base64.URLEncoding.EncodeToString(payloadBytes) input := &sqs.SendMessageInput{ DelaySeconds: aws.Int64(10), MessageBody: aws.String(encodedPayload), QueueUrl: &seq.url, } _, err = seq.queue.SendMessage(input) if err != nil { msgTemplate := "Unable to send message='%s' to queue='%+v' with input='%+v'." return errors.Wrap(err, fmt.Sprintf(msgTemplate, message, seq.queue, input)) } return nil } // ErrorStreamLogger is a logger.Logger implementation that decorates a base logger.Logger instance // and emits error and panic messages to an ErrorStore. // // All other log levels delegate to the base logger.Logger implementation. type ErrorStreamLogger struct { base logger.Logger store ErrorStore } // NewErrorStreamLogger constructs an ErrorStreamLogger from a logger.Logger and an ErrorStore. func NewErrorStreamLogger(base logger.Logger, store ErrorStore) *ErrorStreamLogger { return &ErrorStreamLogger{base, store} } // Printf just delegates to the wrapped/base logger.Logger's Printf implementation. func (esl *ErrorStreamLogger) Printf(format string, v ...interface{}) { esl.base.Printf(format, v...) } // Debugf just delegates to the wrapped/base logger.Logger's Debugf implementation. func (esl *ErrorStreamLogger) Debugf(format string, v ...interface{}) { esl.base.Debugf(format, v...) } // Infof just delegates to the wrapped/base logger.Logger's Infof implementation. func (esl *ErrorStreamLogger) Infof(format string, v ...interface{}) { esl.base.Infof(format, v...) } // Warnf just delegates to the wrapped/base logger.Logger's Warnf implementation. func (esl *ErrorStreamLogger) Warnf(format string, v ...interface{}) { esl.base.Warnf(format, v...) } // Errorf delegates to the wrapped/base logger.Logger's Errorf implementation and additionally/ // pushes an error message with RecoverableErrorType ErrorType to the ErrorStore. // // If an error occurs during a Push to the ErrorStore, the error is logged to the wrapped/base // logger.Logger using Errorf. func (esl *ErrorStreamLogger) Errorf(format string, v ...interface{}) { esl.base.Errorf(format, v...) err := esl.store.Push(RecoverableErrorType, fmt.Sprintf(format, v...), esl) if err != nil { errMsg := fmt.Sprintf("Failed during push to store='%+v'", esl.store) esl.base.Errorf(errors.Wrap(err, errMsg).Error()) } } // Panicf delegates to the wrapped/base logger.Logger's Panicf implementation and additionally // pushes an error message with PanicErrorType ErrorType to the ErrorStore. // // If an error occurs during a Push to the ErrorStore, the error is logged to the wrapped/base // logger.Logger using Errorf NOT Panicf. func (esl *ErrorStreamLogger) Panicf(format string, v ...interface{}) { esl.base.Panicf(format, v...) err := esl.store.Push(PanicErrorType, fmt.Sprintf(format, v...), esl) if err != nil { // A (recoverable) error occurred during a push to the store, so // log that error using `Errorf` not `Panicf`. errMsg := fmt.Sprintf("Failed during push to store='%+v'", esl.store) esl.base.Errorf(errors.Wrap(err, errMsg).Error()) } } func (esl *ErrorStreamLogger) WithPrefix(prefix string) logger.Logger { return NewErrorStreamLogger(esl.base.WithPrefix(prefix), esl.store) }