Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support logging exception stack traces when the last argument is Throwable #3960

Merged
merged 13 commits into from
Feb 4, 2025

Conversation

raccoonback
Copy link
Contributor

@raccoonback raccoonback commented Jan 2, 2025

Motivation

Hello,

Reactor Core supports SLF4J, Console Logger, and JDK Logger.
In SLF4J implementations (e.g., Logback), when the last argument is of type Throwable, the exception stack trace is automatically logged, as shown below.
(ref. SLF4J FAQ)
(ref: Logback implementation)

log.info("An error occurred: {}", "details", new Exception("Sample exception"));

However, the Console Logger and JDK Logger supported by Reactor Core do not provide this functionality, and users must manually check for Throwable and handle it accordingly in their code.

	@Test
	void useVerboseConsoleLoggers() throws Exception {
		try {
			Loggers.useVerboseConsoleLoggers();
			Logger l = Loggers.getLogger("test");

			l.debug("Processing data for user: {}", "user123", new Exception("Sample exception"));
		}
		finally {
			Loggers.resetLoggerFactory();
		        ReactorLauncherSessionListener.resetLoggersFactory();
		}
	}
[DEBUG] (Test worker) Processing data for user: user123

This results in unnecessary branching in user code and decreases consistency across logging mechanisms.

Therefore, in this change, we have modified the ConsoleLogger and JdkLogger to detect if the last argument is of type Throwable and automatically log the exception stack trace.

Description

This PR enhances the logging functionality in Reactor Core to check if the last argument in logger methods is of type Throwable. If a Throwable is detected, the exception stack trace is included in the log output.

Key Changes

  1. Added logic to inspect the last argument in logger method calls.
  2. Automatically handles Throwable instances for improved debugging.
  3. Ensures compatibility with existing SLF4J-style message formatting for consistency across logging frameworks.

Example Usage

log.info("Processing data for user: {}", "user123", new Exception("Sample exception"));

Before

INFO: Processing data for user: user123

After

INFO: Processing data for user: user123
java.lang.Exception: Sample exception
    at ...

Benefits

  • Aligns Reactor's logging capabilities with familiar SLF4J-style formatting.
  • Simplifies exception handling for developers using JDK or Console Loggers.
  • Improves traceability and debugging in complex systems.

Testing

	@Test
	void useVerboseConsoleLoggers() throws Exception {
		try {
			Loggers.useVerboseConsoleLoggers();
			Logger l = Loggers.getLogger("test");

			l.debug("Processing data for user: {}", "user123", new Exception("Sample exception"));
		}
		finally {
			Loggers.resetLoggerFactory();
		        ReactorLauncherSessionListener.resetLoggersFactory();
		}
	}

	@Test
	void useJdkLoggers() throws Exception {
		try {
			Loggers.useJdkLoggers();
			Logger l = Loggers.getLogger("test");

			l.debug("Processing data for user: {}", "user123", new Exception("Sample exception"));
		}
		finally {
			Loggers.resetLoggerFactory();
		        ReactorLauncherSessionListener.resetLoggersFactory();
		}
	}

@raccoonback raccoonback requested a review from a team as a code owner January 2, 2025 15:49
@raccoonback raccoonback force-pushed the log-with-optional-throable branch from e336792 to d946148 Compare January 2, 2025 15:51
Comment on lines 472 to 483
private boolean isLastElementThrowable(Object... arguments) {
int length = arguments.length;
return length > 0 && arguments[length - 1] instanceof Throwable;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checks whether the last argements are of type Throwable.

Comment on lines 459 to 473
private void logWithOptionalThrowable(Level level, String format, Object... arguments) {
if(isLastElementThrowable(arguments)) {
int lastIndex = arguments.length - 1;
Object[] args = Arrays.copyOfRange(arguments, 0, lastIndex);
Throwable t = (Throwable) arguments[lastIndex];

logger.log(level, format(format, args), t);
return;
}

logger.log(level, format(format, arguments));
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In JdkLogger, if the last arguments are Throwable type, stacktrace is also logged.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to see a test for this.

Comment on lines 557 to 573
private boolean isLastElementThrowable(Object... arguments) {
int length = arguments.length;
return length > 0 && arguments[length - 1] instanceof Throwable;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checks whether the last argements are of type Throwable.

Comment on lines 529 to 563
private synchronized void logWithOptionalThrowable(String level, String format, Object... arguments) {
if(isLastElementThrowable(arguments)) {
int lastIndex = arguments.length - 1;
Object[] args = Arrays.copyOfRange(arguments, 0, lastIndex);
Throwable t = (Throwable) arguments[lastIndex];

this.log.format("[%s] (%s) %s\n", level.toUpperCase(), Thread.currentThread().getName(), format(format, args));
t.printStackTrace(this.log);
return;
}

this.log.format("[%s] (%s) %s\n", level.toUpperCase(), Thread.currentThread().getName(), format(format, arguments));
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In ConsoleLogger, if the last argument is of type Throwable, it also prints a stack trace.
(only use for TRACE, DEBUG, INFO level)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have tests to validate this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added test code!

Comment on lines 543 to 555
private synchronized void logErrorWithOptionalThrowable(String level, String format, Object... arguments) {
if(isLastElementThrowable(arguments)) {
int lastIndex = arguments.length - 1;
Object[] args = Arrays.copyOfRange(arguments, 0, lastIndex);
Throwable t = (Throwable) arguments[lastIndex];

this.err.format("[%s] (%s) %s\n", level.toUpperCase(), Thread.currentThread().getName(), format(format, args));
t.printStackTrace(this.err);
return;
}

this.err.format("[%s] (%s) %s\n", level.toUpperCase(), Thread.currentThread().getName(), format(format, arguments));
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In ConsoleLogger, if the last arguments are Throwable type, it also prints a stack trace with the err PrintStream.
(only use for WARN, ERROR level)

@raccoonback
Copy link
Contributor Author

@chemicL
Hello!
Could you please take a look at this PR?
The review seems to be delayed, so I would greatly appreciate your confirmation.
Thank you!

Copy link
Member

@chemicL chemicL left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the proposal. I've been occupied with other priority subjects and now found the time to review the PR. I think it is a useful utility. I left some comments, please address them if you'd like to integrate this change. Most importantly, I look forward to tests validating the change has some meaningful effect.

private void logWithOptionalThrowable(Level level, String format, Object... arguments) {
if(isLastElementThrowable(arguments)) {
int lastIndex = arguments.length - 1;
Object[] args = Arrays.copyOfRange(arguments, 0, lastIndex);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try to avoid copying the array, it isn't necessary.

Object[] args = Arrays.copyOfRange(arguments, 0, lastIndex);
Throwable t = (Throwable) arguments[lastIndex];

logger.log(level, format(format, args), t);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest changing the way the format method works to optionally exclude the last element in case we know it's a Throwable. Pseudocode:

Throwable t = getPotentialThrowable(arguments);
if (t != null) logger.log(level, format(format, arguments, true), t);
else logger.log(level, format(format, arguments));

The existing format method would then delegate to the one with an optional boolean skipLast argument with false.

The getPotentialThrowable would be similar to isLastElementThrowable but would return the value or null.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the skipLast parameter to the format(...) method. Since Varargs must be placed at the end, I added skipLast as a middle parameter.
Additionally, I overloaded the existing method.

Comment on lines 459 to 473
private void logWithOptionalThrowable(Level level, String format, Object... arguments) {
if(isLastElementThrowable(arguments)) {
int lastIndex = arguments.length - 1;
Object[] args = Arrays.copyOfRange(arguments, 0, lastIndex);
Throwable t = (Throwable) arguments[lastIndex];

logger.log(level, format(format, args), t);
return;
}

logger.log(level, format(format, arguments));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to see a test for this.

Comment on lines 529 to 563
private synchronized void logWithOptionalThrowable(String level, String format, Object... arguments) {
if(isLastElementThrowable(arguments)) {
int lastIndex = arguments.length - 1;
Object[] args = Arrays.copyOfRange(arguments, 0, lastIndex);
Throwable t = (Throwable) arguments[lastIndex];

this.log.format("[%s] (%s) %s\n", level.toUpperCase(), Thread.currentThread().getName(), format(format, args));
t.printStackTrace(this.log);
return;
}

this.log.format("[%s] (%s) %s\n", level.toUpperCase(), Thread.currentThread().getName(), format(format, arguments));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have tests to validate this?

@@ -507,6 +526,39 @@ final String format(@Nullable String from, @Nullable Object... arguments){
return null;
}

private synchronized void logWithOptionalThrowable(String level, String format, Object... arguments) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comments as with the JdkLogger.

this.log.format("[%s] (%s) %s\n", level.toUpperCase(), Thread.currentThread().getName(), format(format, arguments));
}

private synchronized void logErrorWithOptionalThrowable(String level, String format, Object... arguments) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is almost exactly the same as the one above, can you please unify them with a PrintStream as an argument?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have merged the methods into one, and modified it to accept a PrintStream as a parameter.

public synchronized void info(String format, Object... arguments) {
this.log.format("[ INFO] (%s) %s\n", Thread.currentThread().getName(), format(format, arguments));
public void info(String format, Object... arguments) {
logWithOptionalThrowable("INFO", format, arguments);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am guessing the space was to align INFO under DEBUG and such in a single column so the space was a filler. With the spaces all of (TRACE, DEBUG, INFO, WARN, ERROR) take up the same amount of horizontal space. Please roll back.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for detailed explanation!
I have reverted the code.

- Enhanced logger methods to check if the last argument is of type Throwable.
- If Throwable is detected, its stack trace is included in the log output.
- Improves debugging by seamlessly handling exception logging alongside message formatting.

ref. https://www.slf4j.org/faq.html#paramException

Signed-off-by: raccoonback <[email protected]>
Signed-off-by: raccoonback <[email protected]>
@raccoonback raccoonback force-pushed the log-with-optional-throable branch from 9df27da to 4dd4f62 Compare January 31, 2025 14:42
@raccoonback
Copy link
Contributor Author

@chemicL
First of all, thanks for your review!
I have incorporated all of your suggestions.
Please let me know if there's anything else that needs adjustment!

@raccoonback raccoonback requested a review from chemicL January 31, 2025 14:55
Comment on lines 455 to 457
if (skipLast) --lastIndex;
for (int index = 0; index < lastIndex; ++index) {
computed = computed.replaceFirst("\\{\\}", Matcher.quoteReplacement(String.valueOf(arguments[index])));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Determines whether to replace the last factor depending on skipLast.

Copy link
Member

@chemicL chemicL left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow up. I added a few more comments, please apply them also to similar areas where appropriate. Please check the build results, they are failing with NPE. Before the next round please make sure the build is passing on your machine.

@@ -442,18 +442,45 @@ public void error(String msg, Throwable t) {
}

@Nullable
final String format(@Nullable String from, @Nullable Object... arguments){
if(from != null) {
String format(@Nullable String from, @Nullable Object... arguments) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one can also be private.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to private accessor.

for (Object argument : arguments) {
computed = computed.replaceFirst("\\{\\}", Matcher.quoteReplacement(String.valueOf(argument)));
int lastIndex = arguments.length;
if (skipLast) --lastIndex;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make sure you use braces around the instructions following an if condition.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflect it!

}

@Nullable
private String format(@Nullable String from, boolean skipLast, @Nullable Object... arguments) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the skipLast parameter to the format(...) method. Since Varargs must be placed at the end, I added skipLast as a middle parameter.
Additionally, I overloaded the existing method.

Would it work if instead of Object... you used Object[] ? Since this is an internal API it should be totally fine with the usages here. Then the order of arguments will be more appropriate IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed it to use Object[] instead.

@raccoonback
Copy link
Contributor Author

@chemicL
Hello.

Thank you for your detailed review!
I have addressed all the comments and also fixed the issue causing the NPE.

@raccoonback raccoonback requested a review from chemicL February 1, 2025 01:00
@chemicL chemicL added the type/enhancement A general enhancement label Feb 3, 2025
@chemicL chemicL added this to the 3.7.3 milestone Feb 3, 2025
Copy link
Member

@chemicL chemicL left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the follow up. The production code looks good. Regarding the tests, please refrain from using mocks when more end-to-end tests are possible. The existing tests allow validating the output. Check the use of ConsoleLoggerTest#outContent and JdkLoggerTest#nullFormatIsAcceptedByUnderlyingLogger. Also, please revert unrelated formatting changes so that the PR consists only of changes relevant to the problem that's being solved. Thanks.

@raccoonback raccoonback requested a review from chemicL February 3, 2025 13:10
@raccoonback
Copy link
Contributor Author

@chemicL

I have rolled back the unrelated formatting changes.

Additionally, since ConsoleLoggerTest already covers all cases for ConsoleLogger, I found the newly added test to be redundant and have therefore removed it.

@chemicL
Copy link
Member

chemicL commented Feb 3, 2025

I have rolled back the unrelated formatting changes.

Additionally, since ConsoleLoggerTest already covers all cases for ConsoleLogger, I found the newly added test to be redundant and have therefore removed it.

I'm not sure I understand. Since the functionality of ConsoleLogger has been enhanced, there should be a need for more tests. Specifically, no test validates a vararg call that has a Throwable at the end, which is the entire purpose of this PR.

In JdkLoggerTest there is no validation of the stacktrace. Please thoroughly review the changes and expectations.

Also, make sure the test methods are descriptive as the existing methods are and capture the circumstance and expectation.

@raccoonback
Copy link
Contributor Author

@chemicL

I misunderstood;;
As you mentioned, I have added a test for the case where the last argument of varargs is a Throwable.
Additionally, I modified JdkLoggerTest to validate the stacktrace and extracted the duplicate code for creating JdkLogger into the getJdkLogger() method.

@chemicL chemicL merged commit fb3d31d into reactor:main Feb 4, 2025
7 checks passed
@chemicL
Copy link
Member

chemicL commented Feb 4, 2025

@raccoonback thank you for the contribution! 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type/enhancement A general enhancement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants