├── .gitignore ├── .travis.yml ├── README.md ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── github │ │ └── alexvictoor │ │ └── weblogback │ │ ├── BrowserConsoleAppender.java │ │ ├── ChannelOutputStream.java │ │ ├── ChannelRegistry.java │ │ ├── FileReader.java │ │ ├── ServerInitializer.java │ │ ├── ServerSentEvent.java │ │ ├── ServerSentEventHandler.java │ │ └── WebServer.java └── resources │ ├── homepage.html │ └── logback.js ├── site └── screenshot.png └── test ├── java └── com │ └── github │ └── alexvictoor │ └── weblogback │ ├── ChannelOutputStreamTest.java │ ├── FileReaderTest.java │ ├── Main.java │ ├── ServerSentEventTest.java │ └── WebServerTest.java └── resources ├── logback-web.xml └── test.file /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | pom.xml.tag 3 | pom.xml.releaseBackup 4 | pom.xml.versionsBackup 5 | pom.xml.next 6 | release.properties 7 | 8 | .idea 9 | *.iml 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/alexvictoor/web-logback.svg?branch=master)](https://travis-ci.org/alexvictoor/web-logback) 2 | 3 | web-logback 4 | =========== 5 | 6 | ![screenshot](src/site/screenshot.png) 7 | 8 | Logback appender leveraging "HTML5 Server Sent Event" (SSE) to push logs on browser consoles. 9 | It is based on worderful Netty framework to implement a lightweight HTTP SSE server. 10 | 11 | Usage 12 | ------ 13 | 14 | Activation requires 3 steps: 15 | - configuration of your build to add a dependency to this project 16 | - configuration of the appender in the logback.xml configuration file 17 | - inclusion of a javascript snippet in your HTML code to open a SSE connection 18 | 19 | If you use maven, below the xml fragment you should add in the dependencies section of your pom file: 20 | ```xml 21 | 22 | com.github.alexvictoor 23 | web-logback 24 | 0.2 25 | 26 | ``` 27 | 28 | Below an XML fragment example that shows how to configure logback on the server side 29 | ```xml 30 | 31 | ... 32 | 33 | 34 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 35 | 36 | 8765 37 | true 38 | 1 39 | 40 | ... 41 | 42 | ``` 43 | 44 | In the browser side, the easiest way to get the logs is to include in your HTML document logback.js. This script is delivered by the embedded HTTP SSE server at URL path "/logback.js". 45 | During developpement, if you are running the appender with default setting you can simply add the following declaration in your HTML code: 46 | 47 | 48 | 49 | It gets even simpler when using a bookmarklet. To do so use your browser to display the "homepage" of the embedded HTTP SSE server (at URL http:// HOST : PORT where HOST & PORT are the parameters you have used in the log4net configuration). The main purpose of this "homepage" is to test your web-logback configuration but it also brings a ready to use bookmarklet (named "Get Logs!"). This bookmarklet looks like code fragment below: 50 | 51 | (function () { 52 | var jsCode = document.createElement('script'); 53 | jsCode.setAttribute('src', 'http://HOST:PORT/logback.js'); 54 | document.body.appendChild(jsCode); 55 | }()); 56 | 57 | Why SSE? 58 | -------- 59 | [Server Sent Events](https://en.wikipedia.org/wiki/Server-sent_events) while being quite simple to implement, allows to stream data form a server to a browser leveraging on the HTTP protocol, just the HTTP protocol. Resources required on the server side are also very low. Simply put, to stream text log it makes no sens to use WebSocket which is much more complex. 60 | All newest browsers implement SSE, except IE... (no troll intended). Chrome even implements a nice reconnection strategy. Hence when you are using a Chrome console as a log viewer, log streams will survive to a restart of a server side process, reconnection are handled automatically by the browser. 61 | 62 | Multiple server-side process? 63 | ----------------------------- 64 | If you want to watch logs coming from several process, you just need one single browser console! 65 | Several bookmarlets can be executed on same browser location. To distinguisg logs coming from one service to logs coming from another one, you can use different log4net pattern, use a log prefix, or use custom styles (see next section). 66 | 67 | 68 | Custom colors and styles? 69 | ------------------------- 70 | Once you are connected to several log streams, you will might want to get different visual appearance for those streams. 71 | By default all streams use default browser log styles. Styles can be customized by adding special attributed to logback.js script tag: 72 | 73 | 77 | 78 | Styles attrbutes can also be specific to a logging category: 79 | 80 | 85 | 86 | With above example, all logs are written in black on white, size 12px, except error logs written in red, size 18px. 87 | Below a bookmarklet code that gives similar results: 88 | 89 | (function () { 90 | var jsCode = document.createElement('script'); 91 | jsCode.setAttribute('src', 'http://HOST:PORT/logback.js'); 92 | jsCode.setAttribute('style' 'color: black;'); 93 | jsCode.setAttribute('style-error' 'color: red; font-size: 18px;'); 94 | document.body.appendChild(jsCode); 95 | }()); 96 | 97 | Custom styles can be specify for debug (style-debug), info (style-info) and warn (style-warn) logs as well. 98 | 99 | 100 | 101 | Disclaimer 102 | --------- 103 | 1. For obvious security concerns, **do not activate it in production!** 104 | 2. Do not try to use this appender to log Netty activity... this might generate an infinite loop 105 | 3. This is a first basic not opimized implementation, no batching of log messages, etc 106 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 3 | 4.0.0 4 | com.github.alexvictoor 5 | web-logback 6 | jar 7 | 1.0-SNAPSHOT 8 | web-logback 9 | https://github.com/alexvictoor/web-logback 10 | Logback appender relying on HTML5 SSE to push logs on browser consoles 11 | 12 | 13 | 14 | https://github.com/alexvictoor/web-logback 15 | scm:git:git@github.com:alexvictoor/web-logback.git 16 | scm:git:git@github.com:alexvictoor/web-logback.git 17 | 18 | 19 | 20 | 21 | The Apache Software License, Version 2.0 22 | http://www.apache.org/licenses/LICENSE-2.0.txt 23 | repo 24 | 25 | 26 | 27 | 28 | https://github.com/alexvictoor/web-logback/issues 29 | GitHub 30 | 31 | 32 | 33 | 34 | ossrh 35 | https://oss.sonatype.org/content/repositories/snapshots 36 | 37 | 38 | ossrh 39 | https://oss.sonatype.org/service/local/staging/deploy/maven2/ 40 | 41 | 42 | 43 | 44 | 45 | alexvictoor 46 | Alexandre Victoor 47 | alexvictoor@gmail.com 48 | 49 | 50 | 51 | 52 | 4.11 53 | 2.16 54 | 4.0.24.Final 55 | 56 | 57 | 58 | io.netty 59 | netty-transport 60 | ${netty.version} 61 | 62 | 63 | io.netty 64 | netty-codec-http 65 | ${netty.version} 66 | 67 | 68 | org.slf4j 69 | slf4j-api 70 | 1.6.4 71 | 72 | 73 | ch.qos.logback 74 | logback-classic 75 | 1.1.2 76 | 77 | 78 | 79 | junit 80 | junit 81 | ${junit.version} 82 | test 83 | 84 | 85 | org.assertj 86 | assertj-core 87 | 1.7.0 88 | test 89 | 90 | 91 | com.jayway.awaitility 92 | awaitility 93 | 1.6.2 94 | test 95 | 96 | 97 | org.mockito 98 | mockito-all 99 | 1.10.8 100 | test 101 | 102 | 103 | com.squareup.okhttp 104 | okhttp 105 | 2.5.0 106 | test 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | org.apache.maven.plugins 117 | maven-compiler-plugin 118 | 3.2 119 | 120 | 1.7 121 | 1.7 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | release 131 | 132 | 133 | 134 | org.apache.maven.plugins 135 | maven-source-plugin 136 | 2.2.1 137 | 138 | 139 | attach-sources 140 | 141 | jar 142 | 143 | 144 | 145 | 146 | 147 | org.apache.maven.plugins 148 | maven-javadoc-plugin 149 | 2.9.1 150 | 151 | 152 | javadoc 153 | 154 | jar 155 | 156 | 157 | 158 | 159 | 160 | org.apache.maven.plugins 161 | maven-gpg-plugin 162 | 1.5 163 | 164 | 165 | sign-artifacts 166 | verify 167 | 168 | sign 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | -------------------------------------------------------------------------------- /src/main/java/com/github/alexvictoor/weblogback/BrowserConsoleAppender.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | 4 | import ch.qos.logback.classic.spi.ILoggingEvent; 5 | import ch.qos.logback.core.OutputStreamAppender; 6 | import org.slf4j.ILoggerFactory; 7 | import org.slf4j.LoggerFactory; 8 | import org.slf4j.helpers.SubstituteLoggerFactory; 9 | 10 | import java.io.IOException; 11 | import java.net.InetAddress; 12 | import java.net.UnknownHostException; 13 | 14 | public class BrowserConsoleAppender extends OutputStreamAppender { 15 | 16 | private String host; 17 | private int port = 8765; 18 | private int buffer = 1; 19 | private boolean active = true; 20 | private WebServer webServer; 21 | private ChannelOutputStream stream; 22 | 23 | public void setActive(boolean active) { 24 | this.active = active; 25 | } 26 | 27 | public void setHost(String host) { 28 | this.host = host; 29 | } 30 | 31 | public void setPort(int port) { 32 | this.port = port; 33 | } 34 | 35 | public void setBuffer(int buffer) { 36 | this.buffer = buffer; 37 | } 38 | 39 | @Override 40 | protected void writeOut(E event) throws IOException { 41 | if (event instanceof ILoggingEvent) { 42 | ILoggingEvent loggingEvent = (ILoggingEvent) event; 43 | // 44 | // dirty hack - not easy to work with logback appenders 45 | // since the 'encoder' works with an outputstream set at init 46 | // 47 | stream.setCurrentLevel(loggingEvent.getLevel()); 48 | } 49 | super.writeOut(event); 50 | } 51 | 52 | @Override 53 | public void start() { 54 | if (!active) { 55 | return; 56 | } 57 | 58 | if ("".equals(host) || host==null) { 59 | try { 60 | host = InetAddress.getLocalHost().getHostName(); 61 | } catch (UnknownHostException e) { 62 | host = "localhost"; 63 | } 64 | } 65 | 66 | new Thread(new Runnable() { 67 | @Override 68 | public void run() { 69 | waitForSlf4jInitialization(); 70 | webServer = new WebServer(host, port, buffer); 71 | stream = webServer.start(); 72 | setOutputStream(stream); 73 | BrowserConsoleAppender.super.start(); 74 | } 75 | }).start(); 76 | super.start(); 77 | } 78 | 79 | /** 80 | * Dirty hack but no obvious other way to do it 81 | */ 82 | private void waitForSlf4jInitialization() { 83 | try { 84 | Integer lock = new Integer(0); 85 | synchronized (lock) { 86 | while (isSlf4jUninitialized()) { 87 | lock.wait(100); 88 | } 89 | } 90 | } catch (InterruptedException e) { 91 | throw new RuntimeException(e); 92 | } 93 | } 94 | 95 | private boolean isSlf4jUninitialized() { 96 | ILoggerFactory factory = LoggerFactory.getILoggerFactory(); 97 | return factory instanceof SubstituteLoggerFactory; 98 | } 99 | 100 | @Override 101 | public void stop() { 102 | if (!active) { 103 | return; 104 | } 105 | webServer.stop(); 106 | super.stop(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/com/github/alexvictoor/weblogback/ChannelOutputStream.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | import ch.qos.logback.classic.Level; 4 | import io.netty.buffer.ByteBuf; 5 | import io.netty.buffer.Unpooled; 6 | import io.netty.channel.Channel; 7 | import io.netty.channel.group.ChannelGroup; 8 | import io.netty.handler.codec.http.DefaultHttpContent; 9 | import io.netty.handler.codec.http.HttpContent; 10 | 11 | import java.io.IOException; 12 | import java.io.OutputStream; 13 | import java.nio.charset.Charset; 14 | import java.util.ArrayList; 15 | import java.util.List; 16 | import java.util.concurrent.locks.Lock; 17 | import java.util.concurrent.locks.ReentrantLock; 18 | 19 | public class ChannelOutputStream extends OutputStream implements ChannelRegistry { 20 | 21 | private final ChannelGroup channels; 22 | private final List buffer; 23 | private Level currentLevel; 24 | private int bufferSize; 25 | 26 | public ChannelOutputStream(ChannelGroup channels, int bufferSize) { 27 | this.channels = channels; 28 | this.bufferSize = bufferSize; 29 | this.currentLevel = Level.OFF; 30 | this.buffer = new ArrayList<>(); 31 | } 32 | 33 | @Override 34 | public void addChannel(Channel ch) { 35 | channels.add(ch); 36 | synchronized (buffer) { 37 | for (ServerSentEvent event : buffer) { 38 | ByteBuf buffer = Unpooled.copiedBuffer(event.toString(), Charset.defaultCharset()); 39 | HttpContent content = new DefaultHttpContent(buffer); 40 | ch.write(content); 41 | } 42 | ch.flush(); 43 | } 44 | } 45 | 46 | @Override 47 | public void write(int b) throws IOException { 48 | // should not be called 49 | } 50 | 51 | @Override 52 | public void write(byte[] data, int off, int len) throws IOException { 53 | String msg = new String(data, off, len); 54 | String level = currentLevel.toString(); 55 | ServerSentEvent event = new ServerSentEvent(level, msg); 56 | synchronized (buffer) { 57 | buffer.add(event); 58 | if (buffer.size() > bufferSize) { 59 | buffer.remove(0); 60 | } 61 | } 62 | ByteBuf buffer = Unpooled.copiedBuffer(event.toString(), Charset.defaultCharset()); 63 | HttpContent content = new DefaultHttpContent(buffer); 64 | channels.write(content); 65 | } 66 | 67 | @Override 68 | public void flush() throws IOException { 69 | channels.flush(); 70 | } 71 | 72 | 73 | public void setCurrentLevel(Level currentLevel) { 74 | this.currentLevel = currentLevel; 75 | } 76 | } -------------------------------------------------------------------------------- /src/main/java/com/github/alexvictoor/weblogback/ChannelRegistry.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | 4 | import io.netty.channel.Channel; 5 | 6 | public interface ChannelRegistry { 7 | 8 | void addChannel(Channel ch); 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/github/alexvictoor/weblogback/FileReader.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | import java.util.Scanner; 4 | 5 | public class FileReader { 6 | 7 | public String readFileFromClassPath(String classPath) { 8 | return new Scanner(getClass().getResourceAsStream(classPath)).useDelimiter("\\Z").next(); 9 | 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/github/alexvictoor/weblogback/ServerInitializer.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | 4 | import io.netty.channel.ChannelInitializer; 5 | import io.netty.channel.ChannelPipeline; 6 | import io.netty.channel.group.ChannelGroup; 7 | import io.netty.channel.socket.SocketChannel; 8 | import io.netty.handler.codec.http.HttpRequestDecoder; 9 | import io.netty.handler.codec.http.HttpResponseEncoder; 10 | 11 | public class ServerInitializer extends ChannelInitializer { 12 | 13 | private final ChannelRegistry channelRegistry; 14 | private final String host; 15 | private final int port; 16 | 17 | public ServerInitializer(ChannelRegistry channelRegistry, String host, int port) { 18 | this.channelRegistry = channelRegistry; 19 | this.host = host; 20 | this.port = port; 21 | } 22 | 23 | @Override 24 | public void initChannel(SocketChannel ch) { 25 | ChannelPipeline p = ch.pipeline(); 26 | p.addLast(new HttpRequestDecoder()); 27 | p.addLast(new HttpResponseEncoder()); 28 | p.addLast(new ServerSentEventHandler(channelRegistry, host, port)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/github/alexvictoor/weblogback/ServerSentEvent.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | 4 | import java.util.Arrays; 5 | import java.util.Collection; 6 | 7 | public class ServerSentEvent { 8 | 9 | private final static Collection logLevels = Arrays.asList("DEBUG", "INFO", "WARN", "ERROR"); 10 | 11 | private final String type; 12 | private final String data; 13 | 14 | 15 | public ServerSentEvent(String type, String data) { 16 | this.type = type; 17 | this.data = data; 18 | } 19 | 20 | @Override 21 | public String toString() { 22 | String filteredMsg = data.replace("\r", ""); 23 | String[] lines = filteredMsg.split("\n"); 24 | 25 | StringBuilder builder = new StringBuilder(); 26 | if (logLevels.contains(type)) { 27 | builder.append("event: ").append(type).append("\r\n"); 28 | } 29 | for (String line : lines) { 30 | builder.append("data: ").append(line).append("\r\n"); 31 | } 32 | builder.append("\n"); 33 | 34 | return builder.toString(); 35 | } 36 | } -------------------------------------------------------------------------------- /src/main/java/com/github/alexvictoor/weblogback/ServerSentEventHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | 4 | import io.netty.buffer.ByteBuf; 5 | import io.netty.buffer.Unpooled; 6 | import io.netty.channel.ChannelFuture; 7 | import io.netty.channel.ChannelFutureListener; 8 | import io.netty.channel.ChannelHandlerContext; 9 | import io.netty.channel.SimpleChannelInboundHandler; 10 | import io.netty.channel.group.ChannelGroup; 11 | import io.netty.handler.codec.http.*; 12 | import io.netty.util.CharsetUtil; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | 16 | import java.nio.charset.Charset; 17 | 18 | import static io.netty.handler.codec.http.HttpHeaders.Names.*; 19 | import static io.netty.handler.codec.http.HttpResponseStatus.OK; 20 | import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1; 21 | 22 | public class ServerSentEventHandler extends SimpleChannelInboundHandler { 23 | 24 | public static final Logger logger = LoggerFactory.getLogger(ServerSentEventHandler.class); 25 | 26 | private final ChannelRegistry allChannels; 27 | private final String jsContent; 28 | private final String htmlContent; 29 | private final String welcomeMessage; 30 | 31 | public ServerSentEventHandler(ChannelRegistry allChannels, String host, int port) { 32 | this.allChannels = allChannels; 33 | this.jsContent 34 | = new FileReader().readFileFromClassPath("/logback.js"); 35 | this.htmlContent 36 | = new FileReader().readFileFromClassPath("/homepage.html") 37 | .replace("HOST", host) 38 | .replace("PORT", Integer.toString(port) 39 | ); 40 | this.welcomeMessage = "Connected successfully on LOG stream from " + host + ":" + port; 41 | } 42 | 43 | @Override 44 | protected void channelRead0(ChannelHandlerContext ctx, Object msg) { 45 | if (msg instanceof HttpRequest) { 46 | HttpRequest request = (HttpRequest) msg; 47 | if ("/stream".equals(request.getUri())) { 48 | logger.info("New streaming request"); 49 | HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK); 50 | response.headers().set(CONTENT_TYPE, "text/event-stream; charset=UTF-8"); 51 | response.headers().set(CACHE_CONTROL, "no-cache"); 52 | response.headers().set(CONNECTION, "keep-alive"); 53 | response.headers().set(ACCESS_CONTROL_ALLOW_ORIGIN, "*"); 54 | ctx.write(response); 55 | ServerSentEvent event = new ServerSentEvent("INFO", welcomeMessage); 56 | ByteBuf buffer = Unpooled.copiedBuffer(event.toString(), Charset.defaultCharset()); 57 | HttpContent content = new DefaultHttpContent(buffer); 58 | ctx.write(content); 59 | ctx.flush(); 60 | allChannels.addChannel(ctx.channel()); 61 | } else if ("/logback.js".equals(request.getUri())) { 62 | ByteBuf content = Unpooled.copiedBuffer(jsContent, Charset.defaultCharset()); 63 | FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, OK, content); 64 | response.headers().set(CONTENT_TYPE, "text/javascript"); 65 | HttpHeaders.setContentLength(response, content.readableBytes()); 66 | sendHttpResponse(ctx, request, response); 67 | } else { 68 | ByteBuf content = Unpooled.copiedBuffer(htmlContent, Charset.defaultCharset()); 69 | FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, OK, content); 70 | response.headers().set(CONTENT_TYPE, "text/html"); 71 | HttpHeaders.setContentLength(response, content.readableBytes()); 72 | sendHttpResponse(ctx, request, response); 73 | } 74 | } 75 | } 76 | 77 | // from netty http examples 78 | private static void sendHttpResponse( 79 | ChannelHandlerContext ctx, HttpRequest req, FullHttpResponse res) { 80 | // Generate an error page if response getStatus code is not OK (200). 81 | if (res.getStatus().code() != 200) { 82 | ByteBuf buf = Unpooled.copiedBuffer(res.getStatus().toString(), CharsetUtil.UTF_8); 83 | res.content().writeBytes(buf); 84 | buf.release(); 85 | HttpHeaders.setContentLength(res, res.content().readableBytes()); 86 | } 87 | 88 | // Send the response and close the connection if necessary. 89 | ChannelFuture f = ctx.channel().writeAndFlush(res); 90 | if (!HttpHeaders.isKeepAlive(req) || res.getStatus().code() != 200) { 91 | f.addListener(ChannelFutureListener.CLOSE); 92 | } 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/main/java/com/github/alexvictoor/weblogback/WebServer.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | 4 | import io.netty.bootstrap.ServerBootstrap; 5 | import io.netty.channel.Channel; 6 | import io.netty.channel.EventLoopGroup; 7 | import io.netty.channel.group.ChannelGroup; 8 | import io.netty.channel.group.DefaultChannelGroup; 9 | import io.netty.channel.nio.NioEventLoopGroup; 10 | import io.netty.channel.socket.nio.NioServerSocketChannel; 11 | import io.netty.util.concurrent.GlobalEventExecutor; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | 15 | public class WebServer { 16 | 17 | public static final Logger logger = LoggerFactory.getLogger(WebServer.class); 18 | 19 | private final String host; 20 | private final int port; 21 | private final int replayBufferSize; 22 | private EventLoopGroup bossGroup; 23 | private EventLoopGroup workerGroup; 24 | private Channel serverChannel; 25 | private ChannelGroup allChannels; 26 | 27 | public WebServer(String host, int port, int replayBufferSize) { 28 | this.host = host; 29 | this.port = port; 30 | this.replayBufferSize = replayBufferSize; 31 | } 32 | 33 | public ChannelOutputStream start() { 34 | logger.info("Starting server, listening on port {}", port); 35 | allChannels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); 36 | ChannelOutputStream channelOutputStream = new ChannelOutputStream(allChannels, replayBufferSize); 37 | 38 | bossGroup = new NioEventLoopGroup(1); 39 | workerGroup = new NioEventLoopGroup(1); 40 | try { 41 | ServerBootstrap b = new ServerBootstrap(); 42 | b.group(bossGroup, workerGroup) 43 | .channel(NioServerSocketChannel.class) 44 | .childHandler(new ServerInitializer(channelOutputStream, host, port)); 45 | 46 | serverChannel = b.bind(port).sync().channel(); 47 | } catch (InterruptedException e) { 48 | throw new RuntimeException(e); 49 | } 50 | 51 | return channelOutputStream; 52 | } 53 | 54 | public void stop() { 55 | if (serverChannel == null) { 56 | return; 57 | } 58 | try { 59 | serverChannel.close().sync(); 60 | allChannels.close().sync(); 61 | bossGroup.shutdownGracefully(); 62 | workerGroup.shutdownGracefully(); 63 | } catch (InterruptedException e) { 64 | throw new RuntimeException(e); 65 | } 66 | 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/resources/homepage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | WEB logback on HOST 9 | 10 | 11 | 12 | 13 | 14 | 15 | 182 | 183 | 184 | 185 | 186 | 187 | 188 |
189 | 190 |
191 | 192 |
193 | 194 |
195 |
196 |

WEB logback

197 | 203 |
204 |
205 | 206 |
207 |

Logs are coming!

208 |

(press CTRL + SHIFT + J to display them)

209 | 210 |

Use the bookmarklet below to open a stream to those logs from any web page

211 |

212 | Get logs! 213 |

214 |
215 | 216 |
217 |
218 |

Check out the project homepage on Github

219 |
220 |
221 | 222 |
223 | 224 |
225 | 226 |
227 | 228 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | -------------------------------------------------------------------------------- /src/main/resources/logback.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | if (typeof (EventSource) !== "undefined") { 3 | var scripts = document.getElementsByTagName('script'); 4 | var index = scripts.length - 1; 5 | var myScript = scripts[index]; 6 | var scriptUrl = myScript.src; 7 | var streamUrl = scriptUrl.substring(0, scriptUrl.length - 10) + "stream"; // 10 being the length of string "logback.js" 8 | var source = new EventSource(streamUrl); 9 | 10 | var defaultStyle = myScript.getAttribute("style"); 11 | 12 | if (defaultStyle) { 13 | source.onmessage = function(event) { 14 | console.log("%c " + event.data, defaultStyle); 15 | }; 16 | } else { 17 | source.onmessage = function (event) { 18 | console.log(event.data); 19 | }; 20 | } 21 | 22 | var debugStyle = myScript.getAttribute("style-debug") || defaultStyle; 23 | if (debugStyle) { 24 | source.addEventListener("DEBUG", function(event) { 25 | console.debug("%c " + event.data, debugStyle); 26 | }); 27 | } else { 28 | source.addEventListener("DEBUG", function (event) { 29 | console.debug(event.data); 30 | }); 31 | } 32 | 33 | var infoStyle = myScript.getAttribute("style-info") || defaultStyle; 34 | if (infoStyle) { 35 | source.addEventListener("INFO", function(event) { 36 | console.info("%c " + event.data, infoStyle); 37 | }); 38 | } else { 39 | source.addEventListener("INFO", function (event) { 40 | console.info(event.data); 41 | }); 42 | } 43 | 44 | var warnStyle = myScript.getAttribute("style-warn") || defaultStyle; 45 | if (warnStyle) { 46 | source.addEventListener("WARN", function(event) { 47 | console.warn("%c " + event.data, warnStyle); 48 | }); 49 | } else { 50 | source.addEventListener("WARN", function (event) { 51 | console.warn(event.data); 52 | }); 53 | } 54 | 55 | var errorStyle = myScript.getAttribute("style-error") || defaultStyle; 56 | if (errorStyle) { 57 | source.addEventListener("ERROR", function(event) { 58 | console.error("%c " + event.data, errorStyle); 59 | }); 60 | } else { 61 | source.addEventListener("ERROR", function (event) { 62 | console.error(event.data); 63 | }); 64 | } 65 | 66 | } else { 67 | alert("Your browser does not support SSE, hence web-logback will not work properly"); 68 | } 69 | })(); -------------------------------------------------------------------------------- /src/site/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexvictoor/web-logback/51c01491821d5780c595ff4329ebeabbb5008c6c/src/site/screenshot.png -------------------------------------------------------------------------------- /src/test/java/com/github/alexvictoor/weblogback/ChannelOutputStreamTest.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | import io.netty.channel.Channel; 4 | import io.netty.channel.group.ChannelGroup; 5 | import io.netty.handler.codec.http.HttpContent; 6 | import org.junit.Test; 7 | import org.junit.runner.RunWith; 8 | import org.mockito.ArgumentCaptor; 9 | import org.mockito.Mock; 10 | import org.mockito.runners.MockitoJUnitRunner; 11 | 12 | import java.io.IOException; 13 | 14 | import static org.assertj.core.api.Assertions.anyOf; 15 | import static org.assertj.core.api.Assertions.assertThat; 16 | import static org.mockito.Matchers.any; 17 | import static org.mockito.Mockito.mock; 18 | import static org.mockito.Mockito.times; 19 | import static org.mockito.Mockito.verify; 20 | 21 | @RunWith(MockitoJUnitRunner.class) 22 | public class ChannelOutputStreamTest { 23 | 24 | @Mock 25 | private ChannelGroup channels; 26 | 27 | @Test 28 | public void should_write_content_to_channel() throws IOException { 29 | // given 30 | ChannelOutputStream stream = new ChannelOutputStream(channels, 1); 31 | // when 32 | stream.write("hello".getBytes(),0,5); 33 | // then 34 | ArgumentCaptor captor = ArgumentCaptor.forClass(HttpContent.class); 35 | verify(channels).write(captor.capture()); 36 | String output = new String(captor.getValue().content().array()); 37 | assertThat(output).startsWith("data: hello"); 38 | } 39 | 40 | @Test 41 | public void should_add_channel_to_group() throws IOException { 42 | // given 43 | ChannelOutputStream stream = new ChannelOutputStream(channels, 1); 44 | Channel ch = mock(Channel.class); 45 | // when 46 | stream.addChannel(ch); 47 | // then 48 | verify(channels).add(ch); 49 | } 50 | 51 | @Test 52 | public void should_replay_last_event_adding_a_channel() throws IOException { 53 | // given 54 | ChannelOutputStream stream = new ChannelOutputStream(channels, 1); 55 | Channel ch = mock(Channel.class); 56 | stream.write("hello".getBytes(),0,5); 57 | // when 58 | stream.addChannel(ch); 59 | // then 60 | verify(ch).write(any()); 61 | } 62 | 63 | @Test 64 | public void should_replay_only_last_events_adding_a_channel() throws IOException { 65 | // given 66 | ChannelOutputStream stream = new ChannelOutputStream(channels, 2); 67 | Channel ch = mock(Channel.class); 68 | stream.write("hello".getBytes(),0,5); 69 | stream.write("good".getBytes(),0,4); 70 | stream.write("bye".getBytes(),0,3); 71 | // when 72 | stream.addChannel(ch); 73 | // then 74 | verify(ch, times(2)).write(any()); 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /src/test/java/com/github/alexvictoor/weblogback/FileReaderTest.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | import org.junit.Test; 4 | 5 | import static org.assertj.core.api.Assertions.assertThat; 6 | 7 | public class FileReaderTest { 8 | 9 | @Test 10 | public void should_read_file_content() throws Exception { 11 | // given 12 | FileReader reader = new FileReader(); 13 | // when 14 | String content = reader.readFileFromClassPath("/test.file"); 15 | // then 16 | assertThat(content).isEqualTo("Test Content"); 17 | } 18 | } -------------------------------------------------------------------------------- /src/test/java/com/github/alexvictoor/weblogback/Main.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import java.io.IOException; 8 | import java.util.concurrent.Executors; 9 | import java.util.concurrent.TimeUnit; 10 | 11 | public class Main { 12 | 13 | public static void main(String[] args) { 14 | System.setProperty("logback.configurationFile", "logback-web.xml"); 15 | final Logger logger = LoggerFactory.getLogger(Main.class); 16 | /*for (int i=0; i<10; i++) { 17 | logger.info("Hello!"); 18 | } 19 | try { 20 | System.in.read(); 21 | logger.info("Hello2!"); 22 | logger.info("He\"); alert(\"llo2"); 23 | } catch (IOException e) { 24 | e.printStackTrace(); 25 | }*/ 26 | Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(new Runnable() { 27 | @Override 28 | public void run() { 29 | logger.debug("----------------------------------------------------"); 30 | logger.info("With web-logback you can check out your server logs"); 31 | logger.info("in the console of your browser."); 32 | logger.info("If you are doing web development with a Java backend"); 33 | logger.info("that might be handy"); 34 | logger.debug("----------------------------------------------------"); 35 | //logger.warn("bof bof", new Exception("bad")); 36 | } 37 | }, 0, 10, TimeUnit.SECONDS); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/test/java/com/github/alexvictoor/weblogback/ServerSentEventTest.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | 4 | import org.junit.Test; 5 | 6 | import static org.assertj.core.api.Assertions.assertThat; 7 | 8 | public class ServerSentEventTest { 9 | 10 | @Test 11 | public void should_generate_a_string_with_level_and_message_ending_with_an_empty_line() { 12 | ServerSentEvent event = new ServerSentEvent("DEBUG", "whatever"); 13 | String result = event.toString(); 14 | assertThat(result) 15 | .hasLineCount(3) 16 | .contains("event: DEBUG") 17 | .contains("data: whatever") 18 | .endsWith("\n\n"); 19 | } 20 | 21 | @Test 22 | public void should_add_a_data_prefix_on_each_message_line() { 23 | ServerSentEvent event = new ServerSentEvent("WARN", "winter\nis\ncoming"); 24 | String result = event.toString(); 25 | assertThat(result) 26 | .hasLineCount(5) 27 | .contains("event: WARN") 28 | .contains("data: winter") 29 | .contains("data: is") 30 | .contains("data: coming") 31 | .endsWith("\n\n"); 32 | } 33 | 34 | @Test 35 | public void should_keep_only_levels_known_by_the_browser() { 36 | ServerSentEvent event = new ServerSentEvent("FATAL", "oops..."); 37 | String result = event.toString(); 38 | assertThat(result).doesNotContain("type: FATAL"); 39 | } 40 | 41 | 42 | } -------------------------------------------------------------------------------- /src/test/java/com/github/alexvictoor/weblogback/WebServerTest.java: -------------------------------------------------------------------------------- 1 | package com.github.alexvictoor.weblogback; 2 | 3 | import com.squareup.okhttp.OkHttpClient; 4 | import com.squareup.okhttp.Request; 5 | import com.squareup.okhttp.Response; 6 | import org.junit.After; 7 | import org.junit.Before; 8 | import org.junit.Test; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | 12 | import java.io.IOException; 13 | import java.net.ServerSocket; 14 | 15 | import static org.assertj.core.api.Assertions.assertThat; 16 | 17 | public class WebServerTest { 18 | 19 | public static final Logger logger = LoggerFactory.getLogger(WebServerTest.class); 20 | private WebServer server; 21 | private int port; 22 | private OkHttpClient httpClient; 23 | private int replayBufferSize = 1; 24 | 25 | @Before 26 | public void launchServer() { 27 | port = getAvailablePort(); 28 | server = new WebServer("localhost", port, replayBufferSize); 29 | server.start(); 30 | httpClient = new OkHttpClient(); 31 | } 32 | @After 33 | public void shutdownServer() { 34 | server.stop(); 35 | } 36 | 37 | @Test 38 | public void should_respond_with_js_content() throws IOException { 39 | Request request = new Request.Builder() 40 | .url("http://localhost:" + port + "/logback.js") 41 | .build(); 42 | Response response = httpClient.newCall(request).execute(); 43 | assertThat(response.code()).isEqualTo(200); 44 | assertThat(response.body().string()).contains("EventSource"); 45 | 46 | } 47 | 48 | @Test 49 | public void should_respond_with_html_content() throws IOException { 50 | Request request = new Request.Builder() 51 | .url("http://localhost:" + port) 52 | .build(); 53 | Response response = httpClient.newCall(request).execute(); 54 | assertThat(response.code()).isEqualTo(200); 55 | assertThat(response.body().string()).contains("Get logs!"); 56 | 57 | } 58 | 59 | @Test 60 | public void should_not_fail_stopping_a_server_not_started() throws IOException { 61 | WebServer server2 = new WebServer("localhost", port, replayBufferSize); 62 | server2.stop(); 63 | } 64 | 65 | public int getAvailablePort() { 66 | try { 67 | ServerSocket s = new ServerSocket(0); 68 | int result = s.getLocalPort(); 69 | s.close(); 70 | return result; 71 | } catch (IOException e) { 72 | logger.error("did not found any free port", e); 73 | throw new RuntimeException(e); 74 | } 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /src/test/resources/logback-web.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 8 | 9 | 10 | 11 | 12 | 13 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 14 | 15 | 8765 16 | true 17 | 10 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/test/resources/test.file: -------------------------------------------------------------------------------- 1 | Test Content --------------------------------------------------------------------------------