从 Unleash 中更改 Quarkus 日志记录器级别
简介
我是 Red Hat 团队的一员,该团队负责十几个 Quarkus 应用程序,这些应用程序运行在 Red Hat OpenShift 上,每个应用程序都有多个 pod。虽然这些应用程序的目的各不相同,但它们也有一个共同的命运:总有一天会出错。当这种情况发生时,我们需要尽快了解并解决问题。降低日志记录器的级别通常很有帮助,但我们的应用程序是容器化的,通过环境变量更新日志记录器的级别并不总是像听起来那么容易。我们也不想在大多数应用程序中公开 REST 端点,因此像 quarkus-logging-manager 这样的扩展就不是一个选项。
我们的应用程序还有另一个共同点:它们依赖于 quarkus-unleash,因为我们从 Unleash 获取功能开关。当我阅读 Aman Jain 的 使用 Unleash 实现零停机日志级别更改 时,我希望能用 Quarkus 做同样的事情。下面我将展示我如何成功地做到了这一点。
这篇博文包含增量代码片段。它们中的每一个都是前一个片段的增强版本,并解决了特定的技术挑战。 |
以编程方式更改日志记录器级别
让我们从显而易见的开始:如何在 Quarkus 中以编程方式更改日志记录器的级别。
正如 日志记录配置指南中所述,Quarkus 支持多种日志记录 API。我只测试了 JBoss Logging API 和 |
JBoss Logging API 不提供以编程方式更改日志记录器级别的方法,因此我们需要 java.util.logging
API 的帮助来完成此操作。
import java.util.logging.Level; (1)
import java.util.logging.Logger; (1)
public class LogLevelManager {
public void setLoggerLevel(String loggerName, String levelName) {
Logger logger = Logger.getLogger(loggerName); (2)
Level level = Level.parse(levelName); (3)
logger.setLevel(level);
}
}
1 | 确保您正在导入 java.util.logging 包中的类。 |
2 | 如 日志记录配置指南中所述,任何类别都可以作为日志记录器的名称。 |
3 | 如果级别名称无效,Level#parse 将抛出异常。请仔细处理。 |
从 Unleash 设置日志记录器级别
因此,我们能够以编程方式设置日志记录器的级别。现在,我们如何将 LogLevelManager#setLoggerLevel
方法与 Unleash 中的数据结合使用?
Unleash 变体来拯救
在 Unleash 中,功能开关可以与 变体 相关联,这些变体旨在促进 A/B 测试和实验。每个变体都定义了一组属性,包括可选的 payload
,可用于将 JSON 数据从 Unleash 传递到我们的 Quarkus 应用程序。这就是我们将设置 Quarkus 应用程序日志记录器级别的方式。

检索变体载荷
现在,让我们看看如何从 Quarkus 应用程序检索 Unleash 中定义的变体载荷。

首先,Quarkus 应用程序需要依赖 quarkus-unleash 扩展。
我们还将使用一个非常简单的数据结构来使用 Jackson 反序列化载荷。
public class LogConfig {
public String category;
public String level;
}
然后,这是 LogLevelManager
类的更新,使其能够从 Unleash 获取变体、反序列化载荷并更改一系列日志记录器的级别。
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.getunleash.Unleash;
import io.getunleash.Variant;
import io.getunleash.variant.Payload;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
@ApplicationScoped (1)
public class LogLevelManager {
private static final String UNLEASH_TOGGLE_NAME = "my-app.log-levels";
@Inject
Unleash unleash; (2)
@Inject
ObjectMapper objectMapper;
public void updateLoggersLevel() {
for (LogConfig logConfig : getLogConfigs()) {
try {
setLoggerLevel(logConfig.category, logConfig.level);
} catch (Exception e) {
Log.error("Could not the set level of a logger", e);
}
}
}
private LogConfig[] getLogConfigs() {
Variant variant = unleash.getVariant(UNLEASH_TOGGLE_NAME); (3)
if (variant.isEnabled()) { (4)
Optional<Payload> payload = variant.getPayload();
if (payload.isPresent() && payload.get().getType().equals("json") && payload.get().getValue() != null) {
try {
return objectMapper.readValue(payload.get().getValue(), LogConfig[].class);
} catch (JsonProcessingException e) {
Log.error("Variant payload deserialization failed", e);
}
}
}
return new LogConfig[0]; (5)
}
private void setLoggerLevel(String loggerName, String levelName) {
Logger logger = Logger.getLogger(loggerName);
Level currentLevel = logger.getLevel();
Level newLevel = Level.parse(levelName);
if (!newLevel.equals(currentLevel)) {
logger.setLevel(newLevel);
}
}
}
1 | 从现在开始,LogLevelManager 是一个 @ApplicationScoped bean。 |
2 | Unleash 是一个由 quarkus-unleash 扩展生成的 @ApplicationScoped bean。 |
3 | 请注意传递给 Unleash#getVariant 的参数:它必须是切换名称,而不是变体名称。 |
4 | 如果切换在 Unleash 中被禁用,或者切换没有变体,variant.isEnabled() 将返回 false 。 |
5 | 如果方法因任何原因无法找到变体载荷或无法反序列化该载荷,则将返回一个空的 LogConfig 数组。 |
我们现在可以从 Unleash 检索日志记录器配置了,这太棒了!但是这个新的 LogLevelManager#updateLoggerslevel
方法还没有被使用。它应该从哪里使用,何时使用?

我们需要在 Unleash 中的日志记录器配置更改后立即执行该方法。因此,它的执行必须以某种方式定期安排。我们可以使用 quarkus-scheduler 扩展将该方法标记为 @Scheduled
,但由于 Unleash SDK,有一种更好的方法。让我们跳转到下一节。
Unleash 的 Subscriber API
Unleash Java 客户端 SDK 提供了一项非常有用的功能:Subscriber API。UnleashSubscriber
接口可以实现以订阅各种 Unleash 事件,包括当 Unleash 客户端从服务器获取切换时发出的 FeatureToggleResponse
。
使用 Subscriber API 和 quarkus-unleash 扩展非常简单。UnleashSubscriber
需要在一个 CDI bean 中实现,仅此而已!该扩展将自动将 bean 传递给 Unleash 客户端构建器。
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.getunleash.Unleash;
import io.getunleash.Variant;
import io.getunleash.event.UnleashSubscriber;
import io.getunleash.repository.FeatureToggleResponse;
import io.getunleash.variant.Payload;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import static io.getunleash.repository.FeatureToggleResponse.Status.CHANGED;
@ApplicationScoped
public class LogLevelManager implements UnleashSubscriber { (1)
private static final String UNLEASH_TOGGLE_NAME = "my-app.log-levels";
@Inject
Unleash unleash;
@Inject
ObjectMapper objectMapper;
@Override
public void togglesFetched(FeatureToggleResponse toggleResponse) { (2)
if (toggleResponse.getStatus() == CHANGED) { (3)
updateLoggersLevel();
}
}
// Unchanged, except for the access modifier.
private void updateLoggersLevel() {
for (LogConfig logConfig : getLogConfigs()) {
try {
setLoggerLevel(logConfig.category, logConfig.level);
} catch (Exception e) {
Log.error("Could not the set level of a logger", e);
}
}
}
// Unchanged.
private LogConfig[] getLogConfigs() {
Variant variant = unleash.getVariant(UNLEASH_TOGGLE_NAME);
if (variant.isEnabled()) {
Optional<Payload> payload = variant.getPayload();
if (payload.isPresent() && payload.get().getType().equals("json") && payload.get().getValue() != null) {
try {
return objectMapper.readValue(payload.get().getValue(), LogConfig[].class);
} catch (JsonProcessingException e) {
Log.error("Variant payload deserialization failed", e);
}
}
}
return new LogConfig[0];
}
// Unchanged.
private void setLoggerLevel(String loggerName, String levelName) {
Logger logger = Logger.getLogger(loggerName);
Level currentLevel = logger.getLevel();
Level newLevel = Level.parse(levelName);
if (!newLevel.equals(currentLevel)) {
logger.setLevel(newLevel);
}
}
}
1 | 我们仍然使用相同的 LogLevelManager 类,但现在它实现了 UnleashSubscriber 。 |
2 | 每次 Unleash 客户端从服务器获取切换时都会调用此方法。 |
3 | 我们仅在服务器端切换更改时才更新日志记录器的级别。 |
好的,LogLevelManager#updateLoggerslevel
方法现在会在客户端从服务器获取新数据时自动调用。但是定期安排该怎么办?嗯,Unleash 客户端已经依赖于内部的计划执行器来获取切换。因此,我们无需担心在应用程序中安排任何内容。它将自动工作!

一个变体统治一切
在本文开头,我提到我的团队负责十几个 Quarkus 应用程序。每个应用程序都有不同数量的副本。为了简化,我们可以将它们都视为主机。
我们有几十个主机,但只有一个 Unleash 变体来管理所有主机的日志记录器级别。这是我实现它的方法。
首先,变体载荷的数据结构需要一个小小的补充。
public class LogConfig {
public String hostName; (1)
public String category;
public String level;
}
1 | 这是新的! |
现在,我们可以在 LogLevelManager
中引入主机过滤功能。
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.getunleash.Unleash;
import io.getunleash.Variant;
import io.getunleash.event.UnleashSubscriber;
import io.getunleash.repository.FeatureToggleResponse;
import io.getunleash.variant.Payload;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import static io.getunleash.repository.FeatureToggleResponse.Status.CHANGED;
@ApplicationScoped
public class LogLevelManager implements UnleashSubscriber {
private static final String UNLEASH_TOGGLE_NAME = "my-app.log-levels";
@ConfigProperty(name = "host-name", defaultValue = "localhost") (1)
String hostName;
@Inject
Unleash unleash;
@Inject
ObjectMapper objectMapper;
// Unchanged.
@Override
public void togglesFetched(FeatureToggleResponse toggleResponse) {
if (toggleResponse.getStatus() == CHANGED) {
updateLoggersLevel();
}
}
private void updateLoggersLevel() {
for (LogConfig logConfig : getLogConfigs()) {
try {
if (shouldThisHostBeUpdated(logConfig)) { (2)
setLoggerLevel(logConfig.category, logConfig.level);
}
} catch (Exception e) {
Log.error("Could not the set level of a logger", e);
}
}
}
// Unchanged.
private LogConfig[] getLogConfigs() {
Variant variant = unleash.getVariant(UNLEASH_TOGGLE_NAME);
if (variant.isEnabled()) {
Optional<Payload> payload = variant.getPayload();
if (payload.isPresent() && payload.get().getType().equals("json") && payload.get().getValue() != null) {
try {
return objectMapper.readValue(payload.get().getValue(), LogConfig[].class);
} catch (JsonProcessingException e) {
Log.error("Variant payload deserialization failed", e);
}
}
}
return new LogConfig[0];
}
private boolean shouldThisHostBeUpdated(LogConfig logConfig) {
if (logConfig.hostName == null) {
return true;
}
if (logConfig.hostName.endsWith("*")) { (3)
return hostName.startsWith(logConfig.hostName.substring(0, logConfig.hostName.length() - 1));
} else {
return hostName.equals(logConfig.hostName);
}
}
// Unchanged.
private void setLoggerLevel(String loggerName, String levelName) {
Logger logger = Logger.getLogger(loggerName);
Level currentLevel = logger.getLevel();
Level newLevel = Level.parse(levelName);
if (!newLevel.equals(currentLevel)) {
logger.setLevel(newLevel);
}
}
}
1 | 在 OpenShift 中,我们通过 HOST_NAME 环境变量传递生成的 pod 名称。 |
2 | 这是新的! |
3 | 此块用于基于主机名前缀过滤主机。这足以满足我们的用例,但可以使用正则表达式进行更精细地过滤。 |
这些更改后,变体载荷可能如下所示。
[
{
"hostName": "unstable-service-7dbbcb4cc-9d9hl",
"category": "io.quarkus.arc",
"level": "FINE"
},
{
"hostName": "awesome-app*",
"category": "org.acme.SomeService",
"level": "WARNING"
},
{
"category": "org.apache.kafka.clients",
"level": "FINER"
}
]
在此载荷中
-
第一个条目将影响一个特定的主机:
unstable-service-7dbbcb4cc-9d9hl
-
第二个条目将影响所有名称以
awesome-app
开头的主机。 -
第三个条目将影响所有主机,无论其名称如何。
自动还原更改
通过 Unleash 变体更改日志记录器的级别应该是临时的操作,主要用于故障排除。这意味着当故障排除完成后,我们需要最终恢复日志记录器的级别。手动执行此操作会很痛苦,所以让我们看看如何自动化。
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.getunleash.Unleash;
import io.getunleash.Variant;
import io.getunleash.event.UnleashSubscriber;
import io.getunleash.repository.FeatureToggleResponse;
import io.getunleash.variant.Payload;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import static io.getunleash.repository.FeatureToggleResponse.Status.CHANGED;
import static java.util.stream.Collectors.toSet;
@ApplicationScoped
public class LogLevelManager implements UnleashSubscriber {
private static final String UNLEASH_TOGGLE_NAME = "my-app.log-levels";
@ConfigProperty(name = "host-name", defaultValue = "localhost")
String hostName;
@Inject
Unleash unleash;
@Inject
ObjectMapper objectMapper;
private final Map<String, Level> originalLoggerLevels = new ConcurrentHashMap<>();
// Unchanged.
@Override
public void togglesFetched(FeatureToggleResponse toggleResponse) {
if (toggleResponse.getStatus() == CHANGED) {
updateLoggersLevel();
}
}
public void updateLoggersLevel() {
LogConfig[] logConfigs = getLogConfigs();
for (LogConfig logConfig : logConfigs) {
try {
if (shouldThisHostBeUpdated(logConfig)) {
setLoggerLevel(logConfig.category, logConfig.level);
}
} catch (Exception e) {
Log.error("Could not the set level of a logger", e);
}
}
revertLoggersLevel(logConfigs); (1)
}
// Unchanged.
private LogConfig[] getLogConfigs() {
Variant variant = unleash.getVariant(UNLEASH_TOGGLE_NAME);
if (variant.isEnabled()) {
Optional<Payload> payload = variant.getPayload();
if (payload.isPresent() && payload.get().getType().equals("json") && payload.get().getValue() != null) {
try {
return objectMapper.readValue(payload.get().getValue(), LogConfig[].class);
} catch (JsonProcessingException e) {
Log.error("Variant payload deserialization failed", e);
}
}
}
return new LogConfig[0];
}
// Unchanged.
private boolean shouldThisHostBeUpdated(LogConfig logConfig) {
if (logConfig.hostName == null) {
return true;
}
if (logConfig.hostName.endsWith("*")) {
return hostName.startsWith(logConfig.hostName.substring(0, logConfig.hostName.length() - 1));
} else {
return hostName.equals(logConfig.hostName);
}
}
private void setLoggerLevel(String loggerName, String levelName) {
Logger logger = Logger.getLogger(loggerName);
Level currentLevel = logger.getLevel();
Level newLevel = Level.parse(levelName);
if (!newLevel.equals(currentLevel)) {
originalLoggerLevels.putIfAbsent(loggerName, currentLevel); (2)
logger.setLevel(newLevel);
}
}
private void revertLoggersLevel(LogConfig[] logConfigs) {
if (logConfigs.length == 0) {
originalLoggerLevels.forEach(this::revertLoggerLevel);
originalLoggerLevels.clear();
} else {
Set<String> knownLoggers = Arrays.stream(logConfigs)
.filter(this::shouldThisHostBeUpdated)
.map(logConfig -> logConfig.category)
.collect(toSet());
originalLoggerLevels.entrySet().removeIf(entry -> {
boolean remove = !knownLoggers.contains(entry.getKey());
if (remove) {
revertLoggerLevel(entry.getKey(), entry.getValue()); (3)
}
return remove;
});
}
}
private void revertLoggerLevel(String loggerName, Level originalLevel) {
Logger logger = Logger.getLogger(loggerName);
logger.setLevel(originalLevel); (4)
}
}
1 | 这是新的! |
2 | 原始日志记录器级别现在已存储在内存中,并在最终还原更改时使用。 |
3 | 如果日志记录器的级别先前已从 Unleash 修改,并且该日志记录器不再是最新 Unleash 变体载荷的一部分,则其级别将恢复为原始值。 |
4 | 如果原始级别为 null ,则日志记录器将继承其父日志记录器的级别。 |