Repository: zalando/logbook (java)

Issue:
Provide a way to disable logging of requests/responses on certain urls
Two approaches suggested:
1. Provide internal url mapping inside LogbookFilter to activate/deactivate logging only on certain URLs
2. Reuse underlying logging library functionality by appending request URL path to logger name (so then it is possible to disable logging like this: 
`<Logger name="org.zalando.logbook.Logbook=/some/resource/path" level="off"/>`)

Pull request description:
Conditional logging
This fixes #45 

Patch:
diff --git a/README.md b/README.md
index 1796b52e..4300045f 100644
--- a/README.md
+++ b/README.md
@@ -7,12 +7,31 @@
 [![Release](https://img.shields.io/github/release/zalando/logbook.svg)](https://github.com/zalando/logbook/releases)
 [![Maven Central](https://img.shields.io/maven-central/v/org.zalando/logbook-parent.svg)](https://maven-badges.herokuapp.com/maven-central/org.zalando/logbook-parent)
 
-*Logbook* is an extensible library to enable request and response logging for different client- and server-side 
-technologies. It comes with a core module `logbook-core` and specific modules per framework, e.g. 
-[`logbook-servlet`](#servlet) for Servlet 3.0 environments and [`logbook-httpclient`](#http-client) for applications 
-using Apache's `HttpClient`.
+*Logbook* is an extensible library to enable complete request and response logging for different client- and server-side 
+technologies. It satisfies a special need by allowing web application developers to log, persist and analyze any HTTP traffic that an application receives or sends. This can be used for traditional log analysis, for audit requirements or for tracing individual historic traffic issues. *Logbook* is meant to be ready to use out of the box for most common setups, but even for uncommon applications and technologies it should be very easy to implement the necessary interfaces to connect some library/framework/etc. to Logbook.
 
-## Dependency
+## Features
+
+- logging of HTTP requests and responses **including the body**
+- customizable conditions which requests to log
+- optional obfuscation of sensitive data
+- customizable logging format
+- customizable logging destination
+- support for Servlet containers
+- partial logging (no body) for unauthorized requests
+- support for Apache's HTTP Client
+- Spring Boot Auto Configuration
+- sensible defaults
+
+## Dependencies
+
+- Java 8
+- Any build tool using Maven Central, or direct download
+- Servlet Container (optional)
+- Apache HTTP Client (optional)
+- Spring Boot (optional)
+
+## Installation
 
 ```xml
 <dependency>
@@ -20,7 +39,23 @@ using Apache's `HttpClient`.
     <artifactId>logbook</artifactId>
     <version>${logbook.version}</version>
 </dependency>
+<dependency>
+    <groupId>org.zalando</groupId>
+    <artifactId>logbook-servlet</artifactId>
+    <version>${logbook.version}</version>
+</dependency>
+<dependency>
+    <groupId>org.zalando</groupId>
+    <artifactId>logbook-httpclient</artifactId>
+    <version>${logbook.version}</version>
+</dependency>
+<dependency>
+    <groupId>org.zalando</groupId>
+    <artifactId>logbook-spring-boot-starter</artifactId>
+    <version>${logbook.version}</version>
+</dependency>
 ```
+
 ## Usage
 
 All integrations require an instance of `Logbook` which holds all configuration and wires all necessary parts together. 
@@ -36,19 +71,39 @@ or create a customized version using the `LogbookBuilder`:
 Logbook logbook = Logbook.builder()
     .formatter(new CustomHttpLogFormatter())
     .writer(new CustomHttpLogWriter())
+    .predicate(new CustomRequestPredicate())
+    .headerObfuscator(new CustomHeaderObfuscator())
+    .parameterObfuscator(new CustomParameterObfuscator())
+    .bodyObfuscator(new CustomBodyObfuscator()
     .build();
 ```
 
-Logbook works in three phases:
+### Phases
+
+Logbook works in several different phases:
 
-1. [Obfuscation](#obfuscation),
-2. [Formatting](#formatting) and
-3. [Writing](#writing)
+1. [Conditional](#conditional),
+2. [Obfuscation](#obfuscation),
+3. [Formatting](#formatting) and
+4. [Writing](#writing)
 
 Each phase is represented by one or more interfaces that can be used for customization and every phase has a sensible
 default:
 
-## Obfuscation
+### Conditional
+
+Since logging HTTP messages including their body is a rather expensive task it makes a lot of sense to disable it for certain requests. A common use case would be to ignore *health check* requests from a load balancer or any request to management endpoints that are usually made by developers.
+
+Defining a condition is as easy as writing a special `Predicate` that decides whether a request (and its corresponding response) should be logged or not:
+
+```java
+Logbook logbook = Logbook.builder()
+    // will not log requests to /health
+    .predicate(request -> !request.getRequestUri().equals("/health"))
+    .build();
+```
+
+### Obfuscation
 
 The goal of *Obfuscation* is to prevent certain sensitive parts of HTTP requests and responses to be logged. This
 usually includes the *Authorization* header but could also apply to certain plaintext query or form parameters,
@@ -84,24 +139,24 @@ Logbook logbook = Logbook.builder()
     .build();
 ```
 
-## Correlation
+### Correlation
 
 Requests and responses are correlated using a *correlation id*. This allows to match related requests and responses that would usually be located in different places in the log file.
 
-## Formatting
+### Formatting
 
 Formatting defines how requests and responses will be transformed to strings basically. Formatters do **not** specify
 where requests and responses are logged to, that's the work of writers.
 
 Logbook comes with two different formatters by default - *HTTP* and *JSON*:
 
-### HTTP
+#### HTTP
 
 *HTTP* is the default formatting style, is provided by the `DefaultHttpLogFormatter` and is primarily designed to be
 used for local development and debugging. Since it's harder to read by machines this is usually not meant to be used
 in production.
 
-#### Request
+##### Request
 
 ```http
 Incoming Request: 2d66e4bc-9a0d-11e5-a84c-1f39510f0d6b
@@ -113,7 +168,7 @@ Content-Type: text/plain
 Hello world!
 ```
 
-#### Response
+##### Response
 
 ```http
 Outgoing Response: 2d66e4bc-9a0d-11e5-a84c-1f39510f0d6b
@@ -123,12 +178,12 @@ Content-Type: application/json
 {"value":"Hello world!"}
 ```
 
-### JSON
+#### JSON
 
 *JSON* is an alternative formatting style, is provided by the `JsonHttpLogFormatter` and is primarily designed to be
 used for production since it's easily consumed by parsers and log consumers.
 
-#### Request
+##### Request
 
 ```json
 {
@@ -149,7 +204,7 @@ used for production since it's easily consumed by parsers and log consumers.
 }
 ```
 
-#### Response
+##### Response
 
 ```json
 {
@@ -182,11 +237,11 @@ a JSON response body will **not** be escaped and represented as a string:
 }
 ```
 
-## Writing
+### Writing
 
 Writing defines where formatted requests and responses are written to. Logback comes with two implementations:
 
-### Logger
+#### Logger
 
 By default requests and responses are logged using a *slf4j* logger that uses the `org.zalando.logbook.Logbook` 
 category and the log level `trace`. This can be customized though:
@@ -199,7 +254,7 @@ Logbook logbook = Logbook.builder()
     .build();
 ```
 
-### Stream
+#### Stream
 
 An alternative implementation is logging requests and responses to a `PrintStream`, e.g. `System.out` or `System.err`. 
 This is usually a bad choice for running in production, but might be used for short-term local development and/or 
@@ -211,17 +266,7 @@ Logbook logbook = Logbook.builder()
     .build();
 ```
 
-## Servlet
-
-### Dependency
-
-```xml
-<dependency>
-    <groupId>org.zalando</groupId>
-    <artifactId>logbook-servlet</artifactId>
-    <version>${logbook.version}</version>
-</dependency>
-```
+### Servlet
 
 You have to register the `LogbookFilter` as a `Filter` in your filter chain.
 
@@ -249,7 +294,7 @@ context.addFilter("LogbookFilter", new LogbookFilter(logbook))
     .addMappingForUrlPatterns(EnumSet.of(REQUEST, ASYNC, ERROR), true, "/*"); 
 ```
 
-### Security
+#### Security
 
 Secure application usually a slightly different setup due to the reason that you should generally avoid logging
 unauthorized requests, especially the body, as it allows attackers to flood your logfile, and therefore your precious
@@ -275,17 +320,7 @@ context.addFilter("authorizedLogbookFilter", new LogbookFilter(logbook))
 The first logbook filter will log unauthorized requests only while the second one will log authorized requests as
 always.
 
-## HTTP Client
-
-### Dependency
-
-```xml
-<dependency>
-    <groupId>org.zalando</groupId>
-    <artifactId>logbook-httpclient</artifactId>
-    <version>${logbook.version}</version>
-</dependency>
-```
+### HTTP Client
 
 The `logbook-httpclient` module contains both a `HttpRequestInterceptor` as well as a `HttpResponseInterceptor` to
 be used with the `HttpClient`:
@@ -297,26 +332,16 @@ CloseableHttpClient client = HttpClientBuilder.create()
         .build();
 ```
 
-## Spring Boot Starter
-
-Logbook comes with a convenient auto configuration for Spring Boot users:
+### Spring Boot Starter
 
-```xml
-<dependency>
-    <groupId>org.zalando</groupId>
-    <artifactId>logbook-spring-boot-starter</artifactId>
-    <version>${logbook.version}</version>
-</dependency>
-```
+Logbook comes with a convenient auto configuration for Spring Boot users. It sets up all of the following parts automatically with sensible defaults:
 
-It sets up all of the following parts automatically with sensible defaults:
 - Servlet Filter
 - 2nd Servlet Filter for unauthorized requests (if Spring Security is detected)
 - Header-/Parameter-/Body-Obfuscators
 - HTTP-/JSON-style formatter
 - Logging writer
 
-
 | Type                     | Name                        | Default                                    |
 |--------------------------|-----------------------------|--------------------------------------------|
 | `FilterRegistrationBean` | `unauthorizedLogbookFilter` | Based on `LogbookFilter`                   |
@@ -328,7 +353,7 @@ It sets up all of the following parts automatically with sensible defaults:
 | `HttpLogFormatter`       |                             | `JsonHttpLogFormatter`                     |
 | `HttpLogWriter`          |                             | `DefaultHttpLogWriter`                     |
 
-### Configuration
+#### Configuration
 
 The following tables shows the available configuration:
 
@@ -356,25 +381,27 @@ logbook:
         level: INFO
 ```
 
-## Attributions
+## How to test the software
 
-![Creative Commons (Attribution-Share Alike 3.0 Unported](https://licensebuttons.net/l/by-sa/3.0/80x15.png)
-[*Grand Turk, a replica of a three-masted 6th rate frigate from Nelson's days - logbook and charts*](https://commons.wikimedia.org/wiki/File:Grand_Turk(34).jpg)
-by [JoJan](https://commons.wikimedia.org/wiki/User:JoJan) is licensed under a
-[Creative Commons (Attribution-Share Alike 3.0 Unported)](http://creativecommons.org/licenses/by-sa/3.0/).
+```
+mvn clean verify
+```
+
+## Getting help
+
+If you have questions, concerns, bug reports, etc, please file an issue in this repository's Issue Tracker.
+
+## Getting involved
 
-## License
+To contribute, simply make a pull request and add a brief description (1-2 sentences) of your addition or change.
 
-Copyright [2015] Zalando SE
+## Open source licensing info
 
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
+[Apache 2.0](LICENSE)
 
-    http://www.apache.org/licenses/LICENSE-2.0
+## Credits and references
 
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
+![Creative Commons (Attribution-Share Alike 3.0 Unported](https://licensebuttons.net/l/by-sa/3.0/80x15.png)
+[*Grand Turk, a replica of a three-masted 6th rate frigate from Nelson's days - logbook and charts*](https://commons.wikimedia.org/wiki/File:Grand_Turk(34).jpg)
+by [JoJan](https://commons.wikimedia.org/wiki/User:JoJan) is licensed under a
+[Creative Commons (Attribution-Share Alike 3.0 Unported)](http://creativecommons.org/licenses/by-sa/3.0/).
diff --git a/logbook-core/src/main/java/org/zalando/logbook/BaseHttpMessage.java b/logbook-core/src/main/java/org/zalando/logbook/BaseHttpMessage.java
new file mode 100644
index 00000000..6c92912f
--- /dev/null
+++ b/logbook-core/src/main/java/org/zalando/logbook/BaseHttpMessage.java
@@ -0,0 +1,37 @@
+package org.zalando.logbook;
+
+/*
+ * #%L
+ * Logbook: Core
+ * %%
+ * Copyright (C) 2015 - 2016 Zalando SE
+ * %%
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * #L%
+ */
+
+import com.google.common.collect.Multimap;
+
+import java.nio.charset.Charset;
+
+public interface BaseHttpMessage {
+
+    Multimap<String, String> getHeaders();
+
+    String getContentType();
+
+    Charset getCharset();
+    
+    Origin getOrigin();
+
+}
diff --git a/logbook-core/src/main/java/org/zalando/logbook/BaseHttpRequest.java b/logbook-core/src/main/java/org/zalando/logbook/BaseHttpRequest.java
index d5483b2b..566198e5 100644
--- a/logbook-core/src/main/java/org/zalando/logbook/BaseHttpRequest.java
+++ b/logbook-core/src/main/java/org/zalando/logbook/BaseHttpRequest.java
@@ -20,7 +20,7 @@ package org.zalando.logbook;
  * #L%
  */
 
-public interface BaseHttpRequest {
+public interface BaseHttpRequest extends BaseHttpMessage {
 
     String getRemote();
 
diff --git a/logbook-core/src/main/java/org/zalando/logbook/BaseHttpResponse.java b/logbook-core/src/main/java/org/zalando/logbook/BaseHttpResponse.java
index af5e373b..9abac917 100644
--- a/logbook-core/src/main/java/org/zalando/logbook/BaseHttpResponse.java
+++ b/logbook-core/src/main/java/org/zalando/logbook/BaseHttpResponse.java
@@ -20,7 +20,7 @@ package org.zalando.logbook;
  * #L%
  */
 
-public interface BaseHttpResponse {
+public interface BaseHttpResponse extends BaseHttpMessage {
 
     int getStatus();
 
diff --git a/logbook-core/src/main/java/org/zalando/logbook/DefaultLogbook.java b/logbook-core/src/main/java/org/zalando/logbook/DefaultLogbook.java
index 8ae0ca5f..39cf52d9 100644
--- a/logbook-core/src/main/java/org/zalando/logbook/DefaultLogbook.java
+++ b/logbook-core/src/main/java/org/zalando/logbook/DefaultLogbook.java
@@ -25,22 +25,26 @@ import com.google.common.annotations.VisibleForTesting;
 import java.io.IOException;
 import java.util.Optional;
 import java.util.UUID;
+import java.util.function.Predicate;
 
 final class DefaultLogbook implements Logbook {
 
     private final HttpLogFormatter formatter;
     private final HttpLogWriter writer;
+    private final Predicate<RawHttpRequest> predicate;
     private final Obfuscation obfuscation;
 
-    DefaultLogbook(final HttpLogFormatter formatter, final HttpLogWriter writer, final Obfuscation obfuscation) {
+    DefaultLogbook(final HttpLogFormatter formatter, final HttpLogWriter writer, 
+            final Predicate<RawHttpRequest> predicate, final Obfuscation obfuscation) {
         this.formatter = formatter;
         this.writer = writer;
+        this.predicate = predicate;
         this.obfuscation = obfuscation;
     }
 
     @Override
     public Optional<Correlator> write(final RawHttpRequest rawHttpRequest) throws IOException {
-        if (writer.isActive(rawHttpRequest)) {
+        if (writer.isActive(rawHttpRequest) && predicate.test(rawHttpRequest)) {
             final String correlationId = UUID.randomUUID().toString();
             final HttpRequest request = obfuscation.obfuscate(rawHttpRequest.withBody());
 
diff --git a/logbook-core/src/main/java/org/zalando/logbook/ForwardingRawHttpRequest.java b/logbook-core/src/main/java/org/zalando/logbook/ForwardingRawHttpRequest.java
index a1857cc8..2263dd24 100644
--- a/logbook-core/src/main/java/org/zalando/logbook/ForwardingRawHttpRequest.java
+++ b/logbook-core/src/main/java/org/zalando/logbook/ForwardingRawHttpRequest.java
@@ -21,8 +21,10 @@ package org.zalando.logbook;
  */
 
 import com.google.common.collect.ForwardingObject;
+import com.google.common.collect.Multimap;
 
 import java.io.IOException;
+import java.nio.charset.Charset;
 
 public abstract class ForwardingRawHttpRequest extends ForwardingObject implements RawHttpRequest {
 
@@ -30,8 +32,23 @@ public abstract class ForwardingRawHttpRequest extends ForwardingObject implemen
     protected abstract RawHttpRequest delegate();
 
     @Override
-    public HttpRequest withBody() throws IOException {
-        return delegate().withBody();
+    public Multimap<String, String> getHeaders() {
+        return delegate().getHeaders();
+    }
+
+    @Override
+    public String getContentType() {
+        return delegate().getContentType();
+    }
+
+    @Override
+    public Charset getCharset() {
+        return delegate().getCharset();
+    }
+
+    @Override
+    public Origin getOrigin() {
+        return delegate().getOrigin();
     }
 
     @Override
@@ -49,4 +66,9 @@ public abstract class ForwardingRawHttpRequest extends ForwardingObject implemen
         return delegate().getRequestUri();
     }
 
+    @Override
+    public HttpRequest withBody() throws IOException {
+        return delegate().withBody();
+    }
+
 }
diff --git a/logbook-core/src/main/java/org/zalando/logbook/HttpMessage.java b/logbook-core/src/main/java/org/zalando/logbook/HttpMessage.java
index 8f4e003a..5c38c91d 100644
--- a/logbook-core/src/main/java/org/zalando/logbook/HttpMessage.java
+++ b/logbook-core/src/main/java/org/zalando/logbook/HttpMessage.java
@@ -25,15 +25,7 @@ import com.google.common.collect.Multimap;
 import java.io.IOException;
 import java.nio.charset.Charset;
 
-public interface HttpMessage {
-
-    Multimap<String, String> getHeaders();
-
-    String getContentType();
-
-    Charset getCharset();
-    
-    Origin getOrigin();
+public interface HttpMessage extends BaseHttpMessage {
 
     byte[] getBody() throws IOException;
 
diff --git a/logbook-core/src/main/java/org/zalando/logbook/Logbook.java b/logbook-core/src/main/java/org/zalando/logbook/Logbook.java
index 8f2753c9..ec27c489 100644
--- a/logbook-core/src/main/java/org/zalando/logbook/Logbook.java
+++ b/logbook-core/src/main/java/org/zalando/logbook/Logbook.java
@@ -23,6 +23,7 @@ package org.zalando.logbook;
 import javax.annotation.Nullable;
 import java.io.IOException;
 import java.util.Optional;
+import java.util.function.Predicate;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 
@@ -37,6 +38,7 @@ public interface Logbook {
     @lombok.Builder(builderClassName = "Builder")
     static Logbook create(@Nullable final HttpLogFormatter formatter,
             @Nullable final HttpLogWriter writer,
+            @Nullable final Predicate<RawHttpRequest> predicate,
             @Nullable final Obfuscator headerObfuscator,
             @Nullable final Obfuscator parameterObfuscator,
             @Nullable final BodyObfuscator bodyObfuscator) {
@@ -44,11 +46,11 @@ public interface Logbook {
         return new DefaultLogbook(
                 firstNonNull(formatter, new DefaultHttpLogFormatter()),
                 firstNonNull(writer, new DefaultHttpLogWriter()),
+                firstNonNull(predicate, request -> true),
                 new Obfuscation(
                         firstNonNull(headerObfuscator, Obfuscator.none()),
                         firstNonNull(parameterObfuscator, Obfuscator.none()),
                         firstNonNull(bodyObfuscator, BodyObfuscator.none())));
     }
 
-
 }
diff --git a/logbook-core/src/main/java/org/zalando/logbook/RawHttpResponse.java b/logbook-core/src/main/java/org/zalando/logbook/RawHttpResponse.java
index 12f67eaf..650e94f6 100644
--- a/logbook-core/src/main/java/org/zalando/logbook/RawHttpResponse.java
+++ b/logbook-core/src/main/java/org/zalando/logbook/RawHttpResponse.java
@@ -22,7 +22,7 @@ package org.zalando.logbook;
 
 import java.io.IOException;
 
-public interface RawHttpResponse {
+public interface RawHttpResponse extends BaseHttpResponse {
 
     HttpResponse withBody() throws IOException;
 


Test patch:
diff --git a/logbook-core/src/test/java/org/zalando/logbook/DefaultHttpLogFormatterTest.java b/logbook-core/src/test/java/org/zalando/logbook/DefaultHttpLogFormatterTest.java
index 9fda5e54..e0fc6fb2 100644
--- a/logbook-core/src/test/java/org/zalando/logbook/DefaultHttpLogFormatterTest.java
+++ b/logbook-core/src/test/java/org/zalando/logbook/DefaultHttpLogFormatterTest.java
@@ -20,6 +20,7 @@ package org.zalando.logbook;
  * #L%
  */
 
+import com.google.common.collect.ImmutableMultimap;
 import org.junit.Test;
 import org.zalando.logbook.DefaultLogbook.SimpleCorrelation;
 import org.zalando.logbook.DefaultLogbook.SimplePrecorrelation;
@@ -39,8 +40,9 @@ public final class DefaultHttpLogFormatterTest {
         final HttpRequest request = MockHttpRequest.builder()
                 .origin(Origin.REMOTE)
                 .requestUri("/test?limit=1")
-                .header("Accept", "application/json")
-                .header("Content-Type", "text/plain")
+                .headers(ImmutableMultimap.of(
+                        "Accept", "application/json",
+                        "Content-Type", "text/plain"))
                 .body("Hello, world!")
                 .build();
 
@@ -60,8 +62,9 @@ public final class DefaultHttpLogFormatterTest {
         final HttpRequest request = MockHttpRequest.builder()
                 .origin(Origin.LOCAL)
                 .requestUri("/test")
-                .header("Accept", "application/json")
-                .header("Content-Type", "text/plain")
+                .headers(ImmutableMultimap.of(
+                        "Accept", "application/json",
+                        "Content-Type", "text/plain"))
                 .body("Hello, world!")
                 .build();
 
@@ -80,7 +83,7 @@ public final class DefaultHttpLogFormatterTest {
         final String correlationId = "0eae9f6c-6824-11e5-8b0a-10ddb1ee7671";
         final HttpRequest request = MockHttpRequest.builder()
                 .requestUri("/test")
-                .header("Accept", "application/json")
+                .headers(ImmutableMultimap.of("Accept", "application/json"))
                 .build();
 
         final String http = unit.format(new SimplePrecorrelation<>(correlationId, request));
@@ -96,7 +99,7 @@ public final class DefaultHttpLogFormatterTest {
         final HttpRequest request = MockHttpRequest.create();
         final HttpResponse response = MockHttpResponse.builder()
                 .origin(Origin.REMOTE)
-                .header("Content-Type", "application/json")
+                .headers(ImmutableMultimap.of("Content-Type", "application/json"))
                 .body("{\"success\":true}")
                 .build();
 
@@ -115,7 +118,7 @@ public final class DefaultHttpLogFormatterTest {
         final HttpRequest request = MockHttpRequest.create();
         final HttpResponse response = MockHttpResponse.builder()
                 .origin(Origin.LOCAL)
-                .header("Content-Type", "application/json")
+                .headers(ImmutableMultimap.of("Content-Type", "application/json"))
                 .build();
 
         final String http = unit.format(new SimpleCorrelation<>(correlationId, request, response));
diff --git a/logbook-core/src/test/java/org/zalando/logbook/DefaultLogbookTest.java b/logbook-core/src/test/java/org/zalando/logbook/DefaultLogbookTest.java
index 052969b2..6f424cdf 100644
--- a/logbook-core/src/test/java/org/zalando/logbook/DefaultLogbookTest.java
+++ b/logbook-core/src/test/java/org/zalando/logbook/DefaultLogbookTest.java
@@ -26,6 +26,7 @@ import org.mockito.ArgumentCaptor;
 
 import java.io.IOException;
 import java.util.Optional;
+import java.util.function.Predicate;
 
 import static org.hamcrest.Matchers.instanceOf;
 import static org.hamcrest.Matchers.is;
@@ -41,6 +42,7 @@ public final class DefaultLogbookTest {
 
     private final HttpLogFormatter formatter = mock(HttpLogFormatter.class);
     private final HttpLogWriter writer = mock(HttpLogWriter.class);
+    @SuppressWarnings("unchecked") private final Predicate<RawHttpRequest> predicate = mock(Predicate.class);
     private final Obfuscator headerObfuscator = mock(Obfuscator.class);
     private final Obfuscator parameterObfuscator = mock(Obfuscator.class);
     private final BodyObfuscator bodyObfuscator = mock(BodyObfuscator.class);
@@ -48,6 +50,7 @@ public final class DefaultLogbookTest {
     private final Logbook unit = Logbook.builder()
             .writer(writer)
             .formatter(formatter)
+            .predicate(predicate)
             .headerObfuscator(headerObfuscator)
             .parameterObfuscator(parameterObfuscator)
             .bodyObfuscator(bodyObfuscator)
@@ -62,6 +65,7 @@ public final class DefaultLogbookTest {
     @Before
     public void defaultBehaviour() throws IOException {
         when(writer.isActive(any())).thenReturn(true);
+        when(predicate.test(any())).thenReturn(true);
         when(rawHttpRequest.withBody()).thenReturn(request);
         when(rawHttpResponse.withBody()).thenReturn(response);
     }
@@ -74,9 +78,19 @@ public final class DefaultLogbookTest {
 
         assertThat(correlator, hasFeature("present", Optional::isPresent, is(false)));
     }
+    
+    @Test
+    public void shouldNotReturnCorrelatorIfPredicateTestsFalse() throws IOException {
+        when(writer.isActive(any())).thenReturn(true);
+        when(predicate.test(any())).thenReturn(false);
+
+        final Optional<Correlator> correlator = unit.write(rawHttpRequest);
+
+        assertThat(correlator, hasFeature("present", Optional::isPresent, is(false)));
+    }
 
     @Test
-    public void shouldRetrieveBodyIfInactiveWriter() throws IOException {
+    public void shouldNeverRetrieveBodyIfInactiveWriter() throws IOException {
         when(writer.isActive(any())).thenReturn(false);
 
         unit.write(rawHttpRequest);
diff --git a/logbook-core/src/test/java/org/zalando/logbook/ForwardingRawHttpRequestTest.java b/logbook-core/src/test/java/org/zalando/logbook/ForwardingRawHttpRequestTest.java
index 6991a45a..bf2b28bd 100644
--- a/logbook-core/src/test/java/org/zalando/logbook/ForwardingRawHttpRequestTest.java
+++ b/logbook-core/src/test/java/org/zalando/logbook/ForwardingRawHttpRequestTest.java
@@ -20,15 +20,18 @@ package org.zalando.logbook;
  * #L%
  */
 
+import com.google.common.collect.ImmutableMultimap;
 import org.junit.Test;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.emptyString;
 import static org.hamcrest.Matchers.is;
 import static org.junit.Assert.assertThat;
+import static org.zalando.logbook.Origin.REMOTE;
 
 public final class ForwardingRawHttpRequestTest {
 
@@ -43,6 +46,10 @@ public final class ForwardingRawHttpRequestTest {
 
     @Test
     public void shouldDelegate() throws IOException {
+        assertThat(unit.getHeaders(), is(ImmutableMultimap.of()));
+        assertThat(unit.getContentType(), is(""));
+        assertThat(unit.getCharset(), is(UTF_8));
+        assertThat(unit.getOrigin(), is(REMOTE));
         assertThat(unit.getRemote(), is("127.0.0.1"));
         assertThat(unit.getMethod(), is("GET"));
         assertThat(unit.getRequestUri(), is("http://localhost/"));
diff --git a/logbook-core/src/test/java/org/zalando/logbook/JsonHttpLogFormatterTest.java b/logbook-core/src/test/java/org/zalando/logbook/JsonHttpLogFormatterTest.java
index 2238612b..8b2eaf3e 100644
--- a/logbook-core/src/test/java/org/zalando/logbook/JsonHttpLogFormatterTest.java
+++ b/logbook-core/src/test/java/org/zalando/logbook/JsonHttpLogFormatterTest.java
@@ -20,6 +20,7 @@ package org.zalando.logbook;
  * #L%
  */
 
+import com.google.common.collect.ImmutableMultimap;
 import org.junit.Test;
 import org.zalando.logbook.DefaultLogbook.SimpleCorrelation;
 import org.zalando.logbook.DefaultLogbook.SimplePrecorrelation;
@@ -48,8 +49,9 @@ public final class JsonHttpLogFormatterTest {
         final HttpRequest request = MockHttpRequest.builder()
                 .origin(REMOTE)
                 .requestUri("/test")
-                .header("Accept", "application/json")
-                .header("Date", "Tue, 15 Nov 1994 08:12:31 GMT")
+                .headers(ImmutableMultimap.of(
+                        "Accept", "application/json",
+                        "Date", "Tue, 15 Nov 1994 08:12:31 GMT"))
                 .contentType("application/xml")
                 .body("<action>test</action>")
                 .build();
@@ -184,7 +186,7 @@ public final class JsonHttpLogFormatterTest {
         final HttpRequest request = MockHttpRequest.create();
         final HttpResponse response = MockHttpResponse.builder()
                 .origin(LOCAL)
-                .header("Date", "Tue, 15 Nov 1994 08:12:31 GMT")
+                .headers(ImmutableMultimap.of("Date", "Tue, 15 Nov 1994 08:12:31 GMT"))
                 .contentType("application/xml")
                 .body("<success>true<success>")
                 .build();
diff --git a/logbook-core/src/test/java/org/zalando/logbook/MockHttpRequest.java b/logbook-core/src/test/java/org/zalando/logbook/MockHttpRequest.java
index c3876cb0..09856721 100644
--- a/logbook-core/src/test/java/org/zalando/logbook/MockHttpRequest.java
+++ b/logbook-core/src/test/java/org/zalando/logbook/MockHttpRequest.java
@@ -20,16 +20,13 @@ package org.zalando.logbook;
  * #L%
  */
 
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Multimaps;
 import lombok.Builder;
-import lombok.Singular;
 
 import javax.annotation.Nullable;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
-import java.util.Map;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
@@ -40,7 +37,7 @@ public final class MockHttpRequest implements HttpRequest {
     private final String remote;
     private final String method;
     private final String requestUri;
-    private final Map<String, String> headers;
+    private final Multimap<String, String> headers;
     private final String contentType;
     private final Charset charset;
     private final String body;
@@ -50,7 +47,7 @@ public final class MockHttpRequest implements HttpRequest {
             @Nullable final String remote,
             @Nullable final String method,
             @Nullable final String requestUri,
-            @Nullable @Singular final Map<String, String> headers,
+            @Nullable final Multimap<String, String> headers,
             @Nullable final String contentType,
             @Nullable final Charset charset,
             @Nullable final String body) {
@@ -58,13 +55,13 @@ public final class MockHttpRequest implements HttpRequest {
         this.remote = firstNonNull(remote, "127.0.0.1");
         this.method = firstNonNull(method, "GET");
         this.requestUri = firstNonNull(requestUri, "http://localhost/");
-        this.headers = firstNonNullNorEmpty(headers, ImmutableMap.of());
+        this.headers = firstNonNullNorEmpty(headers, ImmutableMultimap.of());
         this.contentType = firstNonNull(contentType, "");
         this.charset = firstNonNull(charset, StandardCharsets.UTF_8);
         this.body = firstNonNull(body, "");
     }
 
-    static <K, V> Map<K, V> firstNonNullNorEmpty(@Nullable final Map<K, V> first, final Map<K, V> second) {
+    static <K, V> Multimap<K, V> firstNonNullNorEmpty(@Nullable final Multimap<K, V> first, final Multimap<K, V> second) {
         return first != null && !first.isEmpty() ? first : checkNotNull(second);
     }
 
@@ -90,7 +87,7 @@ public final class MockHttpRequest implements HttpRequest {
 
     @Override
     public Multimap<String, String> getHeaders() {
-        return Multimaps.forMap(headers);
+        return headers;
     }
 
     @Override
diff --git a/logbook-core/src/test/java/org/zalando/logbook/MockHttpResponse.java b/logbook-core/src/test/java/org/zalando/logbook/MockHttpResponse.java
index cb297ee5..2864d2a5 100644
--- a/logbook-core/src/test/java/org/zalando/logbook/MockHttpResponse.java
+++ b/logbook-core/src/test/java/org/zalando/logbook/MockHttpResponse.java
@@ -20,16 +20,13 @@ package org.zalando.logbook;
  * #L%
  */
 
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Multimaps;
 import lombok.Builder;
-import lombok.Singular;
 
 import javax.annotation.Nullable;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
-import java.util.Map;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static org.zalando.logbook.MockHttpRequest.firstNonNullNorEmpty;
@@ -38,7 +35,7 @@ public final class MockHttpResponse implements HttpResponse {
 
     private final Origin origin;
     private final int status;
-    private final Map<String, String> headers;
+    private final Multimap<String, String> headers;
     private final String contentType;
     private final Charset charset;
     private final String body;
@@ -46,13 +43,13 @@ public final class MockHttpResponse implements HttpResponse {
     @Builder
     public MockHttpResponse(@Nullable final Origin origin, 
             final int status,
-            @Nullable @Singular final Map<String, String> headers,
+            @Nullable final Multimap<String, String> headers,
             @Nullable final String contentType,
             @Nullable final Charset charset,
             @Nullable final String body) {
         this.origin = firstNonNull(origin, Origin.LOCAL);
         this.status = status == 0 ? 200 : status;
-        this.headers = firstNonNullNorEmpty(headers, ImmutableMap.of());
+        this.headers = firstNonNullNorEmpty(headers, ImmutableMultimap.of());
         this.contentType = firstNonNull(contentType, "");
         this.charset = firstNonNull(charset, StandardCharsets.UTF_8);
         this.body = firstNonNull(body, "");
@@ -70,7 +67,7 @@ public final class MockHttpResponse implements HttpResponse {
 
     @Override
     public Multimap<String, String> getHeaders() {
-        return Multimaps.forMap(headers);
+        return headers;
     }
 
     @Override
diff --git a/logbook-core/src/test/java/org/zalando/logbook/MockRawHttpRequest.java b/logbook-core/src/test/java/org/zalando/logbook/MockRawHttpRequest.java
index 55bed83b..ee636574 100644
--- a/logbook-core/src/test/java/org/zalando/logbook/MockRawHttpRequest.java
+++ b/logbook-core/src/test/java/org/zalando/logbook/MockRawHttpRequest.java
@@ -20,12 +20,18 @@ package org.zalando.logbook;
  * #L%
  */
 
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Multimap;
 import lombok.Builder;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 import lombok.Value;
 
 import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
 
 @Value
 @Getter
@@ -33,6 +39,10 @@ import java.io.IOException;
 @NoArgsConstructor(staticName = "create")
 public class MockRawHttpRequest implements RawHttpRequest {
 
+    private Multimap<String, String> headers = ImmutableMultimap.of();
+    private String contentType = "";
+    private Charset charset = StandardCharsets.UTF_8;
+    private Origin origin = Origin.REMOTE;
     private String remote = "127.0.0.1";
     private String method = "GET";
     private String requestUri = "http://localhost/";
@@ -40,6 +50,10 @@ public class MockRawHttpRequest implements RawHttpRequest {
     @Override
     public HttpRequest withBody() throws IOException {
         return MockHttpRequest.builder()
+                .headers(headers)
+                .contentType(contentType)
+                .charset(charset)
+                .origin(origin)
                 .remote(remote)
                 .method(method)
                 .requestUri(requestUri)
diff --git a/logbook-core/src/test/java/org/zalando/logbook/ObfuscatedHttpRequestTest.java b/logbook-core/src/test/java/org/zalando/logbook/ObfuscatedHttpRequestTest.java
index 626714b6..e1ec5e1f 100644
--- a/logbook-core/src/test/java/org/zalando/logbook/ObfuscatedHttpRequestTest.java
+++ b/logbook-core/src/test/java/org/zalando/logbook/ObfuscatedHttpRequestTest.java
@@ -20,6 +20,7 @@ package org.zalando.logbook;
  * #L%
  */
 
+import com.google.common.collect.ImmutableMultimap;
 import org.junit.Test;
 
 import java.io.IOException;
@@ -35,8 +36,9 @@ public final class ObfuscatedHttpRequestTest {
 
     private final HttpRequest unit = new ObfuscatedHttpRequest(MockHttpRequest.builder()
             .requestUri("/?password=1234&limit=1")
-            .header("Authorization", "Bearer 9b7606a6-6838-11e5-8ed4-10ddb1ee7671")
-            .header("Accept", "text/plain")
+            .headers(ImmutableMultimap.of(
+                    "Authorization", "Bearer 9b7606a6-6838-11e5-8ed4-10ddb1ee7671",
+                    "Accept", "text/plain"))
             .body("My secret is s3cr3t")
             .build(),
             Obfuscator.authorization(),
diff --git a/logbook-core/src/test/java/org/zalando/logbook/ObfuscatedHttpResponseTest.java b/logbook-core/src/test/java/org/zalando/logbook/ObfuscatedHttpResponseTest.java
index 8b1e82b8..bf6e20bd 100644
--- a/logbook-core/src/test/java/org/zalando/logbook/ObfuscatedHttpResponseTest.java
+++ b/logbook-core/src/test/java/org/zalando/logbook/ObfuscatedHttpResponseTest.java
@@ -20,6 +20,7 @@ package org.zalando.logbook;
  * #L%
  */
 
+import com.google.common.collect.ImmutableMultimap;
 import org.junit.Test;
 
 import java.io.IOException;
@@ -33,8 +34,9 @@ import static org.junit.Assert.assertThat;
 public final class ObfuscatedHttpResponseTest {
 
     private final HttpResponse unit = new ObfuscatedHttpResponse(MockHttpResponse.builder()
-            .header("Authorization", "Bearer 9b7606a6-6838-11e5-8ed4-10ddb1ee7671")
-            .header("Accept", "text/plain")
+            .headers(ImmutableMultimap.of(
+                    "Authorization", "Bearer 9b7606a6-6838-11e5-8ed4-10ddb1ee7671",
+                    "Accept", "text/plain"))
             .body("My secret is s3cr3t")
             .build(),
             Obfuscator.authorization(),
diff --git a/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/HttpFormatterTest.java b/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/HttpFormatterTest.java
index a4e5ebc8..cf9f30c1 100644
--- a/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/HttpFormatterTest.java
+++ b/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/HttpFormatterTest.java
@@ -63,7 +63,7 @@ public final class HttpFormatterTest extends AbstractTest {
     private HttpLogWriter writer;
 
     @Test
-    public void shouldUseJsonFormatter() throws IOException {
+    public void shouldUseHttpFormatter() throws IOException {
         when(writer.isActive(any())).thenReturn(true);
 
         logbook.write(MockRawHttpRequest.create());
diff --git a/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/MockHttpRequest.java b/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/MockHttpRequest.java
index 43cdd23c..2d510ce2 100644
--- a/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/MockHttpRequest.java
+++ b/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/MockHttpRequest.java
@@ -20,18 +20,15 @@ package org.zalando.logbook.spring;
  * #L%
  */
 
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Multimaps;
 import lombok.Builder;
-import lombok.Singular;
 import org.zalando.logbook.HttpRequest;
 import org.zalando.logbook.Origin;
 
 import javax.annotation.Nullable;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
-import java.util.Map;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
@@ -43,7 +40,7 @@ public final class MockHttpRequest implements HttpRequest {
     private final String remote;
     private final String method;
     private final String requestUri;
-    private final Map<String, String> headers;
+    private final Multimap<String, String> headers;
     private final String contentType;
     private final Charset charset;
     private final String body;
@@ -53,7 +50,7 @@ public final class MockHttpRequest implements HttpRequest {
             @Nullable final String remote,
             @Nullable final String method,
             @Nullable final String requestUri,
-            @Nullable @Singular final Map<String, String> headers,
+            @Nullable final Multimap<String, String> headers,
             @Nullable final String contentType,
             @Nullable final Charset charset,
             @Nullable final String body) {
@@ -61,13 +58,13 @@ public final class MockHttpRequest implements HttpRequest {
         this.remote = firstNonNull(remote, "127.0.0.1");
         this.method = firstNonNull(method, "GET");
         this.requestUri = firstNonNull(requestUri, "/");
-        this.headers = firstNonNullNorEmpty(headers, ImmutableMap.of());
+        this.headers = firstNonNullNorEmpty(headers, ImmutableMultimap.of());
         this.contentType = firstNonNull(contentType, "");
         this.charset = firstNonNull(charset, StandardCharsets.UTF_8);
         this.body = firstNonNull(body, "");
     }
 
-    static <K, V> Map<K, V> firstNonNullNorEmpty(@Nullable final Map<K, V> first, final Map<K, V> second) {
+    static <K, V> Multimap<K, V> firstNonNullNorEmpty(@Nullable final Multimap<K, V> first, final Multimap<K, V> second) {
         return first != null && !first.isEmpty() ? first : checkNotNull(second);
     }
 
@@ -93,7 +90,7 @@ public final class MockHttpRequest implements HttpRequest {
 
     @Override
     public Multimap<String, String> getHeaders() {
-        return Multimaps.forMap(headers);
+        return headers;
     }
 
     @Override
diff --git a/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/MockRawHttpRequest.java b/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/MockRawHttpRequest.java
index 185cd69d..1cee1d67 100644
--- a/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/MockRawHttpRequest.java
+++ b/logbook-spring-boot-starter/src/test/java/org/zalando/logbook/spring/MockRawHttpRequest.java
@@ -20,14 +20,19 @@ package org.zalando.logbook.spring;
  * #L%
  */
 
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Multimap;
 import lombok.Builder;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 import lombok.Value;
 import org.zalando.logbook.HttpRequest;
+import org.zalando.logbook.Origin;
 import org.zalando.logbook.RawHttpRequest;
 
 import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
 
 @Value
 @Getter
@@ -35,6 +40,10 @@ import java.io.IOException;
 @NoArgsConstructor(staticName = "create")
 public class MockRawHttpRequest implements RawHttpRequest {
 
+    private Multimap<String, String> headers = ImmutableMultimap.of();
+    private String contentType = "";
+    private Charset charset = StandardCharsets.UTF_8;
+    private Origin origin = Origin.REMOTE;
     private String remote = "127.0.0.1";
     private String method = "GET";
     private String requestUri = "/";
@@ -42,6 +51,10 @@ public class MockRawHttpRequest implements RawHttpRequest {
     @Override
     public HttpRequest withBody() throws IOException {
         return MockHttpRequest.builder()
+                .headers(headers)
+                .contentType(contentType)
+                .charset(charset)
+                .origin(origin)
                 .remote(remote)
                 .method(method)
                 .requestUri(requestUri)