diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/OkHttpServices.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/OkHttpServices.java index f3cf56ba9..059a97aca 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/OkHttpServices.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/OkHttpServices.java @@ -30,6 +30,7 @@ import com.marklogic.client.query.QueryManager.QueryView; import com.marklogic.client.semantics.*; import com.marklogic.client.util.EditableNamespaceContext; +import com.marklogic.client.util.LoggingUtil; import com.marklogic.client.util.RequestLogger; import com.marklogic.client.util.RequestParameters; import jakarta.mail.BodyPart; @@ -79,24 +80,6 @@ public class OkHttpServices implements RESTServices { static final private Logger logger = LoggerFactory.getLogger(OkHttpServices.class); - /** - * Controls the verbosity of OkHttp HTTP traffic logging. Accepted values: {@code NONE}, {@code BASIC}, - * {@code HEADERS}, {@code BODY}. - * - *

Security warning: {@code HEADERS} and {@code BODY} levels log HTTP request and response - * headers and/or bodies, which may contain MarkLogic credentials, OAuth/SAML tokens, or sensitive document - * content. These levels must never be enabled in production environments. {@code Authorization} and - * {@code x-auth-token} header values are automatically redacted from log output, but body content (including - * MarkLogic document data) is not redacted. - */ - private static final String OKHTTP_LOGGINGINTERCEPTOR_LEVEL = "com.marklogic.client.okhttp.httplogginginterceptor.level"; - - /** - * Controls where OkHttp log output is written. Accepted values: {@code LOGGER} (SLF4J debug logger), - * {@code STDERR}, or leave unset for stdout. - */ - private static final String OKHTTP_LOGGINGINTERCEPTOR_OUTPUT = "com.marklogic.client.okhttp.httplogginginterceptor.output"; - private static final String DOCUMENT_URI_PREFIX = "/documents?uri="; static final private int DELAY_FLOOR = 125; @@ -221,7 +204,7 @@ private OkHttpClient connect(ConnectionConfig config) { OkHttpClient.Builder clientBuilder = OkHttpUtil.newOkHttpClientBuilder(config.host, config.securityContext, config.clientConfigurators); Properties props = System.getProperties(); - if (props.containsKey(OKHTTP_LOGGINGINTERCEPTOR_LEVEL)) { + if (props.containsKey(LoggingUtil.OKHTTP_NETWORK_LEVEL)) { configureOkHttpLogging(clientBuilder, props); } this.configureDelayAndRetry(props); @@ -239,17 +222,17 @@ private OkHttpClient connect(ConnectionConfig config) { * never be enabled in production environments. */ private void configureOkHttpLogging(OkHttpClient.Builder clientBuilder, Properties props) { - final boolean useLogger = "LOGGER".equalsIgnoreCase(props.getProperty(OKHTTP_LOGGINGINTERCEPTOR_OUTPUT)); - final boolean useStdErr = "STDERR".equalsIgnoreCase(props.getProperty(OKHTTP_LOGGINGINTERCEPTOR_OUTPUT)); + final boolean useLogger = "LOGGER".equalsIgnoreCase(props.getProperty(LoggingUtil.OKHTTP_NETWORK_OUTPUT)); + final boolean useStdErr = "STDERR".equalsIgnoreCase(props.getProperty(LoggingUtil.OKHTTP_NETWORK_OUTPUT)); HttpLoggingInterceptor networkInterceptor = new HttpLoggingInterceptor(new RedactingHttpLogger(useLogger, useStdErr)); - if ("BASIC".equalsIgnoreCase(props.getProperty(OKHTTP_LOGGINGINTERCEPTOR_LEVEL))) { + if ("BASIC".equalsIgnoreCase(props.getProperty(LoggingUtil.OKHTTP_NETWORK_LEVEL))) { networkInterceptor = networkInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); - } else if ("BODY".equalsIgnoreCase(props.getProperty(OKHTTP_LOGGINGINTERCEPTOR_LEVEL))) { + } else if ("BODY".equalsIgnoreCase(props.getProperty(LoggingUtil.OKHTTP_NETWORK_LEVEL))) { networkInterceptor = networkInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY); - } else if ("HEADERS".equalsIgnoreCase(props.getProperty(OKHTTP_LOGGINGINTERCEPTOR_LEVEL))) { + } else if ("HEADERS".equalsIgnoreCase(props.getProperty(LoggingUtil.OKHTTP_NETWORK_LEVEL))) { networkInterceptor = networkInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); - } else if ("NONE".equalsIgnoreCase(props.getProperty(OKHTTP_LOGGINGINTERCEPTOR_LEVEL))) { + } else if ("NONE".equalsIgnoreCase(props.getProperty(LoggingUtil.OKHTTP_NETWORK_LEVEL))) { networkInterceptor = networkInterceptor.setLevel(HttpLoggingInterceptor.Level.NONE); } clientBuilder.addNetworkInterceptor(networkInterceptor); diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/RedactingHttpLogger.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/RedactingHttpLogger.java index 740d22fc3..ed98dfe58 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/RedactingHttpLogger.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/RedactingHttpLogger.java @@ -3,6 +3,7 @@ */ package com.marklogic.client.impl.okhttp; +import com.marklogic.client.util.LoggingUtil; import okhttp3.logging.HttpLoggingInterceptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -14,6 +15,11 @@ * and {@code x-auth-token} header values before writing each log message to * the configured output target (SLF4J logger, stderr, or stdout). * + *

When the output target is {@code LOGGER}, messages are written to the + * SLF4J logger category {@link com.marklogic.client.util.LoggingUtil#OKHTTP_NETWORK_LOGGER} at + * {@code INFO} level. Users can enable or disable this output without + * touching DEBUG logging for unrelated client internals. + * *

Security warning: Even with header redaction, * {@code BODY}-level logging writes full HTTP request and response bodies to * the log target. This may include MarkLogic document content and must never @@ -30,7 +36,7 @@ public class RedactingHttpLogger implements HttpLoggingInterceptor.Logger { Pattern.compile("(?i)(authorization|x-auth-token):.*"); static final String SENSITIVE_HEADER_REDACTION_REPLACEMENT = "$1: [REDACTED]"; - private static final Logger logger = LoggerFactory.getLogger(RedactingHttpLogger.class); + private static final Logger logger = LoggerFactory.getLogger(LoggingUtil.OKHTTP_NETWORK_LOGGER); private final boolean useLogger; private final boolean useStdErr; @@ -44,7 +50,7 @@ public RedactingHttpLogger(boolean useLogger, boolean useStdErr) { public void log(String message) { String redacted = redactSensitiveHeaders(message); if (useLogger) { - logger.debug(redacted); + logger.info(redacted); } else if (useStdErr) { System.err.println(redacted); } else { diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/util/LoggingUtil.java b/marklogic-client-api/src/main/java/com/marklogic/client/util/LoggingUtil.java new file mode 100644 index 000000000..63b58f479 --- /dev/null +++ b/marklogic-client-api/src/main/java/com/marklogic/client/util/LoggingUtil.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + */ +package com.marklogic.client.util; + +/** + * Provides public constants for configuring MarkLogic Java Client logging behaviour. + * + *

OkHttp Network Logging

+ * + *

The Java Client uses OkHttp for all HTTP communication with MarkLogic. Optional network + * traffic logging is controlled by two Java system properties: + * + *

+ * + *

When the output is set to {@code LOGGER}, log lines are written to the SLF4J logger + * category {@link #OKHTTP_NETWORK_LOGGER} at {@code INFO} level. Configure this category + * in your logging framework to enable network diagnostics without enabling debug logging + * for unrelated client internals. For example, in {@code logback.xml}: + * + *

{@code
+ * 
+ * }
+ * + *

Or in a Spring Boot {@code application.properties} file: + * + *

{@code
+ * logging.level.marklogic.okhttp.network=INFO
+ * }
+ * + *

Security warning: {@code Authorization} and {@code x-auth-token} header + * values are automatically redacted from all log output. However, {@code HEADERS} and + * {@code BODY} levels may expose other sensitive data including MarkLogic document content. + * Never enable {@code HEADERS} or {@code BODY} in a production environment. + * + * @since 8.2.0 + */ +public final class LoggingUtil { + + /** + * Name of the Java system property that controls the verbosity of OkHttp network logging. + * Value: {@code "marklogic.okhttp.network.level"} + * + *

Accepted values (case-insensitive): {@code BASIC}, {@code HEADERS}, {@code BODY}, + * {@code NONE}. Setting this property to any recognised value activates the network + * logging interceptor. + * + *

Security warning: {@code HEADERS} and {@code BODY} levels log HTTP + * request and response headers and/or bodies, which may contain MarkLogic credentials, + * OAuth/SAML tokens, or sensitive document content. These levels must never be + * enabled in production environments. {@code Authorization} and {@code x-auth-token} + * header values are automatically redacted, but body content is not. + */ + public static final String OKHTTP_NETWORK_LEVEL = "marklogic.okhttp.network.level"; + + /** + * Name of the Java system property that controls where OkHttp network log lines are written. + * Value: {@code "marklogic.okhttp.network.output"} + * + *

Accepted values (case-insensitive): + *

+ */ + public static final String OKHTTP_NETWORK_OUTPUT = "marklogic.okhttp.network.output"; + + /** + * The SLF4J logger category used for OkHttp network traffic when + * {@link #OKHTTP_NETWORK_OUTPUT} is set to {@code LOGGER}. + * Value: {@code "marklogic.okhttp.network"} + * + *

Configure this category at {@code INFO} level in your logging framework to enable + * network traffic logging. Example {@code logback.xml} configuration: + *

{@code
+	 * 
+	 * }
+ * + *

Example Spring Boot {@code application.properties}: + *

{@code
+	 * logging.level.marklogic.okhttp.network=INFO
+	 * }
+ */ + public static final String OKHTTP_NETWORK_LOGGER = "marklogic.okhttp.network"; + + private LoggingUtil() { + } +} diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/impl/okhttp/RedactingHttpLoggerTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/impl/okhttp/RedactingHttpLoggerTest.java index be72165a4..3872ff60b 100644 --- a/marklogic-client-api/src/test/java/com/marklogic/client/impl/okhttp/RedactingHttpLoggerTest.java +++ b/marklogic-client-api/src/test/java/com/marklogic/client/impl/okhttp/RedactingHttpLoggerTest.java @@ -3,12 +3,19 @@ */ package com.marklogic.client.impl.okhttp; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.marklogic.client.util.LoggingUtil; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; import static org.junit.jupiter.api.Assertions.*; /** - * Unit tests for {@link RedactingHttpLogger#redactSensitiveHeaders(String)}. + * Unit tests for {@link RedactingHttpLogger}. + * Covers header redaction ({@link RedactingHttpLogger#redactSensitiveHeaders(String)}) + * and LOGGER-mode output category and level behaviour. * No MarkLogic instance or network connectivity is required. */ class RedactingHttpLoggerTest { @@ -88,4 +95,67 @@ void multiLineMessageRedactsOnlyAuthLines() { assertFalse(result.contains("dXNlcjpwYXNzd29yZA==")); assertTrue(result.contains("Content-Length: 42")); } + + @Test + void loggerModeWritesToNetworkLoggerCategoryAtInfoLevel() { + ch.qos.logback.classic.Logger networkLogger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(LoggingUtil.OKHTTP_NETWORK_LOGGER); + Level originalLevel = networkLogger.getLevel(); + boolean originalAdditive = networkLogger.isAdditive(); + networkLogger.setAdditive(false); + networkLogger.setLevel(Level.INFO); + + ListAppender appender = new ListAppender<>(); + appender.start(); + networkLogger.addAppender(appender); + + try { + new RedactingHttpLogger(true, false).log("GET http://localhost:8000/v1/documents HTTP/1.1"); + + assertEquals(1, appender.list.size(), "Expected exactly one log event"); + ILoggingEvent event = appender.list.get(0); + + assertEquals(LoggingUtil.OKHTTP_NETWORK_LOGGER, event.getLoggerName(), + "Log event must be published under " + LoggingUtil.OKHTTP_NETWORK_LOGGER); + assertEquals(Level.INFO, event.getLevel(), + "LOGGER-mode output must be emitted at INFO level"); + } finally { + appender.stop(); + networkLogger.detachAppender(appender); + networkLogger.setLevel(originalLevel); + networkLogger.setAdditive(originalAdditive); + } + } + + @Test + void loggerModeDoesNotRequireDebugOnImplPackage() { + ch.qos.logback.classic.Logger implLogger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("com.marklogic.client.impl.okhttp"); + Level originalImplLevel = implLogger.getLevel(); + implLogger.setLevel(Level.WARN); + + ch.qos.logback.classic.Logger networkLogger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(LoggingUtil.OKHTTP_NETWORK_LOGGER); + Level originalNetworkLevel = networkLogger.getLevel(); + boolean originalNetworkAdditive = networkLogger.isAdditive(); + networkLogger.setAdditive(false); + networkLogger.setLevel(Level.INFO); + + ListAppender appender = new ListAppender<>(); + appender.start(); + networkLogger.addAppender(appender); + + try { + new RedactingHttpLogger(true, false).log("GET http://localhost:8000/v1/documents HTTP/1.1"); + + assertEquals(1, appender.list.size(), + "LOGGER-mode output must be visible without enabling DEBUG on com.marklogic.client.impl.*"); + } finally { + appender.stop(); + networkLogger.detachAppender(appender); + networkLogger.setLevel(originalNetworkLevel); + networkLogger.setAdditive(originalNetworkAdditive); + implLogger.setLevel(originalImplLevel); + } + } }