요청 로깅

`requestLogger(…​)`는 람다 하나를 받아 완료된 요청마다 한 번씩 호출한다:

app.requestLogger((req, completion) -> logger.info("{} {} -> {} ({}ms)",
        req.method(), req.path(), completion.statusCode(), completion.took().toMillis()));

코어는 로깅 프레임워크를 담지 않으므로, 어떤 로거를, 어떤 레벨로, 어떤 형식으로 쓸지는 애플리케이션의 선택이다. 람다는 응답 쓰기가 끝난 뒤 `RequestCompletion`을 받는다. `statusCode()`는 서블릿 응답이 보고한 최종 상태이며, raw 라이터가 바꾼 상태나 쓰기 실패에서 복구하며 바뀐 상태도 포함한다. `took()`은 디스패치, 렌더링, 데코레이션, 쓰기를 아우르는 `Duration`이다. `response()`는 데코레이션 이후의 응답 정의이며, 데코레이션이 실패했으면 마지막으로 확보된 정의다. 그 상태와 헤더는 raw 라이터가 보낸 것과 다를 수 있다.

`failure()`는 데코레이션이나 쓰기에서 난 예외이며, 둘 다 실패하지 않았으면 null이다. `failed()`는 그 예외가 있는지 보고한다. 커밋 전의 실패는 500을 낳을 수 있고, 커밋 후의 실패는 본문이 잘린 200을 남길 수 있다. 실패는 상태 코드와 별개로 검사한다. 디스패치 중에 처리된 애플리케이션 예외는 전송 실패가 아니라 그 응답 상태로 표현된다. SSE의 정상 종료는 전송 실패가 아니다. 완료는 원격 클라이언트가 본문 전체를 받았음을 확인하지 않는다.

exception()`은 핸들러, 필터, 템플릿이 던진 것이며, 예외 핸들러가 답했든 프레임워크의 500이 답했든 마찬가지다. `threw()`는 그 예외가 있는지 보고한다. 아무것도 던지지 않았을 때, 그리고 실패가 아니라 핸들러가 선택한 상태인 `HttpException`일 때 null이다. `statusCode()`와 함께 읽는다: 400에 매핑된 예외는 호출자의 실수이고, 500으로 답한 예외는 애플리케이션의 실수다. 트레이싱 스팬이나 에러 목록에 실패를 기록하는 로거는 여기서 이를 읽으며, 요청에도 답해야 하는 포괄적인 `exception(…​) 핸들러를 등록하는 대신이다:

app.requestLogger((req, completion) -> {
    if (completion.threw() && completion.statusCode() >= 500) {
        Span.current().recordException(completion.exception());
    }
});

req.route()`는 답한 라우트가 있을 때 그 라우트이므로, 로그 한 줄이나 메트릭이 요청을 id마다가 아니라 `/decks/{deckId} 단위로 묶는다. 정적 파일, 404, 405, 자동 OPTIONS 답에서는 null이다. 라우트 인트로스펙션에 규칙이 있다. `req.body()`는 필터나 핸들러가 이미 읽은 텍스트를 읽는데, 텍스트는 요청 전체 동안 보관되기 때문이다. 예외를 던지는 로거는 서블릿 로그에 보고되며 응답은 건드리지 않는다.