Merge "PureRevert: Don't write any output when computing diff"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index ecc2ba0..91cb87f 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -3790,6 +3790,14 @@
 Defaults to link:#retry.timeout[`retry.timeout`]; unit suffixes are supported,
 and assumes milliseconds if not specified.
 
+[[retry.retryWithTraceOnFailure]]retry.retryWithTraceOnFailure::
++
+Whether Gerrit should automatically retry operations on failure with tracing
+enabled. The automatically generated traces can help with debugging. Please
+note that only some of the REST endpoints support automatic retry.
++
+By default this is set to false.
+
 [[rules]]
 === Section rules
 
@@ -4552,6 +4560,52 @@
 +
 By default, true.
 
+[[tracing.traceid]]
+==== Subsection tracing.<trace-id>
+
+There can be multiple `tracing.<trace-id>` subsections to configure
+automatic tracing of requests. To be traced a request must match all
+conditions of one `tracing.<trace-id>` subsection. The subsection name
+is used as trace ID. Using this trace ID administrators can find
+matching log entries.
+
+[[tracing.traceid.requestType]]tracing.<trace-id>.requestType::
++
+Type of request for which request tracing should be always enabled (can
+be `GIT_RECEIVE`, `GIT_UPLOAD`, `REST` and `SSH`).
++
+May be specified multiple times.
++
+By default, unset (all request types are matched).
+
+[[tracing.traceid.requestUriPattern]]tracing.<trace-id>.requestUriPattern::
++
+Regular expression to match request URIs for which request tracing
+should be always enabled. Request URIs are only available for REST
+requests. Request URIs never include the '/a' prefix.
++
+May be specified multiple times.
++
+By default, unset (all request URIs are matched).
+
+[[tracing.traceid.account]]tracing.<trace-id>.account::
++
+Account ID of an account for which request tracing should be always
+enabled.
++
+May be specified multiple times.
++
+By default, unset (all accounts are matched).
+
+[[tracing.traceid.projectPattern]]tracing.<trace-id>.projectPattern::
++
+Regular expression to match project names for which request tracing
+should be always enabled.
++
+May be specified multiple times.
++
+By default, unset (all projects are matched).
+
 [[trackingid]]
 === Section trackingid
 
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index ff43520..9c90ba7 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -297,12 +297,13 @@
 [[label_copyAllScoresOnTrivialRebase]]
 === `label.Label-Name.copyAllScoresOnTrivialRebase`
 
-If true, all scores for the label are copied forward when a new patch
-set is uploaded that is a trivial rebase. A new patch set is considered
-as trivial rebase if the commit message is the same as in the previous
-patch set and if it has the same code delta as the previous patch set.
-This is the case if the change was rebased onto a different parent, or
-if the parent did not change at all.
+If true, all scores for the label are copied forward when a new patch set is
+uploaded that is a trivial rebase. A new patch set is considered to be trivial
+rebase if the commit message is the same as in the previous patch set and if it
+has the same diff (including context lines) as the previous patch set. This is
+the case if the change was rebased onto a different parent and that rebase did
+not require git to perform any conflict resolution, or if the parent did not
+change at all.
 
 This can be used to enable sticky approvals, reducing turn-around for
 trivial rebases prior to submitting a change.
@@ -313,13 +314,13 @@
 [[label_copyAllScoresIfNoCodeChange]]
 === `label.Label-Name.copyAllScoresIfNoCodeChange`
 
-If true, all scores for the label are copied forward when a new patch
-set is uploaded that has the same parent tree as the previous patch
-set and the same code delta as the previous patch set. This means only
-the commit message is different. This can be used to enable sticky
-approvals on labels that only depend on the code, reducing turn-around
-if only the commit message is changed prior to submitting a change.
-For the Verified label that is optionally installed by the
+If true, all scores for the label are copied forward when a new patch set is
+uploaded that has the same parent tree as the previous patch set and the same
+code diff (including context lines) as the previous patch set. This means only
+the commit message is different; the change hasn't even been rebased. This can
+be used to enable sticky approvals on labels that only depend on the code,
+reducing turn-around if only the commit message is changed prior to submitting a
+change. For the Verified label that is optionally installed by the
 link:pgm-init.html[init] site program this is enabled by default.
 
 Defaults to false.
diff --git a/java/com/google/gerrit/extensions/common/AvatarInfo.java b/java/com/google/gerrit/extensions/common/AvatarInfo.java
index 00f1819..de609eb 100644
--- a/java/com/google/gerrit/extensions/common/AvatarInfo.java
+++ b/java/com/google/gerrit/extensions/common/AvatarInfo.java
@@ -21,7 +21,7 @@
    * <p>The web UI prefers avatar images to be square, both the height and width of the image should
    * be this size. The height is the more important dimension to match than the width.
    */
-  public static final int DEFAULT_SIZE = 26;
+  public static final int DEFAULT_SIZE = 32;
 
   public String url;
   public Integer height;
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 736018a..2128777b 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -317,7 +317,7 @@
     try (TraceContext traceContext = enableTracing(req, res)) {
       List<IdString> path = splitPath(req);
 
-      RequestInfo requestInfo = createRequestInfo(traceContext, path);
+      RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
       globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
       try (PerThreadCache ignored = PerThreadCache.create()) {
@@ -1424,9 +1424,11 @@
     return traceContext;
   }
 
-  private RequestInfo createRequestInfo(TraceContext traceContext, List<IdString> path) {
+  private RequestInfo createRequestInfo(
+      TraceContext traceContext, String requestUri, List<IdString> path) {
     RequestInfo.Builder requestInfo =
-        RequestInfo.builder(RequestInfo.RequestType.REST, globals.currentUser.get(), traceContext);
+        RequestInfo.builder(RequestInfo.RequestType.REST, globals.currentUser.get(), traceContext)
+            .requestUri(requestUri);
 
     if (path.size() < 1) {
       return requestInfo.build();
diff --git a/java/com/google/gerrit/server/RequestInfo.java b/java/com/google/gerrit/server/RequestInfo.java
index c86d34d..f5749fc 100644
--- a/java/com/google/gerrit/server/RequestInfo.java
+++ b/java/com/google/gerrit/server/RequestInfo.java
@@ -45,6 +45,15 @@
    */
   public abstract String requestType();
 
+  /**
+   * Request URI.
+   *
+   * <p>Only set if request type is {@link RequestType#REST}.
+   *
+   * <p>Never includes the "/a" prefix.
+   */
+  public abstract Optional<String> requestUri();
+
   /** The user that has sent the request. */
   public abstract CurrentUser callingUser();
 
@@ -74,6 +83,8 @@
       return requestType(requestType.name());
     }
 
+    public abstract Builder requestUri(String requestUri);
+
     public abstract Builder callingUser(CurrentUser callingUser);
 
     public abstract Builder traceContext(TraceContext traceContext);
diff --git a/java/com/google/gerrit/server/TraceRequestListener.java b/java/com/google/gerrit/server/TraceRequestListener.java
index 1ae39e1..773f712 100644
--- a/java/com/google/gerrit/server/TraceRequestListener.java
+++ b/java/com/google/gerrit/server/TraceRequestListener.java
@@ -14,12 +14,215 @@
 
 package com.google.gerrit.server;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.Optional;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 
+/**
+ * Request listener that sets additional logging tags and enables tracing automatically if the
+ * request matches any tracing configuration in gerrit.config (see description of
+ * 'tracing.<trace-id>' subsection in config-gerrit.txt).
+ */
 @Singleton
 public class TraceRequestListener implements RequestListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Config cfg;
+  private final ImmutableList<TraceConfig> traceConfigs;
+
+  @Inject
+  TraceRequestListener(@GerritServerConfig Config cfg) {
+    this.cfg = cfg;
+    this.traceConfigs = parseTraceConfigs();
+  }
+
   @Override
   public void onRequest(RequestInfo requestInfo) {
     requestInfo.project().ifPresent(p -> requestInfo.traceContext().addTag("project", p));
+    traceConfigs.stream()
+        .filter(traceConfig -> traceConfig.matches(requestInfo))
+        .forEach(
+            traceConfig ->
+                requestInfo
+                    .traceContext()
+                    .forceLogging()
+                    .addTag(RequestId.Type.TRACE_ID, traceConfig.traceId()));
+  }
+
+  private ImmutableList<TraceConfig> parseTraceConfigs() {
+    ImmutableList.Builder<TraceConfig> traceConfigs = ImmutableList.builder();
+
+    for (String traceId : cfg.getSubsections("tracing")) {
+      try {
+        TraceConfig.Builder traceConfig = TraceConfig.builder();
+        traceConfig.traceId(traceId);
+        traceConfig.requestTypes(parseRequestTypes(traceId));
+        traceConfig.requestUriPatterns(parseRequestUriPatterns(traceId));
+        traceConfig.accountIds(parseAccounts(traceId));
+        traceConfig.projectPatterns(parseProjectPatterns(traceId));
+        traceConfigs.add(traceConfig.build());
+      } catch (ConfigInvalidException e) {
+        logger.atWarning().log("Ignoring invalid tracing configuration:\n %s", e.getMessage());
+      }
+    }
+
+    return traceConfigs.build();
+  }
+
+  private ImmutableSet<String> parseRequestTypes(String traceId) {
+    return ImmutableSet.copyOf(cfg.getStringList("tracing", traceId, "requestType"));
+  }
+
+  private ImmutableSet<Pattern> parseRequestUriPatterns(String traceId)
+      throws ConfigInvalidException {
+    return parsePatterns(traceId, "requestUriPattern");
+  }
+
+  private ImmutableSet<Account.Id> parseAccounts(String traceId) throws ConfigInvalidException {
+    ImmutableSet.Builder<Account.Id> accountIds = ImmutableSet.builder();
+    String[] accounts = cfg.getStringList("tracing", traceId, "account");
+    for (String account : accounts) {
+      Optional<Account.Id> accountId = Account.Id.tryParse(account);
+      if (!accountId.isPresent()) {
+        throw new ConfigInvalidException(
+            String.format(
+                "Invalid tracing config ('tracing.%s.account = %s'): invalid account ID",
+                traceId, account));
+      }
+      accountIds.add(accountId.get());
+    }
+    return accountIds.build();
+  }
+
+  private ImmutableSet<Pattern> parseProjectPatterns(String traceId) throws ConfigInvalidException {
+    return parsePatterns(traceId, "projectPattern");
+  }
+
+  private ImmutableSet<Pattern> parsePatterns(String traceId, String name)
+      throws ConfigInvalidException {
+    ImmutableSet.Builder<Pattern> patterns = ImmutableSet.builder();
+    String[] patternRegExs = cfg.getStringList("tracing", traceId, name);
+    for (String patternRegEx : patternRegExs) {
+      try {
+        patterns.add(Pattern.compile(patternRegEx));
+      } catch (PatternSyntaxException e) {
+        throw new ConfigInvalidException(
+            String.format(
+                "Invalid tracing config ('tracing.%s.%s = %s'): %s",
+                traceId, name, patternRegEx, e.getMessage()));
+      }
+    }
+    return patterns.build();
+  }
+
+  @AutoValue
+  abstract static class TraceConfig {
+    /** ID for the trace */
+    abstract String traceId();
+
+    /** request types that should be traced */
+    abstract ImmutableSet<String> requestTypes();
+
+    /** pattern matching request URIs */
+    abstract ImmutableSet<Pattern> requestUriPatterns();
+
+    /** accounts IDs matching calling user */
+    abstract ImmutableSet<Account.Id> accountIds();
+
+    /** pattern matching projects names */
+    abstract ImmutableSet<Pattern> projectPatterns();
+
+    static Builder builder() {
+      return new AutoValue_TraceRequestListener_TraceConfig.Builder();
+    }
+
+    /**
+     * Whether this trace config matches a given request.
+     *
+     * @param requestInfo request info
+     * @return whether this trace config matches
+     */
+    boolean matches(RequestInfo requestInfo) {
+      // If in the trace config request types are set and none of them matches, then the request is
+      // not matched.
+      if (!requestTypes().isEmpty()
+          && requestTypes().stream()
+              .noneMatch(type -> type.equalsIgnoreCase(requestInfo.requestType()))) {
+        return false;
+      }
+
+      // If in the trace config request URI patterns are set and none of them matches, then the
+      // request is not matched.
+      if (!requestUriPatterns().isEmpty()) {
+        if (!requestInfo.requestUri().isPresent()) {
+          // The request has no request URI, hence it cannot match a request URI pattern.
+          return false;
+        }
+
+        if (requestUriPatterns().stream()
+            .noneMatch(p -> p.matcher(requestInfo.requestUri().get()).matches())) {
+          return false;
+        }
+      }
+
+      // If in the trace config accounts are set and none of them matches, then the request is not
+      // matched.
+      if (!accountIds().isEmpty()) {
+        try {
+          if (accountIds().stream()
+              .noneMatch(id -> id.equals(requestInfo.callingUser().getAccountId()))) {
+            return false;
+          }
+        } catch (UnsupportedOperationException e) {
+          // The calling user is not logged in, hence it cannot match an account.
+          return false;
+        }
+      }
+
+      // If in the trace config project patterns are set and none of them matches, then the request
+      // is not matched.
+      if (!projectPatterns().isEmpty()) {
+        if (!requestInfo.project().isPresent()) {
+          // The request is not for a project, hence it cannot match a project pattern.
+          return false;
+        }
+
+        if (projectPatterns().stream()
+            .noneMatch(p -> p.matcher(requestInfo.project().get().get()).matches())) {
+          return false;
+        }
+      }
+
+      // For any match criteria (request type, request URI pattern, account, project pattern) that
+      // was specified in the trace config, at least one of the configured value matched the
+      // request.
+      return true;
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder traceId(String traceId);
+
+      abstract Builder requestTypes(ImmutableSet<String> requestTypes);
+
+      abstract Builder requestUriPatterns(ImmutableSet<Pattern> requestUriPatterns);
+
+      abstract Builder accountIds(ImmutableSet<Account.Id> accountIds);
+
+      abstract Builder projectPatterns(ImmutableSet<Pattern> projectPatterns);
+
+      abstract TraceConfig build();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 49afaf2..99ad570 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -150,20 +150,19 @@
     if (options.contains(FillOptions.AVATARS)) {
       AvatarProvider ap = avatar.get();
       if (ap != null) {
-        info.avatars = new ArrayList<>(3);
+        info.avatars = new ArrayList<>();
         IdentifiedUser user = userFactory.create(account.getId());
 
-        // GWT UI uses DEFAULT_SIZE (26px).
+        // PolyGerrit UI uses the following sizes for avatars:
+        // - 32px for avatars next to names e.g. on the dashboard. This is also Gerrit's default.
+        // - 56px for the user's own avatar in the menu
+        // - 100ox for other user's avatars on dashboards
+        // - 120px for the user's own profile settings page
         addAvatar(ap, info, user, AvatarInfo.DEFAULT_SIZE);
-
-        // PolyGerrit UI prefers 32px and 100px.
         if (!info.avatars.isEmpty()) {
-          if (32 != AvatarInfo.DEFAULT_SIZE) {
-            addAvatar(ap, info, user, 32);
-          }
-          if (100 != AvatarInfo.DEFAULT_SIZE) {
-            addAvatar(ap, info, user, 100);
-          }
+          addAvatar(ap, info, user, 56);
+          addAvatar(ap, info, user, 100);
+          addAvatar(ap, info, user, 120);
         }
       }
     }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 4088c81..e464e81 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -17,15 +17,17 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.naturalOrder;
 import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
 
-import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -68,7 +70,6 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
-import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -847,26 +848,22 @@
       return String.format("Merge \"%s\"", c.getShortMessage());
     }
 
-    LinkedHashSet<String> topics = new LinkedHashSet<>(4);
-    for (CodeReviewCommit c : merged) {
-      if (!Strings.isNullOrEmpty(c.change().getTopic())) {
-        topics.add(c.change().getTopic());
-      }
-    }
+    ImmutableSortedSet<String> topics =
+        merged.stream()
+            .map(c -> c.change().getTopic())
+            .filter(t -> !Strings.isNullOrEmpty(t))
+            .map(t -> "\"" + t + "\"")
+            .collect(toImmutableSortedSet(naturalOrder()));
 
-    if (topics.size() == 1) {
-      return String.format("Merge changes from topic \"%s\"", Iterables.getFirst(topics, null));
-    } else if (topics.size() > 1) {
-      return String.format("Merge changes from topics \"%s\"", Joiner.on("\", \"").join(topics));
-    } else {
+    if (!topics.isEmpty()) {
       return String.format(
-          "Merge changes %s%s",
-          FluentIterable.from(merged)
-              .limit(5)
-              .transform(c -> c.change().getKey().abbreviate())
-              .join(Joiner.on(',')),
-          merged.size() > 5 ? ", ..." : "");
+          "Merge changes from topic%s %s",
+          topics.size() > 1 ? "s" : "", topics.stream().collect(joining(", ")));
     }
+    return merged.stream()
+        .limit(5)
+        .map(c -> c.change().getKey().abbreviate())
+        .collect(joining(",", "Merge changes ", merged.size() > 5 ? ", ..." : ""));
   }
 
   public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig) {
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index da051dd..02a24f7 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -146,12 +146,7 @@
               new CommitterUploaderValidator(user, perm, urlFormatter.get()),
               new SignedOffByValidator(user, perm, projectState),
               new ChangeIdValidator(
-                  projectState,
-                  user,
-                  urlFormatter.get(),
-                  installCommitMsgHookCommand,
-                  sshInfo,
-                  change),
+                  user, urlFormatter.get(), installCommitMsgHookCommand, sshInfo, change),
               new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects),
               new BannedCommitsValidator(rejectCommits),
               new PluginCommitValidationListener(pluginValidators, skipValidation),
@@ -178,12 +173,7 @@
               new AuthorUploaderValidator(user, perm, urlFormatter.get()),
               new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.project())),
               new ChangeIdValidator(
-                  projectState,
-                  user,
-                  urlFormatter.get(),
-                  installCommitMsgHookCommand,
-                  sshInfo,
-                  change),
+                  user, urlFormatter.get(), installCommitMsgHookCommand, sshInfo, change),
               new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects),
               new PluginCommitValidationListener(pluginValidators),
               new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
@@ -257,7 +247,6 @@
 
     private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
 
-    private final ProjectState projectState;
     private final UrlFormatter urlFormatter;
     private final String installCommitMsgHookCommand;
     private final SshInfo sshInfo;
@@ -265,13 +254,11 @@
     private final Change change;
 
     public ChangeIdValidator(
-        ProjectState projectState,
         IdentifiedUser user,
         UrlFormatter urlFormatter,
         String installCommitMsgHookCommand,
         SshInfo sshInfo,
         Change change) {
-      this.projectState = projectState;
       this.urlFormatter = urlFormatter;
       this.installCommitMsgHookCommand = installCommitMsgHookCommand;
       this.sshInfo = sshInfo;
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 06a2e0d..eb3b831 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -99,9 +99,9 @@
           try {
             add(matching, state.getNameKey(), nc);
           } catch (QueryParseException e) {
-            logger.atWarning().withCause(e).log(
-                "Project %s has invalid notify %s filter \"%s\"",
-                state.getName(), nc.getName(), nc.getFilter());
+            logger.atInfo().log(
+                "Project %s has invalid notify %s filter \"%s\": %s",
+                state.getName(), nc.getName(), nc.getFilter(), e.getMessage());
           }
         }
       }
@@ -232,8 +232,8 @@
       logger.atFine().log("The filter did not match for account %s; skip notification", accountId);
     } catch (QueryParseException e) {
       // Ignore broken filter expressions.
-      logger.atWarning().withCause(e).log(
-          "Account %s has invalid filter in project watch %s", accountId, key);
+      logger.atInfo().log(
+          "Account %s has invalid filter in project watch %s: %s", accountId, key, e.getMessage());
     }
     return false;
   }
diff --git a/java/com/google/gerrit/server/restapi/account/PutUsername.java b/java/com/google/gerrit/server/restapi/account/PutUsername.java
index bc1ffc8..76a641d 100644
--- a/java/com/google/gerrit/server/restapi/account/PutUsername.java
+++ b/java/com/google/gerrit/server/restapi/account/PutUsername.java
@@ -20,9 +20,10 @@
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.api.accounts.UsernameInput;
 import com.google.gerrit.extensions.client.AccountFieldName;
-import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -71,9 +72,7 @@
 
   @Override
   public String apply(AccountResource rsrc, UsernameInput input)
-      throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
-          ResourceConflictException, IOException, ConfigInvalidException,
-          PermissionBackendException {
+      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
@@ -82,17 +81,13 @@
       throw new MethodNotAllowedException("realm does not allow editing username");
     }
 
-    if (input == null) {
-      input = new UsernameInput();
-    }
-
     Account.Id accountId = rsrc.getUser().getAccountId();
     if (!externalIds.byAccount(accountId, SCHEME_USERNAME).isEmpty()) {
       throw new MethodNotAllowedException("Username cannot be changed.");
     }
 
-    if (Strings.isNullOrEmpty(input.username)) {
-      return input.username;
+    if (input == null || Strings.isNullOrEmpty(input.username)) {
+      throw new BadRequestException("input required");
     }
 
     if (!ExternalId.isValidUsername(input.username)) {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 06c52c7..6c3d48b 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -511,6 +511,8 @@
                     retryHelper
                         .getDefaultTimeout(ActionType.CHANGE_UPDATE)
                         .multipliedBy(cs.projects().size()))
+                .caller(getClass())
+                .retryWithTrace(t -> !(t instanceof RestApiException))
                 .build());
 
         if (projects > 1) {
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index 14d04cb..695feba 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -40,11 +40,14 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.RequestId;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.time.Duration;
 import java.util.Arrays;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import java.util.function.Consumer;
 import java.util.function.Predicate;
@@ -94,12 +97,20 @@
     @Nullable
     abstract Duration timeout();
 
+    abstract Optional<Class<?>> caller();
+
+    abstract Optional<Predicate<Throwable>> retryWithTrace();
+
     @AutoValue.Builder
     public abstract static class Builder {
       public abstract Builder listener(RetryListener listener);
 
       public abstract Builder timeout(Duration timeout);
 
+      public abstract Builder caller(Class<?> caller);
+
+      public abstract Builder retryWithTrace(Predicate<Throwable> exceptionPredicate);
+
       public abstract Options build();
     }
   }
@@ -147,6 +158,7 @@
   private final Map<ActionType, Duration> defaultTimeouts;
   private final WaitStrategy waitStrategy;
   @Nullable private final Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup;
+  private final boolean retryWithTraceOnFailure;
 
   @Inject
   RetryHelper(@GerritServerConfig Config cfg, Metrics metrics, BatchUpdate.Factory updateFactory) {
@@ -186,6 +198,7 @@
                 MILLISECONDS),
             WaitStrategies.randomWait(50, MILLISECONDS));
     this.overwriteDefaultRetryerStrategySetup = overwriteDefaultRetryerStrategySetup;
+    this.retryWithTraceOnFailure = cfg.getBoolean("retry", "retryWithTraceOnFailure", false);
   }
 
   public Duration getDefaultTimeout(ActionType actionType) {
@@ -256,8 +269,35 @@
       Predicate<Throwable> exceptionPredicate)
       throws Throwable {
     MetricListener listener = new MetricListener();
-    try {
-      RetryerBuilder<T> retryerBuilder = createRetryerBuilder(actionType, opts, exceptionPredicate);
+    try (TraceContext traceContext = TraceContext.open()) {
+      RetryerBuilder<T> retryerBuilder =
+          createRetryerBuilder(
+              actionType,
+              opts,
+              t -> {
+                // exceptionPredicate checks for temporary errors for which the operation should be
+                // retried (e.g. LockFailure). The retry has good chances to succeed.
+                if (exceptionPredicate.test(t)) {
+                  return true;
+                }
+
+                // A non-recoverable failure occurred. Check if we should retry to capture a trace
+                // of the failure. If a trace was already done there is no need to retry.
+                if (retryWithTraceOnFailure
+                    && opts.retryWithTrace().isPresent()
+                    && opts.retryWithTrace().get().test(t)
+                    && !traceContext.isTracing()) {
+                  traceContext
+                      .addTag(RequestId.Type.TRACE_ID, "retry-on-failure-" + new RequestId())
+                      .forceLogging();
+                  logger.atFine().withCause(t).log(
+                      "%s failed, retry with tracing eanbled",
+                      opts.caller().map(Class::getSimpleName).orElse("N/A"));
+                  return true;
+                }
+
+                return false;
+              });
       retryerBuilder.withRetryListener(listener);
       return executeWithTimeoutCount(actionType, action, retryerBuilder.build());
     } finally {
diff --git a/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java b/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java
index 7620386..9204565 100644
--- a/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java
+++ b/java/com/google/gerrit/server/update/RetryingRestCollectionModifyView.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.RestResource;
 
@@ -32,7 +33,13 @@
   @Override
   public final O apply(P parentResource, I input)
       throws AuthException, BadRequestException, ResourceConflictException, Exception {
-    return retryHelper.execute((updateFactory) -> applyImpl(updateFactory, parentResource, input));
+    RetryHelper.Options retryOptions =
+        RetryHelper.options()
+            .caller(getClass())
+            .retryWithTrace(t -> !(t instanceof RestApiException))
+            .build();
+    return retryHelper.execute(
+        (updateFactory) -> applyImpl(updateFactory, parentResource, input), retryOptions);
   }
 
   protected abstract O applyImpl(BatchUpdate.Factory updateFactory, P parentResource, I input)
diff --git a/java/com/google/gerrit/server/update/RetryingRestModifyView.java b/java/com/google/gerrit/server/update/RetryingRestModifyView.java
index e2f4a02..b471d70 100644
--- a/java/com/google/gerrit/server/update/RetryingRestModifyView.java
+++ b/java/com/google/gerrit/server/update/RetryingRestModifyView.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.update;
 
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestResource;
 
@@ -27,7 +28,13 @@
 
   @Override
   public final O apply(R resource, I input) throws Exception {
-    return retryHelper.execute((updateFactory) -> applyImpl(updateFactory, resource, input));
+    RetryHelper.Options retryOptions =
+        RetryHelper.options()
+            .caller(getClass())
+            .retryWithTrace(t -> !(t instanceof RestApiException))
+            .build();
+    return retryHelper.execute(
+        (updateFactory) -> applyImpl(updateFactory, resource, input), retryOptions);
   }
 
   protected abstract O applyImpl(BatchUpdate.Factory updateFactory, R resource, I input)
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index cc99a90..3d9b88d 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -2663,10 +2663,6 @@
     return cds.get(0);
   }
 
-  private static void pushForReviewOk(TestRepository<?> testRepo) throws GitAPIException {
-    pushForReview(testRepo, RemoteRefUpdate.Status.OK, null);
-  }
-
   private static void pushForReviewRejected(TestRepository<?> testRepo, String expectedMessage)
       throws GitAPIException {
     pushForReview(testRepo, RemoteRefUpdate.Status.REJECTED_OTHER_REASON, expectedMessage);
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 50d6dff..56a9b69 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static org.apache.http.HttpStatus.SC_CREATED;
+import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
 import static org.apache.http.HttpStatus.SC_OK;
 
 import com.google.auto.value.AutoValue;
@@ -28,6 +29,7 @@
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -44,10 +46,14 @@
 import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.SortedMap;
 import java.util.SortedSet;
@@ -79,6 +85,7 @@
   @Inject private DynamicSet<CommitValidationListener> commitValidationListeners;
   @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
   @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
+  @Inject private DynamicSet<SubmitRule> submitRules;
   @Inject private WorkQueue workQueue;
 
   private TraceValidatingProjectCreationValidationListener projectCreationListener;
@@ -367,6 +374,256 @@
     assertThat(testPerformanceLogger.logEntries()).isEmpty();
   }
 
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new12")
+  public void traceProject() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new12");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new12");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new.*")
+  public void traceProjectMatchRegEx() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new13");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new13");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "foo.*")
+  public void traceProjectNoMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new13");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new13");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "][")
+  public void traceProjectInvalidRegEx() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new14");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new14");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000000")
+  public void traceAccount() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new15");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new15");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000001")
+  public void traceAccountNoMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new16");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new16");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "999")
+  public void traceAccountNotFound() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new17");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new17");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "invalid")
+  public void traceAccountInvalidId() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new18");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new18");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestType", value = "REST")
+  public void traceRequestType() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new19");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new19");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestType", value = "SSH")
+  public void traceRequestTypeNoMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new20");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new20");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestType", value = "FOO")
+  public void traceProjectInvalidRequestType() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new21");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new21");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000000")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new.*")
+  public void traceProjectForAccount() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new22");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new22");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000000")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "foo.*")
+  public void traceProjectForAccountNoProjectMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new23");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.account", value = "1000001")
+  @GerritConfig(name = "tracing.issue123.projectPattern", value = "new.*")
+  public void traceProjectForAccountNoAccountMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new24");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new24");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*")
+  public void traceRequestUri() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new23");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+    assertThat(projectCreationListener.isLoggingForced).isTrue();
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*/foo")
+  public void traceRequestUriNoMatch() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new23");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new23");
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "][")
+  public void traceRequestUriInvalidRegEx() throws Exception {
+    RestResponse response = adminRestSession.put("/projects/new24");
+    assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+    assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+    assertThat(projectCreationListener.traceId).isNull();
+    assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+    // The logging tag with the project name is also set if tracing is off.
+    assertThat(projectCreationListener.tags.get("project")).containsExactly("new24");
+  }
+
+  @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
+  public void autoRetryWithTrace() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
+    traceSubmitRule.failOnce = true;
+    RegistrationHandle submitRuleRegistrationHandle = submitRules.add("gerrit", traceSubmitRule);
+    try {
+      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(traceSubmitRule.traceId).startsWith("retry-on-failure-");
+      assertThat(traceSubmitRule.isLoggingForced).isTrue();
+    } finally {
+      submitRuleRegistrationHandle.remove();
+    }
+  }
+
+  @Test
+  public void noAutoRetryWithTraceIfDisabled() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
+    traceSubmitRule.failOnce = true;
+    RegistrationHandle submitRuleRegistrationHandle = submitRules.add("gerrit", traceSubmitRule);
+    try {
+      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(traceSubmitRule.traceId).isNull();
+      assertThat(traceSubmitRule.isLoggingForced).isNull();
+    } finally {
+      submitRuleRegistrationHandle.remove();
+    }
+  }
+
   private void assertForceLogging(boolean expected) {
     assertThat(LoggingContext.getInstance().shouldForceLogging(null, null, false))
         .isEqualTo(expected);
@@ -417,6 +674,28 @@
     public void onChangeDeleted(int id) {}
   }
 
+  private static class TraceSubmitRule implements SubmitRule {
+    String traceId;
+    Boolean isLoggingForced;
+    boolean failOnce;
+
+    @Override
+    public Collection<SubmitRecord> evaluate(ChangeData changeData, SubmitRuleOptions options) {
+      if (failOnce) {
+        failOnce = false;
+        throw new IllegalStateException("forced failure from test");
+      }
+
+      this.traceId =
+          Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
+      this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+
+      SubmitRecord submitRecord = new SubmitRecord();
+      submitRecord.status = SubmitRecord.Status.OK;
+      return ImmutableList.of(submitRecord);
+    }
+  }
+
   private static class TestPerformanceLogger implements PerformanceLogger {
     private List<PerformanceLogEntry> logEntries = new ArrayList<>();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index be3f2a0..c2df9ca 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -502,6 +502,81 @@
   }
 
   @Test
+  public void submitWholeTopicWithMultipleTopics() throws Throwable {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    String topic1 = "test-topic-1";
+    String topic2 = "test-topic-2";
+    PushOneCommit.Result change1 = createChange("Change 1", "a.txt", "content", topic1);
+    PushOneCommit.Result change2 = createChange("Change 2", "b.txt", "content", topic1);
+    PushOneCommit.Result change3 = createChange("Change 3", "c.txt", "content", topic2);
+    PushOneCommit.Result change4 = createChange("Change 4", "d.txt", "content", topic2);
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    approve(change3.getChangeId());
+    approve(change4.getChangeId());
+    submit(change4.getChangeId());
+    String expectedTopic1 = name(topic1);
+    String expectedTopic2 = name(topic2);
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      change1.assertChange(Change.Status.NEW, expectedTopic1, admin);
+      change2.assertChange(Change.Status.NEW, expectedTopic1, admin);
+
+    } else {
+      change1.assertChange(Change.Status.MERGED, expectedTopic1, admin);
+      change2.assertChange(Change.Status.MERGED, expectedTopic1, admin);
+    }
+
+    // Check for the exact change to have the correct submitter.
+    assertSubmitter(change4);
+    // Also check submitters for changes submitted via the topic relationship.
+    assertSubmitter(change3);
+    if (getSubmitType() != SubmitType.CHERRY_PICK) {
+      assertSubmitter(change1);
+      assertSubmitter(change2);
+    }
+
+    // Check that the repo has the expected commits
+    List<RevCommit> log = getRemoteLog();
+    List<String> commitsInRepo = log.stream().map(RevCommit::getShortMessage).collect(toList());
+    int expectedCommitCount;
+    switch (getSubmitType()) {
+      case MERGE_ALWAYS:
+        // initial commit + 4 commits + merge commit
+        expectedCommitCount = 6;
+        break;
+      case CHERRY_PICK:
+        // initial commit + 2 commits
+        expectedCommitCount = 3;
+        break;
+      case FAST_FORWARD_ONLY:
+      case INHERIT:
+      case MERGE_IF_NECESSARY:
+      case REBASE_ALWAYS:
+      case REBASE_IF_NECESSARY:
+      default:
+        // initial commit + 4 commits
+        expectedCommitCount = 5;
+        break;
+    }
+    assertThat(log).hasSize(expectedCommitCount);
+
+    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+      assertThat(commitsInRepo).containsAtLeast("Initial empty repository", "Change 3", "Change 4");
+      assertThat(commitsInRepo).doesNotContain("Change 1");
+      assertThat(commitsInRepo).doesNotContain("Change 2");
+    } else if (getSubmitType() == SubmitType.MERGE_ALWAYS) {
+      assertThat(commitsInRepo)
+          .contains(
+              String.format(
+                  "Merge changes from topics \"%s\", \"%s\"", expectedTopic1, expectedTopic2));
+    } else {
+      assertThat(commitsInRepo)
+          .containsAtLeast(
+              "Initial empty repository", "Change 1", "Change 2", "Change 3", "Change 4");
+    }
+  }
+
+  @Test
   public void submitReusingOldTopic() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index babf886..37cb317 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -195,6 +195,9 @@
         </template>
       </ul>
       <div class="rightItems">
+        <gr-endpoint-decorator
+            class="hideOnMobile"
+            name="header-small-banner"></gr-endpoint-decorator>
         <gr-smart-search
             id="search"
             search-query="{{searchQuery}}"></gr-smart-search>