Skip to content

Commit 37cfb37

Browse files
authored
Introduce LoggerService (#1144)
Motivation: To enable runtime logger level management via HTTP endpoints, a dedicated `LoggerService` is needed. Modifications: - Implemented `LoggerService` with HTTP API endpoints to get and update logger levels. - Modified `CentralDogmaRuleDelegate` to return `CsrfToken.ANONYMOUS` instead of `null` as a default token. Result: - Users can now inspect and update logger levels at runtime via HTTP.
1 parent 6b906ea commit 37cfb37

File tree

4 files changed

+245
-7
lines changed

4 files changed

+245
-7
lines changed

server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
import com.linecorp.centraldogma.server.internal.api.CredentialServiceV1;
141141
import com.linecorp.centraldogma.server.internal.api.GitHttpService;
142142
import com.linecorp.centraldogma.server.internal.api.HttpApiExceptionHandler;
143+
import com.linecorp.centraldogma.server.internal.api.LoggerService;
143144
import com.linecorp.centraldogma.server.internal.api.MetadataApiService;
144145
import com.linecorp.centraldogma.server.internal.api.MirroringServiceV1;
145146
import com.linecorp.centraldogma.server.internal.api.ProjectServiceV1;
@@ -915,7 +916,8 @@ private void configureHttpApi(ServerBuilder sb,
915916
.annotatedService(new ServerStatusService(executor, statusManager))
916917
.annotatedService(new ProjectServiceV1(projectApiManager, executor))
917918
.annotatedService(new RepositoryServiceV1(executor, mds, encryptionStorageManager))
918-
.annotatedService(new CredentialServiceV1(projectApiManager, executor));
919+
.annotatedService(new CredentialServiceV1(projectApiManager, executor))
920+
.annotatedService(new LoggerService());
919921

920922
if (GIT_MIRROR_ENABLED) {
921923
mirrorRunner = new MirrorRunner(projectApiManager, executor, cfg, meterRegistry,
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* Copyright 2025 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
package com.linecorp.centraldogma.server.internal.api;
17+
18+
import java.util.List;
19+
20+
import javax.annotation.Nullable;
21+
22+
import org.slf4j.LoggerFactory;
23+
24+
import com.fasterxml.jackson.annotation.JsonProperty;
25+
import com.fasterxml.jackson.databind.JsonNode;
26+
import com.google.common.collect.ImmutableList;
27+
import com.google.common.collect.ImmutableList.Builder;
28+
29+
import com.linecorp.armeria.common.HttpResponse;
30+
import com.linecorp.armeria.common.HttpStatus;
31+
import com.linecorp.armeria.common.MediaType;
32+
import com.linecorp.armeria.server.annotation.ConsumesJson;
33+
import com.linecorp.armeria.server.annotation.Get;
34+
import com.linecorp.armeria.server.annotation.Param;
35+
import com.linecorp.armeria.server.annotation.ProducesJson;
36+
import com.linecorp.armeria.server.annotation.Put;
37+
import com.linecorp.centraldogma.server.internal.api.auth.RequiresSystemAdministrator;
38+
39+
import ch.qos.logback.classic.Level;
40+
import ch.qos.logback.classic.Logger;
41+
import ch.qos.logback.classic.LoggerContext;
42+
43+
@RequiresSystemAdministrator
44+
@ProducesJson
45+
public class LoggerService {
46+
47+
private static final org.slf4j.Logger logger = LoggerFactory.getLogger(LoggerService.class);
48+
49+
private static final List<String> LEVELS = ImmutableList.of(
50+
"ALL", "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "OFF");
51+
52+
private final LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
53+
54+
@Put("/loggers/{logger}")
55+
@ConsumesJson
56+
public HttpResponse setLogLevel(@Param("logger") String loggerName, JsonNode jsonNode) {
57+
final Logger logger = loggerContext.exists(loggerName);
58+
if (logger == null) {
59+
return HttpResponse.of(HttpStatus.NOT_FOUND, MediaType.PLAIN_TEXT_UTF_8,
60+
"Logger not found: " + loggerName);
61+
}
62+
final JsonNode levelNode = jsonNode.get("level");
63+
if (levelNode == null) {
64+
return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8,
65+
"Missing 'level' field in request body.");
66+
}
67+
68+
if (levelNode.isNull()) {
69+
logger.setLevel(null);
70+
LoggerService.logger.info("Set log level of '{}' to null. effectiveLevel='{}'",
71+
loggerName, logger.getEffectiveLevel());
72+
return HttpResponse.ofJson(LoggerInfo.of(logger));
73+
}
74+
75+
if (!levelNode.isTextual()) {
76+
return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8,
77+
"'level' field must be a string. Found: " + levelNode.getNodeType());
78+
}
79+
80+
final String level = levelNode.asText();
81+
final String upperCase = level.toUpperCase();
82+
if (!LEVELS.contains(upperCase)) {
83+
return HttpResponse.of(HttpStatus.BAD_REQUEST, MediaType.PLAIN_TEXT_UTF_8,
84+
"Invalid log level: " + level + ". Valid levels are: " + LEVELS);
85+
}
86+
logger.setLevel(Level.toLevel(upperCase));
87+
LoggerService.logger.info("Set log level of '{}' to '{}'.", loggerName, upperCase);
88+
return HttpResponse.ofJson(LoggerInfo.of(logger));
89+
}
90+
91+
@Get("/loggers/{logger}")
92+
public HttpResponse getLogLevel(@Param("logger") String loggerName) {
93+
final Logger logger = loggerContext.exists(loggerName);
94+
if (logger == null) {
95+
return HttpResponse.of(HttpStatus.NOT_FOUND, MediaType.PLAIN_TEXT_UTF_8,
96+
"Logger not found: " + loggerName);
97+
}
98+
return HttpResponse.ofJson(LoggerInfo.of(logger));
99+
}
100+
101+
@Get("/loggers")
102+
@ProducesJson
103+
public HttpResponse getLogLevels() {
104+
final Builder<LoggerInfo> builder = ImmutableList.builder();
105+
for (Logger logger : loggerContext.getLoggerList()) {
106+
builder.add(LoggerInfo.of(logger));
107+
}
108+
return HttpResponse.ofJson(builder.build());
109+
}
110+
111+
private static final class LoggerInfo {
112+
113+
static LoggerInfo of(Logger logger) {
114+
return new LoggerInfo(
115+
logger.getName(),
116+
logger.getLevel() != null ? logger.getLevel().toString() : null,
117+
logger.getEffectiveLevel().toString());
118+
}
119+
120+
private final String name;
121+
@Nullable
122+
private final String level;
123+
private final String effectiveLevel;
124+
125+
LoggerInfo(String name, @Nullable String level, String effectiveLevel) {
126+
this.name = name;
127+
this.level = level;
128+
this.effectiveLevel = effectiveLevel;
129+
}
130+
131+
@JsonProperty("name")
132+
String name() {
133+
return name;
134+
}
135+
136+
@Nullable
137+
@JsonProperty("level")
138+
String level() {
139+
return level;
140+
}
141+
142+
@JsonProperty("effectiveLevel")
143+
String effectiveLevel() {
144+
return effectiveLevel;
145+
}
146+
}
147+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright 2025 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
package com.linecorp.centraldogma.server.internal.api;
17+
18+
import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson;
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.api.extension.RegisterExtension;
23+
24+
import com.linecorp.armeria.common.HttpMethod;
25+
import com.linecorp.armeria.common.HttpStatus;
26+
import com.linecorp.armeria.common.MediaType;
27+
import com.linecorp.armeria.common.RequestHeaders;
28+
import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension;
29+
30+
class LoggerServiceTest {
31+
32+
@RegisterExtension
33+
static final CentralDogmaExtension dogma = new CentralDogmaExtension();
34+
35+
@Test
36+
void logLevel() {
37+
assertThat(dogma.blockingHttpClient().get("/api/v1/loggers/invalid.package").status())
38+
.isSameAs(HttpStatus.NOT_FOUND);
39+
assertThatJson(dogma.blockingHttpClient().get("/api/v1/loggers/com.linecorp").contentUtf8())
40+
.isEqualTo("{\"name\":\"com.linecorp\",\"level\":\"DEBUG\",\"effectiveLevel\":\"DEBUG\"}");
41+
42+
assertThatJson(dogma.blockingHttpClient().get(
43+
"/api/v1/loggers/com.linecorp.armeria.logging.traffic.server.http2").contentUtf8())
44+
.isEqualTo("{\"name\":\"com.linecorp.armeria.logging.traffic.server.http2\"," +
45+
" \"level\":null," +
46+
" \"effectiveLevel\":\"DEBUG\"}");
47+
48+
// Send a request to trigger logger initialization such as HttpResponseUtil logger.
49+
setHttp2TrafficLogLevel("{\"level\":\"TRACE\"}");
50+
// Revert the log level to null.
51+
setHttp2TrafficLogLevel("{\"level\":null}");
52+
53+
final String previousAllLoggers = allLoggers();
54+
55+
setHttp2TrafficLogLevel("{\"level\":\"TRACE\"}");
56+
assertThatJson(dogma.blockingHttpClient().get(
57+
"/api/v1/loggers/com.linecorp.armeria.logging.traffic.server.http2").contentUtf8())
58+
.isEqualTo("{\"name\":\"com.linecorp.armeria.logging.traffic.server.http2\"," +
59+
" \"level\":\"TRACE\"," +
60+
" \"effectiveLevel\":\"TRACE\"}");
61+
62+
// Verify that the log level change is reflected in all loggers.
63+
assertThatJson(allLoggers()).isNotEqualTo(previousAllLoggers);
64+
setHttp2TrafficLogLevel("{\"level\":null}");
65+
assertThatJson(dogma.blockingHttpClient().get(
66+
"/api/v1/loggers/com.linecorp.armeria.logging.traffic.server.http2").contentUtf8())
67+
.isEqualTo("{\"name\":\"com.linecorp.armeria.logging.traffic.server.http2\"," +
68+
" \"level\":null," +
69+
" \"effectiveLevel\":\"DEBUG\"}");
70+
71+
// Verify that the log level change is reverted in all loggers.
72+
assertThatJson(allLoggers()).isEqualTo(previousAllLoggers);
73+
}
74+
75+
private static void setHttp2TrafficLogLevel(String body) {
76+
final RequestHeaders headers =
77+
RequestHeaders.builder(HttpMethod.PUT,
78+
"/api/v1/loggers/com.linecorp.armeria.logging.traffic.server.http2")
79+
.contentType(MediaType.JSON_UTF_8).build();
80+
assertThat(dogma.blockingHttpClient().execute(headers, body).status()).isSameAs(HttpStatus.OK);
81+
}
82+
83+
String allLoggers() {
84+
final String allLoggers = dogma.blockingHttpClient().get("/api/v1/loggers").contentUtf8();
85+
assertThat(allLoggers)
86+
.contains("{\"name\":\"ROOT\",\"level\":\"WARN\",\"effectiveLevel\":\"WARN\"}",
87+
"{\"name\":\"com\",\"level\":null,\"effectiveLevel\":\"WARN\"}",
88+
"{\"name\":\"com.linecorp\",\"level\":\"DEBUG\",\"effectiveLevel\":\"DEBUG\"}");
89+
return allLoggers;
90+
}
91+
}

testing/common/src/main/java/com/linecorp/centraldogma/testing/internal/CentralDogmaRuleDelegate.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import com.linecorp.centraldogma.client.armeria.AbstractArmeriaCentralDogmaBuilder;
4242
import com.linecorp.centraldogma.client.armeria.ArmeriaCentralDogmaBuilder;
4343
import com.linecorp.centraldogma.client.armeria.legacy.LegacyCentralDogmaBuilder;
44+
import com.linecorp.centraldogma.internal.CsrfToken;
4445
import com.linecorp.centraldogma.server.CentralDogmaBuilder;
4546
import com.linecorp.centraldogma.server.GracefulShutdownTimeout;
4647
import com.linecorp.centraldogma.server.MirroringService;
@@ -141,10 +142,8 @@ public final CompletableFuture<Void> startAsync(File dataDir) {
141142
final LegacyCentralDogmaBuilder legacyClientBuilder = new LegacyCentralDogmaBuilder();
142143

143144
final String accessToken = accessToken();
144-
if (accessToken != null) {
145-
clientBuilder.accessToken(accessToken);
146-
legacyClientBuilder.accessToken(accessToken);
147-
}
145+
clientBuilder.accessToken(accessToken);
146+
legacyClientBuilder.accessToken(accessToken);
148147

149148
configureClientCommon(clientBuilder);
150149
configureClientCommon(legacyClientBuilder);
@@ -308,9 +307,8 @@ protected void configureHttpClient(WebClientBuilder builder) {}
308307
/**
309308
* Override this method to inject an access token to the clients.
310309
*/
311-
@Nullable
312310
protected String accessToken() {
313-
return null;
311+
return CsrfToken.ANONYMOUS;
314312
}
315313

316314
/**

0 commit comments

Comments
 (0)