diff --git a/logback13/README.md b/logback13/README.md new file mode 100644 index 0000000..9340b0a --- /dev/null +++ b/logback13/README.md @@ -0,0 +1,75 @@ +# The New Relic Logback 1.3.x Extension + +## Preconditions + +1. logback 1.3.x must be configured and working in the application. +2. The New Relic Java agent must be enabled using the `-javaagent` command-line parameter. +3. You must be using at least version 8.21.0 of the Java Agent. + +## Configuring + +There are some required changes to your application's logging configuration to use the New Relic +Logback Extension for Logback-1.3. All steps are required. + +**Optional**: [Configuration Options](../README.md#configuration-options) for collecting MDC or controlling stack trace behavior. + +### 1. Include the dependency in your project. + +Refer to [Maven Central](https://search.maven.org/search?q=g:com.newrelic.logging%20a:logback13) for the appropriate snippets. + +### 2. Configure an `` element with a `NewRelicEncoder`. + +Update your `logback.xml` to include the `` element like below. + +```xml + + logs/app-log-file.log + + +``` + +*Why?* The New Relic log format is a tailored JSON format with specific fields in specific places +that our log forwarder plugins and back end rely on. At this time, we don't support any customization +of that format. + +### 3. `NewRelicAsyncAppender` must wrap any appenders that will target New Relic's log forwarder + +Update your logging configuration xml to add this section. Change `"LOG_FILE"` to the `name` of the appender +you updated in the previous step. + +```xml + + + +``` + +*Why?* The New Relic log format includes New Relic-specific data that must be captured on the thread the log message +is coming from. This appender captures that information before passing to the standard `AsyncAppender` logic. + +### 4. The Async Appender must be referenced by all loggers + +Update your logging configuration xml to connect the root (and other) loggers to the `ASYNC` appender you configured +in the previous step. + +```xml + + + +``` +### 5. Example `logback.xml` +```xml + + + logs/app-log-file.log + + + + + + + + + + + +``` \ No newline at end of file diff --git a/logback13/build.gradle.kts b/logback13/build.gradle.kts new file mode 100644 index 0000000..e129aa4 --- /dev/null +++ b/logback13/build.gradle.kts @@ -0,0 +1,72 @@ +plugins { + id("java") + id("com.github.spotbugs").version("4.8.0") +} + +group = "com.newrelic.logging" + +// -Prelease=true will render a non-snapshot version +// All other values (including unset) will render a snapshot version. +val release: String? by project +val releaseVersion: String by project +version = releaseVersion + if ("true" == release) "" else "-SNAPSHOT" + +repositories { + mavenCentral() + maven(url = "https://dl.bintray.com/mockito/maven/") +} + +val includeInJar: Configuration by configurations.creating +includeInJar.exclude(group = "org.apache.commons") +configurations["compileOnly"].extendsFrom(includeInJar) + +dependencies { + implementation("com.fasterxml.jackson.core:jackson-core:2.15.0") + implementation("ch.qos.logback:logback-core:1.3.15") + implementation("ch.qos.logback:logback-classic:1.3.15") + implementation("com.newrelic.agent.java:newrelic-api:7.6.0") + includeInJar(project(":core")) + + testImplementation("org.junit.jupiter:junit-jupiter:5.6.2") + testImplementation("com.google.guava:guava:32.0.1-android") + testImplementation("org.mockito:mockito-core:3.4.4") + testImplementation(project(":core-test")) +} + +val jar by tasks.getting(Jar::class) { + from(configurations["includeInJar"].flatMap { + when { + it.isDirectory -> listOf(it) + else -> listOf(zipTree(it)) + } + }) +} + +tasks.withType { + enabled = true + (options as? CoreJavadocOptions)?.addStringOption("link", "https://logback.qos.ch/apidocs") +} + +configure { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.register("sourcesJar") { + from(sourceSets.main.get().allJava) + archiveClassifier.set("sources") +} + +tasks.register("javadocJar") { + from(tasks.javadoc) + archiveClassifier.set("javadoc") +} + +apply(from = "$rootDir/gradle/publish.gradle.kts") + +tasks.withType { + excludeFilter.set(file("spotbugs-filter.xml")) + reports.create("html") { + isEnabled = true + } +} \ No newline at end of file diff --git a/logback13/spotbugs-filter.xml b/logback13/spotbugs-filter.xml new file mode 100644 index 0000000..b96f194 --- /dev/null +++ b/logback13/spotbugs-filter.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logback13/src/main/java/com/newrelic/logging/logback13/CustomLoggingEventWrapper.java b/logback13/src/main/java/com/newrelic/logging/logback13/CustomLoggingEventWrapper.java new file mode 100644 index 0000000..f2ef8a5 --- /dev/null +++ b/logback13/src/main/java/com/newrelic/logging/logback13/CustomLoggingEventWrapper.java @@ -0,0 +1,125 @@ +/* + * Copyright 2025. New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.newrelic.logging.logback13; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.LoggerContextVO; +import org.slf4j.Marker; +import org.slf4j.event.KeyValuePair; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * This wrapper ensures compatibility with Logback 1.3.x, which introduced changes to the {@link ILoggingEvent}. + * {@link ILoggingEvent#getMDCPropertyMap()} now returns an immutable MDC map and {@code ILoggingEvent#setMDCPropertyMap()} is no longer available. + *

+ * This class implements the {@link ILoggingEvent} interface and wraps an existing {@code ILoggingEvent}, + * allowing for custom MDC (Mapped Diagnostic Context) map to be injected and returned when queried. + */ + +public class CustomLoggingEventWrapper implements ILoggingEvent { + private final ILoggingEvent delegate; + private final Map customMdc; + + public CustomLoggingEventWrapper(ILoggingEvent delegate, Map customContext) { + this.delegate = delegate; + this.customMdc = customContext; + } + + @Override + public Map getMDCPropertyMap() { + return new HashMap<>(customMdc); // Returns a mutable copy of the custom MDC map + } + + @Override + public Map getMdc() { + return new HashMap<>(customMdc); // Returns a mutable copy of the custom MDC map + } + + @Override + public String getThreadName() { + return delegate.getThreadName(); + } + + @Override + public Level getLevel() { + return delegate.getLevel(); + } + + @Override + public String getMessage() { + return delegate.getMessage(); + } + + @Override + public Object[] getArgumentArray() { + return delegate.getArgumentArray(); + } + + @Override + public String getFormattedMessage() { + return delegate.getFormattedMessage(); + } + + @Override + public String getLoggerName() { + return delegate.getLoggerName(); + } + + @Override + public LoggerContextVO getLoggerContextVO() { + return delegate.getLoggerContextVO(); + } + + @Override + public IThrowableProxy getThrowableProxy() { + return delegate.getThrowableProxy(); + } + + @Override + public StackTraceElement[] getCallerData() { + return delegate.getCallerData(); + } + + @Override + public boolean hasCallerData() { + return delegate.hasCallerData(); + } + + @Override + public List getMarkerList() { + return delegate.getMarkerList(); + } + + @Override + public long getTimeStamp() { + return delegate.getTimeStamp(); + } + + @Override + public int getNanoseconds() { + return delegate.getNanoseconds(); + } + + @Override + public long getSequenceNumber() { + return delegate.getSequenceNumber(); + } + + @Override + public List getKeyValuePairs() { + return delegate.getKeyValuePairs(); + } + + @Override + public void prepareForDeferredProcessing() { + delegate.prepareForDeferredProcessing(); + } +} diff --git a/logback13/src/main/java/com/newrelic/logging/logback13/NewRelicAsyncAppender.java b/logback13/src/main/java/com/newrelic/logging/logback13/NewRelicAsyncAppender.java new file mode 100644 index 0000000..41f0d61 --- /dev/null +++ b/logback13/src/main/java/com/newrelic/logging/logback13/NewRelicAsyncAppender.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.newrelic.logging.logback13; + +import ch.qos.logback.classic.AsyncAppender; +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.newrelic.api.agent.Agent; +import com.newrelic.api.agent.NewRelic; +import org.slf4j.MDC; +import org.slf4j.helpers.NOPMDCAdapter; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; + +import static com.newrelic.logging.core.LogExtensionConfig.CONTEXT_PREFIX; + +/** + * An {@link AsyncAppender} implementation that synchronously captures New Relic trace data. + *

+ * This appender will wrap the existing {@link AsyncAppender} logic in order to capture New Relic linking + * metadata on the same thread as the log message was created. + *

+ * + *

+ * To use, configure this appender in the 'logback.xml' by wrapping the existing appender: + *

+ * + *
{@code
+ *      
+ *          
+ *      
+ *
+ *      
+ *          
+ *      
+ * }
+ * + * @see Logback AsyncAppender + */ +public class NewRelicAsyncAppender extends AsyncAppender { + public static final String NEW_RELIC_PREFIX = "NewRelic:"; + + @Override + protected void preprocess(ILoggingEvent eventObject) { + eventObject.prepareForDeferredProcessing(); + if (isIncludeCallerData()) { + eventObject.getCallerData(); + } + } + + @Override + protected void append(ILoggingEvent eventObject) { + if (!isStarted()) { + return; + } + + Map combinedContextMap = new HashMap<>(); + + Map copyMdc = MDC.getMDCAdapter().getCopyOfContextMap(); + if (copyMdc != null) { + for (Map.Entry entry : copyMdc.entrySet()) { + if (entry.getValue() != null) { + combinedContextMap.put(CONTEXT_PREFIX + entry.getKey(), entry.getValue()); + } + } + } + + Map linkingMetadata = agentSupplier.get().getLinkingMetadata(); + for (Map.Entry entry : linkingMetadata.entrySet()) { + combinedContextMap.put(NEW_RELIC_PREFIX + entry.getKey(), entry.getValue()); + } + ILoggingEvent wrappedEvent = new CustomLoggingEventWrapper(eventObject, combinedContextMap); + super.append(wrappedEvent); + } + + //visible for testing + public static Supplier agentSupplier = NewRelic::getAgent; + + // visible for testing + @SuppressWarnings("WeakerAccess") + public static boolean isNoOpMDC = MDC.getMDCAdapter() instanceof NOPMDCAdapter; +} + diff --git a/logback13/src/main/java/com/newrelic/logging/logback13/NewRelicEncoder.java b/logback13/src/main/java/com/newrelic/logging/logback13/NewRelicEncoder.java new file mode 100644 index 0000000..d231dd1 --- /dev/null +++ b/logback13/src/main/java/com/newrelic/logging/logback13/NewRelicEncoder.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025. New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.newrelic.logging.logback13; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.encoder.EncoderBase; + +import java.nio.charset.StandardCharsets; + +/** + * An {@link EncoderBase} implementation that serializes {@link ILoggingEvent} instances into JSON format. + * + *

+ * This encoder is designed to work with logback 1.3.x and uses the {@link NewRelicJsonLayout} to format log events. + *

+ * + * Example usage in a logback configuration file: + * + */ +public class NewRelicEncoder extends EncoderBase { + private final NewRelicJsonLayout layout = new NewRelicJsonLayout(); + + @Override + public byte[] encode(ILoggingEvent event) { + String laidOutResult = layout.doLayout(event); + return laidOutResult.getBytes(StandardCharsets.UTF_8); + } + + @Override + public void start() { + super.start(); + layout.start(); + } + + @Override + public byte[] headerBytes() { + return new byte[0]; + } + + @Override + public byte[] footerBytes() { + return new byte[0]; + } +} \ No newline at end of file diff --git a/logback13/src/main/java/com/newrelic/logging/logback13/NewRelicJsonLayout.java b/logback13/src/main/java/com/newrelic/logging/logback13/NewRelicJsonLayout.java new file mode 100644 index 0000000..2530d21 --- /dev/null +++ b/logback13/src/main/java/com/newrelic/logging/logback13/NewRelicJsonLayout.java @@ -0,0 +1,209 @@ +/* + * Copyright 2025. New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.newrelic.logging.logback13; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.StackTraceElementProxy; +import ch.qos.logback.core.Context; +import ch.qos.logback.core.LayoutBase; +import ch.qos.logback.core.status.ErrorStatus; +import ch.qos.logback.core.status.InfoStatus; +import ch.qos.logback.core.status.Status; +import ch.qos.logback.core.status.WarnStatus; +import com.fasterxml.jackson.core.JsonGenerator; +import com.newrelic.logging.core.ElementName; +import com.fasterxml.jackson.core.JsonFactory; +import org.slf4j.MDC; +import org.slf4j.Marker; +import org.slf4j.helpers.NOPMDCAdapter; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.List; +import java.util.Map; + +/** + * A custom layout that formats {@link ILoggingEvent} log events as JSON objects. + * Adds standard log fields and enriches logs with linking metadata. + * + * This layout also adds MDC (Mapped Diagnostic Context) values using prefixed keys (e.g., "context.someKey:someValue"). + */ + +public class NewRelicJsonLayout extends LayoutBase { + private boolean started = false; + private Context context; + + @Override + public String doLayout(ILoggingEvent eventObject) { + StringWriter sw = new StringWriter(); + + try { + JsonGenerator generator = new JsonFactory().createGenerator(sw); + writeToGenerator(eventObject, generator); + generator.close(); + } catch (IOException ignored) { + return eventObject.getFormattedMessage(); + } + sw.append("\n"); + return sw.toString(); + } + + private void writeToGenerator(ILoggingEvent eventObject, JsonGenerator generator) throws IOException { + + generator.writeStartObject(); + generator.writeStringField(ElementName.MESSAGE, eventObject.getFormattedMessage()); + generator.writeNumberField(ElementName.TIMESTAMP, eventObject.getTimeStamp()); + generator.writeStringField(ElementName.LOG_LEVEL, eventObject.getLevel().toString()); + generator.writeStringField(ElementName.LOGGER_NAME, eventObject.getLoggerName()); + generator.writeStringField(ElementName.THREAD_NAME, eventObject.getThreadName()); + + if (eventObject.hasCallerData()) { + StackTraceElement element = eventObject.getCallerData()[eventObject.getCallerData().length - 1]; + generator.writeStringField(ElementName.CLASS_NAME, element.getClassName()); + generator.writeStringField(ElementName.METHOD_NAME, element.getMethodName()); + generator.writeNumberField(ElementName.LINE_NUMBER, element.getLineNumber()); + } + + Map mdcPropertyMap = eventObject.getMDCPropertyMap(); + if (mdcPropertyMap != null) { + String key; + String value; + + for (Map.Entry entry : mdcPropertyMap.entrySet()) { + if (entry.getValue() == null) { + continue; + } + key = entry.getKey(); + value = entry.getValue(); + generator.writeStringField(key, value); + } + } + + List markerList = eventObject.getMarkerList(); + if (markerList != null && !markerList.isEmpty()) { + generator.writeArrayFieldStart(ElementName.MARKER); + for (Marker marker : markerList) { + generator.writeString(marker.getName()); + } + generator.writeEndArray(); + } + + IThrowableProxy throwableProxy = eventObject.getThrowableProxy(); + if (throwableProxy != null) { + generator.writeFieldName("exception"); + generator.writeStartObject(); + generator.writeStringField("type", throwableProxy.getClassName()); + generator.writeStringField("message", throwableProxy.getMessage()); + + generator.writeArrayFieldStart("stackTrace"); + for (StackTraceElementProxy element : throwableProxy.getStackTraceElementProxyArray()) { + generator.writeString(element.toString()); + } + generator.writeEndArray(); + + generator.writeEndObject(); + } + generator.writeEndObject(); + } + + @Override + public String getFileHeader() { + return null; + } + + @Override + public String getPresentationHeader() { + return null; + } + + @Override + public String getPresentationFooter() { + return null; + } + + @Override + public String getFileFooter() { + return null; + } + + @Override + public String getContentType() { + return "application/json"; + } + + @Override + public void setContext(Context context) { + this.context = context; + } + + @Override + public Context getContext() { + return context; + } + + @Override + public void addStatus(Status status) { + context.getStatusManager().add(status); + } + + @Override + public void addInfo(String msg) { + if (context != null) { + context.getStatusManager().add(new InfoStatus(msg, this)); + } + } + + @Override + public void addInfo(String msg, Throwable ex) { + if (context != null) { + context.getStatusManager().add(new InfoStatus(msg, this, ex)); + } + } + + @Override + public void addWarn(String msg) { + if (context != null) { + context.getStatusManager().add(new WarnStatus(msg, this)); + } + } + + @Override + public void addWarn(String msg, Throwable ex) { + if (context != null) { + context.getStatusManager().add(new WarnStatus(msg, this, ex)); + } + } + + @Override + public void addError(String msg) { + if (context != null) { + context.getStatusManager().add(new ErrorStatus(msg, this)); + } + } + + @Override + public void addError(String msg, Throwable ex) { + if (context != null) { + context.getStatusManager().add(new ErrorStatus(msg, this, ex)); + } + } + + @Override + public void start() { + started = true; + } + + @Override + public void stop() { + started = false; + } + + @Override + public boolean isStarted() { + return started; + } +} diff --git a/logback13/src/test/java/com/newrelic/logging/logback13/NewRelicLogback13Tests.java b/logback13/src/test/java/com/newrelic/logging/logback13/NewRelicLogback13Tests.java new file mode 100644 index 0000000..c2913d8 --- /dev/null +++ b/logback13/src/test/java/com/newrelic/logging/logback13/NewRelicLogback13Tests.java @@ -0,0 +1,237 @@ +/* + * Copyright 2025. New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.newrelic.logging.logback13; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.OutputStreamAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class NewRelicLogback13Tests { + + private static Logger logger; + private LoggerContext loggerContext; + private ByteArrayOutputStream outputStream; + + NewRelicAsyncAppender appender; + OutputStreamAppender delegateAppender; + + private static final String CONTEXT_PREFIX = "context."; + + @BeforeEach + void setup() { + loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + loggerContext.reset(); + + logger = loggerContext.getLogger("TestLogger"); + logger.setLevel(Level.DEBUG); + + outputStream = new ByteArrayOutputStream(); + + NewRelicEncoder encoder = new NewRelicEncoder(); + encoder.setContext(loggerContext); + encoder.start(); + + delegateAppender = new OutputStreamAppender<>(); + delegateAppender.setContext(loggerContext); + delegateAppender.setName("NR_TEST_DELEGATE_APPENDER"); + delegateAppender.setOutputStream(outputStream); + delegateAppender.setEncoder(encoder); + delegateAppender.setImmediateFlush(true); + delegateAppender.start(); + + appender = new NewRelicAsyncAppender(); + appender.setContext(loggerContext); + appender.setName("NR_TEST_APPENDER"); + appender.addAppender(delegateAppender); + appender.start(); + + logger.addAppender(appender); + } + + @AfterEach + void teardown() { + MDC.clear(); + } + + @Test + void shouldWrapJsonConsoleAppenderCorrectly() throws InterruptedException, IOException { + logger.info("Very interesting test message"); + Thread.sleep(100); + String output = getLogOutput(); + + assertTrue(logger.isInfoEnabled()); + assertTrue(output.contains("\"message\":\"Very interesting test message\"")); + } + + @Test + void shouldAllWorkCorrectlyWithoutMDC() throws InterruptedException { + logger.info("Very interesting test message, no MDC"); + Thread.sleep(100); + String output = getLogOutput(); + + assertTrue(output.contains("Very interesting test message, no MDC")); + assertFalse(output.contains(CONTEXT_PREFIX)); + } + + @Test + void shouldAppendCallerDataToJsonCorrectly() throws InterruptedException { + appender.setIncludeCallerData(true); + logger.info("Test message with Caller Data"); + + Thread.sleep(100); + String output = getLogOutput(); + + assertTrue(output.contains("class.name")); + assertTrue(output.contains("method.name")); + assertTrue(output.contains("line.number")); + assertTrue(output.contains("Test message with Caller Data")); + } + + @Test + void shouldAppendMDCArgsToJsonWhenEnabled() throws InterruptedException { + MDC.put("userId", "user-123"); + MDC.put("sessionId", "session-456"); + + logger.info("Logging with MDC enabled"); + Thread.sleep(100); + String output = getLogOutput(); + + assertTrue(output.contains("\"context.userId\":\"user-123\"")); + assertTrue(output.contains("\"context.sessionId\":\"session-456\"")); + assertTrue(output.contains("Logging with MDC enabled")); + } + + @Test + void shouldNotAppendMDCArgsToJsonWhenMDCIsDisabled() throws InterruptedException { + NewRelicAsyncAppender.isNoOpMDC = true; + MDC.put("userId", "user-123"); + MDC.clear(); + + logger.info("Logging with MDC disabled"); + Thread.sleep(100); + String output = getLogOutput(); + + assertTrue(output.contains("Logging with MDC disabled")); + assertFalse(output.contains("\"context.userId\":\"user-123\"")); + assertFalse(output.contains("NewRelic:")); + } + + @Test + void shouldSerializeExceptionStackTraceCorrectly() throws InterruptedException { + Exception exception = new IllegalArgumentException("Test exception"); + + logger.error("Logging a test exception", exception); + Thread.sleep(100); + String output = getLogOutput(); + + assertTrue(output.contains("Logging a test exception")); + assertTrue(output.contains("java.lang.IllegalArgumentException")); + assertTrue(output.contains("Test exception")); + assertTrue(output.contains("at ")); + } + + @Test + void shouldIncludeMarkersInJsonOutput() throws InterruptedException { + Marker marker = MarkerFactory.getMarker("TEST_MARKER"); + logger.info(marker, "Log message with marker"); + + Thread.sleep(100); + String output = getLogOutput(); + + assertTrue(output.contains("\"marker\":[\"TEST_MARKER\"]")); + assertTrue(output.contains("Log message with marker")); + } + + @Test + void shouldLogToMultipleAppendersCorrectly() throws InterruptedException { + ByteArrayOutputStream stream1 = new ByteArrayOutputStream(); + OutputStreamAppender appender1 = new OutputStreamAppender<>(); + appender1.setContext(loggerContext); + appender1.setName("NR_TEST_APPENDER_1"); + appender1.setOutputStream(stream1); + appender1.setEncoder(new NewRelicEncoder()); + appender1.start(); + + ByteArrayOutputStream stream2 = new ByteArrayOutputStream(); + OutputStreamAppender appender2 = new OutputStreamAppender<>(); + appender2.setContext(loggerContext); + appender2.setName("NR_TEST_APPENDER_2"); + appender2.setOutputStream(stream2); + appender2.setEncoder(new NewRelicEncoder()); + appender2.start(); + + logger.addAppender(appender1); + logger.addAppender(appender2); + logger.info("Test message for multiple appenders"); + Thread.sleep(100); + + String output1 = stream1.toString(); + String output2 = stream2.toString(); + + assertTrue(output1.contains("\"message\":\"Test message for multiple appenders\"")); + assertTrue(output2.contains("\"message\":\"Test message for multiple appenders\"")); + + assertNotEquals("", output1); + assertNotEquals("", output2); + } + + @Test + void shouldHandleNullOrEmptyMessagesGracefully() throws InterruptedException { + logger.info(null); + Thread.sleep(100); + String output = getLogOutput(); + assertTrue(output.contains("\"message\":null")); + assertTrue(output.contains("\"log.level\":\"INFO\"")); + + logger.info(""); + Thread.sleep(100); + output = getLogOutput(); + assertTrue(output.contains("\"message\":\"\"")); + assertTrue(output.contains("\"log.level\":\"INFO\"")); + } + + @Test + void shouldLogDifferentLevelsCorrectly() throws InterruptedException { + logger.debug("Debug message"); + logger.info("Info message"); + logger.warn("Warn message"); + logger.error("Error message"); + + Thread.sleep(100); + String output = getLogOutput(); + + assertTrue(output.contains("\"message\":\"Info message\"")); + assertTrue(output.contains("\"message\":\"Warn message\"")); + assertTrue(output.contains("\"message\":\"Error message\"")); + assertTrue(output.contains("\"message\":\"Debug message\"")); + + assertTrue(output.contains("\"log.level\":\"DEBUG\"")); + assertTrue(output.contains("\"log.level\":\"INFO\"")); + assertTrue(output.contains("\"log.level\":\"WARN\"")); + assertTrue(output.contains("\"log.level\":\"ERROR\"")); + } + + private String getLogOutput() { + return outputStream.toString().trim(); + } + +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 7388ca2..e7fc11d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,6 +3,7 @@ include 'log4j1' include 'log4j2' include 'logback' include 'logback11' +include 'logback13' include 'jul' include 'dropwizard' include 'core' @@ -16,3 +17,4 @@ include 'examples:jul-app' include 'examples:dropwizard-app' include 'performance:log4j2-perf' +