github.com/blend/go-sdk@v1.20220411.3/status/tracked_action.go (about) 1 /* 2 3 Copyright (c) 2022 - Present. Blend Labs, Inc. All rights reserved 4 Use of this source code is governed by a MIT license that can be found in the LICENSE file. 5 6 */ 7 8 package status 9 10 import ( 11 "context" 12 "fmt" 13 "math" 14 "sync" 15 "time" 16 17 "github.com/blend/go-sdk/async" 18 "github.com/blend/go-sdk/ex" 19 ) 20 21 var ( 22 _ async.Interceptor = (*TrackedAction)(nil) 23 ) 24 25 // NewTrackedAction returns a new tracked action. 26 func NewTrackedAction(serviceName string, opts ...TrackedActionOption) *TrackedAction { 27 ta := &TrackedAction{ 28 ServiceName: serviceName, 29 } 30 _ = (&ta.TrackedActionConfig).Resolve(context.Background()) 31 for _, opt := range opts { 32 opt(ta) 33 } 34 return ta 35 } 36 37 // TrackedActionOption mutates a tracked action. 38 type TrackedActionOption func(*TrackedAction) 39 40 // OptTrackedActionConfig sets the tracked action config. 41 func OptTrackedActionConfig(cfg TrackedActionConfig) TrackedActionOption { 42 return func(ta *TrackedAction) { 43 ta.TrackedActionConfig = cfg 44 } 45 } 46 47 // TrackedAction is a wrapper for action that tracks a rolling 48 // window of history based on the configured expiration. 49 type TrackedAction struct { 50 TrackedActionConfig 51 sync.Mutex 52 53 ServiceName string 54 55 nowProvider func() time.Time 56 errors []ErrorInfo 57 requests []RequestInfo 58 } 59 60 // Intercept implements async.Interceptor. 61 func (t *TrackedAction) Intercept(action Actioner) Actioner { 62 return ActionerFunc(func(ctx context.Context, args interface{}) (output interface{}, err error) { 63 defer func() { 64 if r := recover(); r != nil { 65 err = ex.Append(err, ex.New(r)) 66 } 67 t.CleanOldRequests() 68 if err != nil { 69 t.AddErroredRequest(args) 70 } else { 71 t.AddSuccessfulRequest() 72 } 73 }() 74 output, err = action.Action(ctx, args) 75 return 76 }) 77 } 78 79 // GetStatus gets the status for the tracker. 80 // 81 // It is safe to call concurrently from multiple goroutines. 82 func (t *TrackedAction) GetStatus() (info Info) { 83 t.Lock() 84 defer t.Unlock() 85 86 t.cleanOldRequestsUnsafe() 87 info.Name = t.ServiceName 88 info.Status = t.getStatusSignalUnsafe() 89 90 errorBreakdown := make(map[string]int) 91 if info.Status == SignalYellow || info.Status == SignalRed { 92 for _, errorInfo := range t.errors { 93 errorBreakdown[t.formatArgs(errorInfo.Args)]++ 94 } 95 } 96 info.Details = Details{ 97 ErrorCount: len(t.errors), 98 RequestCount: len(t.requests), 99 ErrorBreakdown: errorBreakdown, 100 } 101 return 102 } 103 104 // GetStatusSignal returns the current status signal. 105 // 106 // It is safe to call concurrently from multiple goroutines. 107 func (t *TrackedAction) GetStatusSignal() (status Signal) { 108 t.Lock() 109 status = t.getStatusSignalUnsafe() 110 t.Unlock() 111 return 112 } 113 114 // AddErroredRequest adds an errored request. 115 // 116 // It is safe to call concurrently from multiple goroutines. 117 func (t *TrackedAction) AddErroredRequest(args interface{}) { 118 t.Lock() 119 defer t.Unlock() 120 t.errors = append(t.errors, ErrorInfo{ 121 Args: args, 122 RequestInfo: RequestInfo{ 123 RequestTime: t.now(), 124 }, 125 }) 126 } 127 128 // AddSuccessfulRequest adds a successful request. 129 // 130 // It is safe to call concurrently from multiple goroutines. 131 func (t *TrackedAction) AddSuccessfulRequest() { 132 t.Lock() 133 defer t.Unlock() 134 t.requests = append(t.requests, RequestInfo{RequestTime: t.now()}) 135 } 136 137 // CleanOldRequests is an action delegate that removes expired requests 138 // from the tracker 139 // 140 // It is safe to call concurrently from multiple goroutines. 141 func (t *TrackedAction) CleanOldRequests() { 142 t.Lock() 143 defer t.Unlock() 144 t.cleanOldRequestsUnsafe() 145 } 146 147 // 148 // Private - Internal 149 // 150 151 func (t *TrackedAction) formatArgs(args interface{}) string { 152 switch typed := args.(type) { 153 case string: 154 return typed 155 case []byte: 156 return string(typed) 157 case []rune: 158 return string(typed) 159 case fmt.Stringer: 160 return typed.String() 161 default: 162 return "unknown" 163 } 164 } 165 166 // getStatusSignalUnsafe gets the specific signal (green, yellow, or red) 167 // for the tracker. 168 func (t *TrackedAction) getStatusSignalUnsafe() (status Signal) { 169 status = SignalGreen 170 requestCount := len(t.requests) 171 errorCount := float64(len(t.errors)) 172 if errorCount >= t.redErrorCount(requestCount) { 173 status = SignalRed 174 } else if errorCount >= t.yellowErrorCount(requestCount) { 175 status = SignalYellow 176 } 177 return status 178 } 179 180 func (t *TrackedAction) cleanOldRequestsUnsafe() { 181 nowUTC := t.now() 182 var filteredErrors []ErrorInfo 183 for _, errorInfo := range t.errors { 184 if nowUTC.Sub(errorInfo.RequestTime) < t.ExpirationOrDefault() { 185 filteredErrors = append(filteredErrors, errorInfo) 186 } 187 } 188 189 t.errors = filteredErrors 190 var filteredRequests []RequestInfo 191 for _, requestInfo := range t.requests { 192 if nowUTC.Sub(requestInfo.RequestTime) < t.ExpirationOrDefault() { 193 filteredRequests = append(filteredRequests, requestInfo) 194 } 195 } 196 t.requests = filteredRequests 197 } 198 199 // redErrorCount returns the expected threshold for what is 200 // considered a "red" signal status based on either the baseline `RedRequestCount` 201 // or the RedRequestPercentage applied to the current request count. 202 // 203 // It is meant to scale the threshold to the volume of the calls 204 // to the tracked action. 205 func (t *TrackedAction) redErrorCount(requestCount int) float64 { 206 return math.Max( 207 float64(t.RedRequestCount), 208 t.RedRequestPercentage*float64(requestCount), 209 ) 210 } 211 212 // yellowErrorCount returns the expected threshold for what is 213 // considered a "yellow" signal status based on either the baseline `YellowRequestCount` 214 // or the YellowRequestPercentage applied to the current request count. 215 // 216 // It is meant to scale the threshold to the volume of the calls 217 // to the tracked action 218 func (t *TrackedAction) yellowErrorCount(requestCount int) float64 { 219 return math.Max( 220 float64(t.YellowRequestCount), 221 t.YellowRequestPercentage*float64(requestCount), 222 ) 223 } 224 225 // now returns the current time. 226 func (t *TrackedAction) now() time.Time { 227 if t.nowProvider != nil { 228 return t.nowProvider() 229 } 230 return time.Now().UTC() 231 }