Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,11 @@ public Rollbar(Context context, String accessToken, String environment,
.notifier(new NotifierProvider(context))
.environment(environment == null ? DEFAULT_ENVIRONMENT : environment)
.sender(sender)
.handleUncaughtErrors(false); // Use the global handler, not the default per thread one.
.handleUncaughtErrors(false) // Use the global handler, not the default per thread one.
// The DiskQueue above already persists payloads before the process dies, and they are
// transmitted on the next app start. Flushing at shutdown would attempt blocking network
// I/O while the app is being torn down, with nothing to gain.
.flushOnShutdown(false);

Config config;
if (configProvider != null) {
Expand Down
102 changes: 102 additions & 0 deletions rollbar-java/src/main/java/com/rollbar/notifier/Rollbar.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import com.rollbar.notifier.config.Config;
import com.rollbar.notifier.config.ConfigBuilder;
import com.rollbar.notifier.config.ConfigProvider;
import com.rollbar.notifier.provider.Provider;
import com.rollbar.notifier.sender.Sender;
import com.rollbar.notifier.sender.SenderShutdownHooks;
import com.rollbar.notifier.uncaughtexception.RollbarUncaughtExceptionHandler;
import com.rollbar.notifier.util.BodyFactory;
import com.rollbar.notifier.util.ObjectsUtils;
Expand All @@ -26,6 +29,10 @@ public class Rollbar extends RollbarBase<Void, Config> {

private static volatile Rollbar notifier;

private final Object shutdownHookLock = new Object();

private Thread shutdownHook;

/**
* Constructor.
*
Expand All @@ -41,9 +48,95 @@ public Rollbar(Config config) {
if (config.handleUncaughtErrors()) {
this.handleUncaughtErrors();
}
if (config.flushOnShutdown()) {
this.registerShutdownHook(config);
}
processAppPackages(config);
}

/**
* Registers a JVM shutdown hook that flushes buffered payloads before the process exits.
*
* <p>
* Mirrors {@link #handleUncaughtErrors()} in being driven from the configuration at
* construction time, so replacing the configuration later via
* {@link #configure(ConfigProvider)} does not add or remove the hook.
* </p>
*/
private void registerShutdownHook(Config config) {
if (config.sender() == null) {
return;
}

Thread hook = SenderShutdownHooks.create(new CurrentSenderProvider(),
config.shutdownTimeoutMillis());
try {
Runtime.getRuntime().addShutdownHook(hook);
synchronized (shutdownHookLock) {
this.shutdownHook = hook;
}
LOGGER.debug("Registered the Rollbar shutdown hook.");
} catch (IllegalStateException e) {
// The JVM is already shutting down, so there is nothing left to flush later.
LOGGER.debug("The JVM is already shutting down, the Rollbar shutdown hook was not "
+ "registered.");
} catch (SecurityException e) {
LOGGER.warn("No permission to register the Rollbar shutdown hook. Payloads buffered when "
+ "the JVM exits will not be sent.", e);
}
}

/**
* Resolves the sender from the configuration in force when the hook actually runs, so that a
* configuration replaced through {@link #configure(ConfigProvider)} after the hook was
* registered does not leave a stale sender being flushed.
*/
private class CurrentSenderProvider implements Provider<Sender> {
@Override
public Sender provide() {
configReadLock.lock();
try {
return config.sender();
} finally {
configReadLock.unlock();
}
}
}

/**
* The registered shutdown hook, or null if none was registered. Visible for testing.
*/
Thread shutdownHook() {
synchronized (shutdownHookLock) {
return this.shutdownHook;
}
}

/**
* Removes the shutdown hook, if one was registered. Called when the notifier is closed
* explicitly, both to avoid flushing twice and to let the hook be garbage collected.
*/
private void unregisterShutdownHook() {
Thread hook;
synchronized (shutdownHookLock) {
hook = this.shutdownHook;
this.shutdownHook = null;
}

if (hook == null) {
return;
}

try {
Runtime.getRuntime().removeShutdownHook(hook);
} catch (IllegalStateException e) {
// Shutdown is already in progress, the hook may be running right now. Nothing to do.
LOGGER.debug("The JVM is already shutting down, the Rollbar shutdown hook was not removed.");
} catch (SecurityException e) {
LOGGER.debug("No permission to remove the Rollbar shutdown hook.");
}
}

/**
* Method to initialize the library managed notifier instance.
*
Expand Down Expand Up @@ -651,7 +744,16 @@ public void log(ThrowableWrapper error, Map<String, Object> custom, String descr
}
}

/**
* Close the notifier and its sender, unregistering the shutdown hook first.
*
* @param wait whether to wait for the sender to send any buffered payloads before closing.
* @throws Exception if closing the sender fails.
*/
public void close(boolean wait) throws Exception {
// Dropped first: the notifier is being shut down explicitly, so the hook would either flush a
// sender that is already closed or duplicate the work being done here.
unregisterShutdownHook();
this.config.sender().close(wait);
}

Expand Down
41 changes: 41 additions & 0 deletions rollbar-java/src/main/java/com/rollbar/notifier/config/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,45 @@ public interface Config extends CommonConfig {
* @return the proxy.
*/
Proxy proxy();

/**
* <p>
* If set to true (the default), a JVM shutdown hook is registered that flushes any payloads
* still buffered in the {@link Sender sender} before the process exits.
* </p>
* <p>
* The default sender buffers payloads in memory and drains them on a background daemon thread
* every few seconds, so without this hook any occurrence captured shortly before the JVM
* terminates is discarded. That includes short-lived processes and, more importantly,
* {@code SIGTERM} during a rolling deploy or container eviction.
* </p>
* <p>
* Set to false if the application manages the notifier lifecycle itself, for example by
* calling {@link Rollbar#close(boolean)} explicitly, or when payloads are already persisted by
* a durable queue.
* </p>
*
* @return true to flush buffered payloads on JVM shutdown, false otherwise.
*/
default boolean flushOnShutdown() {
return true;
}

/**
* <p>
* The maximum time, in milliseconds, that the shutdown hook installed by
* {@link #flushOnShutdown()} will spend flushing buffered payloads before letting the JVM
* continue to exit. Ignored when {@link #flushOnShutdown()} is false.
* </p>
* <p>
* This is a hard bound and not a target: shutdown always proceeds once it elapses, even if
* payloads remain unsent. It exists because a flush performs blocking network I/O, and an
* unbounded one would be able to stall JVM shutdown indefinitely.
* </p>
*
* @return the shutdown flush timeout in milliseconds.
*/
default long shutdownTimeoutMillis() {
return 2000L;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@
*/
public class ConfigBuilder {

/**
* Whether a JVM shutdown hook that flushes buffered payloads is registered by default.
*/
public static final boolean DEFAULT_FLUSH_ON_SHUTDOWN = true;

/**
* The default hard bound, in milliseconds, on the shutdown flush. Kept short because it delays
* JVM exit, and the flush only has to drain what the background sender has not sent yet.
*/
public static final long DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 2000L;

protected String accessToken;

protected String endpoint;
Expand Down Expand Up @@ -92,6 +103,10 @@ public class ConfigBuilder {

protected boolean compressPayload;

protected boolean flushOnShutdown;

protected long shutdownTimeoutMillis;

protected List<String> redactedKeys;

protected boolean useDefaultRedactedKeys = true;
Expand All @@ -113,6 +128,8 @@ protected ConfigBuilder(String accessToken) {
this.handleUncaughtErrors = true;
this.enabled = true;
this.compressPayload = true;
this.flushOnShutdown = DEFAULT_FLUSH_ON_SHUTDOWN;
this.shutdownTimeoutMillis = DEFAULT_SHUTDOWN_TIMEOUT_MILLIS;
this.defaultLevels = new DefaultLevels();
}

Expand Down Expand Up @@ -149,6 +166,8 @@ private ConfigBuilder(Config config) {
this.defaultLevels = new DefaultLevels(config);
this.truncateLargePayloads = config.truncateLargePayloads();
this.compressPayload = config.compressPayload();
this.flushOnShutdown = config.flushOnShutdown();
this.shutdownTimeoutMillis = config.shutdownTimeoutMillis();
this.maximumTelemetryData = config.maximumTelemetryData();
this.telemetryEventTracker = config.telemetryEventTracker();
this.redactedKeys = config.redactedKeys();
Expand Down Expand Up @@ -508,6 +527,50 @@ public ConfigBuilder compressPayload(boolean compress) {
return this;
}

/**
* <p>
* If set to true (the default), a JVM shutdown hook is registered that flushes any payloads
* still buffered in the sender before the process exits.
* </p>
* <p>
* The default sender buffers payloads in memory and drains them on a background daemon thread
* every few seconds, so without this hook an occurrence captured shortly before the JVM
* terminates is discarded. That covers short-lived processes as well as {@code SIGTERM} during
* a rolling deploy or container eviction.
* </p>
* <p>
* Set to false when the application manages the notifier lifecycle itself, for example by
* calling {@link com.rollbar.notifier.Rollbar#close(boolean)}, or when a durable queue already
* persists payloads across restarts.
* </p>
*
* @param flushOnShutdown true to flush buffered payloads on JVM shutdown.
* @return the builder instance.
*/
public ConfigBuilder flushOnShutdown(boolean flushOnShutdown) {
this.flushOnShutdown = flushOnShutdown;
return this;
}

/**
* <p>
* The maximum time, in milliseconds, that the shutdown hook will spend flushing buffered
* payloads before letting the JVM continue to exit. Default: 2000. Ignored when
* {@link #flushOnShutdown(boolean)} is false.
* </p>
* <p>
* This is a hard bound rather than a target, and shutdown proceeds once it elapses even if
* payloads remain unsent. Values of zero or less start the flush without waiting for it.
* </p>
*
* @param shutdownTimeoutMillis the shutdown flush timeout in milliseconds.
* @return the builder instance.
*/
public ConfigBuilder shutdownTimeoutMillis(long shutdownTimeoutMillis) {
this.shutdownTimeoutMillis = shutdownTimeoutMillis;
return this;
}

/**
* <p>
* Maximum Telemetry events sent in a payload, only for the default TelemetryEventTracker, if
Expand Down Expand Up @@ -672,6 +735,10 @@ private static class ConfigImpl implements Config {

private final boolean compressPayload;

private final boolean flushOnShutdown;

private final long shutdownTimeoutMillis;

private final int maximumTelemetryData;

private final TelemetryEventTracker telemetryEventTracker;
Expand Down Expand Up @@ -715,6 +782,8 @@ private static class ConfigImpl implements Config {
this.defaultLevels = builder.defaultLevels;
this.truncateLargePayloads = builder.truncateLargePayloads;
this.compressPayload = builder.compressPayload;
this.flushOnShutdown = builder.flushOnShutdown;
this.shutdownTimeoutMillis = builder.shutdownTimeoutMillis;
this.maximumTelemetryData = builder.maximumTelemetryData;
this.telemetryEventTracker = builder.telemetryEventTracker;
this.redactedKeys = builder.redactedKeys != null
Expand Down Expand Up @@ -874,6 +943,16 @@ public boolean compressPayload() {
return this.compressPayload;
}

@Override
public boolean flushOnShutdown() {
return this.flushOnShutdown;
}

@Override
public long shutdownTimeoutMillis() {
return this.shutdownTimeoutMillis;
}

@Override
public int maximumTelemetryData() {
return this.maximumTelemetryData;
Expand Down
Loading
Loading