从 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 和 io.quarkus.logging.Log API 的以下代码。我不能保证所有内容都能与其他日志记录 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 应用程序日志记录器级别的方式。

Unleash variant payload

检索变体载荷

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

Connecting Quarkus to 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 方法还没有被使用。它应该从哪里使用,何时使用?

Triggering the loggers level update

我们需要在 Unleash 中的日志记录器配置更改后立即执行该方法。因此,它的执行必须以某种方式定期安排。我们可以使用 quarkus-scheduler 扩展将该方法标记为 @Scheduled,但由于 Unleash SDK,有一种更好的方法。让我们跳转到下一节。

Unleash 的 Subscriber API

Unleash Java 客户端 SDK 提供了一项非常有用的功能:Subscriber APIUnleashSubscriber 接口可以实现以订阅各种 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 客户端已经依赖于内部的计划执行器来获取切换。因此,我们无需担心在应用程序中安排任何内容。它将自动工作!

LogLevelManager with UnleashSubscriber

一个变体统治一切

在本文开头,我提到我的团队负责十几个 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,则日志记录器将继承其父日志记录器的级别。

结论

LogLevelManager 类仍然远非完美,但它最终满足了这篇博文的要求。

  • 它根据 Unleash 的变体载荷自动即时更改 Quarkus 日志记录器的级别。

  • 在需要时,它会自动将所有更改恢复到先前的日志记录器配置。

感谢您阅读本文!希望它能帮助您更快地对应用程序进行故障排除。

特别感谢

感谢 Mikel Alejo Barcina 帮助我修复了上述代码中的一个错误!