监控 Java 应用程序编辑

在本教程中,您将学习如何使用 Elastic 可观测性监控 Java 应用程序:日志、基础设施指标、APM 和正常运行时间。

您将学到什么编辑

您将学习如何

  • 创建示例 Java 应用程序。
  • 使用 Filebeat 摄取日志并在 Kibana 中查看日志。
  • 使用 Metricbeat Prometheus 模块 摄取指标并在 Kibana 中查看指标。
  • 使用 Elastic APM Java 代理 为您的应用程序添加检测。
  • 使用 Heartbeat 监控您的服务并在 Kibana 中查看正常运行时间数据。

开始之前编辑

使用我们托管的 Elasticsearch 服务在 Elastic Cloud 上创建一个部署。该部署包括一个用于存储和搜索数据的 Elasticsearch 集群、一个用于可视化和管理数据的 Kibana 以及一个 APM 服务器。如果您不想按照此处列出的所有步骤操作,并想查看最终的 Java 代码,请查看 observability-contrib GitHub 存储库 以获取示例应用程序。

步骤 1:创建 Java 应用程序编辑

要创建 Java 应用程序,您需要 OpenJDK 14(或更高版本)和 Javalin Web 框架。该应用程序将包括主端点、一个人工长时间运行的端点以及一个需要轮询另一个数据源的端点。还将有一个后台作业正在运行。

  1. 设置一个 Gradle 项目并创建以下 build.gradle 文件。

    plugins {
      id 'java'
      id 'application'
    }
    
    repositories {
      jcenter()
    }
    
    dependencies {
      implementation 'io.javalin:javalin:3.10.1'
    
      testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.2'
      testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.6.2'
    }
    
    application {
      mainClassName = 'de.spinscale.javalin.App'
    }
    
    test {
      useJUnitPlatform()
    }
  2. 运行以下命令。

    echo "rootProject.name = 'javalin-app'" >> settings.gradle
    
    mkdir -p src/main/java/de/spinscale/javalin
    mkdir -p src/test/java/de/spinscale/javalin
  3. 安装 Gradle 包装器。安装 Gradle 的一种简单方法是使用 sdkman 并运行 sdk install gradle 6.5.1。接下来,在当前目录中运行 gradle wrapper 以安装 Gradle 包装器。
  4. 运行 ./gradlew clean check。您应该看到一个成功的构建,其中没有构建或编译任何内容。
  5. 要创建 Javalin 服务器及其第一个端点(主端点),请创建 src/main/java/de/spinscale/javalin/App.java 文件。

    package de.spinscale.javalin;
    
    import io.javalin.Javalin;
    
    public class App {
        public static void main(String[] args) {
            Javalin app = Javalin.create().start(7000);
            app.get("/", ctx -> ctx.result("Appsolutely perfect"));
        }
    }
  6. 运行 ./gradlew assemble

    此命令在 build 目录中编译了 App.class 文件。但是,没有办法启动服务器。让我们创建一个包含已编译类以及所有必需依赖项的 jar。

  7. build.gradle 文件中,按如下所示编辑 plugins

    plugins {
      id 'com.github.johnrengelman.shadow' version '6.0.0'
      id 'application'
      id 'java'
    }
  8. 运行 ./gradlew shadowJar。此命令将创建一个 build/libs/javalin-app-all.jar 文件。

    shadowJar 插件需要有关其主类的信息。

  9. 将以下代码段添加到 build.gradle 文件中。

    jar {
      manifest {
        attributes 'Main-Class': 'de.spinscale.javalin.App'
      }
    }
  10. 重建项目并启动服务器。

    java -jar build/libs/javalin-app-all.jar

    打开另一个终端并运行 curl localhost:7000 以显示 HTTP 响应。

  11. 测试代码。将所有内容都放在 main() 方法中会使代码难以测试。但是,专用处理程序可以解决此问题。

    重构 App 类。

    package de.spinscale.javalin;
    
    import io.javalin.Javalin;
    import io.javalin.http.Handler;
    
    public class App {
    
        public static void main(String[] args) {
            Javalin app = Javalin.create().start(7000);
            app.get("/", mainHandler());
        }
    
        static Handler mainHandler() {
            return ctx -> ctx.result("Appsolutely perfect");
        }
    }

    将 Mockito 和 Assertj 依赖项添加到 build.gradle 文件中。

    dependencies {
      implementation 'io.javalin:javalin:3.10.1'
    
      testImplementation 'org.mockito:mockito-core:3.5.10'
      testImplementation 'org.assertj:assertj-core:3.17.2'
      testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.2'
      testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.6.2'
    }

    src/test/java/de/spinscale/javalin 中创建一个 AppTests.java 类文件。

    package de.spinscale.javalin;
    
    import io.javalin.http.Context;
    import org.junit.jupiter.api.Test;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    import java.nio.charset.StandardCharsets;
    import java.util.HashMap;
    
    import static de.spinscale.javalin.App.mainHandler;
    import static org.assertj.core.api.Assertions.assertThat;
    import static org.mockito.Mockito.mock;
    
    public class AppTests {
    
        final HttpServletRequest req = mock(HttpServletRequest.class);
        final HttpServletResponse res = mock(HttpServletResponse.class);
        final Context ctx = new Context(req, res, new HashMap<>());
    
        @Test
        public void testMainHandler() throws Exception {
            mainHandler().handle(ctx);
    
            String response = resultStreamToString(ctx);
            assertThat(response).isEqualTo("Appsolutely perfect");
        }
    
        private String resultStreamToString(Context ctx) throws IOException {
            final byte[] bytes = ctx.resultStream().readAllBytes();
            return new String(bytes, StandardCharsets.UTF_8);
        }
    }
  12. 测试通过后,构建并打包应用程序。

    ./gradlew clean check shadowJar

步骤 2:摄取日志编辑

日志可以是诸如结账、异常或 HTTP 请求之类的事件。在本教程中,让我们使用 log4j2 作为我们的日志记录实现。

添加日志记录实现编辑

  1. 将依赖项添加到 build.gradle 文件中。

    dependencies {
      implementation 'io.javalin:javalin:3.10.1'
      implementation 'org.apache.logging.log4j:log4j-slf4j18-impl:2.13.3'
    
      ...
    }
  2. 要开始日志记录,请编辑 App.java 文件并更改处理程序。

    日志记录调用必须在 lambda 中。否则,日志消息仅在启动时记录。

    package de.spinscale.javalin;
    
    import io.javalin.Javalin;
    import io.javalin.http.Handler;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    public class App {
    
        private static final Logger logger = LoggerFactory.getLogger(App.class);
    
        public static void main(String[] args) {
            Javalin app = Javalin.create();
            app.get("/", mainHandler());
            app.start(7000);
        }
    
        static Handler mainHandler() {
            return ctx -> {
                logger.info("This is an informative logging message, user agent [{}]", ctx.userAgent());
                ctx.result("Appsolutely perfect");
            };
        }
    }
  3. src/main/resources/log4j2.xml 文件中创建一个 log4j2 配置。您可能需要先创建该目录。

    <?xml version="1.0" encoding="UTF-8"?>
    <Configuration>
      <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
          <PatternLayout pattern="%d{HH:mm:ss.SSS} [%-5level] %logger{36} %msg%n"/>
        </Console>
      </Appenders>
      <Loggers>
        <Logger name="de.spinscale.javalin.App" level="INFO"/>
        <Root level="ERROR">
          <AppenderRef ref="Console" />
        </Root>
      </Loggers>
    </Configuration>

    默认情况下,这将在 ERROR 级别上进行日志记录。对于 App 类,有一个额外的配置,以便所有 INFO 日志也都被记录。重新打包并重新启动后,日志消息将显示在终端中。

    17:17:40.019 [INFO ] de.spinscale.javalin.App - This is an informative logging message, user agent [curl/7.64.1]

记录请求编辑

根据应用程序流量以及它是否发生在应用程序外部,在应用程序级别记录每个请求是有意义的。

  1. App.java 文件中,编辑 App 类。

    public class App {
    
        private static final Logger logger = LoggerFactory.getLogger(App.class);
    
        public static void main(String[] args) {
            Javalin app = Javalin.create(config -> {
                config.requestLogger((ctx, executionTimeMs) -> {
                    logger.info("{} {} {} {} \"{}\" {}",
                            ctx.method(),  ctx.url(), ctx.req.getRemoteHost(),
                            ctx.res.getStatus(), ctx.userAgent(), executionTimeMs.longValue());
               });
            });
            app.get("/", mainHandler());
            app.start(7000);
        }
    
        static Handler mainHandler() {
            return ctx -> {
                logger.info("This is an informative logging message, user agent [{}]", ctx.userAgent());
                ctx.result("Appsolutely perfect");
            };
        }
    }
  2. 重建并重新启动应用程序。每个请求的日志消息都会被记录。

    10:43:50.066 [INFO ] de.spinscale.javalin.App - GET / 200 0:0:0:0:0:0:0:1 "curl/7.64.1" 7

创建 ISO8601 时间戳编辑

在将日志摄取到 Elasticsearch 服务之前,请通过编辑 log4j2.xml 文件来创建一个 ISO8601 时间戳。

创建 ISO8601 时间戳消除了在摄取日志时进行任何时间戳计算的需要,因为这是一个包含时区的唯一时间点。当您在尝试跟踪数据流时跨数据中心运行时,拥有时区变得更加重要。

<PatternLayout pattern="%d{ISO8601_OFFSET_DATE_TIME_HHCMM} [%-5level] %logger{36} %msg%n"/>

摄取的日志条目包含以下时间戳。

2020-07-03T14:25:40,378+02:00 [INFO ] de.spinscale.javalin.App GET / 200 0:0:0:0:0:0:0:1 "curl/7.64.1" 0

将日志记录到文件和 stdout编辑

  1. 要读取日志记录输出,让我们将数据写入文件和 stdout。这是一个新的 log4j2.xml 文件。

    <?xml version="1.0" encoding="UTF-8"?>
    <Configuration>
      <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
          <PatternLayout pattern="%highlight{%d{ISO8601_OFFSET_DATE_TIME_HHCMM} [%-5level] %logger{36} %msg%n}"/>
        </Console>
        <File name="JavalinAppLog" fileName="/tmp/javalin/app.log">
          <PatternLayout pattern="%d{ISO8601_OFFSET_DATE_TIME_HHCMM} [%-5level] %logger{36} %msg%n"/>
        </File>
      </Appenders>
      <Loggers>
        <Logger name="de.spinscale.javalin.App" level="INFO"/>
        <Root level="ERROR">
          <AppenderRef ref="Console" />
          <AppenderRef ref="JavalinAppLog" />
        </Root>
      </Loggers>
    </Configuration>
  2. 重新启动应用程序并发送请求。日志将被发送到 /tmp/javalin/app.log

安装和配置 Filebeat编辑

要读取日志文件并将其发送到 Elasticsearch,需要 Filebeat。要下载和安装 Filebeat,请使用适合您系统的命令

curl -L -O https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-8.14.2-amd64.deb
sudo dpkg -i filebeat-8.14.2-amd64.deb
  1. 使用 Filebeat 密钥库来存储 安全设置。让我们将 Cloud ID 存储在密钥库中。

    在以下命令中替换来自您的部署的 Cloud ID。要查找您的 Cloud ID,请在 https://cloud.elastic.co/deployments 中单击您的部署

    ./filebeat keystore create
    echo -n "<Your Cloud ID>" | ./filebeat keystore add CLOUD_ID --stdin

    要以最少的权限将日志存储在 Elasticsearch 中,请创建一个 API 密钥,以便从 Filebeat 发送数据到 Elasticsearch 服务。

  2. 登录 Kibana 用户(您可以在 Cloud Console 中执行此操作,无需输入任何权限),然后选择 管理开发工具。发送以下请求

    POST /_security/api_key
    {
      "name": "filebeat_javalin-app",
      "role_descriptors": {
        "filebeat_writer": {
          "cluster": ["monitor", "read_ilm"],
          "index": [
            {
              "names": ["filebeat-*"],
              "privileges": ["view_index_metadata", "create_doc"]
            }
          ]
        }
      }
    }

    响应包含一个 api_key 字段和一个 id 字段,可以按以下格式存储在 Filebeat 密钥库中:id:api_key

    echo -n "IhrJJHMB4JmIUAPLuM35:1GbfxhkMT8COBB4JWY3pvQ" | ./filebeat keystore add ES_API_KEY --stdin

    确保您指定了 -n 参数;否则,您将进行痛苦的调试会话,因为在 API 密钥的末尾添加了换行符。

    要查看是否已存储这两个设置,请运行 ./filebeat keystore list

  3. 要加载 Filebeat 仪表板,请使用 elastic 超级用户。

    ./filebeat setup -e -E 'cloud.id=${CLOUD_ID}' -E 'cloud.auth=elastic:YOUR_SUPER_SECRET_PASS'

    如果您不想将凭据存储在 shell 的 .history 文件中,请在行首添加一个空格。根据 shell 配置,这些命令不会添加到历史记录中。

  4. 配置 Filebeat,使其知道从哪里读取数据以及发送到哪里。创建一个 filebeat.yml 文件。

    name: javalin-app-shipper
    
    filebeat.inputs:
    - type: log
      paths:
        - /tmp/javalin/*.log
    
    cloud.id: ${CLOUD_ID}
    output.elasticsearch:
      api_key: ${ES_API_KEY}

将数据发送到 Elasticsearch编辑

要将数据发送到 Elasticsearch,请启动 Filebeat。

sudo service filebeat start

如果您使用 init.d 脚本启动 Filebeat,则无法指定命令行标志(请参阅 命令参考)。要指定标志,请在前景中启动 Filebeat。

另请参阅 Filebeat 和 systemd

在日志输出中,您应该看到以下行。

2020-07-03T15:41:56.532+0200    INFO    log/harvester.go:297    Harvester started for file: /tmp/javalin/app.log

让我们为应用程序创建一些日志条目。您可以使用诸如 wrk 之类的工具,并运行以下命令将请求发送到应用程序。

wrk -t1 -c 100 -d10s https://127.0.0.1:7000

此命令会导致每秒大约 8,000 个请求,并且也会写入等量的日志行。

步骤 3:在 Kibana 中查看日志编辑

  1. 登录 Kibana 并选择 发现 应用程序。

    顶部有一个文档摘要,但让我们看一下单个文档。

    Kibana single document view

    您可以看到,索引的数据远远不止事件本身。还包含有关文件偏移量的信息、发送日志的组件信息、输出中发送者的名称,以及包含日志行内容的 message 字段。

    您可以看到请求日志记录中存在一个缺陷。如果用户代理是 null,则返回的值不是 null。阅读日志至关重要;但是,仅仅索引它们并不能给我们带来任何好处。为了解决这个问题,这里有一个新的请求记录器。

    Javalin app = Javalin.create(config -> {
        config.requestLogger((ctx, executionTimeMs) -> {
            String userAgent = ctx.userAgent() != null ? ctx.userAgent() : "-";
            logger.info("{} {} {} {} \"{}\" {}",
                    ctx.method(), ctx.req.getPathInfo(), ctx.res.getStatus(),
                    ctx.req.getRemoteHost(), userAgent, executionTimeMs.longValue());
        });
    });

    您可能还想在主处理程序的日志消息中修复此问题。

    static Handler mainHandler() {
        return ctx -> {
            String userAgent = ctx.userAgent() != null ? ctx.userAgent() : "-";
            logger.info("This is an informative logging message, user agent [{}]", userAgent);
            ctx.result("Appsolutely perfect");
        };
    }
  2. 现在让我们看看 Kibana 中的 Logs 应用程序。选择 可观察性日志

    如果您想查看流式功能的工作原理,请在循环中运行以下 curl 请求,并在每次请求之间休眠。

    while $(sleep 0.7) ; do curl localhost:7000 ; done
  3. 要查看日志消息的连续流,请单击 实时流。您还可以突出显示特定术语,如这里所示。

    Kibana Logs UI Streaming

    查看正在索引的其中一个文档,您可以看到日志消息包含在一个单独的字段中。通过查看这些文档之一来验证这一点。

    GET filebeat-*/_search
    {
      "size": 1
    }

    需要注意的事项

    • 当您将 @timestamp 字段与日志消息的时间戳进行比较时,您会注意到它们不同。这意味着,当您根据 @timestamp 字段进行过滤时,您不会获得预期的结果。当前的 @timestamp 字段反映了事件在 Filebeat 中创建的时间戳,而不是日志事件在应用程序中发生的时间戳。
    • 无法根据特定字段进行过滤,例如 HTTP 动词、HTTP 状态代码、日志级别或生成日志消息的类。

步骤 4:使用您的日志编辑

结构化日志编辑

要从单个日志行中提取更多数据到多个字段,需要对日志进行额外的结构化处理。

让我们再看看应用程序生成的日志消息。

2020-07-03T15:45:01,479+02:00 [INFO ] de.spinscale.javalin.App This is an informative logging message

此消息包含四个部分:timestamplog levelclassmessage。拆分规则也很明显,因为大多数规则都涉及空格。

好消息是,所有 Beats 都可以使用 处理器 在将日志行发送到 Elasticsearch 之前对其进行处理。如果这些处理器的功能不足,您始终可以使用 摄取节点 让 Elasticsearch 完成繁重的工作。这是 Filebeat 中许多模块所做的事情。Filebeat 中的模块是一种解析特定软件的特定日志文件格式的方法。

让我们尝试使用几个处理器和仅一个 Filebeat 配置来实现这一点。

processors:
  - add_host_metadata: ~
  - dissect:
      tokenizer: '%{timestamp} [%{log.level}] %{log.logger} %{message_content}'
      field: "message"
      target_prefix: ""
  - timestamp:
      field: "timestamp"
      layouts:
        - '2006-01-02T15:04:05.999Z0700'
      test:
        - '2020-07-18T04:59:51.123+0200'
  - drop_fields:
      fields: [ "message", "timestamp" ]
  - rename:
      fields:
        - from: "message_content"
        - to: "message"

dissect 处理器将日志消息拆分为四个部分。如果您希望将原始消息的最后部分保留在 message 字段中,您需要先删除旧的 message 字段,然后重命名该字段。dissect 过滤器没有就地替换功能。

还有一个专门的时间戳解析器,以便 @timestamp 字段包含解析后的值。删除重复的字段,但确保原始消息的一部分仍然存在于 message 字段中。

删除原始消息的一部分是有争议的。保留原始消息非常有意义。对于上面的示例,如果解析时间戳无法按预期工作,调试可能会变得很麻烦。

时间戳解析也存在细微差别,因为 go 时间解析器只接受点作为秒和毫秒之间的分隔符。但是,我们默认的 log4j2 输出使用的是逗号。

可以修复日志输出中的时间戳,使其看起来像 Filebeat 预期的那样。这将导致以下模式布局。

  <PatternLayout pattern="%d{yyyy-MM-dd'T'HH:mm:ss.SSSZ} [%-5level] %logger{36} %msg%n"/>

修复时间戳解析是另一种方法,因为您并不总是能够完全控制日志并更改其格式。想象一下使用一些第三方软件。目前,这已经足够好了。

更改后重新启动 Filebeat,并通过运行以下搜索(并让另一个日志消息被索引)查看索引的 JSON 文档中发生了什么变化。

GET filebeat-*/_search?filter_path=**._source
{
  "size": 1,
  "_source": {
    "excludes": [
      "host.ip",
      "host.mac"
    ]
  },
  "sort": [
    {
      "@timestamp": {
        "order": "desc"
      }
    }
  ]
}

这将返回一个类似于以下内容的文档。

{
  "hits" : {
    "hits" : [
      {
        "_source" : {
          "input" : {
            "type" : "log"
          },
          "agent" : {
            "hostname" : "rhincodon",
            "name" : "javalin-app-shipper",
            "id" : "95705f0c-b472-4bcc-8b01-2d387c0d309b",
            "type" : "filebeat",
            "ephemeral_id" : "e4df883f-6073-4a90-a4c4-9e116704f871",
            "version" : "7.9.0"
          },
          "@timestamp" : "2020-07-03T15:11:51.925Z",
          "ecs" : {
            "version" : "1.5.0"
          },
          "log" : {
            "file" : {
              "path" : "/tmp/javalin/app.log"
            },
            "offset" : 1440,
            "level" : "ERROR",
            "logger" : "de.spinscale.javalin.App"
          },
          "host" : {
            "hostname" : "rhincodon",
            "os" : {
              "build" : "19F101",
              "kernel" : "19.5.0",
              "name" : "Mac OS X",
              "family" : "darwin",
              "version" : "10.15.5",
              "platform" : "darwin"
            },
            "name" : "javalin-app-shipper",
            "id" : "C28736BF-0EB3-5A04-BE85-C27A62C99316",
            "architecture" : "x86_64"
          },
          "message" : "This is an informative logging message, user agent [curl/7.64.1]"
        }
      }
    ]
  }
}

您可以看到,message 字段只包含日志消息的最后部分。此外,还有一个 log.levellog.logger 字段。

当日志级别为 INFO 时,它会在末尾添加额外的空格。您可以使用 脚本处理器 并调用 trim()。但是,修复我们的日志记录配置以不总是发出 5 个字符(无论日志级别长度如何)可能更容易。您仍然可以在写入标准输出时保留此配置。

<File name="JavalinAppLog" fileName="/tmp/javalin/app.log">
  <PatternLayout pattern="%d{yyyy-MM-dd'T'HH:mm:ss.SSSZ} [%level] %logger{36} %msg%n"/>
</File>

解析异常编辑

在日志记录的情况下,异常是一种特殊的处理方式。它们跨越多行,因此每行一条消息的旧规则在异常中不存在。

首先在 App.java 中添加一个触发异常的端点,并确保使用异常映射器对其进行日志记录。

app.get("/exception", ctx -> {
    throw new IllegalArgumentException("not yet implemented");
});

app.exception(Exception.class, (e, ctx) -> {
    logger.error("Exception found", e);
    ctx.status(500).result(e.getMessage());
});

调用 /exception 会向客户端返回 HTTP 500 错误,但它会在日志中留下一个类似于以下内容的堆栈跟踪。

2020-07-06T11:27:29,491+02:00 [ERROR] de.spinscale.javalin.App Exception found
java.lang.IllegalArgumentException: not yet implemented
    at de.spinscale.javalin.App.lambda$main$2(App.java:24) ~[classes/:?]
    at io.javalin.core.security.SecurityUtil.noopAccessManager(SecurityUtil.kt:23) ~[javalin-3.10.1.jar:?]
    at io.javalin.http.JavalinServlet$addHandler$protectedHandler$1.handle(JavalinServlet.kt:119) ~[javalin-3.10.1.jar:?]
    at io.javalin.http.JavalinServlet$service$2$1.invoke(JavalinServlet.kt:45) ~[javalin-3.10.1.jar:?]
    at io.javalin.http.JavalinServlet$service$2$1.invoke(JavalinServlet.kt:24) ~[javalin-3.10.1.jar:?]

  ... goes on and on and on and own ...

有一个属性可以帮助解析此堆栈跟踪。它看起来与常规日志消息不同。每条新行都以空格开头,因此与以日期开头的日志消息不同。让我们将此逻辑添加到我们的 Beats 配置中。

- type: log
  enabled: true
  paths:
    - /tmp/javalin/*.log
  multiline.pattern: ^20
  multiline.negate: true
  multiline.match: after

因此,上述设置的逐字翻译是将所有内容视为现有消息的一部分,即一行中不以 20 开头的消息。20 代表时间戳的年份开头。一些用户更喜欢将日期括在 [] 中,以便更容易理解。

这会在您的日志记录中引入状态。您现在无法将日志文件拆分到多个处理器中,因为每条日志行都可能仍然属于当前事件。这不是一件坏事,但需要引起注意。

重新启动 Filebeat 和 Javalin 应用程序后,触发异常,您将在日志的 message 字段中看到一个长堆栈跟踪。

配置日志轮换编辑

为了确保日志不会无限增长,让我们在日志记录配置中添加一些日志轮换功能。

<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
  <Appenders>
    <Console name="Console" target="SYSTEM_OUT">
      <PatternLayout pattern="%highlight{%d{ISO8601_OFFSET_DATE_TIME_HHCMM} [%-5level] %logger{36} %msg%n}"/>
    </Console>

    <RollingFile name="JavalinAppLogRolling" fileName="/tmp/javalin/app.log" filePattern="/tmp/javalin/%d{yyyy-MM-dd}-%i.log.gz">
      <PatternLayout pattern="%d{yyyy-MM-dd'T'HH:mm:ss.SSSZ} [%level] %logger{36} %msg%n"/>
      <Policies>
        <TimeBasedTriggeringPolicy />
        <SizeBasedTriggeringPolicy size="50 MB"/>
      </Policies>
      <DefaultRolloverStrategy max="20"/>
    </RollingFile>
  </Appenders>

  <Loggers>
    <Logger name="de.spinscale.javalin.App" level="INFO"/>
    <Root level="ERROR">
      <AppenderRef ref="Console" />
      <AppenderRef ref="JavalinAppLogRolling" />
    </Root>
  </Loggers>
</Configuration>

示例在我们的配置中添加了一个 JavalinAppLogRolling 附加程序,它使用与之前相同的日志记录模式,但如果新的一天开始或日志文件达到 50 兆字节,则会进行轮换。

如果创建了新的日志文件,旧的日志文件也会被压缩,以便在磁盘上占用更少的空间。50 兆字节的大小指的是未压缩的文件大小,因此磁盘上可能存在的 20 个文件每个文件都将小得多。

摄取节点编辑

内置模块几乎完全使用 Elasticsearch 的 摄取节点 功能,而不是 Beats 处理器。

摄取管道最有用的部分之一是能够使用 模拟管道 API 进行调试。

  1. 让我们编写一个类似于我们的 Filebeat 处理器的管道,使用 Kibana 中的 Dev Tools 面板,运行以下命令

    # Store the pipeline in Elasticsearch
    PUT _ingest/pipeline/javalin_pipeline
    {
      "processors": [
        {
          "dissect": {
            "field": "message",
            "pattern": "%{@timestamp} [%{log.level}] %{log.logger} %{message}"
          }
        },
        {
          "trim": {
            "field": "log.level"
          }
        },
        {
          "date": {
            "field": "@timestamp",
            "formats": [
              "ISO8601"
            ]
          }
        }
      ]
    }
    
    # Test the pipeline
    POST _ingest/pipeline/javalin_pipeline/_simulate
    {
      "docs": [
        {
          "_source": {
            "message": "2020-07-06T13:39:51,737+02:00 [INFO ] de.spinscale.javalin.App This is an informative logging message"
          }
        }
      ]
    }

    您可以在输出中看到管道创建的字段,现在看起来与之前的 Filebeat 处理器相同。由于摄取管道在文档级别上工作,因此您仍然需要检查日志生成的异常,并让 Filebeat 从这些异常中创建一个单一消息。您甚至可以使用单个处理器来实现日志级别修剪,并且日期解析也很容易,因为 Elasticsearch ISO8601 解析器在拆分秒和毫秒时可以正确识别逗号而不是点。

  2. 现在,让我们继续进行 Filebeat 配置。首先,让我们删除所有处理器,除了 add_host_metadata 处理器,以添加一些主机信息,例如主机名和操作系统。

    processors:
      - add_host_metadata: ~
  3. 编辑 Elasticsearch 输出,以确保在从 Filebeat 索引文档时会引用该管道。

    cloud.id: ${CLOUD_ID}
    output.elasticsearch:
      api_key: ${ES_API_KEY}
      pipeline: javalin_pipeline
  4. 重新启动 Filebeat,并查看日志是否按预期流入。

将日志写入 JSON编辑

您现在已经了解了在 Beats 或 Elasticsearch 中解析日志。如果我们不需要考虑解析日志并手动提取数据,会怎么样呢?

将日志写入纯文本可以正常工作,并且易于人类阅读。但是,首先将日志写入纯文本,然后使用 dissect 处理器对其进行解析,最后再创建 JSON 听起来很繁琐,并且会浪费不必要的 CPU 周期。

虽然 log4j2 有一个 JSONLayout,但您可以更进一步,使用一个名为 ecs-logging-java 的库。ECS 日志记录的优势在于它使用 Elastic Common Schema。ECS 定义了一组标准字段,用于在 Elasticsearch 中存储事件数据,例如日志和指标。

  1. 与其编写自己的日志记录标准,不如使用现有的标准。让我们将日志记录依赖项添加到 Javalin 应用程序中。

    dependencies {
      implementation 'io.javalin:javalin:3.10.1'
      implementation 'org.apache.logging.log4j:log4j-slf4j18-impl:2.13.3'
      implementation 'co.elastic.logging:log4j2-ecs-layout:0.5.0'
    
      testImplementation 'org.mockito:mockito-core:3.5.10'
      testImplementation 'org.assertj:assertj-core:3.17.2'
      testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.2'
      testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.6.2'
    }
    
    // this is needed to ensure JSON logging works as expected when building
    // a shadow jar
    shadowJar {
      transform(com.github.jengelman.gradle.plugins.shadow.transformers.Log4j2PluginsCacheFileTransformer)
    }

    log4j2-ecs-layout 附带一个自定义的 <EcsLayout>,它可以在日志记录设置中用于滚动文件附加程序。

    <RollingFile name="JavalinAppLogRolling" fileName="/tmp/javalin/app.log" filePattern="/tmp/javalin/%d{yyyy-MM-dd}-%i.log.gz">
      <EcsLayout serviceName="my-javalin-app"/>
      <Policies>
        <TimeBasedTriggeringPolicy />
        <SizeBasedTriggeringPolicy size="50 MB"/>
      </Policies>
      <DefaultRolloverStrategy max="20"/>
    </RollingFile>

    重新启动应用程序后,您将在日志文件中看到纯 JSON。当您触发异常时,您会看到堆栈跟踪已经包含在您的单个文档中。这意味着 Filebeat 配置可以变得无状态,甚至更轻量级。此外,Elasticsearch 侧面的摄取管道也可以再次删除。

  2. 您可以为 EcsLayout 配置一些 更多参数,但默认值已明智地选择。让我们修复 Filebeat 配置并删除多行设置以及管道

    filebeat.inputs:
    - type: log
      enabled: true
      paths:
        - /tmp/javalin/*.log
      json.keys_under_root: true
    
    name: javalin-app-shipper
    
    cloud.id: ${CLOUD_ID}
    output.elasticsearch:
      api_key: ${ES_API_KEY}
    
    # ================================= Processors =================================
    processors:
      - add_host_metadata: ~

    如您所见,仅仅通过将日志写成 JSON,我们的整个日志记录设置变得容易得多,因此,只要有可能,请尝试直接将日志写成 JSON。

步骤 5:摄取指标编辑

指标被认为是随时可能变化的某个时间点的值。当前请求数每毫秒都可能发生变化。您可能会有 1000 个请求的峰值,然后一切恢复到一个请求。这也意味着这些指标可能不准确,您还需要提取最小值/最大值以获得更多指示。此外,这意味着您还需要考虑这些指标的持续时间。您需要每分钟获取一次还是每 10 秒获取一次?

为了从不同的角度查看您的应用程序,让我们摄取一些指标。在本例中,我们将使用 Metricbeat Prometheus 模块 将数据发送到 Elasticsearch。

我们应用程序中使用的底层库是 micrometer.io,这是一个与供应商无关的应用程序指标外观,结合其 Prometheus 支持 来实现基于拉取的模型。您可以使用 elastic 支持 来实现基于推送的模型。这将要求用户在我们的应用程序中存储 Elasticsearch 集群的凭据数据。本示例将此数据保留在周围工具中。

将指标添加到应用程序编辑

  1. 将依赖项添加到我们的 build.gradle 文件中。

      // metrics via micrometer
      implementation 'io.micrometer:micrometer-core:1.5.4'
      implementation 'io.micrometer:micrometer-registry-prometheus:1.5.4'
      implementation 'org.apache.commons:commons-lang3:3.11'
  2. 将 micrometer 插件及其相应的导入添加到我们的 Javalin 应用程序中。

    ...
    import io.javalin.plugin.metrics.MicrometerPlugin;
    import io.javalin.core.security.BasicAuthCredentials;
    ...
    
    Javalin app = Javalin.create(config -> {
       ...
       config.registerPlugin(new MicrometerPlugin());
    );
  3. 添加一个新的指标端点,并确保 BasicAuthCredentials 类也被导入。

    final Micrometer micrometer = new Micrometer();
    app.get("/metrics", ctx -> {
      ctx.status(404);
      if (ctx.basicAuthCredentialsExist()) {
        final BasicAuthCredentials credentials = ctx.basicAuthCredentials();
        if ("metrics".equals(credentials.getUsername()) && "secret".equals(credentials.getPassword())) {
          ctx.status(200).result(micrometer.scrape());
        }
      }
    });

    在这里,MicroMeter 类是一个名为 MicroMeter.java 的自写类,它设置了几个指标监视器并为 Prometheus 创建了注册表,该注册表提供了基于文本的 Prometheus 输出。

    package de.spinscale.javalin;
    
    import io.micrometer.core.instrument.Metrics;
    import io.micrometer.core.instrument.binder.jvm.JvmCompilationMetrics;
    import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics;
    import io.micrometer.core.instrument.binder.jvm.JvmHeapPressureMetrics;
    import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics;
    import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics;
    import io.micrometer.core.instrument.binder.logging.Log4j2Metrics;
    import io.micrometer.core.instrument.binder.system.FileDescriptorMetrics;
    import io.micrometer.core.instrument.binder.system.ProcessorMetrics;
    import io.micrometer.core.instrument.binder.system.UptimeMetrics;
    import io.micrometer.prometheus.PrometheusConfig;
    import io.micrometer.prometheus.PrometheusMeterRegistry;
    
    public class Micrometer {
    
        final PrometheusMeterRegistry registry = new PrometheusMeterRegistry(new PrometheusConfig() {
            @Override
            public String get(String key) {
                return null;
            }
    
            @Override
            public String prefix() {
                return "javalin";
            }
        });
    
        public Micrometer() {
            Metrics.addRegistry(registry);
            new JvmGcMetrics().bindTo(Metrics.globalRegistry);
            new JvmHeapPressureMetrics().bindTo(Metrics.globalRegistry);
            new JvmThreadMetrics().bindTo(Metrics.globalRegistry);
            new JvmCompilationMetrics().bindTo(Metrics.globalRegistry);
            new JvmMemoryMetrics().bindTo(Metrics.globalRegistry);
            new Log4j2Metrics().bindTo(Metrics.globalRegistry);
            new UptimeMetrics().bindTo(Metrics.globalRegistry);
            new FileDescriptorMetrics().bindTo(Metrics.globalRegistry);
            new ProcessorMetrics().bindTo(Metrics.globalRegistry);
        }
    
        public String scrape() {
            return registry.scrape();
        }
    }
  4. 重建您的应用程序并轮询指标端点。

    curl localhost:7000/metrics -u metrics:secret

    这将返回一个基于行的响应,每行一个指标。这是标准的 Prometheus 格式。

安装和配置 Metricbeat编辑

要将指标发送到 Elasticsearch,需要 Metricbeat。要下载和安装 Metricbeat,请使用适合您系统的命令

curl -L -O https://artifacts.elastic.co/downloads/beats/metricbeat/metricbeat-8.14.2-amd64.deb
sudo dpkg -i metricbeat-8.14.2-amd64.deb
  1. 与 Filebeat 设置类似,使用管理员用户运行所有仪表板的初始设置,然后使用 API 密钥。

    POST /_security/api_key
    {
      "name": "metricbeat_javalin-app",
      "role_descriptors": {
        "metricbeat_writer": {
          "cluster": ["monitor", "read_ilm"],
          "index": [
            {
              "names": ["metricbeat-*"],
              "privileges": ["view_index_metadata", "create_doc"]
            }
          ]
        }
      }
    }
  2. idapi_key 字段的组合存储在密钥库中。

    ./metricbeat keystore create
    echo -n "IhrJJHMB4JmIUAPLuM35:1GbfxhkMT8COBB4JWY3pvQ" | ./metricbeat keystore add ES_API_KEY --stdin
    echo -n "observability-javalin-app:ZXUtY2VudHJhbC0xLmF3cy5jbG91ZC5lcy5pbyQ4NDU5M2I1YmQzYTY0N2NhYjA2MWQ3NTJhZWFhNWEzYyQzYmQwMWE2OTQ2MmQ0N2ExYjdhYTkwMzI0YjJiOTMyYQ==" | ./metricbeat keystore add CLOUD_ID --stdin

    不要忘记像这样进行初始设置。

    ./metricbeat setup -e -E 'cloud.id=${CLOUD_ID}' -E 'cloud.auth=elastic:YOUR_SUPER_SECRET_PASS'
  3. 配置 Metricbeat 以读取我们的 Prometheus 指标。从一个基本的 metricbeat.yaml 开始。

    metricbeat.config.modules:
      path: ${path.config}/modules.d/*.yml
      reload.enabled: false
    
    name: javalin-metrics-shipper
    
    cloud.id: ${CLOUD_ID}
    output.elasticsearch:
      api_key: ${ES_API_KEY}
    
    processors:
      - add_host_metadata: ~
      - add_cloud_metadata: ~
      - add_docker_metadata: ~
      - add_kubernetes_metadata: ~

    由于 Metricbeat 支持数十个模块,这些模块反过来又是摄取指标的不同方式(同样适用于 Filebeat,它具有不同类型的日志文件和格式),因此需要启用 Prometheus 模块。

    ./metricbeat modules enable prometheus

    将要轮询的 Prometheus 端点添加到 ./modules.d/prometheus.yml

    - module: prometheus
      period: 10s
      hosts: ["localhost:7000"]
      metrics_path: /metrics
      username: "metrics"
      password: "secret"
      use_types: true
      rate_counters: true
  4. 为了提高安全性,您应该将用户名和密码添加到密钥库中,并在配置中引用两者。
  5. 启动 Metricbeat。
sudo service metricbeat start

如果您使用 init.d 脚本启动 Metricbeat,则无法指定命令行标志(请参阅 命令参考)。要指定标志,请在前景中启动 Metricbeat。

另请参阅 Metricbeat 和 systemd

验证 Prometheus 事件是否正在流入 Elasticsearch。

GET metricbeat-*/_search?filter_path=**.prometheus,hits.total
{
  "query": {
    "term": {
      "event.module": "prometheus"
    }
  }
}

步骤 6:在 Kibana 中查看指标编辑

由于这是来自我们 Javalin 应用程序的自定义数据,因此没有用于显示此数据的预定义仪表板。

让我们检查每个日志级别的日志消息数量。

GET metricbeat-*/_search
{
  "query": {
    "exists": {
      "field": "prometheus.log4j2_events_total.counter"
    }
  }
}

可视化日志消息随时间的变化,按日志级别拆分。从 Elastic Stack 7.7 开始,有一种创建可视化的新方法,称为 Lens

  1. 登录 Kibana 并选择 可视化创建可视化
  2. 创建一个折线图并选择 metricbeat-* 作为源。

    基本思想是在 y 轴上对 prometheus.log4j2_events_total.rate 字段进行 最大聚合,而 x 轴则使用 @timestamp 字段上的 date_histogram 聚合 按日期拆分。

    在每个日期直方图桶内还有一个拆分,按日志级别拆分,使用 prometheus.labels.level 上的 terms 聚合,其中包含日志级别。此外,将日志级别的 size 增加到 6 以显示每个日志级别。

    最终结果如下所示。

    Date Histogram of the log rate per log level

可视化打开的文件随时间的变化编辑

第二个可视化是检查我们应用程序中打开的文件数量。

由于没有人能记住所有字段名,因此让我们再次先查看指标输出。

curl -s localhost:7000/metrics -u metrics:secret | grep ^process
process_files_max_files 10240.0
process_cpu_usage 1.8120711232436825E-4
process_uptime_seconds 72903.726
process_start_time_seconds 1.594048883317E9
process_files_open_files 61.0

让我们看一下 process_files_open_files 指标。这应该是一个相当静态的值,很少发生变化。如果您运行一个在 JVM 中存储数据或打开和关闭网络套接字的应用程序,则此指标会根据负载增加和减少。对于 Web 应用程序,这相当静态。让我们找出为什么我们的小型 Web 应用程序打开了 60 个文件。

  1. 运行 jps,它将包含您在进程列表中的应用程序。

    $ jps
    14224 Jps
    82437 Launcher
    82438 App
    40895
  2. 对该进程使用 lsof

    $ lsof -p 82438

    您将看到比所有打开的文件更多的输出,因为文件也是当前正在发生的 TCP 连接。

  3. 添加一个端点,通过具有长时间运行的 HTTP 连接(每个连接也被视为一个打开的文件,因为它需要一个文件描述符)来增加打开的文件数量,然后对它运行 wrk

    ...
    import java.util.concurrent.CompletableFuture;
    import java.util.concurrent.Executor;
    import java.util.concurrent.TimeUnit;
    ...
    
    public static void main(String[] args) {
    ...
        final Executor executor = CompletableFuture.delayedExecutor(20, TimeUnit.SECONDS);
        app.get("/wait", ctx -> {
            CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "done", executor);
            ctx.result(future);
        });
    ...

    每个 future 都被延迟了 20 秒,这意味着单个 HTTP 请求将保持打开状态 20 秒。

  4. 让我们运行一个 wrk 工作负载。

    wrk -c 100 -t 20 -d 5m https://127.0.0.1:7000/wait

    结果显示只发送了 20 个请求,这与处理时间相符。

    现在让我们使用 Kibana 中的 Lens 构建一个可视化。

    Lens visualization

  5. 添加过滤器 下方,选择 metricbeat-* 索引模式。这很可能使用 filebeat-* 作为默认值。

    x 轴使用 @timestamp 字段 - 这反过来又将再次创建一个 date_histogram 聚合。y 轴不应该是文档计数,因为那将始终保持稳定,而是桶中文档的最大值。单击 y 轴上字段名称右侧并选择 Max。这将为您提供与所示类似的可视化,在您运行上面的 wrk 命令时有一个峰值。

  6. 现在让我们看一下 Kibana 中的 Infrastructure 应用程序。选择 可观察性基础设施

    您只会看到来自单个发货人的数据。但是,当您运行多个服务时,能够按 Kubernetes Pod 或主机对这些服务进行分组,使您能够发现 CPU 或内存使用量较高的主机。

  7. 单击 指标资源管理器,您可以开始探索特定主机的数据或节点上的 CPU 使用情况。

    Metrics UI Log Counter

    这是一个 Javalin 应用程序发出的总事件计数器的区域图。它正在上升,因为有一个组件正在轮询一个端点,该端点反过来会生成另一个日志消息。较陡峭的峰值是由于发送了更多请求造成的。但是,突然下降是从哪里来的呢?JVM 重启。由于这些指标不会持久化,因此它们会在 JVM 重启时重置。考虑到这一点,通常最好记录 rate 而不是 counter 字段。

步骤 7:检测应用程序编辑

可观察性的第三部分是应用程序性能管理 (APM)。APM 设置包括一个 APM 服务器(它接受数据,并且已经在我们的 Elastic Cloud 设置中运行)和一个将数据传递到服务器的代理。

代理有两个任务:检测 Java 应用程序以提取应用程序性能信息,并将这些数据发送到 APM 服务器。

APM 的核心思想之一是能够跟踪用户会话在整个堆栈中的流动,无论您有几十个微服务还是一个单体来响应用户请求。这意味着能够在整个堆栈中标记请求。

要完全捕获用户活动,您需要从使用实时用户监控 (RUM) 的用户的浏览器开始,一直到您的应用程序,该应用程序向您的数据库发送 SQL 查询。

数据模型编辑

尽管 APM 环境高度分散,但术语通常相似。两个最重要的术语是 跨度事务

事务封装了一系列跨度,这些跨度包含有关代码片段执行的信息。让我们看一下 Kibana APM 应用程序中的此屏幕截图。

A transaction with spans

这是一个 Spring Boot 应用程序。调用了 UserProfileController.showProfile() 方法,该方法被标记为事务。其中包含两个跨度。首先,使用 Elasticsearch REST 客户端向 Elasticsearch 发送请求,然后使用 Thymeleaf 呈现响应。在这种情况下,对 Elasticsearch 的请求比渲染速度更快。

Java APM 代理可以自动检测特定框架。Spring 和 Spring Boot 受到良好支持,上面的数据是通过将代理添加到 Spring Boot 应用程序创建的;无需任何配置。

目前有针对 Go、.NET、Node、Python、Ruby 和浏览器 (RUM) 的代理。代理不断添加,因此您可能需要查看 APM 代理文档

将 APM 代理添加到您的代码中编辑

您有两个选项可以将 Java 代理检测添加到您的应用程序中。

首先,您可以在调用 java 二进制文件时通过参数添加代理。这样,它不会干扰应用程序的打包。此机制在启动时检测应用程序。

首先,下载代理,您可以查看 最新版本

wget https://repo1.maven.org/maven2/co/elastic/apm/elastic-apm-agent/1.17.0/elastic-apm-agent-1.17.0.jar

在启动时指定代理以及将 APM 数据发送到的配置参数。在启动 Java 应用程序之前,让我们获取在 Elastic Cloud 中运行的 APM 服务器的 API 密钥。

当您在 Elastic Cloud 中检查您的部署并单击左侧的 APM 时,您将看到 APM Server Secret Token,您可以使用它。您也可以从那里复制 APM 端点 URL。

java -javaagent:/path/to/elastic-apm-agent-1.17.0.jar\
  -Delastic.apm.service_name=javalin-app \
  -Delastic.apm.application_packages=de.spinscale.javalin \
  -Delastic.apm.server_urls=$APM_ENDPOINT_URL \
  -Delastic.apm.secret_token=PqWTHGtHZS2i0ZuBol \
  -jar build/libs/javalin-app-all.jar

您现在可以继续打开 APM UI,您应该会看到数据流入。

自动附加编辑

如果您不想更改应用程序的启动选项,独立代理允许您附加到主机上运行的 JVM。

这要求您下载独立的 jar。您可以在 官方文档 上找到链接。

要列出您本地运行的 Java 应用程序,您可以运行

java -jar /path/to/apm-agent-attach-1.17.0-standalone.jar --list

由于我通常在我的系统上运行多个 Java 应用程序,因此我指定要附加的应用程序。此外,请确保您已停止使用代理附加的 Javalin 应用程序,并仅启动一个没有配置代理的常规 Javalin 应用程序。

java -jar /tmp/apm-agent-attach-1.17.0-standalone.jar --pid 30730 \
  --config service_name=javalin-app \
  --config application_packages=de.spinscale.javalin \
  --config server_urls=$APM_ENDPOINT_URL \
  --config secret_token=PqWTHGtHZS2i0ZuBol

上面的消息将返回类似以下内容

2020-07-10 15:04:48.144  INFO Attaching the Elastic {apm-agent} to 30730
2020-07-10 15:04:49.649  INFO Done

因此,现在代理已附加到具有特殊配置的正在运行的应用程序。

虽然前两种可能性都适用,但您也可以使用第三种可能性:将 APM 代理用作直接依赖项。这使您能够在我们的应用程序中编写自定义跨度和事务。

编程设置编辑

编程设置允许您通过在源代码中添加一行 Java 代码来附加代理。

  1. 添加 Java 代理依赖项。

    dependencies {
      ...
      implementation 'co.elastic.apm:apm-agent-attach:1.17.0'
      ...
    }
  2. 在我们的 main() 方法中,在开始时检测应用程序。

    import co.elastic.apm.attach.ElasticApmAttacher;
    
    ...
    
    public static void main(String[] args) {
        ElasticApmAttacher.attach();
        ...
    }

    我们还没有配置任何端点或 API 令牌。虽然 文档 建议使用 src/main/resources/elasticapm.properties 文件,但我更喜欢使用环境变量,因为这可以防止将 API 令牌提交到您的源代码或合并另一个存储库。像 vault 这样的机制允许您以这种方式管理您的机密。

    对于我们的本地部署,我通常使用类似于 direnv 的东西进行本地设置。direnv 是您本地 shell 的一个扩展,当您进入目录(如您的应用程序)时,它会加载/卸载环境变量。direnv 可以做更多的事情,例如加载正确的节点/ruby 版本或将目录添加到您的 $PATH 变量中。

  3. 要启用 direnv,您需要创建一个包含以下内容的 .envrc 文件。

    dotenv

    这告诉 direnv.env 文件的内容加载为环境变量。.env 文件应如下所示

    ELASTIC_APM_SERVICE_NAME=javalin-app
    ELASTIC_APM_SERVER_URLS=https://APM_ENDPOINT_URL
    ELASTIC_APM_SECRET_TOKEN=PqWTHGtHZS2i0ZuBol

    如果您不习惯将敏感数据放在 .env 文件中,您可以使用像 envchain 这样的工具,或者在 .envrc 文件中调用任意命令,例如访问 Vault。

  4. 您现在可以像以前一样运行 Java 应用程序。

    java -jar build/libs/javalin-app-all.jar

    如果您想在 IDE 中运行它,您可以手动设置环境变量,或者搜索支持 .env 文件的插件。

    等待几分钟,让我们最后看一下 APM 应用程序。

    Javalin App APM UI

    如您所见,这与之前显示的 Spring Boot 应用程序有很大不同。不同的端点没有列出;我们可以看到每分钟的请求,包括错误。

    唯一的事务来自单个 servlet,这不太有用。让我们尝试通过引入自定义编程事务来解决此问题。

自定义事务编辑

  1. 添加另一个依赖项。

    dependencies {
      ...
      implementation 'co.elastic.apm:apm-agent-attach:1.17.0'
      implementation 'co.elastic.apm:apm-agent-api:1.17.0'
      ...
    }
  2. 修复事务的名称以包括 HTTP 方法和请求路径

    app.before(ctx -> ElasticApm.currentTransaction()
      .setName(ctx.method() + " " + ctx.path()));
  3. 重新启动您的应用程序,并查看数据流入。测试几个不同的端点,尤其是引发异常的端点和触发 404 的端点。

    APM UI with correct transaction names

    看起来好多了,端点之间有差异。

  4. 添加另一个端点以查看事务的功能,该端点轮询另一个 HTTP 服务。您可能听说过 wttr.in,这是一个轮询天气信息的服務。让我们实现一个代理 HTTP 方法,该方法将请求转发到该端点。让我们使用 Apache HTTP 客户端,这是最典型的 HTTP 客户端之一。

    implementation 'org.apache.httpcomponents:fluent-hc:4.5.12'

    这是我们的新端点。

    import org.apache.http.client.fluent.Request;
    
    ...
    
    public static void main(String[] args) {
    ...
    
        app.get("/weather/:city", ctx -> {
            String city = ctx.pathParam("city");
            ctx.result(Request.Get("https://wttr.in/" + city + "?format=3").execute()
                .returnContent().asBytes())
                .contentType("text/plain; charset=utf-8");
        });
    
    ...
  5. Curl https://127.0.0.1:7000/weather/Munich 并查看有关当前天气的单行响应。让我们检查 APM UI。

    在概述中,您可以看到大部分时间都花在 HTTP 客户端上,这并不奇怪。

    Overview

    我们针对 /weather/Munich 的事务现在包含一个跨度,显示了在检索天气数据上花费了多少时间。由于 HTTP 客户端是自动检测的,因此无需执行任何操作。

    Transaction with span

    如果该 URL 中的 city 参数具有高基数,这将导致大量提及的 URL,而不是通用的端点。如果您想防止这种情况,一种可能性是使用 ctx.matchedPath() 将对天气 API 的每次调用记录为 GET /weather/:city。但是,这需要进行一些重构,即删除 app.before() 处理程序,并将其替换为 app.after() 处理程序。

    app.after(ctx -> ElasticApm.currentTransaction().setName(ctx.method()
      + " " + ctx.endpointHandlerPath()));

通过代理配置进行方法跟踪编辑

除了编写代码来跟踪方法之外,您还可以配置代理来执行此操作。让我们尝试确定日志记录是否是我们应用程序的瓶颈,并跟踪我们之前添加的请求日志记录语句。

代理可以 跟踪方法,方法是根据其签名。

要监控的接口是 io.javalin.http.RequestLogger 接口,其中包含 handle 方法。因此,让我们尝试使用 io.javalin.http.RequestLogger#handle 来识别要记录的方法,并将此方法放在您的 .env 中。

ELASTIC_APM_TRACE_METHODS="de.spinscale.javalin.Log4j2RequestLogger#handle"
  1. 创建一个专用的记录器类,以匹配上面的跟踪方法。

    package de.spinscale.javalin;
    
    import io.javalin.http.Context;
    import io.javalin.http.RequestLogger;
    import org.jetbrains.annotations.NotNull;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    public class Log4j2RequestLogger implements RequestLogger  {
    
        private final Logger logger = LoggerFactory.getLogger(Log4j2RequestLogger.class);
    
        @Override
        public void handle(@NotNull Context ctx, @NotNull Float executionTimeMs) throws Exception {
            String userAgent = ctx.userAgent() != null ? ctx.userAgent() : "-";
            logger.info("{} {} {} {} \"{}\" {}",
                    ctx.method(), ctx.req.getPathInfo(), ctx.res.getStatus(),
                    ctx.req.getRemoteHost(), userAgent, executionTimeMs.longValue());
        }
    }
  2. 修复我们 App 类中的调用。

    config.requestLogger(new Log4j2RequestLogger());
  3. 重新启动您的应用程序,并查看日志记录需要多少时间。

    Logging caller trace

    请求记录器大约需要 400 微秒。整个请求大约需要 1.3 毫秒。大约三分之一的请求处理时间用于记录。

    如果您正在寻找更快的服务,您可能需要重新考虑记录。但是,此记录发生在结果写入客户端之后,因此虽然总处理时间会随着记录的增加而增加,但对客户端的响应不会(但是关闭连接可能会)。另请注意,这些测试是在没有适当预热的情况下进行的。我假设在适当的 JVM 预热之后,您将拥有更快的请求处理速度。

推断跨度的自动分析编辑

一旦您拥有一个比我们的示例应用程序具有更多代码路径的更大应用程序,您可以尝试通过设置以下内容来启用推断跨度的自动分析

ELASTIC_APM_PROFILING_INFERRED_SPANS_ENABLED=true

此机制使用异步分析器来创建跨度,而无需您进行任何检测,从而使您能够更快地找到瓶颈。

日志关联编辑

事务 ID 会自动添加到日志中。您可以检查通过 Filebeat 发送到 Elasticsearch 的生成的日志文件。条目如下所示。

{
  "@timestamp": "2020-07-13T12:03:22.491Z",
  "log.level": "INFO",
  "message": "GET / 200 0:0:0:0:0:0:0:1 \"curl/7.64.1\" 0",
  "service.name": "my-javalin-app",
  "event.dataset": "my-javalin-app.log",
  "process.thread.name": "qtp34871826-36",
  "log.logger": "de.spinscale.javalin.Log4j2RequestLogger",
  "trace.id": "ed735860ec0cd3ee3bdf80ed7ea47afb",
  "transaction.id": "8af7dff698937dc5"
}

在添加了trace.idtransaction.id 的情况下,如果出现错误,您将获得一个error.id 字段。

我们还没有介绍Elastic APM OpenTracing 桥接或查看代理提供的其他指标,这使我们能够查看诸如垃圾收集或应用程序的内存占用等内容。

步骤 8:摄取正常运行时间数据编辑

到目前为止,我们的应用程序中有一些基本的监控功能。我们索引日志(带有跟踪),我们索引指标,我们甚至可以查看我们的应用程序以找出单个性能瓶颈,这要归功于 APM。但是,仍然有一个弱点。到目前为止所做的一切都在应用程序内部,但所有用户都从互联网访问应用程序。

如何检查我们的用户是否拥有与我们的 APM 数据建议给我们的相同的体验。想象一下,您的应用程序前面有一个滞后的负载均衡器,这会给您每个请求带来额外的 50 毫秒的成本。这将是毁灭性的。或者 TLS 协商成本很高。即使这些外部事件都不是您的错,您仍然会受到影响,并且应该尝试减轻这些影响。这意味着您需要首先了解它们。

正常运行时间不仅使您能够监控服务的可用性,还可以绘制随时间推移的延迟图,并接收有关 TLS 证书到期的通知。

设置编辑

要将正常运行时间数据发送到 Elasticsearch,需要 Heartbeat(轮询组件)。要下载和安装 Heartbeat,请使用适合您系统的命令

curl -L -O https://artifacts.elastic.co/downloads/beats/heartbeat/heartbeat-8.14.2-amd64.deb
sudo dpkg -i heartbeat-8.14.2-amd64.deb

下载并解压缩后,我们必须再次设置云 ID 和密码。

  1. 我们需要在 Kibana 中以 elastic 管理员用户的身份创建另一个API_KEY

    POST /_security/api_key
    {
      "name": "heartbeat_javalin-app",
      "role_descriptors": {
        "heartbeat_writer": {
          "cluster": ["monitor", "read_ilm"],
          "index": [
            {
              "names": ["heartbeat-*"],
              "privileges": ["view_index_metadata", "create_doc"]
            }
          ]
        }
      }
    }
  2. 让我们设置 Heartbeat 密钥库并运行设置。

    ./heartbeat keystore create
    echo -n "observability-javalin-app:ZXUtY2VudHJhbC0xLmF3cy5jbG91ZC5lcy5pbyQ4NDU5M2I1YmQzYTY0N2NhYjA2MWQ3NTJhZWFhNWEzYyQzYmQwMWE2OTQ2MmQ0N2ExYjdhYTkwMzI0YjJiOTMyYQ==" | ./heartbeat keystore add CLOUD_ID --stdin
    echo -n "SCdUSHMB1JmLUFPLgWAY:R3PQzBWW3faJT01wxXD6uw" | ./heartbeat keystore add ES_API_KEY --stdin
    
    ./heartbeat setup -e -E 'cloud.id=${CLOUD_ID}' -E 'cloud.auth=elastic:YOUR_SUPER_SECRET_PASS'
  3. 添加一些要监控的服务。

    name: heartbeat-shipper
    
    cloud.id: ${CLOUD_ID}
    output.elasticsearch:
      api_key: ${ES_API_KEY}
    
    heartbeat.monitors:
      - type: http
        id: javalin-http-app
        name: "Javalin Web Application"
        urls: ["https://127.0.0.1:7000"]
        check.response.status: [200]
        schedule: '@every 15s'
    
      - type: http
        id: httpbin-get
        name: "httpbin GET"
        urls: ["https://httpbin.org/get"]
        check.response.status: [200]
        schedule: '@every 15s'
    
      - type: tcp
        id: javalin-tcp
        name: "TCP Port 7000"
        hosts: ["localhost:7000"]
        schedule: '@every 15s'
    
    processors:
      - add_observer_metadata:
          geo:
            name: europe-munich
            location: "48.138791, 11.583030"
  4. 现在启动 Heartbeat 并等待几分钟以获取一些数据。
sudo service heartbeat start

如果您使用init.d 脚本启动 Heartbeat,则无法指定命令行标志(请参阅命令参考)。要指定标志,请在前景中启动 Heartbeat。

另请参阅Heartbeat 和 systemd

要查看正常运行时间应用程序,请选择可观察性正常运行时间。概述如下所示。

Uptime Overview

您可以看到监控器的列表和全局概述。让我们看看其中一个警报的详细信息。单击Javalin Web 应用程序

您可以看到上次计划检查的执行情况,但每次检查的持续时间可能更有趣。您可以查看其中一项检查的延迟是否正在上升。

有趣的部分是顶部的世界地图。您可以在配置中指定检查的来源,在本例中是欧洲的慕尼黑。通过配置在世界各地运行的多个 Heartbeat,您可以比较延迟并确定需要运行应用程序以靠近用户的哪个数据中心。

监控器的持续时间在毫秒级,因为它非常快。检查httpbin.org 端点的监控器,您将看到更高的持续时间。在本例中,每次请求大约需要 400 毫秒。这并不奇怪,因为端点并不在附近,并且您需要为每次请求启动 TLS 连接,这很昂贵。

不要低估这种监控的重要性。此外,请将此视为一个开始,因为下一步是拥有合成监控器,以监控应用程序的正确行为,例如,确保您的结账流程始终有效。

下一步是什么?编辑

有关使用 Elastic 可观察性的更多信息,请参阅可观察性文档