This article will teach you how to monitor spring boot application’s live logs online via websocket.

Create a project

spring boot app

poml

The spring-boot-starter-websocket dependency needs to be added.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <groupId>io.springcloud</groupId>
    <artifactId>springcloud-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.4</version>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <encoding>UTF-8</encoding>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-undertow</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
        
        <!-- WebSocket Starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <executable>true</executable>
                </configuration>
            </plugin>
        </plugins>
    </build>
    </project>

1. Configure WebSocket

LogChannel

Customize a WebSocket endpoint. It will store every connection initiated by the client. And it can push messages to all connections.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package io.springcloud.demo.channel;

import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import javax.websocket.CloseReason;
import javax.websocket.EndpointConfig;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.CloseReason.CloseCodes;
import javax.websocket.server.ServerEndpoint;

import lombok.extern.slf4j.Slf4j;

@ServerEndpoint(value = "/channel/log")
@Slf4j
public class LogChannel {

    public static final ConcurrentMap<String, LogChannel> CHANNELS = new ConcurrentHashMap<>();

    private Session session;

    @OnMessage(maxMessageSize = 1) // MaxMessage 1 byte
    public void onMessage(String message) {
        
        log.debug("Recv Message: {}", message);
        
        try {
            this.session.close(new CloseReason(CloseCodes.TOO_BIG, "This endpoint does not accept client messages"));
        } catch (IOException e) {
            log.error("Connection close error: id={}, err={}", this.session.getId(), e.getMessage());
        }
    }

    @OnOpen
    public void onOpen(Session session, EndpointConfig endpointConfig) {
        this.session = session;
        this.session.setMaxIdleTimeout(0);
        CHANNELS.put(this.session.getId(), this);
        
        log.info("New client connection: id={}", this.session.getId());
    }

    @OnClose
    public void onClose(CloseReason closeReason) {

        log.info("Connection disconnected: id={}, err={}", this.session.getId(), closeReason);

        CHANNELS.remove(this.session.getId());
    }

    @OnError
    public void onError(Throwable throwable) throws IOException {
        log.info("Connection Error: id={}, err={}", this.session.getId(), throwable);
        this.session.close(new CloseReason(CloseCodes.UNEXPECTED_CONDITION, throwable.getMessage()));
    }

    /**
        * Push messages to all clients
        * 
        * @param message
        */
    public static void push(Object message) {
        CHANNELS.values().stream().forEach(endpoint -> {
            if (endpoint.session.isOpen()) {
                endpoint.session.getAsyncRemote().sendObject(message);
            }
        });
    }
}

WebSocketConfiguration

Configure the custom WebSocket endpoint into ServerEndpointExporter to enable it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package io.springcloud.demo.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

import io.springcloud.demo.channel.LogChannel;

@Configuration
public class WebSocketConfiguration {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        ServerEndpointExporter serverEndpointExporter = new ServerEndpointExporter();
        
        /**
            * Add one or more classes annotated with `@ServerEndpoint`.
            */
        serverEndpointExporter.setAnnotatedEndpointClasses(LogChannel.class);
        
        return serverEndpointExporter;
    }
}

2. Configuring Logback

spring boot uses logback as the logging framework by default. So there is no need to add additional dependencies.

WebSocketAppender

Customize a logback Appender implementation. Its main function is to push logs to the client via WebSocket.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package io.springcloud.demo.logback;

import java.nio.charset.StandardCharsets;

import ch.qos.logback.classic.encoder.PatternLayoutEncoder;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.AppenderBase;
import io.springcloud.demo.channel.LogChannel;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = true)
public class WebSocketAppender extends AppenderBase<ILoggingEvent> {

    // encoder is required. And it has to have legal getter/setter methods.
    private PatternLayoutEncoder encoder;

    @Override
    protected void append(ILoggingEvent eventObject) {
        
        // Use encoder to encode logs.
        byte[] data = this.encoder.encode(eventObject);

        // Push to client.
        LogChannel.push(new String(data, StandardCharsets.UTF_8));
    }
}

application.yaml

Specify the Logback configuration file via logging.config.

1
2
3
4
5
6
server:
  port: 80

# xml configuration file for logback
logging:
  config: classpath:logback-spring.xml

logback-spring.xml

Add a custom appender implementation to the configuration file. and use it in the root node.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xml>
<configuration>

    <!-- Use some logging configuration predefined by spring boot. For example: formatting pattern of logs. -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <!-- Console -->
    <appender name="console"
        class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- WebSocket -->
    <appender name="websocket"
        class="io.springcloud.demo.logback.WebSocketAppender">
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>


    <root level="DEBUG">
        <appender-ref ref="console" />
        <appender-ref ref="websocket" /> <!-- The logs will be output to the WebSocketAppender -->
    </root>

</configuration>

3. Client

index.html

This is a very rudimentary client. It will connect to the logging endpoint using a websocket, and will output to the browser console after receiving log messages pushed from the server in real time.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>app log</title>
    </head>
    <body>

    Please check the live log of the application in the console.

    <script type="text/javascript">
        const websocket = new WebSocket("ws://localhost/channel/log");
        websocket.onclose = e => {
            console.log(`conn closed: code=${e.code}, reason=${e.reason}, wasClean=${e.wasClean}`)
        }
        websocket.onmessage = e => {
            console.log(`${e.data}`);
        }
        websocket.onerror = e => {
            console.log(`conn err`)
            console.error(e)
        }
        websocket.onopen = e => {
            console.log(`conn open: ${e}`);
        }
    </script>
    </body>
</html>

Testing

After starting the application, use your browser to access the http://localhost address and open the browser console. Wait for the server log output.

1
2
conn open: [object Event]
2022-03-15 13:22:16.976  INFO 13972 --- [XNIO-1 task-1] io.springcloud.demo.channel.LogChannel   : New client connection: id=7jrVhs5OZyDEaXkSCOEDE8lSLQFktzaMrkJh88pd

Open another browser (or another tab) at this point. Visit the http://localhost/404 address. Then you will see the exception log message output from the browser console in the previous step.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
2022-03-15 13:26:41.111 DEBUG 13972 --- [  XNIO-1 task-1] io.undertow.request.security             : Attempting to authenticate /404, authentication required: false
2022-03-15 13:26:41.111 DEBUG 13972 --- [  XNIO-1 task-1] io.undertow.request.security             : Authentication outcome was NOT_ATTEMPTED with method io.undertow.security.impl.CachedAuthenticatedSessionMechanism@b1b72d4 for /404
2022-03-15 13:26:41.111 DEBUG 13972 --- [  XNIO-1 task-1] io.undertow.request.security             : Authentication result was ATTEMPTED for /404
2022-03-15 13:26:41.112 DEBUG 13972 --- [  XNIO-1 task-1] o.s.web.servlet.DispatcherServlet        : GET "/404", parameters={}
2022-03-15 13:26:41.113 DEBUG 13972 --- [  XNIO-1 task-1] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped to ResourceHttpRequestHandler [classpath [META-INF/resources/], classpath [resources/], classpath [static/], classpath [public/], ServletContext [/]]
2022-03-15 13:26:41.114 DEBUG 13972 --- [  XNIO-1 task-1] o.s.w.s.r.ResourceHttpRequestHandler     : Resource not found
2022-03-15 13:26:41.114 DEBUG 13972 --- [  XNIO-1 task-1] o.s.web.servlet.DispatcherServlet        : Completed 404 NOT_FOUND
2022-03-15 13:26:41.114 DEBUG 13972 --- [  XNIO-1 task-1] o.s.web.servlet.DispatcherServlet        : "ERROR" dispatch for GET "/error", parameters={}
2022-03-15 13:26:41.115 DEBUG 13972 --- [  XNIO-1 task-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#errorHtml(HttpServletRequest, HttpServletResponse)
2022-03-15 13:26:41.117 DEBUG 13972 --- [  XNIO-1 task-1] o.s.w.s.v.ContentNegotiatingViewResolver : Selected 'text/html' given [text/html, text/html;q=0.8]
2022-03-15 13:26:41.117 DEBUG 13972 --- [  XNIO-1 task-1] o.s.web.servlet.DispatcherServlet        : Exiting from "ERROR" dispatch, status 404

The next thing to do is to develop a nice client.