I found a possible solution. Within ch.qos.logback.classic.spi.ThrowableProxy, replace the default constructor with the following field, constructors and method:

  private static final StackTraceElementProxy[] NO_STACK_TRACE=new StackTraceElementProxy[0];

  public ThrowableProxy(Throwable throwable) {
      this(throwable, null, false);
  }
  
  public ThrowableProxy(Throwable throwable, Set<Throwable> exclude, boolean circular) {
   
    this.throwable = throwable;
    if (!circular) {
      this.className = throwable.getClass().getName();
      this.message = throwable.getMessage();
      this.stackTraceElementProxyArray = ThrowableProxyUtil.steArrayToStepArray(throwable
          .getStackTrace());

      Throwable nested = throwable.getCause();

      if (nested != null) {
        if (exclude==null) {
            exclude = createExcludeSet(throwable);
        }
        if (!exclude.contains(nested)) {
          exclude.add(nested);
          this.cause = new ThrowableProxy(nested, exclude, false);
          this.cause.commonFrames = ThrowableProxyUtil
              .findNumberOfCommonFrames(nested.getStackTrace(),
                  stackTraceElementProxyArray);
        } else {
          this.cause = new ThrowableProxy(nested, exclude, true);
        }
      }
      if(GET_SUPPRESSED_METHOD != null) {
        // this will only execute on Java 7
        try {
          Object obj = GET_SUPPRESSED_METHOD.invoke(throwable);
          if(obj instanceof Throwable[]) {
            Throwable[] throwableSuppressed = (Throwable[]) obj;
            if(throwableSuppressed.length > 0) {
              suppressed = new ThrowableProxy[throwableSuppressed.length];
              if (exclude==null) {
                exclude = createExcludeSet(throwable);
              }
              for(int i=0;i<throwableSuppressed.length;i++) {
                if (!exclude.contains(throwableSuppressed[i])) {
                  exclude.add(throwableSuppressed[i]);
                  this.suppressed[i] = new ThrowableProxy(throwableSuppressed[i], exclude, false);
                  this.suppressed[i].commonFrames = ThrowableProxyUtil
                      .findNumberOfCommonFrames(throwableSuppressed[i].getStackTrace(),
                          stackTraceElementProxyArray);
                } else {
                  this.suppressed[i] = new ThrowableProxy(throwableSuppressed[i], exclude, true);
                }
              }
            }
          }
        } catch (IllegalAccessException e) {
          // ignore
        } catch (InvocationTargetException e) {
          // ignore
        }
      }
    } else {
      this.className = "[CIRCULAR REFERENCE:" + throwable.getClass().getName();
      this.message = throwable.getMessage() + "]";
      this.stackTraceElementProxyArray = NO_STACK_TRACE;
    }

  }

  private static Set<Throwable> createExcludeSet(Throwable parent) {
    Set<Throwable> exclude = Collections.newSetFromMap(new IdentityHashMap<Throwable, Boolean>());
    exclude.add(parent);
    return exclude;
  }

Basically, it will use an IdentityHashMap to maintain the throwable proxies created, partially skipping those that have already been processed.

To minimize the string concatenation, the message could just be shortened to throwable.getMessage(), although something must be prepended to the class name to indicate its a circular reference.

The above code would then generate:

ERROR ROOT - Error
java.lang.Exception: null
	at NewClass.main(NewClass.java:20) ~[classes/:na]
	Suppressed: java.lang.Exception: null
		at NewClass.main(NewClass.java:21) ~[classes/:na]
		Suppressed: [CIRCULAR REFERENCE:java.lang.Exception: null]
This message is automatically generated by JIRA.
If you think it was sent incorrectly, please contact your JIRA administrators
For more information on JIRA, see: http://www.atlassian.com/software/jira