This is an automated email from the ASF dual-hosted git repository.
imbajin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hugegraph.git
The following commit(s) were added to refs/heads/master by this push:
new 79bf6e993 fix(server): record bounded request body and client IP in
slow query log (#3177)
79bf6e993 is described below
commit 79bf6e993cff6d12d3927537c04831a3523a2176
Author: KAI <[email protected]>
AuthorDate: Sun Aug 30 19:08:40 2026 +0530
fix(server): record bounded request body and client IP in slow query log
(#3177)
The slow query log has printed body=null since #2347 disabled the capture
from #2327, which broke gzip batch imports. AccessLogFilter now also runs
as a request filter and keeps at most log.slow_query_body_limit bytes
(default 512, 0 disables) of POST/PUT bodies on slow-log paths, replaying
the prefix in front of the rest of the entity stream. Nothing is read for
other paths, for GET/DELETE, or when the slow query log is off, so loader
batch imports are untouched. Compressed bodies are logged as <gzip>
instead of being decoded. The client IP is the Grizzly Request peer
address, as in AuthenticationFilter. Adds AccessLogFilterTest and removes
the unused PathFilter.REQUEST_PARAMS_JSON constant.
---------
Co-authored-by: SunnyBoy-WYH <[email protected]>
Co-authored-by: imbajin <[email protected]>
---
.../static/conf/rest-server.properties.template | 2 +
.../hugegraph/api/filter/AccessLogFilter.java | 135 ++++-
.../apache/hugegraph/api/filter/PathFilter.java | 1 -
.../api/filter/RedirectFilterDynamicFeature.java | 6 +-
.../org/apache/hugegraph/config/ServerOptions.java | 11 +
.../assembly/static/conf/rest-server.properties | 2 +
.../org/apache/hugegraph/unit/UnitTestSuite.java | 2 +
.../unit/api/filter/AccessLogFilterTest.java | 633 +++++++++++++++++++++
8 files changed, 776 insertions(+), 16 deletions(-)
diff --git
a/hugegraph-cluster-test/hugegraph-clustertest-dist/src/assembly/static/conf/rest-server.properties.template
b/hugegraph-cluster-test/hugegraph-clustertest-dist/src/assembly/static/conf/rest-server.properties.template
index 01744ac2c..43979ec12 100644
---
a/hugegraph-cluster-test/hugegraph-clustertest-dist/src/assembly/static/conf/rest-server.properties.template
+++
b/hugegraph-cluster-test/hugegraph-clustertest-dist/src/assembly/static/conf/rest-server.properties.template
@@ -70,3 +70,5 @@ server.role=$ROLE$
# slow query log
log.slow_query_threshold=1000
+# bytes of request body recorded as-is (may contain sensitive literals), 0 to
disable
+log.slow_query_body_limit=512
diff --git
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/AccessLogFilter.java
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/AccessLogFilter.java
index 194a0c45e..d510b2d07 100644
---
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/AccessLogFilter.java
+++
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/AccessLogFilter.java
@@ -23,30 +23,44 @@ import static
org.apache.hugegraph.metrics.MetricsUtil.METRICS_PATH_RESPONSE_TIM
import static
org.apache.hugegraph.metrics.MetricsUtil.METRICS_PATH_SUCCESS_COUNTER;
import static
org.apache.hugegraph.metrics.MetricsUtil.METRICS_PATH_TOTAL_COUNTER;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.SequenceInputStream;
+import java.lang.reflect.Method;
import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CodingErrorAction;
import java.util.Map;
+import org.apache.hugegraph.api.API;
+import org.apache.hugegraph.api.filter.DecompressInterceptor.Decompress;
import org.apache.hugegraph.config.HugeConfig;
import org.apache.hugegraph.config.ServerOptions;
import org.apache.hugegraph.core.GraphManager;
import org.apache.hugegraph.metrics.MetricsUtil;
import org.apache.hugegraph.util.Log;
+import org.glassfish.grizzly.http.server.Request;
import org.slf4j.Logger;
import jakarta.inject.Singleton;
import jakarta.ws.rs.HttpMethod;
import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.container.ContainerResponseFilter;
+import jakarta.ws.rs.container.ResourceInfo;
import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.ext.Provider;
-// TODO: should add test for this class
@Provider
@Singleton
-public class AccessLogFilter implements ContainerResponseFilter {
+public class AccessLogFilter implements ContainerRequestFilter,
ContainerResponseFilter {
private static final Logger LOG = Log.logger(AccessLogFilter.class);
@@ -55,14 +69,27 @@ public class AccessLogFilter implements
ContainerResponseFilter {
private static final String GREMLIN = "gremlin";
private static final String CYPHER = "cypher";
+ // Request property holding the bounded request body preview for the slow
query log
+ public static final String REQUEST_BODY = "request_body";
+ public static final String UNKNOWN_IP = "<unknown_ip>";
+
+ private static final String TRUNCATED_MARK = "...";
+ private static final String ENCODED_BODY = "<encoded>";
+ private static final Charset DEFAULT_CHARSET =
Charset.forName(API.CHARSET);
+
@Context
private jakarta.inject.Provider<HugeConfig> configProvider;
@Context
private jakarta.inject.Provider<GraphManager> managerProvider;
+ @Context
+ private jakarta.inject.Provider<Request> requestProvider;
+
+ @Context
+ private ResourceInfo resourceInfo;
+
public static boolean needRecordLog(ContainerRequestContext context) {
- // TODO: add test for 'path' result ('/gremlin' or 'gremlin')
String path = context.getUriInfo().getPath();
// GraphsAPI/CypherAPI/Job GremlinAPI
@@ -83,8 +110,7 @@ public class AccessLogFilter implements
ContainerResponseFilter {
// Replace variable parts of the path with placeholders
String requestPath = requestContext.getUriInfo().getPath();
// get uri params
- MultivaluedMap<String, String> pathParameters =
requestContext.getUriInfo()
-
.getPathParameters();
+ MultivaluedMap<String, String> pathParameters =
requestContext.getUriInfo().getPathParameters();
String newPath = requestPath;
for (Map.Entry<String, java.util.List<String>> entry :
pathParameters.entrySet()) {
@@ -100,6 +126,41 @@ public class AccessLogFilter implements
ContainerResponseFilter {
return newPath;
}
+ /**
+ * Keep a bounded preview of the request body for the slow query log.
+ * Only the first {@link ServerOptions#SLOW_QUERY_LOG_BODY_LIMIT} bytes
are read, and they are replayed in front
+ * of the untouched remainder of the entity stream, so the resource method
still receives the whole body.
+ *
+ * @param requestContext requestContext
+ */
+ @Override
+ public void filter(ContainerRequestContext requestContext) throws
IOException {
+ if (!mayHaveBody(requestContext.getMethod()) ||
!needRecordLog(requestContext)) {
+ return;
+ }
+
+ HugeConfig config = this.configProvider.get();
+ long timeThreshold =
config.get(ServerOptions.SLOW_QUERY_LOG_TIME_THRESHOLD);
+ int bodyLimit = config.get(ServerOptions.SLOW_QUERY_LOG_BODY_LIMIT);
+ if (timeThreshold <= 0 || bodyLimit <= 0) {
+ return;
+ }
+
+ if (this.decodesEntity()) {
+ // The resource decodes its entity later (DecompressInterceptor),
so the raw bytes here are not readable
+ requestContext.setProperty(REQUEST_BODY, ENCODED_BODY);
+ return;
+ }
+
+ InputStream entity = requestContext.getEntityStream();
+ // Read one byte past the limit to know whether the preview is
truncated
+ byte[] prefix = new byte[bodyLimit + 1];
+ int length = entity.readNBytes(prefix, 0, prefix.length);
+ requestContext.setEntityStream(new SequenceInputStream(new
ByteArrayInputStream(prefix, 0, length), entity));
+ Charset charset = requestCharset(requestContext);
+ requestContext.setProperty(REQUEST_BODY, preview(prefix, length,
bodyLimit, charset));
+ }
+
/**
* Use filter to log request info
*
@@ -107,8 +168,8 @@ public class AccessLogFilter implements
ContainerResponseFilter {
* @param responseContext responseContext
*/
@Override
- public void filter(ContainerRequestContext requestContext,
- ContainerResponseContext responseContext) throws
IOException {
+ public void filter(ContainerRequestContext requestContext,
ContainerResponseContext responseContext)
+ throws IOException {
// Grab corresponding request / response info from context;
URI uri = requestContext.getUriInfo().getRequestUri();
String method = requestContext.getMethod();
@@ -145,11 +206,12 @@ public class AccessLogFilter implements
ContainerResponseFilter {
HugeConfig config = configProvider.get();
long timeThreshold =
config.get(ServerOptions.SLOW_QUERY_LOG_TIME_THRESHOLD);
// Record slow query if meet needs, watch out the perf
- if (timeThreshold > 0 && executeTime > timeThreshold &&
- needRecordLog(requestContext)) {
- // TODO: set RequestBody null, handle it later & should record
"client IP"
- LOG.info("[Slow Query] execTime={}ms, body={}, method={},
path={}, query={}",
- executeTime, null, method, path, uri.getQuery());
+ if (timeThreshold > 0 && executeTime > timeThreshold &&
needRecordLog(requestContext)) {
+ String clientIp = this.clientIp();
+ Object body = requestContext.getProperty(REQUEST_BODY);
+ LOG.info("[Slow Query] ip={}, execTime={}ms, method={},
path={}, query={}, " +
+ "body={}", clientIp, executeTime, method,
singleLine(path),
+ singleLine(uri.getQuery()), singleLine(body));
}
}
@@ -161,6 +223,55 @@ public class AccessLogFilter implements
ContainerResponseFilter {
}
}
+ private static boolean mayHaveBody(String method) {
+ // DELETE endpoints take path/query params only, so there is no body
to record
+ return HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method);
+ }
+
+ private boolean decodesEntity() {
+ Method method = this.resourceInfo == null ? null :
this.resourceInfo.getResourceMethod();
+ return method != null && method.isAnnotationPresent(Decompress.class);
+ }
+
+ private static Charset requestCharset(ContainerRequestContext
requestContext) {
+ MediaType mediaType = requestContext.getMediaType();
+ if (mediaType == null) {
+ return DEFAULT_CHARSET;
+ }
+ String charset =
mediaType.getParameters().get(MediaType.CHARSET_PARAMETER);
+ return charset == null ? DEFAULT_CHARSET : Charset.forName(charset);
+ }
+
+ private static String preview(byte[] bytes, int length, int limit, Charset
charset) {
+ boolean truncated = length > limit;
+ int size = Math.min(length, limit);
+ CharsetDecoder decoder =
charset.newDecoder().onMalformedInput(CodingErrorAction.REPLACE)
+
.onUnmappableCharacter(CodingErrorAction.REPLACE);
+ CharBuffer chars = CharBuffer.allocate((int) (size *
decoder.maxCharsPerByte()) + 1);
+ /*
+ * A multi-byte character cut by the limit is dropped rather than
turned
+ * into a replacement character: endOfInput=false keeps the incomplete
+ * tail undecoded
+ */
+ decoder.decode(ByteBuffer.wrap(bytes, 0, size), chars, !truncated);
+ if (!truncated) {
+ decoder.flush(chars);
+ }
+ String body = chars.flip().toString();
+ return truncated ? body + TRUNCATED_MARK : body;
+ }
+
+ private static String singleLine(Object value) {
+ // Path, query and body are client controlled, keep the log entry on a
single line
+ return value == null ? null : value.toString().replace("\r",
"\\r").replace("\n", "\\n");
+ }
+
+ private String clientIp() {
+ Request request = this.requestProvider.get();
+ String address = request == null ? null : request.getRemoteAddr();
+ return address == null || address.isEmpty() ? UNKNOWN_IP : address;
+ }
+
private boolean statusOk(int status) {
return status >= 200 && status < 300;
}
diff --git
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/PathFilter.java
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/PathFilter.java
index b69ff5959..5e4dd5081 100644
---
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/PathFilter.java
+++
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/PathFilter.java
@@ -50,7 +50,6 @@ public class PathFilter implements ContainerRequestFilter {
private static final String ARTHAS_START = "arthas";
public static final String REQUEST_TIME = "request_time";
- public static final String REQUEST_PARAMS_JSON = "request_params_json";
private static final String DELIMITER = "/";
private static final Set<String> WHITE_API_LIST = ImmutableSet.of(
diff --git
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/RedirectFilterDynamicFeature.java
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/RedirectFilterDynamicFeature.java
index 894274761..9a90084dd 100644
---
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/RedirectFilterDynamicFeature.java
+++
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/filter/RedirectFilterDynamicFeature.java
@@ -17,6 +17,7 @@
package org.apache.hugegraph.api.filter;
+import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.DynamicFeature;
import jakarta.ws.rs.container.ResourceInfo;
import jakarta.ws.rs.core.FeatureContext;
@@ -27,9 +28,8 @@ public class RedirectFilterDynamicFeature implements
DynamicFeature {
@Override
public void configure(ResourceInfo resourceInfo, FeatureContext context) {
- if (resourceInfo.getResourceMethod()
-
.isAnnotationPresent(RedirectFilter.RedirectMasterRole.class)) {
- context.register(RedirectFilter.class);
+ if
(resourceInfo.getResourceMethod().isAnnotationPresent(RedirectFilter.RedirectMasterRole.class))
{
+ context.register(RedirectFilter.class, Priorities.USER + 1);
}
}
}
diff --git
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java
index 4f59a79b5..05093d6f4 100644
---
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java
+++
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java
@@ -657,6 +657,17 @@ public class ServerOptions extends OptionHolder {
nonNegativeInt(),
1000L
);
+
+ public static final ConfigOption<Integer> SLOW_QUERY_LOG_BODY_LIMIT =
+ new ConfigOption<>(
+ "log.slow_query_body_limit",
+ "The max bytes of request body recorded in the slow query
log, " +
+ "the recorded prefix is written as-is and may contain
sensitive " +
+ "literals of gremlin/cypher scripts, 0 means the body is
not recorded.",
+ rangeInt(0, 1024 * 1024),
+ 512
+ );
+
public static final ConfigOption<Double> JVM_MEMORY_MONITOR_THRESHOLD =
new ConfigOption<>(
"memory_monitor.threshold",
diff --git
a/hugegraph-server/hugegraph-dist/src/assembly/static/conf/rest-server.properties
b/hugegraph-server/hugegraph-dist/src/assembly/static/conf/rest-server.properties
index 33ff0effc..daeb092b7 100644
---
a/hugegraph-server/hugegraph-dist/src/assembly/static/conf/rest-server.properties
+++
b/hugegraph-server/hugegraph-dist/src/assembly/static/conf/rest-server.properties
@@ -28,6 +28,8 @@ arthas.disabled_commands=jad
# slow query log
log.slow_query_threshold=1000
+# bytes of request body recorded as-is (may contain sensitive literals), 0 to
disable
+log.slow_query_body_limit=512
# jvm(in-heap) memory usage monitor, set 1 to disable it
memory_monitor.threshold=0.85
diff --git
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
index ee44a7fce..efc38395a 100644
---
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
+++
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
@@ -28,6 +28,7 @@ import
org.apache.hugegraph.meta.MetaManagerSchemaCacheClearEventTest;
import org.apache.hugegraph.meta.managers.AuthMetaManagerTest;
import org.apache.hugegraph.traversal.optimize.TraversalUtilOptimizeTest;
import org.apache.hugegraph.unit.api.auth.LoginAPITest;
+import org.apache.hugegraph.unit.api.filter.AccessLogFilterTest;
import org.apache.hugegraph.unit.api.filter.LoadDetectFilterTest;
import org.apache.hugegraph.unit.api.filter.PathFilterTest;
import org.apache.hugegraph.unit.api.gremlin.GremlinQueryAPITest;
@@ -103,6 +104,7 @@ import org.junit.runners.Suite;
@RunWith(Suite.class)
@Suite.SuiteClasses({
/* api filter */
+ AccessLogFilterTest.class,
LoadDetectFilterTest.class,
LoginAPITest.class,
PathFilterTest.class,
diff --git
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/filter/AccessLogFilterTest.java
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/filter/AccessLogFilterTest.java
new file mode 100644
index 000000000..ee7d87091
--- /dev/null
+++
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/api/filter/AccessLogFilterTest.java
@@ -0,0 +1,633 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+package org.apache.hugegraph.unit.api.filter;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Serializable;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import java.util.zip.GZIPOutputStream;
+
+import org.apache.commons.configuration2.Configuration;
+import org.apache.commons.configuration2.PropertiesConfiguration;
+import org.apache.hugegraph.api.filter.AccessLogFilter;
+import org.apache.hugegraph.api.filter.DecompressInterceptor;
+import org.apache.hugegraph.api.filter.DecompressInterceptor.Decompress;
+import org.apache.hugegraph.api.filter.PathFilter;
+import org.apache.hugegraph.api.filter.RedirectFilter;
+import org.apache.hugegraph.api.filter.RedirectFilter.RedirectMasterRole;
+import org.apache.hugegraph.api.filter.RedirectFilterDynamicFeature;
+import org.apache.hugegraph.config.HugeConfig;
+import org.apache.hugegraph.config.ServerOptions;
+import org.apache.hugegraph.core.GraphManager;
+import org.apache.hugegraph.testutil.Assert;
+import org.apache.hugegraph.testutil.Whitebox;
+import org.apache.hugegraph.unit.BaseUnitTest;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.Filter;
+import org.apache.logging.log4j.core.Layout;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.LoggerConfig;
+import org.apache.logging.log4j.core.config.Property;
+import org.glassfish.grizzly.http.server.Request;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import jakarta.inject.Provider;
+import jakarta.ws.rs.Priorities;
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerResponseContext;
+import jakarta.ws.rs.container.ResourceInfo;
+import jakarta.ws.rs.core.FeatureContext;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.MultivaluedHashMap;
+import jakarta.ws.rs.core.UriInfo;
+import jakarta.ws.rs.ext.ReaderInterceptorContext;
+import sun.misc.Unsafe;
+
+/**
+ * Unit tests for AccessLogFilter
+ * Test scenarios:
+ * 1. Which requests qualify for the slow query log
+ * 2. Bounded request body capture and full replay to the resource method
+ * 3. Body capture is skipped when it can not or should not be recorded
+ * 4. The slow query log line carries the client IP and the body preview,
+ * and stays on a single line whatever the client sends
+ */
+public class AccessLogFilterTest extends BaseUnitTest {
+
+ private static final String TEST_LOGGER_NAME =
AccessLogFilter.class.getName();
+ private static final String SLOW_QUERY_PREFIX = "[Slow Query]";
+ private static final String CLIENT_IP = "10.0.0.9";
+
+ private AccessLogFilter filter;
+ private ContainerRequestContext requestContext;
+ private ContainerResponseContext responseContext;
+ private UriInfo uriInfo;
+ private Request request;
+ private ResourceInfo resourceInfo;
+ private TestAppender testAppender;
+ private LoggerContext loggerContext;
+ private org.apache.logging.log4j.core.config.Configuration
loggerConfiguration;
+ private LoggerConfig originalLoggerConfig;
+
+ @Before
+ public void setup() {
+ this.filter = new AccessLogFilter();
+ this.requestContext = Mockito.mock(ContainerRequestContext.class);
+ this.responseContext = Mockito.mock(ContainerResponseContext.class);
+ this.uriInfo = Mockito.mock(UriInfo.class);
+ this.request = Mockito.mock(Request.class);
+ this.resourceInfo = Mockito.mock(ResourceInfo.class);
+
+
Mockito.when(this.requestContext.getUriInfo()).thenReturn(this.uriInfo);
+ this.mockResourceMethod("plainResource");
+ Whitebox.setInternalState(this.filter, "resourceInfo",
this.resourceInfo);
+ Mockito.when(this.uriInfo.getPathParameters()).thenReturn(new
MultivaluedHashMap<>());
+ Mockito.when(this.responseContext.getStatus()).thenReturn(200);
+ Mockito.when(this.request.getRemoteAddr()).thenReturn(CLIENT_IP);
+
+ this.setConfig(1000L, 512);
+ this.setRemoteRequest(this.request);
+ Whitebox.setInternalState(this.filter, "managerProvider",
+ (Provider<GraphManager>)
AccessLogFilterTest::managerWithoutAuth);
+
+ this.testAppender = new TestAppender();
+ this.testAppender.start();
+ this.loggerContext = (LoggerContext) LogManager.getContext(false);
+ this.loggerConfiguration = this.loggerContext.getConfiguration();
+ /*
+ * log4j2.xml of this module already declares an (async) logger with
this
+ * name and addLogger() only adds absent names, so swap it for a
+ * synchronous one during the test and put it back afterwards
+ */
+ LoggerConfig existing =
this.loggerConfiguration.getLoggerConfig(TEST_LOGGER_NAME);
+ this.originalLoggerConfig =
TEST_LOGGER_NAME.equals(existing.getName()) ? existing : null;
+ if (this.originalLoggerConfig != null) {
+ this.loggerConfiguration.removeLogger(TEST_LOGGER_NAME);
+ }
+ LoggerConfig loggerConfig = new LoggerConfig(TEST_LOGGER_NAME,
Level.INFO, false);
+ loggerConfig.addAppender(this.testAppender, Level.INFO, null);
+ this.loggerConfiguration.addLogger(TEST_LOGGER_NAME, loggerConfig);
+ this.loggerContext.updateLoggers();
+ }
+
+ @After
+ public void teardown() {
+ this.loggerConfiguration.removeLogger(TEST_LOGGER_NAME);
+ if (this.originalLoggerConfig != null) {
+ this.loggerConfiguration.addLogger(TEST_LOGGER_NAME,
this.originalLoggerConfig);
+ }
+ this.loggerContext.updateLoggers();
+ this.testAppender.stop();
+ }
+
+ /**
+ * Test which requests are candidates for the slow query log
+ */
+ @Test
+ public void testNeedRecordLog() {
+ Assert.assertTrue(this.needRecordLog("POST", "gremlin"));
+ Assert.assertTrue(this.needRecordLog("GET", "gremlin"));
+ Assert.assertTrue(this.needRecordLog("POST",
"graphs/hugegraph/jobs/gremlin"));
+ Assert.assertTrue(this.needRecordLog("POST",
"graphs/hugegraph/cypher"));
+ Assert.assertTrue(this.needRecordLog("GET",
"graphs/hugegraph/graph/vertices"));
+ // PathFilter redirects requests under graphspaces/, they must stay
loggable
+ Assert.assertTrue(this.needRecordLog("POST",
"graphspaces/DEFAULT/graphs/hugegraph/cypher"));
+ Assert.assertTrue(this.needRecordLog("GET",
"graphspaces/DEFAULT/graphs/hugegraph/graph/vertices"));
+
+ Assert.assertFalse(this.needRecordLog("POST",
"graphs/hugegraph/graph/vertices/batch"));
+ Assert.assertFalse(this.needRecordLog("PUT",
"graphs/hugegraph/graph/edges/batch"));
+ Assert.assertFalse(this.needRecordLog("POST",
"graphs/hugegraph/schema/vertexlabels"));
+ Assert.assertFalse(this.needRecordLog("POST", "auth/login"));
+ Assert.assertFalse(this.needRecordLog("GET", "metrics"));
+ }
+
+ /**
+ * Test a short body is recorded as is and still readable by the resource
+ */
+ @Test
+ public void testCaptureBody_ShortBody() throws IOException {
+ String body = "{\"gremlin\":\"g.V().limit(1)\"}";
+ this.mockRequest("POST", "gremlin", body);
+
+ this.filter.filter(this.requestContext);
+
+ Assert.assertEquals(body, this.capturedBody());
+ Assert.assertEquals(body, this.replayedEntity());
+ }
+
+ /**
+ * Test only the configured prefix is recorded while the full body is
replayed
+ */
+ @Test
+ public void testCaptureBody_LongBodyIsTruncatedButReplayedInFull() throws
IOException {
+ this.setConfig(1000L, 16);
+ String body = "{\"gremlin\":\"g.V().hasLabel('person').limit(1)\"}";
+ this.mockRequest("POST", "gremlin", body);
+
+ this.filter.filter(this.requestContext);
+
+ Assert.assertEquals(body.substring(0, 16) + "...",
this.capturedBody());
+ Assert.assertEquals(body, this.replayedEntity());
+ }
+
+ /**
+ * Test a body whose size equals the limit is not marked as truncated
+ */
+ @Test
+ public void testCaptureBody_ExactLimitIsNotMarkedTruncated() throws
IOException {
+ this.setConfig(1000L, 8);
+ String body = "12345678";
+ this.mockRequest("PUT", "graphs/hugegraph/cypher", body);
+
+ this.filter.filter(this.requestContext);
+
+ Assert.assertEquals(body, this.capturedBody());
+ Assert.assertEquals(body, this.replayedEntity());
+ }
+
+ /**
+ * Test a multi-byte character cut by the limit is dropped, not replaced
+ */
+ @Test
+ public void testCaptureBody_MultiByteCharacterAtLimitIsDropped() throws
IOException {
+ // "é" is 2 bytes and "€" is 3 bytes in UTF-8, limit 4 cuts "€"
+ this.setConfig(1000L, 4);
+ String body = "é€ab";
+ this.mockRequest("POST", "gremlin", body);
+
+ this.filter.filter(this.requestContext);
+
+ Assert.assertEquals("é...", this.capturedBody());
+ Assert.assertEquals(body, this.replayedEntity());
+ }
+
+ @Test
+ public void testCaptureBody_UsesRequestCharset() throws IOException {
+ String body = "MATCH (张三) RETURN 张三";
+ InputStream entity = new
ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_16LE));
+ this.mockStreamRequest("POST", "graphs/hugegraph/cypher", entity);
+ Mockito.when(this.requestContext.getMediaType())
+ .thenReturn(MediaType.valueOf("application/json;
charset=UTF-16LE"));
+
+ this.filter.filter(this.requestContext);
+
+ Assert.assertEquals(body, this.capturedBody());
+ Assert.assertEquals(body,
this.replayedEntity(StandardCharsets.UTF_16LE));
+ }
+
+ /**
+ * Test line breaks in the body are kept in the preview and in the replay
+ */
+ @Test
+ public void testCaptureBody_KeepsLineBreaks() throws IOException {
+ String body = "g.V()\n .limit(1)\r\n";
+ this.mockRequest("POST", "graphs/hugegraph/cypher", body);
+
+ this.filter.filter(this.requestContext);
+
+ Assert.assertEquals(body, this.capturedBody());
+ Assert.assertEquals(body, this.replayedEntity());
+ }
+
+ /**
+ * Test a Content-Encoding header alone does not stop the capture, since
the
+ * endpoints that qualify for the slow query log read their entity as is
+ */
+ @Test
+ public void testCaptureBody_ContentEncodingHeaderIsIgnored() throws
IOException {
+ String body = "{\"gremlin\":\"g.V()\"}";
+ this.mockRequest("POST", "gremlin", body);
+
Mockito.when(this.requestContext.getHeaderString("Content-Encoding")).thenReturn("gzip");
+
+ this.filter.filter(this.requestContext);
+
+ Assert.assertEquals(body, this.capturedBody());
+ Assert.assertEquals(body, this.replayedEntity());
+ }
+
+ /**
+ * Test an empty body is handled
+ */
+ @Test
+ public void testCaptureBody_EmptyBody() throws IOException {
+ this.mockRequest("POST", "gremlin", "");
+
+ this.filter.filter(this.requestContext);
+
+ Assert.assertEquals("", this.capturedBody());
+ Assert.assertEquals("", this.replayedEntity());
+ }
+
+ /**
+ * Test nothing is read when the slow query log is disabled
+ */
+ @Test
+ public void testSkipBody_WhenSlowQueryLogDisabled() throws IOException {
+ this.setConfig(0L, 512);
+ this.mockRequest("POST", "gremlin", "{\"gremlin\":\"g.V()\"}");
+
+ this.filter.filter(this.requestContext);
+
+ this.verifyNoCapture();
+ }
+
+ /**
+ * Test nothing is read when the body limit is zero
+ */
+ @Test
+ public void testSkipBody_WhenBodyLimitIsZero() throws IOException {
+ this.setConfig(1000L, 0);
+ this.mockRequest("POST", "gremlin", "{\"gremlin\":\"g.V()\"}");
+
+ this.filter.filter(this.requestContext);
+
+ this.verifyNoCapture();
+ }
+
+ /**
+ * Test GET and DELETE requests are never read
+ */
+ @Test
+ public void testSkipBody_ForMethodsWithoutBody() throws IOException {
+ this.mockRequest("GET", "gremlin", "ignored");
+ this.filter.filter(this.requestContext);
+ this.verifyNoCapture();
+
+ this.mockRequest("DELETE", "graphs/hugegraph/cypher", "ignored");
+ this.filter.filter(this.requestContext);
+ this.verifyNoCapture();
+ }
+
+ /**
+ * Test loader batch imports are left untouched, even when gzip encoded
+ */
+ @Test
+ public void testSkipBody_ForBatchImport() throws IOException {
+ String body = "[{\"id\":1}]";
+ InputStream entity = new ByteArrayInputStream(gzip(body));
+ this.mockResourceMethod("decompressResource");
+ this.mockStreamRequest("POST",
"graphs/hugegraph/graph/vertices/batch", entity);
+
Mockito.when(this.requestContext.getHeaderString("Content-Encoding")).thenReturn("gzip");
+ this.filter.filter(this.requestContext);
+ this.verifyNoCapture();
+ Assert.assertEquals(body, decompress(entity));
+
+ this.mockRequest("PUT", "graphs/hugegraph/graph/edges/batch", "[{}]");
+ this.filter.filter(this.requestContext);
+ this.verifyNoCapture();
+ }
+
+ /**
+ * Test the entity of a resource that decodes it later is not read
+ */
+ @Test
+ public void testSkipBody_ForResourceThatDecodesEntity() throws IOException
{
+ this.mockResourceMethod("decompressResource");
+ this.mockRequest("POST", "gremlin", "compressed bytes");
+
+ this.filter.filter(this.requestContext);
+
+ Assert.assertEquals("<encoded>", this.capturedBody());
+ Mockito.verify(this.requestContext, Mockito.never()).getEntityStream();
+ Mockito.verify(this.requestContext,
Mockito.never()).setEntityStream(Mockito.any(InputStream.class));
+ }
+
+ @Test
+ public void testRedirectRunsAfterBodyCapture() {
+ this.mockResourceMethod("redirectResource");
+ FeatureContext context = Mockito.mock(FeatureContext.class);
+
+ new RedirectFilterDynamicFeature().configure(this.resourceInfo,
context);
+
+ Mockito.verify(context).register(RedirectFilter.class, Priorities.USER
+ 1);
+ }
+
+ /**
+ * Test the slow query log line contains the client IP and the body preview
+ */
+ @Test
+ public void testSlowQueryLog_ContainsClientIpAndBody() throws IOException {
+ this.mockRequest("POST", "gremlin", null);
+ this.mockElapsed(5000L);
+
Mockito.when(this.requestContext.getProperty(AccessLogFilter.REQUEST_BODY))
+ .thenReturn("{\"gremlin\":\"g.V()\"}");
+
+ this.filter.filter(this.requestContext, this.responseContext);
+
+ List<String> messages = this.slowQueryMessages();
+ Assert.assertEquals(1, messages.size());
+ String message = messages.get(0);
+ Assert.assertTrue(message, message.contains("ip=" + CLIENT_IP + ","));
+ Assert.assertTrue(message, message.contains("method=POST,"));
+ Assert.assertTrue(message, message.contains("path=gremlin,"));
+ Assert.assertTrue(message,
message.endsWith("body={\"gremlin\":\"g.V()\"}"));
+ }
+
+ /**
+ * Test the query string and a missing body are logged for GET
+ */
+ @Test
+ public void testSlowQueryLog_GetWithQuery() throws IOException {
+ this.mockRequest("GET", "graphs/hugegraph/graph/vertices", null);
+ Mockito.when(this.uriInfo.getRequestUri()).thenReturn(URI.create(
+
"http://localhost:8080/graphs/hugegraph/graph/vertices?label=person&limit=1"));
+ this.mockElapsed(5000L);
+
+ this.filter.filter(this.requestContext, this.responseContext);
+
+ List<String> messages = this.slowQueryMessages();
+ Assert.assertEquals(1, messages.size());
+ String message = messages.get(0);
+ Assert.assertTrue(message, message.contains("method=GET,"));
+ Assert.assertTrue(message,
message.contains("query=label=person&limit=1,"));
+ Assert.assertTrue(message, message.endsWith("body=null"));
+ }
+
+ /**
+ * Test line breaks in path, query and body can not forge a second log
entry
+ */
+ @Test
+ public void testSlowQueryLog_StaysOnOneLine() throws IOException {
+ this.mockRequest("GET", "graphs/hugegraph/graph/vertices", null);
+
Mockito.when(this.uriInfo.getPath()).thenReturn("graphs/hugegraph/graph/vertices\nforged
path");
+ // URI.getQuery() decodes %0A into a line feed
+ Mockito.when(this.uriInfo.getRequestUri()).thenReturn(URI.create(
+
"http://localhost:8080/graphs/hugegraph/graph/vertices?x=%0Aforged%20query"));
+
Mockito.when(this.requestContext.getProperty(AccessLogFilter.REQUEST_BODY)).thenReturn("a\r\nforged
body");
+ this.mockElapsed(5000L);
+
+ this.filter.filter(this.requestContext, this.responseContext);
+
+ List<String> messages = this.slowQueryMessages();
+ Assert.assertEquals(1, messages.size());
+ String message = messages.get(0);
+ Assert.assertFalse(message, message.contains("\n") ||
message.contains("\r"));
+ Assert.assertTrue(message,
message.contains("path=graphs/hugegraph/graph/vertices" +
+ "\\nforged path,"));
+ Assert.assertTrue(message, message.contains("query=x=\\nforged
query,"));
+ Assert.assertTrue(message, message.endsWith("body=a\\r\\nforged
body"));
+ }
+
+ /**
+ * Test the client IP falls back to a placeholder without a peer request
+ */
+ @Test
+ public void testSlowQueryLog_UnknownIpWithoutRequest() throws IOException {
+ this.setRemoteRequest(null);
+ this.mockRequest("POST", "gremlin", null);
+ this.mockElapsed(5000L);
+
+ this.filter.filter(this.requestContext, this.responseContext);
+
+ List<String> messages = this.slowQueryMessages();
+ Assert.assertEquals(1, messages.size());
+ Assert.assertTrue(messages.get(0), messages.get(0).contains("ip=" +
AccessLogFilter.UNKNOWN_IP + ","));
+ }
+
+ /**
+ * Test fast requests and disabled slow query log produce no log line
+ */
+ @Test
+ public void testSlowQueryLog_SkipsFastRequestAndDisabledLog() throws
IOException {
+ this.mockRequest("POST", "gremlin", null);
+ this.mockElapsed(0L);
+ this.filter.filter(this.requestContext, this.responseContext);
+ Assert.assertTrue(this.slowQueryMessages().isEmpty());
+
+ this.setConfig(0L, 512);
+ this.mockElapsed(5000L);
+ this.filter.filter(this.requestContext, this.responseContext);
+ Assert.assertTrue(this.slowQueryMessages().isEmpty());
+ }
+
+ private boolean needRecordLog(String method, String path) {
+ this.mockRequest(method, path, null);
+ return AccessLogFilter.needRecordLog(this.requestContext);
+ }
+
+ private void mockRequest(String method, String path, String body) {
+ Mockito.when(this.requestContext.getMethod()).thenReturn(method);
+ Mockito.when(this.uriInfo.getPath()).thenReturn(path);
+
Mockito.when(this.uriInfo.getRequestUri()).thenReturn(URI.create("http://localhost:8080/"
+ path));
+ if (body != null) {
+ InputStream entity = new
ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
+
Mockito.when(this.requestContext.getEntityStream()).thenReturn(entity);
+ }
+ }
+
+ private void mockStreamRequest(String method, String path, InputStream
entity) {
+ Mockito.when(this.requestContext.getMethod()).thenReturn(method);
+ Mockito.when(this.uriInfo.getPath()).thenReturn(path);
+
Mockito.when(this.uriInfo.getRequestUri()).thenReturn(URI.create("http://localhost:8080/"
+ path));
+ Mockito.when(this.requestContext.getEntityStream()).thenReturn(entity);
+ }
+
+ private static byte[] gzip(String body) throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try (GZIPOutputStream gzip = new GZIPOutputStream(output)) {
+ gzip.write(body.getBytes(StandardCharsets.UTF_8));
+ }
+ return output.toByteArray();
+ }
+
+ private static String decompress(InputStream entity) throws IOException {
+ ReaderInterceptorContext context =
Mockito.mock(ReaderInterceptorContext.class);
+ MultivaluedHashMap<String, String> headers = new
MultivaluedHashMap<>();
+ headers.putSingle("Content-Encoding", "gzip");
+ AtomicReference<InputStream> input = new AtomicReference<>(entity);
+ Mockito.when(context.getHeaders()).thenReturn(headers);
+ Mockito.when(context.getInputStream()).thenAnswer(invocation ->
input.get());
+ Mockito.doAnswer(invocation -> {
+ input.set(invocation.getArgument(0));
+ return null;
+ }).when(context).setInputStream(Mockito.any(InputStream.class));
+ Mockito.when(context.proceed()).thenAnswer(invocation ->
+ new String(input.get().readAllBytes(),
StandardCharsets.UTF_8));
+ return (String) new DecompressInterceptor().aroundReadFrom(context);
+ }
+
+ private void mockResourceMethod(String name) {
+ try {
+ Method method = AccessLogFilterTest.class.getMethod(name);
+
Mockito.when(this.resourceInfo.getResourceMethod()).thenReturn(method);
+ } catch (NoSuchMethodException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ /**
+ * Stand-in for a resource method that reads its entity as is
+ */
+ public void plainResource() {
+ // pass
+ }
+
+ /**
+ * Stand-in for a resource method whose entity is decoded by
DecompressInterceptor
+ */
+ @Decompress
+ public void decompressResource() {
+ // pass
+ }
+
+ @RedirectMasterRole
+ public void redirectResource() {
+ // pass
+ }
+
+ private void mockElapsed(long elapsed) {
+ Mockito.when(this.requestContext.getProperty(PathFilter.REQUEST_TIME))
+ .thenReturn(System.currentTimeMillis() - elapsed);
+ }
+
+ private void setConfig(long threshold, int bodyLimit) {
+ Configuration conf = new PropertiesConfiguration();
+ conf.setProperty(ServerOptions.SLOW_QUERY_LOG_TIME_THRESHOLD.name(),
threshold);
+ conf.setProperty(ServerOptions.SLOW_QUERY_LOG_BODY_LIMIT.name(),
bodyLimit);
+ HugeConfig config = new HugeConfig(conf);
+ Whitebox.setInternalState(this.filter, "configProvider",
(Provider<HugeConfig>) () -> config);
+ }
+
+ private void setRemoteRequest(Request request) {
+ Whitebox.setInternalState(this.filter, "requestProvider",
(Provider<Request>) () -> request);
+ }
+
+ private String capturedBody() {
+ ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
+
Mockito.verify(this.requestContext).setProperty(Mockito.eq(AccessLogFilter.REQUEST_BODY),
captor.capture());
+ return (String) captor.getValue();
+ }
+
+ private String replayedEntity() throws IOException {
+ return this.replayedEntity(StandardCharsets.UTF_8);
+ }
+
+ private String replayedEntity(Charset charset) throws IOException {
+ ArgumentCaptor<InputStream> captor =
ArgumentCaptor.forClass(InputStream.class);
+ Mockito.verify(this.requestContext).setEntityStream(captor.capture());
+ return new String(captor.getValue().readAllBytes(), charset);
+ }
+
+ private void verifyNoCapture() {
+ Mockito.verify(this.requestContext, Mockito.never()).getEntityStream();
+ Mockito.verify(this.requestContext,
Mockito.never()).setEntityStream(Mockito.any(InputStream.class));
+ Mockito.verify(this.requestContext, Mockito.never())
+ .setProperty(Mockito.eq(AccessLogFilter.REQUEST_BODY),
Mockito.any());
+ }
+
+ private List<String> slowQueryMessages() {
+ return this.testAppender.events().stream()
+ .map(event -> event.getMessage().getFormattedMessage())
+ .filter(message -> message.startsWith(SLOW_QUERY_PREFIX))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * GraphManager has no test friendly constructor, allocate one without an
+ * authenticator so that requireAuthentication() is false
+ */
+ private static GraphManager managerWithoutAuth() {
+ try {
+ Field field = Unsafe.class.getDeclaredField("theUnsafe");
+ field.setAccessible(true);
+ Unsafe unsafe = (Unsafe) field.get(null);
+ return (GraphManager) unsafe.allocateInstance(GraphManager.class);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private static class TestAppender extends AbstractAppender {
+
+ private final List<LogEvent> events;
+
+ protected TestAppender() {
+ super("AccessLogFilterTestAppender", (Filter) null,
+ (Layout<? extends Serializable>) null, false,
+ Property.EMPTY_ARRAY);
+ this.events = new ArrayList<>();
+ }
+
+ @Override
+ public void append(LogEvent event) {
+ this.events.add(event.toImmutable());
+ }
+
+ public List<LogEvent> events() {
+ return this.events;
+ }
+ }
+}