github.com/m3db/m3@v1.5.0/src/query/api/v1/middleware/logging_test.go (about) 1 // Copyright (c) 2021 Uber Technologies, Inc. 2 // 3 // Permission is hereby granted, free of charge, to any person obtaining a copy 4 // of this software and associated documentation files (the "Software"), to deal 5 // in the Software without restriction, including without limitation the rights 6 // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 // copies of the Software, and to permit persons to whom the Software is 8 // furnished to do so, subject to the following conditions: 9 // 10 // The above copyright notice and this permission notice shall be included in 11 // all copies or substantial portions of the Software. 12 // 13 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 // THE SOFTWARE. 20 21 package middleware 22 23 import ( 24 "context" 25 "io/ioutil" 26 "net/http" 27 "os" 28 "strings" 29 "testing" 30 "time" 31 32 "github.com/jonboulle/clockwork" 33 "github.com/stretchr/testify/assert" 34 "github.com/stretchr/testify/require" 35 "go.uber.org/zap" 36 "go.uber.org/zap/zapcore" 37 38 "github.com/m3db/m3/src/query/util/logging" 39 "github.com/m3db/m3/src/x/instrument" 40 ) 41 42 type httpWriter struct { 43 written []string 44 status int 45 } 46 47 func (w *httpWriter) Header() http.Header { 48 return make(http.Header) 49 } 50 51 func (w *httpWriter) WriteHeader(statusCode int) { 52 w.status = statusCode 53 } 54 55 func (w *httpWriter) Write(b []byte) (int, error) { 56 w.written = append(w.written, string(b)) 57 return len(b), nil 58 } 59 60 type panicHandler struct{} 61 62 func (h panicHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) { 63 panic("beef") 64 } 65 66 type cleanupFn func() 67 68 func setup(t *testing.T, ctxLogger bool) ( 69 *os.File, 70 *os.File, 71 *http.Request, 72 instrument.Options, 73 cleanupFn, 74 ) { 75 success := false 76 77 stdout, err := ioutil.TempFile("", "temp-log-file-out") 78 require.NoError(t, err, "Couldn't create a temporary out file for test.") 79 defer func() { 80 if success { 81 return 82 } 83 _ = os.Remove(stdout.Name()) 84 }() 85 86 stderr, err := ioutil.TempFile("", "temp-log-file-err") 87 require.NoError(t, err, "Couldn't create a temporary err file for test.") 88 defer func() { 89 if success { 90 return 91 } 92 _ = os.Remove(stderr.Name()) 93 }() 94 95 cfg := zap.NewDevelopmentConfig() 96 cfg.OutputPaths = []string{stdout.Name()} 97 cfg.ErrorOutputPaths = []string{stderr.Name()} 98 99 templogger, err := cfg.Build() 100 require.NoError(t, err) 101 102 instrumentOpts := instrument.NewOptions(). 103 SetLogger(templogger) 104 105 req, err := http.NewRequest("GET", "cool", nil) 106 require.NoError(t, err) 107 108 if ctxLogger { 109 // Only stacktrace `error` priority and higher 110 highPriority := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool { 111 return lvl >= zapcore.ErrorLevel 112 }) 113 114 templogger = templogger.WithOptions(zap.AddStacktrace(highPriority)).With(zap.String("foo", "bar")) 115 req = req.WithContext(logging.NewContextWithLogger(context.TODO(), templogger)) 116 } 117 118 // Success to true to avoid removing log files 119 success = true 120 cleanup := func() { 121 _ = os.Remove(stdout.Name()) 122 _ = os.Remove(stderr.Name()) 123 } 124 return stdout, stderr, req, instrumentOpts, cleanup 125 } 126 127 func TestPanicErrorResponder(t *testing.T) { 128 stdout, stderr, req, instrumentOpts, cleanup := setup(t, true) 129 defer cleanup() 130 131 writer := &httpWriter{written: make([]string, 0, 10)} 132 deadbeef := Panic(instrumentOpts).Middleware(panicHandler{}) 133 deadbeef.ServeHTTP(writer, req) 134 135 assert.Equal(t, 500, writer.status) 136 require.Equal(t, 1, len(writer.written)) 137 assert.JSONEq(t, `{"status":"error","error":"caught panic: beef"}`, writer.written[0]) 138 139 assertNoErrorLogs(t, stderr) 140 b, err := ioutil.ReadAll(stdout) 141 require.NoError(t, err) 142 outstrs := strings.Split(string(b), "\n") 143 144 assert.True(t, strings.Contains(outstrs[0], "ERROR")) 145 assert.True(t, strings.Contains(outstrs[0], "panic captured")) 146 assert.True(t, strings.Contains(outstrs[0], `"stack": "beef"`)) 147 assert.True(t, strings.Contains(outstrs[0], `"foo": "bar"`)) 148 149 // Assert that stack trace is written 150 count := 0 151 for _, s := range outstrs { 152 if strings.Contains(s, "runtime.gopanic") { 153 count++ 154 } 155 } 156 157 assert.True(t, count > 0) 158 } 159 160 func assertNoErrorLogs(t *testing.T, stderr *os.File) { 161 b, err := ioutil.ReadAll(stderr) 162 require.NoError(t, err) 163 assert.Equal(t, 0, len(b)) 164 } 165 166 func assertPanicLogsWritten(t *testing.T, stdout, stderr *os.File) { 167 assertNoErrorLogs(t, stderr) 168 b, err := ioutil.ReadAll(stdout) 169 require.NoError(t, err) 170 outstrs := strings.Split(string(b), "\n") 171 172 assert.True(t, strings.Contains(outstrs[0], "ERROR")) 173 assert.True(t, strings.Contains(outstrs[0], "panic captured")) 174 assert.True(t, strings.Contains(outstrs[0], `"stack": "err"`)) 175 assert.True(t, strings.Contains(outstrs[0], `"foo": "bar"`)) 176 177 // Assert that stack trace is written 178 count := 0 179 for _, s := range outstrs { 180 if strings.Contains(s, "runtime.gopanic") { 181 count++ 182 } 183 } 184 assert.True(t, count > 0) 185 186 count = 0 187 for _, s := range outstrs { 188 if strings.Contains(s, "cannot write error for request; already written\t{\"foo\": \"bar\"}") { 189 count++ 190 } 191 } 192 assert.True(t, count > 0) 193 } 194 195 func TestPanicErrorResponderOnlyIfNotWrittenRequest(t *testing.T) { 196 stdout, stderr, req, instrumentOpts, cleanup := setup(t, true) 197 defer cleanup() 198 199 writer := &httpWriter{written: make([]string, 0, 10)} 200 deadbeef := Panic(instrumentOpts).Middleware( 201 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 202 _, _ = w.Write([]byte("foo")) 203 panic("err") 204 })) 205 deadbeef.ServeHTTP(writer, req) 206 207 assert.Equal(t, 0, writer.status) 208 require.Equal(t, 1, len(writer.written)) 209 assert.Equal(t, "foo", writer.written[0]) 210 211 // Verify that panic capture is still logged. 212 assertPanicLogsWritten(t, stdout, stderr) 213 } 214 215 func TestPanicErrorResponderOnlyIfNotWrittenHeader(t *testing.T) { 216 stdout, stderr, req, instrumentOpts, cleanup := setup(t, true) 217 defer cleanup() 218 219 writer := &httpWriter{written: make([]string, 0, 10)} 220 deadbeef := Panic(instrumentOpts).Middleware( 221 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 222 w.WriteHeader(404) 223 panic("err") 224 })) 225 deadbeef.ServeHTTP(writer, req) 226 227 assert.Equal(t, 404, writer.status) 228 require.Equal(t, 0, len(writer.written)) 229 230 // Verify that panic capture is still logged. 231 assertPanicLogsWritten(t, stdout, stderr) 232 } 233 234 type delayHandler struct { 235 delay time.Duration 236 } 237 238 func (h delayHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) { 239 time.Sleep(h.delay) 240 } 241 242 func TestWithResponseTimeLogging(t *testing.T) { 243 stdout, stderr, req, instrumentOpts, cleanup := setup(t, false) 244 defer cleanup() 245 246 m := ResponseLogging(Options{ 247 Clock: clockwork.NewRealClock(), 248 InstrumentOpts: instrumentOpts, 249 Logging: LoggingOptions{ 250 Threshold: time.Second, 251 }, 252 }) 253 slowHandler := m.Middleware(&delayHandler{delay: time.Second}) 254 fastHandler := m.Middleware(&delayHandler{delay: time.Duration(0)}) 255 256 writer := &httpWriter{written: make([]string, 0, 10)} 257 258 slowHandler.ServeHTTP(writer, req) 259 fastHandler.ServeHTTP(writer, req) 260 261 require.Equal(t, 0, len(writer.written)) 262 assertNoErrorLogs(t, stderr) 263 264 b, err := ioutil.ReadAll(stdout) 265 require.NoError(t, err) 266 outstrs := strings.Split(string(b), "\n") 267 268 require.Equal(t, 2, len(outstrs)) 269 out := outstrs[0] 270 271 assert.True(t, strings.Contains(out, "INFO")) 272 assert.True(t, strings.Contains(out, "finished handling request")) 273 assert.True(t, strings.Contains(out, `"url": "cool"`)) 274 assert.True(t, strings.Contains(out, `duration": "1.`)) 275 } 276 277 func TestWithResponseTimeAndPanicErrorLoggingFunc(t *testing.T) { 278 stdout, stderr, req, instrumentOpts, cleanup := setup(t, true) 279 defer cleanup() 280 281 writer := &httpWriter{written: make([]string, 0, 10)} 282 slowPanic := ResponseLogging(Options{ 283 Clock: clockwork.NewRealClock(), 284 InstrumentOpts: instrumentOpts, 285 Logging: LoggingOptions{ 286 Threshold: time.Second, 287 }, 288 }).Middleware( 289 Panic(instrumentOpts).Middleware( 290 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 291 time.Sleep(time.Second) 292 panic("err") 293 }))) 294 295 slowPanic.ServeHTTP(writer, req) 296 297 assert.Equal(t, 500, writer.status) 298 require.Equal(t, 1, len(writer.written)) 299 assert.JSONEq(t, `{"status":"error","error":"caught panic: err"}`, writer.written[0]) 300 301 assertNoErrorLogs(t, stderr) 302 303 b, err := ioutil.ReadAll(stdout) 304 require.NoError(t, err) 305 outstrs := strings.Split(string(b), "\n") 306 assert.True(t, strings.Contains(outstrs[0], "ERROR")) 307 assert.True(t, strings.Contains(outstrs[0], "panic captured")) 308 assert.True(t, strings.Contains(outstrs[0], `"stack": "err"`)) 309 assert.True(t, strings.Contains(outstrs[0], `"foo": "bar"`)) 310 311 // Assert that stack trace is written 312 count := 0 313 for _, s := range outstrs { 314 if strings.Contains(s, "runtime.gopanic") { 315 count++ 316 } 317 } 318 319 assert.True(t, count > 0) 320 321 // assert that the second last line of the log captures response time. 322 last := outstrs[len(outstrs)-2] 323 324 assert.True(t, strings.Contains(last, "INFO")) 325 assert.True(t, strings.Contains(last, "finished handling request")) 326 assert.True(t, strings.Contains(last, `"url": "cool"`)) 327 assert.True(t, strings.Contains(last, `duration": "1.`)) 328 } 329 330 func TestFields_Append(t *testing.T) { 331 cases := []struct { 332 name string 333 fields []zap.Field 334 other []zap.Field 335 expected []zap.Field 336 }{ 337 { 338 name: "both", 339 fields: []zap.Field{ 340 zap.String("foo", "foos"), 341 zap.String("bar", "bars"), 342 }, 343 other: []zap.Field{ 344 zap.String("foo2", "foos2"), 345 zap.String("bar2", "bars2"), 346 }, 347 expected: []zap.Field{ 348 zap.String("foo", "foos"), 349 zap.String("bar", "bars"), 350 zap.String("foo2", "foos2"), 351 zap.String("bar2", "bars2"), 352 }, 353 }, 354 { 355 name: "both nil", 356 expected: nil, 357 }, 358 { 359 name: "fields nil", 360 other: []zap.Field{ 361 zap.String("foo2", "foos2"), 362 zap.String("bar2", "bars2"), 363 }, 364 expected: []zap.Field{ 365 zap.String("foo2", "foos2"), 366 zap.String("bar2", "bars2"), 367 }, 368 }, 369 { 370 name: "other nil", 371 fields: []zap.Field{ 372 zap.String("foo", "foos"), 373 zap.String("bar", "bars"), 374 }, 375 expected: []zap.Field{ 376 zap.String("foo", "foos"), 377 zap.String("bar", "bars"), 378 }, 379 }, 380 } 381 382 for _, tc := range cases { 383 tc := tc 384 t.Run(tc.name, func(t *testing.T) { 385 var ( 386 f Fields 387 o Fields 388 ) 389 req, err := http.NewRequest("GET", "/", nil) 390 startTime := time.Now() 391 require.NoError(t, err) 392 if tc.fields != nil { 393 f = func(r *http.Request, start time.Time) []zap.Field { 394 require.Equal(t, req, r) 395 require.Equal(t, startTime, start) 396 return tc.fields 397 } 398 } 399 if tc.other != nil { 400 o = func(r *http.Request, start time.Time) []zap.Field { 401 require.Equal(t, req, r) 402 require.Equal(t, startTime, start) 403 return tc.other 404 } 405 } 406 actual := f.Append(o)(req, startTime) 407 require.Equal(t, tc.expected, actual) 408 }) 409 } 410 }