diff --git a/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/HttpApiV1Constants.java b/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/HttpApiV1Constants.java index 78c2e5c4b..832ae30ca 100644 --- a/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/HttpApiV1Constants.java +++ b/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/HttpApiV1Constants.java @@ -42,6 +42,8 @@ public final class HttpApiV1Constants { public static final String METRICS_PATH = "/monitor/metrics"; + public static final String DOCS_PATH = "/docs"; + public static final String REMOVED = "/removed"; private HttpApiV1Constants() {} diff --git a/dist/src/conf/dogma.json b/dist/src/conf/dogma.json index 09117cac2..3a62b7d62 100644 --- a/dist/src/conf/dogma.json +++ b/dist/src/conf/dogma.json @@ -37,5 +37,8 @@ }, "csrfTokenRequiredForThrift": null, "accessLogFormat": "common", - "authentication": null + "authentication": null, + "requestLog": { + "targetGroups": [ "API" ] + } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java index ac5504cc3..fbca5cbed 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java @@ -21,6 +21,7 @@ import static com.google.common.base.Strings.isNullOrEmpty; import static com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants.API_V0_PATH_PREFIX; import static com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants.API_V1_PATH_PREFIX; +import static com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants.DOCS_PATH; import static com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants.HEALTH_CHECK_PATH; import static com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants.METRICS_PATH; import static com.linecorp.centraldogma.server.auth.AuthProvider.BUILTIN_WEB_BASE_PATH; @@ -38,6 +39,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.Executor; @@ -88,6 +90,7 @@ import com.linecorp.armeria.server.ServerPort; import com.linecorp.armeria.server.ServiceNaming; import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.server.TransientServiceOption; import com.linecorp.armeria.server.auth.AuthService; import com.linecorp.armeria.server.auth.Authorizer; import com.linecorp.armeria.server.docs.DocService; @@ -96,6 +99,7 @@ import com.linecorp.armeria.server.file.HttpFile; import com.linecorp.armeria.server.healthcheck.HealthCheckService; import com.linecorp.armeria.server.logging.AccessLogWriter; +import com.linecorp.armeria.server.logging.LoggingService; import com.linecorp.armeria.server.metric.MetricCollectingService; import com.linecorp.armeria.server.metric.PrometheusExpositionService; import com.linecorp.armeria.server.thrift.THttpService; @@ -147,11 +151,11 @@ import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.binder.jvm.ClassLoaderMetrics; -import io.micrometer.core.instrument.binder.jvm.DiskSpaceMetrics; import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics; import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics; import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics; +import io.micrometer.core.instrument.binder.system.DiskSpaceMetrics; import io.micrometer.core.instrument.binder.system.FileDescriptorMetrics; import io.micrometer.core.instrument.binder.system.ProcessorMetrics; import io.micrometer.core.instrument.binder.system.UptimeMetrics; @@ -206,6 +210,8 @@ public static CentralDogma forConfig(File configFile) throws IOException { private PrometheusMeterRegistry meterRegistry; @Nullable private SessionManager sessionManager; + @Nullable + private JvmGcMetrics jvmGcMetrics; CentralDogma(CentralDogmaConfig cfg) { this.cfg = requireNonNull(cfg, "cfg"); @@ -385,13 +391,13 @@ private CommandExecutor startCommandExecutor( logger.info("Starting plugins on the leader replica .."); pluginsForLeaderOnly .start(cfg, pm, exec, meterRegistry, purgeWorker).handle((unused, cause) -> { - if (cause == null) { - logger.info("Started plugins on the leader replica."); - } else { - logger.error("Failed to start plugins on the leader replica..", cause); - } - return null; - }); + if (cause == null) { + logger.info("Started plugins on the leader replica."); + } else { + logger.error("Failed to start plugins on the leader replica..", cause); + } + return null; + }); } }; @@ -521,9 +527,67 @@ private Server startServer(ProjectManager pm, CommandExecutor executor, sb.service("/title", webAppTitleFile(cfg.webAppTitle(), SystemInfo.hostname()).asService()); - sb.service(HEALTH_CHECK_PATH, HealthCheckService.of()); + final RequestLogConfig requestLogConfig = cfg.requestLogConfig(); + boolean logHealthCheck = false; + boolean logMetrics = false; + if (requestLogConfig != null) { + final Function loggingService = + LoggingService.builder() + .logger(requestLogConfig.loggerName()) + .requestLogLevel(requestLogConfig.requestLogLevel()) + .successfulResponseLogLevel(requestLogConfig.successfulResponseLogLevel()) + .failureResponseLogLevel(requestLogConfig.failureResponseLogLevel()) + .successSamplingRate(requestLogConfig.successSamplingRate()) + .failureSamplingRate(requestLogConfig.failureSamplingRate()) + .newDecorator(); + final Set requestLogGroups = requestLogConfig.targetGroups(); + if (requestLogGroups.contains(RequestLogGroup.ALL)) { + sb.decorator(loggingService); + logHealthCheck = true; + logMetrics = true; + } else { + for (RequestLogGroup logGroup : requestLogGroups) { + switch (logGroup) { + case API: + sb.decoratorUnder("/api", loggingService); + break; + case METRICS: + sb.decorator(METRICS_PATH, loggingService); + logMetrics = true; + break; + case HEALTH: + sb.decorator(HEALTH_CHECK_PATH, loggingService); + logHealthCheck = true; + break; + case DOCS: + sb.decoratorUnder(DOCS_PATH, loggingService); + break; + case WEB: + for (String webResources : ImmutableList.of("/web", "/vendor", + "/scripts", "/styles")) { + sb.decoratorUnder(webResources, loggingService); + } + break; + case ALL: + // Should never reach here. + throw new Error(); + } + } + } + } + + final HealthCheckService healthCheckService; + if (logHealthCheck) { + healthCheckService = + HealthCheckService.builder() + .transientServiceOptions(TransientServiceOption.WITH_SERVICE_LOGGING) + .build(); + } else { + healthCheckService = HealthCheckService.of(); + } + sb.service(HEALTH_CHECK_PATH, healthCheckService); - sb.serviceUnder("/docs/", + sb.serviceUnder(DOCS_PATH, DocService.builder() .exampleHeaders(CentralDogmaService.class, HttpHeaders.of(HttpHeaderNames.AUTHORIZATION, @@ -532,7 +596,7 @@ private Server startServer(ProjectManager pm, CommandExecutor executor, configureHttpApi(sb, pm, executor, watchService, mds, authProvider, sessionManager); - configureMetrics(sb, meterRegistry); + configureMetrics(sb, meterRegistry, logMetrics); // Configure access log format. final String accessLogFormat = cfg.accessLogFormat(); @@ -776,9 +840,18 @@ private static Function contentEncodingDec .build(delegate); } - private void configureMetrics(ServerBuilder sb, PrometheusMeterRegistry registry) { + private void configureMetrics(ServerBuilder sb, PrometheusMeterRegistry registry, boolean logMetrics) { sb.meterRegistry(registry); - sb.service(METRICS_PATH, new PrometheusExpositionService(registry.getPrometheusRegistry())); + final PrometheusExpositionService expositionService; + if (logMetrics) { + expositionService = PrometheusExpositionService.builder(registry.getPrometheusRegistry()) + .transientServiceOptions( + TransientServiceOption.WITH_SERVICE_LOGGING) + .build(); + } else { + expositionService = PrometheusExpositionService.of(registry.getPrometheusRegistry()); + } + sb.service(METRICS_PATH, expositionService); sb.decorator(MetricCollectingService.newDecorator(MeterIdPrefixFunction.ofDefault("api"))); // Bind system metrics. @@ -787,7 +860,8 @@ private void configureMetrics(ServerBuilder sb, PrometheusMeterRegistry registry new ClassLoaderMetrics().bindTo(registry); new UptimeMetrics().bindTo(registry); new DiskSpaceMetrics(cfg.dataDir()).bindTo(registry); - new JvmGcMetrics().bindTo(registry); + jvmGcMetrics = new JvmGcMetrics(); + jvmGcMetrics.bindTo(registry); new JvmMemoryMetrics().bindTo(registry); new JvmThreadMetrics().bindTo(registry); @@ -817,6 +891,11 @@ private void doStop() { meterRegistry = null; } + if (jvmGcMetrics != null) { + jvmGcMetrics.close(); + jvmGcMetrics = null; + } + logger.info("Stopping the Central Dogma .."); if (!doStop(server, executor, pm, repositoryWorker, purgeWorker, sessionManager)) { logger.warn("Stopped the Central Dogma with failure."); diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java index c32f8be68..02c986e74 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java @@ -121,6 +121,8 @@ public final class CentralDogmaBuilder { private Object authProviderProperties; private int writeQuota; private int timeWindowSeconds; + @Nullable + private RequestLogConfig requestLogConfig; /** * Creates a new builder with the specified data directory. @@ -520,6 +522,16 @@ public CentralDogmaBuilder writeQuotaPerRepository(int writeQuota, int timeWindo return this; } + /** + * Sets the {@link RequestLogConfig} to log requests, responses and exceptions in detail. + * If unspecified, request logging is disabled. + */ + public CentralDogmaBuilder requestLogConfig(RequestLogConfig requestLogConfig) { + requireNonNull(requestLogConfig, "requestLogConfig"); + this.requestLogConfig = requestLogConfig; + return this; + } + /** * Returns a newly-created {@link CentralDogma} server. */ @@ -553,6 +565,6 @@ private CentralDogmaConfig buildConfig() { maxRemovedRepositoryAgeMillis, gracefulShutdownTimeout, webAppEnabled, webAppTitle, mirroringEnabled, numMirroringThreads, maxNumFilesPerMirror, maxNumBytesPerMirror, replicationConfig, - null, accessLogFormat, authCfg, quotaConfig); + null, accessLogFormat, authCfg, quotaConfig, requestLogConfig); } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java index bdd205843..20688e042 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java @@ -138,6 +138,9 @@ public final class CentralDogmaConfig { @Nullable private final QuotaConfig writeQuotaPerRepository; + @Nullable + private final RequestLogConfig requestLogConfig; + CentralDogmaConfig( @JsonProperty(value = "dataDir", required = true) File dataDir, @JsonProperty(value = "ports", required = true) @@ -165,7 +168,8 @@ public final class CentralDogmaConfig { @JsonProperty("csrfTokenRequiredForThrift") @Nullable Boolean csrfTokenRequiredForThrift, @JsonProperty("accessLogFormat") @Nullable String accessLogFormat, @JsonProperty("authentication") @Nullable AuthConfig authConfig, - @JsonProperty("writeQuotaPerRepository") @Nullable QuotaConfig writeQuotaPerRepository) { + @JsonProperty("writeQuotaPerRepository") @Nullable QuotaConfig writeQuotaPerRepository, + @JsonProperty("requestLog") @Nullable RequestLogConfig requestLogConfig) { this.dataDir = requireNonNull(dataDir, "dataDir"); this.ports = ImmutableList.copyOf(requireNonNull(ports, "ports")); @@ -219,6 +223,7 @@ public final class CentralDogmaConfig { ports.stream().anyMatch(ServerPort::hasProxyProtocol)); this.writeQuotaPerRepository = writeQuotaPerRepository; + this.requestLogConfig = requestLogConfig; } /** @@ -456,6 +461,15 @@ public QuotaConfig writeQuotaPerRepository() { return writeQuotaPerRepository; } + /** + * Returns the {@link RequestLogConfig}. + */ + @JsonProperty("requestLog") + @Nullable + public RequestLogConfig requestLogConfig() { + return requestLogConfig; + } + @Override public String toString() { try { diff --git a/server/src/main/java/com/linecorp/centraldogma/server/RequestLogConfig.java b/server/src/main/java/com/linecorp/centraldogma/server/RequestLogConfig.java new file mode 100644 index 000000000..36ed8e830 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/RequestLogConfig.java @@ -0,0 +1,175 @@ +/* + * Copyright 2022 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.centraldogma.server; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static java.util.Objects.requireNonNull; + +import java.util.Objects; +import java.util.Set; + +import javax.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.logging.LogLevel; + +/** + * A request log configuration. + */ +public final class RequestLogConfig { + + private final Set targetGroups; + + private final LogLevel requestLogLevel; + private final LogLevel successfulResponseLogLevel; + private final LogLevel failureResponseLogLevel; + + private final float successSamplingRate; + private final float failureSamplingRate; + private final String loggerName; + + /** + * Create a new instance. + * + * @param targetGroups the target {@link RequestLogGroup}s which should be logged. + * @param loggerName the logger name to use when logging. + * @param requestLogLevel the {@link LogLevel} to use when logging incoming requests. + * @param successfulResponseLogLevel the {@link LogLevel} to use when logging successful responses + * (e.g., an HTTP status is less than 400). + * @param failureResponseLogLevel the {@link LogLevel} to use when logging failed responses + * (e.g., an HTTP status is 4xx or 5xx). + * @param successSamplingRate The rate at which to sample success requests to log. Any number between + * {@code 0.0} and {@code 1.0} will cause a random sample of the requests to + * be logged. + * @param failureSamplingRate The rate at which to sample failed requests to log. Any number between + * {@code 0.0} and {@code 1.0} will cause a random sample of the requests to + * be logged. + */ + public RequestLogConfig( + @JsonProperty(value = "targetGroups", required = true) Set targetGroups, + @JsonProperty("loggerName") @Nullable String loggerName, + @JsonProperty("requestLogLevel") @Nullable LogLevel requestLogLevel, + @JsonProperty("successfulResponseLogLevel") @Nullable LogLevel successfulResponseLogLevel, + @JsonProperty("failureResponseLogLevel") @Nullable LogLevel failureResponseLogLevel, + @JsonProperty("successSamplingRate") @Nullable Float successSamplingRate, + @JsonProperty("failureSamplingRate") @Nullable Float failureSamplingRate) { + this.targetGroups = requireNonNull(targetGroups, "targetGroups"); + this.loggerName = firstNonNull(loggerName, "com.linecorp.centraldogma.request.log"); + this.requestLogLevel = firstNonNull(requestLogLevel, LogLevel.TRACE); + this.successfulResponseLogLevel = firstNonNull(successfulResponseLogLevel, LogLevel.TRACE); + this.failureResponseLogLevel = firstNonNull(failureResponseLogLevel, LogLevel.WARN); + this.successSamplingRate = firstNonNull(successSamplingRate, 1.0f); + this.failureSamplingRate = firstNonNull(failureSamplingRate, 1.0f); + } + + /** + * Returns the target {@link RequestLogGroup}s which should be logged. + */ + @JsonProperty + public Set targetGroups() { + return targetGroups; + } + + /** + * Returns the logger name to use when logging. + */ + @JsonProperty + public String loggerName() { + return loggerName; + } + + /** + * Returns the {@link LogLevel} to use when logging requests. + */ + @JsonProperty + public LogLevel requestLogLevel() { + return requestLogLevel; + } + + /** + * Returns the {@link LogLevel} to use when logging successful responses + * (e.g., an HTTP status is less than 400). + */ + @JsonProperty + public LogLevel successfulResponseLogLevel() { + return successfulResponseLogLevel; + } + + /** + * Returns the {@link LogLevel} to use when logging failed responses + * (e.g., an HTTP status is 4xx or 5xx). + */ + @JsonProperty + public LogLevel failureResponseLogLevel() { + return failureResponseLogLevel; + } + + /** + * Returns the rate at which to sample success requests to log. Any number between {@code 0.0} and + * {@code 1.0} will cause a random sample of the requests to be logged. + */ + @JsonProperty + public float successSamplingRate() { + return successSamplingRate; + } + + /** + * The rate at which to sample failed requests to log. Any number between {@code 0.0} and {@code 1.0} + * will cause a random sample of the requests to be logged. + */ + @JsonProperty + public float failureSamplingRate() { + return failureSamplingRate; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RequestLogConfig)) { + return false; + } + final RequestLogConfig that = (RequestLogConfig) o; + return targetGroups.equals(that.targetGroups) && + requestLogLevel == that.requestLogLevel && + successfulResponseLogLevel == that.successfulResponseLogLevel && + failureResponseLogLevel == that.failureResponseLogLevel && + Float.compare(that.successSamplingRate, successSamplingRate) == 0 && + Float.compare(that.failureSamplingRate, failureSamplingRate) == 0; + } + + @Override + public int hashCode() { + return Objects.hash(targetGroups, requestLogLevel, successfulResponseLogLevel, failureResponseLogLevel, + successSamplingRate, failureSamplingRate); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("targetGroups", targetGroups) + .add("requestLogLevel", requestLogLevel) + .add("successfulResponseLogLevel", successfulResponseLogLevel) + .add("failureResponseLogLevel", failureResponseLogLevel) + .add("successSamplingRate", successSamplingRate) + .add("failureSamplingRate", failureSamplingRate) + .toString(); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/RequestLogGroup.java b/server/src/main/java/com/linecorp/centraldogma/server/RequestLogGroup.java new file mode 100644 index 000000000..2ac905cab --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/RequestLogGroup.java @@ -0,0 +1,53 @@ +/* + * Copyright 2022 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.centraldogma.server; + +import com.linecorp.armeria.server.docs.DocService; +import com.linecorp.armeria.server.healthcheck.HealthCheckService; +import com.linecorp.armeria.server.metric.PrometheusExpositionService; +import com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants; + +/** + * A target service group which requests are logged. + */ +public enum RequestLogGroup { + /** + * The services served under {@code "/api"}. + */ + API, + /** + * The {@link PrometheusExpositionService} served at {@value HttpApiV1Constants#METRICS_PATH}. + */ + METRICS, + /** + * The {@link HealthCheckService} served at {@value HttpApiV1Constants#HEALTH_CHECK_PATH}. + */ + HEALTH, + /** + * The {@link DocService} served under {@value HttpApiV1Constants#DOCS_PATH}. + */ + DOCS, + /** + * The static file services served under {@code "/web"}, {@code "/vendor"}, {@code "/scripts"} and + * {@code "/styles"}. + */ + WEB, + /** + * The group that represents all {@link RequestLogGroup}s. + */ + ALL; +} diff --git a/server/src/test/java/com/linecorp/centraldogma/server/RequestLogTest.java b/server/src/test/java/com/linecorp/centraldogma/server/RequestLogTest.java new file mode 100644 index 000000000..f19609c56 --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/RequestLogTest.java @@ -0,0 +1,219 @@ +/* + * Copyright 2022 LINE Corporation + * + * LINE Corporation 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: + * + * https://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 com.linecorp.centraldogma.server; + +import static net.javacrumbs.jsonunit.core.Option.IGNORING_ARRAY_ORDER; +import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.slf4j.LoggerFactory; + +import com.google.common.collect.ImmutableSet; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.WebClientBuilder; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.logging.LogLevel; +import com.linecorp.centraldogma.client.CentralDogma; +import com.linecorp.centraldogma.internal.Jackson; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +class RequestLogTest { + private static String LOGGER_NAME = "com.linecorp.centraldogma.test"; + + private ListAppender logWatcher; + + private void setUp() { + logWatcher = new ListAppender<>(); + logWatcher.start(); + final Logger logger = (Logger) LoggerFactory.getLogger(LOGGER_NAME); + logger.setLevel(Level.DEBUG); + logger.addAppender(logWatcher); + } + + @Test + void jsonSerialization() throws Exception { + final String json = "{\n" + + " \"targetGroups\": [ \"API\", \"HEALTH\" ],\n" + + " \"loggerName\": \"centraldogma.test\",\n" + + " \"requestLogLevel\": \"DEBUG\",\n" + + " \"successfulResponseLogLevel\": \"DEBUG\",\n" + + " \"failureResponseLogLevel\": \"ERROR\",\n" + + " \"successSamplingRate\": 0.4,\n" + + " \"failureSamplingRate\": 0.6\n" + + '}'; + final RequestLogConfig requestLogConfig = Jackson.readValue(json, RequestLogConfig.class); + assertThat(requestLogConfig.targetGroups()) + .containsExactlyInAnyOrder(RequestLogGroup.API, RequestLogGroup.HEALTH); + assertThat(requestLogConfig.loggerName()).isEqualTo("centraldogma.test"); + assertThat(requestLogConfig.requestLogLevel()).isEqualTo(LogLevel.DEBUG); + assertThat(requestLogConfig.successfulResponseLogLevel()).isEqualTo(LogLevel.DEBUG); + assertThat(requestLogConfig.failureResponseLogLevel()).isEqualTo(LogLevel.ERROR); + assertThat(requestLogConfig.successSamplingRate()).isEqualTo(0.4f); + assertThat(requestLogConfig.failureSamplingRate()).isEqualTo(0.6f); + assertThatJson(Jackson.writeValueAsString(requestLogConfig)) + .when(IGNORING_ARRAY_ORDER) + .isEqualTo(json); + } + + @CsvSource({ "API, /api/v1/projects", "HEALTH, /monitor/l7check", "METRICS, /monitor/metrics", + "DOCS, /docs/index.html", "WEB, /styles/main.css" }) + @ParameterizedTest + void shouldLogRequests(RequestLogGroup logGroup, String path) throws Exception { + final CentralDogmaExtension dogmaExtension = newDogmaExtension(logGroup); + final BlockingWebClient client = dogmaExtension.httpClient().blocking(); + setUp(); + assertThat(client.get(path).status()).isEqualTo(HttpStatus.OK); + + await().untilAsserted(() -> { + assertThat(logWatcher.list) + .anyMatch(event -> { + return event.getLevel().equals(Level.DEBUG) && + event.getMessage().contains("{} Request: {}") && + event.getFormattedMessage().contains(path); + }); + }); + dogmaExtension.stop(); + } + + @CsvSource({ "/api/v1/projects", "/monitor/l7check", "/monitor/metrics", + "/docs/index.html", "/styles/main.css" }) + @ParameterizedTest + void shouldLogAllRequests(String path) throws Exception { + final CentralDogmaExtension dogmaExtension = newDogmaExtension(RequestLogGroup.ALL); + final BlockingWebClient client = dogmaExtension.httpClient().blocking(); + setUp(); + assertThat(client.get(path).status()).isEqualTo(HttpStatus.OK); + + await().untilAsserted(() -> { + assertThat(logWatcher.list) + .anyMatch(event -> { + return event.getLevel().equals(Level.DEBUG) && + event.getMessage().contains("{} Request: {}") && + event.getFormattedMessage().contains(path); + }); + }); + dogmaExtension.stop(); + } + + @Test + void noSamplingSuccess() throws Exception { + final CentralDogmaExtension dogmaExtension = newDogmaExtension(0, 1.0f, RequestLogGroup.ALL); + final BlockingWebClient client = dogmaExtension.httpClient().blocking(); + setUp(); + assertThat(client.get("/api/v1/projects").status()).isEqualTo(HttpStatus.OK); + + Thread.sleep(2000); + assertThat(logWatcher.list) + .noneMatch(event -> { + return event.getLevel().equals(Level.DEBUG) && + event.getMessage().contains("{} Request: {}") && + event.getFormattedMessage().contains("/api/v1/projects"); + }); + dogmaExtension.stop(); + } + + @Test + void noSamplingFailure() throws Exception { + final CentralDogmaExtension dogmaExtension = newDogmaExtension(1.0f, 0, RequestLogGroup.ALL); + final BlockingWebClient unauthorizedClient = WebClient.of(dogmaExtension.httpClient().uri()).blocking(); + setUp(); + assertThat(unauthorizedClient.get("/api/v1/projects").status()).isEqualTo(HttpStatus.UNAUTHORIZED); + + Thread.sleep(2000); + assertThat(logWatcher.list) + .noneMatch(event -> { + return event.getLevel().equals(Level.DEBUG) && + event.getMessage().contains("{} Request: {}") && + event.getFormattedMessage().contains("/api/v1/projects"); + }); + dogmaExtension.stop(); + } + + @CsvSource({ "API, /api/v1/projects", "HEALTH, /monitor/l7check", "METRICS, /monitor/metrics", + "DOCS, /docs/index.html", "WEB, /styles/main.css" }) + @ParameterizedTest + void shouldNotLogTargetRequests(RequestLogGroup logGroup, String path) throws Exception { + final RequestLogGroup[] logGroups = + Arrays.stream(RequestLogGroup.values()) + .filter(group -> group != RequestLogGroup.ALL && group != logGroup) + .toArray(RequestLogGroup[]::new); + final CentralDogmaExtension dogmaExtension = newDogmaExtension(logGroups); + final BlockingWebClient client = dogmaExtension.httpClient().blocking(); + setUp(); + + assertThat(client.get(path).status()).isEqualTo(HttpStatus.OK); + Thread.sleep(2000); + assertThat(logWatcher.list) + .noneMatch(event -> { + return event.getLevel().equals(Level.DEBUG) && + event.getMessage().contains("{} Request: {}") && + event.getFormattedMessage().contains(path); + }); + dogmaExtension.stop(); + } + + private CentralDogmaExtension newDogmaExtension(RequestLogGroup... logGroup) throws Exception { + return newDogmaExtension(1.0f, 1.0f, logGroup); + } + + private CentralDogmaExtension newDogmaExtension(float successSamplingRate, float failureSamplingRate, + RequestLogGroup... logGroup) throws Exception { + final CentralDogmaExtension dogmaExtension = new CentralDogmaExtension() { + @Override + protected void configure(CentralDogmaBuilder builder) { + builder.webAppEnabled(true); + final RequestLogConfig requestLogConfig = + new RequestLogConfig(ImmutableSet.copyOf(logGroup), + LOGGER_NAME, + LogLevel.DEBUG, + LogLevel.INFO, + LogLevel.ERROR, + successSamplingRate, + failureSamplingRate); + builder.requestLogConfig(requestLogConfig); + } + + @Override + protected void configureHttpClient(WebClientBuilder builder) { + builder.addHeader(HttpHeaderNames.AUTHORIZATION, "Bearer anonymous"); + } + + @Override + protected void scaffold(CentralDogma client) { + client.createProject("foo").join(); + } + }; + dogmaExtension.start(); + dogmaExtension.before(null); + setUp(); + return dogmaExtension; + } +} diff --git a/site/src/sphinx/setup-configuration.rst b/site/src/sphinx/setup-configuration.rst index ddbaaba7c..3132c270b 100644 --- a/site/src/sphinx/setup-configuration.rst +++ b/site/src/sphinx/setup-configuration.rst @@ -52,7 +52,10 @@ defaults: "timeWindowSeconds": 1 }, "accessLogFormat": "common", - "authentication": null + "authentication": null, + "requestLog": { + "targetGroups": [ "API" ] + } } Core properties @@ -229,6 +232,56 @@ Core properties - the authentication configuration. If ``null``, the authentication is disabled. See :ref:`auth` to learn how to configure the authentication layer. +- ``requestLog`` + + - the request log configuration. If this option is enabled, request and response duration, headers and status + are logged. If a request is failed with an exception, the stack trace of the exception is logged as well. + + - ``targetGroups`` (string array) + + - the target :apiplural:`RequestLogGroup` which should be logged. + + - ``API`` - the services served under ``/api`` + - ``METRICS`` - the Prometheus exposition service served at ``/monitor/metrics`` + - ``HEALTH`` - the health check service served at ``/monitor/l7check`` + - ``DOCS`` - the documentation service served under ``/docs`` + - ``WEB`` - the static file services served under ``/web``, ``/vendor``, ``/scripts`` and ``/styles``. + - ``ALL`` - the group that represents all groups. + + - ``loggerName`` (string) + + - the logger name to use when logging. + If ``null``, defaults to ``com.linecorp.centraldogma.request.log`` + + - ``requestLogLevel`` (string) + + - the :api:`com.linecorp.armeria.common.logging.LogLevel` to use when logging incoming requests. + If ``null``, defaults to ``TRACE`` + + - ``successfulResponseLogLevel`` (string) + + - the :api:`com.linecorp.armeria.common.logging.LogLevel` to use when logging successful responses (e.g., an HTTP status is less than 400). + If ``null``, defaults to ``TRACE`` + + - ``failureResponseLogLevel`` (string) + + - the :api:`com.linecorp.armeria.common.logging.LogLevel` to use when logging failed responses (e.g., an HTTP status is 4xx or 5xx). + If ``null``, defaults to ``WARN`` + + - ``successSamplingRate`` (float) + + - the rate at which to sample success requests to log. Any number between + ``0.0`` and ``1.0`` will cause a random sample of the requests to + be logged. + If ``null``, defaults to ``1.0`` + + - ``failureSamplingRate`` (float) + + - the rate at which to sample failed requests to log. Any number between + ``0.0`` and ``1.0`` will cause a random sample of the requests to + be logged. + If ``null``, defaults to ``1.0`` + .. _replication: Configuring replication